From 34bd629f3cf69f3a29124289eb1f5e77fc0638e4 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 13:19:21 -0700 Subject: [PATCH 001/128] style: spotless reformat, no functional change Enroll SwipeRevealLayout.kt in the file-level Spotless ratchet ahead of the ADFA-5487 functional change, so the whole-file reindent to tabs is not reviewer noise in a behavioral commit. ktlint changes only: import ordering, parameter list wrapping, and `return x` to expression-body conversions. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../itsaky/androidide/ui/SwipeRevealLayout.kt | 802 ++++++++++-------- 1 file changed, 428 insertions(+), 374 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt index c3888d672c..5790a55dce 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt @@ -26,388 +26,442 @@ import android.view.ViewGroup import androidx.annotation.CallSuper import androidx.annotation.FloatRange import androidx.annotation.IdRes +import androidx.core.content.withStyledAttributes import androidx.customview.widget.ViewDragHelper import com.google.android.material.shape.MaterialShapeDrawable import com.itsaky.androidide.R import kotlin.math.max import kotlin.math.min -import androidx.core.content.withStyledAttributes /** * A layout which can be dragged vertically to reveal a hidden content. * * @author Akash Yadav */ -open class SwipeRevealLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0, -) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { - - /** - * Interface for listening to drag events. - */ - interface OnDragListener { - - /** - * Called when the drag state changes. - */ - fun onDragStateChanged(swipeRevealLayout: SwipeRevealLayout, state: Int) - - /** - * Called when the drag progress changes. - */ - fun onDragProgress(swipeRevealLayout: SwipeRevealLayout, progress: Float) - } - - private val leftDragHelper: ViewDragHelper - private val rightDragHelper: ViewDragHelper - - private var leftDragProgress = 0f - private var rightDragProgress = 0f - private var isVerticalDragEnabled = true - - private var isDownInDragHandle = false - /** - * Whether the most recent touch-down landed within the configured drag handle. The vertical - * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting - * in the middle of the overlapping content (e.g. the editor) are not stolen. - */ - private val dragHandleLocation = IntArray(2) - - init { - leftDragHelper = ViewDragHelper.create(this, 1f, LeftDragCallback()) - rightDragHelper = ViewDragHelper.create(this, 1f, RightDragCallback()) - } - - private val dragHelperCallback = object : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return isVerticalDragEnabled && isDownInDragHandle && child === overlappingContent - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - draggingViewTop = top - onDragProgress(min(1f, top.toFloat() / dragHeightMax.toFloat())) - } - - override fun getViewVerticalDragRange(child: View): Int { - return if (isVerticalDragEnabled) dragHeightMax else 0 - } - - override fun getOrderedChildIndex(index: Int): Int { - return OVERLAPPING_CONTENT_INDEX - } - - override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { - return min(max(top, paddingTop), dragHeightMax) - } - - override fun onViewDragStateChanged(state: Int) { - if (state == draggingState) { - return - } - - if (isDragging && state == ViewDragHelper.STATE_IDLE) { - isOpen = draggingViewTop >= dragHeightMax - } - - onDragStateChanged(state) - } - - override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { - if (draggingViewTop == 0) { - isOpen = false - return - } - - if (draggingViewTop >= dragHeightMax) { - isOpen = true - return - } - - // whether the view should settle to open or close - val settleDestY = if (yvel > AUTO_OPEN_VELOCITY_LIM || draggingViewTop > dragHeightMax / 2) { - dragHeightMax - } else { - paddingTop - } - - if (dragHelper.settleCapturedViewAt(0, settleDestY)) { - this@SwipeRevealLayout.postInvalidateOnAnimation() - } - } - } - - private val hiddenContent: View - get() = getChildAt(HIDDEN_CONTENT_INDEX)!! - - private val overlappingContent: View - get() = getChildAt(OVERLAPPING_CONTENT_INDEX)!! - - private var draggingState = -1 - private var draggingViewTop = 0 - private val dragHeightMax - get() = hiddenContent.height - - private lateinit var dragHelper: ViewDragHelper - - /** - * Whether the view is currently in 'dragging' state. - */ - val isDragging: Boolean - get() = draggingState == ViewDragHelper.STATE_DRAGGING || - draggingState == ViewDragHelper.STATE_SETTLING - - /** - * The ID of the view which will be dragged to reveal the content. - */ - @IdRes - var dragHandleViewId = 0 - - /** - * The current drag progress. - */ - @FloatRange(from = 0.0, to = 1.0) - var dragProgress = 0.0f - private set - - /** - * Listener for drag events. - */ - var dragListener: OnDragListener? = null - - /** - * Whether the view is open. - */ - var isOpen = false - protected set - - companion object { - - private const val HIDDEN_CONTENT_INDEX = 0 - private const val OVERLAPPING_CONTENT_INDEX = 1 - - @Suppress("UNUSED") - const val STATE_IDLE = ViewDragHelper.STATE_IDLE - - @Suppress("UNUSED") - const val STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING - - @Suppress("UNUSED") - const val STATE_SETTLING = ViewDragHelper.STATE_SETTLING - - const val AUTO_OPEN_VELOCITY_LIM = 800.0 - } - - init { - if (attrs != null) { - context.withStyledAttributes( - attrs, R.styleable.SwipeRevealLayout, - defStyleAttr, defStyleRes - ) { - dragHandleViewId = getResourceId( - R.styleable.SwipeRevealLayout_dragHandle, - dragHandleViewId - ) - } - } - } - - override fun onFinishInflate() { - super.onFinishInflate() - this.dragHelper = ViewDragHelper.create(this, dragHelperCallback) - this.isOpen = false - - check(childCount == 2) { - "SwipeRevealLayout must have exactly two children; the hidden content and the overlapping content" - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - measureChildren(widthMeasureSpec, heightMeasureSpec) - - val maxWidth = MeasureSpec.getSize(widthMeasureSpec) - val maxHeight = MeasureSpec.getSize(heightMeasureSpec) - - setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 0), - resolveSizeAndState(maxHeight, heightMeasureSpec, 0)) - } - - override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { - hiddenContent.layout(0, paddingTop, r, paddingTop + hiddenContent.measuredHeight) - - val olapTop = paddingTop + (hiddenContent.height * dragProgress).toInt() - // Ensure overlappingContent extends to bottom to fill available space - overlappingContent.layout(0, olapTop, r, b) - } - - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - val action = ev.actionMasked - if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { - leftDragHelper.cancel() - rightDragHelper.cancel() - dragHelper.cancel() - return false - } - if (action == MotionEvent.ACTION_DOWN) { - isDownInDragHandle = isTouchInDragHandle(ev) - } - val isLeft = leftDragHelper.shouldInterceptTouchEvent(ev) - val isRight = rightDragHelper.shouldInterceptTouchEvent(ev) - val isVertical = dragHelper.shouldInterceptTouchEvent(ev) - return isLeft || isRight || isVertical - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - leftDragHelper.processTouchEvent(event) - rightDragHelper.processTouchEvent(event) - dragHelper.processTouchEvent(event) - return true - } - - /** - * Returns whether the given touch event falls within the bounds of the configured drag handle - * (see [dragHandleViewId] / the `app:dragHandle` attribute). When no drag handle is configured, - * the whole overlapping content acts as the handle (legacy behavior). - */ - private fun isTouchInDragHandle(ev: MotionEvent): Boolean { - if (dragHandleViewId == 0) { - return true - } - - val handle = findViewById(dragHandleViewId) - if (handle == null || handle.visibility != VISIBLE) { - return false - } - - handle.getLocationOnScreen(dragHandleLocation) - val left = dragHandleLocation[0] - val top = dragHandleLocation[1] - val right = left + handle.width - val bottom = top + handle.height - val x = ev.rawX - val y = ev.rawY - return x >= left && x <= right && y >= top && y <= bottom - } - - override fun computeScroll() { - if (leftDragHelper.continueSettling(true) or rightDragHelper.continueSettling(true) or dragHelper.continueSettling(true)) { - postInvalidateOnAnimation() - } - } - - /** - * Internal callback. Invoked when the drag state changes. - */ - @CallSuper - protected open fun onDragStateChanged(state: Int) { - draggingState = state - dragListener?.onDragStateChanged(this, state) - } - - /** - * Internal callback. Invoked when the drag progress changes. - */ - @CallSuper - protected open fun onDragProgress(progress: Float) { - if (dragProgress == progress) { - return - } - - dragProgress = progress - applyDragProgress(progress) - dragListener?.onDragProgress(this, progress) - } - - /** - * Applies the drag progress to the content. - */ - protected open fun applyDragProgress(progress: Float) { - val min = 0.97f - val max = 1f - val scale = min + (max - min) * (1 - progress) - overlappingContent.scaleX = scale - overlappingContent.scaleY = scale - (overlappingContent.background as? MaterialShapeDrawable?)?.interpolation = progress - } - - /** - * Toggles the state of the view. - */ - fun toggleState(isOpen: Boolean) { - if (isOpen) { - open() - } else { - close() - } - } - - /** - * Opens the view. - */ - fun open() { - if (isOpen) { - return - } - smoothSlideTo(1f) - } - - /** - * Closes the view. - */ - fun close() { - if (!isOpen) { - return - } - - smoothSlideTo(0f) - } - - fun setVerticalDragEnabled(enabled: Boolean) { - this.isVerticalDragEnabled = enabled - if (!enabled && isOpen) { - close() - } - } - - private fun smoothSlideTo(offset: Float) { - val y = paddingTop + offset * dragHeightMax - if (dragHelper.smoothSlideViewTo(overlappingContent, overlappingContent.left, y.toInt())) { - postInvalidateOnAnimation() - } - } - - private inner class LeftDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return child.id == R.id.drawer_sidebar // Your left drawer ID - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - leftDragProgress = left.toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, leftDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return max(0, min(left, width - child.width)) - } - } - - private inner class RightDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return true//child.id == R.id.right_drawer_sidebar - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - rightDragProgress = (width - left).toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, rightDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return max(width - child.width, min(left, width)) - } - } -} \ No newline at end of file +open class SwipeRevealLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, + ) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { + /** + * Interface for listening to drag events. + */ + interface OnDragListener { + /** + * Called when the drag state changes. + */ + fun onDragStateChanged( + swipeRevealLayout: SwipeRevealLayout, + state: Int, + ) + + /** + * Called when the drag progress changes. + */ + fun onDragProgress( + swipeRevealLayout: SwipeRevealLayout, + progress: Float, + ) + } + + private val leftDragHelper: ViewDragHelper + private val rightDragHelper: ViewDragHelper + + private var leftDragProgress = 0f + private var rightDragProgress = 0f + private var isVerticalDragEnabled = true + + private var isDownInDragHandle = false + + /** + * Whether the most recent touch-down landed within the configured drag handle. The vertical + * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting + * in the middle of the overlapping content (e.g. the editor) are not stolen. + */ + private val dragHandleLocation = IntArray(2) + + init { + leftDragHelper = ViewDragHelper.create(this, 1f, LeftDragCallback()) + rightDragHelper = ViewDragHelper.create(this, 1f, RightDragCallback()) + } + + private val dragHelperCallback = + object : ViewDragHelper.Callback() { + override fun tryCaptureView( + child: View, + pointerId: Int, + ): Boolean = isVerticalDragEnabled && isDownInDragHandle && child === overlappingContent + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int, + ) { + draggingViewTop = top + onDragProgress(min(1f, top.toFloat() / dragHeightMax.toFloat())) + } + + override fun getViewVerticalDragRange(child: View): Int = if (isVerticalDragEnabled) dragHeightMax else 0 + + override fun getOrderedChildIndex(index: Int): Int = OVERLAPPING_CONTENT_INDEX + + override fun clampViewPositionVertical( + child: View, + top: Int, + dy: Int, + ): Int = min(max(top, paddingTop), dragHeightMax) + + override fun onViewDragStateChanged(state: Int) { + if (state == draggingState) { + return + } + + if (isDragging && state == ViewDragHelper.STATE_IDLE) { + isOpen = draggingViewTop >= dragHeightMax + } + + onDragStateChanged(state) + } + + override fun onViewReleased( + releasedChild: View, + xvel: Float, + yvel: Float, + ) { + if (draggingViewTop == 0) { + isOpen = false + return + } + + if (draggingViewTop >= dragHeightMax) { + isOpen = true + return + } + + // whether the view should settle to open or close + val settleDestY = + if (yvel > AUTO_OPEN_VELOCITY_LIM || draggingViewTop > dragHeightMax / 2) { + dragHeightMax + } else { + paddingTop + } + + if (dragHelper.settleCapturedViewAt(0, settleDestY)) { + this@SwipeRevealLayout.postInvalidateOnAnimation() + } + } + } + + private val hiddenContent: View + get() = getChildAt(HIDDEN_CONTENT_INDEX)!! + + private val overlappingContent: View + get() = getChildAt(OVERLAPPING_CONTENT_INDEX)!! + + private var draggingState = -1 + private var draggingViewTop = 0 + private val dragHeightMax + get() = hiddenContent.height + + private lateinit var dragHelper: ViewDragHelper + + /** + * Whether the view is currently in 'dragging' state. + */ + val isDragging: Boolean + get() = + draggingState == ViewDragHelper.STATE_DRAGGING || + draggingState == ViewDragHelper.STATE_SETTLING + + /** + * The ID of the view which will be dragged to reveal the content. + */ + @IdRes + var dragHandleViewId = 0 + + /** + * The current drag progress. + */ + @FloatRange(from = 0.0, to = 1.0) + var dragProgress = 0.0f + private set + + /** + * Listener for drag events. + */ + var dragListener: OnDragListener? = null + + /** + * Whether the view is open. + */ + var isOpen = false + protected set + + companion object { + private const val HIDDEN_CONTENT_INDEX = 0 + private const val OVERLAPPING_CONTENT_INDEX = 1 + + @Suppress("UNUSED") + const val STATE_IDLE = ViewDragHelper.STATE_IDLE + + @Suppress("UNUSED") + const val STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING + + @Suppress("UNUSED") + const val STATE_SETTLING = ViewDragHelper.STATE_SETTLING + + const val AUTO_OPEN_VELOCITY_LIM = 800.0 + } + + init { + if (attrs != null) { + context.withStyledAttributes( + attrs, + R.styleable.SwipeRevealLayout, + defStyleAttr, + defStyleRes, + ) { + dragHandleViewId = + getResourceId( + R.styleable.SwipeRevealLayout_dragHandle, + dragHandleViewId, + ) + } + } + } + + override fun onFinishInflate() { + super.onFinishInflate() + this.dragHelper = ViewDragHelper.create(this, dragHelperCallback) + this.isOpen = false + + check(childCount == 2) { + "SwipeRevealLayout must have exactly two children; the hidden content and the overlapping content" + } + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + measureChildren(widthMeasureSpec, heightMeasureSpec) + + val maxWidth = MeasureSpec.getSize(widthMeasureSpec) + val maxHeight = MeasureSpec.getSize(heightMeasureSpec) + + setMeasuredDimension( + resolveSizeAndState(maxWidth, widthMeasureSpec, 0), + resolveSizeAndState(maxHeight, heightMeasureSpec, 0), + ) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + hiddenContent.layout(0, paddingTop, r, paddingTop + hiddenContent.measuredHeight) + + val olapTop = paddingTop + (hiddenContent.height * dragProgress).toInt() + // Ensure overlappingContent extends to bottom to fill available space + overlappingContent.layout(0, olapTop, r, b) + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + val action = ev.actionMasked + if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + leftDragHelper.cancel() + rightDragHelper.cancel() + dragHelper.cancel() + return false + } + if (action == MotionEvent.ACTION_DOWN) { + isDownInDragHandle = isTouchInDragHandle(ev) + } + val isLeft = leftDragHelper.shouldInterceptTouchEvent(ev) + val isRight = rightDragHelper.shouldInterceptTouchEvent(ev) + val isVertical = dragHelper.shouldInterceptTouchEvent(ev) + return isLeft || isRight || isVertical + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + leftDragHelper.processTouchEvent(event) + rightDragHelper.processTouchEvent(event) + dragHelper.processTouchEvent(event) + return true + } + + /** + * Returns whether the given touch event falls within the bounds of the configured drag handle + * (see [dragHandleViewId] / the `app:dragHandle` attribute). When no drag handle is configured, + * the whole overlapping content acts as the handle (legacy behavior). + */ + private fun isTouchInDragHandle(ev: MotionEvent): Boolean { + if (dragHandleViewId == 0) { + return true + } + + val handle = findViewById(dragHandleViewId) + if (handle == null || handle.visibility != VISIBLE) { + return false + } + + handle.getLocationOnScreen(dragHandleLocation) + val left = dragHandleLocation[0] + val top = dragHandleLocation[1] + val right = left + handle.width + val bottom = top + handle.height + val x = ev.rawX + val y = ev.rawY + return x >= left && x <= right && y >= top && y <= bottom + } + + override fun computeScroll() { + if (leftDragHelper.continueSettling(true) or rightDragHelper.continueSettling(true) or dragHelper.continueSettling(true)) { + postInvalidateOnAnimation() + } + } + + /** + * Internal callback. Invoked when the drag state changes. + */ + @CallSuper + protected open fun onDragStateChanged(state: Int) { + draggingState = state + dragListener?.onDragStateChanged(this, state) + } + + /** + * Internal callback. Invoked when the drag progress changes. + */ + @CallSuper + protected open fun onDragProgress(progress: Float) { + if (dragProgress == progress) { + return + } + + dragProgress = progress + applyDragProgress(progress) + dragListener?.onDragProgress(this, progress) + } + + /** + * Applies the drag progress to the content. + */ + protected open fun applyDragProgress(progress: Float) { + val min = 0.97f + val max = 1f + val scale = min + (max - min) * (1 - progress) + overlappingContent.scaleX = scale + overlappingContent.scaleY = scale + (overlappingContent.background as? MaterialShapeDrawable?)?.interpolation = progress + } + + /** + * Toggles the state of the view. + */ + fun toggleState(isOpen: Boolean) { + if (isOpen) { + open() + } else { + close() + } + } + + /** + * Opens the view. + */ + fun open() { + if (isOpen) { + return + } + smoothSlideTo(1f) + } + + /** + * Closes the view. + */ + fun close() { + if (!isOpen) { + return + } + + smoothSlideTo(0f) + } + + fun setVerticalDragEnabled(enabled: Boolean) { + this.isVerticalDragEnabled = enabled + if (!enabled && isOpen) { + close() + } + } + + private fun smoothSlideTo(offset: Float) { + val y = paddingTop + offset * dragHeightMax + if (dragHelper.smoothSlideViewTo(overlappingContent, overlappingContent.left, y.toInt())) { + postInvalidateOnAnimation() + } + } + + private inner class LeftDragCallback : ViewDragHelper.Callback() { + override fun tryCaptureView( + child: View, + pointerId: Int, + ): Boolean { + return child.id == R.id.drawer_sidebar // Your left drawer ID + } + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int, + ) { + leftDragProgress = left.toFloat() / changedView.width + dragListener?.onDragProgress(this@SwipeRevealLayout, leftDragProgress) + invalidate() + } + + override fun clampViewPositionHorizontal( + child: View, + left: Int, + dx: Int, + ): Int = max(0, min(left, width - child.width)) + } + + private inner class RightDragCallback : ViewDragHelper.Callback() { + override fun tryCaptureView( + child: View, + pointerId: Int, + ): Boolean { + return true // child.id == R.id.right_drawer_sidebar + } + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int, + ) { + rightDragProgress = (width - left).toFloat() / changedView.width + dragListener?.onDragProgress(this@SwipeRevealLayout, rightDragProgress) + invalidate() + } + + override fun clampViewPositionHorizontal( + child: View, + left: Int, + dx: Int, + ): Int = max(width - child.width, min(left, width)) + } + } From 20b0c4624e90d148389f54a0f875555a9817600a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 13:23:48 -0700 Subject: [PATCH 002/128] fix: stop SwipeRevealLayout's right drag helper capturing every child RightDragCallback.tryCaptureView returned an unconditional `true`, with the intended check commented out as `// child.id == R.id.right_drawer_sidebar` -- an id that exists nowhere in the project. There is no right drawer in activity_editor.xml, so the helper had no legitimate target but captured whichever child sat under a horizontal drag and offset it sideways. Two consequences, both fixed by never capturing: - onViewPositionChanged pushed that horizontal travel straight to dragListener.onDragProgress, bypassing the layout's own onDragProgress. BaseEditorActivity.onSwipeRevealDragProgress then animated the content card's corner interpolation and top padding as if the vertical reveal were being dragged. - onInterceptTouchEvent returns `isLeft || isRight || isVertical`, so the layout stole horizontal gestures from its children. A horizontally scrolling child raced this helper across the same ViewConfiguration touch slop, making the outcome nondeterministic. ADFA-5487 puts a ViewPager2 carousel in exactly that position, which is how this surfaced. No edge tracking is configured, so with capture refused the helper is inert. The callback is left in place as the attachment point for a right drawer, should one ever be added. Verified: :app:compileV8DebugKotlin. ADFA-5487 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../com/itsaky/androidide/ui/SwipeRevealLayout.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt index 5790a55dce..9e3066d0a6 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt @@ -439,12 +439,17 @@ open class SwipeRevealLayout } private inner class RightDragCallback : ViewDragHelper.Callback() { + // There is no right drawer in this layout: R.id.right_drawer_sidebar does not exist, so + // the intended check was commented out and this returned true for every child. That made + // the helper capture whichever child sat under a horizontal drag and offset it sideways, + // and its onViewPositionChanged reported that horizontal travel to dragListener as if it + // were vertical reveal progress. It also stole horizontal gestures from child views, so a + // horizontally scrolling child (ADFA-5487's metrics carousel) raced this helper for them. + // Capture nothing until a right drawer actually exists to name here. override fun tryCaptureView( child: View, pointerId: Int, - ): Boolean { - return true // child.id == R.id.right_drawer_sidebar - } + ): Boolean = false override fun onViewPositionChanged( changedView: View, From 9cd2750467ac353b17a4a3f3bd23e6ca2fd884ca Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 13:35:08 -0700 Subject: [PATCH 003/128] refactor: extract MemoryUsageChartRenderer, render from watcher history BaseEditorActivity drove the memory chart by reaching into binding.memUsageView.chart from six sites and mutating entry.y against a pidToDatasetIdxMap that only resetMemUsageChart() populated. That works only while exactly one chart view exists for the activity's lifetime. ADFA-5487 makes the chart one page of a carousel, where the view can be unbound, recycled, or created long after watching began. MemoryUsageChartRenderer owns the chart wiring instead and holds no sample state: MemoryUsageWatcher already keeps each process's usageHistory ring buffer, so the renderer can rebuild a complete chart from getMemoryUsages() at any time. attach/detach are independent of the data. Two behaviour changes, both deliberate: - attach() renders the full existing history. resetMemUsageChart() used to seed every entry with 0f and wait a tick for real values, which a carousel page bound mid-session would show as a flat line. - onUsagesChanged() rebuilds when the incoming processes no longer match the chart's datasets, instead of logging "No dataset found for process" and dropping that process's samples. This was already reachable without a carousel: ProjectHandlerActivity watches the Gradle Tooling process and then calls resetMemUsageChart(), so any sample arriving between those two lines was discarded. The once-a-second path still mutates the existing Entry objects in place and allocates nothing; the rebuild is the exception, not the rule. The renderer relies on ChartData.getDataSetByIndex returning null for an out-of-range index, which the shipped AndroidChart 3.1.0.21 bytecode confirms (null for index < 0 or >= size) -- the same guard the previous code depended on. Sites swept: all six chart call sites in BaseEditorActivity, both resetMemUsageChart() callers in ProjectHandlerActivity (unchanged, the method keeps its signature), and the now-dead pidToDatasetIdxMap/editorSurfaceContainerBackground members and their imports. No other module referenced either. Tests: 5 new Robolectric tests in MemoryUsageChartRendererTest. Verified they fail without the fix -- reverting the two behaviour changes fails "attach renders the complete existing history", "attach after detach renders the history into the new chart" (all-zero entries) and "onUsagesChanged rebuilds when a process starts being watched" (dataSetCount stays 1), each for the reason it is named for. The in-place-update test passes either way by design, since that path is unchanged. Verified: :app:compileV8DebugKotlin, :app:testV8DebugUnitTest (MemoryUsageChartRendererTest, 5/5). No UI change, so no font-scale check yet; that lands with the carousel. ADFA-5487 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 132 ++-------- .../androidide/ui/MemoryUsageChartRenderer.kt | 231 ++++++++++++++++++ .../ui/MemoryUsageChartRendererTest.kt | 157 ++++++++++++ 3 files changed, 403 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.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 d1957709da..4da4e552bc 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 @@ -52,7 +52,6 @@ import androidx.annotation.GravityInt import androidx.annotation.RequiresApi import androidx.annotation.UiThread import androidx.appcompat.app.ActionBarDrawerToggle -import androidx.collection.MutableIntIntMap import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import androidx.core.graphics.Insets @@ -67,11 +66,6 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.github.mikephil.charting.components.AxisBase -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.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN @@ -124,6 +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.SwipeRevealLayout import com.itsaky.androidide.uidesigner.UIDesignerActivity import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow @@ -145,7 +140,6 @@ import com.itsaky.androidide.utils.flashMessage import com.itsaky.androidide.utils.getOrStoreInitialPadding import com.itsaky.androidide.utils.isAtLeastR import com.itsaky.androidide.utils.isDeepLinkTargetOfOpenProject -import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.viewmodel.ApkInstallationViewModel import com.itsaky.androidide.viewmodel.AppLogsCoordinator import com.itsaky.androidide.viewmodel.AppLogsViewModel @@ -173,7 +167,6 @@ import rikka.shizuku.Shizuku import java.io.File import kotlin.math.abs import kotlin.math.roundToInt -import kotlin.math.roundToLong /** * Base class for EditorActivity which handles most of the view related things. @@ -192,7 +185,11 @@ abstract class BaseEditorActivity : private var drawerToggle: ActionBarDrawerToggle? = null private var bottomSheetCallback: BottomSheetBehavior.BottomSheetCallback? = null protected val memoryUsageWatcher = MemoryUsageWatcher() - protected val pidToDatasetIdxMap = MutableIntIntMap(initialCapacity = 3) + private val memUsageChartRenderer = + MemoryUsageChartRenderer( + usagesProvider = memoryUsageWatcher::getMemoryUsages, + lineColorFor = ::getMemUsageLineColorFor, + ) private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null @@ -315,45 +312,7 @@ abstract class BaseEditorActivity : private val memoryUsageListener = MemoryUsageWatcher.MemoryUsageListener { memoryUsage -> - var dataChanged = false - memoryUsage.forEachValue { proc -> - _binding?.memUsageView?.chart?.apply { - val dataset = - ( - data.getDataSetByIndex( - pidToDatasetIdxMap.getOrDefault( - proc.pid, - -1, - ), - ) as LineDataSet? - ) - ?: run { - log.error( - "No dataset found for process: {}: {}", - proc.pid, - proc.pname, - ) - return@forEachValue - } - - dataset.entries.mapIndexed { index, entry -> - entry.y = - (proc.usageHistory[index] / (1024.0 * 1024.0)).toFloat() - } - - dataset.label = "%s - %.2fMB".format(proc.pname, dataset.entries.last().y) - dataset.notifyDataSetChanged() - dataChanged = true - } - } - - if (dataChanged) { - _binding?.memUsageView?.chart?.apply { - data.notifyDataChanged() - notifyDataSetChanged() - invalidate() - } - } + memUsageChartRenderer.onUsagesChanged(memoryUsage) } private val shizukuBinderReceivedListener = @@ -363,10 +322,6 @@ abstract class BaseEditorActivity : private var isImeVisible = false private var contentCardRealHeight: Int? = null - private val editorSurfaceContainerBackground by lazy { - resolveAttr(R.attr.colorSurfaceDim) - } - private var isDebuggerStarting = false @UiThread set(value) { field = value @@ -559,6 +514,7 @@ abstract class BaseEditorActivity : fullscreenManager?.destroy() fullscreenManager = null + memUsageChartRenderer.detach() _binding = null if (isDestroying) { @@ -1000,36 +956,12 @@ abstract class BaseEditorActivity : content.editorAppBarLayout.updatePadding(top = topInset) } - memUsageView.chart.updateLayoutParams { - topMargin = (insetsTop * progress).roundToInt() - } + memUsageChartRenderer.setTopMargin((insetsTop * progress).roundToInt()) } } private fun setupMemUsageChart() { - binding.memUsageView.chart.apply { - val colorAccent = resolveAttr(R.attr.colorAccent) - - isDragEnabled = false - description.isEnabled = false - xAxis.axisLineColor = colorAccent - axisRight.axisLineColor = colorAccent - - setPinchZoom(false) - setBackgroundColor(editorSurfaceContainerBackground) - setDrawGridBackground(true) - setScaleEnabled(true) - - axisLeft.isEnabled = false - axisRight.valueFormatter = - object : - IAxisValueFormatter { - override fun getFormattedValue( - value: Float, - axis: AxisBase?, - ): String = "%dMB".format(value.roundToLong()) - } - } + memUsageChartRenderer.attach(binding.memUsageView.chart) } private fun watchMemory() { @@ -1038,46 +970,12 @@ abstract class BaseEditorActivity : resetMemUsageChart() } + /** + * Rebuilds the memory chart for the currently watched processes. Call after starting or stopping + * watching a process. + */ protected fun resetMemUsageChart() { - val processes = memoryUsageWatcher.getMemoryUsages() - val datasets = - Array(processes.size) { index -> - LineDataSet( - List(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { Entry(it.toFloat(), 0f) }, - processes[index].pname, - ) - } - - val bgColor = editorSurfaceContainerBackground - val textColor = resolveAttr(R.attr.colorOnSurface) - - for ((index, proc) in processes.withIndex()) { - val dataset = datasets[index] - dataset.color = getMemUsageLineColorFor(proc) - dataset.setDrawIcons(false) - dataset.setDrawCircles(false) - dataset.setDrawCircleHole(false) - dataset.setDrawValues(false) - dataset.formLineWidth = 1f - dataset.formSize = 15f - dataset.isHighlightEnabled = false - pidToDatasetIdxMap[proc.pid] = index - } - - binding.memUsageView.chart.setBackgroundColor(bgColor) - - binding.memUsageView.chart.apply { - data = LineData(*datasets) - axisRight.textColor = textColor - axisLeft.textColor = textColor - legend.textColor = textColor - - data.setValueTextColor(textColor) - setBackgroundColor(bgColor) - setGridBackgroundColor(bgColor) - notifyDataSetChanged() - invalidate() - } + memUsageChartRenderer.rebuild() } private fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt new file mode 100644 index 0000000000..331088b32c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -0,0 +1,231 @@ +/* + * 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.view.ViewGroup +import androidx.annotation.UiThread +import androidx.collection.IntObjectMap +import androidx.collection.MutableIntIntMap +import androidx.core.view.updateLayoutParams +import com.github.mikephil.charting.components.AxisBase +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.MemoryUsageWatcher +import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.ShiftedLongArray +import com.itsaky.androidide.utils.resolveAttr +import kotlin.math.roundToLong + +/** + * Renders [MemoryUsageWatcher] samples into a [SafeLineChart]. + * + * The chart view is attached and detached independently of the data: [MemoryUsageWatcher] owns the + * per-process [ProcessMemoryInfo.usageHistory] ring buffers, so this renderer holds no sample state + * of its own and can rebuild a complete chart from [usagesProvider] at any time. That is what makes + * the chart safe to host in a recycling container (ADFA-5487's metrics carousel): a chart view that + * is created long after watching began still shows the full history, and one that is recycled away + * loses nothing. + * + * All methods must be called on the UI thread. MPAndroidChart is not thread-safe; see [SafeLineChart]. + * + * @param usagesProvider Supplies the currently watched processes, newest state each call. + * @param lineColorFor Supplies the plot line color for a process. + */ +class MemoryUsageChartRenderer( + private val usagesProvider: () -> Array, + private val lineColorFor: (ProcessMemoryInfo) -> Int, +) { + private var chart: SafeLineChart? = null + + /** + * 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 + pidToDatasetIdx.clear() + } + + /** + * Applies a top margin to the attached chart. No-op when no chart is attached. + */ + @UiThread + fun setTopMargin(margin: Int) { + chart?.updateLayoutParams { + topMargin = margin + } + } + + /** + * 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() { + val chart = this.chart ?: return + val processes = usagesProvider() + + pidToDatasetIdx.clear() + + val datasets = + Array(processes.size) { index -> + val proc = processes[index] + pidToDatasetIdx[proc.pid] = index + + LineDataSet( + List(proc.usageHistory.size) { entryIdx -> + Entry(entryIdx.toFloat(), proc.usageHistory.megabytesAt(entryIdx)) + }, + proc.pname, + ).apply { + color = lineColorFor(proc) + setDrawIcons(false) + setDrawCircles(false) + setDrawCircleHole(false) + setDrawValues(false) + formLineWidth = 1f + formSize = 15f + isHighlightEnabled = false + label = labelFor(proc.pname, entries.lastOrNull()?.y ?: 0f) + } + } + + 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() + } + } + + /** + * Renders a fresh set of samples into the attached chart, mutating the existing entries in place. + * + * Falls back to [rebuild] when [memoryUsage] no longer matches the datasets the chart was built + * with -- a process started or stopped being watched, or the chart was attached before this pid + * existed. The in-place path is the common one and allocates nothing, which matters because this + * runs once a second for the lifetime of the editor. + */ + @UiThread + fun onUsagesChanged(memoryUsage: IntObjectMap) { + val chart = this.chart ?: return + + if (memoryUsage.size != pidToDatasetIdx.size) { + rebuild() + return + } + + var dataChanged = false + memoryUsage.forEachValue { proc -> + val datasetIdx = pidToDatasetIdx.getOrDefault(proc.pid, -1) + val dataset = chart.data?.getDataSetByIndex(datasetIdx) as LineDataSet? + if (dataset == null) { + // The chart's datasets no longer describe the watched processes. Rebuild rather than + // dropping this process's samples on the floor, as the previous code did. + rebuild() + return + } + + for (index in dataset.entries.indices) { + dataset.entries[index].y = proc.usageHistory.megabytesAt(index) + } + + dataset.label = labelFor(proc.pname, dataset.entries.lastOrNull()?.y ?: 0f) + dataset.notifyDataSetChanged() + dataChanged = true + } + + if (dataChanged) { + chart.apply { + data.notifyDataChanged() + notifyDataSetChanged() + invalidate() + } + } + } + + /** + * 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()) + } + } + } + + private fun labelFor( + pname: String, + megabytes: Float, + ): String = "%s - %.2fMB".format(pname, megabytes) +} + +private const val BYTES_PER_MEGABYTE = 1024.0 * 1024.0 + +/** + * The sample at [index] in megabytes. [MemoryUsageWatcher] stores bytes. + */ +private fun ShiftedLongArray.megabytesAt(index: Int): Float = (this[index] / BYTES_PER_MEGABYTE).toFloat() diff --git a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt new file mode 100644 index 0000000000..a2959fc4d8 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -0,0 +1,157 @@ +/* + * 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.graphics.Color +import androidx.collection.MutableIntObjectMap +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.data.LineDataSet +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.MutableShiftedLongArray +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the properties ADFA-5487's metrics carousel relies on: the renderer holds no sample state, so + * a chart attached at any time shows the complete history, and a change to the watched process set + * is picked up rather than dropped. + */ +@RunWith(RobolectricTestRunner::class) +class MemoryUsageChartRendererTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun chart() = SafeLineChart(context) + + private fun renderer(processes: () -> Array) = + MemoryUsageChartRenderer( + usagesProvider = processes, + lineColorFor = { Color.BLUE }, + ) + + /** A process whose history ramps from [firstMegabytes] by 1MB per sample. */ + private fun proc( + pid: Int, + pname: String, + firstMegabytes: Long, + ) = ProcessMemoryInfo( + pid, + pname, + MutableShiftedLongArray(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { (firstMegabytes + it) * BYTES_PER_MB }, + ) + + private fun datasetFor( + chart: SafeLineChart, + index: Int, + ) = chart.data.getDataSetByIndex(index) as LineDataSet + + @Test + fun `attach renders the complete existing history, not a flat line`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + + renderer { processes }.attach(chart) + + val dataset = datasetFor(chart, 0) + assertThat(dataset.entryCount).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + // The old resetMemUsageChart() seeded every entry with 0f and waited a tick for real values; + // a carousel page attached mid-session would have shown that flat line. + assertThat(dataset.entries.map { it.y }).doesNotContain(0f) + assertThat(dataset.entries.first().y).isEqualTo(100f) + assertThat(dataset.entries.last().y).isEqualTo((100 + MemoryUsageWatcher.MAX_USAGE_ENTRIES - 1).toFloat()) + assertThat(dataset.label).isEqualTo("IDE - %.2fMB".format(dataset.entries.last().y)) + } + + @Test + fun `onUsagesChanged updates entries in place without replacing the datasets`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + val renderer = renderer { processes } + renderer.attach(chart) + + val datasetBefore = datasetFor(chart, 0) + val entryBefore = datasetBefore.entries.first() + + val updated = proc(pid = 1, pname = "IDE", firstMegabytes = 200) + renderer.onUsagesChanged(MutableIntObjectMap().apply { put(1, updated) }) + + // Same dataset and same Entry objects, new values: this path runs once a second for the + // lifetime of the editor, so it must not allocate. + assertThat(datasetFor(chart, 0)).isSameInstanceAs(datasetBefore) + assertThat(datasetBefore.entries.first()).isSameInstanceAs(entryBefore) + assertThat(entryBefore.y).isEqualTo(200f) + } + + @Test + fun `onUsagesChanged rebuilds when a process starts being watched`() { + var processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + val renderer = renderer { processes } + renderer.attach(chart) + + assertThat(chart.data.dataSetCount).isEqualTo(1) + + // Gradle Tooling starts up. The old code looked the new pid up in a map that only reset() + // populated, logged "No dataset found for process", and dropped its samples. + val gradle = proc(pid = 2, pname = "Gradle Tooling", firstMegabytes = 300) + processes = arrayOf(processes[0], gradle) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { + put(1, processes[0]) + put(2, gradle) + }, + ) + + assertThat(chart.data.dataSetCount).isEqualTo(2) + assertThat(datasetFor(chart, 1).label).startsWith("Gradle Tooling - ") + assertThat(datasetFor(chart, 1).entries.first().y).isEqualTo(300f) + } + + @Test + fun `onUsagesChanged after detach is a no-op`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val renderer = renderer { processes } + renderer.attach(chart()) + renderer.detach() + + // A recycled carousel page must not keep the renderer writing into a dead view. + renderer.onUsagesChanged( + MutableIntObjectMap().apply { put(1, processes[0]) }, + ) + } + + @Test + fun `attach after detach renders the history into the new chart`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val renderer = renderer { processes } + renderer.attach(chart()) + renderer.detach() + + val rebound = chart() + renderer.attach(rebound) + + assertThat(datasetFor(rebound, 0).entryCount).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(datasetFor(rebound, 0).entries.first().y).isEqualTo(100f) + } + + private companion object { + const val BYTES_PER_MB = 1024L * 1024L + } +} From fc4bc267efe3380f87b9083178c8c4d77031674d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 14:06:37 -0700 Subject: [PATCH 004/128] feat: make the editor's memory chart a carousel of metric displays The chart at the top of the editor (revealed by dragging the app bar down) is now a ViewPager2 carousel. Page 1 is the memory chart, still the default; page 2 is the Code On The Go brand mark, a placeholder until there is a real second metric. MetricsCarouselAdapter takes its page list as a constructor argument, so the follow-up tickets (a TrafficStats network chart, and plugin- contributed displays) add pages rather than change this class. The chart page attaches MemoryUsageChartRenderer on bind and detaches on recycle; because the renderer rebuilds from MemoryUsageWatcher's history, swiping away and back shows the full 30-sample series rather than a flat line. Layout notes: - layout_mem_usage.xml stays a single view. SwipeRevealLayout asserts childCount == 2 and indexes its children positionally, so the include cannot gain a sibling; the pager and indicator live inside it. - The status-bar inset now applies to the pager rather than the chart, so MemoryUsageChartRenderer.setTopMargin (a shim from the previous commit, when the activity owned the only chart) is gone. It gains detachIfAttached, which a recycling container needs: RecyclerView can bind a replacement view before recycling the one it replaced, and an unconditional detach would then drop the new chart. - editor_mem_usage_view_height goes 200dp -> 248dp. The indicator is new chrome, so the container grows by its 48dp rather than the chart shrinking. This is a visible change beyond the ticket's literal scope; it is here because of the touch-target point below. - TabLayout has no dot mode, so each tab's background is a selector and the sliding indicator is suppressed. The oval needs a sized, centred layer-list item: a tab background is stretched to fill the tab, which ignores a bare shape's and renders an oval as tall as the whole row. The active dot differs in both size and colour because several of this app's themes resolve colorPrimary to a grey indistinguishable from colorOutline (measured on device: #AAAAAA vs #8F9099). A left-to-right swipe cannot page backwards: that gesture opens the navigation drawer, which is documented app behaviour ("To view the file tree and project options, swipe from left to right", shown in the editor's own onboarding text). InterceptableDrawerLayout's findScrollingChild starts at index 1 and so never examines DrawerLayout's content child, which is consistent with that intent. Backward navigation is therefore by tapping the indicator, which makes the dots a primary control rather than decoration -- hence real 48dp touch targets, measured on device at 48x48dp (168x168px at 560dpi), each carrying a "Metric N of 2" content description. androidx.viewpager2 is declared explicitly. It was already on the compile classpath transitively and pinned to the same 1.1.0-beta02 the version catalog names, so this adds no new dependency; it just stops a compile-time use depending on another library's graph. Verified on a Pixel 6 Pro (arm64), v8 debug: - Both pages render; swipe forward and tap-to-navigate both directions. - Returning to page 1 shows the complete history for both watched processes, including a Gradle Tooling process that started while the carousel was open (the rebuild path from the previous commit). - Font scale 1.0 and 2.0: no clipping, no overlap, status bar clear, touch targets unchanged. MPAndroidChart sizes its own text in pixels so the chart labels do not grow with font scale -- pre-existing, and worth a follow-up for low-vision users. - Landscape: renders correctly, nothing clipped. - :app:testV8DebugUnitTest for ui/activities/fragments: 41 tests green. ADFA-5487 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- app/build.gradle.kts | 3 + .../activities/editor/BaseEditorActivity.kt | 33 ++++- .../androidide/ui/MemoryUsageChartRenderer.kt | 11 +- .../androidide/ui/MetricsCarouselAdapter.kt | 132 ++++++++++++++++++ .../res/drawable/metrics_carousel_dot.xml | 39 ++++++ .../main/res/layout/item_metrics_image.xml | 14 ++ .../res/layout/item_metrics_memory_chart.xml | 13 ++ app/src/main/res/layout/layout_mem_usage.xml | 66 +++++---- app/src/main/res/values/dimens.xml | 53 ++++--- resources/src/main/res/values/strings.xml | 5 + 10 files changed, 300 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt create mode 100644 app/src/main/res/drawable/metrics_carousel_dot.xml create mode 100644 app/src/main/res/layout/item_metrics_image.xml create mode 100644 app/src/main/res/layout/item_metrics_memory_chart.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b21c2d872f..be8c074e7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -329,6 +329,9 @@ dependencies { implementation(libs.androidx.work) implementation(libs.androidx.work.ktx) implementation(libs.google.material) + // Metrics carousel (ADFA-5487). Already on the classpath transitively; declared so the + // compile-time use in MetricsCarouselAdapter does not depend on another library's graph. + implementation(libs.androidx.viewpager2.v110beta02) implementation(libs.google.flexbox) implementation(libs.libsu.core) 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 4da4e552bc..87446cac7d 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 @@ -71,6 +71,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPS import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.Tab +import com.google.android.material.tabs.TabLayoutMediator import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R import com.itsaky.androidide.R.string @@ -119,6 +120,8 @@ 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.SwipeRevealLayout import com.itsaky.androidide.uidesigner.UIDesignerActivity import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow @@ -514,6 +517,7 @@ abstract class BaseEditorActivity : fullscreenManager?.destroy() fullscreenManager = null + _binding?.memUsageView?.metricsPager?.adapter = null memUsageChartRenderer.detach() _binding = null @@ -855,7 +859,7 @@ abstract class BaseEditorActivity : ) feedbackButtonManager?.setupDraggableFab() - setupMemUsageChart() + setupMetricsCarousel() watchMemory() observeFileOperations() @@ -956,12 +960,33 @@ abstract class BaseEditorActivity : content.editorAppBarLayout.updatePadding(top = topInset) } - memUsageChartRenderer.setTopMargin((insetsTop * progress).roundToInt()) + memUsageView.metricsPager.updateLayoutParams { + topMargin = (insetsTop * progress).roundToInt() + } } } - private fun setupMemUsageChart() { - memUsageChartRenderer.attach(binding.memUsageView.chart) + private fun setupMetricsCarousel() { + val pages = + listOf( + // The memory chart is the default page (ADFA-5487). The logo is a placeholder second + // page until there is a real second metric; the network-traffic chart replaces it. + MetricsPage.MemoryChart, + MetricsPage.Image( + drawable = R.drawable.cogo_brand_mark, + description = string.metrics_carousel_brand_mark, + ), + ) + + binding.memUsageView.metricsPager.adapter = MetricsCarouselAdapter(pages, memUsageChartRenderer) + + TabLayoutMediator( + binding.memUsageView.metricsIndicator, + binding.memUsageView.metricsPager, + ) { tab, position -> + // Dots carry no label, but they are still focusable, so give them a spoken position. + tab.contentDescription = getString(string.metrics_carousel_page, position + 1, pages.size) + }.attach() } private fun watchMemory() { 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 331088b32c..67d8b94ef3 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -17,11 +17,9 @@ package com.itsaky.androidide.ui -import android.view.ViewGroup import androidx.annotation.UiThread import androidx.collection.IntObjectMap import androidx.collection.MutableIntIntMap -import androidx.core.view.updateLayoutParams import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineData @@ -82,12 +80,13 @@ class MemoryUsageChartRenderer( } /** - * Applies a top margin to the attached chart. No-op when no chart is attached. + * 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 setTopMargin(margin: Int) { - chart?.updateLayoutParams { - topMargin = margin + fun detachIfAttached(chart: SafeLineChart) { + if (this.chart === chart) { + detach() } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt new file mode 100644 index 0000000000..be4c9fb32c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt @@ -0,0 +1,132 @@ +/* + * 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.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +import com.itsaky.androidide.R + +/** + * A page of the editor's metrics carousel. + */ +sealed interface MetricsPage { + /** The live memory-usage chart, rendered by [MemoryUsageChartRenderer]. */ + data object MemoryChart : MetricsPage + + /** A static image. Placeholder page until real metrics exist to show alongside memory. */ + data class Image( + @DrawableRes val drawable: Int, + @StringRes val description: Int, + ) : MetricsPage +} + +/** + * Backs the editor's horizontally swipeable carousel of metric displays. + * + * [pages] is a constructor argument rather than a hardcoded list so that new displays -- a network + * traffic chart, or pages contributed by plugins -- can be added without touching this class. + * + * The chart page holds no sample state of its own: [chartRenderer] is attached when the page binds + * and detached when it is recycled, and rebuilds the full history from [MemoryUsageChartRenderer]'s + * watcher each time. Swiping away from the chart and back therefore loses nothing. + */ +class MetricsCarouselAdapter( + private val pages: List, + private val chartRenderer: MemoryUsageChartRenderer, +) : RecyclerView.Adapter() { + sealed class PageViewHolder( + view: View, + ) : RecyclerView.ViewHolder(view) { + class MemoryChart( + val chart: SafeLineChart, + ) : PageViewHolder(chart) + + class Image( + val image: ImageView, + ) : PageViewHolder(image) + } + + override fun getItemCount(): Int = pages.size + + override fun getItemViewType(position: Int): Int = + when (pages[position]) { + is MetricsPage.MemoryChart -> VIEW_TYPE_MEMORY_CHART + is MetricsPage.Image -> VIEW_TYPE_IMAGE + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): PageViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_MEMORY_CHART -> { + PageViewHolder.MemoryChart( + inflater.inflate(R.layout.item_metrics_memory_chart, parent, false) as SafeLineChart, + ) + } + + VIEW_TYPE_IMAGE -> { + PageViewHolder.Image( + inflater.inflate(R.layout.item_metrics_image, parent, false) as ImageView, + ) + } + + else -> { + throw IllegalArgumentException("Unknown metrics page view type: $viewType") + } + } + } + + override fun onBindViewHolder( + holder: PageViewHolder, + position: Int, + ) { + when (val page = pages[position]) { + is MetricsPage.MemoryChart -> { + chartRenderer.attach((holder as PageViewHolder.MemoryChart).chart) + } + + is MetricsPage.Image -> { + (holder as PageViewHolder.Image).image.apply { + setImageResource(page.drawable) + contentDescription = context.getString(page.description) + } + } + } + } + + override fun onViewRecycled(holder: PageViewHolder) { + if (holder is PageViewHolder.MemoryChart) { + // Only if this holder's chart is still the attached one: a rebind can create the + // replacement before RecyclerView recycles the view it replaced, and detaching then + // would drop the new chart instead of the old. + chartRenderer.detachIfAttached(holder.chart) + } + } + + private companion object { + const val VIEW_TYPE_MEMORY_CHART = 0 + const val VIEW_TYPE_IMAGE = 1 + } +} diff --git a/app/src/main/res/drawable/metrics_carousel_dot.xml b/app/src/main/res/drawable/metrics_carousel_dot.xml new file mode 100644 index 0000000000..602285af95 --- /dev/null +++ b/app/src/main/res/drawable/metrics_carousel_dot.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_metrics_image.xml b/app/src/main/res/layout/item_metrics_image.xml new file mode 100644 index 0000000000..4d8617b328 --- /dev/null +++ b/app/src/main/res/layout/item_metrics_image.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/layout/item_metrics_memory_chart.xml b/app/src/main/res/layout/item_metrics_memory_chart.xml new file mode 100644 index 0000000000..d6eaaa40ab --- /dev/null +++ b/app/src/main/res/layout/item_metrics_memory_chart.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index 92888099bc..33bf21bdc3 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -1,33 +1,39 @@ - + - + + - - + - \ No newline at end of file + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e39716e1cf..51ea64c7a0 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,33 +1,28 @@ - + - 200dp - 28dp + 248dp + 48dp + 9dp + 6dp + 24dp + 16dp + 28dp - 28dp - 8dp - 24dp - 32sp - 16sp - 12sp + 28dp + 8dp + 24dp + 32sp + 16sp + 12sp - - 44dp - 64dp - 6dp - \ No newline at end of file + + 44dp + 64dp + 6dp + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ca8fe80b79..0094cecccf 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1673,4 +1673,9 @@ Dock to editor Close + Memory usage chart + Metric %1$d of %2$d + Code On The Go logo + + From f4f678a3d40f0c25c6c64a7aaa58dc9401e4acca Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 14:29:03 -0700 Subject: [PATCH 005/128] feat: let the metrics carousel own horizontal swipes in its own strip The carousel could only page forwards. A left-to-right swipe opened the navigation drawer instead, so going back needed a tap on the indicator dots, which in turn forced them to be 48dp touch targets. Two mechanisms claim that gesture, and each needs its own answer: - View-hierarchy interceptors. MetricsCarouselLayout, the new root of layout_mem_usage.xml, calls requestDisallowInterceptTouchEvent on its ancestors on ACTION_DOWN. That propagates the whole way up, so any ancestor ViewGroup is out of the way for the rest of the gesture, and only for gestures starting inside this strip. - The editor's activity-level GestureDetector, run from dispatchTouchEvent. It never calls onInterceptTouchEvent, so no disallow-intercept can stop it; this was in fact the one opening the drawer, confirmed on device. isTouchOnMetricsCarousel excludes the carousel's bounds the same way isTouchOnBottomSheetTabs already excludes the bottom-sheet tab strip. The exclusion is gated on swipeReveal.dragProgress > 0. The carousel is laid out at the top of the reveal even while the content card covers it, and siblings do not clip each other, so getGlobalVisibleRect reports it visible either way; without the gate the drawer gesture would have gone dead over the top of a closed editor. The vertical reveal drag is unaffected: SwipeRevealLayout only captures a vertical drag whose touch-down landed in its drag handle (the app bar), never in this strip. With swipe working both ways the dots are a status indicator rather than a control, so they no longer need 48dp targets or accessibility nodes of their own -- ViewPager2 already reports page position, and each page carries its own content description. Touches on the indicator are swallowed so the dots cannot act as tabs, while TabLayoutMediator still tracks the selected page. The row drops 48dp -> 20dp and, with the panel kept at 248dp, that space goes to the chart: the plot area grows from 135dp to 187dp. The now-unused metrics_carousel_page string is removed. Verified on a Pixel 6 Pro (arm64), v8 debug: - Paging forward and backward by swipe, portrait and landscape. - Returning to page 1 still shows full history for both watched processes. - Drawer gesture unaffected: still opens from a rightward fling outside the carousel while the reveal is open, and from one over the region the carousel occupies once the reveal is closed. - Font scale 1.0 and 2.0: geometry is dp-only and unchanged (pager and indicator bounds identical at both), nothing clipped, status bar clear. - :app:testV8DebugUnitTest for ui/activities/fragments: 41 tests green. ADFA-5487 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 45 ++++++++++++--- .../androidide/ui/MetricsCarouselLayout.kt | 56 +++++++++++++++++++ app/src/main/res/layout/layout_mem_usage.xml | 4 +- app/src/main/res/values/dimens.xml | 4 +- resources/src/main/res/values/strings.xml | 1 - 5 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.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 87446cac7d..53a78057ad 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 @@ -983,10 +983,17 @@ abstract class BaseEditorActivity : TabLayoutMediator( binding.memUsageView.metricsIndicator, binding.memUsageView.metricsPager, - ) { tab, position -> - // Dots carry no label, but they are still focusable, so give them a spoken position. - tab.contentDescription = getString(string.metrics_carousel_page, position + 1, pages.size) - }.attach() + ) { _, _ -> }.attach() + + binding.memUsageView.metricsIndicator.apply { + // A pure indicator, not a control: the carousel pages by swipe (MetricsCarouselLayout), + // so the dots need neither 48dp touch targets nor their own accessibility nodes -- + // ViewPager2 already reports page position. Swallowing touches keeps the dots from + // acting as tabs while leaving TabLayoutMediator to track the selected page. + @Suppress("ClickableViewAccessibility") + setOnTouchListener { _, _ -> true } + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } } private fun watchMemory() { @@ -1808,8 +1815,12 @@ abstract class BaseEditorActivity : // Filter out diagonal flings so only an intentional right swipe opens the drawer. // A horizontal fling that started on the bottom-sheet tab strip is the user - // scrolling tabs, not asking for the drawer. - if (isDrawerOpenFling && !isTouchOnBottomSheetTabs(e1)) { + // scrolling tabs, not asking for the drawer; one that started on the metrics + // carousel is the user paging it backwards. + if (isDrawerOpenFling && + !isTouchOnBottomSheetTabs(e1) && + !isTouchOnMetricsCarousel(e1) + ) { binding.editorDrawerLayout.openDrawer(GravityCompat.START) return true } @@ -1831,8 +1842,28 @@ abstract class BaseEditorActivity : private fun isTouchOnBottomSheetTabs(ev: MotionEvent): Boolean { val tabs = contentOrNull?.bottomSheet?.binding?.tabs ?: return false + return containsTouch(tabs, ev) + } + + private fun isTouchOnMetricsCarousel(ev: MotionEvent): Boolean { + val binding = _binding ?: return false + + // The carousel is laid out at the top of the reveal even while the content card covers it, + // and siblings do not clip each other, so getGlobalVisibleRect reports it visible either + // way. Without this check the drawer gesture would be dead over the top of a closed editor. + if (binding.swipeReveal.dragProgress <= 0f) { + return false + } + + return containsTouch(binding.memUsageView.root, ev) + } + + private fun containsTouch( + view: View, + ev: MotionEvent, + ): Boolean { val rect = Rect() - if (!tabs.getGlobalVisibleRect(rect)) return false + if (!view.getGlobalVisibleRect(rect)) return false return rect.contains(ev.rawX.toInt(), ev.rawY.toInt()) } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt new file mode 100644 index 0000000000..79dd92c872 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -0,0 +1,56 @@ +/* + * 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.util.AttributeSet +import android.view.MotionEvent +import androidx.constraintlayout.widget.ConstraintLayout + +/** + * 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. + * + * This covers ancestors that intercept through the view hierarchy. The editor also runs an + * activity-level [android.view.GestureDetector] from `dispatchTouchEvent`, which never calls + * `onInterceptTouchEvent` and so cannot be stopped this way; `BaseEditorActivity` excludes this + * view's bounds there instead, the same way it already excludes the bottom-sheet tab strip. + * + * The vertical reveal drag is unaffected: `SwipeRevealLayout` only captures a vertical drag whose + * touch-down landed in its configured drag handle (the editor app bar), never in this strip. + */ +class MetricsCarouselLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : ConstraintLayout(context, attrs, defStyleAttr) { + 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) + } + return super.onInterceptTouchEvent(ev) + } + } diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index 33bf21bdc3..6000902f22 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -7,7 +7,7 @@ - - + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 51ea64c7a0..17e9a78278 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -7,10 +7,10 @@ 248dp - 48dp + 20dp 9dp 6dp - 24dp + 7dp 16dp 28dp diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 0094cecccf..897133acc9 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1674,7 +1674,6 @@ Close Memory usage chart - Metric %1$d of %2$d Code On The Go logo From ddee12e16279aefed897493fdaa3f3fa63c4572d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 15:02:56 -0700 Subject: [PATCH 006/128] feat: replace the carousel's dot indicator with a page title Dots said which page you were on but not what it was. A metrics carousel is a set of different displays, so naming the current one carries more information in the same space: "Memory usage" rather than two dots. MetricsPage gains a title, so a page names itself and the follow-up tickets (network chart, plugin-contributed pages) supply one as a matter of course. A ViewPager2.OnPageChangeCallback drives the label; it is unregistered alongside the adapter in preDestroy. The callback does not fire for the page the carousel opens on, so the initial title is set explicitly. The title is sp text, unlike the dp-sized dots, so the layout had to change shape: the title is wrap_content and the pager takes whatever height is left. At 2x font scale the title grows from 22dp to 35dp and the chart gives up that space, rather than the label clipping or the panel changing height. No maxLines or ellipsize -- a long title wraps and the chart absorbs it, which is the right failure mode for text that is not disposable. This drops the TabLayout, the dot selector drawable, its four dimens, and the touch-swallowing needed to stop dots acting as tabs. The dots' theme problem goes with them: the active dot needed to differ in both size and colour because several themes resolve colorPrimary to a grey indistinguishable from colorOutline. Trade-off: a title does not show that further pages exist, which dots did. Worth revisiting if the carousel grows past a handful of pages; at two, swiping finds the second one and the title then says what it is. Verified on a Pixel 6 Pro (arm64), v8 debug: - Titles track the page ("Memory usage", "Code On The Go"); paging both directions still works and page 1 still returns with full history. - Font scale 1.0 and 2.0, measured on a cold start: title 22dp -> 35dp, pager 185dp -> 171dp, panel 248dp throughout, nothing clipped. EditorActivityKt declares fontScale in configChanges, so it is not recreated on a font-scale change -- a warm relaunch reports stale geometry and the app must be force-stopped first to measure this. - :app:testV8DebugUnitTest for ui/activities/fragments: 41 tests green. ADFA-5487 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 37 +++++++++++------- .../androidide/ui/MetricsCarouselAdapter.kt | 10 ++++- .../res/drawable/metrics_carousel_dot.xml | 39 ------------------- app/src/main/res/layout/layout_mem_usage.xml | 25 ++++++------ app/src/main/res/values/dimens.xml | 6 +-- resources/src/main/res/values/strings.xml | 2 + 6 files changed, 49 insertions(+), 70 deletions(-) delete mode 100644 app/src/main/res/drawable/metrics_carousel_dot.xml 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 53a78057ad..3f4794af23 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,12 +66,12 @@ 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 import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.Tab -import com.google.android.material.tabs.TabLayoutMediator import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R import com.itsaky.androidide.R.string @@ -188,6 +188,7 @@ abstract class BaseEditorActivity : private var drawerToggle: ActionBarDrawerToggle? = null private var bottomSheetCallback: BottomSheetBehavior.BottomSheetCallback? = null protected val memoryUsageWatcher = MemoryUsageWatcher() + private var metricsPageCallback: ViewPager2.OnPageChangeCallback? = null private val memUsageChartRenderer = MemoryUsageChartRenderer( usagesProvider = memoryUsageWatcher::getMemoryUsages, @@ -517,6 +518,10 @@ abstract class BaseEditorActivity : fullscreenManager?.destroy() fullscreenManager = null + metricsPageCallback?.let { callback -> + _binding?.memUsageView?.metricsPager?.unregisterOnPageChangeCallback(callback) + } + metricsPageCallback = null _binding?.memUsageView?.metricsPager?.adapter = null memUsageChartRenderer.detach() _binding = null @@ -971,29 +976,31 @@ abstract class BaseEditorActivity : listOf( // The memory chart is the default page (ADFA-5487). The logo is a placeholder second // page until there is a real second metric; the network-traffic chart replaces it. - MetricsPage.MemoryChart, + MetricsPage.MemoryChart(title = string.metrics_title_memory), MetricsPage.Image( drawable = R.drawable.cogo_brand_mark, description = string.metrics_carousel_brand_mark, + title = string.metrics_title_brand_mark, ), ) binding.memUsageView.metricsPager.adapter = MetricsCarouselAdapter(pages, memUsageChartRenderer) - TabLayoutMediator( - binding.memUsageView.metricsIndicator, - binding.memUsageView.metricsPager, - ) { _, _ -> }.attach() - - binding.memUsageView.metricsIndicator.apply { - // A pure indicator, not a control: the carousel pages by swipe (MetricsCarouselLayout), - // so the dots need neither 48dp touch targets nor their own accessibility nodes -- - // ViewPager2 already reports page position. Swallowing touches keeps the dots from - // acting as tabs while leaving TabLayoutMediator to track the selected page. - @Suppress("ClickableViewAccessibility") - setOnTouchListener { _, _ -> true } - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + 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) } private fun watchMemory() { diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt index be4c9fb32c..4b602584fc 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt @@ -28,15 +28,23 @@ import com.itsaky.androidide.R /** * A page of the editor's metrics carousel. + * + * @property title Names the page. Shown below the carousel, and the only cue to which page is + * showing, so every page needs one. */ sealed interface MetricsPage { + @get:StringRes val title: Int + /** The live memory-usage chart, rendered by [MemoryUsageChartRenderer]. */ - data object MemoryChart : MetricsPage + data class MemoryChart( + @StringRes override val title: Int, + ) : MetricsPage /** A static image. Placeholder page until real metrics exist to show alongside memory. */ data class Image( @DrawableRes val drawable: Int, @StringRes val description: Int, + @StringRes override val title: Int, ) : MetricsPage } diff --git a/app/src/main/res/drawable/metrics_carousel_dot.xml b/app/src/main/res/drawable/metrics_carousel_dot.xml deleted file mode 100644 index 602285af95..0000000000 --- a/app/src/main/res/drawable/metrics_carousel_dot.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index 6000902f22..e78b5f9dc9 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -13,27 +13,30 @@ android:layout_width="match_parent" android:layout_height="@dimen/editor_mem_usage_view_height"> + - + 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 17e9a78278..c3bbde87e7 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -7,10 +7,8 @@ 248dp - 20dp - 9dp - 6dp - 7dp + 16dp + 4dp 16dp 28dp diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 897133acc9..808f226785 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1674,6 +1674,8 @@ Close Memory usage chart + Memory usage + Code On The Go Code On The Go logo From 7becd06cebdb0b5392fa9a92bf886624fe70cc98 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 16:21:32 -0700 Subject: [PATCH 007/128] feat: add a UID-level network traffic page to the metrics carousel Second page of the editor's metrics carousel (ADFA-5487) is now a live network traffic chart, replacing the brand-mark placeholder. Accounting is UID-level, as decided on the ticket: TrafficStats.getUidRxBytes / getUidTxBytes cover every process sharing the app's UID, so Gradle's downloads are included without any socket tagging -- the Gradle Tooling and daemon processes share it. There is deliberately no per-feature breakdown; the only two tagged sockets in the tree are the local documentation web server and the JDWP listener, neither of which is interesting here. The platform counters are cumulative since boot, so NetworkUsageWatcher records the delta between consecutive samples. Three cases the raw counters would get wrong: - The first sample only establishes a baseline and contributes 0. Otherwise the chart would open with a spike equal to everything the app had transferred since boot. - A counter that goes backwards (reboot, re-based accounting) records 0 rather than plotting negative traffic. - TrafficStats.UNSUPPORTED (-1), which some devices return, is detected once and latched, so -1 is never plotted as a byte count. getUsage() hands out copies rather than the live ring buffers, guarded by a lock. The renderer reads all 30 entries while the sampler thread appends, and MemoryUsageWatcher's equivalent has that race today. Axis, per the ticket's decisions: - Values are log10(bytes + 1). Traffic spans orders of magnitude -- a few hundred bytes of chatter next to a multi-megabyte download -- and a linear axis flattens all of it but the largest burst onto the baseline. MPAndroidChart has no logarithmic axis. - The + 1 floors zero, which is the common sample rather than an edge case: an idle IDE transfers nothing and log10(0) is negative infinity. A zero sample plots at exactly 0.0 and the line stays continuous. - Units are decimal (1 kB = 1000 B), not binary. This was not in the ticket and is a consequence of the log axis: on-device the first cut labelled the gridlines 9B / 99B / 999B / 9.8KB, because powers of ten divided by 1024 stop looking like decades. Decimal units label them 0B / 10B / 100B / 1.0kB, and are the convention for throughput. - Axis labels show 10^value rather than the exact inverse 10^value - 1, which would read 9B / 99B / 999B. One byte is not worth the confusion, and the legend carries the exact current figure. Zero is labelled exactly, since log10(0 + 1) really is 0. MetricsPage.Image and its layout go with the placeholder, having no remaining user; ADFA-5490 will define its own extension surface. The cogo_brand_mark drawable stays -- six other screens use it. Verified on a Pixel 6 Pro (arm64), v8 debug, over wifi with a real Gradle sync: - Both series track real traffic (peaks ~10kB/s against byte-level chatter, both legible on the one scale), idle periods sit flat on the 0B baseline, and the axis reads 0B / 10B / 100B / 1.0kB / 10.0kB. - Swiping to the memory page and back returns the full 30-sample history, so the page is recycling-safe like the memory one. - Font scale 1.0 and 2.0, measured on a cold start (EditorActivityKt declares fontScale in configChanges, so a warm relaunch reports stale geometry): title 22dp -> 35dp, pager 185dp -> 171dp, panel 248dp throughout, nothing clipped. - Landscape renders correctly, nothing clipped. - 16 new tests (7 watcher, 9 renderer); 80 tests green across app ui/utils/activities/fragments. ADFA-5489 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 36 ++- .../androidide/ui/MetricsCarouselAdapter.kt | 49 ++-- .../ui/NetworkUsageChartRenderer.kt | 267 ++++++++++++++++++ .../androidide/utils/NetworkUsageWatcher.kt | 228 +++++++++++++++ ...age.xml => item_metrics_network_chart.xml} | 7 +- app/src/main/res/values/dimens.xml | 1 - .../ui/NetworkUsageChartRendererTest.kt | 167 +++++++++++ .../utils/NetworkUsageWatcherTest.kt | 165 +++++++++++ resources/src/main/res/values/strings.xml | 6 +- 9 files changed, 884 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt rename app/src/main/res/layout/{item_metrics_image.xml => item_metrics_network_chart.xml} (86%) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.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 3f4794af23..05ff076332 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 @@ -122,6 +122,7 @@ 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.SwipeRevealLayout import com.itsaky.androidide.uidesigner.UIDesignerActivity import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow @@ -131,6 +132,7 @@ 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 @@ -195,6 +197,15 @@ abstract class BaseEditorActivity : lineColorFor = ::getMemUsageLineColorFor, ) + protected val networkUsageWatcher = NetworkUsageWatcher() + 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 private var fullscreenManager: FullscreenManager? = null @@ -524,11 +535,14 @@ abstract class BaseEditorActivity : metricsPageCallback = null _binding?.memUsageView?.metricsPager?.adapter = null memUsageChartRenderer.detach() + networkUsageChartRenderer.detach() _binding = null if (isDestroying) { memoryUsageWatcher.stopWatching(true) memoryUsageWatcher.listener = null + networkUsageWatcher.stopWatching() + networkUsageWatcher.listener = null editorActivityScope.cancelIfActive("Activity is being destroyed") unbindDebuggerService() @@ -866,6 +880,7 @@ abstract class BaseEditorActivity : setupMetricsCarousel() watchMemory() + watchNetwork() observeFileOperations() setupGestureDetector() @@ -974,17 +989,14 @@ abstract class BaseEditorActivity : private fun setupMetricsCarousel() { val pages = listOf( - // The memory chart is the default page (ADFA-5487). The logo is a placeholder second - // page until there is a real second metric; the network-traffic chart replaces it. + // 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.Image( - drawable = R.drawable.cogo_brand_mark, - description = string.metrics_carousel_brand_mark, - title = string.metrics_title_brand_mark, - ), + MetricsPage.NetworkChart(title = string.metrics_title_network), ) - binding.memUsageView.metricsPager.adapter = MetricsCarouselAdapter(pages, memUsageChartRenderer) + binding.memUsageView.metricsPager.adapter = + MetricsCarouselAdapter(pages, memUsageChartRenderer, networkUsageChartRenderer) val showTitleFor = { position: Int -> pages.getOrNull(position)?.let { page -> @@ -1009,6 +1021,10 @@ abstract class BaseEditorActivity : resetMemUsageChart() } + private fun watchNetwork() { + networkUsageWatcher.listener = networkUsageListener + } + /** * Rebuilds the memory chart for the currently watched processes. Call after starting or stopping * watching a process. @@ -1029,6 +1045,8 @@ abstract class BaseEditorActivity : super.onPause() memoryUsageWatcher.listener = null memoryUsageWatcher.stopWatching(false) + networkUsageWatcher.listener = null + networkUsageWatcher.stopWatching() this.isDestroying = isFinishing getFileTreeFragment()?.saveTreeState() @@ -1047,6 +1065,8 @@ abstract class BaseEditorActivity : memoryUsageWatcher.listener = memoryUsageListener memoryUsageWatcher.startWatching() + networkUsageWatcher.listener = networkUsageListener + networkUsageWatcher.startWatching() apkInstallationViewModel.reloadStatus(this) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt index 4b602584fc..e9ff65f7f8 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt @@ -20,8 +20,6 @@ package com.itsaky.androidide.ui import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import com.itsaky.androidide.R @@ -40,10 +38,8 @@ sealed interface MetricsPage { @StringRes override val title: Int, ) : MetricsPage - /** A static image. Placeholder page until real metrics exist to show alongside memory. */ - data class Image( - @DrawableRes val drawable: Int, - @StringRes val description: Int, + /** The live network-traffic chart, rendered by [NetworkUsageChartRenderer]. */ + data class NetworkChart( @StringRes override val title: Int, ) : MetricsPage } @@ -60,7 +56,8 @@ sealed interface MetricsPage { */ class MetricsCarouselAdapter( private val pages: List, - private val chartRenderer: MemoryUsageChartRenderer, + private val memoryChartRenderer: MemoryUsageChartRenderer, + private val networkChartRenderer: NetworkUsageChartRenderer, ) : RecyclerView.Adapter() { sealed class PageViewHolder( view: View, @@ -69,9 +66,9 @@ class MetricsCarouselAdapter( val chart: SafeLineChart, ) : PageViewHolder(chart) - class Image( - val image: ImageView, - ) : PageViewHolder(image) + class NetworkChart( + val chart: SafeLineChart, + ) : PageViewHolder(chart) } override fun getItemCount(): Int = pages.size @@ -79,7 +76,7 @@ class MetricsCarouselAdapter( override fun getItemViewType(position: Int): Int = when (pages[position]) { is MetricsPage.MemoryChart -> VIEW_TYPE_MEMORY_CHART - is MetricsPage.Image -> VIEW_TYPE_IMAGE + is MetricsPage.NetworkChart -> VIEW_TYPE_NETWORK_CHART } override fun onCreateViewHolder( @@ -94,9 +91,9 @@ class MetricsCarouselAdapter( ) } - VIEW_TYPE_IMAGE -> { - PageViewHolder.Image( - inflater.inflate(R.layout.item_metrics_image, parent, false) as ImageView, + VIEW_TYPE_NETWORK_CHART -> { + PageViewHolder.NetworkChart( + inflater.inflate(R.layout.item_metrics_network_chart, parent, false) as SafeLineChart, ) } @@ -110,31 +107,29 @@ class MetricsCarouselAdapter( holder: PageViewHolder, position: Int, ) { - when (val page = pages[position]) { + when (pages[position]) { is MetricsPage.MemoryChart -> { - chartRenderer.attach((holder as PageViewHolder.MemoryChart).chart) + memoryChartRenderer.attach((holder as PageViewHolder.MemoryChart).chart) } - is MetricsPage.Image -> { - (holder as PageViewHolder.Image).image.apply { - setImageResource(page.drawable) - contentDescription = context.getString(page.description) - } + is MetricsPage.NetworkChart -> { + networkChartRenderer.attach((holder as PageViewHolder.NetworkChart).chart) } } } override fun onViewRecycled(holder: PageViewHolder) { - if (holder is PageViewHolder.MemoryChart) { - // Only if this holder's chart is still the attached one: a rebind can create the - // replacement before RecyclerView recycles the view it replaced, and detaching then - // would drop the new chart instead of the old. - chartRenderer.detachIfAttached(holder.chart) + // Only if this holder's chart is still the attached one: a rebind can create the replacement + // before RecyclerView recycles the view it replaced, and detaching then would drop the new + // chart instead of the old. + when (holder) { + is PageViewHolder.MemoryChart -> memoryChartRenderer.detachIfAttached(holder.chart) + is PageViewHolder.NetworkChart -> networkChartRenderer.detachIfAttached(holder.chart) } } private companion object { const val VIEW_TYPE_MEMORY_CHART = 0 - const val VIEW_TYPE_IMAGE = 1 + const val VIEW_TYPE_NETWORK_CHART = 1 } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt new file mode 100644 index 0000000000..13061c2a35 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -0,0 +1,267 @@ +/* + * 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.graphics.Color +import androidx.annotation.UiThread +import com.github.mikephil.charting.components.AxisBase +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.log10 +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Renders [NetworkUsageWatcher] samples into a [SafeLineChart] on a logarithmic scale (ADFA-5489). + * + * Traffic spans orders of magnitude -- a few hundred bytes of chatter next to a multi-megabyte + * Gradle download -- so a linear axis flattens everything but the largest burst into the baseline. + * MPAndroidChart has no logarithmic axis, so the plotted value is [log10] of the byte count and + * [BytesAxisFormatter] turns the axis labels back into byte units. + * + * Zero is the common sample, not an edge case: an idle IDE transfers nothing, and `log10(0)` is + * negative infinity. Values are therefore `log10(bytes + 1)`, which puts a zero sample at exactly + * `0.0` and keeps the line continuous. + * + * Like [MemoryUsageChartRenderer] this holds no sample state -- [NetworkUsageWatcher] owns the + * history -- so a chart can be attached, detached and recycled by the metrics carousel without + * losing anything. + * + * All methods must be called on the UI thread; MPAndroidChart is not thread-safe (see + * [SafeLineChart]). + * + * @param usageProvider Supplies the current sample history. + */ +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() + } + } + + /** + * Rebuilds both series from the full sample history. + */ + @UiThread + 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), + dataset(usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted), TRANSMITTED_COLOR), + ) + + chart.apply { + data = LineData(*datasets) + axisRight.textColor = textColor + axisLeft.textColor = textColor + legend.textColor = textColor + + data.setValueTextColor(textColor) + setBackgroundColor(bgColor) + setGridBackgroundColor(bgColor) + notifyDataSetChanged() + invalidate() + } + } + + /** + * Updates both series in place from a fresh sample, rebuilding if the chart's shape no longer + * matches. Allocates nothing on the common path, which runs once a second. + */ + @UiThread + fun onUsageChanged(usage: NetworkUsage) { + val chart = this.chart ?: return + val data = chart.data + + if (data == null || data.dataSetCount != SERIES_COUNT) { + rebuild() + return + } + + val received = data.getDataSetByIndex(RECEIVED_INDEX) as LineDataSet? + val transmitted = data.getDataSetByIndex(TRANSMITTED_INDEX) as LineDataSet? + if (received == null || transmitted == null || + received.entryCount != usage.received.size || + transmitted.entryCount != usage.transmitted.size + ) { + rebuild() + return + } + + update(received, usage.received, chart.context.getString(R.string.metrics_network_received)) + update(transmitted, usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted)) + + chart.apply { + data.notifyDataChanged() + notifyDataSetChanged() + invalidate() + } + } + + private fun dataset( + samples: LongArray, + label: String, + lineColor: Int, + ): LineDataSet = + LineDataSet( + List(samples.size) { index -> Entry(index.toFloat(), samples[index].toLogBytes()) }, + label, + ).apply { + color = lineColor + setDrawIcons(false) + setDrawCircles(false) + setDrawCircleHole(false) + setDrawValues(false) + formLineWidth = 1f + formSize = 15f + isHighlightEnabled = false + this.label = labelFor(label, samples.lastOrNull() ?: 0L) + } + + private fun update( + dataset: LineDataSet, + samples: LongArray, + label: String, + ) { + for (index in samples.indices) { + dataset.entries[index].y = samples[index].toLogBytes() + } + dataset.label = labelFor(label, samples.lastOrNull() ?: 0L) + dataset.notifyDataSetChanged() + } + + private fun labelFor( + label: String, + bytes: Long, + ): String = "%s - %s/s".format(label, formatBytes(bytes.toDouble())) + + 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 + // Without a floor the axis auto-scales to the noise around zero when nothing is happening. + axisRight.axisMinimum = 0f + // One label per decade, so the gridlines read as 1.0kB / 1.0MB rather than arbitrary + // fractions of a logarithm. + axisRight.granularity = 1f + axisRight.isGranularityEnabled = true + } + } + + /** + * Labels a logarithmic axis value in byte units. + * + * Gridlines land on integer values (granularity 1), so each is a power of ten and is labelled as + * one: 10B, 100B, 1.0kB. The exact inverse of [toLogBytes] would be `10^value - 1`, which labels + * those same lines 9B, 99B, 999B -- correct to the byte but unreadable as a scale. The one byte + * is not worth the confusion; the legend carries the exact current figure. + * + * Zero is the exception and is labelled exactly: `log10(0 + 1)` is 0, so the baseline really is + * no traffic, not one byte. + */ + private object BytesAxisFormatter : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = + if (value < 0.5f) { + formatBytes(0.0) + } else { + formatBytes(10.0.pow(value.toDouble())) + } + } + + private companion object { + const val SERIES_COUNT = 2 + const val RECEIVED_INDEX = 0 + const val TRANSMITTED_INDEX = 1 + + val RECEIVED_COLOR = Color.CYAN + val TRANSMITTED_COLOR = Color.MAGENTA + } +} + +/** + * The plotted value for a byte count: `log10(bytes + 1)`. + * + * The `+ 1` is what makes zero plottable -- it maps to `0.0` rather than negative infinity -- and + * zero is the usual sample for an idle IDE. + */ +private fun Long.toLogBytes(): Float = log10(this.coerceAtLeast(0L).toDouble() + 1.0).toFloat() + +/** + * Formats a byte count for an axis label or legend, to at most one decimal place. + * + * Units are decimal (1 kB = 1000 B), not binary. On a log10 axis the gridlines are powers of ten, + * and dividing those by 1024 would label them 9.8KB, 977KB, 954MB -- the decades stop looking like + * decades. Decimal units are also the convention for network throughput. + */ +private fun formatBytes(bytes: Double): String { + val clamped = bytes.coerceAtLeast(0.0) + return when { + clamped < 1_000 -> "%dB".format(clamped.roundToLong()) + clamped < 1_000_000 -> "%.1fkB".format(clamped / 1_000) + clamped < 1_000_000_000 -> "%.1fMB".format(clamped / 1_000_000) + else -> "%.1fGB".format(clamped / 1_000_000_000) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt new file mode 100644 index 0000000000..09e2f52e34 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -0,0 +1,228 @@ +/* + * 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.net.TrafficStats +import android.os.Process +import androidx.annotation.VisibleForTesting +import com.itsaky.androidide.tasks.cancelIfActive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Samples this app's network traffic (ADFA-5489). + * + * Accounting is UID-level, not per socket: [TrafficStats.getUidRxBytes] and + * [TrafficStats.getUidTxBytes] cover every process sharing the app's UID, which is what makes + * Gradle's downloads show up here -- the Gradle Tooling and daemon processes share it. No socket + * tagging is involved, so there is deliberately no per-feature breakdown. + * + * The platform counters are cumulative since boot, so what is recorded is the *delta* between + * consecutive samples: bytes transferred during that interval. A sampler that reported the raw + * counters would draw a monotonically rising line that says nothing about current activity. + * + * @param updateInterval Milliseconds between samples. + * @param uid The UID to account for. Defaults to this process's own; injectable for tests. + * @param readRxBytes Reads the cumulative received byte count. Injectable for tests. + * @param readTxBytes Reads the cumulative transmitted byte count. Injectable for tests. + */ +class NetworkUsageWatcher( + private val 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, +) { + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + private val coroutineDispatcher = newSingleThreadContext("NetworkUsageWatcher") + private val coroutineScope = CoroutineScope(coroutineDispatcher) + private val watching = AtomicBoolean(false) + + /** Guards the two ring buffers: the sampler writes them, the UI thread snapshots them. */ + private val historyLock = Any() + + private val received = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + private val transmitted = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + + /** + * The previous cumulative readings, or `null` before the first sample. The first sample + * establishes a baseline and contributes no delta -- the alternative would be a spike equal to + * everything the app had transferred since boot. + */ + private var lastRx: Long? = null + private var lastTx: Long? = null + + /** + * Whether the platform reports traffic for this UID at all. Cleared permanently if a read comes + * back [TrafficStats.UNSUPPORTED], which some devices and emulators do. + */ + @Volatile + var isSupported: Boolean = true + private set + + val isWatching: Boolean + get() = watching.get() + + /** + * Notified on the main thread after each sample. + */ + var listener: NetworkUsageListener? = null + + /** + * A snapshot of the sampled history, oldest first. Safe to call from any thread at any time; + * before the first sample every entry is zero. + * + * The arrays are copies. Handing out the live ring buffers would let the caller read them while + * the sampler thread is midway through appending, and the chart renderer reads all 30 entries. + */ + fun getUsage(): NetworkUsage = + synchronized(historyLock) { + NetworkUsage(received.snapshot(), transmitted.snapshot()) + } + + fun startWatching() { + if (isWatching) { + 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) + } + } + + delay(updateInterval) + } + } + } + + fun stopWatching() { + watching.set(false) + coroutineScope.cancelIfActive("Cancellation requested") + } + + /** + * Takes one sample. The sampling loop calls this once per [updateInterval]; tests call it + * directly so the delta accounting can be exercised without threads or waiting. + */ + @VisibleForTesting + internal fun sampleOnce() { + if (!isSupported) { + return + } + + val rx = readRxBytes(uid) + val tx = readTxBytes(uid) + + if (rx == UNSUPPORTED || tx == UNSUPPORTED) { + // Not transient: the platform either accounts for this UID or it does not. + isSupported = false + log.info("Network usage is unavailable on this device; the traffic chart will read zero") + return + } + + synchronized(historyLock) { + record(received, previous = lastRx, current = rx) + record(transmitted, previous = lastTx, current = tx) + } + + lastRx = rx + lastTx = tx + } + + /** + * Appends the delta between [previous] and [current] to [history]. + * + * A negative delta means the counter went backwards, which happens when it is reset -- the + * device rebooted, or the platform re-based its accounting. Treated as a fresh baseline (zero + * for this interval) rather than plotted as negative traffic. + */ + private fun record( + history: MutableShiftedLongArray, + previous: Long?, + current: Long, + ) { + val delta = + when { + previous == null -> 0L + current < previous -> 0L + else -> current - previous + } + + // Newest entry goes in at index 0 and the shift makes it the last element, so + // history[size - 1] is always the newest. Same convention as MemoryUsageWatcher. + history[0] = delta + history.shift(1) + } + + /** + * Bytes transferred per sampling interval, oldest first. + * + * @property received Bytes received during each interval. + * @property transmitted Bytes transmitted during each interval. + */ + data class NetworkUsage( + val received: LongArray, + val transmitted: LongArray, + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is NetworkUsage && + received.contentEquals(other.received) && + transmitted.contentEquals(other.transmitted) + ) + + override fun hashCode(): Int = 31 * received.contentHashCode() + transmitted.contentHashCode() + } + + fun interface NetworkUsageListener { + fun onNetworkUsageChanged(usage: NetworkUsage) + } + + companion object { + const val MAX_USAGE_ENTRIES = 30 + const val DEFAULT_UPDATE_INTERVAL = 1000L + + /** [TrafficStats.UNSUPPORTED] widened to [Long], which is what the getters return. */ + private const val UNSUPPORTED = TrafficStats.UNSUPPORTED.toLong() + + private val log = LoggerFactory.getLogger(NetworkUsageWatcher::class.java) + } +} + +/** + * Copies this ring buffer into a plain array in logical order, oldest first. + */ +private fun ShiftedLongArray.snapshot(): LongArray = LongArray(size) { this[it] } diff --git a/app/src/main/res/layout/item_metrics_image.xml b/app/src/main/res/layout/item_metrics_network_chart.xml similarity index 86% rename from app/src/main/res/layout/item_metrics_image.xml rename to app/src/main/res/layout/item_metrics_network_chart.xml index 4d8617b328..f011080f06 100644 --- a/app/src/main/res/layout/item_metrics_image.xml +++ b/app/src/main/res/layout/item_metrics_network_chart.xml @@ -5,10 +5,9 @@ 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 . --> - + android:contentDescription="@string/metrics_network_chart" /> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index c3bbde87e7..f1785efd39 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -9,7 +9,6 @@ 248dp 16dp 4dp - 16dp 28dp 28dp diff --git a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt new file mode 100644 index 0000000000..95b81c8c6a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -0,0 +1,167 @@ +/* + * 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 androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.data.LineDataSet +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 +import kotlin.math.log10 + +/** + * Pins the two axis decisions ADFA-5489 was scoped around: values are log10, and zero is floored + * via `log10(bytes + 1)` so an idle IDE plots a continuous line at 0 instead of negative infinity. + */ +@RunWith(RobolectricTestRunner::class) +class NetworkUsageChartRendererTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun usage( + received: LongArray, + transmitted: LongArray = received, + ) = NetworkUsageWatcher.NetworkUsage(received, transmitted) + + private fun rendererFor(usage: NetworkUsageWatcher.NetworkUsage): Pair { + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage }) + renderer.attach(chart) + return renderer to chart + } + + private fun dataset( + chart: SafeLineChart, + index: Int, + ) = chart.data.getDataSetByIndex(index) as LineDataSet + + @Test + fun `plots log10 of the byte count`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 9L, 99L, 999L))) + + val ys = dataset(chart, 0).entries.map { it.y } + + // log10(n + 1): 0 -> 0, 9 -> 1, 99 -> 2, 999 -> 3. Exact decades, so the floor is visible. + assertThat(ys).containsExactly(0f, 1f, 2f, 3f).inOrder() + } + + @Test + fun `zero bytes plots at zero rather than negative infinity`() { + val (_, chart) = rendererFor(usage(LongArray(30) { 0L })) + + val ys = dataset(chart, 0).entries.map { it.y } + + assertThat(ys.none { it.isInfinite() || it.isNaN() }).isTrue() + assertThat(ys.toSet()).containsExactly(0f) + } + + @Test + fun `a megabyte burst stays on scale with surrounding chatter`() { + val bytes = longArrayOf(0L, 512L, 2L * 1024 * 1024, 256L) + val (_, chart) = rendererFor(usage(bytes)) + + val ys = dataset(chart, 0).entries.map { it.y } + + // The point of the log axis: a 2MB burst is ~6.3 while 512B is ~2.7, so the small values + // stay legible instead of being flattened onto the baseline. + assertThat(ys[2]).isWithin(0.01f).of(log10(2.0 * 1024 * 1024 + 1).toFloat()) + assertThat(ys[1]).isGreaterThan(2f) + assertThat(ys[2] - ys[1]).isLessThan(4f) + } + + @Test + fun `received and transmitted are separate series`() { + val (_, chart) = + rendererFor( + usage( + received = longArrayOf(0L, 999L), + transmitted = longArrayOf(0L, 9L), + ), + ) + + assertThat(chart.data.dataSetCount).isEqualTo(2) + assertThat(dataset(chart, 0).entries.last().y).isEqualTo(3f) + assertThat(dataset(chart, 1).entries.last().y).isEqualTo(1f) + } + + @Test + fun `the legend reports the latest sample in byte units`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 2_000L))) + + // Rendered from the raw byte count, not from the logarithm, and in decimal units so that + // the log10 axis labels come out as clean decades. + assertThat(dataset(chart, 0).label).endsWith("2.0kB/s") + } + + @Test + fun `onUsageChanged updates entries in place without replacing the datasets`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 9L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + val datasetBefore = dataset(chart, 0) + val entryBefore = datasetBefore.entries.last() + + current = usage(longArrayOf(0L, 999L)) + renderer.onUsageChanged(current) + + assertThat(dataset(chart, 0)).isSameInstanceAs(datasetBefore) + assertThat(datasetBefore.entries.last()).isSameInstanceAs(entryBefore) + assertThat(entryBefore.y).isEqualTo(3f) + } + + @Test + fun `onUsageChanged rebuilds when the sample count changes`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 9L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + assertThat(dataset(chart, 0).entryCount).isEqualTo(2) + + current = usage(longArrayOf(0L, 9L, 99L)) + renderer.onUsageChanged(current) + + assertThat(dataset(chart, 0).entryCount).isEqualTo(3) + } + + @Test + fun `attach after detach renders the history into the new chart`() { + val current = usage(longArrayOf(0L, 99L)) + val (renderer, _) = rendererFor(current) + renderer.detach() + + val rebound = SafeLineChart(context) + renderer.attach(rebound) + + assertThat(dataset(rebound, 0).entries.last().y).isEqualTo(2f) + } + + @Test + fun `onUsageChanged after detach is a no-op`() { + val current = usage(longArrayOf(0L, 99L)) + val (renderer, _) = rendererFor(current) + renderer.detach() + + // A recycled carousel page must not keep the renderer writing into a dead view. + renderer.onUsageChanged(current) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt new file mode 100644 index 0000000000..c21420595a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt @@ -0,0 +1,165 @@ +/* + * 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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the accounting decisions ADFA-5489 was scoped around: the platform counters are cumulative, + * so what is plotted is the delta between samples, and a counter reset must not plot as negative + * traffic. + * + * These drive [NetworkUsageWatcher.sampleOnce] directly rather than starting the sampling loop, so + * there is no waiting and no dependence on thread timing. + */ +@RunWith(RobolectricTestRunner::class) +class NetworkUsageWatcherTest { + /** + * A watcher fed a scripted sequence of cumulative readings, advancing one step per sample. + */ + private class Fixture( + rx: List, + tx: List = rx, + ) { + private var index = -1 + private val rxReadings = rx + private val txReadings = tx + + val watcher = + NetworkUsageWatcher( + uid = TEST_UID, + readRxBytes = { rxReadings[index.coerceIn(0, rxReadings.lastIndex)] }, + readTxBytes = { txReadings[index.coerceIn(0, txReadings.lastIndex)] }, + ) + + /** Takes [count] samples, walking the scripted readings. */ + fun sample(count: Int) { + repeat(count) { + index++ + watcher.sampleOnce() + } + } + } + + /** The last [count] recorded samples, ignoring the leading zeros of an unfilled buffer. */ + private fun LongArray.recent(count: Int): List = takeLast(count) + + @Test + fun `history is all zeros before the first sample`() { + val fixture = Fixture(listOf(5_000L)) + + val usage = fixture.watcher.getUsage() + + assertThat(usage.received).hasLength(NetworkUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(usage.received.sum()).isEqualTo(0L) + assertThat(usage.transmitted.sum()).isEqualTo(0L) + } + + @Test + fun `plots deltas between samples, not the cumulative counters`() { + // Cumulative since boot: 1000, then +500, then +2500. + val fixture = Fixture(listOf(1_000L, 1_500L, 4_000L)) + + fixture.sample(3) + val usage = fixture.watcher.getUsage() + + // The first sample only establishes a baseline, so it contributes 0 rather than a + // 1000-byte spike for traffic that happened before the chart existed. + assertThat(usage.received.recent(3)).containsExactly(0L, 500L, 2_500L).inOrder() + assertThat(usage.transmitted.recent(3)).containsExactly(0L, 500L, 2_500L).inOrder() + } + + @Test + fun `a counter reset records zero rather than negative traffic`() { + // A reboot or re-based accounting makes the counter go backwards. + val fixture = Fixture(listOf(10_000L, 10_400L, 200L, 700L)) + + fixture.sample(4) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received.recent(4)).containsExactly(0L, 400L, 0L, 500L).inOrder() + assertThat(usage.received.none { it < 0L }).isTrue() + } + + @Test + fun `received and transmitted are accounted separately`() { + val fixture = + Fixture( + rx = listOf(0L, 1_000L), + tx = listOf(0L, 7L), + ) + + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received.recent(2)).containsExactly(0L, 1_000L).inOrder() + assertThat(usage.transmitted.recent(2)).containsExactly(0L, 7L).inOrder() + } + + @Test + fun `the ring buffer keeps only the most recent samples`() { + val capacity = NetworkUsageWatcher.MAX_USAGE_ENTRIES + // Cumulative readings rising by 10 bytes each sample, for one more sample than fits. + val readings = List(capacity + 2) { it * 10L } + val fixture = Fixture(readings) + + fixture.sample(readings.size) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received).hasLength(capacity) + // The baseline zero has been pushed out; every retained sample is a full 10-byte delta. + assertThat(usage.received.toList()).containsNoneIn(listOf(-10L)) + assertThat(usage.received.last()).isEqualTo(10L) + assertThat(usage.received.sum()).isEqualTo(10L * capacity) + } + + @Test + fun `an unsupported counter is detected and nothing is recorded`() { + // TrafficStats.UNSUPPORTED is -1. + val fixture = Fixture(listOf(-1L)) + + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + assertThat(fixture.watcher.isSupported).isFalse() + // In particular, -1 is not plotted as traffic. + assertThat(usage.received.sum()).isEqualTo(0L) + assertThat(usage.transmitted.sum()).isEqualTo(0L) + } + + @Test + fun `getUsage returns a copy, not the live buffer`() { + val fixture = Fixture(listOf(0L, 100L, 300L)) + + fixture.sample(2) + val first = fixture.watcher.getUsage() + val asHandedOut = first.received.copyOf() + fixture.sample(1) + + // The array handed out earlier must not have been mutated by the later sample. + assertThat(first.received).isEqualTo(asHandedOut) + assertThat(fixture.watcher.getUsage().received).isNotEqualTo(asHandedOut) + } + + private companion object { + const val TEST_UID = 10_123 + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 808f226785..1b9946fa0c 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1675,8 +1675,10 @@ Memory usage chart Memory usage - Code On The Go - Code On The Go logo + Network traffic chart + Network traffic + Received + Sent From d668f782584ffd932ab860f286848fb674b330b3 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 16:34:55 -0700 Subject: [PATCH 008/128] fix: label the network axis in whole units and rest zero on the baseline Two axis problems, one cosmetic and one a real rendering bug. Labels now read "10 kB" rather than "10.0kB". Gridlines sit on whole decades (granularity 1), so the mantissa is always exact and the decimal place carried no information. formatBytes takes the precision as an argument: none for axis labels, one place for the legend, where the figure is an arbitrary sample and the decimal does carry information. A space separates value from unit throughout. Zero now rests on the baseline. Two causes, both fixed: - The series were scaled against the wrong axis. LineDataSet defaults to axisDependency LEFT, and the labelled axis here is the right one, so the line was positioned by the disabled, auto-ranged left axis while the labels came from the right. The two only agree while both auto-range over the same data; pinning one made them disagree visibly -- an idle chart drew its zero line halfway up a plot whose baseline was labelled 0 B. - The range was not pinned. With every sample zero the data range is degenerate and the chart pads around it. applyAxisRange now fixes the minimum at 0 and the maximum at whole decades above the peak, with a floor of three decades so an idle chart keeps a sensible scale instead of collapsing onto a single value. Worth noting for review: the unit tests asserting axisMinimum and axisMaximum passed throughout, because the axis really was configured correctly -- the data simply was not drawn against it. Only the device showed it. There is now a test asserting the axis dependency of both series, which is the part that was untested. MemoryUsageChartRenderer has the same LEFT-dependency-with-RIGHT-labels shape and renders correctly, because it pins neither axis and both auto-range over the same data. Left alone. Verified on a Pixel 6 Pro (arm64), v8 debug: - Idle: both series rest exactly on the 0 B baseline, axis reads 0 B / 10 B / 100 B / 1 kB. - Under a Gradle sync: axis grows to 10 kB, peaks and zero-traffic troughs both legible, legend reads "212 B/s". - 49 tests green across app ui/utils, including four new ones covering the axis range, its growth across both series, whole-unit labels, and the axis dependency. ADFA-5489 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/NetworkUsageChartRenderer.kt | 61 ++++++++++++++---- .../ui/NetworkUsageChartRendererTest.kt | 63 ++++++++++++++++++- 2 files changed, 111 insertions(+), 13 deletions(-) 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 13061c2a35..2dae31b2f2 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.ui import android.graphics.Color 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 @@ -28,7 +29,9 @@ 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 import kotlin.math.pow import kotlin.math.roundToLong @@ -98,6 +101,8 @@ class NetworkUsageChartRenderer( dataset(usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted), TRANSMITTED_COLOR), ) + applyAxisRange(chart, usage) + chart.apply { data = LineData(*datasets) axisRight.textColor = textColor @@ -139,6 +144,8 @@ 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) + chart.apply { data.notifyDataChanged() notifyDataSetChanged() @@ -155,6 +162,11 @@ class NetworkUsageChartRenderer( List(samples.size) { index -> Entry(index.toFloat(), samples[index].toLogBytes()) }, label, ).apply { + // The labelled axis is the right one, and applyAxisRange pins its range. Without this the + // series is scaled against the (disabled, auto-ranged) left axis instead, so the line is + // drawn at a position the labels do not describe -- an idle chart plots its zero line + // halfway up a plot whose baseline is labelled 0 B. + axisDependency = YAxis.AxisDependency.RIGHT color = lineColor setDrawIcons(false) setDrawCircles(false) @@ -181,7 +193,24 @@ class NetworkUsageChartRenderer( private fun labelFor( label: String, bytes: Long, - ): String = "%s - %s/s".format(label, formatBytes(bytes.toDouble())) + ): String = "%s - %s/s".format(label, formatBytes(bytes.toDouble(), decimals = 1)) + + /** + * Pins the axis to whole decades, from zero up to at least [MIN_AXIS_DECADES]. + * + * Two things depend on this. Zero has to sit on the baseline: when every sample is zero -- an + * idle IDE -- the data range is degenerate, and left to itself the chart pads around it and + * floats the flat line up the middle of the plot. And the maximum has to be a whole number, so + * the gridlines (granularity 1) land on exact powers of ten and can be labelled as whole units. + */ + private fun applyAxisRange( + chart: SafeLineChart, + usage: NetworkUsage, + ) { + val peak = max(usage.received.maxOrNull() ?: 0L, usage.transmitted.maxOrNull() ?: 0L) + chart.axisRight.axisMinimum = 0f + chart.axisRight.axisMaximum = ceil(peak.toLogBytes()).coerceAtLeast(MIN_AXIS_DECADES) + } private fun configure(chart: SafeLineChart) { chart.apply { @@ -199,10 +228,8 @@ class NetworkUsageChartRenderer( axisLeft.isEnabled = false axisRight.valueFormatter = BytesAxisFormatter - // Without a floor the axis auto-scales to the noise around zero when nothing is happening. - axisRight.axisMinimum = 0f - // One label per decade, so the gridlines read as 1.0kB / 1.0MB rather than arbitrary - // fractions of a logarithm. + // 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 } @@ -225,13 +252,20 @@ class NetworkUsageChartRenderer( axis: AxisBase?, ): String = if (value < 0.5f) { - formatBytes(0.0) + formatBytes(0.0, decimals = 0) } else { - formatBytes(10.0.pow(value.toDouble())) + // Gridlines are whole decades, so the mantissa is exact and needs no decimal place. + formatBytes(10.0.pow(value.toDouble()), decimals = 0) } } private companion object { + /** + * The axis always spans at least this many decades (0 B to 1 kB), so an idle chart keeps a + * sensible scale instead of collapsing onto a single value. + */ + const val MIN_AXIS_DECADES = 3f + const val SERIES_COUNT = 2 const val RECEIVED_INDEX = 0 const val TRANSMITTED_INDEX = 1 @@ -256,12 +290,15 @@ private fun Long.toLogBytes(): Float = log10(this.coerceAtLeast(0L).toDouble() + * and dividing those by 1024 would label them 9.8KB, 977KB, 954MB -- the decades stop looking like * decades. Decimal units are also the convention for network throughput. */ -private fun formatBytes(bytes: Double): String { +private fun formatBytes( + bytes: Double, + decimals: Int, +): String { val clamped = bytes.coerceAtLeast(0.0) return when { - clamped < 1_000 -> "%dB".format(clamped.roundToLong()) - clamped < 1_000_000 -> "%.1fkB".format(clamped / 1_000) - clamped < 1_000_000_000 -> "%.1fMB".format(clamped / 1_000_000) - else -> "%.1fGB".format(clamped / 1_000_000_000) + clamped < 1_000 -> "%d B".format(clamped.roundToLong()) + clamped < 1_000_000 -> "%.${decimals}f kB".format(clamped / 1_000) + clamped < 1_000_000_000 -> "%.${decimals}f MB".format(clamped / 1_000_000) + else -> "%.${decimals}f GB".format(clamped / 1_000_000_000) } } 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 95b81c8c6a..d24b027cd8 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.ui import android.content.Context 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.NetworkUsageWatcher @@ -107,7 +108,7 @@ class NetworkUsageChartRendererTest { // Rendered from the raw byte count, not from the logarithm, and in decimal units so that // the log10 axis labels come out as clean decades. - assertThat(dataset(chart, 0).label).endsWith("2.0kB/s") + assertThat(dataset(chart, 0).label).endsWith("2.0 kB/s") } @Test @@ -155,6 +156,66 @@ class NetworkUsageChartRendererTest { assertThat(dataset(rebound, 0).entries.last().y).isEqualTo(2f) } + @Test + fun `axis labels are whole units with no decimal place`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 10_000_000L))) + val formatter = chart.axisRight.valueFormatter + + // Gridlines sit on whole decades, so the mantissa is exact. + assertThat(formatter.getFormattedValue(0f, chart.axisRight)).isEqualTo("0 B") + assertThat(formatter.getFormattedValue(1f, chart.axisRight)).isEqualTo("10 B") + assertThat(formatter.getFormattedValue(3f, chart.axisRight)).isEqualTo("1 kB") + assertThat(formatter.getFormattedValue(4f, chart.axisRight)).isEqualTo("10 kB") + assertThat(formatter.getFormattedValue(6f, chart.axisRight)).isEqualTo("1 MB") + } + + @Test + fun `the series are scaled against the labelled axis`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 100L))) + + // The right axis is the one carrying the labels and the pinned range. A dataset left on the + // default LEFT dependency is drawn against the auto-ranged left axis, so the line lands + // somewhere the labels do not describe -- which is invisible to an assertion on the axis + // alone, and was only caught on a device. + assertThat(dataset(chart, 0).axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + assertThat(dataset(chart, 1).axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + } + + @Test + fun `an idle chart keeps zero on the baseline`() { + // Every sample zero. Left to itself the chart pads around a degenerate range and floats the + // flat line up the middle of the plot instead of resting it on the axis minimum. + val (_, chart) = rendererFor(usage(LongArray(30) { 0L })) + + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + assertThat(chart.axisRight.axisMaximum).isEqualTo(3f) + } + + @Test + fun `the axis grows to whole decades around the peak`() { + // 2 MB peak -> log10 is ~6.3, so the axis tops out at the 10 MB decade. + val (_, chart) = rendererFor(usage(longArrayOf(0L, 2_000_000L))) + + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + assertThat(chart.axisRight.axisMaximum).isEqualTo(7f) + } + + @Test + fun `the axis follows the peak across both series`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 100L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + assertThat(chart.axisRight.axisMaximum).isEqualTo(3f) + + // A burst on the transmitted series alone must still lift the axis. + current = usage(received = longArrayOf(0L, 100L), transmitted = longArrayOf(0L, 500_000L)) + renderer.onUsageChanged(current) + + assertThat(chart.axisRight.axisMaximum).isEqualTo(6f) + } + @Test fun `onUsageChanged after detach is a no-op`() { val current = usage(longArrayOf(0L, 99L)) From cf90aaf40b308169a1d83e46e96f653a22704fae Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 17:02:26 -0700 Subject: [PATCH 009/128] 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 010/128] 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 011/128] 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 012/128] 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 013/128] 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 014/128] 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 015/128] 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 016/128] 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 017/128] 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 018/128] 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 019/128] 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 020/128] 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 021/128] 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 022/128] 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 023/128] 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 024/128] 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 ccc3ae260c7a74dbcd1e75e8891d5a56029cd99d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 16:15:33 -0700 Subject: [PATCH 025/128] feat(metrics): add a temperature and power page to the carousel (ADFA-5499) A third carousel page charts battery temperature against instantaneous power draw, with thermal throttling shaded behind the plot and the battery level shown in the corner. Design decisions, and what was rejected: - Instantaneous power, not cumulative. A running total only ever rises and says nothing about which piece of work cost anything; instantaneous draw lines up with the spikes on the memory and network pages. - Battery readings only. The per-zone CPU, GPU and skin temperatures need android.permission.DEVICE_POWER, which is prot=signature|role|module -- it cannot be granted to an installed app, so there is no prompt to defer and no fallback worth attempting. PowerSource is an interface so a privileged build can supply better readings without the chart changing. - Throttling is shaded, not plotted. The platform reports an ordinal level, not a temperature, so plotting it against degrees would invent a scale. Alpha rises with severity so the bands read as a gradient of concern. - Battery level is a readout, not a series: it moves about a percent every few minutes, so over the chart's window a line would be flat, spending an axis on a constant. Hidden while charging, when a rising level would contradict a chart about power being spent. Charging periods are not shaded. - Two value axes, the only page with them. Degrees and milliwatts share no unit, so each series declares its axis; a series left on the default would be drawn against labels that do not describe it. - Power is plotted as a magnitude. The battery current reverses while charging, and a line dipping below zero would read as negative power spent. Two defects found on-device, both invisible to passing unit tests -- the same class of failure as the black-on-black axis labels and the black-tinted arrows earlier in this stack: - Shading never reached the screen. setDrawGridBackground(true) fills the plot opaquely inside super.onDraw, so spans painted before it were covered. SafeLineChart now overrides drawGridBackground and paints the spans straight after that fill, which also puts them under the grid lines and the data. - A single-sample throttle had zero width. Spans ran centre to centre, so one sample mapped to one pixel column and two adjacent runs left a sample-wide gap. Each span now covers its samples' full cells. Also wired up two things that were built but unreachable: the power page's x-axis tap now opens the sampling-rate chooser like the other pages, and batteryReadout() now has a view to write to. Verified on a Pixel 6 Pro (arm64) with `cmd thermalservice override-status` stepped through levels 1, 3 and 6 and `dumpsys battery unplug`: three bands appear, deepen with severity, abut without gaps, and stop when the override clears. Checked at font scale 1.0 and 2.0 -- the title, arrows and battery readout all grow without clipping. The chart's own axis and legend text is drawn by MPAndroidChart in dp and does not scale, which is a pre-existing limitation of the library recorded under ADFA-5486, not new here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 6 + .../androidide/ui/MetricsCarouselAdapter.kt | 23 ++ .../ui/MetricsCarouselController.kt | 51 +++- .../androidide/ui/PowerUsageChartRenderer.kt | 260 ++++++++++++++++ .../com/itsaky/androidide/ui/SafeLineChart.kt | 62 ++++ .../androidide/utils/DevicePowerSource.kt | 139 +++++++++ .../androidide/utils/PowerUsageWatcher.kt | 281 ++++++++++++++++++ .../androidide/viewmodel/MetricsViewModel.kt | 16 +- .../res/layout/item_metrics_power_chart.xml | 13 + app/src/main/res/layout/layout_mem_usage.xml | 15 + .../ui/PowerUsageChartRendererTest.kt | 255 ++++++++++++++++ .../androidide/utils/PowerUsageWatcherTest.kt | 188 ++++++++++++ resources/src/main/res/values/strings.xml | 4 + 13 files changed, 1310 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt create mode 100644 app/src/main/res/layout/item_metrics_power_chart.xml create mode 100644 app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.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 72299abfc4..48ed604014 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 @@ -196,10 +196,13 @@ abstract class BaseEditorActivity : protected val networkUsageWatcher get() = metricsViewModel.networkUsageWatcher + protected val powerUsageWatcher get() = metricsViewModel.powerUsageWatcher + protected val metricsCarousel by lazy { MetricsCarouselController( memoryUsageWatcher = memoryUsageWatcher, networkUsageWatcher = networkUsageWatcher, + powerUsageWatcher = powerUsageWatcher, lineColorFor = ::getMemUsageLineColorFor, annotations = metricsViewModel.annotations, ) @@ -1072,6 +1075,9 @@ abstract class BaseEditorActivity : if (!networkUsageWatcher.isWatching) { networkUsageWatcher.startWatching() } + if (!powerUsageWatcher.isWatching) { + powerUsageWatcher.startWatching() + } if (!isMetricsCarouselUndocked()) { // Draw whatever was sampled while away, rather than waiting for the next tick. diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt index e9ff65f7f8..cc2b1bcc51 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt @@ -42,6 +42,11 @@ sealed interface MetricsPage { data class NetworkChart( @StringRes override val title: Int, ) : MetricsPage + + /** The live temperature and power chart, rendered by [PowerUsageChartRenderer]. */ + data class PowerChart( + @StringRes override val title: Int, + ) : MetricsPage } /** @@ -58,6 +63,7 @@ class MetricsCarouselAdapter( private val pages: List, private val memoryChartRenderer: MemoryUsageChartRenderer, private val networkChartRenderer: NetworkUsageChartRenderer, + private val powerChartRenderer: PowerUsageChartRenderer, ) : RecyclerView.Adapter() { sealed class PageViewHolder( view: View, @@ -69,6 +75,10 @@ class MetricsCarouselAdapter( class NetworkChart( val chart: SafeLineChart, ) : PageViewHolder(chart) + + class PowerChart( + val chart: SafeLineChart, + ) : PageViewHolder(chart) } override fun getItemCount(): Int = pages.size @@ -77,6 +87,7 @@ class MetricsCarouselAdapter( when (pages[position]) { is MetricsPage.MemoryChart -> VIEW_TYPE_MEMORY_CHART is MetricsPage.NetworkChart -> VIEW_TYPE_NETWORK_CHART + is MetricsPage.PowerChart -> VIEW_TYPE_POWER_CHART } override fun onCreateViewHolder( @@ -97,6 +108,12 @@ class MetricsCarouselAdapter( ) } + VIEW_TYPE_POWER_CHART -> { + PageViewHolder.PowerChart( + inflater.inflate(R.layout.item_metrics_power_chart, parent, false) as SafeLineChart, + ) + } + else -> { throw IllegalArgumentException("Unknown metrics page view type: $viewType") } @@ -115,6 +132,10 @@ class MetricsCarouselAdapter( is MetricsPage.NetworkChart -> { networkChartRenderer.attach((holder as PageViewHolder.NetworkChart).chart) } + + is MetricsPage.PowerChart -> { + powerChartRenderer.attach((holder as PageViewHolder.PowerChart).chart) + } } } @@ -125,11 +146,13 @@ class MetricsCarouselAdapter( when (holder) { is PageViewHolder.MemoryChart -> memoryChartRenderer.detachIfAttached(holder.chart) is PageViewHolder.NetworkChart -> networkChartRenderer.detachIfAttached(holder.chart) + is PageViewHolder.PowerChart -> powerChartRenderer.detachIfAttached(holder.chart) } } private companion object { const val VIEW_TYPE_MEMORY_CHART = 0 const val VIEW_TYPE_NETWORK_CHART = 1 + const val VIEW_TYPE_POWER_CHART = 2 } } 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..cc63c6e114 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.ui import android.widget.Toast import androidx.annotation.UiThread +import androidx.core.view.isVisible import androidx.viewpager2.widget.ViewPager2 import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider import com.itsaky.androidide.databinding.LayoutMemUsageBinding @@ -30,6 +31,7 @@ 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 com.itsaky.androidide.utils.PowerUsageWatcher /** * Drives one metrics carousel: its pages, its renderers, and the title that names the current page. @@ -48,6 +50,7 @@ import com.itsaky.androidide.utils.NetworkUsageWatcher class MetricsCarouselController( private val memoryUsageWatcher: MemoryUsageWatcher, private val networkUsageWatcher: NetworkUsageWatcher, + private val powerUsageWatcher: PowerUsageWatcher, lineColorFor: (MemoryUsageWatcher.ProcessMemoryInfo) -> Int, annotations: MetricsAnnotationStore? = null, ) { @@ -72,8 +75,23 @@ class MetricsCarouselController( // (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), + MetricsPage.PowerChart(title = string.metrics_title_power), ) + private val powerRenderer = + PowerUsageChartRenderer( + usageProvider = { powerUsageWatcher.getUsage() }, + batteryProvider = { powerUsageWatcher.latestBattery }, + annotations = annotations, + sampleIntervalMillis = { powerUsageWatcher.updateInterval }, + ) + + private val powerListener = + PowerUsageWatcher.PowerUsageListener { usage -> + powerRenderer.onUsageChanged(usage) + updateBatteryReadout() + } + private val memoryListener = MemoryUsageWatcher.MemoryUsageListener { memoryUsage -> memoryRenderer.onUsagesChanged(memoryUsage) @@ -101,7 +119,7 @@ class MetricsCarouselController( fun bind(binding: LayoutMemUsageBinding) { this.binding = binding - binding.metricsPager.adapter = MetricsCarouselAdapter(pages, memoryRenderer, networkRenderer) + binding.metricsPager.adapter = MetricsCarouselAdapter(pages, memoryRenderer, networkRenderer, powerRenderer) val showTitleFor = { position: Int -> pages.getOrNull(position)?.let { page -> @@ -114,9 +132,11 @@ class MetricsCarouselController( override fun onPageSelected(position: Int) { showTitleFor(position) updateArrows(position) + updateBatteryReadout() // A page left zoomed would keep claiming horizontal drags when swiped back to. memoryRenderer.resetZoom() networkRenderer.resetZoom() + powerRenderer.resetZoom() } }.also { binding.metricsPager.registerOnPageChangeCallback(it) } @@ -132,6 +152,9 @@ class MetricsCarouselController( memoryRenderer.onXAxisTap = { showSamplingRateDialog() } networkRenderer.onXAxisTap = { showSamplingRateDialog() } + powerRenderer.onXAxisTap = { showSamplingRateDialog() } + + updateBatteryReadout() // 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. @@ -146,6 +169,7 @@ class MetricsCarouselController( memoryUsageWatcher.listener = memoryListener networkUsageWatcher.listener = networkListener + powerUsageWatcher.listener = powerListener } /** @@ -160,9 +184,13 @@ class MetricsCarouselController( if (networkUsageWatcher.listener === networkListener) { networkUsageWatcher.listener = null } + if (powerUsageWatcher.listener === powerListener) { + powerUsageWatcher.listener = null + } memoryRenderer.onXAxisTap = null networkRenderer.onXAxisTap = null + powerRenderer.onXAxisTap = null binding?.metricsSnapshot?.setOnClickListener(null) binding?.metricsPrevious?.setOnClickListener(null) binding?.metricsNext?.setOnClickListener(null) @@ -172,6 +200,7 @@ class MetricsCarouselController( binding?.metricsPager?.adapter = null memoryRenderer.detach() networkRenderer.detach() + powerRenderer.detach() binding = null } @@ -199,6 +228,22 @@ class MetricsCarouselController( binding.metricsNext.alpha = if (position < pages.lastIndex) 1f else DISABLED_ARROW_ALPHA } + /** + * Shows the battery level beside the power chart, and nowhere else (ADFA-5499). + * + * It is a readout rather than a plotted series because the level moves about a percent every few + * minutes: over the chart's window a line would be flat, spending an axis on a constant. + */ + @UiThread + private fun updateBatteryReadout() { + val binding = this.binding ?: return + val onPowerPage = pages.getOrNull(binding.metricsPager.currentItem) is MetricsPage.PowerChart + val readout = if (onPowerPage) powerRenderer.batteryReadout() else null + + binding.metricsBattery.text = readout.orEmpty() + binding.metricsBattery.isVisible = readout != null + } + /** * The renderer behind the page currently on screen, or `null` when nothing is bound. */ @@ -207,6 +252,7 @@ class MetricsCarouselController( return when (pages.getOrNull(binding.metricsPager.currentItem)) { is MetricsPage.MemoryChart -> memoryRenderer is MetricsPage.NetworkChart -> networkRenderer + is MetricsPage.PowerChart -> powerRenderer null -> null } } @@ -263,6 +309,7 @@ class MetricsCarouselController( private fun setSamplingInterval(intervalMillis: Long) { memoryUsageWatcher.updateInterval = intervalMillis networkUsageWatcher.updateInterval = intervalMillis + powerUsageWatcher.updateInterval = intervalMillis refresh() } @@ -289,6 +336,7 @@ class MetricsCarouselController( when (page) { is MetricsPage.MemoryChart -> memoryRenderer is MetricsPage.NetworkChart -> networkRenderer + is MetricsPage.PowerChart -> powerRenderer } val label = context.getString(page.title) @@ -316,6 +364,7 @@ class MetricsCarouselController( fun refresh() { memoryRenderer.rebuild() networkRenderer.rebuild() + powerRenderer.rebuild() } /** diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt new file mode 100644 index 0000000000..09fb52d14b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -0,0 +1,260 @@ +/* + * 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.graphics.Color +import androidx.annotation.UiThread +import androidx.core.graphics.ColorUtils +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.LineDataSet +import com.github.mikephil.charting.formatter.IAxisValueFormatter +import com.itsaky.androidide.R +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.PowerUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher.PowerUsage +import com.itsaky.androidide.utils.resolveAttr +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * Renders [PowerUsageWatcher] samples: battery temperature against power draw (ADFA-5499). + * + * The only page with two value axes. Degrees and milliwatts differ in unit and by orders of + * magnitude, so temperature takes the left axis and power the right. Both series therefore have to + * declare which axis they belong to -- a dataset left on the default would be drawn against an axis + * whose labels do not describe it, which is a bug this codebase has already shipped once. + * + * Thermal throttling is shown as background shading rather than as a line: the platform reports an + * ordinal level, not a temperature, so plotting it against degrees would invent a scale. The level + * is sampled alongside the readings, so a shaded band is simply a run of equal levels. + */ +class PowerUsageChartRenderer( + private val usageProvider: () -> PowerUsage, + private val batteryProvider: () -> PowerUsageWatcher.BatteryState, + annotations: MetricsAnnotationStore? = null, + sampleIntervalMillis: () -> Long = { PowerUsageWatcher.DEFAULT_UPDATE_INTERVAL }, +) : MetricsChartRenderer( + sampleIntervalMillis = sampleIntervalMillis, + annotations = annotations, + ) { + @UiThread + override fun rebuild() { + val chart = this.chart ?: return + val usage = usageProvider() + val context = chart.context + + val datasets = + arrayOf( + series( + values = usage.temperatureMilliCelsius, + label = context.getString(R.string.metrics_power_temperature), + lineColor = TEMPERATURE_COLOR, + axis = YAxis.AxisDependency.LEFT, + transform = ::milliCelsiusToCelsius, + ), + series( + values = usage.powerMicroWatts, + label = context.getString(R.string.metrics_power_draw), + lineColor = POWER_COLOR, + axis = YAxis.AxisDependency.RIGHT, + transform = ::microWattsToMilliWatts, + ), + ) + + setData(chart, datasets) + applyThermalShading(chart, usage) + } + + /** + * Redraws from a fresh sample. Rebuilds rather than mutating in place: this chart samples + * relatively slowly and has two short series, so the saving is not worth a second code path + * that can disagree with the first. + */ + @UiThread + fun onUsageChanged(usage: PowerUsage) { + chart ?: return + rebuild() + } + + /** + * Paints a band behind the chart for each stretch of throttling, deepening with the level. + * + * Unthrottled and unknown stretches are left unpainted: shading everything would say nothing. + */ + private fun applyThermalShading( + chart: SafeLineChart, + usage: PowerUsage, + ) { + val levels = usage.thermalStatus + val spans = mutableListOf() + + var index = 0 + while (index < levels.size) { + val level = levels[index].toInt() + var end = index + while (end + 1 < levels.size && levels[end + 1].toInt() == level) { + end++ + } + + shadeFor(chart, level)?.let { color -> + // Half a sample either side, so each sample covers its own cell: a single-sample + // spike would otherwise have zero width and never be drawn, and two adjacent runs + // would leave a sample-wide gap between them. + spans += SafeLineChart.Span(index - HALF_SAMPLE, end + HALF_SAMPLE, color) + } + index = end + 1 + } + + chart.backgroundSpans = spans + } + + /** + * The shade for a throttling level, or `null` where there is nothing to say. + * + * Alpha rises with severity so the bands read as a gradient of concern rather than as separate + * categories, and stays low enough throughout that the plotted lines remain the foreground. + */ + private fun shadeFor( + chart: SafeLineChart, + level: Int, + ): Int? { + val alpha = + when (level) { + THERMAL_LIGHT -> 24 + THERMAL_MODERATE -> 40 + THERMAL_SEVERE -> 64 + THERMAL_CRITICAL -> 88 + THERMAL_EMERGENCY, THERMAL_SHUTDOWN -> 112 + else -> return null + } + + val base = chart.context.resolveAttr(R.attr.colorError) + return ColorUtils.setAlphaComponent(base, alpha) + } + + private fun series( + values: LongArray, + label: String, + lineColor: Int, + axis: YAxis.AxisDependency, + transform: (Long) -> Float, + ): LineDataSet = + LineDataSet( + values.mapIndexed { index, value -> Entry(index.toFloat(), transform(value)) }, + label, + ).apply { + axisDependency = axis + color = lineColor + setDrawIcons(false) + setDrawCircles(false) + setDrawCircleHole(false) + setDrawValues(false) + formLineWidth = 1f + formSize = 15f + isHighlightEnabled = false + this.label = labelFor(label, values.lastOrNull(), axis) + } + + private fun labelFor( + label: String, + latest: Long?, + axis: YAxis.AxisDependency, + ): String { + val value = latest ?: PowerUsageWatcher.UNAVAILABLE + if (value == PowerUsageWatcher.UNAVAILABLE) { + return "%s - n/a".format(label) + } + + return if (axis == YAxis.AxisDependency.LEFT) { + "%s - %.1fC".format(label, milliCelsiusToCelsius(value)) + } else { + "%s - %.0fmW".format(label, milliWattsMagnitude(value)) + } + } + + override fun configure(chart: SafeLineChart) { + super.configure(chart) + + // Two units, two axes: the base class disables the left one because every other page has a + // single series family. + chart.axisLeft.isEnabled = true + chart.axisLeft.valueFormatter = + object : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = "%dC".format(value.roundToLong()) + } + + chart.axisRight.valueFormatter = + object : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = "%dmW".format(value.roundToLong()) + } + } + + /** + * The battery line for the legend, or `null` while charging. + * + * Level is a readout rather than a series because it moves about a percent every few minutes: + * over the chart's window a plotted line would be flat, spending an axis on a constant. It is + * hidden while charging, when a rising level would contradict a chart about power being spent. + */ + @UiThread + fun batteryReadout(): String? { + val battery = batteryProvider() + if (battery.isCharging || battery.levelPercent < 0) { + return null + } + return "%d%%".format(battery.levelPercent) + } + + private companion object { + val TEMPERATURE_COLOR = Color.rgb(255, 138, 101) + val POWER_COLOR = Color.rgb(129, 212, 250) + + /** Half the x-axis width of one sample, which is 1 because x values are sample indices. */ + const val HALF_SAMPLE = 0.5f + + const val THERMAL_LIGHT = 1 + const val THERMAL_MODERATE = 2 + const val THERMAL_SEVERE = 3 + const val THERMAL_CRITICAL = 4 + const val THERMAL_EMERGENCY = 5 + const val THERMAL_SHUTDOWN = 6 + } +} + +/** + * An unavailable reading plots at zero rather than breaking the line. + */ +private fun milliCelsiusToCelsius(milliCelsius: Long): Float = + if (milliCelsius == PowerUsageWatcher.UNAVAILABLE) 0f else milliCelsius / 1000f + +/** + * Power is plotted as a magnitude. The battery current reverses while charging, and a line that + * dips below zero would read as the device spending negative power. + */ +private fun microWattsToMilliWatts(microWatts: Long): Float = + if (microWatts == PowerUsageWatcher.UNAVAILABLE) 0f else abs(microWatts) / 1000f + +private fun milliWattsMagnitude(microWatts: Long): Float = abs(microWatts) / 1000f diff --git a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt index 3eda88b076..3f93c67ba3 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt @@ -19,8 +19,10 @@ package com.itsaky.androidide.ui import android.content.Context import android.graphics.Canvas +import android.graphics.Paint import android.util.AttributeSet import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.YAxis import org.slf4j.LoggerFactory /** @@ -53,6 +55,66 @@ class SafeLineChart : LineChart { private var skippedFrames = 0L + /** + * Bands painted behind the data, in x-value coordinates (ADFA-5499's thermal shading). + * + * Drawn here rather than by the caller because the chart owns the transformer that maps an + * x value to a pixel, and that mapping changes with every zoom, pan and layout. + */ + var backgroundSpans: List = emptyList() + set(value) { + field = value + invalidate() + } + + /** + * A shaded range of the x axis. + * + * @property startX First x value covered, inclusive. + * @property endX Last x value covered, inclusive. + * @property color Fill colour, expected to carry its own alpha. + */ + data class Span( + val startX: Float, + val endX: Float, + val color: Int, + ) + + private val spanPaint = Paint(Paint.ANTI_ALIAS_FLAG) + + /** + * Draws the spans immediately after the grid background, which is an opaque fill of the plot: a + * span painted before [onDraw] delegates upwards is covered by it and never reaches the screen. + * Landing here also puts the shading under the grid lines and the data, where it belongs. + */ + override fun drawGridBackground(canvas: Canvas) { + super.drawGridBackground(canvas) + drawBackgroundSpans(canvas) + } + + private fun drawBackgroundSpans(canvas: Canvas) { + if (backgroundSpans.isEmpty()) { + return + } + + val content = viewPortHandler.contentRect + val transformer = getTransformer(YAxis.AxisDependency.LEFT) ?: return + + backgroundSpans.forEach { span -> + val left = transformer.getPixelForValues(span.startX, 0f).x.toFloat() + val right = transformer.getPixelForValues(span.endX, 0f).x.toFloat() + // A span scrolled out of view still maps to a pixel, so clip to the plot. + val clippedLeft = left.coerceAtLeast(content.left) + val clippedRight = right.coerceAtMost(content.right) + if (clippedRight <= clippedLeft) { + return@forEach + } + + spanPaint.color = span.color + canvas.drawRect(clippedLeft, content.top, clippedRight, content.bottom, spanPaint) + } + } + override fun onDraw(canvas: Canvas) { try { super.onDraw(canvas) diff --git a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt new file mode 100644 index 0000000000..882d7de1b3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -0,0 +1,139 @@ +/* + * 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.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import android.os.Build +import android.os.PowerManager +import androidx.core.content.getSystemService +import com.itsaky.androidide.services.builder.ThermalInfo +import com.itsaky.androidide.services.builder.ThermalState +import com.itsaky.androidide.utils.PowerUsageWatcher.BatteryState +import com.itsaky.androidide.utils.PowerUsageWatcher.PowerReading + +/** + * Reads temperature and power from the battery, which is all a normally-installed app can see + * (ADFA-5499). + * + * `ACTION_BATTERY_CHANGED` is a sticky broadcast, so the current values can be read on demand with a + * null receiver rather than by registering one and waiting -- which suits being polled on the + * sampling tick. + * + * Not read here, deliberately: the per-zone CPU, GPU and skin temperatures from + * `HardwarePropertiesManager`. Those need `android.permission.DEVICE_POWER`, which is signature + * level and cannot be granted to an installed app, so there is nothing to ask for and no fallback + * worth attempting. A privileged build would supply a different `PowerSource`. + */ +class DevicePowerSource( + private val context: Context, +) : PowerUsageWatcher.PowerSource { + private val batteryManager = context.getSystemService() + private val powerManager = context.getSystemService() + + override fun read(): PowerReading { + val battery = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + + return PowerReading( + temperatureMilliCelsius = readTemperature(battery), + powerMicroWatts = readPower(battery), + thermalStatus = readThermalStatus(), + battery = readBatteryState(battery), + ) + } + + /** + * Battery temperature. The broadcast reports tenths of a degree, which is coarser than the + * millidegrees stored, but storing the finer unit keeps the arithmetic honest if a privileged + * source ever supplies something better. + */ + private fun readTemperature(battery: Intent?): Long { + val tenthsCelsius = battery?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE) + if (tenthsCelsius == null || tenthsCelsius == Int.MIN_VALUE) { + return PowerUsageWatcher.UNAVAILABLE + } + return tenthsCelsius.toLong() * 100L + } + + /** + * Instantaneous draw, from current and voltage. + * + * Microamps times millivolts is nanowatts, so the product is scaled down to microwatts. The sign + * follows the battery current: negative while charging, because current is then flowing in. + */ + private fun readPower(battery: Intent?): Long { + val microAmps = batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) + val milliVolts = battery?.getIntExtra(BatteryManager.EXTRA_VOLTAGE, Int.MIN_VALUE) + + if (microAmps == null || microAmps == Int.MIN_VALUE || + milliVolts == null || milliVolts <= 0 + ) { + return PowerUsageWatcher.UNAVAILABLE + } + + return microAmps.toLong() * milliVolts.toLong() / NANOWATTS_PER_MICROWATT + } + + /** + * The platform's throttling level, which is what the chart shades by. + * + * Only API 29 and above report a graded level. Below that [ThermalInfo] can still say whether + * the device is throttled at all, which gives one shade instead of several. + */ + private fun readThermalStatus(): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val status = runCatching { powerManager?.currentThermalStatus }.getOrNull() + if (status != null) { + return status + } + } + + return when (ThermalInfo.getThermalState(context)) { + ThermalState.Throttled -> PowerManager.THERMAL_STATUS_SEVERE + ThermalState.NotThrottled -> PowerManager.THERMAL_STATUS_NONE + else -> PowerUsageWatcher.THERMAL_UNKNOWN + } + } + + private fun readBatteryState(battery: Intent?): BatteryState { + battery ?: return BatteryState.UNKNOWN + + val level = battery.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = battery.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + val status = battery.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) + + val percent = + if (level < 0 || scale <= 0) { + -1 + } else { + level * 100 / scale + } + + return BatteryState( + levelPercent = percent, + isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL, + ) + } + + private companion object { + /** Microamps times millivolts gives nanowatts; this scales the product to microwatts. */ + const val NANOWATTS_PER_MICROWATT = 1_000L + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt new file mode 100644 index 0000000000..7c1e8b987a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -0,0 +1,281 @@ +/* + * 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 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 +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext + +/** + * Samples the device's temperature and power draw (ADFA-5499). + * + * What a normally-installed app can read is narrower than it sounds. Battery temperature and the + * current/voltage pair behind power come from the battery, free of any permission. The per-zone CPU + * and skin temperatures the platform itself can see need `android.permission.DEVICE_POWER`, which is + * signature-level and cannot be granted to an installed app at all -- hence [PowerSource], so a + * privileged build could supply better readings without the chart changing. + * + * Power is instantaneous rather than cumulative: a running total only ever rises and says nothing + * about which piece of work cost anything, whereas power lines up with the spikes on the memory and + * network pages. + * + * @param updateInterval Milliseconds between samples. + * @param source Where readings come from. Injectable so tests need no device. + */ +class PowerUsageWatcher + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + constructor( + updateInterval: Long = DEFAULT_UPDATE_INTERVAL, + private val source: PowerSource, + private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("PowerUsageWatcher"), + private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, + ) { + 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 + + /** Guards the ring buffers: the sampler writes them, the UI thread snapshots them. */ + private val historyLock = Any() + + private val temperature = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + private val power = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + + /** + * The thermal throttling level at each sample, or [THERMAL_UNKNOWN]. + * + * Kept per sample rather than as a separate timestamped log so the chart's shading lines up + * with the sample grid exactly: a shaded span is just a run of equal values here. + */ + private val thermal = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + + /** + * 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() + } + + /** The most recent battery reading, for the chart's legend. */ + @Volatile + var latestBattery: BatteryState = BatteryState.UNKNOWN + private set + + val isWatching: Boolean + get() = watching.get() + + /** Notified on the main thread after each sample. */ + var listener: PowerUsageListener? = null + + /** + * A snapshot of the sampled history, oldest first. The arrays are copies; handing out the + * live ring buffers would let a reader see them mid-append. + */ + fun getUsage(): PowerUsage = + synchronized(historyLock) { + PowerUsage(temperature.snapshotArray(), power.snapshotArray(), thermal.snapshotArray()) + } + + fun clearHistory() { + synchronized(historyLock) { + temperature.clear() + power.clear() + thermal.clear() + } + } + + fun startWatching() { + if (!watching.compareAndSet(false, true)) { + log.warn("Power usage is already being watched") + return + } + + samplingJob = + coroutineScope.launch { + while (isWatching) { + runCatching { + sampleOnce() + + listener?.also { listener -> + val usage = getUsage() + withContext(mainDispatcher) { + listener.onPowerUsageChanged(usage) + } + } + }.onFailure { failure -> + if (failure is CancellationException) { + throw failure + } + log.error("Power usage sampling failed; continuing", failure) + } + + delay(updateInterval) + } + } + } + + fun stopWatching() { + watching.set(false) + samplingJob?.cancel() + samplingJob = null + } + + /** Stops sampling and releases the sampling thread. The watcher cannot be started again. */ + fun close() { + stopWatching() + listener = null + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() + } + + /** + * Takes one sample. The loop calls this once per [updateInterval]; tests call it directly. + */ + @VisibleForTesting + internal fun sampleOnce() { + val reading = source.read() + latestBattery = reading.battery + + synchronized(historyLock) { + append(temperature, reading.temperatureMilliCelsius) + append(power, reading.powerMicroWatts) + append(thermal, reading.thermalStatus.toLong()) + } + } + + private fun append( + history: MutableShiftedLongArray, + value: Long, + ) { + // Newest entry goes in at index 0 and the shift makes it the last element, matching + // MemoryUsageWatcher and NetworkUsageWatcher. + history[0] = value + history.shift(1) + } + + /** + * One sample's worth of readings. + * + * @property temperatureMilliCelsius Battery temperature, or [UNAVAILABLE]. + * @property powerMicroWatts Instantaneous draw, or [UNAVAILABLE]. Negative while charging, + * because the battery current reverses. + * @property thermalStatus The platform throttling level, or [THERMAL_UNKNOWN]. + * @property battery Level and charging state, for the legend. + */ + data class PowerReading( + val temperatureMilliCelsius: Long, + val powerMicroWatts: Long, + val thermalStatus: Int, + val battery: BatteryState, + ) + + /** + * @property levelPercent Charge remaining, or -1 if unknown. + * @property isCharging Whether the battery is being charged. + */ + data class BatteryState( + val levelPercent: Int, + val isCharging: Boolean, + ) { + companion object { + val UNKNOWN = BatteryState(levelPercent = -1, isCharging = false) + } + } + + /** + * Where readings come from. An interface because the best available source depends on how + * the app is installed: a privileged build can read per-zone temperatures that an installed + * one cannot. + */ + fun interface PowerSource { + fun read(): PowerReading + } + + /** + * Sampled history, oldest first. + * + * @property temperatureMilliCelsius Battery temperature per sample. + * @property powerMicroWatts Instantaneous draw per sample. + * @property thermalStatus Throttling level per sample, for the chart's shading. + */ + data class PowerUsage( + val temperatureMilliCelsius: LongArray, + val powerMicroWatts: LongArray, + val thermalStatus: LongArray, + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is PowerUsage && + temperatureMilliCelsius.contentEquals(other.temperatureMilliCelsius) && + powerMicroWatts.contentEquals(other.powerMicroWatts) && + thermalStatus.contentEquals(other.thermalStatus) + ) + + override fun hashCode(): Int { + var result = temperatureMilliCelsius.contentHashCode() + result = 31 * result + powerMicroWatts.contentHashCode() + result = 31 * result + thermalStatus.contentHashCode() + return result + } + } + + fun interface PowerUsageListener { + fun onPowerUsageChanged(usage: PowerUsage) + } + + companion object { + /** Samples retained per series, matching the other watchers. */ + const val MAX_USAGE_ENTRIES = 10000 + const val DEFAULT_UPDATE_INTERVAL = 1000L + + /** A reading the device does not provide. */ + const val UNAVAILABLE = Long.MIN_VALUE + + /** No throttling level could be read -- an API 28 device, or the call failed. */ + const val THERMAL_UNKNOWN = -1 + + private val log = LoggerFactory.getLogger(PowerUsageWatcher::class.java) + } + } + +/** + * Copies this ring buffer into a plain array in logical order, oldest first. + */ +private fun ShiftedLongArray.snapshotArray(): LongArray = LongArray(size) { this[it] } 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 bcf61bd48d..2400fe4cc5 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt @@ -17,10 +17,13 @@ package com.itsaky.androidide.viewmodel -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import com.itsaky.androidide.utils.DevicePowerSource import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher /** * Owns the sample history behind the editor's metrics carousel. @@ -34,11 +37,19 @@ import com.itsaky.androidide.utils.NetworkUsageWatcher * This survives configuration changes and activity recreation. It does not survive the process being * killed -- see ADFA-5494. */ -class MetricsViewModel : ViewModel() { +class MetricsViewModel( + application: Application, +) : AndroidViewModel(application) { val memoryUsageWatcher = MemoryUsageWatcher() val networkUsageWatcher = NetworkUsageWatcher() + /** + * Temperature and power (ADFA-5499). Needs a Context for the battery broadcast, which is why + * this is an AndroidViewModel. + */ + val powerUsageWatcher = PowerUsageWatcher(source = DevicePowerSource(application)) + /** Significant events for the charts to annotate (ADFA-5486). */ val annotations = MetricsAnnotationStore() @@ -48,5 +59,6 @@ class MetricsViewModel : ViewModel() { // dedicated sampling thread that newSingleThreadContext keeps alive until it is closed. memoryUsageWatcher.close() networkUsageWatcher.close() + powerUsageWatcher.close() } } diff --git a/app/src/main/res/layout/item_metrics_power_chart.xml b/app/src/main/res/layout/item_metrics_power_chart.xml new file mode 100644 index 0000000000..aa319feba1 --- /dev/null +++ b/app/src/main/res/layout/item_metrics_power_chart.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index 5190312432..f267ae6466 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -88,6 +88,21 @@ app:layout_constraintBottom_toBottomOf="@id/metrics_pager" app:layout_constraintEnd_toEndOf="@id/metrics_pager" /> + + + diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt new file mode 100644 index 0000000000..d34ff1081a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -0,0 +1,255 @@ +/* + * 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 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.PowerUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher.BatteryState +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the three decisions ADFA-5499 was scoped around: temperature and power get an axis each + * because they share no unit, throttling is shaded rather than plotted because the platform reports + * an ordinal and not a temperature, and the battery level is hidden while charging. + */ +@RunWith(RobolectricTestRunner::class) +class PowerUsageChartRendererTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun usage( + temperature: LongArray, + power: LongArray = LongArray(temperature.size), + thermal: LongArray = LongArray(temperature.size), + ) = PowerUsageWatcher.PowerUsage(temperature, power, thermal) + + private fun rendererFor( + usage: PowerUsageWatcher.PowerUsage, + battery: BatteryState = BatteryState(levelPercent = 80, isCharging = false), + ): Pair { + val chart = SafeLineChart(context) + val renderer = + PowerUsageChartRenderer( + usageProvider = { usage }, + batteryProvider = { battery }, + ) + renderer.attach(chart) + return renderer to chart + } + + private fun dataset( + chart: SafeLineChart, + index: Int, + ) = chart.data.getDataSetByIndex(index) as LineDataSet + + @Test + fun `temperature and power are plotted against separate axes`() { + val (_, chart) = + rendererFor( + usage( + temperature = longArrayOf(30_000L, 31_000L), + power = longArrayOf(1_000_000L, 4_000_000L), + ), + ) + + assertThat(chart.data.dataSetCount).isEqualTo(2) + // Degrees and milliwatts differ by orders of magnitude; a series left on the default axis + // would be drawn against labels that do not describe it. + assertThat(dataset(chart, 0).axisDependency).isEqualTo(YAxis.AxisDependency.LEFT) + assertThat(dataset(chart, 1).axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + assertThat(chart.axisLeft.isEnabled).isTrue() + assertThat(chart.axisRight.isEnabled).isTrue() + } + + @Test + fun `temperature is plotted in degrees and power in milliwatts`() { + val (_, chart) = + rendererFor( + usage( + temperature = longArrayOf(29_700L), + power = longArrayOf(6_358_064L), + ), + ) + + assertThat(dataset(chart, 0).entries.last().y).isWithin(0.01f).of(29.7f) + assertThat(dataset(chart, 1).entries.last().y).isWithin(0.01f).of(6358.064f) + } + + @Test + fun `power is plotted as a magnitude, so charging does not dip below zero`() { + val (_, chart) = + rendererFor( + usage( + temperature = longArrayOf(30_000L, 30_000L), + // The battery current reverses while charging. + power = longArrayOf(2_000_000L, -3_000_000L), + ), + ) + + val ys = dataset(chart, 1).entries.map { it.y } + + assertThat(ys).containsExactly(2000f, 3000f).inOrder() + assertThat(ys.none { it < 0f }).isTrue() + } + + @Test + fun `an unavailable reading plots at zero rather than at Long MIN_VALUE`() { + val (_, chart) = + rendererFor( + usage( + temperature = longArrayOf(PowerUsageWatcher.UNAVAILABLE, 30_000L), + power = longArrayOf(PowerUsageWatcher.UNAVAILABLE, 1_000_000L), + ), + ) + + // Plotted as MIN_VALUE the point would put the axis range into the billions and flatten + // every real reading onto one line. + assertThat(dataset(chart, 0).entries.first().y).isEqualTo(0f) + assertThat(dataset(chart, 1).entries.first().y).isEqualTo(0f) + } + + @Test + fun `the legend says n slash a for a reading the device does not provide`() { + val (_, chart) = + rendererFor( + usage( + temperature = longArrayOf(PowerUsageWatcher.UNAVAILABLE), + power = longArrayOf(PowerUsageWatcher.UNAVAILABLE), + ), + ) + + assertThat(dataset(chart, 0).label).endsWith("n/a") + assertThat(dataset(chart, 1).label).endsWith("n/a") + } + + @Test + fun `a run of one throttling level becomes one shaded span`() { + val (_, chart) = + rendererFor( + usage( + temperature = LongArray(6) { 30_000L }, + thermal = longArrayOf(0L, 0L, 2L, 2L, 2L, 0L), + ), + ) + + assertThat(chart.backgroundSpans).hasSize(1) + val span = chart.backgroundSpans.single() + // Samples 2..4, each covering its own cell rather than just its centre point. + assertThat(span.startX).isEqualTo(1.5f) + assertThat(span.endX).isEqualTo(4.5f) + } + + @Test + fun `a single throttled sample still gets a span with width`() { + val (_, chart) = + rendererFor( + usage( + temperature = LongArray(3) { 30_000L }, + thermal = longArrayOf(0L, 3L, 0L), + ), + ) + + // Drawn from centre to centre this span would be zero pixels wide and never appear. + val span = chart.backgroundSpans.single() + assertThat(span.endX - span.startX).isEqualTo(1f) + } + + @Test + fun `adjacent runs leave no unshaded gap between them`() { + val (_, chart) = + rendererFor( + usage( + temperature = LongArray(4) { 30_000L }, + thermal = longArrayOf(1L, 1L, 3L, 3L), + ), + ) + + val (first, second) = chart.backgroundSpans + assertThat(first.endX).isEqualTo(second.startX) + } + + @Test + fun `adjacent levels shade separately, and deeper for the worse one`() { + val (_, chart) = + rendererFor( + usage( + temperature = LongArray(4) { 30_000L }, + thermal = longArrayOf(1L, 1L, 4L, 4L), + ), + ) + + assertThat(chart.backgroundSpans).hasSize(2) + val (light, critical) = chart.backgroundSpans + // The bands read as a gradient of concern rather than as unrelated categories. + assertThat(critical.color ushr 24).isGreaterThan(light.color ushr 24) + } + + @Test + fun `no shading where there is nothing to say`() { + val (_, chart) = + rendererFor( + usage( + temperature = LongArray(4) { 30_000L }, + // Not throttled, then a device that reports no level at all. + thermal = longArrayOf(0L, 0L, -1L, -1L), + ), + ) + + // Shading everything would say nothing. + assertThat(chart.backgroundSpans).isEmpty() + } + + @Test + fun `the battery readout is hidden while charging`() { + val (charging, _) = + rendererFor( + usage(temperature = longArrayOf(30_000L)), + battery = BatteryState(levelPercent = 62, isCharging = true), + ) + + // A level climbing while the chart is about power being spent reads as a contradiction. + assertThat(charging.batteryReadout()).isNull() + } + + @Test + fun `the battery readout shows the level on battery power`() { + val (renderer, _) = + rendererFor( + usage(temperature = longArrayOf(30_000L)), + battery = BatteryState(levelPercent = 62, isCharging = false), + ) + + assertThat(renderer.batteryReadout()).isEqualTo("62%") + } + + @Test + fun `an unknown battery level shows nothing rather than a negative percentage`() { + val (renderer, _) = + rendererFor( + usage(temperature = longArrayOf(30_000L)), + battery = BatteryState.UNKNOWN, + ) + + assertThat(renderer.batteryReadout()).isNull() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt b/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt new file mode 100644 index 0000000000..f7cf75f39b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt @@ -0,0 +1,188 @@ +/* + * 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.utils.PowerUsageWatcher.BatteryState +import com.itsaky.androidide.utils.PowerUsageWatcher.PowerReading +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins what ADFA-5499 records per sample: temperature, instantaneous power and the throttling + * level land on one shared sample grid, and a reading the device does not provide stays + * distinguishable from a real zero. + * + * These drive [PowerUsageWatcher.sampleOnce] directly rather than starting the sampling loop, so + * there is no waiting and no dependence on thread timing. + */ +@RunWith(RobolectricTestRunner::class) +class PowerUsageWatcherTest { + /** Every watcher built here, so the sampling threads they allocate are released. */ + private val created = mutableListOf() + + @After + fun tearDown() { + created.forEach { it.close() } + created.clear() + } + + /** A watcher fed a scripted sequence of readings, advancing one step per sample. */ + private inner class Fixture( + private val readings: List, + ) { + private var index = -1 + + val watcher = + PowerUsageWatcher( + source = { readings[index.coerceIn(0, readings.lastIndex)] }, + ).also { created += it } + + fun sample(count: Int) { + repeat(count) { + index++ + watcher.sampleOnce() + } + } + } + + private fun reading( + temperature: Long = 30_000L, + power: Long = 1_000_000L, + thermal: Int = 0, + battery: BatteryState = BatteryState(levelPercent = 80, isCharging = false), + ) = PowerReading(temperature, power, thermal, battery) + + private fun LongArray.recent(count: Int): List = takeLast(count) + + @Test + fun `history is all zeros before the first sample`() { + val fixture = Fixture(listOf(reading())) + + val usage = fixture.watcher.getUsage() + + assertThat(usage.temperatureMilliCelsius).hasLength(PowerUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(usage.powerMicroWatts.sum()).isEqualTo(0L) + assertThat(usage.thermalStatus.sum()).isEqualTo(0L) + } + + @Test + fun `records temperature, power and throttling level on one sample grid`() { + val fixture = + Fixture( + listOf( + reading(temperature = 30_000L, power = 1_000_000L, thermal = 0), + reading(temperature = 31_500L, power = 4_500_000L, thermal = 2), + reading(temperature = 32_000L, power = 2_250_000L, thermal = 2), + ), + ) + + fixture.sample(3) + val usage = fixture.watcher.getUsage() + + // Index n of each array is the same instant, which is what lets the chart shade a run of + // equal levels by sample index rather than by a separate timeline. + assertThat(usage.temperatureMilliCelsius.recent(3)).containsExactly(30_000L, 31_500L, 32_000L).inOrder() + assertThat(usage.powerMicroWatts.recent(3)).containsExactly(1_000_000L, 4_500_000L, 2_250_000L).inOrder() + assertThat(usage.thermalStatus.recent(3)).containsExactly(0L, 2L, 2L).inOrder() + } + + @Test + fun `an unavailable reading is recorded as unavailable, not as zero`() { + val fixture = Fixture(listOf(reading(temperature = PowerUsageWatcher.UNAVAILABLE, power = PowerUsageWatcher.UNAVAILABLE))) + + fixture.sample(1) + val usage = fixture.watcher.getUsage() + + // A device with no readable current would otherwise plot a flat, believable 0 mW. + assertThat(usage.temperatureMilliCelsius.last()).isEqualTo(PowerUsageWatcher.UNAVAILABLE) + assertThat(usage.powerMicroWatts.last()).isEqualTo(PowerUsageWatcher.UNAVAILABLE) + } + + @Test + fun `negative power is kept as recorded, because charging reverses the current`() { + val fixture = Fixture(listOf(reading(power = -3_000_000L))) + + fixture.sample(1) + + // The watcher records the sign; deciding how to plot it is the renderer's job. + assertThat( + fixture.watcher + .getUsage() + .powerMicroWatts + .last(), + ).isEqualTo(-3_000_000L) + } + + @Test + fun `the latest battery state is exposed for the legend`() { + val fixture = + Fixture( + listOf( + reading(battery = BatteryState(levelPercent = 80, isCharging = false)), + reading(battery = BatteryState(levelPercent = 79, isCharging = true)), + ), + ) + + fixture.sample(2) + + assertThat(fixture.watcher.latestBattery).isEqualTo(BatteryState(levelPercent = 79, isCharging = true)) + } + + @Test + fun `the ring buffer keeps only the most recent samples`() { + val capacity = PowerUsageWatcher.MAX_USAGE_ENTRIES + val readings = List(capacity + 2) { reading(temperature = it.toLong()) } + val fixture = Fixture(readings) + + fixture.sample(readings.size) + val usage = fixture.watcher.getUsage() + + assertThat(usage.temperatureMilliCelsius).hasLength(capacity) + assertThat(usage.temperatureMilliCelsius.last()).isEqualTo((readings.size - 1).toLong()) + assertThat(usage.temperatureMilliCelsius.first()).isEqualTo(2L) + } + + @Test + fun `changing the sampling interval clears the history`() { + val fixture = Fixture(listOf(reading())) + fixture.sample(5) + + fixture.watcher.updateInterval = 5_000L + + // Samples taken at two rates in one buffer would misdate the older ones. + val usage = fixture.watcher.getUsage() + assertThat(usage.temperatureMilliCelsius.sum()).isEqualTo(0L) + assertThat(usage.powerMicroWatts.sum()).isEqualTo(0L) + } + + @Test + fun `getUsage returns a copy, not the live buffer`() { + val fixture = Fixture(listOf(reading(temperature = 30_000L), reading(temperature = 40_000L))) + + fixture.sample(1) + val first = fixture.watcher.getUsage() + val asHandedOut = first.temperatureMilliCelsius.copyOf() + fixture.sample(1) + + assertThat(first.temperatureMilliCelsius).isEqualTo(asHandedOut) + assertThat(fixture.watcher.getUsage().temperatureMilliCelsius).isNotEqualTo(asHandedOut) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index bdba956b6b..5a4a57ae9e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1682,6 +1682,10 @@ Sampling rate Every %1$s %1$s (needs a 64-bit device) + Temperature and power + Temperature and power chart + Battery temp + Power Previous metric Next metric Save chart image From 7f34c6e5d3c69066f058f87788d83f7113310c38 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 17:09:23 -0700 Subject: [PATCH 026/128] feat(metrics): colour the throttle bands, label power in watts, stagger annotations Four changes to the temperature and power page (ADFA-5499) and one to the annotations shared by every page (ADFA-5486). Throttle shading is now hue-coded rather than one colour at six depths: green, cyan, yellow, orange, rust, red for levels 1 to 6, at one fixed alpha. Level 0 and an unreadable level stay unshaded. Ranking seven ordinals by depth of a single colour asks the eye to compare shades that are never side by side; the bands are separated in time, so distinct hues stay tellable apart wherever on the chart they fall. The source is unchanged: PowerManager.getCurrentThermalStatus() on API 29+, with ThermalInfo behind it for API 28, which minSdk still admits. The power axis is labelled in whole watts. A build peaks in single-digit watts, so milliwatt labels spent three characters each on trailing zeros. Granularity is pinned to 1 W as well: left to choose its own spacing the axis puts gridlines a fraction of a watt apart on an idle device, and rounding those to whole watts prints the same label several times over. The legend keeps finer units, falling back to milliwatts below a watt, where "0W" would lose the only value it exists to show. Each value axis takes the colour of the line it describes -- orange for temperature on the left, blue for power on the right. With two axes carrying unrelated units, colour is what says which reads which. That last one needed a hook. setData repaints both axes in the surface's text colour on every redraw, so anything a subclass set in configure was overwritten within a frame; it now calls an open styleValueAxes, which the power page overrides. The test caught this -- the same shape as the two defects in the previous commit, and this time it was caught before the device. Annotation labels are staggered across eight rows, cycling. Gradle fires tasks in bursts, so several markers land within a few pixels of each other and their labels, all drawn on one row, overwrote each other into an unreadable smear. The row comes from a new Annotation.sequence, counted from the first annotation of the session, rather than from a position in the visible list: that list shifts as older entries age out, so a label would hop rows while merely sitting still. Nothing covered the drawing of annotations before this, only the store behind them, which is how the smear came to ship. MetricsAnnotationRenderingTest now covers it; its three stagger tests were confirmed to fail with the offset held constant, and the row-stability test to fail when the row is taken from the visible list. Verified on a Pixel 6 Pro against a newly created Compose Activity project, so the Gradle run was long and task-dense: three annotations drawn on three different rows, the right axis reading 0W through 6W, the left axis orange and the right blue, and all six throttle hues distinct under `cmd thermalservice override-status`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsChartRenderer.kt | 42 ++++- .../androidide/ui/PowerUsageChartRenderer.kt | 106 +++++++++--- .../utils/MetricsAnnotationStore.kt | 16 +- .../ui/MetricsAnnotationRenderingTest.kt | 158 ++++++++++++++++++ .../ui/PowerUsageChartRendererTest.kt | 76 +++++++-- .../utils/MetricsAnnotationStoreTest.kt | 39 +++++ 6 files changed, 398 insertions(+), 39 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.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..0254ed9940 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -285,8 +285,6 @@ abstract class MetricsChartRenderer( chart.apply { data = LineData(*datasets) - 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 @@ -295,6 +293,7 @@ abstract class MetricsChartRenderer( xAxis.textColor = textColor data.setValueTextColor(textColor) + styleValueAxes(this, textColor) setBackgroundColor(bgColor) setGridBackgroundColor(bgColor) notifyDataSetChanged() @@ -304,6 +303,22 @@ abstract class MetricsChartRenderer( chart.invalidate() } + /** + * Colours the value axes' labels. Called from [setData], not [configure], because the styling + * here is re-applied on every redraw and would otherwise overwrite whatever a subclass had set + * up once at configuration time. + * + * The default paints both in the surface's text colour, which suits a page whose series all + * share one unit. A page with two unrelated axes overrides this. + */ + protected open fun styleValueAxes( + chart: SafeLineChart, + defaultTextColor: Int, + ) { + chart.axisLeft.textColor = defaultTextColor + chart.axisRight.textColor = defaultTextColor + } + /** * Draws a vertical marker for each recent significant event (ADFA-5486). * @@ -311,6 +326,10 @@ abstract class MetricsChartRenderer( * 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. + * + * Labels are staggered across [ANNOTATION_LABEL_SLOTS] rows. Gradle fires tasks in bursts, so + * several markers land within a few pixels of each other and their labels, all drawn on one + * row, overwrite each other into an unreadable smear. */ private fun applyAnnotations(chart: SafeLineChart) { val store = annotations ?: return @@ -337,11 +356,19 @@ abstract class MetricsChartRenderer( textColor = markerColor enableDashedLine(ANNOTATION_DASH_LENGTH, ANNOTATION_DASH_LENGTH, 0f) labelPosition = LimitLine.LimitLabelPosition.RIGHT_BOTTOM + // Rows are counted up from the bottom of the plot, and the offset is in dp: + // LimitLine converts it on the way in. + yOffset = ANNOTATION_LABEL_ROW_HEIGHT_DP * slotFor(annotation.sequence) }, ) } } + /** + * The row an annotation's label sits on, cycling so that neighbours never share one. + */ + private fun slotFor(sequence: Long): Int = (sequence % ANNOTATION_LABEL_SLOTS).toInt() + /** * Redraws after the attached series have been mutated in place. */ @@ -368,5 +395,16 @@ abstract class MetricsChartRenderer( const val ANNOTATION_LINE_WIDTH = 1f const val ANNOTATION_DASH_LENGTH = 6f + + /** + * Rows the annotation labels cycle through, counted up from the bottom of the plot. + * + * Eight rows at [ANNOTATION_LABEL_ROW_HEIGHT_DP] apiece stay inside the strip's plot area + * while spreading a burst of Gradle tasks far enough apart to read. + */ + const val ANNOTATION_LABEL_SLOTS = 8 + + /** One row, in dp. The label text is 10dp, so this leaves a little air between rows. */ + const val ANNOTATION_LABEL_ROW_HEIGHT_DP = 12f } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index 09fb52d14b..89e090d21d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -29,21 +29,25 @@ import com.itsaky.androidide.R import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.PowerUsageWatcher import com.itsaky.androidide.utils.PowerUsageWatcher.PowerUsage -import com.itsaky.androidide.utils.resolveAttr import kotlin.math.abs import kotlin.math.roundToLong /** * Renders [PowerUsageWatcher] samples: battery temperature against power draw (ADFA-5499). * - * The only page with two value axes. Degrees and milliwatts differ in unit and by orders of - * magnitude, so temperature takes the left axis and power the right. Both series therefore have to - * declare which axis they belong to -- a dataset left on the default would be drawn against an axis - * whose labels do not describe it, which is a bug this codebase has already shipped once. + * The only page with two value axes. Degrees and watts differ in unit and by orders of magnitude, + * so temperature takes the left axis and power the right. Both series therefore have to declare + * which axis they belong to -- a dataset left on the default would be drawn against an axis whose + * labels do not describe it, which is a bug this codebase has already shipped once. Each axis's + * labels are drawn in its series' colour, so which axis reads which line needs no explaining. * * Thermal throttling is shown as background shading rather than as a line: the platform reports an * ordinal level, not a temperature, so plotting it against degrees would invent a scale. The level * is sampled alongside the readings, so a shaded band is simply a run of equal levels. + * + * Severity is carried by hue, green through red, at one fixed alpha. Ranking seven ordinals by + * depth of a single colour asks the eye to compare shades that are never side by side; distinct + * hues stay tellable apart wherever on the chart they fall. */ class PowerUsageChartRenderer( private val usageProvider: () -> PowerUsage, @@ -74,7 +78,7 @@ class PowerUsageChartRenderer( label = context.getString(R.string.metrics_power_draw), lineColor = POWER_COLOR, axis = YAxis.AxisDependency.RIGHT, - transform = ::microWattsToMilliWatts, + transform = ::microWattsToWatts, ), ) @@ -113,7 +117,7 @@ class PowerUsageChartRenderer( end++ } - shadeFor(chart, level)?.let { color -> + shadeFor(level)?.let { color -> // Half a sample either side, so each sample covers its own cell: a single-sample // spike would otherwise have zero width and never be drawn, and two adjacent runs // would leave a sample-wide gap between them. @@ -128,25 +132,23 @@ class PowerUsageChartRenderer( /** * The shade for a throttling level, or `null` where there is nothing to say. * - * Alpha rises with severity so the bands read as a gradient of concern rather than as separate - * categories, and stays low enough throughout that the plotted lines remain the foreground. + * Level 0 is unthrottled and level -1 is a device that reports no level at all; neither is + * shaded, because shading everything would say nothing. The alpha is the same for every level, + * so hue alone ranks them, and low enough throughout that the plotted lines stay the foreground. */ - private fun shadeFor( - chart: SafeLineChart, - level: Int, - ): Int? { - val alpha = + private fun shadeFor(level: Int): Int? { + val hue = when (level) { - THERMAL_LIGHT -> 24 - THERMAL_MODERATE -> 40 - THERMAL_SEVERE -> 64 - THERMAL_CRITICAL -> 88 - THERMAL_EMERGENCY, THERMAL_SHUTDOWN -> 112 + THERMAL_LIGHT -> SHADE_LIGHT + THERMAL_MODERATE -> SHADE_MODERATE + THERMAL_SEVERE -> SHADE_SEVERE + THERMAL_CRITICAL -> SHADE_CRITICAL + THERMAL_EMERGENCY -> SHADE_EMERGENCY + THERMAL_SHUTDOWN -> SHADE_SHUTDOWN else -> return null } - val base = chart.context.resolveAttr(R.attr.colorError) - return ColorUtils.setAlphaComponent(base, alpha) + return ColorUtils.setAlphaComponent(hue, SHADE_ALPHA) } private fun series( @@ -185,7 +187,20 @@ class PowerUsageChartRenderer( return if (axis == YAxis.AxisDependency.LEFT) { "%s - %.1fC".format(label, milliCelsiusToCelsius(value)) } else { - "%s - %.0fmW".format(label, milliWattsMagnitude(value)) + "%s - %s".format(label, formatPower(value)) + } + } + + /** + * The latest draw, for the legend. Below a watt it is given in milliwatts: an idle device would + * otherwise read "0.0W", losing the very value the legend exists to show. + */ + private fun formatPower(microWatts: Long): String { + val watts = wattsMagnitude(microWatts) + return if (watts < 1f) { + "%.0fmW".format(abs(microWatts) / MICROWATTS_PER_MILLIWATT) + } else { + "%.1fW".format(watts) } } @@ -195,6 +210,7 @@ class PowerUsageChartRenderer( // Two units, two axes: the base class disables the left one because every other page has a // single series family. chart.axisLeft.isEnabled = true + chart.axisLeft.valueFormatter = object : IAxisValueFormatter { override fun getFormattedValue( @@ -203,13 +219,33 @@ class PowerUsageChartRenderer( ): String = "%dC".format(value.roundToLong()) } + // Watts, not milliwatts: a build peaks in single digit watts, so mW labels spent three + // characters on trailing zeros. Whole watts, so the labels carry no decimal point either. chart.axisRight.valueFormatter = object : IAxisValueFormatter { override fun getFormattedValue( value: Float, axis: AxisBase?, - ): String = "%dmW".format(value.roundToLong()) + ): String = "%dW".format(value.roundToLong()) } + + // Integer labels need integer gridlines to match. Left to pick its own spacing the axis + // will place lines a fraction of a watt apart on an idle device, and rounding those to + // whole watts prints the same label several times over. + chart.axisRight.granularity = 1f + chart.axisRight.isGranularityEnabled = true + } + + /** + * Each axis's labels take the colour of the line they describe. With two axes carrying + * unrelated units, colour is what says which reads which; one shared text colour cannot. + */ + override fun styleValueAxes( + chart: SafeLineChart, + defaultTextColor: Int, + ) { + chart.axisLeft.textColor = TEMPERATURE_COLOR + chart.axisRight.textColor = POWER_COLOR } /** @@ -235,6 +271,21 @@ class PowerUsageChartRenderer( /** Half the x-axis width of one sample, which is 1 because x values are sample indices. */ const val HALF_SAMPLE = 0.5f + /** + * Throttling shades, green through red. Deliberately six distinct hues rather than one + * colour at six depths: the bands are separated in time, so shades of one colour would have + * to be compared across the width of the chart. + */ + val SHADE_LIGHT = Color.rgb(76, 175, 80) + val SHADE_MODERATE = Color.rgb(0, 188, 212) + val SHADE_SEVERE = Color.rgb(253, 216, 53) + val SHADE_CRITICAL = Color.rgb(251, 140, 0) + val SHADE_EMERGENCY = Color.rgb(183, 65, 14) + val SHADE_SHUTDOWN = Color.rgb(229, 57, 53) + + /** Visible against the plot surface without drowning the lines drawn over it. */ + const val SHADE_ALPHA = 96 + const val THERMAL_LIGHT = 1 const val THERMAL_MODERATE = 2 const val THERMAL_SEVERE = 3 @@ -254,7 +305,10 @@ private fun milliCelsiusToCelsius(milliCelsius: Long): Float = * Power is plotted as a magnitude. The battery current reverses while charging, and a line that * dips below zero would read as the device spending negative power. */ -private fun microWattsToMilliWatts(microWatts: Long): Float = - if (microWatts == PowerUsageWatcher.UNAVAILABLE) 0f else abs(microWatts) / 1000f +private fun microWattsToWatts(microWatts: Long): Float = + if (microWatts == PowerUsageWatcher.UNAVAILABLE) 0f else abs(microWatts) / MICROWATTS_PER_WATT + +private fun wattsMagnitude(microWatts: Long): Float = abs(microWatts) / MICROWATTS_PER_WATT -private fun milliWattsMagnitude(microWatts: Long): Float = abs(microWatts) / 1000f +private const val MICROWATTS_PER_WATT = 1_000_000f +private const val MICROWATTS_PER_MILLIWATT = 1_000f 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..ae90870f42 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -43,6 +43,9 @@ class MetricsAnnotationStore( */ private var lastRecordedAt: Long? = null + /** Hands each annotation its [Annotation.sequence]. */ + private var nextSequence: Long = 0L + /** * An annotated moment. * @@ -52,6 +55,16 @@ class MetricsAnnotationStore( data class Annotation( val atMillis: Long, val label: String, + /** + * Position in the order recorded, counted from the first annotation of the session. + * + * The chart staggers labels across rows to stop them overwriting each other, and picks the + * row from this. Its own position in [recentAnnotations] would not do: that list shifts as + * older entries age out of it, so a label would hop between rows while merely sitting + * still. Counting from the first annotation instead pins a label to one row for life, and + * makes consecutive annotations differ, which is when a collision is likeliest. + */ + val sequence: Long, ) /** @@ -68,7 +81,7 @@ class MetricsAnnotationStore( } lastRecordedAt = now - annotations.addLast(Annotation(now, label)) + annotations.addLast(Annotation(now, label, nextSequence++)) while (annotations.size > MAX_ANNOTATIONS) { annotations.removeFirst() } @@ -88,6 +101,7 @@ class MetricsAnnotationStore( fun clear() { annotations.clear() lastRecordedAt = null + nextSequence = 0L } companion object { diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt new file mode 100644 index 0000000000..98ccf47ea7 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt @@ -0,0 +1,158 @@ +/* + * 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 androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineDataSet +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MetricsAnnotationStore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins how annotation labels are placed (ADFA-5486, ADFA-5499). + * + * Nothing covered the drawing of annotations before, only the store behind them, which is how a + * burst of Gradle tasks came to render its labels stacked on one row as an unreadable smear. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsAnnotationRenderingTest { + private val context = ApplicationProvider.getApplicationContext() + + /** A minimal renderer, so the placement is tested without a particular page's data. */ + private class TestRenderer( + private val sampleCount: Int, + annotations: MetricsAnnotationStore, + now: () -> Long, + ) : MetricsChartRenderer( + sampleIntervalMillis = { SAMPLE_INTERVAL_MS }, + annotations = annotations, + nowMillis = now, + ) { + override fun rebuild() { + val chart = this.chart ?: return + val entries = List(sampleCount) { Entry(it.toFloat(), 0f) } + setData(chart, arrayOf(LineDataSet(entries, "test"))) + } + } + + private class Fixture { + var now = 0L + val store = MetricsAnnotationStore(nowMillis = { now }) + + /** Records [count] annotations, spaced far enough apart to clear the store's throttle. */ + fun recordBurst(count: Int) { + repeat(count) { index -> + store.record("task $index") + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + } + } + } + + private fun render(fixture: Fixture): Pair { + val chart = SafeLineChart(context) + val renderer = TestRenderer(SAMPLE_COUNT, fixture.store, { fixture.now }) + renderer.attach(chart) + return renderer to chart + } + + private fun rowsOf(chart: SafeLineChart): List = chart.xAxis.limitLines.map { it.yOffset } + + @Test + fun `a marker is drawn for each annotation in the window`() { + val fixture = Fixture() + fixture.recordBurst(4) + + val (_, chart) = render(fixture) + + assertThat(chart.xAxis.limitLines).hasSize(4) + } + + @Test + fun `labels are staggered across rows rather than stacked on one`() { + val fixture = Fixture() + fixture.recordBurst(4) + + val (_, chart) = render(fixture) + + // All on one row is exactly the smear this exists to prevent. + assertThat(rowsOf(chart).toSet()).hasSize(4) + } + + @Test + fun `neighbouring labels never share a row`() { + val fixture = Fixture() + fixture.recordBurst(10) + + val (_, chart) = render(fixture) + + // Gradle fires tasks in bursts, so consecutive markers are the ones likeliest to collide. + val rows = rowsOf(chart) + assertThat(rows.zipWithNext().none { (earlier, later) -> earlier == later }).isTrue() + } + + @Test + fun `the rows cycle once more annotations than rows are drawn`() { + val fixture = Fixture() + fixture.recordBurst(10) + + val (_, chart) = render(fixture) + + // Ten annotations over eight rows: the ninth starts the cycle again. + val rows = rowsOf(chart) + assertThat(rows.toSet()).hasSize(8) + assertThat(rows[8]).isEqualTo(rows[0]) + assertThat(rows[9]).isEqualTo(rows[1]) + } + + @Test + fun `a label keeps its row as older annotations scroll out of the window`() { + val fixture = Fixture() + fixture.recordBurst(3) + + val (renderer, chart) = render(fixture) + assertThat(chart.xAxis.limitLines).hasSize(3) + val newestRowBefore = rowsOf(chart).last() + + // Age the chart until the first two annotations have fallen out of the buffer's span and + // only the third is still inside it. Nothing new is recorded. + fixture.now = SURVIVOR_ONLY_AT_MS + renderer.rebuild() + + // Rows come from the order recorded, not from a position in the visible list: taking the + // row from the latter would move this label from the third row to the first while it has + // merely sat still. + assertThat(chart.xAxis.limitLines).hasSize(1) + assertThat(rowsOf(chart).single()).isEqualTo(newestRowBefore) + } + + private companion object { + const val SAMPLE_INTERVAL_MS = 1_000L + const val SAMPLE_COUNT = 60 + + /** + * A time by which the burst's first two annotations are older than the buffer's span and + * its third is not: they were recorded at 0ms, 5000ms and 10000ms, and the buffer holds + * SAMPLE_COUNT * SAMPLE_INTERVAL_MS = 60000ms. + */ + const val SURVIVOR_ONLY_AT_MS = 66_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index d34ff1081a..cf7bf30652 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -82,7 +82,30 @@ class PowerUsageChartRendererTest { } @Test - fun `temperature is plotted in degrees and power in milliwatts`() { + fun `the power axis is labelled in whole watts`() { + val (_, chart) = rendererFor(usage(temperature = longArrayOf(30_000L), power = longArrayOf(8_400_000L))) + val axis = chart.axisRight + + assertThat(axis.valueFormatter.getFormattedValue(8.4f, axis)).isEqualTo("8W") + assertThat(axis.valueFormatter.getFormattedValue(0f, axis)).isEqualTo("0W") + // Without this the axis puts gridlines a fraction of a watt apart on an idle device, and + // rounding them to whole watts prints the same label several times over. + assertThat(axis.isGranularityEnabled).isTrue() + assertThat(axis.granularity).isEqualTo(1f) + } + + @Test + fun `each axis takes the colour of the line it describes`() { + val (_, chart) = rendererFor(usage(temperature = longArrayOf(30_000L), power = longArrayOf(1_000_000L))) + + // Two axes with unrelated units; colour is what pairs each with its series. + assertThat(chart.axisLeft.textColor).isEqualTo(dataset(chart, 0).color) + assertThat(chart.axisRight.textColor).isEqualTo(dataset(chart, 1).color) + assertThat(chart.axisLeft.textColor).isNotEqualTo(chart.axisRight.textColor) + } + + @Test + fun `temperature is plotted in degrees and power in watts`() { val (_, chart) = rendererFor( usage( @@ -92,7 +115,7 @@ class PowerUsageChartRendererTest { ) assertThat(dataset(chart, 0).entries.last().y).isWithin(0.01f).of(29.7f) - assertThat(dataset(chart, 1).entries.last().y).isWithin(0.01f).of(6358.064f) + assertThat(dataset(chart, 1).entries.last().y).isWithin(0.001f).of(6.358064f) } @Test @@ -108,7 +131,7 @@ class PowerUsageChartRendererTest { val ys = dataset(chart, 1).entries.map { it.y } - assertThat(ys).containsExactly(2000f, 3000f).inOrder() + assertThat(ys).containsExactly(2f, 3f).inOrder() assertThat(ys.none { it < 0f }).isTrue() } @@ -189,19 +212,43 @@ class PowerUsageChartRendererTest { } @Test - fun `adjacent levels shade separately, and deeper for the worse one`() { + fun `each throttling level gets its own hue, green through red`() { val (_, chart) = rendererFor( usage( - temperature = LongArray(4) { 30_000L }, - thermal = longArrayOf(1L, 1L, 4L, 4L), + temperature = LongArray(6) { 30_000L }, + thermal = longArrayOf(1L, 2L, 3L, 4L, 5L, 6L), + ), + ) + + assertThat(chart.backgroundSpans).hasSize(6) + assertThat(chart.backgroundSpans.map { it.color or OPAQUE }).isEqualTo(EXPECTED_HUES) + } + + @Test + fun `no two levels share a colour, and the alpha does not vary`() { + val (_, chart) = + rendererFor( + usage( + temperature = LongArray(6) { 30_000L }, + thermal = longArrayOf(1L, 2L, 3L, 4L, 5L, 6L), ), ) - assertThat(chart.backgroundSpans).hasSize(2) - val (light, critical) = chart.backgroundSpans - // The bands read as a gradient of concern rather than as unrelated categories. - assertThat(critical.color ushr 24).isGreaterThan(light.color ushr 24) + // Hue alone ranks the levels, so a repeat would make two of them indistinguishable... + assertThat(chart.backgroundSpans.map { it.color }.toSet()).hasSize(6) + // ...and a varying alpha would add a second, weaker ranking that disagrees with it. + assertThat(chart.backgroundSpans.map { it.color ushr 24 }.toSet()).hasSize(1) + } + + @Test + fun `the legend reports power in watts, and in milliwatts below a watt`() { + val (_, loaded) = rendererFor(usage(temperature = longArrayOf(30_000L), power = longArrayOf(6_358_064L))) + assertThat(dataset(loaded, 1).label).endsWith("6.4W") + + // An idle device reads 0.0W in watts, losing the value the legend exists to show. + val (_, idle) = rendererFor(usage(temperature = longArrayOf(30_000L), power = longArrayOf(6_000L))) + assertThat(dataset(idle, 1).label).endsWith("6mW") } @Test @@ -252,4 +299,13 @@ class PowerUsageChartRendererTest { assertThat(renderer.batteryReadout()).isNull() } + + private companion object { + const val OPAQUE = 0xFF000000.toInt() + + /** The palette ADFA-5499 specifies: green, cyan, yellow, orange, rust, red. */ + val EXPECTED_HUES = + listOf(0xFF4CAF50, 0xFF00BCD4, 0xFFFDD835, 0xFFFB8C00, 0xFFB7410E, 0xFFE53935) + .map { it.toInt() } + } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt index 03dc8b4b46..1e672ab2dd 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt @@ -104,4 +104,43 @@ class MetricsAnnotationStoreTest { // Without resetting the throttle, the next event would be swallowed for five seconds. assertThat(store.record("second")).isTrue() } + + @Test + fun `sequence numbers count from the first annotation of the session`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + repeat(3) { + store.record("task") + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + } + + // The chart picks a label's row from this, so it has to be stable and gap-free. + assertThat(store.recentAnnotations(60_000L).map { it.sequence }).containsExactly(0L, 1L, 2L).inOrder() + } + + @Test + fun `a throttled record consumes no sequence number`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("kept") + // Inside the throttle window, so this one is dropped rather than stored. + store.record("dropped") + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("kept too") + + // A gap here would leave a row unused and push neighbours together. + assertThat(store.recentAnnotations(60_000L).map { it.sequence }).containsExactly(0L, 1L).inOrder() + } + + @Test + fun `clear restarts the numbering`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + store.record("before") + + store.clear() + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("after") + + assertThat(store.recentAnnotations(60_000L).map { it.sequence }).containsExactly(0L) + } } From 59d6851afe3e71802433cd452593273341252fab Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 17:23:05 -0700 Subject: [PATCH 027/128] 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 0254ed9940..7f594941ce 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..f965eabda6 --- /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.PowerUsageWatcher +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) + val renderer = + PowerUsageChartRenderer( + usageProvider = { + PowerUsageWatcher.PowerUsage( + LongArray(SAMPLES) { 30_000L }, + LongArray(SAMPLES) { 1_000_000L }, + LongArray(SAMPLES), + ) + }, + batteryProvider = { PowerUsageWatcher.BatteryState.UNKNOWN }, + ) + 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 c12bc1e9d4ffe99c1b7d6c66bf15efdfa37ddaf0 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 21:55:13 -0700 Subject: [PATCH 028/128] 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 5028834f10ce6a41ba22e9a24774fae6792c8b2c Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 21:58:56 -0700 Subject: [PATCH 029/128] docs(metrics): correct the sign contract on the battery current (ADFA-5499) `BATTERY_PROPERTY_CURRENT_NOW` is positive for current entering the battery -- charging -- and negative for current leaving it. The KDoc on `PowerReading.powerMicroWatts` claimed the opposite, and a test name repeated the claim. No behaviour changes, and deliberately so. CodeRabbit's suggestion was to negate the reading to match the doc; that would make the stored value disagree with the platform it came from, which is the wrong half to move. Nothing consumes the sign: the renderer plots the magnitude, both because a line dipping below zero reads as negative power spent and because not every OEM signs this property the way the documentation says. That second reason is now written down where it belongs, next to the reading. Confirmed against the device the feature was built on: current_now reads positive while charging. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../java/com/itsaky/androidide/utils/DevicePowerSource.kt | 8 ++++++-- .../java/com/itsaky/androidide/utils/PowerUsageWatcher.kt | 5 +++-- .../itsaky/androidide/ui/PowerUsageChartRendererTest.kt | 5 +++-- .../com/itsaky/androidide/utils/PowerUsageWatcherTest.kt | 5 +++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt index 882d7de1b3..ceacbdff75 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -75,8 +75,12 @@ class DevicePowerSource( /** * Instantaneous draw, from current and voltage. * - * Microamps times millivolts is nanowatts, so the product is scaled down to microwatts. The sign - * follows the battery current: negative while charging, because current is then flowing in. + * Microamps times millivolts is nanowatts, so the product is scaled down to microwatts. + * + * The sign is the platform's, passed through unchanged: `BATTERY_PROPERTY_CURRENT_NOW` is + * positive for current entering the battery -- charging -- and negative for current leaving it. + * Not every OEM honours that, which is one reason the chart plots the magnitude rather than the + * signed value; the other is that a line dipping below zero reads as negative power spent. */ private fun readPower(battery: Intent?): Long { val microAmps = batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 7c1e8b987a..29d162094b 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -193,8 +193,9 @@ class PowerUsageWatcher * One sample's worth of readings. * * @property temperatureMilliCelsius Battery temperature, or [UNAVAILABLE]. - * @property powerMicroWatts Instantaneous draw, or [UNAVAILABLE]. Negative while charging, - * because the battery current reverses. + * @property powerMicroWatts Instantaneous draw, or [UNAVAILABLE]. Signed as the platform + * signs the battery current: positive while charging, negative while discharging. Recorded + * as read; the renderer decides how to plot it. * @property thermalStatus The platform throttling level, or [THERMAL_UNKNOWN]. * @property battery Level and charging state, for the legend. */ diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index cf7bf30652..678b94bcbb 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -119,12 +119,13 @@ class PowerUsageChartRendererTest { } @Test - fun `power is plotted as a magnitude, so charging does not dip below zero`() { + fun `power is plotted as a magnitude, whichever way the current is signed`() { val (_, chart) = rendererFor( usage( temperature = longArrayOf(30_000L, 30_000L), - // The battery current reverses while charging. + // The platform signs the battery current by direction, and not every OEM signs it + // the same way round, so both signs have to plot as spent power. power = longArrayOf(2_000_000L, -3_000_000L), ), ) diff --git a/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt b/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt index f7cf75f39b..6032bcbd8e 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt @@ -117,12 +117,13 @@ class PowerUsageWatcherTest { } @Test - fun `negative power is kept as recorded, because charging reverses the current`() { + fun `the sign of the current is recorded, not interpreted`() { val fixture = Fixture(listOf(reading(power = -3_000_000L))) fixture.sample(1) - // The watcher records the sign; deciding how to plot it is the renderer's job. + // The watcher passes the platform's sign through. Deciding what it means -- and that the + // chart plots the magnitude either way -- is the renderer's job. assertThat( fixture.watcher .getUsage() From 8e68679eabcaa5e5996f481965a6d0148a9a92e8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 17:23:05 -0700 Subject: [PATCH 030/128] 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 245d95de5133d4cdfa434301e8646d48963f643d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 22:20:58 -0700 Subject: [PATCH 031/128] fix(metrics): make the network sampling loop stoppable and crash-proof (ADFA-5489) CodeRabbit raised three Major findings against this watcher. They were fixed, but on #1785 -- a later PR in the stack than the one that ships the bug. This PR is already approved and ahead of that one, so on its own it still carried all three. Moving the fix to where the defect lives. The scope had no parent Job and startWatching() supplied its own SupervisorJob per launch, so nothing the scope did could cancel the sampler. stopWatching() only lowered a flag the loop checks once per interval, and the loop spends nearly all its time in delay() -- up to 60s once ADFA-5486 makes the rate configurable. A stop and start inside that window left two loops appending to one buffer, splitting each delta between them. The scope now has a parent job, the launch is stored, and stopWatching() cancels it. Nothing caught exceptions inside the loop. An exception -- a misbehaving listener is enough -- ended the coroutine while `watching` stayed true, so every later startWatching() was refused as "already watching" and sampling was dead for the rest of the session. The body is wrapped, and CancellationException is rethrown so structured cancellation still works. The dedicated sampling thread was never released. close() is separate from stopWatching() on purpose: the editor stops and restarts the watcher across its lifecycle, and only the terminal teardown should give up the thread that newSingleThreadContext keeps alive. The activity's destroy path calls it. startWatching() now guards with compareAndSet rather than a read followed by a write, so two callers racing cannot each start a sampler. The watcher takes its dispatchers as parameters, matching MemoryUsageWatcher, so NetworkWatcherLifecycleTest can drive the loop on a virtual clock. Waiting on the wall clock is what hung the test executor the first time this was attempted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 5 +- .../androidide/utils/NetworkUsageWatcher.kt | 340 ++++++++++-------- .../utils/NetworkWatcherLifecycleTest.kt | 132 +++++++ 3 files changed, 328 insertions(+), 149 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.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..39aef60a74 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 @@ -541,8 +541,9 @@ abstract class BaseEditorActivity : if (isDestroying) { memoryUsageWatcher.stopWatching(true) memoryUsageWatcher.listener = null - networkUsageWatcher.stopWatching() - networkUsageWatcher.listener = null + // close(), not stopWatching(): this is the terminal teardown, and the watcher holds a + // dedicated sampling thread that newSingleThreadContext keeps alive until it is closed. + networkUsageWatcher.close() editorActivityScope.cancelIfActive("Activity is being destroyed") unbindDebuggerService() 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..ea167568c7 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 @@ -32,6 +35,7 @@ import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext /** * Samples this app's network traffic (ADFA-5489). @@ -50,177 +54,219 @@ import java.util.concurrent.atomic.AtomicBoolean * @param readRxBytes Reads the cumulative received byte count. Injectable for tests. * @param readTxBytes Reads the cumulative transmitted byte count. Injectable for tests. */ -class NetworkUsageWatcher( - private val 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, -) { +class NetworkUsageWatcher @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) - private val coroutineDispatcher = newSingleThreadContext("NetworkUsageWatcher") - private val coroutineScope = CoroutineScope(coroutineDispatcher) - private val watching = AtomicBoolean(false) - - /** Guards the two ring buffers: the sampler writes them, the UI thread snapshots them. */ - private val historyLock = Any() - - private val received = MutableShiftedLongArray(MAX_USAGE_ENTRIES) - private val transmitted = MutableShiftedLongArray(MAX_USAGE_ENTRIES) - - /** - * The previous cumulative readings, or `null` before the first sample. The first sample - * establishes a baseline and contributes no delta -- the alternative would be a spike equal to - * everything the app had transferred since boot. - */ - private var lastRx: Long? = null - private var lastTx: Long? = null - - /** - * Whether the platform reports traffic for this UID at all. Cleared permanently if a read comes - * back [TrafficStats.UNSUPPORTED], which some devices and emulators do. - */ - @Volatile - var isSupported: Boolean = true - private set - - val isWatching: Boolean - get() = watching.get() - - /** - * Notified on the main thread after each sample. - */ - var listener: NetworkUsageListener? = null - - /** - * A snapshot of the sampled history, oldest first. Safe to call from any thread at any time; - * before the first sample every entry is zero. - * - * The arrays are copies. Handing out the live ring buffers would let the caller read them while - * the sampler thread is midway through appending, and the chart renderer reads all 30 entries. - */ - fun getUsage(): NetworkUsage = - synchronized(historyLock) { - NetworkUsage(received.snapshot(), transmitted.snapshot()) - } + constructor( + private val 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, + // Injectable so a test can drive the sampling loop on a virtual clock. Waiting on the wall + // clock instead is what hung the test executor the first time this was attempted. + private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("NetworkUsageWatcher"), + private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, + ) { + // A parent job, so cancelling the scope in close() actually reaches the sampler. Without one + // the launch below had to supply its own, and nothing the scope did could stop it. + private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher) + private val watching = AtomicBoolean(false) - fun startWatching() { - if (isWatching) { - log.warn("Network usage is already being watched") - return - } + /** The running sampling loop, so [stopWatching] can actually stop it. */ + private var samplingJob: Job? = null - watching.set(true) + /** Guards the two ring buffers: the sampler writes them, the UI thread snapshots them. */ + private val historyLock = Any() - coroutineScope.launch(context = SupervisorJob() + coroutineDispatcher) { - while (isWatching) { - sampleOnce() + private val received = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + private val transmitted = MutableShiftedLongArray(MAX_USAGE_ENTRIES) - listener?.also { listener -> - val usage = getUsage() - withContext(Dispatchers.Main.immediate) { - listener.onNetworkUsageChanged(usage) - } - } + /** + * The previous cumulative readings, or `null` before the first sample. The first sample + * establishes a baseline and contributes no delta -- the alternative would be a spike equal to + * everything the app had transferred since boot. + */ + private var lastRx: Long? = null + private var lastTx: Long? = null - delay(updateInterval) + /** + * Whether the platform reports traffic for this UID at all. Cleared permanently if a read comes + * back [TrafficStats.UNSUPPORTED], which some devices and emulators do. + */ + @Volatile + var isSupported: Boolean = true + private set + + val isWatching: Boolean + get() = watching.get() + + /** + * Notified on the main thread after each sample. + */ + var listener: NetworkUsageListener? = null + + /** + * A snapshot of the sampled history, oldest first. Safe to call from any thread at any time; + * before the first sample every entry is zero. + * + * The arrays are copies. Handing out the live ring buffers would let the caller read them while + * the sampler thread is midway through appending, and the chart renderer reads all 30 entries. + */ + fun getUsage(): NetworkUsage = + synchronized(historyLock) { + NetworkUsage(received.snapshot(), transmitted.snapshot()) } - } - } - fun stopWatching() { - watching.set(false) - coroutineScope.cancelIfActive("Cancellation requested") - } + fun startWatching() { + // compareAndSet, not a read then a write: two callers racing here would each start a + // sampler, and both would append to the same buffers. + if (!watching.compareAndSet(false, true)) { + log.warn("Network usage is already being watched") + return + } - /** - * Takes one sample. The sampling loop calls this once per [updateInterval]; tests call it - * directly so the delta accounting can be exercised without threads or waiting. - */ - @VisibleForTesting - internal fun sampleOnce() { - if (!isSupported) { - return - } + samplingJob = + coroutineScope.launch(context = SupervisorJob() + coroutineDispatcher) { + while (isWatching) { + // The loop must outlive a bad sample. Without this an exception -- a + // misbehaving listener is enough -- ends the coroutine while `watching` stays + // true, so every later startWatching() is refused as "already watching" and + // sampling is dead for the rest of the session. + runCatching { + sampleOnce() - val rx = readRxBytes(uid) - val tx = readTxBytes(uid) + listener?.also { listener -> + val usage = getUsage() + withContext(mainDispatcher) { + listener.onNetworkUsageChanged(usage) + } + } + }.onFailure { failure -> + if (failure is CancellationException) { + throw failure + } + log.error("Network usage sampling failed; continuing", failure) + } - if (rx == UNSUPPORTED || tx == UNSUPPORTED) { - // Not transient: the platform either accounts for this UID or it does not. - isSupported = false - log.info("Network usage is unavailable on this device; the traffic chart will read zero") - return + delay(updateInterval) + } + } } - synchronized(historyLock) { - record(received, previous = lastRx, current = rx) - record(transmitted, previous = lastTx, current = tx) + /** + * Stops sampling. The watcher can be started again; the history is kept. + */ + fun stopWatching() { + watching.set(false) + // Cancel the job, not the scope. The loop spends nearly all its time in delay(), so waiting + // for it to notice the flag leaves it sampling for up to a full interval after the editor + // asked it to stop -- long enough for a stop/start to run two samplers at once. Cancelling + // the scope instead would end the watcher for good, and this is a pause, not a teardown. + coroutineScope.cancelIfActive("Cancellation requested") } - lastRx = rx - lastTx = tx - } + /** + * Stops sampling and releases the sampling thread. Terminal: the watcher cannot be restarted. + * + * Separate from [stopWatching] because the editor stops and restarts the watcher across its + * lifecycle, and only the final teardown should give up the thread that + * [newSingleThreadContext] keeps alive. + */ + fun close() { + stopWatching() + listener = null + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() + } - /** - * Appends the delta between [previous] and [current] to [history]. - * - * A negative delta means the counter went backwards, which happens when it is reset -- the - * device rebooted, or the platform re-based its accounting. Treated as a fresh baseline (zero - * for this interval) rather than plotted as negative traffic. - */ - private fun record( - history: MutableShiftedLongArray, - previous: Long?, - current: Long, - ) { - val delta = - when { - previous == null -> 0L - current < previous -> 0L - else -> current - previous + /** + * Takes one sample. The sampling loop calls this once per [updateInterval]; tests call it + * directly so the delta accounting can be exercised without threads or waiting. + */ + @VisibleForTesting + internal fun sampleOnce() { + if (!isSupported) { + return } - // Newest entry goes in at index 0 and the shift makes it the last element, so - // history[size - 1] is always the newest. Same convention as MemoryUsageWatcher. - history[0] = delta - history.shift(1) - } + val rx = readRxBytes(uid) + val tx = readTxBytes(uid) - /** - * Bytes transferred per sampling interval, oldest first. - * - * @property received Bytes received during each interval. - * @property transmitted Bytes transmitted during each interval. - */ - data class NetworkUsage( - val received: LongArray, - val transmitted: LongArray, - ) { - override fun equals(other: Any?): Boolean = - this === other || - ( - other is NetworkUsage && - received.contentEquals(other.received) && - transmitted.contentEquals(other.transmitted) - ) - - override fun hashCode(): Int = 31 * received.contentHashCode() + transmitted.contentHashCode() - } + if (rx == UNSUPPORTED || tx == UNSUPPORTED) { + // Not transient: the platform either accounts for this UID or it does not. + isSupported = false + log.info("Network usage is unavailable on this device; the traffic chart will read zero") + return + } - fun interface NetworkUsageListener { - fun onNetworkUsageChanged(usage: NetworkUsage) - } + synchronized(historyLock) { + record(received, previous = lastRx, current = rx) + record(transmitted, previous = lastTx, current = tx) + } + + lastRx = rx + lastTx = tx + } + + /** + * Appends the delta between [previous] and [current] to [history]. + * + * A negative delta means the counter went backwards, which happens when it is reset -- the + * device rebooted, or the platform re-based its accounting. Treated as a fresh baseline (zero + * for this interval) rather than plotted as negative traffic. + */ + private fun record( + history: MutableShiftedLongArray, + previous: Long?, + current: Long, + ) { + val delta = + when { + previous == null -> 0L + current < previous -> 0L + else -> current - previous + } - companion object { - const val MAX_USAGE_ENTRIES = 30 - const val DEFAULT_UPDATE_INTERVAL = 1000L + // Newest entry goes in at index 0 and the shift makes it the last element, so + // history[size - 1] is always the newest. Same convention as MemoryUsageWatcher. + history[0] = delta + history.shift(1) + } - /** [TrafficStats.UNSUPPORTED] widened to [Long], which is what the getters return. */ - private const val UNSUPPORTED = TrafficStats.UNSUPPORTED.toLong() + /** + * Bytes transferred per sampling interval, oldest first. + * + * @property received Bytes received during each interval. + * @property transmitted Bytes transmitted during each interval. + */ + data class NetworkUsage( + val received: LongArray, + val transmitted: LongArray, + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is NetworkUsage && + received.contentEquals(other.received) && + transmitted.contentEquals(other.transmitted) + ) - private val log = LoggerFactory.getLogger(NetworkUsageWatcher::class.java) + override fun hashCode(): Int = 31 * received.contentHashCode() + transmitted.contentHashCode() + } + + fun interface NetworkUsageListener { + fun onNetworkUsageChanged(usage: NetworkUsage) + } + + companion object { + const val MAX_USAGE_ENTRIES = 30 + const val DEFAULT_UPDATE_INTERVAL = 1000L + + /** [TrafficStats.UNSUPPORTED] widened to [Long], which is what the getters return. */ + private const val UNSUPPORTED = TrafficStats.UNSUPPORTED.toLong() + + private val log = LoggerFactory.getLogger(NetworkUsageWatcher::class.java) + } } -} /** * Copies this ring buffer into a plain array in logical order, oldest first. diff --git a/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt new file mode 100644 index 0000000000..3b69d60fc4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt @@ -0,0 +1,132 @@ +/* + * 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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the sampling loop's lifecycle (ADFA-5489). + * + * The loop spends nearly all of its time in `delay()`, so "stopped" cannot mean "will notice a + * flag eventually": between the request and the next tick the watcher is still sampling, and a + * stop followed by a start inside that window used to leave two loops appending to one buffer. + * + * Driven on a virtual clock. Waiting on the wall clock instead is what hung the test executor the + * first time this was attempted. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class NetworkWatcherLifecycleTest { + private fun watcher( + dispatcher: kotlin.coroutines.CoroutineContext, + onSample: () -> Unit = {}, + ): NetworkUsageWatcher { + var counter = 0L + return NetworkUsageWatcher( + updateInterval = INTERVAL_MS, + uid = TEST_UID, + readRxBytes = { + onSample() + counter += 100L + counter + }, + readTxBytes = { counter }, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + } + + @Test + fun `stopping inside the sampling interval actually stops sampling`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 3) + val whileRunning = samples + + watcher.stopWatching() + advanceTimeBy(INTERVAL_MS * 5) + + // Cancelling the job rather than waiting for the loop to observe a flag is what makes + // this exact: nothing is sampled after the stop. + assertThat(whileRunning).isGreaterThan(0) + assertThat(samples).isEqualTo(whileRunning) + assertThat(watcher.isWatching).isFalse() + watcher.close() + } + + @Test + fun `restarting inside the sampling interval does not leave two loops running`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 2) + watcher.stopWatching() + watcher.startWatching() + + val before = samples + advanceTimeBy(INTERVAL_MS * 4) + val perInterval = (samples - before) / 4 + + // Two loops would double the rate against the same buffer. + assertThat(perInterval).isEqualTo(1) + watcher.close() + } + + @Test + fun `a listener that throws does not kill sampling for the rest of the session`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + var thrown = 0 + watcher.listener = + NetworkUsageWatcher.NetworkUsageListener { + if (thrown++ == 0) { + throw IllegalStateException("listener blew up") + } + } + + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 4) + + // Uncaught, the exception ends the coroutine while isWatching stays true, so every + // later startWatching() is refused and the charts freeze for good. + assertThat(samples).isGreaterThan(1) + assertThat(watcher.isWatching).isTrue() + watcher.close() + } + + private companion object { + const val INTERVAL_MS = 1_000L + const val TEST_UID = 10_123 + } +} From afdd6aec0401e11f65d6546af28a0b141bea1633 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 22:58:51 -0700 Subject: [PATCH 032/128] fix(metrics): actually apply the sampler fix, and stop a failed test spinning The previous commit shipped the commit message for this fix without the fix. An interrupted command had reverted the watcher to its pre-fix shape for a negative check and was killed before it restored it, so what got committed was `launch(SupervisorJob() + dispatcher)` and a scope cancel that cannot reach the sampler -- the very defect being fixed. stopWatching() now cancels the stored job, as its own comment already claimed. That mistake did prove the tests: against the unfixed watcher NetworkWatcherLifecycleTest reported two samples per interval where one was expected, which is exactly the two-loop overlap the fix exists to prevent. The tests also gained the cleanup they should have had. Each body now closes its watcher in a finally. Without it a failed assertion skipped close(), left the sampling loop live, and runTest's trailing advanceUntilIdle advanced virtual time forever -- a synchronous spin no test timeout can interrupt, which pinned a core and took the Gradle task to its ten-minute limit with no output. CodeRabbit raised exactly this about the tests on #1785; the lesson had not been carried over here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/utils/NetworkUsageWatcher.kt | 5 +- .../utils/NetworkWatcherLifecycleTest.kt | 91 +++++++++++-------- 2 files changed, 58 insertions(+), 38 deletions(-) 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 ea167568c7..7911086b2e 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -125,7 +125,7 @@ class NetworkUsageWatcher } samplingJob = - coroutineScope.launch(context = SupervisorJob() + coroutineDispatcher) { + coroutineScope.launch { while (isWatching) { // The loop must outlive a bad sample. Without this an exception -- a // misbehaving listener is enough -- ends the coroutine while `watching` stays @@ -161,7 +161,8 @@ class NetworkUsageWatcher // for it to notice the flag leaves it sampling for up to a full interval after the editor // asked it to stop -- long enough for a stop/start to run two samplers at once. Cancelling // the scope instead would end the watcher for good, and this is a pause, not a teardown. - coroutineScope.cancelIfActive("Cancellation requested") + samplingJob?.cancel() + samplingJob = null } /** diff --git a/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt index 3b69d60fc4..a63ce316e5 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt @@ -65,19 +65,25 @@ class NetworkWatcherLifecycleTest { var samples = 0 val watcher = watcher(dispatcher) { samples++ } - watcher.startWatching() - advanceTimeBy(INTERVAL_MS * 3) - val whileRunning = samples - - watcher.stopWatching() - advanceTimeBy(INTERVAL_MS * 5) - - // Cancelling the job rather than waiting for the loop to observe a flag is what makes - // this exact: nothing is sampled after the stop. - assertThat(whileRunning).isGreaterThan(0) - assertThat(samples).isEqualTo(whileRunning) - assertThat(watcher.isWatching).isFalse() - watcher.close() + try { + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 3) + val whileRunning = samples + + watcher.stopWatching() + advanceTimeBy(INTERVAL_MS * 5) + + // Cancelling the job rather than waiting for the loop to observe a flag is what makes + // this exact: nothing is sampled after the stop. + assertThat(whileRunning).isGreaterThan(0) + assertThat(samples).isEqualTo(whileRunning) + assertThat(watcher.isWatching).isFalse() + } finally { + // In a finally: a failed assertion would otherwise leave the sampling loop alive, + // and runTest's trailing advanceUntilIdle then advances virtual time forever. That + // spin is synchronous, so no test timeout can interrupt it -- it just pins a core. + watcher.close() + } } @Test @@ -87,18 +93,24 @@ class NetworkWatcherLifecycleTest { var samples = 0 val watcher = watcher(dispatcher) { samples++ } - watcher.startWatching() - advanceTimeBy(INTERVAL_MS * 2) - watcher.stopWatching() - watcher.startWatching() + try { + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 2) + watcher.stopWatching() + watcher.startWatching() - val before = samples - advanceTimeBy(INTERVAL_MS * 4) - val perInterval = (samples - before) / 4 + val before = samples + advanceTimeBy(INTERVAL_MS * 4) + val perInterval = (samples - before) / 4 - // Two loops would double the rate against the same buffer. - assertThat(perInterval).isEqualTo(1) - watcher.close() + // Two loops would double the rate against the same buffer. + assertThat(perInterval).isEqualTo(1) + } finally { + // In a finally: a failed assertion would otherwise leave the sampling loop alive, + // and runTest's trailing advanceUntilIdle then advances virtual time forever. That + // spin is synchronous, so no test timeout can interrupt it -- it just pins a core. + watcher.close() + } } @Test @@ -107,22 +119,29 @@ class NetworkWatcherLifecycleTest { val dispatcher = StandardTestDispatcher(testScheduler) var samples = 0 val watcher = watcher(dispatcher) { samples++ } - var thrown = 0 - watcher.listener = - NetworkUsageWatcher.NetworkUsageListener { - if (thrown++ == 0) { - throw IllegalStateException("listener blew up") + + try { + var thrown = 0 + watcher.listener = + NetworkUsageWatcher.NetworkUsageListener { + if (thrown++ == 0) { + throw IllegalStateException("listener blew up") + } } - } - watcher.startWatching() - advanceTimeBy(INTERVAL_MS * 4) + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 4) - // Uncaught, the exception ends the coroutine while isWatching stays true, so every - // later startWatching() is refused and the charts freeze for good. - assertThat(samples).isGreaterThan(1) - assertThat(watcher.isWatching).isTrue() - watcher.close() + // Uncaught, the exception ends the coroutine while isWatching stays true, so every + // later startWatching() is refused and the charts freeze for good. + assertThat(samples).isGreaterThan(1) + assertThat(watcher.isWatching).isTrue() + } finally { + // In a finally: a failed assertion would otherwise leave the sampling loop alive, + // and runTest's trailing advanceUntilIdle then advances virtual time forever. That + // spin is synchronous, so no test timeout can interrupt it -- it just pins a core. + watcher.close() + } } private companion object { From 717f5098739b2df3f21ebd2444afcdc485c35bfd Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 23:13:56 -0700 Subject: [PATCH 033/128] 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 034/128] 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 0984cb5a1e20b1fe67b5ff35d5c28c50b0adb2bf Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 00:01:51 -0700 Subject: [PATCH 035/128] feat(metrics): add long-press help to the metrics carousel (ADFA-5510) Twelve tags under "carousel.", declared in TooltipTag alongside every other area. No "ide." prefix despite the ticket's wording: the lookup is by tag AND category, the category column already carries "ide", and none of the 530 existing ide-category rows are prefixed -- a prefixed tag would have made the carousel the sole exception. The three charts do not use setOnLongClickListener, and that is the whole subtlety of this change. MPAndroidChart's BarLineChartBase.onTouchEvent hands the event to its own touch listener and never calls super, so the framework's long-press detection never runs: a view listener would have been installed, looked wired in review, and never fired -- the same shape as the sampling-rate tap that was bound to the wrong edge of the chart. The charts answer through onChartLongPressed instead, which the renderer already implemented as a no-op. That callback also decides between two tags by where the press landed: below the plot is the time axis, which is what the sampling rate belongs to, and inside the plot is the metric itself. The decision is split out into helpTagAt so it can be tested -- TooltipManager reads the docs database from device storage in its static initialiser and cannot be loaded off-device, so a test can assert which tag is chosen but not that a tooltip appears. The rate chooser is a dialog with no free surface to long-press, so help is a neutral button there. It deliberately does not dismiss: the point is to read it and then pick a rate. Everything else is wired once, in MetricsCarouselController.bind(), which runs for the docked strip and the floating window alike -- the window's own chrome already carries the window-* tags. unbind() clears the listeners and also resets isLongClickable, which setOnLongClickListener(null) leaves set: the view would otherwise still claim a long press it no longer answers. Tier 1 and Tier 2 copy for all twelve tags is written; Tier 3 has nothing to link to, as there is no i/ help page for the carousel yet. Verified on a Pixel 6 Pro with the authored database pushed to /sdcard/Download/documentation.db, which the debug path prefers when it is newer: the arrows, title, camera and both chart regions each resolve their own tag and render the real copy, and the dialog's Help button resolves carousel.rate over the open dialog. Known gap: an arrow dimmed at the end of the carousel gives no help. A disabled view that is long-clickable consumes the touch and never fires the long press, so it does not fall through to the panel either. Fixing it means the arrows stop being disabled, which is an accessibility trade-off worth deciding explicitly rather than in passing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MemoryUsageChartRenderer.kt | 3 + .../ui/MetricsCarouselController.kt | 52 ++++++ .../androidide/ui/MetricsChartRenderer.kt | 37 +++- .../ui/NetworkUsageChartRenderer.kt | 3 + .../androidide/ui/PowerUsageChartRenderer.kt | 3 + .../androidide/ui/MetricsCarouselHelpTest.kt | 159 ++++++++++++++++++ .../androidide/idetooltips/TooltipTag.kt | 15 ++ 7 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.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 de412b4262..a7d3af98c6 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -26,6 +26,7 @@ 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.idetooltips.TooltipTag import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo import com.itsaky.androidide.utils.MetricsAnnotationStore @@ -73,6 +74,8 @@ class MemoryUsageChartRenderer( * 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. */ + override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY + @UiThread override fun rebuild() { val chart = this.chart ?: return 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 01a661b265..3057a454e1 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -22,10 +22,12 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Toast import androidx.annotation.UiThread +import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.viewpager2.widget.ViewPager2 import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.IntentUtils @@ -35,6 +37,8 @@ import com.itsaky.androidide.utils.MetricsSamplingRates import com.itsaky.androidide.utils.MetricsSnapshot import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.PowerUsageWatcher +import com.itsaky.androidide.utils.displayTooltipOnLongPress +import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -190,11 +194,36 @@ class MetricsCarouselController( binding.metricsNext.setOnClickListener { step(1) } updateArrows(binding.metricsPager.currentItem) + wireHelp(binding) + memoryUsageWatcher.listener = memoryListener networkUsageWatcher.listener = networkListener powerUsageWatcher.listener = powerListener } + /** + * Gives every control in the strip its long-press help (ADFA-5510). + * + * Here rather than at each host, because this runs for the docked strip and for the floating + * window alike -- the window's own chrome already carries the `window-*` tags, and the carousel + * inside it is this same controller. + * + * The charts are absent from this list on purpose: MPAndroidChart swallows the touch events a + * view-level long press would need, so each renderer answers through the chart's gesture + * listener instead. + */ + @UiThread + private fun wireHelp(binding: LayoutMemUsageBinding) { + val context = binding.root.context + binding.root.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_PANEL) + binding.metricsTitle.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_TITLE) + binding.metricsPrevious.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_PREVIOUS) + binding.metricsNext.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_NEXT) + binding.metricsSnapshot.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_SNAPSHOT) + binding.metricsBattery.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_BATTERY) + binding.metricsUndockedMessage.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_UNDOCKED) + } + /** * Stops feeding the carousel and releases the bound views. Sampling is unaffected -- the * watchers keep their history, so re-binding shows it in full. @@ -215,6 +244,22 @@ class MetricsCarouselController( networkRenderer.onXAxisTap = null powerRenderer.onXAxisTap = null binding?.metricsSnapshot?.setOnClickListener(null) + binding?.let { bound -> + listOf( + bound.root, + bound.metricsTitle, + bound.metricsPrevious, + bound.metricsNext, + bound.metricsSnapshot, + bound.metricsBattery, + bound.metricsUndockedMessage, + ).forEach { control -> + control.setOnLongClickListener(null) + // setOnLongClickListener(null) leaves isLongClickable set, so the view would still + // claim a long press it no longer answers. + control.isLongClickable = false + } + } binding?.metricsPrevious?.setOnClickListener(null) binding?.metricsNext?.setOnClickListener(null) pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } @@ -340,7 +385,14 @@ 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() } + // A dialog has no free surface to long-press, so help is a button here rather than a + // gesture. It does not dismiss: the point is to read it and then choose a rate. + .setNeutralButton(string.help, null) .show() + + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { helpAnchor -> + showIdeCategoryTooltipIfPresent(context, helpAnchor, TooltipTag.CAROUSEL_RATE) + } } /** 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 7f594941ce..3d7f32e221 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 @@ -31,8 +32,10 @@ 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.idetooltips.TooltipTag import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.resolveAttr +import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent import kotlin.math.roundToLong /** @@ -63,6 +66,34 @@ abstract class MetricsChartRenderer( */ var onXAxisTap: (() -> Unit)? = null + /** + * The help tag for this page's plot, shown on a long press (ADFA-5510). + * + * Routed through the chart's own gesture listener rather than [android.view.View.setOnLongClickListener]: + * MPAndroidChart's `BarLineChartBase.onTouchEvent` hands the event to its touch listener and + * never calls `super`, so the framework's long-press detection never runs and a view listener + * would be installed, look wired, and never fire. + */ + protected open val helpTag: String? = null + + /** + * The help tag for a long press at [y], or `null` if this page has none. + * + * Separated from showing the tooltip so it can be tested: TooltipManager reads the docs + * database from device storage in its static initialiser and cannot be loaded off-device. + */ + @VisibleForTesting + internal fun helpTagAt(y: Float): String? { + val chart = this.chart ?: return null + // The axis band answers for the sampling rate, the plot for the metric itself, matching + // where a tap goes. + return if (y >= chart.viewPortHandler.contentBottom()) { + TooltipTag.CAROUSEL_AXIS_TIME + } else { + helpTag + } + } + /** * Whether the user has pinched this chart. * @@ -236,7 +267,11 @@ abstract class MetricsChartRenderer( lastPerformedGesture: ChartTouchListener.ChartGesture?, ) = Unit - override fun onChartLongPressed(me: MotionEvent?) = Unit + override fun onChartLongPressed(me: MotionEvent?) { + val y = me?.y ?: return + val tag = helpTagAt(y) ?: return + showIdeCategoryTooltipIfPresent(chart.context, chart, tag) + } override fun onChartDoubleTapped(me: MotionEvent?) = Unit 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..62f9a0b72b 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.idetooltips.TooltipTag import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.NetworkUsageWatcher.NetworkUsage @@ -66,6 +67,8 @@ class NetworkUsageChartRenderer( /** * Rebuilds both series from the full sample history. */ + override val helpTag: String = TooltipTag.CAROUSEL_CHART_NETWORK + @UiThread override fun rebuild() { val chart = this.chart ?: return diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index 89e090d21d..3bb4128a06 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -26,6 +26,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.idetooltips.TooltipTag import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.PowerUsageWatcher import com.itsaky.androidide.utils.PowerUsageWatcher.PowerUsage @@ -58,6 +59,8 @@ class PowerUsageChartRenderer( sampleIntervalMillis = sampleIntervalMillis, annotations = annotations, ) { + override val helpTag: String = TooltipTag.CAROUSEL_CHART_POWER + @UiThread override fun rebuild() { val chart = this.chart ?: return diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt new file mode 100644 index 0000000000..6b8422a3e0 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt @@ -0,0 +1,159 @@ +/* + * 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 android.view.View +import androidx.appcompat.view.ContextThemeWrapper +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.idetooltips.TooltipTag +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins that every control in the metrics carousel answers a long press (ADFA-5510). + * + * The assertion is that a listener is installed, not that a tooltip appears: TooltipManager reads + * the docs database from device storage in its static initialiser and cannot be loaded off-device. + * Whether a tag has copy behind it is the database's business, not this code's. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCarouselHelpTest { + private val context: Context = + ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), + R.style.Theme_AndroidIDE, + ) + + private fun boundStrip(): LayoutMemUsageBinding { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + controller().bind(binding) + return binding + } + + private fun controller() = + MetricsCarouselController( + memoryUsageWatcher = MemoryUsageWatcher(), + networkUsageWatcher = NetworkUsageWatcher(uid = TEST_UID), + powerUsageWatcher = + PowerUsageWatcher( + source = { + PowerUsageWatcher.PowerReading( + temperatureMilliCelsius = 30_000L, + powerMicroWatts = 1_000_000L, + thermalStatus = 0, + battery = PowerUsageWatcher.BatteryState.UNKNOWN, + ) + }, + ), + lineColorFor = { android.graphics.Color.BLUE }, + annotations = MetricsAnnotationStore(), + ) + + @Test + fun `every control in the strip answers a long press`() { + val binding = boundStrip() + + val controls: List> = + listOf( + "panel" to binding.root, + "title" to binding.metricsTitle, + "previous" to binding.metricsPrevious, + "next" to binding.metricsNext, + "snapshot" to binding.metricsSnapshot, + "battery" to binding.metricsBattery, + "undocked message" to binding.metricsUndockedMessage, + ) + + val unwired = controls.filterNot { (_, view) -> view.isLongClickable }.map { it.first } + assertThat(unwired).isEmpty() + } + + @Test + fun `an unbound strip has no help wired`() { + // Guards the test above: if inflation alone made these long-clickable, it would pass + // against a controller that wires nothing. + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + + assertThat(binding.metricsPrevious.isLongClickable).isFalse() + assertThat(binding.metricsSnapshot.isLongClickable).isFalse() + } + + @Test + fun `unbinding releases the help listeners`() { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + val controller = controller() + controller.bind(binding) + controller.unbind() + + assertThat(binding.metricsPrevious.isLongClickable).isFalse() + assertThat(binding.metricsSnapshot.isLongClickable).isFalse() + } + + @Test + fun `each chart page declares its own help tag`() { + // The charts are not in the list above: MPAndroidChart swallows the touch events a view + // long press needs, so they answer through the chart's gesture listener instead. + assertThat(TooltipTag.CAROUSEL_CHART_MEMORY).isEqualTo("carousel.chart.memory") + assertThat(TooltipTag.CAROUSEL_CHART_NETWORK).isEqualTo("carousel.chart.network") + assertThat(TooltipTag.CAROUSEL_CHART_POWER).isEqualTo("carousel.chart.power") + } + + @Test + fun `a long press below the plot asks about the sampling rate, not the metric`() { + val chart = SafeLineChart(context) + val renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + }, + ) + 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) + + val handler = chart.viewPortHandler + // Guards the two assertions below: on an unlaid-out chart both points land on one edge. + assertThat(handler.contentBottom()).isLessThan(HEIGHT.toFloat()) + + // Below the plot is the time axis, which is what the sampling rate belongs to. + assertThat(renderer.helpTagAt(handler.contentBottom() + 1f)).isEqualTo(TooltipTag.CAROUSEL_AXIS_TIME) + // Inside the plot, the metric itself answers. + assertThat(renderer.helpTagAt((handler.contentTop() + handler.contentBottom()) / 2f)) + .isEqualTo(TooltipTag.CAROUSEL_CHART_NETWORK) + } + + private companion object { + const val TEST_UID = 10_123 + const val WIDTH = 720 + const val HEIGHT = 400 + const val SAMPLES = 60 + } +} diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 7c4e23f0e3..64d2025174 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -314,4 +314,19 @@ object TooltipTag { const val GIT_DIALOG_ABORT_MERGE = "git.dialog.abortmerge" const val GIT_PUSH = "git.action.push" const val GIT_PULL = "git.action.pull" + + // Editor metrics carousel (ADFA-5510). Unprefixed like every other tag here: the lookup is by + // tag AND category, and the category column already carries "ide". + const val CAROUSEL_PANEL = "carousel.panel" + const val CAROUSEL_TITLE = "carousel.title" + const val CAROUSEL_PREVIOUS = "carousel.previous" + const val CAROUSEL_NEXT = "carousel.next" + const val CAROUSEL_SNAPSHOT = "carousel.snapshot" + const val CAROUSEL_CHART_MEMORY = "carousel.chart.memory" + const val CAROUSEL_CHART_NETWORK = "carousel.chart.network" + const val CAROUSEL_CHART_POWER = "carousel.chart.power" + const val CAROUSEL_BATTERY = "carousel.battery" + const val CAROUSEL_AXIS_TIME = "carousel.axis.time" + const val CAROUSEL_RATE = "carousel.rate" + const val CAROUSEL_UNDOCKED = "carousel.undocked" } From aa7c7198888e8e0910fc3b877e95eeb83aee4eac Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 00:16:37 -0700 Subject: [PATCH 036/128] 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 ec285377f7efa286cf336beb4553e69a8a885849 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 01:02:57 -0700 Subject: [PATCH 037/128] feat(metrics): annotate build start, finish and failure on the charts (ADFA-5509) Three markers, drawn in the theme's semantic colours: colorSuccess for a build starting and finishing, colorError for one that failed, and the existing colorOnSurface for the Gradle task markers around them. Both the line and its label take the colour; colouring only the line leaves the label unreadable against a coloured rule. The wiring is three calls into hooks that already existed -- prepareBuild, onBuildSuccessful and onBuildFailed -- but two things had to change first, and one of them is the whole point of the ticket. The throttle would have eaten them. The store keeps one annotation every five seconds, because Gradle emits dozens of task events a second, and it keeps the first of each quiet period. A build failure arriving two seconds after a task marker would therefore have been dropped -- the one annotation on the chart actually worth having. Annotations now carry a kind, and only task events are throttled. A build marker also restarts the window, so the next task marker waits its five seconds instead of landing a few pixels away and colliding. Colour had to become per-annotation. applyAnnotations resolved one colour and painted every limit line with it, so the kind is carried through to the renderer and mapped there. The labels are user-facing text, so they are string resources in :resources, unlike the task markers which are raw Gradle task display names. Verified on a Pixel 6 Pro against the sample project: a green dashed marker labelled "Build started" when a build begins, and -- with a deliberate syntax error pushed into MainActivity.kt -- a red marker beside it when the build fails. The source was restored and confirmed byte-identical afterwards. Not verified on the device: the "Build finished" marker. It shares its code path and colour with "Build started", and the tests pin that the two resolve to one colour while a failure resolves to another, but the device check was defeated by the install prompt that follows a successful build: returning from it parks the chart's viewport at the oldest samples in the buffer for a sample or two, and the marker had scrolled out of the window by the time the chart recovered. That viewport glitch is a pre-existing bug of the same family as the rotation and floating-window cases fixed under ADFA-5486, and is being reported separately rather than folded in here. Co-Authored-By: Claude Opus 5 --- .../activities/editor/BaseEditorActivity.kt | 26 +++++++- .../handlers/EditorBuildEventListener.kt | 9 ++- .../androidide/ui/MetricsChartRenderer.kt | 28 ++++++++- .../utils/MetricsAnnotationStore.kt | 44 ++++++++++++- .../ui/MetricsAnnotationRenderingTest.kt | 43 ++++++++++++- .../utils/MetricsAnnotationStoreTest.kt | 62 +++++++++++++++++++ resources/src/main/res/values/strings.xml | 4 ++ 7 files changed, 207 insertions(+), 9 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 903e5f728f..de54574c05 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 @@ -129,6 +129,7 @@ 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.MetricsAnnotationStore import com.itsaky.androidide.utils.StringsInjectionException import com.itsaky.androidide.utils.StringsXmlInjector import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation @@ -209,8 +210,29 @@ abstract class BaseEditorActivity : } /** Records a significant event for the charts to annotate (ADFA-5486). */ - fun recordMetricsAnnotation(label: String) { - metricsViewModel.annotations.record(label) + fun recordMetricsAnnotation( + label: String, + kind: MetricsAnnotationStore.Kind = MetricsAnnotationStore.Kind.TASK, + ) { + metricsViewModel.annotations.record(label, kind) + } + + /** + * Marks a build outcome on the charts (ADFA-5509). + * + * Separate from [recordMetricsAnnotation] so the caller names the outcome rather than repeating + * the string lookup, and so these are never accidentally recorded as ordinary task markers -- + * which the throttle is allowed to drop. + */ + fun recordBuildAnnotation(kind: MetricsAnnotationStore.Kind) { + val label = + when (kind) { + MetricsAnnotationStore.Kind.BUILD_STARTED -> string.metrics_annotation_build_started + MetricsAnnotationStore.Kind.BUILD_FINISHED -> string.metrics_annotation_build_finished + MetricsAnnotationStore.Kind.BUILD_FAILED -> string.metrics_annotation_build_failed + MetricsAnnotationStore.Kind.TASK -> return + } + metricsViewModel.annotations.record(getString(label), kind) } private val fileManagerViewModel by viewModels() 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..946246d8cd 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -30,6 +30,7 @@ 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.MetricsAnnotationStore import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.BuildOutputViewModel @@ -79,7 +80,9 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } override fun prepareBuild(buildInfo: BuildInfo) { - checkActivity("prepareBuild") ?: return + val prepared = checkActivity("prepareBuild") ?: return + + prepared.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_STARTED) pluginBuildService?.setBuildInProgress(true) @@ -113,6 +116,8 @@ class EditorBuildEventListener : GradleBuildService.EventListener { override fun onBuildSuccessful(tasks: List) { val act = checkActivity("onBuildSuccessful") ?: return + act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FINISHED) + pluginBuildService?.notifyBuildFinished() analyzeCurrentFile() @@ -158,6 +163,8 @@ class EditorBuildEventListener : GradleBuildService.EventListener { override fun onBuildFailed(tasks: List) { val act = checkActivity("onBuildFailed") ?: return + act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FAILED) + analyzeCurrentFile() GeneralPreferences.isFirstBuild = false act.editorViewModel.isBuildInProgress = 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 15085b3b2e..abe2484e20 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -413,8 +413,6 @@ abstract class MetricsChartRenderer( 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() / interval val x = newestIndex - samplesAgo @@ -424,6 +422,7 @@ abstract class MetricsChartRenderer( chart.xAxis.addLimitLine( LimitLine(x, annotation.label).apply { + val markerColor = markerColorFor(chart, annotation.kind) lineWidth = ANNOTATION_LINE_WIDTH lineColor = markerColor textColor = markerColor @@ -437,6 +436,31 @@ abstract class MetricsChartRenderer( } } + /** + * The colour a marker is drawn in, from the kind of event it marks (ADFA-5509). + * + * Build outcomes are the events a user came to the chart for, so they get the theme's semantic + * colours -- success for a build starting or finishing, error for one that failed -- while the + * task markers that surround them stay in the ordinary text colour. Both the line and the label + * take it; colouring only the line would leave the label unreadable against a coloured rule. + */ + private fun markerColorFor( + chart: SafeLineChart, + kind: MetricsAnnotationStore.Kind, + ): Int { + val attr = + when (kind) { + MetricsAnnotationStore.Kind.BUILD_STARTED, + MetricsAnnotationStore.Kind.BUILD_FINISHED, + -> R.attr.colorSuccess + + MetricsAnnotationStore.Kind.BUILD_FAILED -> R.attr.colorError + + MetricsAnnotationStore.Kind.TASK -> R.attr.colorOnSurface + } + return chart.context.resolveAttr(attr) + } + /** * The row an annotation's label sits on, cycling so that neighbours never share one. */ 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 ae90870f42..8892058b80 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -46,6 +46,34 @@ class MetricsAnnotationStore( /** Hands each annotation its [Annotation.sequence]. */ private var nextSequence: Long = 0L + /** + * What kind of event an annotation marks, which decides both how it is drawn and whether the + * throttle applies to it (ADFA-5509). + */ + enum class Kind { + /** A Gradle task starting or finishing. Throttled: Gradle emits dozens a second. */ + TASK, + + /** A build beginning. */ + BUILD_STARTED, + + /** A build completing successfully. */ + BUILD_FINISHED, + + /** A build failing. */ + BUILD_FAILED, + ; + + /** + * Whether the throttle may drop this kind. + * + * Only task events. A build outcome dropped because a task marker happened to land two + * seconds earlier would be the one annotation on the chart worth having. + */ + val isThrottled: Boolean + get() = this == TASK + } + /** * An annotated moment. * @@ -65,23 +93,33 @@ class MetricsAnnotationStore( * makes consecutive annotations differ, which is when a collision is likeliest. */ val sequence: Long, + /** Decides the marker's colour, and whether the throttle could have dropped it. */ + val kind: Kind = Kind.TASK, ) /** * Records [label] unless another annotation was recorded within [THROTTLE_INTERVAL_MS]. * + * The throttle only applies to [Kind.TASK]; a build outcome is always kept. See + * [Kind.isThrottled]. + * * @return whether it was recorded. */ @Synchronized - fun record(label: String): Boolean { + fun record( + label: String, + kind: Kind = Kind.TASK, + ): Boolean { val now = nowMillis() val since = lastRecordedAt - if (since != null && now - since < THROTTLE_INTERVAL_MS) { + if (kind.isThrottled && since != null && now - since < THROTTLE_INTERVAL_MS) { return false } + // Set even for an unthrottled kind, so the next task marker waits its interval instead of + // landing a few pixels from a build marker and colliding with it. lastRecordedAt = now - annotations.addLast(Annotation(now, label, nextSequence++)) + annotations.addLast(Annotation(now, label, nextSequence++, kind)) while (annotations.size > MAX_ANNOTATIONS) { annotations.removeFirst() } diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt index 98ccf47ea7..9c6176c102 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.ui import android.content.Context +import androidx.appcompat.view.ContextThemeWrapper import androidx.test.core.app.ApplicationProvider import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineDataSet @@ -35,7 +36,13 @@ import org.robolectric.RobolectricTestRunner */ @RunWith(RobolectricTestRunner::class) class MetricsAnnotationRenderingTest { - private val context = ApplicationProvider.getApplicationContext() + // Themed: the marker colours come from theme attributes, and against a bare application + // context every one of them resolves to 0, so a colour test would pass by comparing nothing. + private val context: Context = + ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), + com.itsaky.androidide.R.style.Theme_AndroidIDE, + ) /** A minimal renderer, so the placement is tested without a particular page's data. */ private class TestRenderer( @@ -144,6 +151,40 @@ class MetricsAnnotationRenderingTest { assertThat(rowsOf(chart).single()).isEqualTo(newestRowBefore) } + @Test + fun `a failed build is drawn in a different colour from a task marker`() { + val fixture = Fixture() + fixture.store.record("some task") + fixture.now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + fixture.store.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + + val (_, chart) = render(fixture) + + val lines = chart.xAxis.limitLines + assertThat(lines).hasSize(2) + assertThat(lines[1].lineColor).isNotEqualTo(lines[0].lineColor) + // The label sits on the line, so colouring only the line would leave it unreadable. + assertThat(lines[1].textColor).isEqualTo(lines[1].lineColor) + } + + @Test + fun `a build starting and finishing share one colour, distinct from a failure`() { + val fixture = Fixture() + fixture.store.record("Build started", MetricsAnnotationStore.Kind.BUILD_STARTED) + fixture.now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + fixture.store.record("Build finished", MetricsAnnotationStore.Kind.BUILD_FINISHED) + fixture.now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + fixture.store.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + + val (_, chart) = render(fixture) + + val lines = chart.xAxis.limitLines + assertThat(lines).hasSize(3) + // Started and finished are both outcomes worth seeing; only failure is bad news. + assertThat(lines[1].lineColor).isEqualTo(lines[0].lineColor) + assertThat(lines[2].lineColor).isNotEqualTo(lines[0].lineColor) + } + private companion object { const val SAMPLE_INTERVAL_MS = 1_000L const val SAMPLE_COUNT = 60 diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt index 1e672ab2dd..0b1f0c921a 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt @@ -143,4 +143,66 @@ class MetricsAnnotationStoreTest { assertThat(store.recentAnnotations(60_000L).map { it.sequence }).containsExactly(0L) } + + @Test + fun `a build outcome is kept even inside the throttle window`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("some task") + // Well inside the window that drops a task marker. + now += 1_000L + store.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + + // Dropped, this would be the one annotation on the chart worth having. + assertThat(store.recentAnnotations(60_000L).map { it.label }) + .containsExactly("some task", "Build failed") + .inOrder() + } + + @Test + fun `a task marker inside the window is still dropped`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("first task") + now += 1_000L + store.record("second task") + + // Guards the test above: the bypass must be for build outcomes only. + assertThat(store.recentAnnotations(60_000L).map { it.label }).containsExactly("first task") + } + + @Test + fun `a build outcome restarts the throttle window`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("Build started", MetricsAnnotationStore.Kind.BUILD_STARTED) + now += 1_000L + store.record("a task right behind it") + + // Otherwise the first task marker lands a few pixels from the build marker and collides. + assertThat(store.recentAnnotations(60_000L).map { it.label }).containsExactly("Build started") + } + + @Test + fun `the kind survives to the reader`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("Build started", MetricsAnnotationStore.Kind.BUILD_STARTED) + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + + // The renderer colours by kind, so it has to arrive intact. + assertThat(store.recentAnnotations(60_000L).map { it.kind }) + .containsExactly( + MetricsAnnotationStore.Kind.BUILD_STARTED, + MetricsAnnotationStore.Kind.BUILD_FAILED, + ).inOrder() + } + + @Test + fun `only task markers are throttled`() { + assertThat(MetricsAnnotationStore.Kind.TASK.isThrottled).isTrue() + assertThat(MetricsAnnotationStore.Kind.entries.filter { it.isThrottled }) + .containsExactly(MetricsAnnotationStore.Kind.TASK) + } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 5a4a57ae9e..6e77ed19ae 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1683,6 +1683,10 @@ Every %1$s %1$s (needs a 64-bit device) Temperature and power + + Build started + Build finished + Build failed Temperature and power chart Battery temp Power From aeac2bb0080c2b7c59b63ba1dcce5f152d55f787 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 01:09:41 -0700 Subject: [PATCH 038/128] 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 eb98d74997c8b6d1fa938b1b8bd3a9099e80de29 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 01:11:22 -0700 Subject: [PATCH 039/128] docs(metrics): re-attach the rebuild KDoc the help tag displaced (ADFA-5510) Adding `override val helpTag` above `rebuild()` left each renderer's rebuild KDoc documenting the property instead of the function. The property is already documented on the base class, so the fix is to put it above the comment rather than to write a second one. ktlint did not catch it: a KDoc before a property is legal, and only the identical mistake in MetricsAnnotationStore -- where the doc ended up inside a class body with nothing to attach to -- tripped the linter. Co-Authored-By: Claude Opus 5 --- .../java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt | 3 ++- .../java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt | 3 ++- 2 files changed, 4 insertions(+), 2 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 a7d3af98c6..0a2b886829 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -69,12 +69,13 @@ class MemoryUsageChartRenderer( pidToDatasetIdx.clear() } + override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY + /** * 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. */ - override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY @UiThread override fun rebuild() { 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 bfe570290b..4a3e4de0a9 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -64,10 +64,11 @@ class NetworkUsageChartRenderer( sampleIntervalMillis = sampleIntervalMillis, annotations = annotations, ) { + override val helpTag: String = TooltipTag.CAROUSEL_CHART_NETWORK + /** * Rebuilds both series from the full sample history. */ - override val helpTag: String = TooltipTag.CAROUSEL_CHART_NETWORK @UiThread override fun rebuild() { From c0a1c1606bb248ba37ac35fca21b36dd8218b090 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 04:53:18 -0700 Subject: [PATCH 040/128] fix(metrics): stop annotating syncs and cancels as builds (ADFA-5509) Five review findings, four of them behaviour a user would have seen. Opening a project drew a build. prepareBuild and onBuildSuccessful also fire for project initialization, which runs no tasks, so merely opening a project stamped a green "Build started" and "Build finished" pair on the charts -- and blamed the sync's own memory spike on a build nobody asked for. BuildInfo.tasks and the result's task list already distinguish the two; a sync now annotates nothing. A cancelled build was reported as a failure. The tooling API surfaces a cancel through onBuildFailed, so stopping a build yourself drew a red "Build failed" rule and left it on the chart for the next hour. GradleBuildService now tells the listener when a cancel is requested -- a defaulted interface method, since only a listener that cares needs it -- and a cancel gets its own kind, drawn in the ordinary text colour because it is neither good news nor bad. The colour tests could not have caught a mistake in the colours. All three asserted only that two resolved colours were equal or unequal, so swapping success and error passed every one of them while the chart told the user a failed build had succeeded. They now assert which attribute each kind resolves to, and were confirmed to fail with the two swapped. Labels moved onto the kind as string ids, which removes three problems at once: the label is resolved at draw time, so markers follow the system language rather than freezing the language they were recorded in -- the store lives in a ViewModel that outlives the activity; recordBuildAnnotation no longer needs a `when` with a silent `return` for the one kind it cannot label; and recordMetricsAnnotation loses the dead `kind` parameter that let any caller give a task name an unthrottled marker in the error colour. Marker colours are resolved once per redraw instead of once per annotation. applyAnnotations runs on every sampling tick across three charts and can hold MAX_ANNOTATIONS markers, and resolveAttr allocates a TypedValue per call. Also: prepareBuild now uses the checked local for its whole body rather than re-reading the WeakReference through the throwing `activity` property, which is what its four sibling handlers already do. Not changed: build start and finish still share one colour. A reviewer argued they should differ so the two ends of a build are distinguishable at a glance, which is a fair point, but green for both was an explicit product decision and the labels already differ. Co-Authored-By: Claude Opus 5 --- .../activities/editor/BaseEditorActivity.kt | 22 +++----- .../handlers/EditorBuildEventListener.kt | 50 +++++++++++++++---- .../services/builder/GradleBuildService.kt | 12 +++++ .../androidide/ui/MetricsChartRenderer.kt | 25 ++++++++-- .../utils/MetricsAnnotationStore.kt | 35 +++++++++++-- .../ui/MetricsAnnotationRenderingTest.kt | 43 ++++++++++++++-- resources/src/main/res/values/strings.xml | 1 + 7 files changed, 151 insertions(+), 37 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 de54574c05..f34796e79e 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 @@ -210,29 +210,19 @@ abstract class BaseEditorActivity : } /** Records a significant event for the charts to annotate (ADFA-5486). */ - fun recordMetricsAnnotation( - label: String, - kind: MetricsAnnotationStore.Kind = MetricsAnnotationStore.Kind.TASK, - ) { - metricsViewModel.annotations.record(label, kind) + fun recordMetricsAnnotation(label: String) { + metricsViewModel.annotations.record(label) } /** * Marks a build outcome on the charts (ADFA-5509). * - * Separate from [recordMetricsAnnotation] so the caller names the outcome rather than repeating - * the string lookup, and so these are never accidentally recorded as ordinary task markers -- - * which the throttle is allowed to drop. + * Separate from [recordMetricsAnnotation] so a build outcome cannot be recorded as an ordinary + * task marker, which the throttle is allowed to drop -- and so a task name cannot be recorded + * as an outcome, which would give it an unthrottled marker in the error colour. */ fun recordBuildAnnotation(kind: MetricsAnnotationStore.Kind) { - val label = - when (kind) { - MetricsAnnotationStore.Kind.BUILD_STARTED -> string.metrics_annotation_build_started - MetricsAnnotationStore.Kind.BUILD_FINISHED -> string.metrics_annotation_build_finished - MetricsAnnotationStore.Kind.BUILD_FAILED -> string.metrics_annotation_build_failed - MetricsAnnotationStore.Kind.TASK -> return - } - metricsViewModel.annotations.record(getString(label), kind) + metricsViewModel.annotations.recordBuild(kind) } private val fileManagerViewModel by viewModels() 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 946246d8cd..206918f423 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -48,6 +48,12 @@ class EditorBuildEventListener : GradleBuildService.EventListener { private var buildStartTimeMs: Long = System.currentTimeMillis() private var lastOutputTimeMs: Long = SystemClock.elapsedRealtime() + /** + * Set when the user asks for the running build to stop, so [onBuildFailed] can tell a cancel + * from a real failure. Cleared as each build is prepared. + */ + private var cancelRequested = false + private var enabled = true private var activityReference: WeakReference = WeakReference(null) @@ -80,30 +86,37 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } override fun prepareBuild(buildInfo: BuildInfo) { - val prepared = checkActivity("prepareBuild") ?: return + val act = checkActivity("prepareBuild") ?: return - prepared.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_STARTED) + cancelRequested = false + + // A project sync runs through the same callbacks with no tasks, so annotating every + // prepareBuild put a "Build started" marker on the chart merely for opening a project -- + // and blamed the sync's own memory spike on a build the user never ran. + if (buildInfo.tasks.isNotEmpty()) { + act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_STARTED) + } pluginBuildService?.setBuildInProgress(true) val isFirstBuild = GeneralPreferences.isFirstBuild - activity + act .setStatus( - activity.getString(if (isFirstBuild) string.preparing_first else string.preparing), + act.getString(if (isFirstBuild) string.preparing_first else string.preparing), ) if (isFirstBuild) { - activity.showFirstBuildNotice() + act.showFirstBuildNotice() } resetBuildTimers() - activity.editorViewModel.isBuildInProgress = true - activity.content.bottomSheet.clearBuildOutput() + act.editorViewModel.isBuildInProgress = true + act.content.bottomSheet.clearBuildOutput() if (buildInfo.tasks.isNotEmpty()) { onOutput( - activity.getString(R.string.title_run_tasks) + " : " + buildInfo.tasks, + act.getString(R.string.title_run_tasks) + " : " + buildInfo.tasks, ) } } @@ -116,7 +129,9 @@ class EditorBuildEventListener : GradleBuildService.EventListener { override fun onBuildSuccessful(tasks: List) { val act = checkActivity("onBuildSuccessful") ?: return - act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FINISHED) + if (tasks.isNotEmpty()) { + act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FINISHED) + } pluginBuildService?.notifyBuildFinished() @@ -145,6 +160,10 @@ class EditorBuildEventListener : GradleBuildService.EventListener { lastStatusLine = "" } + override fun onBuildCancelRequested() { + cancelRequested = true + } + override fun onProgressEvent(event: ProgressEvent) { val act = checkActivity("onProgressEvent") ?: return @@ -163,7 +182,18 @@ class EditorBuildEventListener : GradleBuildService.EventListener { override fun onBuildFailed(tasks: List) { val act = checkActivity("onBuildFailed") ?: return - act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FAILED) + if (tasks.isNotEmpty()) { + // A build the user stopped arrives through this same callback. Marking it as a failure + // would report their own deliberate action back to them in the error colour. + act.recordBuildAnnotation( + if (cancelRequested) { + MetricsAnnotationStore.Kind.BUILD_CANCELLED + } else { + MetricsAnnotationStore.Kind.BUILD_FAILED + }, + ) + } + cancelRequested = false analyzeCurrentFile() GeneralPreferences.isFirstBuild = false diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 1182029806..c116e5b19a 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -632,6 +632,9 @@ class GradleBuildService : override fun cancelCurrentBuild(): CompletableFuture { checkServerStarted() + // Before delegating: the cancellation surfaces as a build failure, and the listener needs + // to know it was asked for rather than reporting the user's own action as an error. + eventListener?.onBuildCancelRequested() return server!!.cancelCurrentBuild() } @@ -807,6 +810,15 @@ class GradleBuildService : /** Handles events received from a Gradle build. */ interface EventListener { + /** + * Called when the user asks for the running build to stop. + * + * The tooling API reports a cancelled build through [onBuildFailed], so a listener that + * wants to tell the two apart has to be told here. Defaulted, because only a listener that + * cares about the distinction needs it. + */ + fun onBuildCancelRequested() = Unit + /** * Called just before a build is started. * 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 abe2484e20..6ecbb9a9d2 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -413,6 +413,11 @@ abstract class MetricsChartRenderer( val interval = sampleIntervalMillis() val bufferSpanMillis = (newestIndex.toLong() + 1L) * interval val now = nowMillis() + // Resolved once per redraw rather than once per annotation: applyAnnotations runs on every + // sampling tick, there can be MAX_ANNOTATIONS of them, and resolveAttr allocates a + // TypedValue per call. + val markerColors = MetricsAnnotationStore.Kind.entries.associateWith { markerColorFor(chart, it) } + store.recentAnnotations(bufferSpanMillis).forEach { annotation -> val samplesAgo = (now - annotation.atMillis).toFloat() / interval val x = newestIndex - samplesAgo @@ -421,8 +426,8 @@ abstract class MetricsChartRenderer( } chart.xAxis.addLimitLine( - LimitLine(x, annotation.label).apply { - val markerColor = markerColorFor(chart, annotation.kind) + LimitLine(x, labelFor(chart, annotation)).apply { + val markerColor = markerColors.getValue(annotation.kind) lineWidth = ANNOTATION_LINE_WIDTH lineColor = markerColor textColor = markerColor @@ -436,6 +441,17 @@ abstract class MetricsChartRenderer( } } + /** + * An annotation's label, resolved now rather than when it was recorded. + * + * A build outcome carries a string id instead of text, so its marker follows the system + * language even though the store holding it outlives the activity that recorded it. + */ + private fun labelFor( + chart: SafeLineChart, + annotation: MetricsAnnotationStore.Annotation, + ): String = annotation.kind.labelRes?.let(chart.context::getString) ?: annotation.label + /** * The colour a marker is drawn in, from the kind of event it marks (ADFA-5509). * @@ -456,7 +472,10 @@ abstract class MetricsChartRenderer( MetricsAnnotationStore.Kind.BUILD_FAILED -> R.attr.colorError - MetricsAnnotationStore.Kind.TASK -> R.attr.colorOnSurface + // A cancel is the user's own doing, so it is neither good news nor bad. + MetricsAnnotationStore.Kind.BUILD_CANCELLED, + MetricsAnnotationStore.Kind.TASK, + -> R.attr.colorOnSurface } return chart.context.resolveAttr(attr) } 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 8892058b80..77b220762c 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -18,6 +18,8 @@ package com.itsaky.androidide.utils import android.os.SystemClock +import androidx.annotation.StringRes +import com.itsaky.androidide.resources.R.string /** * Records significant events for the metrics charts to annotate (ADFA-5486). @@ -50,18 +52,34 @@ class MetricsAnnotationStore( * What kind of event an annotation marks, which decides both how it is drawn and whether the * throttle applies to it (ADFA-5509). */ - enum class Kind { + enum class Kind( + /** + * The label for this kind, or `null` for [TASK], whose label is the Gradle task's own name. + * + * A resource id rather than resolved text: the store lives in a ViewModel that outlives an + * activity, so a label resolved at record time would keep the old language after the system + * locale changes. Holding the id also removes the only reason a caller had to know which + * string went with which kind. + */ + @StringRes val labelRes: Int?, + ) { /** A Gradle task starting or finishing. Throttled: Gradle emits dozens a second. */ - TASK, + TASK(labelRes = null), /** A build beginning. */ - BUILD_STARTED, + BUILD_STARTED(string.metrics_annotation_build_started), /** A build completing successfully. */ - BUILD_FINISHED, + BUILD_FINISHED(string.metrics_annotation_build_finished), /** A build failing. */ - BUILD_FAILED, + BUILD_FAILED(string.metrics_annotation_build_failed), + + /** + * A build stopped by the user. Not a failure: the platform reports a cancel through the + * same failure callback, and painting a deliberate stop in the error colour misreports it. + */ + BUILD_CANCELLED(string.metrics_annotation_build_cancelled), ; /** @@ -97,6 +115,13 @@ class MetricsAnnotationStore( val kind: Kind = Kind.TASK, ) + /** + * Records a build outcome. Its label comes from [Kind.labelRes], so the caller names the + * outcome and nothing else. + */ + @Synchronized + fun recordBuild(kind: Kind): Boolean = record(label = "", kind = kind) + /** * Records [label] unless another annotation was recorded within [THROTTLE_INTERVAL_MS]. * diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt index 9c6176c102..308173aa2b 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt @@ -23,7 +23,10 @@ import androidx.test.core.app.ApplicationProvider import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineDataSet import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.resolveAttr import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -162,7 +165,10 @@ class MetricsAnnotationRenderingTest { val lines = chart.xAxis.limitLines assertThat(lines).hasSize(2) - assertThat(lines[1].lineColor).isNotEqualTo(lines[0].lineColor) + // Which colour, not merely a different one: asserting inequality alone passes just as + // happily with the two attributes swapped, telling the user a failed build succeeded. + assertThat(lines[0].lineColor).isEqualTo(context.resolveAttr(R.attr.colorOnSurface)) + assertThat(lines[1].lineColor).isEqualTo(context.resolveAttr(R.attr.colorError)) // The label sits on the line, so colouring only the line would leave it unreadable. assertThat(lines[1].textColor).isEqualTo(lines[1].lineColor) } @@ -181,8 +187,39 @@ class MetricsAnnotationRenderingTest { val lines = chart.xAxis.limitLines assertThat(lines).hasSize(3) // Started and finished are both outcomes worth seeing; only failure is bad news. - assertThat(lines[1].lineColor).isEqualTo(lines[0].lineColor) - assertThat(lines[2].lineColor).isNotEqualTo(lines[0].lineColor) + assertThat(lines[0].lineColor).isEqualTo(context.resolveAttr(R.attr.colorSuccess)) + assertThat(lines[1].lineColor).isEqualTo(context.resolveAttr(R.attr.colorSuccess)) + assertThat(lines[2].lineColor).isEqualTo(context.resolveAttr(R.attr.colorError)) + } + + @Test + fun `a cancelled build is not drawn as a failure`() { + val fixture = Fixture() + fixture.store.recordBuild(MetricsAnnotationStore.Kind.BUILD_CANCELLED) + + val (_, chart) = render(fixture) + + // The user stopped the build themselves; reporting that back in the error colour reads as + // something having gone wrong. + val line = chart.xAxis.limitLines.single() + assertThat(line.lineColor).isNotEqualTo(context.resolveAttr(R.attr.colorError)) + assertThat(line.lineColor).isEqualTo(context.resolveAttr(R.attr.colorOnSurface)) + } + + @Test + fun `a build marker takes its label from its kind, not from the recorded text`() { + val fixture = Fixture() + fixture.store.recordBuild(MetricsAnnotationStore.Kind.BUILD_FAILED) + + val (_, chart) = render(fixture) + + // Resolved at draw time, so the marker follows the system language even though the store + // outlives the activity that recorded it. + assertThat( + chart.xAxis.limitLines + .single() + .label, + ).isEqualTo(context.getString(string.metrics_annotation_build_failed)) } private companion object { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 6e77ed19ae..5941177d9e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1687,6 +1687,7 @@ Build started Build finished Build failed + Build cancelled Temperature and power chart Battery temp Power From eb4335bbb4f9fd6cc02c78b599e47686e4afd8a5 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 05:27:56 -0700 Subject: [PATCH 041/128] 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 042/128] 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 9f15dd856a9cbba280c67040594f40eeb9d9b36f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 06:54:16 -0700 Subject: [PATCH 043/128] fix(metrics): range the power axes, and give the watcher its siblings' guards (ADFA-5499) Two axis defects with one cause, plus two guards this watcher was simply missing. Neither power axis was bounded, so MPAndroidChart ranged both over every entry in the data -- which includes the buffer's unsampled prefix, ten thousand slots that plot as zero. The 29-33C band the page exists to show was therefore pressed into the top tenth of the plot with a negative gridline beneath it, and it stayed that way for the hours the buffer takes to fill. Both axes now range over the samples on screen, skipping the unsampled prefix and anything the device does not report, with a plausible fallback span until the first readable temperature arrives. The right axis is pinned to zero. Unpinned it picked up the chart's 10% bottom padding and printed a negative watt label -- under a series deliberately plotted as a magnitude precisely so it could never read as negative power spent. The axis was offering exactly the reading the transform exists to prevent. PowerUsageWatcher was missing both guards its siblings carry. Without the interval clamp a non-positive value reaches delay(), which does not suspend for one, so the loop spins -- and this watcher does a registerReceiver binder call per iteration, so it spins more expensively than the other two. Without the terminal closed flag, a start after close() flips isWatching to true and launches into a cancelled scope: power sampling is then dead, isWatching lies about it, and the editor's `if (!isWatching) startWatching()` never retries while memory and network keep working. Both axis fixes were confirmed to fail without them. Co-Authored-By: Claude Opus 5 --- .../androidide/ui/PowerUsageChartRenderer.kt | 63 +++++++++++++++++++ .../androidide/utils/PowerUsageWatcher.kt | 20 +++++- .../ui/PowerUsageChartRendererTest.kt | 55 ++++++++++++++++ 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index 89e090d21d..247a53392e 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -30,6 +30,10 @@ import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.PowerUsageWatcher import com.itsaky.androidide.utils.PowerUsageWatcher.PowerUsage import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min import kotlin.math.roundToLong /** @@ -83,6 +87,7 @@ class PowerUsageChartRenderer( ) setData(chart, datasets) + applyAxisRanges(chart, usage) applyThermalShading(chart, usage) } @@ -97,6 +102,51 @@ class PowerUsageChartRenderer( rebuild() } + /** + * Ranges both axes over the samples on screen. + * + * Two problems, one cause. Left to itself MPAndroidChart ranges over every entry, which + * includes the buffer's unsampled prefix -- ten thousand slots that plot as zero -- so the + * 29-33C band this page exists to show was pressed into the top tenth of the plot with a + * negative gridline beneath it. And the right axis, unpinned, picked up MPAndroidChart's 10% + * bottom padding: a negative watt label under a series deliberately plotted as a magnitude + * precisely so it could never read as negative power spent. + */ + private fun applyAxisRanges( + chart: SafeLineChart, + usage: PowerUsage, + ) { + val visible = visibleSampleRange(chart, usage.temperatureMilliCelsius.size) + + var hottest = Float.NEGATIVE_INFINITY + var coldest = Float.POSITIVE_INFINITY + var peakWatts = 0f + for (index in visible) { + val milliCelsius = usage.temperatureMilliCelsius[index] + // Skip the unsampled prefix and anything the device does not report: both plot at + // zero, and letting zero into the range is what flattened the real readings. + if (milliCelsius != PowerUsageWatcher.UNAVAILABLE && milliCelsius != 0L) { + val celsius = milliCelsiusToCelsius(milliCelsius) + hottest = max(hottest, celsius) + coldest = min(coldest, celsius) + } + peakWatts = max(peakWatts, microWattsToWatts(usage.powerMicroWatts[index])) + } + + // Power always starts at zero: it is a magnitude, so there is nothing below it. + chart.axisRight.axisMinimum = 0f + chart.axisRight.axisMaximum = max(peakWatts * AXIS_HEADROOM, MIN_AXIS_WATTS) + + if (hottest.isFinite() && coldest.isFinite()) { + chart.axisLeft.axisMinimum = floor(coldest) - TEMPERATURE_MARGIN_CELSIUS + chart.axisLeft.axisMaximum = ceil(hottest) + TEMPERATURE_MARGIN_CELSIUS + } else { + // Nothing readable yet; a plausible room-to-warm span beats a range built from zeros. + chart.axisLeft.axisMinimum = DEFAULT_MIN_CELSIUS + chart.axisLeft.axisMaximum = DEFAULT_MAX_CELSIUS + } + } + /** * Paints a band behind the chart for each stretch of throttling, deepening with the level. * @@ -268,6 +318,19 @@ class PowerUsageChartRenderer( val TEMPERATURE_COLOR = Color.rgb(255, 138, 101) val POWER_COLOR = Color.rgb(129, 212, 250) + /** Keeps the tallest line off the top frame of the plot. */ + const val AXIS_HEADROOM = 1.1f + + /** Floor for the power axis, so an idle device still has a readable scale. */ + const val MIN_AXIS_WATTS = 2f + + /** Air above and below the temperature range, so the line is not drawn on the frame. */ + const val TEMPERATURE_MARGIN_CELSIUS = 1f + + /** Shown until the first readable temperature arrives. */ + const val DEFAULT_MIN_CELSIUS = 20f + const val DEFAULT_MAX_CELSIUS = 40f + /** Half the x-axis width of one sample, which is 1 because x values are sample indices. */ const val HALF_SAMPLE = 0.5f diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 29d162094b..3551001fbd 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -62,6 +62,13 @@ class PowerUsageWatcher 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 -- and nothing ever retries. + */ + private val closed = AtomicBoolean(false) + /** The running sampling loop, so [stopWatching] can actually stop it. */ private var samplingJob: Job? = null @@ -83,12 +90,13 @@ class PowerUsageWatcher * 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() } @@ -121,6 +129,11 @@ class PowerUsageWatcher } fun startWatching() { + if (closed.get()) { + log.warn("Power usage watcher is closed and cannot be restarted") + return + } + if (!watching.compareAndSet(false, true)) { log.warn("Power usage is already being watched") return @@ -158,6 +171,7 @@ class PowerUsageWatcher /** Stops sampling and releases the sampling thread. The watcher cannot be started again. */ fun close() { + closed.set(true) stopWatching() listener = null coroutineScope.cancelIfActive("Watcher closed") diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index 678b94bcbb..30209b5f1a 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.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 @@ -301,7 +304,59 @@ class PowerUsageChartRendererTest { assertThat(renderer.batteryReadout()).isNull() } + 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) + // The scroll to the newest samples is a job that only runs during a draw pass. + chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) + } + + @Test + fun `the power axis starts at zero, never below it`() { + val (_, chart) = + rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L }, power = LongArray(SAMPLES) { 7_000_000L })) + laidOut(chart) + + // Unpinned, the chart's own 10% bottom padding prints a negative watt label under a series + // plotted as a magnitude precisely so it could never read as negative power. + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + } + + @Test + fun `the temperature axis ignores the buffer's unsampled zeros`() { + // A real reading only in the newest slots; the rest of the buffer has never been written. + val temperature = LongArray(SAMPLES) + for (index in SAMPLES - 10 until SAMPLES) { + temperature[index] = 30_000L + } + val (_, chart) = rendererFor(usage(temperature = temperature)) + laidOut(chart) + + // Ranged over the zeros the 30C band is squeezed into the top tenth of the plot, with a + // negative gridline below it. + assertThat(chart.axisLeft.axisMinimum).isGreaterThan(20f) + assertThat(chart.axisLeft.axisMaximum).isLessThan(40f) + } + + @Test + fun `an unreadable temperature falls back to a plausible span`() { + val (_, chart) = + rendererFor(usage(temperature = LongArray(SAMPLES) { PowerUsageWatcher.UNAVAILABLE })) + laidOut(chart) + + // Nothing readable, so a sensible range beats one computed from placeholder zeros. + assertThat(chart.axisLeft.axisMinimum).isLessThan(chart.axisLeft.axisMaximum) + assertThat(chart.axisLeft.axisMaximum).isAtMost(40f) + } + private companion object { + const val WIDTH = 720 + const val HEIGHT = 400 + const val SAMPLES = 200 + const val OPAQUE = 0xFF000000.toInt() /** The palette ADFA-5499 specifies: green, cyan, yellow, orange, rust, red. */ From fb5bc7ba3ea0f01cfbe14960c5a799fa03c777aa Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 07:48:01 -0700 Subject: [PATCH 044/128] fix(metrics): re-baseline on resume, and stop sampling a device that cannot (ADFA-5489) Four review findings on the network watcher. A resume reported the whole gap as one interval. stopWatching() left lastRx and lastTx set, so the first sample afterwards took the delta against a counter read minutes earlier: background a Gradle download for three minutes and the legend read hundreds of MB/s while the axis stretched to match. The baseline is now dropped on stop, which is exactly what the null baseline already means elsewhere -- the next sample re-establishes it and contributes nothing. The baseline was also written outside the lock that clears it. sampleOnce wrote lastRx/lastTx on the sampler thread while clearHistory nulled them on the UI thread, so an interleaving could restore a pre-clear baseline and produce the same spike at the moment the user changed the sampling rate -- the failure the "cumulative baseline is dropped too" test exists to prevent, which it cannot see because it drives sampleOnce synchronously. listener was a plain var written by the UI thread and read by the sampler every tick, with no happens-before edge, so a null written in onPause could go unobserved and the sampler keep dispatching into a paused activity. Now @Volatile, as isSupported on the same class already was for the same reason. A device whose counters are unsupported kept the loop running anyway. isSupported latched false and sampleOnce returned immediately, but every interval still snapshotted the buffers, hopped to the main thread and repainted the chart with data known to be permanently zero. The loop now ends, and clears the watching flag as it goes so isWatching does not claim a sampler that has stopped. Not fixed here, deliberately: the legend's "/s" suffix. It is accurate on this branch, where the interval is a constructor value fixed at one second. It only becomes wrong once ADFA-5486 makes the rate user-settable, and only that branch has the interval available to the renderer, so the fix belongs there. Co-Authored-By: Claude Opus 5 --- .../androidide/utils/NetworkUsageWatcher.kt | 24 ++++++++++++++-- .../utils/NetworkUsageWatcherTest.kt | 28 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) 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 7911086b2e..9499f527d9 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -102,6 +102,7 @@ class NetworkUsageWatcher /** * Notified on the main thread after each sample. */ + @Volatile var listener: NetworkUsageListener? = null /** @@ -147,6 +148,15 @@ class NetworkUsageWatcher log.error("Network usage sampling failed; continuing", failure) } + // A device whose counters are unsupported has nothing further to give, and + // the loop was otherwise repainting three charts a second with data known + // to be permanently zero. Clearing the flag too, so isWatching does not + // claim a sampler that has stopped. + if (!isSupported) { + watching.set(false) + break + } + delay(updateInterval) } } @@ -157,6 +167,14 @@ class NetworkUsageWatcher */ fun stopWatching() { watching.set(false) + // Drop the cumulative baseline as well. Left set, the first sample after a resume + // reports everything transferred while the watcher was stopped as a single interval -- + // background a Gradle download for three minutes and the chart reads hundreds of MB/s. + // The next sample re-establishes it, which is what the null baseline means. + synchronized(historyLock) { + lastRx = null + lastTx = null + } // Cancel the job, not the scope. The loop spends nearly all its time in delay(), so waiting // for it to notice the flag leaves it sampling for up to a full interval after the editor // asked it to stop -- long enough for a stop/start to run two samplers at once. Cancelling @@ -204,8 +222,10 @@ class NetworkUsageWatcher record(transmitted, previous = lastTx, current = tx) } - lastRx = rx - lastTx = tx + synchronized(historyLock) { + lastRx = rx + lastTx = tx + } } /** diff --git a/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt index c21420595a..2ec49fbf69 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt @@ -159,6 +159,34 @@ class NetworkUsageWatcherTest { assertThat(fixture.watcher.getUsage().received).isNotEqualTo(asHandedOut) } + @Test + fun `stopping drops the cumulative baseline so a resume does not spike`() { + // 1 MB transferred, then the watcher is stopped while a download keeps running. + val fixture = Fixture(listOf(1_000_000L, 1_000_000L, 250_000_000L, 250_500_000L)) + fixture.sample(2) + + fixture.watcher.stopWatching() + + // Resume: the counter has moved by 249 MB while nothing was watching. + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + // Kept, the baseline turns the whole gap into one interval's traffic -- the legend reads + // hundreds of MB/s and the axis is stretched for the next minute. + assertThat(usage.received.recent(2)).containsExactly(0L, 500_000L).inOrder() + } + + @Test + fun `an unsupported counter stops the watcher rather than sampling zeroes forever`() { + val fixture = Fixture(listOf(-1L)) + + fixture.sample(1) + + // Nothing more to read, so nothing more to do: the loop was repainting the charts once a + // second with data known to be permanently unavailable. + assertThat(fixture.watcher.isSupported).isFalse() + } + private companion object { const val TEST_UID = 10_123 } From c34b648e8cf61273dff81e7e85ba834578e53d87 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 07:51:37 -0700 Subject: [PATCH 045/128] 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 379b91c51ca045dbd97fd3ed1c3974236ad4e476 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 07:54:13 -0700 Subject: [PATCH 046/128] fix(metrics): keep the undocked message's help, and stop the target list drifting (ADFA-5510) The "In a floating window" message could never show its tooltip, and the fault was mine twice over. It is only visible while the carousel is undocked -- and undocking calls unbind(), whose listener-clearing block I added for a different reason took that view's long-press listener with it. Docked, the view is gone. So the tag was unreachable in both states, which is the whole of what it was for. The controls are now one list, helpTargets(binding), driving the wiring, the unwiring and the test. There were three hand-maintained copies, which is precisely how a control added later gets help on binding and keeps a stale listener capturing a dead binding after unbinding -- the hazard MetricsCarouselLayout.setUndocked already carries a comment about. Unbinding clears every target except the undocked message, for the reason above. The test that claimed to check the tags asserted the TooltipTag constants against their own string literals, so it would have passed with two controls' tags swapped and never read the wiring at all. It now asserts which view each tag reaches, and that no two controls share one. Left as it is: carousel.panel. Its children tile the strip, so a long press almost always lands on a child that answers for itself, and the base of the strip is the pager, whose chart consumes its own touches. It is a genuine catch-all for the gaps rather than a control, and wiring it costs nothing -- but it will rarely be what answers, and the PR should not claim otherwise. Co-Authored-By: Claude Opus 5 --- .../ui/MetricsCarouselController.kt | 61 ++++++++++++------- .../androidide/ui/MetricsCarouselHelpTest.kt | 33 ++++++++-- 2 files changed, 67 insertions(+), 27 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 5939076eb5..785bdc141f 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -26,6 +26,7 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Toast import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.viewpager2.widget.ViewPager2 @@ -222,15 +223,35 @@ class MetricsCarouselController( @UiThread private fun wireHelp(binding: LayoutMemUsageBinding) { val context = binding.root.context - binding.root.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_PANEL) - binding.metricsTitle.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_TITLE) - binding.metricsPrevious.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_PREVIOUS) - binding.metricsNext.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_NEXT) - binding.metricsSnapshot.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_SNAPSHOT) - binding.metricsBattery.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_BATTERY) - binding.metricsUndockedMessage.displayTooltipOnLongPress(context, TooltipTag.CAROUSEL_UNDOCKED) + helpTargets(binding).forEach { (view, tag) -> + view.displayTooltipOnLongPress(context, tag) + } } + /** + * Every control that answers a long press, and the tag it answers with. + * + * One list drives the wiring, the unwiring and the test, because three hand-maintained copies + * is how a control added later gets help on binding and keeps a stale listener after unbinding. + * + * The charts are absent on purpose: MPAndroidChart swallows the touch events a view-level long + * press needs, so each renderer answers through the chart's own gesture listener instead. + */ + @VisibleForTesting + internal fun helpTargets(binding: LayoutMemUsageBinding): List> = + listOf( + // The strip itself, for the gaps its children do not cover. + binding.root to TooltipTag.CAROUSEL_PANEL, + binding.metricsTitle to TooltipTag.CAROUSEL_TITLE, + binding.metricsPrevious to TooltipTag.CAROUSEL_PREVIOUS, + binding.metricsNext to TooltipTag.CAROUSEL_NEXT, + binding.metricsSnapshot to TooltipTag.CAROUSEL_SNAPSHOT, + binding.metricsBattery to TooltipTag.CAROUSEL_BATTERY, + // Wired even though it is only visible while undocked: the message is the one control + // that outlives unbind(), so its help must not be torn down with the rest. + binding.metricsUndockedMessage to TooltipTag.CAROUSEL_UNDOCKED, + ) + /** * Stops feeding the carousel and releases the bound views. Sampling is unaffected -- the * watchers keep their history, so re-binding shows it in full. @@ -252,20 +273,18 @@ class MetricsCarouselController( powerRenderer.onXAxisTap = null binding?.metricsSnapshot?.setOnClickListener(null) binding?.let { bound -> - listOf( - bound.root, - bound.metricsTitle, - bound.metricsPrevious, - bound.metricsNext, - bound.metricsSnapshot, - bound.metricsBattery, - bound.metricsUndockedMessage, - ).forEach { control -> - control.setOnLongClickListener(null) - // setOnLongClickListener(null) leaves isLongClickable set, so the view would still - // claim a long press it no longer answers. - control.isLongClickable = false - } + helpTargets(bound) + // All but the undocked message: that view becomes visible *because* the carousel + // unbound, so clearing its listener here left the one control the user can still + // reach with no help at all. + .filterNot { (view, _) -> view === bound.metricsUndockedMessage } + .map { (view, _) -> view } + .forEach { control -> + control.setOnLongClickListener(null) + // setOnLongClickListener(null) leaves isLongClickable set, so the view would still + // claim a long press it no longer answers. + control.isLongClickable = false + } } binding?.metricsPrevious?.setOnClickListener(null) binding?.metricsNext?.setOnClickListener(null) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt index 6b8422a3e0..ed9ec75c93 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt @@ -115,12 +115,33 @@ class MetricsCarouselHelpTest { } @Test - fun `each chart page declares its own help tag`() { - // The charts are not in the list above: MPAndroidChart swallows the touch events a view - // long press needs, so they answer through the chart's gesture listener instead. - assertThat(TooltipTag.CAROUSEL_CHART_MEMORY).isEqualTo("carousel.chart.memory") - assertThat(TooltipTag.CAROUSEL_CHART_NETWORK).isEqualTo("carousel.chart.network") - assertThat(TooltipTag.CAROUSEL_CHART_POWER).isEqualTo("carousel.chart.power") + fun `each control is wired to its own tag`() { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + val targets = controller().helpTargets(binding) + + // Asserting the constants against their own literals, as this test used to, would pass + // just as happily with two controls' tags swapped. + val byTag = targets.associate { (view, tag) -> tag to view } + assertThat(byTag[TooltipTag.CAROUSEL_PREVIOUS]).isSameInstanceAs(binding.metricsPrevious) + assertThat(byTag[TooltipTag.CAROUSEL_NEXT]).isSameInstanceAs(binding.metricsNext) + assertThat(byTag[TooltipTag.CAROUSEL_SNAPSHOT]).isSameInstanceAs(binding.metricsSnapshot) + assertThat(byTag[TooltipTag.CAROUSEL_BATTERY]).isSameInstanceAs(binding.metricsBattery) + assertThat(byTag[TooltipTag.CAROUSEL_TITLE]).isSameInstanceAs(binding.metricsTitle) + assertThat(byTag[TooltipTag.CAROUSEL_UNDOCKED]).isSameInstanceAs(binding.metricsUndockedMessage) + // Every tag distinct, so no two controls can answer with the same one. + assertThat(targets.map { it.second }.toSet()).hasSize(targets.size) + } + + @Test + fun `unbinding keeps the undocked message answering`() { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + val controller = controller() + controller.bind(binding) + controller.unbind() + + // That view becomes visible *because* the carousel unbound, so clearing its listener left + // the one control a user can still reach with no help at all. + assertThat(binding.metricsUndockedMessage.isLongClickable).isTrue() } @Test From 5d00a796ab5af584a84cdf0b7a220cceed849bf6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 08:13:08 -0700 Subject: [PATCH 047/128] fix(metrics): correctness findings from review on the carousel (ADFA-5487) resetMemUsageChart() ran on two background threads. The renderer documents itself as UI-thread-only, and rebuild() clears and repopulates a non-thread-safe pid-to-dataset map that the once-a-second sample listener reads on the main thread. The tooling server's start callback arrives on its own thread and the metadata correction on a CompletableFuture completion thread, so either could interleave with a tick and plot one process's samples on another's line, or throw out of the entry loop. Both now post to the main thread. The process colour lookup could take the editor down, from a timer. It threw IllegalArgumentException for an unrecognised process name, which was survivable while only two explicit call sites reached it -- this PR routes it through the 1 Hz listener and through RecyclerView's bind pass. An unknown name now falls back to grey. It also moves to the companion: a bound reference to an activity method is handed to the renderer, which the adapter holds, and nothing in the function needs an activity. containsTouch compared window coordinates against screen coordinates. getGlobalVisibleRect reports the rect in window space -- ViewRootImpl intersects with the window and never offsets by its position on screen -- while rawX/rawY are screen coordinates. In split-screen or freeform the window origin is not zero, so the drawer gesture was dead over the carousel and live below it. Now uses getLocationOnScreen, the idiom SwipeRevealLayout.isTouchInDragHandle already used in this same file. The drawer fling was excluded over the whole strip even when the carousel could not use it. A left-to-right fling pages the carousel backwards, and the carousel opens on the first page, so on that page the gesture did nothing at all while the documented right-swipe drawer gesture stayed dead. The exclusion now applies only when there is a previous page, and only over the pager rather than the whole strip. The reveal drag relaid out a ViewPager2 every frame. The inset compensation moved from a chart view to the pager, so a margin change now re-measures the pager, its RecyclerView and every attached page on each frame of the drag. A translationY gives the same result for a pure vertical offset with no layout pass. The viewpager2 dependency pointed at 1.1.0-beta02 while the catalog's other alias for the same module is 1.0.0, so Gradle's conflict resolution upgraded the whole app classpath -- including appintro, compiled against 1.0.0 -- to a pre-release nobody chose. Now uses the stable alias. The brand strings duplicated app_name, were translatable, and had drifted to a different capitalisation of the product name. The title now uses app_name; the content description is one string, not translatable. Two claims in the diff were false and are now either true or gone: the in-place update path does not "allocate nothing" -- it reformats a legend label per series per tick -- and the byte-per-megabyte constant was defined twice, once in main and once in the test, so the test verified its own arithmetic rather than the renderer's. Two tests were strengthened. "onUsagesChanged after detach is a no-op" asserted nothing at all and passed with the guard deleted; it now snapshots the chart and asserts it is unchanged. And the branch production actually hits -- same process count, one pid swapped, which is what a tooling-server pid correction produces -- had no coverage, so correctness rested on getDataSetByIndex(-1) happening to return null. Co-Authored-By: Claude Opus 5 --- app/build.gradle.kts | 2 +- .../activities/editor/BaseEditorActivity.kt | 69 ++++++++++++++----- .../editor/ProjectHandlerActivity.kt | 9 ++- .../androidide/ui/MemoryUsageChartRenderer.kt | 8 ++- .../ui/MemoryUsageChartRendererTest.kt | 43 ++++++++++-- resources/src/main/res/values/strings.xml | 5 +- 6 files changed, 107 insertions(+), 29 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index be8c074e7a..d57f254ba4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -331,7 +331,7 @@ dependencies { implementation(libs.google.material) // Metrics carousel (ADFA-5487). Already on the classpath transitively; declared so the // compile-time use in MetricsCarouselAdapter does not depend on another library's graph. - implementation(libs.androidx.viewpager2.v110beta02) + implementation(libs.androidx.viewpager2) implementation(libs.google.flexbox) implementation(libs.libsu.core) 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 3f4794af23..5e00e7a010 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 @@ -192,7 +192,7 @@ abstract class BaseEditorActivity : private val memUsageChartRenderer = MemoryUsageChartRenderer( usagesProvider = memoryUsageWatcher::getMemoryUsages, - lineColorFor = ::getMemUsageLineColorFor, + lineColorFor = Companion::getMemUsageLineColorFor, ) private val fileManagerViewModel by viewModels() @@ -439,7 +439,26 @@ abstract class BaseEditorActivity : companion object { const val DEBUGGER_SERVICE_STOP_DELAY_MS: Long = 60 * 1000 + /** + * The plot colour for a watched process. + * + * On the companion rather than the activity: a bound reference to an activity method is + * handed to the renderer, which the carousel adapter holds, so any path that misses the + * adapter teardown would keep the whole editor reachable. Nothing here needs an activity. + * + * An unrecognised name falls back rather than throwing. The renderer now reaches this 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. + */ @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 -> Color.GRAY + } + protected val PROC_IDE = "IDE" @JvmStatic @@ -965,9 +984,11 @@ abstract class BaseEditorActivity : content.editorAppBarLayout.updatePadding(top = topInset) } - memUsageView.metricsPager.updateLayoutParams { - topMargin = (insetsTop * progress).roundToInt() - } + // translationY, not a margin: this runs on every frame of the reveal drag, and a + // margin change calls requestLayout, which now re-measures a ViewPager2, its + // RecyclerView and every attached page rather than the single chart view it used to. + // The visual result is identical for a pure vertical offset. + memUsageView.metricsPager.translationY = insetsTop * progress } } @@ -980,7 +1001,8 @@ abstract class BaseEditorActivity : MetricsPage.Image( drawable = R.drawable.cogo_brand_mark, description = string.metrics_carousel_brand_mark, - title = string.metrics_title_brand_mark, + // The product's own name, from the one place it is defined. + title = string.app_name, ), ) @@ -1017,14 +1039,6 @@ abstract class BaseEditorActivity : memUsageChartRenderer.rebuild() } - 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() memoryUsageWatcher.listener = null @@ -1855,6 +1869,14 @@ abstract class BaseEditorActivity : private fun isTouchOnMetricsCarousel(ev: MotionEvent): Boolean { val binding = _binding ?: return false + // A left-to-right fling pages the carousel *backwards*, so there is nothing for it to do + // on the first page -- which is the page the carousel opens on. Excluding the strip + // regardless left the documented right-swipe drawer gesture dead over the whole panel + // while doing nothing in its place. + if (binding.memUsageView.metricsPager.currentItem <= 0) { + return false + } + // The carousel is laid out at the top of the reveal even while the content card covers it, // and siblings do not clip each other, so getGlobalVisibleRect reports it visible either // way. Without this check the drawer gesture would be dead over the top of a closed editor. @@ -1862,16 +1884,29 @@ abstract class BaseEditorActivity : return false } - return containsTouch(binding.memUsageView.root, ev) + // The pager, not the whole strip: the title and its row are not something the carousel + // pages from, and MetricsCarouselLayout has already walled that row off from every + // ancestor, so a fling there would otherwise be swallowed twice over. + return containsTouch(binding.memUsageView.metricsPager, ev) } private fun containsTouch( view: View, ev: MotionEvent, ): Boolean { - val rect = Rect() - if (!view.getGlobalVisibleRect(rect)) return false - return rect.contains(ev.rawX.toInt(), ev.rawY.toInt()) + if (!view.isShown) return false + + // getLocationOnScreen, not getGlobalVisibleRect: the latter reports window coordinates -- + // ViewRootImpl intersects with the window and never offsets by its position on screen -- + // while rawX/rawY are screen coordinates. In split-screen or freeform the window origin is + // not zero, so the two disagree and the hit test lands somewhere else entirely. + // SwipeRevealLayout.isTouchInDragHandle already uses this idiom. + val location = IntArray(2) + view.getLocationOnScreen(location) + val x = ev.rawX.toInt() + val y = ev.rawY.toInt() + return x >= location[0] && x < location[0] + view.width && + y >= location[1] && y < location[1] + view.height } private fun showTooltip(tag: String) { diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 26ed2966e3..afc6707577 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -716,7 +716,11 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { service.startToolingServer { pid -> memoryUsageWatcher.watchProcess(pid, PROC_GRADLE_TOOLING) - resetMemUsageChart() + // The callback arrives on the tooling server's own thread, and the renderer is + // @UiThread: rebuild() clears and repopulates a non-thread-safe pid map that the + // once-a-second sample listener reads on the main thread, so racing it can plot one + // process's samples on another's line or throw out of the entry loop. + runOnUiThread { resetMemUsageChart() } service.metadata().whenComplete { metadata, err -> if (metadata == null || err != null) { @@ -731,7 +735,8 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { metadata.pid, ) memoryUsageWatcher.watchProcess(metadata.pid, PROC_GRADLE_TOOLING) - resetMemUsageChart() + // A CompletableFuture completion thread, for the same reason as above. + runOnUiThread { resetMemUsageChart() } } } 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..8d348944a7 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -147,8 +147,10 @@ class MemoryUsageChartRenderer( * * Falls back to [rebuild] when [memoryUsage] no longer matches the datasets the chart was built * with -- a process started or stopped being watched, or the chart was attached before this pid - * existed. The in-place path is the common one and allocates nothing, which matters because this - * runs once a second for the lifetime of the editor. + * existed. The in-place path is the common one: it mutates the existing entries rather than + * rebuilding the datasets, which is what matters because this runs once a second for the + * lifetime of the editor. It is not allocation-free -- each series reformats its legend label + * every tick -- so do not add work here on the assumption that it is. */ @UiThread fun onUsagesChanged(memoryUsage: IntObjectMap) { @@ -222,7 +224,7 @@ class MemoryUsageChartRenderer( ): String = "%s - %.2fMB".format(pname, megabytes) } -private const val BYTES_PER_MEGABYTE = 1024.0 * 1024.0 +internal const val BYTES_PER_MEGABYTE = 1024.0 * 1024.0 /** * The sample at [index] in megabytes. [MemoryUsageWatcher] stores bytes. 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..5d1c8bab2a 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -126,15 +126,45 @@ class MemoryUsageChartRendererTest { @Test fun `onUsagesChanged after detach is a no-op`() { - val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + var processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) val renderer = renderer { processes } - renderer.attach(chart()) + val detached = chart() + renderer.attach(detached) + val before = datasetFor(detached, 0).entries.map { it.y } + renderer.detach() - // A recycled carousel page must not keep the renderer writing into a dead view. + // Different samples, so a renderer that kept writing would visibly change the chart. + processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 900)) renderer.onUsagesChanged( MutableIntObjectMap().apply { put(1, processes[0]) }, ) + + // A recycled carousel page must not keep the renderer writing into a dead view. Asserting + // only that the call does not throw pinned nothing: it would not have thrown anyway. + assertThat(datasetFor(detached, 0).entries.map { it.y }).isEqualTo(before) + } + + @Test + fun `a swapped process rebuilds rather than plotting its samples on another line`() { + // The count stays the same and a pid changes -- what a tooling-server pid correction does. + // The suite covered only the count-changed path, and correctness here rested on + // getDataSetByIndex(-1) happening to return null. + val first = proc(pid = 1, pname = "IDE", firstMegabytes = 100) + var processes = arrayOf(first) + val renderer = renderer { processes } + val chart = chart() + renderer.attach(chart) + + val replacement = proc(pid = 2, pname = "Gradle Tooling", firstMegabytes = 700) + processes = arrayOf(replacement) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { put(2, replacement) }, + ) + + assertThat(chart.data.dataSetCount).isEqualTo(1) + assertThat(datasetFor(chart, 0).label).startsWith("Gradle Tooling - ") + assertThat(datasetFor(chart, 0).entries.first().y).isEqualTo(700f) } @Test @@ -152,6 +182,11 @@ class MemoryUsageChartRendererTest { } private companion object { - const val BYTES_PER_MB = 1024L * 1024L + /** + * The production constant, not a copy of it. With its own literal the test verified its + * own arithmetic: change the renderer to decimal megabytes and every assertion still + * passed because both sides had stopped agreeing. + */ + val BYTES_PER_MB = BYTES_PER_MEGABYTE.toLong() } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 808f226785..927526c1ab 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1675,8 +1675,9 @@ Memory usage chart Memory usage - Code On The Go - Code On The Go logo + + Code on the Go logo From 092b633f8046f6ca193e65159032e383d1704833 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 08:14:26 -0700 Subject: [PATCH 048/128] refactor: delete SwipeRevealLayout's two dead horizontal drag helpers No behaviour change. Both were provably unreachable and still ran on every touch event and every animation frame. LeftDragCallback.tryCaptureView required a child whose id is R.id.drawer_sidebar. The layout asserts childCount == 2 and indexes its children positionally -- the hidden content and the overlapping content -- and drawer_sidebar is a FragmentContainerView inside the NavigationView, not a child here, so it was never true. RightDragCallback.tryCaptureView already returned false unconditionally, having been narrowed earlier in this stack when it was found capturing whichever child sat under a horizontal drag. Yet onInterceptTouchEvent still asked both helpers whether to intercept, onTouchEvent still fed both every event, and computeScroll still settled both on every frame. Their onViewPositionChanged also reported horizontal travel to dragListener as though it were vertical reveal progress, which is exactly the kind of thing a reader trusts and then debugs the wrong way round. Gone with them: leftDragProgress and rightDragProgress, both unread. Co-Authored-By: Claude Opus 5 --- .../itsaky/androidide/ui/SwipeRevealLayout.kt | 77 +------------------ 1 file changed, 2 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt index 9e3066d0a6..429b9f6a18 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt @@ -67,11 +67,6 @@ open class SwipeRevealLayout ) } - private val leftDragHelper: ViewDragHelper - private val rightDragHelper: ViewDragHelper - - private var leftDragProgress = 0f - private var rightDragProgress = 0f private var isVerticalDragEnabled = true private var isDownInDragHandle = false @@ -84,8 +79,6 @@ open class SwipeRevealLayout private val dragHandleLocation = IntArray(2) init { - leftDragHelper = ViewDragHelper.create(this, 1f, LeftDragCallback()) - rightDragHelper = ViewDragHelper.create(this, 1f, RightDragCallback()) } private val dragHelperCallback = @@ -277,24 +270,17 @@ open class SwipeRevealLayout override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { val action = ev.actionMasked if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { - leftDragHelper.cancel() - rightDragHelper.cancel() dragHelper.cancel() return false } if (action == MotionEvent.ACTION_DOWN) { isDownInDragHandle = isTouchInDragHandle(ev) } - val isLeft = leftDragHelper.shouldInterceptTouchEvent(ev) - val isRight = rightDragHelper.shouldInterceptTouchEvent(ev) - val isVertical = dragHelper.shouldInterceptTouchEvent(ev) - return isLeft || isRight || isVertical + return dragHelper.shouldInterceptTouchEvent(ev) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { - leftDragHelper.processTouchEvent(event) - rightDragHelper.processTouchEvent(event) dragHelper.processTouchEvent(event) return true } @@ -325,7 +311,7 @@ open class SwipeRevealLayout } override fun computeScroll() { - if (leftDragHelper.continueSettling(true) or rightDragHelper.continueSettling(true) or dragHelper.continueSettling(true)) { + if (dragHelper.continueSettling(true)) { postInvalidateOnAnimation() } } @@ -410,63 +396,4 @@ open class SwipeRevealLayout postInvalidateOnAnimation() } } - - private inner class LeftDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView( - child: View, - pointerId: Int, - ): Boolean { - return child.id == R.id.drawer_sidebar // Your left drawer ID - } - - override fun onViewPositionChanged( - changedView: View, - left: Int, - top: Int, - dx: Int, - dy: Int, - ) { - leftDragProgress = left.toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, leftDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal( - child: View, - left: Int, - dx: Int, - ): Int = max(0, min(left, width - child.width)) - } - - private inner class RightDragCallback : ViewDragHelper.Callback() { - // There is no right drawer in this layout: R.id.right_drawer_sidebar does not exist, so - // the intended check was commented out and this returned true for every child. That made - // the helper capture whichever child sat under a horizontal drag and offset it sideways, - // and its onViewPositionChanged reported that horizontal travel to dragListener as if it - // were vertical reveal progress. It also stole horizontal gestures from child views, so a - // horizontally scrolling child (ADFA-5487's metrics carousel) raced this helper for them. - // Capture nothing until a right drawer actually exists to name here. - override fun tryCaptureView( - child: View, - pointerId: Int, - ): Boolean = false - - override fun onViewPositionChanged( - changedView: View, - left: Int, - top: Int, - dx: Int, - dy: Int, - ) { - rightDragProgress = (width - left).toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, rightDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal( - child: View, - left: Int, - dx: Int, - ): Int = max(width - child.width, min(left, width)) - } } From 14aae3f20855498a8db081918160ac4cf5012c07 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 09:27:00 -0700 Subject: [PATCH 049/128] ADFA-5499: address review findings on the power page One axis rules the plot. Both axes were drawing grid lines at their own pitch, so the plot carried two interleaved sets of horizontal rules -- nine of them, including a pair eight pixels apart. The left axis is enabled for its labels alone, so it no longer rules; that is now a base-class invariant rather than a per-page reminder. With the tight post-fix range the temperature axis also needed whole-degree granularity, or it printed "29C, 30C, 30C, 31C". onUsageChanged mutates the series in place. It used to discard the sample it was handed and call rebuild, which asked the watcher for another copy of all three buffers and allocated two datasets and twenty thousand entries -- every tick, on the UI thread. The KDoc justified that with "two short series"; they are MAX_USAGE_ENTRIES long. rebuild now takes the sample, so the fallback path cannot disagree with the fast one. DevicePowerSource: read EXTRA_PLUGGED rather than EXTRA_STATUS, since a full battery on the charger reports BATTERY_STATUS_FULL and read as discharging; map the pre-API-29 thermal fallback to THERMAL_STATUS_LIGHT rather than SEVERE, so a device that cannot report shading is not painted as if it were throttling hard; and drop a power reading outside a plausible envelope, because OEMs diverge on both the sign and the unit of BATTERY_PROPERTY_CURRENT_NOW and a microamp reading plots as kilowatts. SafeLineChart transforms span endpoints through a reused buffer instead of two pooled MPPointD instances it never recycled, in a method that runs for every span on every frame of every pan and zoom. Tests: the draw order of the spans against the grid background, asserted against pixels under Robolectric's native graphics -- the geometry tests could not see the bug, because backgroundSpans was correct all along and the shading was simply painted and then covered. Plus the gridline and granularity invariants, and both onUsageChanged paths. Also folds two identical private ShiftedLongArray snapshot extensions into one shared internal one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsChartRenderer.kt | 5 + .../androidide/ui/PowerUsageChartRenderer.kt | 79 ++++++- .../com/itsaky/androidide/ui/SafeLineChart.kt | 15 +- .../androidide/utils/DevicePowerSource.kt | 36 +++- .../androidide/utils/NetworkUsageWatcher.kt | 7 +- .../androidide/utils/PowerUsageWatcher.kt | 7 +- .../androidide/utils/ShiftedLongArray.kt | 204 +++++++++--------- .../ui/PowerUsageChartRendererTest.kt | 47 ++++ .../itsaky/androidide/ui/SafeLineChartTest.kt | 98 +++++++++ 9 files changed, 368 insertions(+), 130 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/SafeLineChartTest.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 574e220ae8..02914c74db 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -172,6 +172,11 @@ abstract class MetricsChartRenderer( // The right axis carries the labels; the left is unused. axisLeft.isEnabled = false + // The right axis rules the plot. Harmless while the left one is disabled, and it means + // a page that enables the left for a second unit gets its labels without a second set + // of grid lines at unrelated heights -- MPAndroidChart rules the plot once per enabled + // axis, and AxisBase defaults to drawing them. + axisLeft.setDrawGridLines(false) onChartGestureListener = XAxisTapListener(this) diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index 247a53392e..121fdb5947 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -63,9 +63,17 @@ class PowerUsageChartRenderer( annotations = annotations, ) { @UiThread - override fun rebuild() { + override fun rebuild() = rebuild(usageProvider()) + + /** + * Replaces both series from [usage]. + * + * Takes the sample rather than fetching one so [onUsageChanged] can fall back to it without + * asking the watcher for a second, later copy of the buffers it was just handed. + */ + @UiThread + private fun rebuild(usage: PowerUsage) { val chart = this.chart ?: return - val usage = usageProvider() val context = chart.context val datasets = @@ -92,14 +100,62 @@ class PowerUsageChartRenderer( } /** - * Redraws from a fresh sample. Rebuilds rather than mutating in place: this chart samples - * relatively slowly and has two short series, so the saving is not worth a second code path - * that can disagree with the first. + * Redraws from the sample just taken, mutating the existing entries in place. + * + * It used to discard [usage] and call [rebuild], which asked the watcher for another copy of + * all three series and allocated two datasets and twenty thousand entries -- every tick, on + * the UI thread. The KDoc justified that with "two short series"; they are MAX_USAGE_ENTRIES + * long. Falls back to a full rebuild only when the chart's shape no longer matches. */ @UiThread fun onUsageChanged(usage: PowerUsage) { - chart ?: return - rebuild() + val chart = this.chart ?: return + val data = chart.data + val temperature = data?.getDataSetByIndex(TEMPERATURE_INDEX) as LineDataSet? + val power = data?.getDataSetByIndex(POWER_INDEX) as LineDataSet? + + if (temperature == null || power == null || + temperature.entryCount != usage.temperatureMilliCelsius.size || + power.entryCount != usage.powerMicroWatts.size + ) { + rebuild(usage) + return + } + + val context = chart.context + update( + dataset = temperature, + values = usage.temperatureMilliCelsius, + label = context.getString(R.string.metrics_power_temperature), + axis = YAxis.AxisDependency.LEFT, + transform = ::milliCelsiusToCelsius, + ) + update( + dataset = power, + values = usage.powerMicroWatts, + label = context.getString(R.string.metrics_power_draw), + axis = YAxis.AxisDependency.RIGHT, + transform = ::microWattsToWatts, + ) + + applyAxisRanges(chart, usage) + applyThermalShading(chart, usage) + redraw(chart) + } + + /** Rewrites one series' values in place and refreshes its legend entry. */ + private fun update( + dataset: LineDataSet, + values: LongArray, + label: String, + axis: YAxis.AxisDependency, + transform: (Long) -> Float, + ) { + for (index in values.indices) { + dataset.entries[index].y = transform(values[index]) + } + dataset.label = labelFor(label, values.lastOrNull(), axis) + dataset.notifyDataSetChanged() } /** @@ -261,6 +317,12 @@ class PowerUsageChartRenderer( // single series family. chart.axisLeft.isEnabled = true + // Integer labels need integer grid lines, exactly as the watt axis below does. Now that + // the range is tight -- 29 to 33 rather than 0 to 36 -- the axis would otherwise place + // lines half a degree apart and "%dC" would print 29C, 30C, 30C, 31C, 31C. + chart.axisLeft.granularity = 1f + chart.axisLeft.isGranularityEnabled = true + chart.axisLeft.valueFormatter = object : IAxisValueFormatter { override fun getFormattedValue( @@ -349,6 +411,9 @@ class PowerUsageChartRenderer( /** Visible against the plot surface without drowning the lines drawn over it. */ const val SHADE_ALPHA = 96 + const val TEMPERATURE_INDEX = 0 + const val POWER_INDEX = 1 + const val THERMAL_LIGHT = 1 const val THERMAL_MODERATE = 2 const val THERMAL_SEVERE = 3 diff --git a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt index 3f93c67ba3..8b96943750 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt @@ -82,6 +82,9 @@ class SafeLineChart : LineChart { private val spanPaint = Paint(Paint.ANTI_ALIAS_FLAG) + /** Reused by [drawBackgroundSpans]: two (x, y) pairs, transformed in place. */ + private val spanPoints = FloatArray(4) + /** * Draws the spans immediately after the grid background, which is an opaque fill of the plot: a * span painted before [onDraw] delegates upwards is covered by it and never reaches the screen. @@ -101,8 +104,16 @@ class SafeLineChart : LineChart { val transformer = getTransformer(YAxis.AxisDependency.LEFT) ?: return backgroundSpans.forEach { span -> - val left = transformer.getPixelForValues(span.startX, 0f).x.toFloat() - val right = transformer.getPixelForValues(span.endX, 0f).x.toFloat() + // A reused buffer through pointValuesToPixel, not two getPixelForValues calls: those + // hand back pooled MPPointD instances that have to be recycled, and this runs inside + // onDraw for every span on every frame of every pan and zoom. + spanPoints[0] = span.startX + spanPoints[1] = 0f + spanPoints[2] = span.endX + spanPoints[3] = 0f + transformer.pointValuesToPixel(spanPoints) + val left = spanPoints[0] + val right = spanPoints[2] // A span scrolled out of view still maps to a pixel, so clip to the plot. val clippedLeft = left.coerceAtLeast(content.left) val clippedRight = right.coerceAtMost(content.right) diff --git a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt index ceacbdff75..c05b5d8b33 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -28,6 +28,7 @@ import com.itsaky.androidide.services.builder.ThermalInfo import com.itsaky.androidide.services.builder.ThermalState import com.itsaky.androidide.utils.PowerUsageWatcher.BatteryState import com.itsaky.androidide.utils.PowerUsageWatcher.PowerReading +import kotlin.math.abs /** * Reads temperature and power from the battery, which is all a normally-installed app can see @@ -92,7 +93,18 @@ class DevicePowerSource( return PowerUsageWatcher.UNAVAILABLE } - return microAmps.toLong() * milliVolts.toLong() / NANOWATTS_PER_MICROWATT + val microWatts = microAmps.toLong() * milliVolts.toLong() / NANOWATTS_PER_MICROWATT + + // The sign of CURRENT_NOW is documented and not always honoured; the unit is the same + // story. Several OEM kernels report milliamps, which makes a five-watt build read as five + // milliwatts -- indistinguishable from an idle device, with no error path at all. Outside + // a plausible envelope, report the reading as unavailable rather than as a believable lie. + val magnitude = abs(microWatts) + return if (magnitude == 0L || magnitude in MIN_PLAUSIBLE_MICROWATTS..MAX_PLAUSIBLE_MICROWATTS) { + microWatts + } else { + PowerUsageWatcher.UNAVAILABLE + } } /** @@ -110,8 +122,14 @@ class DevicePowerSource( } return when (ThermalInfo.getThermalState(context)) { - ThermalState.Throttled -> PowerManager.THERMAL_STATUS_SEVERE + // LIGHT, not SEVERE. The fallback knows only throttled or not, and its own + // PowerManager mapping counts LIGHT and MODERATE as not throttled -- so one mild trip + // point was painted with the middle hue of a six-level severity scale. Claim the least + // the reading could mean. + ThermalState.Throttled -> PowerManager.THERMAL_STATUS_LIGHT + ThermalState.NotThrottled -> PowerManager.THERMAL_STATUS_NONE + else -> PowerUsageWatcher.THERMAL_UNKNOWN } } @@ -121,7 +139,11 @@ class DevicePowerSource( val level = battery.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val scale = battery.getIntExtra(BatteryManager.EXTRA_SCALE, -1) - val status = battery.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) + // EXTRA_PLUGGED rather than EXTRA_STATUS. A device held at a charge cap -- Adaptive + // Charging, or any battery-protection limit -- reports NOT_CHARGING while plugged in, so + // testing the status showed the battery readout for a device on mains power with its + // current still reversed. Plugged is the question the readout actually asks. + val plugged = battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) val percent = if (level < 0 || scale <= 0) { @@ -132,12 +154,18 @@ class DevicePowerSource( return BatteryState( levelPercent = percent, - isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL, + isCharging = plugged != 0, ) } private companion object { /** Microamps times millivolts gives nanowatts; this scales the product to microwatts. */ const val NANOWATTS_PER_MICROWATT = 1_000L + + /** A milliwatt: below this a non-zero reading is likelier a unit mismatch than a real draw. */ + const val MIN_PLAUSIBLE_MICROWATTS = 1_000L + + /** A hundred watts: no phone draws this, so that is a unit mismatch the other way. */ + const val MAX_PLAUSIBLE_MICROWATTS = 100_000_000L } } 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 f78a7728f0..0d7a7d69ee 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -135,7 +135,7 @@ class NetworkUsageWatcher */ fun getUsage(): NetworkUsage = synchronized(historyLock) { - NetworkUsage(received.snapshot(), transmitted.snapshot()) + NetworkUsage(received.toLongArray(), transmitted.toLongArray()) } /** @@ -313,8 +313,3 @@ class NetworkUsageWatcher private val log = LoggerFactory.getLogger(NetworkUsageWatcher::class.java) } } - -/** - * Copies this ring buffer into a plain array in logical order, oldest first. - */ -private fun ShiftedLongArray.snapshot(): LongArray = LongArray(size) { this[it] } diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 3551001fbd..dfc1c8a9a2 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -117,7 +117,7 @@ class PowerUsageWatcher */ fun getUsage(): PowerUsage = synchronized(historyLock) { - PowerUsage(temperature.snapshotArray(), power.snapshotArray(), thermal.snapshotArray()) + PowerUsage(temperature.toLongArray(), power.toLongArray(), thermal.toLongArray()) } fun clearHistory() { @@ -289,8 +289,3 @@ class PowerUsageWatcher private val log = LoggerFactory.getLogger(PowerUsageWatcher::class.java) } } - -/** - * Copies this ring buffer into a plain array in logical order, oldest first. - */ -private fun ShiftedLongArray.snapshotArray(): LongArray = LongArray(size) { this[it] } diff --git a/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt b/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt index 4e2c524293..6392e21f7c 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt @@ -34,110 +34,104 @@ package com.itsaky.androidide.utils * @author Akash Yadav */ open class ShiftedLongArray( - protected val array: LongArray, - shift: Int = 0 + protected val array: LongArray, + shift: Int = 0, ) : Collection { + override val size: Int + get() = array.size + + var shift: Int = shift + protected set + + val normalizedShift: Int + get() = ((shift % size) + size) % size + + @Suppress("NOTHING_TO_INLINE") + protected inline fun checkIdx(idx: Int) { + if (idx < 0 || idx >= array.size) { + throw IndexOutOfBoundsException("Index $idx is out of bounds for array of size ${array.size}") + } + } + + /** + * Get the corresponding shifted-index for the given index. + */ + open fun getShiftedIndex(index: Int): Int { + val size = this.size + val idx = + if (shift < 0) { + size - index + } else { + index + } + return (idx + normalizedShift) % size + } + + /** + * Returns whether the contents of this array are equal to the specified array. + */ + fun contentEquals(array: ShiftedLongArray): Boolean = contentEquals(array.array) + + /** + * Returns whether the contents of this array are equal to the specified array. + */ + fun contentEquals(array: LongArray): Boolean = this.array.contentEquals(array) + + /** + * Returns the hash code value for the contents of this array. + */ + fun contentHashCode(): Int = array.contentHashCode() + + operator fun get(index: Int): Long { + checkIdx(index) + return array[getShiftedIndex(index)] + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ShiftedLongArray) return false + + if (!array.contentEquals(other.array)) return false + if (shift != other.shift) return false + + return true + } + + override fun hashCode(): Int { + var result = array.contentHashCode() + result = 31 * result + shift + return result + } + + override fun isEmpty(): Boolean = array.isEmpty() + + override fun containsAll(elements: Collection): Boolean = elements.all { array.contains(it) } + + override fun contains(element: Long): Boolean = array.contains(element) + + override fun iterator(): Iterator { + return object : Iterator { + var index = 0 + + override fun hasNext(): Boolean = index < array.size + + override fun next(): Long { + if (!hasNext()) { + throw NoSuchElementException() + } else { + return this@ShiftedLongArray[index++] + } + } + } + } + + override fun toString(): String = "ShiftedLongArray(array=${array.contentToString()}, shift=$shift)" +} - override val size: Int - get() = array.size - - var shift: Int = shift - protected set - - val normalizedShift: Int - get() = ((shift % size) + size) % size - - @Suppress("NOTHING_TO_INLINE") - protected inline fun checkIdx(idx: Int) { - if (idx < 0 || idx >= array.size) { - throw IndexOutOfBoundsException("Index $idx is out of bounds for array of size ${array.size}") - } - } - - /** - * Get the corresponding shifted-index for the given index. - */ - open fun getShiftedIndex(index: Int): Int { - val size = this.size - val idx = if (shift < 0) { - size - index - } else index - return (idx + normalizedShift) % size - } - - /** - * Returns whether the contents of this array are equal to the specified array. - */ - fun contentEquals(array: ShiftedLongArray): Boolean { - return contentEquals(array.array) - } - - /** - * Returns whether the contents of this array are equal to the specified array. - */ - fun contentEquals(array: LongArray): Boolean { - return this.array.contentEquals(array) - } - - /** - * Returns the hash code value for the contents of this array. - */ - fun contentHashCode(): Int { - return array.contentHashCode() - } - - operator fun get(index: Int): Long { - checkIdx(index) - return array[getShiftedIndex(index)] - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ShiftedLongArray) return false - - if (!array.contentEquals(other.array)) return false - if (shift != other.shift) return false - - return true - } - - override fun hashCode(): Int { - var result = array.contentHashCode() - result = 31 * result + shift - return result - } - - override fun isEmpty(): Boolean { - return array.isEmpty() - } - - override fun containsAll(elements: Collection): Boolean { - return elements.all { array.contains(it) } - } - - override fun contains(element: Long): Boolean { - return array.contains(element) - } - - override fun iterator(): Iterator { - return object : Iterator { - var index = 0 - - override fun hasNext(): Boolean { - return index < array.size - } - - override fun next(): Long { - if (!hasNext()) { - throw NoSuchElementException() - } else { - return this@ShiftedLongArray[index++] - } - } - } - } - - override fun toString(): String { - return "ShiftedLongArray(array=${array.contentToString()}, shift=$shift)" - } -} \ No newline at end of file +/** + * Copies this ring buffer into a plain array in logical order, oldest first. + * + * Shared so the watchers' snapshots cannot drift from [ShiftedLongArray]'s shift semantics; each + * of them had its own private copy of this one line. + */ +internal fun ShiftedLongArray.toLongArray(): LongArray = LongArray(size) { this[it] } diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index 30209b5f1a..79cbf3e120 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -341,6 +341,53 @@ class PowerUsageChartRendererTest { assertThat(chart.axisLeft.axisMaximum).isLessThan(40f) } + @Test + fun `only one axis rules the plot`() { + val (_, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) + laidOut(chart) + + // Both axes drew grid lines at their own pitch, so the plot carried two interleaved sets + // of horizontal rules -- nine of them, including a pair eight pixels apart. Only the + // labelled axis should rule the plot; the left axis is enabled for its labels alone. + assertThat(chart.axisLeft.isDrawGridLinesEnabled).isFalse() + assertThat(chart.axisRight.isDrawGridLinesEnabled).isTrue() + } + + @Test + fun `the temperature axis does not repeat a label`() { + val (_, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) + laidOut(chart) + + // Ranged over a few degrees and formatted without decimals, a finer pitch prints + // "29C, 30C, 30C, 31C". + assertThat(chart.axisLeft.granularity).isEqualTo(1f) + assertThat(chart.axisLeft.isGranularityEnabled).isTrue() + } + + @Test + fun `a new sample updates the existing series rather than replacing them`() { + val (renderer, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) + val before = dataset(chart, 0) + + renderer.onUsageChanged(usage(temperature = LongArray(SAMPLES) { 31_000L })) + + // Rebuilding allocated two datasets and 2 * MAX_USAGE_ENTRIES entries every tick, on the + // UI thread, and threw away the sample it had just been handed. + assertThat(dataset(chart, 0)).isSameInstanceAs(before) + assertThat(before.entries.last().y).isEqualTo(31f) + } + + @Test + fun `a series that no longer matches the sample is rebuilt`() { + val (renderer, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) + + // The buffer grows to its full length over the first minutes of a session, so an + // in-place update has to notice when the shape it is writing into is the wrong one. + renderer.onUsageChanged(usage(temperature = LongArray(SAMPLES + 1) { 31_000L })) + + assertThat(dataset(chart, 0).entryCount).isEqualTo(SAMPLES + 1) + } + @Test fun `an unreadable temperature falls back to a plausible span`() { val (_, chart) = diff --git a/app/src/test/java/com/itsaky/androidide/ui/SafeLineChartTest.kt b/app/src/test/java/com/itsaky/androidide/ui/SafeLineChartTest.kt new file mode 100644 index 0000000000..b209006670 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/SafeLineChartTest.kt @@ -0,0 +1,98 @@ +/* + * 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.graphics.Color +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode + +/** + * Where [SafeLineChart] paints its background spans relative to the grid background. + * + * The spans first went in before the call up to `super.onDraw`, which was the one place they could + * not survive: the grid background is an opaque fill of the whole plot, so every span was painted + * and then covered. Nothing in the span geometry tests noticed -- they read + * [SafeLineChart.backgroundSpans], which was correct all along -- so this asserts against pixels. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SafeLineChartTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun drawn(configure: SafeLineChart.() -> Unit): Bitmap { + val chart = SafeLineChart(context) + chart.setDrawGridBackground(true) + chart.setGridBackgroundColor(GRID_BACKGROUND) + chart.description.isEnabled = false + chart.legend.isEnabled = false + chart.axisLeft.axisMinimum = 0f + chart.axisLeft.axisMaximum = 10f + // Flat at the axis minimum, so the line itself stays clear of the sampled pixel. + chart.data = LineData(LineDataSet(List(SAMPLES) { Entry(it.toFloat(), 0f) }, "flat")) + chart.configure() + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + val bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888) + chart.draw(Canvas(bitmap)) + return bitmap + } + + /** A pixel inside the plot, near its top, away from the flat data line. */ + private fun Bitmap.plotPixel(): Int = getPixel(WIDTH / 2, HEIGHT / 4) + + @Test + fun `a span reaches the screen instead of being covered by the grid background`() { + val shaded = + drawn { + backgroundSpans = + listOf(SafeLineChart.Span(startX = 0f, endX = SAMPLES.toFloat(), color = SPAN)) + } + + // Painted before the grid background this pixel came back GRID_BACKGROUND, every time. + assertThat(shaded.plotPixel()).isEqualTo(SPAN) + } + + @Test + fun `the grid background still shows through where nothing is shaded`() { + // The other half of the order: the span must not be a wash over the whole plot either. + assertThat(drawn { }.plotPixel()).isEqualTo(GRID_BACKGROUND) + } + + private companion object { + const val WIDTH = 720 + const val HEIGHT = 400 + const val SAMPLES = 20 + + val GRID_BACKGROUND = Color.WHITE + val SPAN = Color.RED + } +} From 93bee8c9ac851a70ec1df9f1fa9921926efb1dd3 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 09:45:41 -0700 Subject: [PATCH 050/128] ADFA-5510: address review findings on the carousel's help The undocked carousel was the one floating window whose chrome answered no long press. ADFA-5510 wired help to everything inside the carousel and missed the frame around it -- including the dock control, which is the only way back and so the one that most needs explaining. It now installs the same ChromeControlTooltips.handler the editor and plugin tabs do. helpTag is abstract. A page with no help is a page nobody wrote copy for, which should be a compile error rather than a silently unhelpful long press; every page already overrides it. One predicate decides what is on the axis band. The tap that opens the sampling-rate chooser and the long press that explains it have to agree on where that band is; written twice, they could drift apart and the tooltip would then describe a control the tap no longer reaches. clearLongPressHelp() is now one call, because setOnLongClickListener(null) leaves isLongClickable set and the view goes on claiming long presses for help it no longer offers. EditorBottomSheet had the same bug at six teardown sites and is swept too. Tests: four classes had grown their own measure/layout/draw helper, with the comment explaining why the draw matters in three of them and the draw itself missing from one. One layOutAndDraw() now, shared. The help test also built a controller per test case and never released any of them -- each one installs itself as the listener on three watchers -- so it ran against a growing pile of live carousels; they are tracked and unbound. Checked on device rather than by argument: the sampling-rate dialog's Help and Cancel buttons stay separate and legible at font scales 1.0, 1.5 and 2.0 (Help ends at x=479, Cancel starts at x=873 at 2.0), and the nine rate entries still fit without scrolling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../MetricsCarouselDockableContent.kt | 12 +++++ .../itsaky/androidide/ui/EditorBottomSheet.kt | 13 ++--- .../ui/MetricsCarouselController.kt | 8 +-- .../androidide/ui/MetricsChartRenderer.kt | 23 +++++--- .../utils/LongPressHelpExtensions.kt | 34 ++++++++++++ .../com/itsaky/androidide/ui/ChartLayout.kt | 52 +++++++++++++++++++ .../ui/MemoryUsageChartRendererTest.kt | 16 +----- .../ui/MetricsAnnotationRenderingTest.kt | 3 ++ .../androidide/ui/MetricsCarouselHelpTest.kt | 30 +++++++---- .../androidide/ui/MetricsChartAxisTapTest.kt | 11 +--- .../ui/NetworkUsageChartRendererTest.kt | 19 +------ .../ui/PowerUsageChartRendererTest.kt | 12 +---- 12 files changed, 152 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt create mode 100644 app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt 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 e35c79b40b..294fb33857 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 @@ -23,6 +23,7 @@ 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.ChromeControl import com.itsaky.androidide.floating.model.DockableContent import com.itsaky.androidide.floating.window.FloatingWindowHost import com.itsaky.androidide.ui.MetricsCarouselController @@ -49,6 +50,17 @@ class MetricsCarouselDockableContent( ) : DockableContent { override val id: String = ID + /** + * The window chrome's own help, the same handler the editor and plugin tabs install. + * + * Without it the undocked carousel was the one floating window whose minimize, maximize and + * dock controls answered no long press -- and the dock control is the only way back, so it is + * the one that most needs explaining. ADFA-5510 wired help to everything inside the carousel + * and missed the frame around it. + */ + override val onChromeControlLongPress: (ChromeControl, View) -> Unit = + ChromeControlTooltips.handler + override fun onCreateView( context: Context, host: FloatingWindowHost, diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 9570333eb2..7f464dbd2f 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -67,6 +67,7 @@ import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.DiagnosticsFormatter import com.itsaky.androidide.utils.IntentUtils.shareFile import com.itsaky.androidide.utils.Symbols.forFile +import com.itsaky.androidide.utils.clearLongPressHelp import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess @@ -332,17 +333,17 @@ class EditorBottomSheet binding.tabs.clearOnTabSelectedListeners() binding.shareOutputAction.setOnClickListener(null) - binding.shareOutputAction.setOnLongClickListener(null) + binding.shareOutputAction.clearLongPressHelp() binding.clearOutputAction.setOnClickListener(null) - binding.clearOutputAction.setOnLongClickListener(null) + binding.clearOutputAction.clearLongPressHelp() binding.searchOutputAction.setOnClickListener(null) - binding.searchOutputAction.setOnLongClickListener(null) + binding.searchOutputAction.clearLongPressHelp() binding.filterOutputAction.setOnClickListener(null) - binding.filterOutputAction.setOnLongClickListener(null) + binding.filterOutputAction.clearLongPressHelp() binding.wordWrapOutputAction.setOnClickListener(null) - binding.wordWrapOutputAction.setOnLongClickListener(null) + binding.wordWrapOutputAction.clearLongPressHelp() binding.viewOptionsOutputAction.setOnClickListener(null) - binding.viewOptionsOutputAction.setOnLongClickListener(null) + binding.viewOptionsOutputAction.clearLongPressHelp() binding.copyDiagnosticsFab.setOnClickListener(null) binding.headerContainer.setOnClickListener(null) removeOnLayoutChangeListener(fabLayoutChangeListener) 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 785bdc141f..5e6854c50c 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -43,6 +43,7 @@ import com.itsaky.androidide.utils.MetricsSamplingRates import com.itsaky.androidide.utils.MetricsSnapshot import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.PowerUsageWatcher +import com.itsaky.androidide.utils.clearLongPressHelp import com.itsaky.androidide.utils.displayTooltipOnLongPress import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent import kotlinx.coroutines.CancellationException @@ -279,12 +280,7 @@ class MetricsCarouselController( // reach with no help at all. .filterNot { (view, _) -> view === bound.metricsUndockedMessage } .map { (view, _) -> view } - .forEach { control -> - control.setOnLongClickListener(null) - // setOnLongClickListener(null) leaves isLongClickable set, so the view would still - // claim a long press it no longer answers. - control.isLongClickable = false - } + .forEach(View::clearLongPressHelp) } binding?.metricsPrevious?.setOnClickListener(null) binding?.metricsNext?.setOnClickListener(null) 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 15085b3b2e..a5da502bcc 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -76,7 +76,7 @@ abstract class MetricsChartRenderer( * never calls `super`, so the framework's long-press detection never runs and a view listener * would be installed, look wired, and never fire. */ - protected open val helpTag: String? = null + protected abstract val helpTag: String /** * The help tag for a long press at [y], or `null` if this page has none. @@ -86,14 +86,21 @@ abstract class MetricsChartRenderer( */ @VisibleForTesting internal fun helpTagAt(y: Float): String? { - val chart = this.chart ?: return null // The axis band answers for the sampling rate, the plot for the metric itself, matching // where a tap goes. - return if (y >= chart.viewPortHandler.contentBottom()) { - TooltipTag.CAROUSEL_AXIS_TIME - } else { - helpTag - } + return if (isOnAxisBand(y)) TooltipTag.CAROUSEL_AXIS_TIME else helpTag + } + + /** + * Whether [y] landed on the x axis band rather than in the plot. + * + * One predicate, because the tap that opens the sampling-rate chooser and the long press that + * explains it have to agree on where that band is: written twice, they can drift apart and the + * tooltip then describes a control the tap no longer reaches. + */ + private fun isOnAxisBand(y: Float): Boolean { + val chart = this.chart ?: return false + return y >= chart.viewPortHandler.contentBottom() } /** @@ -284,7 +291,7 @@ abstract class MetricsChartRenderer( ) : OnChartGestureListener { override fun onChartSingleTapped(me: MotionEvent?) { val y = me?.y ?: return - if (y >= chart.viewPortHandler.contentBottom()) { + if (isOnAxisBand(y)) { onXAxisTap?.invoke() } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt b/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt new file mode 100644 index 0000000000..43e3b76b00 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt @@ -0,0 +1,34 @@ +/* + * 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.view.View + +/** + * Stops this view answering a long press. + * + * `setOnLongClickListener(null)` alone is not enough: [View.setOnLongClickListener] sets + * `isLongClickable` when it installs a listener but does not unset it when the listener is + * removed, so the view goes on consuming long presses -- and showing the system's own + * "performLongClick" feedback -- for help it no longer offers. Every teardown that clears a + * long-press help listener wants both halves, so it is one call. + */ +fun View.clearLongPressHelp() { + setOnLongClickListener(null) + isLongClickable = false +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt b/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt new file mode 100644 index 0000000000..4bc1d534c8 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt @@ -0,0 +1,52 @@ +/* + * 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.graphics.Bitmap +import android.graphics.Canvas +import android.view.View + +/** The plot size every metrics chart test lays out at; roughly the carousel strip on a phone. */ +const val CHART_WIDTH = 720 + +const val CHART_HEIGHT = 400 + +/** + * Lays this chart out and draws it once, which is what every assertion about its viewport needs. + * + * Two separate reasons, both easy to leave out and neither of which fails loudly. Without the + * layout the plot area has no extent, so every coordinate lands on its edge and a hit test cannot + * tell inside from outside. Without the draw the scroll to the newest samples has not run -- + * MPAndroidChart queues `moveViewToX` as a job that only executes during a draw pass -- so the + * chart still reports the *oldest* samples as visible and a window assertion reads the wrong end + * of the buffer. + * + * Four test classes had grown their own copy of this, with the comment explaining it in three of + * them and the draw missing from one. + */ +fun SafeLineChart.layOutAndDraw( + width: Int = CHART_WIDTH, + height: Int = CHART_HEIGHT, +) { + measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + layout(0, 0, width, height) + draw(Canvas(Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888))) +} 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 031c59167c..9140466a8f 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -49,12 +49,7 @@ 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. - */ + /** A chart showing one process with the given byte history, laid out and drawn once. */ private fun laidOutChart(history: LongArray): SafeLineChart { val chart = chart() val process = @@ -64,12 +59,7 @@ class MemoryUsageChartRendererTest { 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))) + chart.layOutAndDraw() return chart } @@ -211,8 +201,6 @@ class MemoryUsageChartRendererTest { 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. */ diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt index 98ccf47ea7..c752cf9ece 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt @@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineDataSet import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.utils.MetricsAnnotationStore import org.junit.Test import org.junit.runner.RunWith @@ -47,6 +48,8 @@ class MetricsAnnotationRenderingTest { annotations = annotations, nowMillis = now, ) { + override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY + override fun rebuild() { val chart = this.chart ?: return val entries = List(sampleCount) { Entry(it.toFloat(), 0f) } diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt index ed9ec75c93..177c1e4651 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt @@ -30,6 +30,7 @@ import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.PowerUsageWatcher +import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -49,13 +50,30 @@ class MetricsCarouselHelpTest { R.style.Theme_AndroidIDE, ) + /** + * Every controller this test builds, so [tearDown] can release them. + * + * Each one installs itself as the listener on three watchers; a controller left bound holds + * its views and goes on being fed for the rest of the JVM's life, and the tests then run + * against a growing pile of live carousels. + */ + private val controllers = mutableListOf() + + @After + fun tearDown() { + controllers.forEach { it.unbind() } + controllers.clear() + } + private fun boundStrip(): LayoutMemUsageBinding { val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) controller().bind(binding) return binding } - private fun controller() = + private fun controller() = newController().also(controllers::add) + + private fun newController() = MetricsCarouselController( memoryUsageWatcher = MemoryUsageWatcher(), networkUsageWatcher = NetworkUsageWatcher(uid = TEST_UID), @@ -154,15 +172,11 @@ class MetricsCarouselHelpTest { }, ) 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) + chart.layOutAndDraw() val handler = chart.viewPortHandler // Guards the two assertions below: on an unlaid-out chart both points land on one edge. - assertThat(handler.contentBottom()).isLessThan(HEIGHT.toFloat()) + assertThat(handler.contentBottom()).isLessThan(CHART_HEIGHT.toFloat()) // Below the plot is the time axis, which is what the sampling rate belongs to. assertThat(renderer.helpTagAt(handler.contentBottom() + 1f)).isEqualTo(TooltipTag.CAROUSEL_AXIS_TIME) @@ -173,8 +187,6 @@ class MetricsCarouselHelpTest { private companion object { const val TEST_UID = 10_123 - const val WIDTH = 720 - const val HEIGHT = 400 const val SAMPLES = 60 } } 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..8d752e17c5 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -58,12 +58,7 @@ class MetricsChartAxisTapTest { 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) + chart.layOutAndDraw() return chart } @@ -82,7 +77,7 @@ class MetricsChartAxisTapTest { // 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()) + assertThat(chart.viewPortHandler.contentBottom()).isLessThan(CHART_HEIGHT.toFloat()) } @Test @@ -115,8 +110,6 @@ class MetricsChartAxisTapTest { } private companion object { - const val WIDTH = 720 - const val HEIGHT = 400 const val SAMPLES = 60 } } 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 85cd37298c..699b8c64da 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -38,9 +38,6 @@ 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 } @@ -98,21 +95,7 @@ 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))) - } + private fun laidOut(chart: SafeLineChart) = chart.layOutAndDraw() @Test fun `the axis is scaled to what is on screen, not to the whole buffer`() { diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index 30209b5f1a..4cfb5770f5 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -304,15 +304,7 @@ class PowerUsageChartRendererTest { assertThat(renderer.batteryReadout()).isNull() } - 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) - // The scroll to the newest samples is a job that only runs during a draw pass. - chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) - } + private fun laidOut(chart: SafeLineChart) = chart.layOutAndDraw() @Test fun `the power axis starts at zero, never below it`() { @@ -353,8 +345,6 @@ class PowerUsageChartRendererTest { } private companion object { - const val WIDTH = 720 - const val HEIGHT = 400 const val SAMPLES = 200 const val OPAQUE = 0xFF000000.toInt() From 7b5028265a2620e5860f67e08ac3ed13373720de Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 09:51:48 -0700 Subject: [PATCH 051/128] ADFA-5499: keep the battery readout off the topmost axis label The readout is anchored to the pager's top-right corner, over the chart, which is exactly where the right axis prints its highest label. At the default font scale it sits above the plot and the two never meet, so the collision was invisible; the strip is a fixed height, so at a 2.0 font scale the readout grows down into the plot and covers that label entirely. Reserving the readout's line height as the chart's extra top offset moves the plot instead, which scales with the text rather than against it, and gives the room back when the readout is hidden. setExtraTopOffset only stores the value -- the viewport is recomputed by the protected calculateOffsets, which otherwise runs only when the chart's size changes -- so this notifies the chart as well. The first version of the test failed for that reason, reporting an unchanged contentTop of 15.0. Found by looking at the page at font scales 1.0, 1.5 and 2.0 rather than only at 1.0. Verified there too: with the fix, at 2.0, "79%" ends at y=214 and the "2W" label begins at y=228. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 10 +++++++++ .../androidide/ui/MetricsChartRenderer.kt | 19 +++++++++++++++++ .../ui/PowerUsageChartRendererTest.kt | 21 +++++++++++++++++++ 3 files changed, 50 insertions(+) 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 e2b3c68e86..8400228169 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -272,6 +272,16 @@ class MetricsCarouselController( binding.metricsBattery.text = readout.orEmpty() binding.metricsBattery.isVisible = readout != null + + // lineHeight rather than the measured height: this runs on bind, before the readout has + // been laid out, and it is the text's own size that grows with the font scale. + val reserved = + if (readout == null) { + 0f + } else { + binding.metricsBattery.lineHeight + binding.metricsBattery.paddingTop.toFloat() + } + powerRenderer.reserveTopSpace(reserved) } /** 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 02914c74db..9ce73d8036 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -82,6 +82,25 @@ abstract class MetricsChartRenderer( protected var chart: SafeLineChart? = null private set + /** + * Keeps [pixels] of the chart's top clear of the plot and its labels. + * + * The battery readout is anchored to the pager's top-right corner, over the chart, where the + * right axis prints its topmost label. At the default font scale the readout sits above the + * plot and the two do not meet; the strip is a fixed height, so at a 2.0 font scale the + * readout grows down into the plot and hides that label. Reserving its height moves the plot + * instead, which scales with the text rather than against it. + */ + @UiThread + fun reserveTopSpace(pixels: Float) { + val chart = this.chart ?: return + chart.setExtraTopOffset(pixels / chart.resources.displayMetrics.density) + // setExtraTopOffset only stores the value; the viewport is recomputed by calculateOffsets, + // which is protected and otherwise runs only when the chart's size changes. + chart.notifyDataSetChanged() + chart.invalidate() + } + /** * Attaches [chart], applies configuration, and renders the full current history. */ diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index 79cbf3e120..ae3e62b186 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -341,6 +341,24 @@ class PowerUsageChartRendererTest { assertThat(chart.axisLeft.axisMaximum).isLessThan(40f) } + @Test + fun `the battery readout gets room, and gives it back`() { + val (renderer, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) + laidOut(chart) + val unreserved = chart.viewPortHandler.contentTop() + + // The readout is anchored over the chart's top-right corner, where the right axis prints + // its topmost label; at a 2.0 font scale it grew down into the plot and hid that label. + renderer.reserveTopSpace(READOUT_HEIGHT_PX) + laidOut(chart) + assertThat(chart.viewPortHandler.contentTop()).isGreaterThan(unreserved) + + // Off the power page the readout is hidden, and the plot should have the room back. + renderer.reserveTopSpace(0f) + laidOut(chart) + assertThat(chart.viewPortHandler.contentTop()).isEqualTo(unreserved) + } + @Test fun `only one axis rules the plot`() { val (_, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) @@ -404,6 +422,9 @@ class PowerUsageChartRendererTest { const val HEIGHT = 400 const val SAMPLES = 200 + /** A readout two lines tall, which is roughly what a 2.0 font scale gives. */ + const val READOUT_HEIGHT_PX = 80f + const val OPAQUE = 0xFF000000.toInt() /** The palette ADFA-5499 specifies: green, cyan, yellow, orange, rust, red. */ From 531c0722962e9134201326b124fd484aab4f7826 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 09:58:55 -0700 Subject: [PATCH 052/128] ADFA-5509: address review findings on the build annotations Eviction now sacrifices task markers before build outcomes. Plain oldest-first eviction dropped a build's own "Build started" while the build was still running: 256 annotations at one per five seconds is about twenty minutes, which a clean build on a phone can exceed. That left an unpaired outcome on the chart and no way to see how long the build took -- which is most of the point of ADFA-5509. Task markers are the padding; the build's moments are the signal. A build marker can no longer come out invisible. resolveAttr discards resolveAttribute's result and hands back TypedValue.data, which for an attribute the theme does not carry is 0 -- fully transparent. colorSuccess is ours rather than Material's, and a floating window is built against a window context whose theme is not the activity's, so this is the same shape as the black-on-black axis labels: correct in every test, invisible on the device. It falls back to the axis text colour, which configure has already set to something legible. The "only task markers are throttled" test asserted isThrottled against its own definition, so it would have passed just as happily with record() ignoring the flag. It now records a task marker and then every build outcome inside the throttle window, and checks all of them survive. Plus both eviction branches, including the one where nothing but outcomes is left. Also refreshes the class KDoc, which still described the store as holding task starts and stops only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsChartRenderer.kt | 21 +++++++- .../utils/MetricsAnnotationStore.kt | 33 +++++++++--- .../utils/MetricsAnnotationStoreTest.kt | 53 +++++++++++++++++-- 3 files changed, 96 insertions(+), 11 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 050039879d..1c94736199 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -17,8 +17,10 @@ package com.itsaky.androidide.ui +import android.content.Context import android.graphics.Bitmap import android.os.SystemClock +import android.util.TypedValue import android.view.MotionEvent import androidx.annotation.CallSuper import androidx.annotation.UiThread @@ -508,7 +510,24 @@ abstract class MetricsChartRenderer( MetricsAnnotationStore.Kind.TASK, -> R.attr.colorOnSurface } - return chart.context.resolveAttr(attr) + // Not plain resolveAttr: it discards resolveAttribute's result and hands back TypedValue.data, + // which for an attribute the theme does not carry is 0 -- transparent. colorSuccess is + // ours rather than Material's, and a floating window is built against a window context + // whose theme is not the activity's, so a build marker could come out invisible. It falls + // back to the axis text colour, which configure has already set to something legible. + return chart.context.resolveColorAttr(attr, fallback = chart.xAxis.textColor) + } + + /** + * The colour [attr] names in this context's theme, or [fallback] if the theme has no such + * attribute. + */ + private fun Context.resolveColorAttr( + attr: Int, + fallback: Int, + ): Int { + val value = TypedValue() + return if (theme.resolveAttribute(attr, value, true)) value.data else fallback } /** 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 77b220762c..10394507be 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -24,10 +24,12 @@ import com.itsaky.androidide.resources.R.string /** * 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. + * Significant means Gradle task starts and stops, and a build's own start and outcome + * (ADFA-5509). A real build emits far too many task events to draw -- dozens a second during + * configuration -- so those 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. Build outcomes are never throttled and are the last + * thing evicted; see [Kind.isThrottled] and [record]. * * 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 @@ -145,10 +147,29 @@ class MetricsAnnotationStore( // landing a few pixels from a build marker and colliding with it. lastRecordedAt = now annotations.addLast(Annotation(now, label, nextSequence++, kind)) + evictToCapacity() + return true + } + + /** + * Drops the oldest annotations until the store is back within [MAX_ANNOTATIONS]. + * + * Task markers go first, whatever their age. Plain oldest-first eviction dropped a build's + * "Build started" while the build was still running -- 256 markers at one per + * [THROTTLE_INTERVAL_MS] is about twenty minutes, which a clean build on a phone can exceed -- + * leaving an unpaired outcome on the chart and no way to see how long the build took. Task + * markers are the padding here; the build's own moments are the point. + */ + private fun evictToCapacity() { while (annotations.size > MAX_ANNOTATIONS) { - annotations.removeFirst() + val oldestTask = annotations.indexOfFirst { it.kind.isThrottled } + if (oldestTask >= 0) { + annotations.removeAt(oldestTask) + } else { + // Nothing but build outcomes left, so the oldest of those has to go. + annotations.removeFirst() + } } - return true } /** diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt index 0b1f0c921a..26821a9256 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt @@ -200,9 +200,54 @@ class MetricsAnnotationStoreTest { } @Test - fun `only task markers are throttled`() { - assertThat(MetricsAnnotationStore.Kind.TASK.isThrottled).isTrue() - assertThat(MetricsAnnotationStore.Kind.entries.filter { it.isThrottled }) - .containsExactly(MetricsAnnotationStore.Kind.TASK) + fun `a build outcome inside the throttle window is still recorded`() { + // Asserting isThrottled against its own definition, as this test used to, would pass just + // as happily with record() ignoring the flag altogether. + store.record("a task") + now += 1_000L + + MetricsAnnotationStore.Kind.entries + .filterNot { it == MetricsAnnotationStore.Kind.TASK } + .forEach { kind -> + assertThat(store.recordBuild(kind)).isTrue() + now += 1_000L + } + + // One task marker, then every build outcome, none of them dropped. + assertThat(store.recentAnnotations(60_000L).map { it.kind }) + .containsExactlyElementsIn( + listOf(MetricsAnnotationStore.Kind.TASK) + + MetricsAnnotationStore.Kind.entries.filterNot { it == MetricsAnnotationStore.Kind.TASK }, + ).inOrder() + } + + @Test + fun `a full store evicts task markers before build outcomes`() { + store.recordBuild(MetricsAnnotationStore.Kind.BUILD_STARTED) + // Enough task markers to overflow the store several times over. A build long enough to do + // that -- about twenty minutes at one marker every five seconds -- used to lose its own + // "Build started", leaving an unpaired outcome and no way to see how long it took. + repeat(MetricsAnnotationStore.MAX_ANNOTATIONS * 2) { + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("task $it") + } + store.recordBuild(MetricsAnnotationStore.Kind.BUILD_FINISHED) + + val kinds = store.recentAnnotations(Long.MAX_VALUE / 2).map { it.kind } + assertThat(kinds.first()).isEqualTo(MetricsAnnotationStore.Kind.BUILD_STARTED) + assertThat(kinds.last()).isEqualTo(MetricsAnnotationStore.Kind.BUILD_FINISHED) + assertThat(kinds).hasSize(MetricsAnnotationStore.MAX_ANNOTATIONS) + } + + @Test + fun `a store holding nothing but build outcomes still respects its bound`() { + // The fallback branch: with no task marker left to sacrifice, the oldest outcome goes. + repeat(MetricsAnnotationStore.MAX_ANNOTATIONS + 5) { + now += 1_000L + store.recordBuild(MetricsAnnotationStore.Kind.BUILD_FINISHED) + } + + assertThat(store.recentAnnotations(Long.MAX_VALUE / 2)) + .hasSize(MetricsAnnotationStore.MAX_ANNOTATIONS) } } From 963e37b7f821c00deca452116103b25800b328f5 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 10:05:52 -0700 Subject: [PATCH 053/128] ADFA-5509: dim the end arrows rather than disabling them A disabled View still consumes a touch and then drops it, so a long press on the arrow at either end of the carousel showed no tooltip -- and that is exactly the arrow whose greyed-out state a user might want explained. The alpha was already there; the isEnabled = false beside it was doing nothing the clamp in step() did not already do, except swallow the help. Test fixture: record() now advances past the throttle window itself. Every caller wanted both halves and had to remember the second, and forgetting it made the store drop the next annotation -- leaving the test asserting against a chart with one fewer marker than it had asked for, which is a passing test for the wrong reason. Also removes a stray blank line between a KDoc and its declaration in three files, where it detaches the doc from what it documents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MemoryUsageChartRenderer.kt | 1 - .../ui/MetricsCarouselController.kt | 12 ++--- .../ui/NetworkUsageChartRenderer.kt | 1 - .../com/itsaky/androidide/ui/SafeLineChart.kt | 1 - .../ui/MetricsAnnotationRenderingTest.kt | 45 ++++++++++++------- .../androidide/ui/MetricsCarouselHelpTest.kt | 16 +++++++ 6 files changed, 53 insertions(+), 23 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 0529f2f390..066d845ed0 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -77,7 +77,6 @@ class MemoryUsageChartRenderer( * 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 override fun rebuild() { val chart = this.chart ?: return 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 b74d495321..56f5e068cc 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -312,10 +312,12 @@ class MetricsCarouselController( @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 + // Dimmed, not disabled. A disabled View still consumes a touch and simply drops it, so a + // long press on the arrow at either end of the carousel showed no tooltip -- and that is + // exactly the arrow whose greying-out a user might want explained. [step] already clamps, + // so a tap on a dimmed arrow does nothing either way. + binding.metricsPrevious.alpha = if (position > 0) 1f else DIMMED_ARROW_ALPHA + binding.metricsNext.alpha = if (position < pages.lastIndex) 1f else DIMMED_ARROW_ALPHA } /** @@ -571,7 +573,7 @@ class MetricsCarouselController( else -> null } - const val DISABLED_ARROW_ALPHA = 0.35f + const val DIMMED_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/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt index 601ab3aeb6..1834f40d80 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -69,7 +69,6 @@ class NetworkUsageChartRenderer( /** * Rebuilds both series from the full sample history. */ - @UiThread override fun rebuild() { val chart = this.chart ?: return diff --git a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt index 8b96943750..8362a2565c 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt @@ -38,7 +38,6 @@ import org.slf4j.LoggerFactory * hierarchy on a background thread, which races the main-thread updates of the memory-usage chart. The * chart is a non-critical diagnostic view, so dropping the occasional frame is preferable to crashing the * whole IDE. The next `invalidate()` recovers cleanly. - * */ class SafeLineChart : LineChart { constructor(context: Context) : super(context) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt index a930e438be..a174c5ace3 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt @@ -71,12 +71,30 @@ class MetricsAnnotationRenderingTest { var now = 0L val store = MetricsAnnotationStore(nowMillis = { now }) - /** Records [count] annotations, spaced far enough apart to clear the store's throttle. */ + /** Records [count] task markers, spaced far enough apart to clear the store's throttle. */ fun recordBurst(count: Int) { - repeat(count) { index -> - store.record("task $index") - now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS - } + repeat(count) { index -> record("task $index") } + } + + /** + * Records one annotation and advances past the throttle window. + * + * Every caller wanted both halves and had to remember the second one; forgetting it made + * the store drop the next annotation, and the test then asserted against a chart with one + * fewer marker than it had asked for. + */ + fun record( + label: String, + kind: MetricsAnnotationStore.Kind = MetricsAnnotationStore.Kind.TASK, + ) { + store.record(label, kind) + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + } + + /** Records a build outcome, whose label comes from its kind, and advances the clock. */ + fun recordBuild(kind: MetricsAnnotationStore.Kind) { + store.recordBuild(kind) + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS } } @@ -160,9 +178,8 @@ class MetricsAnnotationRenderingTest { @Test fun `a failed build is drawn in a different colour from a task marker`() { val fixture = Fixture() - fixture.store.record("some task") - fixture.now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS - fixture.store.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + fixture.record("some task") + fixture.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) val (_, chart) = render(fixture) @@ -179,11 +196,9 @@ class MetricsAnnotationRenderingTest { @Test fun `a build starting and finishing share one colour, distinct from a failure`() { val fixture = Fixture() - fixture.store.record("Build started", MetricsAnnotationStore.Kind.BUILD_STARTED) - fixture.now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS - fixture.store.record("Build finished", MetricsAnnotationStore.Kind.BUILD_FINISHED) - fixture.now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS - fixture.store.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + fixture.record("Build started", MetricsAnnotationStore.Kind.BUILD_STARTED) + fixture.record("Build finished", MetricsAnnotationStore.Kind.BUILD_FINISHED) + fixture.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) val (_, chart) = render(fixture) @@ -198,7 +213,7 @@ class MetricsAnnotationRenderingTest { @Test fun `a cancelled build is not drawn as a failure`() { val fixture = Fixture() - fixture.store.recordBuild(MetricsAnnotationStore.Kind.BUILD_CANCELLED) + fixture.recordBuild(MetricsAnnotationStore.Kind.BUILD_CANCELLED) val (_, chart) = render(fixture) @@ -212,7 +227,7 @@ class MetricsAnnotationRenderingTest { @Test fun `a build marker takes its label from its kind, not from the recorded text`() { val fixture = Fixture() - fixture.store.recordBuild(MetricsAnnotationStore.Kind.BUILD_FAILED) + fixture.recordBuild(MetricsAnnotationStore.Kind.BUILD_FAILED) val (_, chart) = render(fixture) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt index 177c1e4651..ba1f0699df 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt @@ -111,6 +111,22 @@ class MetricsCarouselHelpTest { assertThat(unwired).isEmpty() } + @Test + fun `the arrow at the end of the carousel is dimmed but still answers`() { + val binding = boundStrip() + + // On the first page there is nowhere to go back to. Disabling that arrow would leave it + // consuming the long press and dropping it, so the one arrow whose greyed-out state a + // user might want explained was the one with no explanation. + assertThat(binding.metricsPager.currentItem).isEqualTo(0) + assertThat(binding.metricsPrevious.alpha).isLessThan(1f) + assertThat(binding.metricsPrevious.isEnabled).isTrue() + assertThat(binding.metricsPrevious.isLongClickable).isTrue() + + // ...and the other end is at full strength, so the dimming means something. + assertThat(binding.metricsNext.alpha).isEqualTo(1f) + } + @Test fun `an unbound strip has no help wired`() { // Guards the test above: if inflation alone made these long-clickable, it would pass From 90ee07448a193ba992b9b1b3be4ba01d5c42d7b2 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 11:28:18 -0700 Subject: [PATCH 054/128] ADFA-5510: let the end arrows answer a long press, without losing their state A disabled View still consumes a touch and then drops it without calling any listener, so a long press on the arrow at either end of the carousel showed no tooltip -- and that is the arrow whose greying-out a user is likeliest to ask about. Same shape as the isLongClickable bug: the listener was installed, looked wired, and never fired. isClickable is the narrower statement and the true one. The arrow does not answer a tap; it does answer a long press. step() clamps anyway, so a tap on a dimmed arrow was already a no-op, and isEnabled was buying nothing but the swallowed help. Dropping isEnabled outright would have cost more than it gained, though: it is what a screen reader reads to announce a control as unavailable, and alpha is invisible to accessibility services, so the state would have disappeared for exactly the users who cannot see the dimming. An AccessibilityDelegateCompat reports the node as disabled and non-clickable instead, reading View.isClickable rather than holding its own copy of the state. Tested both halves: what a touch sees, and what createAccessibilityNodeInfo hands a screen reader. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 58 +++++++++++++++++-- .../androidide/ui/MetricsCarouselHelpTest.kt | 42 ++++++++++++++ 2 files changed, 95 insertions(+), 5 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 b74d495321..079391b036 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -28,6 +28,9 @@ import android.widget.Toast import androidx.annotation.UiThread import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.core.view.isVisible import androidx.viewpager2.widget.ViewPager2 import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider @@ -201,6 +204,9 @@ class MetricsCarouselController( // annoying. binding.metricsPrevious.setOnClickListener { step(-1) } binding.metricsNext.setOnClickListener { step(1) } + // After the click listeners, which set isClickable themselves. + ViewCompat.setAccessibilityDelegate(binding.metricsPrevious, arrowAccessibilityDelegate) + ViewCompat.setAccessibilityDelegate(binding.metricsNext, arrowAccessibilityDelegate) updateArrows(binding.metricsPager.currentItem) wireHelp(binding) @@ -284,6 +290,8 @@ class MetricsCarouselController( } binding?.metricsPrevious?.setOnClickListener(null) binding?.metricsNext?.setOnClickListener(null) + binding?.metricsPrevious?.let { ViewCompat.setAccessibilityDelegate(it, null) } + binding?.metricsNext?.let { ViewCompat.setAccessibilityDelegate(it, null) } pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } pageCallback = null @@ -312,10 +320,30 @@ class MetricsCarouselController( @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 + setPagingAvailable(binding.metricsPrevious, available = position > 0) + setPagingAvailable(binding.metricsNext, available = position < pages.lastIndex) + } + + /** + * Marks an arrow as leading somewhere, or not. + * + * Deliberately not `isEnabled`. A disabled View still consumes a touch and then drops it + * without calling any listener, so a long press on the arrow at either end of the carousel + * showed no tooltip -- and that is the arrow whose greying-out a user is likeliest to ask + * about. [isClickable] is the narrower statement and the true one: the arrow does not answer a + * tap, but it does answer a long press. [step] clamps anyway, so a tap on a dimmed arrow was + * already a no-op. + * + * Alpha alone would have lost the state for anyone who cannot see it, since a screen reader + * reads a node's flags rather than its opacity. [arrowAccessibilityDelegate] puts it back. + */ + @UiThread + private fun setPagingAvailable( + arrow: View, + available: Boolean, + ) { + arrow.alpha = if (available) 1f else DIMMED_ARROW_ALPHA + arrow.isClickable = available } /** @@ -571,7 +599,27 @@ class MetricsCarouselController( else -> null } - const val DISABLED_ARROW_ALPHA = 0.35f + const val DIMMED_ARROW_ALPHA = 0.35f + + /** + * Reports an arrow that leads nowhere as disabled, and as offering no tap. + * + * The views stay touch-enabled so they can still answer a long press with their tooltip + * (see [setPagingAvailable]); without this, TalkBack would offer "double-tap to activate" + * on an arrow that does nothing, and give no hint that the carousel has an end. Reads + * [View.isClickable] rather than holding its own copy, so there is one source of truth. + */ + val arrowAccessibilityDelegate = + object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.isEnabled = host.isClickable + info.isClickable = host.isClickable + } + } /** 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/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt index 177c1e4651..e2bf17635c 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt @@ -21,6 +21,7 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.R @@ -111,6 +112,47 @@ class MetricsCarouselHelpTest { assertThat(unwired).isEmpty() } + @Test + fun `the arrow at the end of the carousel is dimmed but still answers a long press`() { + val binding = boundStrip() + + // On the first page there is nowhere to go back to. Disabling that arrow would leave it + // consuming the long press and dropping it, so the one arrow whose greyed-out state a + // user is likeliest to ask about was the one with no answer. + assertThat(binding.metricsPager.currentItem).isEqualTo(0) + assertThat(binding.metricsPrevious.alpha).isLessThan(1f) + assertThat(binding.metricsPrevious.isEnabled).isTrue() + assertThat(binding.metricsPrevious.isLongClickable).isTrue() + // It answers no tap, though: that is the narrower and the true statement. + assertThat(binding.metricsPrevious.isClickable).isFalse() + + // ...and the other end is at full strength, so the dimming means something. + assertThat(binding.metricsNext.alpha).isEqualTo(1f) + assertThat(binding.metricsNext.isClickable).isTrue() + } + + @Test + fun `a dimmed arrow still reads as disabled to a screen reader`() { + val binding = boundStrip() + + // Alpha is invisible to accessibility services, so dropping isEnabled would have taken + // the state away from exactly the users who cannot see the dimming. + val previous = nodeInfoFor(binding.metricsPrevious) + assertThat(previous.isEnabled).isFalse() + assertThat(previous.isClickable).isFalse() + + val next = nodeInfoFor(binding.metricsNext) + assertThat(next.isEnabled).isTrue() + assertThat(next.isClickable).isTrue() + } + + /** What a screen reader would be handed for [view]. */ + private fun nodeInfoFor(view: View): AccessibilityNodeInfoCompat { + val info = view.createAccessibilityNodeInfo() + assertThat(info).isNotNull() + return AccessibilityNodeInfoCompat.wrap(info!!) + } + @Test fun `an unbound strip has no help wired`() { // Guards the test above: if inflation alone made these long-clickable, it would pass From 1573eeff62a29fedde22276cd97799dc106bfc37 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 11:29:46 -0700 Subject: [PATCH 055/128] ADFA-5509: move the end-arrow change to ADFA-5510, where it belongs 963e37b7f bundled three unrelated things because they were what happened to be in the tree. The arrow change is help behaviour -- it is what makes ADFA-5510's tooltip on a dimmed arrow actually fire -- so reviewing it here, on the build-annotations PR, hides it from the reviewer who cares about it. Backed out to exactly the merge base, so the version on ADFA-5510 applies cleanly; it also fixes an accessibility regression this one had. The other two thirds of that commit stay: the test fixture that advances its own clock, and the stray blank lines between a KDoc and what it documents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsCarouselController.kt | 12 +++++------- .../androidide/ui/MetricsCarouselHelpTest.kt | 16 ---------------- 2 files changed, 5 insertions(+), 23 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 56f5e068cc..b74d495321 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -312,12 +312,10 @@ class MetricsCarouselController( @UiThread private fun updateArrows(position: Int) { val binding = this.binding ?: return - // Dimmed, not disabled. A disabled View still consumes a touch and simply drops it, so a - // long press on the arrow at either end of the carousel showed no tooltip -- and that is - // exactly the arrow whose greying-out a user might want explained. [step] already clamps, - // so a tap on a dimmed arrow does nothing either way. - binding.metricsPrevious.alpha = if (position > 0) 1f else DIMMED_ARROW_ALPHA - binding.metricsNext.alpha = if (position < pages.lastIndex) 1f else DIMMED_ARROW_ALPHA + 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 } /** @@ -573,7 +571,7 @@ class MetricsCarouselController( else -> null } - const val DIMMED_ARROW_ALPHA = 0.35f + 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/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt index ba1f0699df..177c1e4651 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt @@ -111,22 +111,6 @@ class MetricsCarouselHelpTest { assertThat(unwired).isEmpty() } - @Test - fun `the arrow at the end of the carousel is dimmed but still answers`() { - val binding = boundStrip() - - // On the first page there is nowhere to go back to. Disabling that arrow would leave it - // consuming the long press and dropping it, so the one arrow whose greyed-out state a - // user might want explained was the one with no explanation. - assertThat(binding.metricsPager.currentItem).isEqualTo(0) - assertThat(binding.metricsPrevious.alpha).isLessThan(1f) - assertThat(binding.metricsPrevious.isEnabled).isTrue() - assertThat(binding.metricsPrevious.isLongClickable).isTrue() - - // ...and the other end is at full strength, so the dimming means something. - assertThat(binding.metricsNext.alpha).isEqualTo(1f) - } - @Test fun `an unbound strip has no help wired`() { // Guards the test above: if inflation alone made these long-clickable, it would pass From 6596564f17c0c185b00f815c9a9d81b620bd15a3 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 12:28:22 -0700 Subject: [PATCH 056/128] ADFA-5499: make the carousel adapter actually page-agnostic The adapter's KDoc has claimed since ADFA-5487 that a new display -- "including pages contributed by plugins" -- could be added without touching the class. That was false in three ways at once. MetricsPage was a sealed interface, so a plugin, being a different module, could not implement it at all. Four exhaustive `when` expressions inside this class named the three page types, and two more in the controller did, so a fourth page meant editing six sites. And each renderer was its own constructor parameter, so adding one changed the signature. Adding the third page is what made the shape untenable, so this is where it gets fixed. A page now says what it is called, what it is, and what draws it; nothing in the adapter or the controller names a metric. All six `when`s are gone, and the interface is no longer sealed, so the doc's claim is true rather than aspirational. The three per-metric layouts differed from each other in exactly one attribute -- the content description -- so they are one layout, set per page at bind time. Two things this could plausibly have broken, and did not: Charts are still never shared between pages. One view type per position, because a chart carries what its renderer put on it and some of that is written by one renderer and cleared by none of the others: the power page's thermal shading goes on through SafeLineChart.backgroundSpans, which no memory or network renderer touches. Verified on device by forcing thermal status 4, confirming the band covers 248 of 248 sampled columns on the power page, and then finding 0 of 248 on network and memory after paging away -- and the band still there on returning. Recycling still detaches the right renderer. The holder no longer carries its page's type, so it remembers the renderer it was bound to; onViewRecycled is not told the position and may be handed NO_POSITION. The battery readout moved from a page-type test to MetricsChartRenderer, which answers null unless a page has something to read out. That was the last place the carousel needed to know which page it was holding. Also: `pages` is now declared after the renderers. Holding its own renderer, the list read powerRenderer while that property was still null where it used to sit -- Kotlin initialises properties in declaration order. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsCarouselAdapter.kt | 147 +++++++--------- .../ui/MetricsCarouselController.kt | 59 ++++--- .../androidide/ui/MetricsChartRenderer.kt | 9 + .../androidide/ui/PowerUsageChartRenderer.kt | 2 +- ...emory_chart.xml => item_metrics_chart.xml} | 5 +- .../res/layout/item_metrics_network_chart.xml | 13 -- .../res/layout/item_metrics_power_chart.xml | 13 -- .../ui/MetricsCarouselAdapterTest.kt | 158 ++++++++++++++++++ .../ui/PowerUsageChartRendererTest.kt | 6 +- 9 files changed, 267 insertions(+), 145 deletions(-) rename app/src/main/res/layout/{item_metrics_memory_chart.xml => item_metrics_chart.xml} (79%) delete mode 100644 app/src/main/res/layout/item_metrics_network_chart.xml delete mode 100644 app/src/main/res/layout/item_metrics_power_chart.xml create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt index 878ddb36e1..0ea5ba14fc 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt @@ -18,7 +18,6 @@ package com.itsaky.androidide.ui import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView @@ -27,33 +26,47 @@ import com.itsaky.androidide.R /** * A page of the editor's metrics carousel. * + * A page says what it is called, what it is, and what draws it. Nothing else in the carousel needs + * to know which page it is holding, which is what lets [MetricsCarouselAdapter] be page-agnostic. + * + * Deliberately an ordinary interface rather than a sealed one. The adapter has always claimed that + * a new display -- including one contributed by a plugin -- could be added without touching it; + * while this was sealed that was impossible, since a plugin is a different module and could not + * implement it at all. + * * @property title Names the page. Shown below the carousel, and the only cue to which page is * showing, so every page needs one. + * @property contentDescription What the plot is, for a screen reader. + * @property renderer Draws this page and owns its axes, annotations and shading. */ -sealed interface MetricsPage { +interface MetricsPage { @get:StringRes val title: Int - /** The live memory-usage chart, rendered by [MemoryUsageChartRenderer]. */ - data class MemoryChart( - @StringRes override val title: Int, - ) : MetricsPage + @get:StringRes val contentDescription: Int - /** The live network-traffic chart, rendered by [NetworkUsageChartRenderer]. */ - data class NetworkChart( - @StringRes override val title: Int, - ) : MetricsPage - - /** The live temperature and power chart, rendered by [PowerUsageChartRenderer]. */ - data class PowerChart( - @StringRes override val title: Int, - ) : MetricsPage + val renderer: MetricsChartRenderer } +/** + * A page showing one line chart. + * + * There used to be a type per metric -- `MemoryChart`, `NetworkChart`, `PowerChart` -- each with a + * layout of its own that differed from its siblings by one attribute, plus a view type, a view + * holder subclass and a branch in four `when` expressions. They differed in nothing a chart page + * needs to differ in. + */ +data class ChartPage( + @StringRes override val title: Int, + @StringRes override val contentDescription: Int, + override val renderer: MetricsChartRenderer, +) : MetricsPage + /** * Backs the editor's horizontally swipeable carousel of metric displays. * - * [pages] is a constructor argument rather than a hardcoded list so that new displays -- a network - * traffic chart, or pages contributed by plugins -- can be added without touching this class. + * [pages] is a constructor argument rather than a hardcoded list so that new displays can be added + * without touching this class -- and now nothing here names a page or a metric, so that is true + * rather than aspirational. * * A chart page holds no sample state of its own: its renderer is attached when the page binds and * detached when it is recycled, and rebuilds the full history from its watcher each time. Moving @@ -61,98 +74,56 @@ sealed interface MetricsPage { */ class MetricsCarouselAdapter( private val pages: List, - private val memoryChartRenderer: MemoryUsageChartRenderer, - private val networkChartRenderer: NetworkUsageChartRenderer, - private val powerChartRenderer: PowerUsageChartRenderer, ) : RecyclerView.Adapter() { - sealed class PageViewHolder( - view: View, - ) : RecyclerView.ViewHolder(view) { - class MemoryChart( - val chart: SafeLineChart, - ) : PageViewHolder(chart) - - class NetworkChart( - val chart: SafeLineChart, - ) : PageViewHolder(chart) - - class PowerChart( - val chart: SafeLineChart, - ) : PageViewHolder(chart) + /** + * @property boundRenderer What was attached to [chart] at bind time, so [onViewRecycled] can + * detach the right renderer without being told the position -- which it is not. + */ + class PageViewHolder( + val chart: SafeLineChart, + ) : RecyclerView.ViewHolder(chart) { + var boundRenderer: MetricsChartRenderer? = null } override fun getItemCount(): Int = pages.size - override fun getItemViewType(position: Int): Int = - when (pages[position]) { - is MetricsPage.MemoryChart -> VIEW_TYPE_MEMORY_CHART - is MetricsPage.NetworkChart -> VIEW_TYPE_NETWORK_CHART - is MetricsPage.PowerChart -> VIEW_TYPE_POWER_CHART - } + /** + * One view type per page, so a chart is never recycled from one page onto another. + * + * Not a saving worth making here: a chart carries the state its renderer put on it, and some of + * that is written by one renderer and cleared by none of the others -- the thermal shading on + * the power page is set through [SafeLineChart.backgroundSpans], which a memory or network + * renderer has no reason to touch. A handful of pages, each keeping its own chart, costs + * nothing and cannot leak one page's decoration onto another. + */ + override fun getItemViewType(position: Int): Int = position override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, ): PageViewHolder { - val inflater = LayoutInflater.from(parent.context) - return when (viewType) { - VIEW_TYPE_MEMORY_CHART -> { - PageViewHolder.MemoryChart( - inflater.inflate(R.layout.item_metrics_memory_chart, parent, false) as SafeLineChart, - ) - } - - VIEW_TYPE_NETWORK_CHART -> { - PageViewHolder.NetworkChart( - inflater.inflate(R.layout.item_metrics_network_chart, parent, false) as SafeLineChart, - ) - } - - VIEW_TYPE_POWER_CHART -> { - PageViewHolder.PowerChart( - inflater.inflate(R.layout.item_metrics_power_chart, parent, false) as SafeLineChart, - ) - } - - else -> { - throw IllegalArgumentException("Unknown metrics page view type: $viewType") - } - } + val chart = + LayoutInflater + .from(parent.context) + .inflate(R.layout.item_metrics_chart, parent, false) as SafeLineChart + return PageViewHolder(chart) } override fun onBindViewHolder( holder: PageViewHolder, position: Int, ) { - when (pages[position]) { - is MetricsPage.MemoryChart -> { - memoryChartRenderer.attach((holder as PageViewHolder.MemoryChart).chart) - } - - is MetricsPage.NetworkChart -> { - networkChartRenderer.attach((holder as PageViewHolder.NetworkChart).chart) - } - - is MetricsPage.PowerChart -> { - powerChartRenderer.attach((holder as PageViewHolder.PowerChart).chart) - } - } + val page = pages[position] + holder.chart.contentDescription = holder.chart.context.getString(page.contentDescription) + holder.boundRenderer = page.renderer + page.renderer.attach(holder.chart) } override fun onViewRecycled(holder: PageViewHolder) { // Only if this holder's chart is still the attached one: a rebind can create the replacement // before RecyclerView recycles the view it replaced, and detaching then would drop the new // chart instead of the old. - when (holder) { - is PageViewHolder.MemoryChart -> memoryChartRenderer.detachIfAttached(holder.chart) - is PageViewHolder.NetworkChart -> networkChartRenderer.detachIfAttached(holder.chart) - is PageViewHolder.PowerChart -> powerChartRenderer.detachIfAttached(holder.chart) - } - } - - private companion object { - const val VIEW_TYPE_MEMORY_CHART = 0 - const val VIEW_TYPE_NETWORK_CHART = 1 - const val VIEW_TYPE_POWER_CHART = 2 + holder.boundRenderer?.detachIfAttached(holder.chart) + holder.boundRenderer = null } } 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 7acae11e8a..e02a7399e7 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -85,15 +85,6 @@ class MetricsCarouselController( sampleInterval = { networkUsageWatcher.updateInterval }, ) - 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), - MetricsPage.PowerChart(title = string.metrics_title_power), - ) - private val powerRenderer = PowerUsageChartRenderer( usageProvider = { powerUsageWatcher.getUsage() }, @@ -102,6 +93,34 @@ class MetricsCarouselController( sampleIntervalMillis = { powerUsageWatcher.updateInterval }, ) + /** + * The carousel's pages, in order. + * + * Declared after the renderers, not before: a page holds its own renderer, and Kotlin + * initialises properties in declaration order, so listing the pages first read powerRenderer + * while it was still null. + */ + private val pages: List = + 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. + ChartPage( + title = string.metrics_title_memory, + contentDescription = string.metrics_carousel_memory_chart, + renderer = memoryRenderer, + ), + ChartPage( + title = string.metrics_title_network, + contentDescription = string.metrics_network_chart, + renderer = networkRenderer, + ), + ChartPage( + title = string.metrics_title_power, + contentDescription = string.metrics_power_chart, + renderer = powerRenderer, + ), + ) + private val powerListener = PowerUsageWatcher.PowerUsageListener { usage -> powerRenderer.onUsageChanged(usage) @@ -149,7 +168,7 @@ class MetricsCarouselController( this.binding = binding - binding.metricsPager.adapter = MetricsCarouselAdapter(pages, memoryRenderer, networkRenderer, powerRenderer) + binding.metricsPager.adapter = MetricsCarouselAdapter(pages) val showTitleFor = { position: Int -> pages.getOrNull(position)?.let { page -> @@ -267,8 +286,8 @@ class MetricsCarouselController( @UiThread private fun updateBatteryReadout() { val binding = this.binding ?: return - val onPowerPage = pages.getOrNull(binding.metricsPager.currentItem) is MetricsPage.PowerChart - val readout = if (onPowerPage) powerRenderer.batteryReadout() else null + val renderer = currentRenderer() + val readout = renderer?.readout() binding.metricsBattery.text = readout.orEmpty() binding.metricsBattery.isVisible = readout != null @@ -281,7 +300,7 @@ class MetricsCarouselController( } else { binding.metricsBattery.lineHeight + binding.metricsBattery.paddingTop.toFloat() } - powerRenderer.reserveTopSpace(reserved) + renderer?.reserveTopSpace(reserved) } /** @@ -289,12 +308,7 @@ class MetricsCarouselController( */ 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 - is MetricsPage.PowerChart -> powerRenderer - null -> null - } + return pages.getOrNull(binding.metricsPager.currentItem)?.renderer } /** @@ -413,12 +427,7 @@ class MetricsCarouselController( val position = binding.metricsPager.currentItem val page = pages.getOrNull(position) ?: return false - val renderer = - when (page) { - is MetricsPage.MemoryChart -> memoryRenderer - is MetricsPage.NetworkChart -> networkRenderer - is MetricsPage.PowerChart -> powerRenderer - } + val renderer = page.renderer val label = context.getString(page.title) val bitmap = renderer.snapshot() 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 1e5862ac52..ac0fc3d610 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -82,6 +82,15 @@ abstract class MetricsChartRenderer( protected var chart: SafeLineChart? = null private set + /** + * A short readout to show beside this page's chart, or `null` if it has none. + * + * Asked of the renderer rather than decided from the page's type, so the carousel does not + * have to know which of its pages happens to have a battery on it. + */ + @UiThread + open fun readout(): String? = null + /** * Keeps [pixels] of the chart's top clear of the plot and its labels. * diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index 121fdb5947..306cae5fe4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -368,7 +368,7 @@ class PowerUsageChartRenderer( * hidden while charging, when a rising level would contradict a chart about power being spent. */ @UiThread - fun batteryReadout(): String? { + override fun readout(): String? { val battery = batteryProvider() if (battery.isCharging || battery.levelPercent < 0) { return null diff --git a/app/src/main/res/layout/item_metrics_memory_chart.xml b/app/src/main/res/layout/item_metrics_chart.xml similarity index 79% rename from app/src/main/res/layout/item_metrics_memory_chart.xml rename to app/src/main/res/layout/item_metrics_chart.xml index d6eaaa40ab..643645bd1f 100644 --- a/app/src/main/res/layout/item_metrics_memory_chart.xml +++ b/app/src/main/res/layout/item_metrics_chart.xml @@ -5,9 +5,10 @@ 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 . --> + + android:layout_height="match_parent" /> diff --git a/app/src/main/res/layout/item_metrics_network_chart.xml b/app/src/main/res/layout/item_metrics_network_chart.xml deleted file mode 100644 index f011080f06..0000000000 --- a/app/src/main/res/layout/item_metrics_network_chart.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/layout/item_metrics_power_chart.xml b/app/src/main/res/layout/item_metrics_power_chart.xml deleted file mode 100644 index aa319feba1..0000000000 --- a/app/src/main/res/layout/item_metrics_power_chart.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt new file mode 100644 index 0000000000..b2cb9f205e --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt @@ -0,0 +1,158 @@ +/* + * 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.widget.FrameLayout +import androidx.appcompat.view.ContextThemeWrapper +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineDataSet +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.resources.R.string +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins what the carousel adapter promises now that it no longer names any metric. + * + * It used to branch on the page's type in four places, with a view type, a view-holder subclass + * and a layout per metric; the layouts differed from each other by one attribute. These are the + * behaviours that branching was providing, asserted directly so the page-agnostic version cannot + * quietly drop one. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCarouselAdapterTest { + private val context: Context = + ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.Theme_AndroidIDE) + + private val parent = FrameLayout(context) + + /** A renderer that records what it was attached to, and draws just enough to be attachable. */ + private class TestRenderer( + private val readout: String? = null, + ) : MetricsChartRenderer(sampleIntervalMillis = { 1_000L }) { + val attached = mutableListOf() + + override fun rebuild() { + val chart = this.chart ?: return + setData(chart, arrayOf(LineDataSet(listOf(Entry(0f, 0f)), "test"))) + attached += chart + } + + override fun readout(): String? = readout + + /** detachIfAttached is final, so detachment is observed through what it leaves behind. */ + val isAttached: Boolean + get() = chart != null + } + + private fun pageOf( + renderer: MetricsChartRenderer, + description: Int = string.metrics_carousel_memory_chart, + ) = ChartPage(title = string.metrics_title_memory, contentDescription = description, renderer = renderer) + + private fun bind( + adapter: MetricsCarouselAdapter, + position: Int, + ): MetricsCarouselAdapter.PageViewHolder { + val holder = adapter.onCreateViewHolder(parent, adapter.getItemViewType(position)) + adapter.onBindViewHolder(holder, position) + return holder + } + + @Test + fun `each page gets its own chart, never one recycled from another page`() { + val pages = List(3) { pageOf(TestRenderer()) } + val adapter = MetricsCarouselAdapter(pages) + + // One view type per position. Sharing a chart between pages would carry over whatever the + // previous renderer had put on it -- the power page's thermal shading is written through + // SafeLineChart.backgroundSpans, which no other renderer clears. + val types = pages.indices.map(adapter::getItemViewType) + assertThat(types.toSet()).hasSize(pages.size) + } + + @Test + fun `binding attaches that page's own renderer`() { + val first = TestRenderer() + val second = TestRenderer() + val adapter = MetricsCarouselAdapter(listOf(pageOf(first), pageOf(second))) + + val holder = bind(adapter, 1) + + assertThat(second.attached).containsExactly(holder.chart) + assertThat(first.attached).isEmpty() + } + + @Test + fun `binding describes the plot for a screen reader`() { + val adapter = + MetricsCarouselAdapter( + listOf(pageOf(TestRenderer(), description = string.metrics_power_chart)), + ) + + val holder = bind(adapter, 0) + + // This was the only thing the three per-metric layouts differed in, so it is the one + // thing collapsing them to one could have lost. + assertThat(holder.chart.contentDescription) + .isEqualTo(context.getString(string.metrics_power_chart)) + } + + @Test + fun `recycling detaches the renderer that was bound`() { + val first = TestRenderer() + val second = TestRenderer() + val adapter = MetricsCarouselAdapter(listOf(pageOf(first), pageOf(second))) + val holder = bind(adapter, 1) + assertThat(second.isAttached).isTrue() + + adapter.onViewRecycled(holder) + + // The holder no longer carries its page's type, so it has to remember its renderer: + // onViewRecycled is not told the position, and may be given NO_POSITION. + assertThat(second.isAttached).isFalse() + assertThat(holder.boundRenderer).isNull() + } + + @Test + fun `a rebind before the old view is recycled keeps the new chart attached`() { + val renderer = TestRenderer() + val adapter = MetricsCarouselAdapter(listOf(pageOf(renderer))) + val old = bind(adapter, 0) + val new = bind(adapter, 0) + + // RecyclerView can create the replacement before recycling what it replaced. Detaching + // unconditionally here would drop the new chart instead of the old one. + adapter.onViewRecycled(old) + + assertThat(renderer.isAttached).isTrue() + assertThat(new.boundRenderer).isSameInstanceAs(renderer) + } + + @Test + fun `a page with nothing to read out says so, without being asked what kind it is`() { + // The battery readout used to be reached by testing the page's type. Only the power page + // has one; every other renderer answers null from the base class. + assertThat(TestRenderer().readout()).isNull() + assertThat(TestRenderer(readout = "62%").readout()).isEqualTo("62%") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index ae3e62b186..880ca7526d 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -279,7 +279,7 @@ class PowerUsageChartRendererTest { ) // A level climbing while the chart is about power being spent reads as a contradiction. - assertThat(charging.batteryReadout()).isNull() + assertThat(charging.readout()).isNull() } @Test @@ -290,7 +290,7 @@ class PowerUsageChartRendererTest { battery = BatteryState(levelPercent = 62, isCharging = false), ) - assertThat(renderer.batteryReadout()).isEqualTo("62%") + assertThat(renderer.readout()).isEqualTo("62%") } @Test @@ -301,7 +301,7 @@ class PowerUsageChartRendererTest { battery = BatteryState.UNKNOWN, ) - assertThat(renderer.batteryReadout()).isNull() + assertThat(renderer.readout()).isNull() } private fun laidOut(chart: SafeLineChart) { From fcff821f74175447b6355795ab3221004e999355 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 12:30:35 -0700 Subject: [PATCH 057/128] ADFA-5510: give the new adapter test's renderer a help tag helpTag became abstract on this branch, so the test renderer that arrived with ADFA-5499's adapter refactor does not compile here without one. The merge is the first place the two changes meet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt index b2cb9f205e..148507adc4 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt @@ -25,6 +25,7 @@ import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineDataSet import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.resources.R.string import org.junit.Test import org.junit.runner.RunWith @@ -49,6 +50,8 @@ class MetricsCarouselAdapterTest { private class TestRenderer( private val readout: String? = null, ) : MetricsChartRenderer(sampleIntervalMillis = { 1_000L }) { + override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY + val attached = mutableListOf() override fun rebuild() { From 434174621cc22729bbe88d275487ccd897580613 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 13:30:19 -0700 Subject: [PATCH 058/128] ADFA-5499: set axis bounds before the notify, and publish watcher state Two findings from CodeRabbit, both confirmed and both wider than reported. Manual axis bounds never reached the transform. axisMinimum and axisMaximum only store a value; notifyDataSetChanged is what recomputes the axis values and the value-to-pixel mapping, and it is protected against being called directly. Two of the three renderers ranged after that notify, so until something else recalculated -- a layout change, or the next tick -- the chart drew through a transform built from the bounds MPAndroidChart had picked for itself. The network chart had it in the per-tick path, which runs once a second. The order is now the base class's, not each renderer's: setData and redraw take the ranging step and run it before the notify. No renderer chooses any more, which is the point -- all three had drifted to different orderings, and the comment on one of them ("After, not before: setData is what scrolls the window...") described a design that no longer exists, since visibleSampleRange stopped reading the viewport when it started keying on userHasZoomed. updateInterval and listener are now @Volatile on all three watchers. They are written on the UI thread and read on each watcher's own sampling thread, so a reader could go on seeing a cleared listener or a stale interval indefinitely. CodeRabbit flagged PowerUsageWatcher; MemoryUsageWatcher had the same on listener, and all three did on updateInterval. NetworkUsageWatcher.listener was already marked, so the knowledge was in the codebase and the sweep was what was missing -- the third time that shape has come up in this stack. The first version of the rebuild-path test passed with the bug still in place: it laid the chart out with a draw, and the draw recomputes the transform by itself. It now rebuilds after the layout and asserts without drawing again. Both tests fail against the old ordering. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MemoryUsageChartRenderer.kt | 10 ++--- .../androidide/ui/MetricsChartRenderer.kt | 17 +++++++- .../ui/NetworkUsageChartRenderer.kt | 10 +---- .../androidide/ui/PowerUsageChartRenderer.kt | 6 +-- .../androidide/utils/MemoryUsageWatcher.kt | 8 ++++ .../androidide/utils/NetworkUsageWatcher.kt | 4 ++ .../androidide/utils/PowerUsageWatcher.kt | 8 ++++ .../ui/NetworkUsageChartRendererTest.kt | 41 +++++++++++++++++++ 8 files changed, 85 insertions(+), 19 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 fd00fa02bc..e046c95e08 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -104,8 +104,7 @@ class MemoryUsageChartRenderer( } } - applyAxisRange(chart, processes) - setData(chart, datasets) + setData(chart, datasets) { applyAxisRange(it, processes) } } /** @@ -184,10 +183,11 @@ class MemoryUsageChartRenderer( if (dataChanged) { // From the samples already in hand: usagesProvider() copies every history, so calling // it again here would snapshot the whole buffer a second time per tick. - applyAxisRangeFor(chart) { visit -> - memoryUsage.forEachValue { visit(it) } + redraw(chart) { ranged -> + applyAxisRangeFor(ranged) { visit -> + memoryUsage.forEachValue { visit(it) } + } } - redraw(chart) } } 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 ac0fc3d610..5cbc153b3f 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -352,6 +352,7 @@ abstract class MetricsChartRenderer( protected fun setData( chart: SafeLineChart, datasets: Array, + applyAxisRanges: (SafeLineChart) -> Unit = {}, ) { val bgColor = chart.context.resolveAttr(R.attr.colorSurfaceDim) val textColor = chart.context.resolveAttr(R.attr.colorOnSurface) @@ -369,8 +370,14 @@ abstract class MetricsChartRenderer( styleValueAxes(this, textColor) setBackgroundColor(bgColor) setGridBackgroundColor(bgColor) - notifyDataSetChanged() } + // Ranges first, then the notify. setting axisMinimum and axisMaximum only stores them; + // what recomputes the axis values and the value-to-pixel transform is notifyDataSetChanged, + // and it is protected against being called directly. Ranged after the notify -- as two of + // the three renderers did -- the chart draws its next frame through a transform built from + // the bounds MPAndroidChart picked for itself. + applyAxisRanges(chart) + chart.notifyDataSetChanged() applyAnnotations(chart) showNewestWindow(chart) chart.invalidate() @@ -448,7 +455,13 @@ abstract class MetricsChartRenderer( /** * Redraws after the attached series have been mutated in place. */ - protected fun redraw(chart: SafeLineChart) { + protected fun redraw( + chart: SafeLineChart, + applyAxisRanges: (SafeLineChart) -> Unit = {}, + ) { + // Same order as [setData], and for the same reason: the bounds have to be in place before + // the notify that turns them into a transform. + applyAxisRanges(chart) chart.apply { data.notifyDataChanged() notifyDataSetChanged() 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 989139e676..d8ce7d0cec 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -78,11 +78,7 @@ class NetworkUsageChartRenderer( dataset(usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted), TRANSMITTED_COLOR), ) - 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() + setData(chart, datasets) { applyAxisRange(it, usage) } } /** @@ -112,9 +108,7 @@ 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)) - redraw(chart) - applyAxisRange(chart, usage) - chart.invalidate() + redraw(chart) { applyAxisRange(it, usage) } } private fun dataset( diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index 306cae5fe4..31d6948ffb 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -94,8 +94,7 @@ class PowerUsageChartRenderer( ), ) - setData(chart, datasets) - applyAxisRanges(chart, usage) + setData(chart, datasets) { applyAxisRanges(it, usage) } applyThermalShading(chart, usage) } @@ -138,9 +137,8 @@ class PowerUsageChartRenderer( transform = ::microWattsToWatts, ) - applyAxisRanges(chart, usage) applyThermalShading(chart, usage) - redraw(chart) + redraw(chart) { applyAxisRanges(it, usage) } } /** Rewrites one series' values in place and refreshes its legend entry. */ 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/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index 8735ff2d3d..2407c1673a 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/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index dfc1c8a9a2..4753e9e730 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -89,7 +89,14 @@ class PowerUsageWatcher /** * 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: 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) @@ -109,6 +116,7 @@ class PowerUsageWatcher get() = watching.get() /** Notified on the main thread after each sample. */ + @Volatile var listener: PowerUsageListener? = null /** 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 85cd37298c..3e8cf8f781 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -114,6 +114,47 @@ class NetworkUsageChartRendererTest { chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) } + @Test + fun `a rebuild after layout leaves the bounds and the transform in step`() { + val chart = SafeLineChart(context) + var samples = LongArray(SAMPLE_COUNT) { 500L } + val renderer = NetworkUsageChartRenderer(usageProvider = { usage(samples) }) + renderer.attach(chart) + laidOut(chart) + + // Rebuild after the layout, and assert without drawing again: a draw recomputes the + // transform on its own, which is what made the first version of this test pass with the + // bug still in place. + samples = LongArray(SAMPLE_COUNT) { 900_000L } + renderer.rebuild() + + // Setting axisMinimum and axisMaximum only stores them; notifyDataSetChanged is what turns + // them into a value-to-pixel transform. + val ceiling = chart.axisRight.axisMaximum + val pixel = chart.getPixelForValues(0f, ceiling, YAxis.AxisDependency.RIGHT) + + assertThat(pixel.y.toFloat()).isWithin(1f).of(chart.viewPortHandler.contentTop()) + } + + @Test + fun `a tick keeps the bounds and the transform in step`() { + val chart = SafeLineChart(context) + var samples = LongArray(SAMPLE_COUNT) { 500L } + val renderer = NetworkUsageChartRenderer(usageProvider = { usage(samples) }) + renderer.attach(chart) + laidOut(chart) + + // A burst raises the ceiling. The per-tick path had the same ordering bug as the rebuild, + // and it is the one that runs once a second. + samples = LongArray(SAMPLE_COUNT) { 900_000L } + renderer.onUsageChanged(usage(samples)) + + val ceiling = chart.axisRight.axisMaximum + val pixel = chart.getPixelForValues(0f, ceiling, YAxis.AxisDependency.RIGHT) + + assertThat(pixel.y.toFloat()).isWithin(1f).of(chart.viewPortHandler.contentTop()) + } + @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. From 55c553d2d0920260de715be8b4eef4980f07c01b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 14:17:28 -0700 Subject: [PATCH 059/128] ADFA-5509: forward the cancel request, so BUILD_CANCELLED can happen The feature was unreachable. setEventListener stores wrap(listener), and cancelCurrentBuild calls onBuildCancelRequested on that wrapper -- but wrap overrode five of the interface's six callbacks and not this one, so the call landed on the interface default and stopped there. EditorBuildEventListener.cancelRequested therefore stayed false, and a build the user had stopped went on being annotated BUILD_FAILED: exactly what the kind was added to prevent. The comment at the call site described an intent the wiring did not deliver. The default is what allowed it. `= Unit` was there so that only listeners caring about the distinction had to implement it, and the wrapper then inherited silence instead of being asked to forward. It is now abstract: the compiler asks every implementor, wrapper included, and this class of omission stops being possible. My own test was why it survived. MetricsAnnotationStoreTest calls store.recordBuild(BUILD_CANCELLED) directly, so it proved the store handles the kind while nothing proved the kind could ever be produced -- a test on the destination with the path to it untested. Two tests now, and the first one I wrote was worthless: asserting that the wrapper "overrides every method the interface declares" cannot fail, because Kotlin emits a bridge method on the implementing class for an inherited default, so reflection sees an override that is really a no-op. It passed against the bug. What it checks instead is that no callback has a default implementation at all, which is the property that would have caught this; that one fails against the bug, as does a direct check that a cancel reaches the listener. wrap moved to the companion object -- it closes over nothing but its argument, and building the service under Robolectric to reach a private method brought up the whole tooling stack and crashed the test JVM. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../services/builder/GradleBuildService.kt | 70 ++++++++------- .../GradleBuildServiceListenerWrapperTest.kt | 88 +++++++++++++++++++ 2 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index c116e5b19a..437698d64d 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -24,6 +24,7 @@ import android.app.Service import android.content.Intent import android.os.IBinder import android.text.TextUtils +import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationManagerCompat import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.analytics.IAnalyticsManager @@ -174,6 +175,38 @@ class GradleBuildService : ) companion object { + @VisibleForTesting + internal fun wrap(listener: EventListener?): EventListener? = + if (listener == null) { + null + } else { + object : EventListener { + override fun onBuildCancelRequested() { + runOnUiThread { listener.onBuildCancelRequested() } + } + + override fun prepareBuild(buildInfo: BuildInfo) { + runOnUiThread { listener.prepareBuild(buildInfo) } + } + + override fun onBuildSuccessful(tasks: List) { + runOnUiThread { listener.onBuildSuccessful(tasks) } + } + + override fun onProgressEvent(event: ProgressEvent) { + runOnUiThread { listener.onProgressEvent(event) } + } + + override fun onBuildFailed(tasks: List) { + runOnUiThread { listener.onBuildFailed(tasks) } + } + + override fun onOutput(line: String?) { + runOnUiThread { listener.onOutput(line) } + } + } + } + private val log = LoggerFactory.getLogger(GradleBuildService::class.java) private val NOTIFICATION_ID = R.string.app_name private val SERVER_System_err = LoggerFactory.getLogger("ToolingApiErrorStream") @@ -750,33 +783,6 @@ class GradleBuildService : return this } - private fun wrap(listener: EventListener?): EventListener? = - if (listener == null) { - null - } else { - object : EventListener { - override fun prepareBuild(buildInfo: BuildInfo) { - runOnUiThread { listener.prepareBuild(buildInfo) } - } - - override fun onBuildSuccessful(tasks: List) { - runOnUiThread { listener.onBuildSuccessful(tasks) } - } - - override fun onProgressEvent(event: ProgressEvent) { - runOnUiThread { listener.onProgressEvent(event) } - } - - override fun onBuildFailed(tasks: List) { - runOnUiThread { listener.onBuildFailed(tasks) } - } - - override fun onOutput(line: String?) { - runOnUiThread { listener.onOutput(line) } - } - } - } - private fun startServerOutputReader(input: InputStream): Job { outputReaderJob?.let { job -> if (job.isActive) { @@ -814,10 +820,14 @@ class GradleBuildService : * Called when the user asks for the running build to stop. * * The tooling API reports a cancelled build through [onBuildFailed], so a listener that - * wants to tell the two apart has to be told here. Defaulted, because only a listener that - * cares about the distinction needs it. + * wants to tell the two apart has to be told here. + * + * Deliberately not defaulted. It was, and the forwarding wrapper in [GradleBuildService] + * then quietly inherited the no-op instead of passing it on -- so the cancel never reached + * the real listener, and a build the user stopped went on being annotated as a failure. A + * member with no default cannot be forgotten by a wrapper; the compiler asks for it. */ - fun onBuildCancelRequested() = Unit + fun onBuildCancelRequested() /** * Called just before a build is started. diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt new file mode 100644 index 0000000000..d515017ffa --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt @@ -0,0 +1,88 @@ +/* + * 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.services.builder + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.services.builder.GradleBuildService.EventListener +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.lang.reflect.Proxy + +/** + * Pins that the build service's listener wrapper forwards every callback it is given. + * + * It did not. `onBuildCancelRequested` was declared with a `= Unit` default so that only listeners + * that cared had to implement it; the wrapper then inherited that no-op rather than passing the + * call on, so the cancel never reached the real listener and a build the user had stopped went on + * being annotated as a failure -- which is what BUILD_CANCELLED exists to prevent. The feature was + * unreachable, and the store-level test for it passed the whole time, because it called the store + * directly and nothing exercised the path to it. + */ +@RunWith(RobolectricTestRunner::class) +class GradleBuildServiceListenerWrapperTest { + /** Records which interface method it was handed, so no call has to be spelled out here. */ + private class Recorder { + val calls = mutableListOf() + + val listener: EventListener = + Proxy.newProxyInstance( + EventListener::class.java.classLoader, + arrayOf(EventListener::class.java), + ) { _, method, _ -> + calls += method.name + null + } as EventListener + } + + @Test + fun `no callback on the interface has a default implementation`() { + // This is the invariant that would have caught the bug, and it is not the one you reach + // for first: asserting that the wrapper "overrides every declared method" looks right and + // cannot fail, because Kotlin emits a bridge method on the implementing class for an + // inherited default, so reflection sees an override that is really a no-op. + // + // A default is what let the wrapper inherit silence instead of being asked to forward. With + // none, the compiler demands an implementation from every implementor -- the wrapper + // included -- and this class of omission stops being possible. + val defaults = + EventListener::class.java.declaredClasses + .firstOrNull { it.simpleName == "DefaultImpls" } + ?.declaredMethods + .orEmpty() + .map { it.name } + + assertThat(defaults).isEmpty() + } + + @Test + fun `a cancel request reaches the listener`() { + val recorder = Recorder() + val wrapped = GradleBuildService.wrap(recorder.listener)!! + + wrapped.onBuildCancelRequested() + + // The one this went wrong on, kept as its own case so the reason is legible in a report. + assertThat(recorder.calls).containsExactly("onBuildCancelRequested") + } + + @Test + fun `wrapping nothing yields nothing`() { + assertThat(GradleBuildService.wrap(null)).isNull() + } +} From 7cdbfa9446b134f72226a00129157d6a239cf76d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 14:28:28 -0700 Subject: [PATCH 060/128] 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 f9b39e28add2d4373734400e569fa2bbe670f483 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 14:55:21 -0700 Subject: [PATCH 061/128] ADFA-5510: give the window's close control a tag of its own Long-pressing the X on a floating window answered "Opens the file in a separate window" -- the opposite action, and the wrong noun when the window holds the metrics carousel. The close control had no tag, so it borrowed WINDOW_UNDOCK, which is not spare: two editor controls use it for a real undock, where that copy is correct. So this was a mismap rather than bad copy, and the fix is a tag of its own. Found from a screenshot of the undocked carousel, and reachable from the carousel at all because ADFA-5510 wired its chrome to the shared handler. It was equally wrong on editor and plugin windows before that. Verified on device: the X now reads "Closes the floating window.", and its See More gives the detail. Checked the dock control alongside it, so the two neighbouring controls no longer describe the same action. Also pins in the rebind test that dimming an end arrow changes its alpha and not its tint. The two are orthogonal -- the tint is the colour the glyph is drawn in, the dimming is alpha over it -- which is why asserting the arrows share a tint does not contradict their looking different at the ends of the carousel. A later change that dimmed through a state-aware ColorStateList would break that, and now it would be caught. The copy lives in documentation.db, which this repository cannot edit; it has been written into the local database for testing, and the four window-* tags need it in the content release. Recorded on ADFA-5513. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../editor/floating/ChromeControlTooltips.kt | 3 +-- .../ui/MetricsCarouselRebindTest.kt | 19 +++++++++++++++++++ .../androidide/idetooltips/TooltipTag.kt | 9 +++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/ChromeControlTooltips.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/ChromeControlTooltips.kt index 20121ec1db..43a32c64c8 100644 --- a/app/src/main/java/com/itsaky/androidide/editor/floating/ChromeControlTooltips.kt +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/ChromeControlTooltips.kt @@ -7,7 +7,6 @@ import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag - object ChromeControlTooltips { val handler: (ChromeControl, View) -> Unit = { control, anchor -> tagFor(control)?.let { tag -> @@ -20,6 +19,6 @@ object ChromeControlTooltips { ChromeControl.MINIMIZE -> TooltipTag.WINDOW_MINIMIZE ChromeControl.MAXIMIZE -> TooltipTag.WINDOW_MAXIMIZE ChromeControl.DOCK -> TooltipTag.WINDOW_DOCK - ChromeControl.CLOSE -> TooltipTag.WINDOW_UNDOCK + ChromeControl.CLOSE -> TooltipTag.WINDOW_CLOSE } } 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 8546083abb..66df690e00 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt @@ -123,6 +123,25 @@ class MetricsCarouselRebindTest { assertThat(previous.defaultColor).isEqualTo(next.defaultColor) } + @Test + fun `dimming an end arrow changes its alpha, not its tint`() { + val binding = strip() + controller().bind(binding) + + // The two are orthogonal, which is why asserting the arrows share a tint does not + // contradict their looking different at the ends of the carousel: the tint is the colour + // the glyph is drawn in, and the dimming is alpha over the top of it. A later change that + // dimmed through a state-aware ColorStateList instead would break that, and this says so. + assertThat(binding.metricsPager.currentItem).isEqualTo(0) + assertThat(binding.metricsPrevious.alpha).isLessThan(binding.metricsNext.alpha) + + val previous = ImageViewCompat.getImageTintList(binding.metricsPrevious)!! + val next = ImageViewCompat.getImageTintList(binding.metricsNext)!! + assertThat(previous.defaultColor).isEqualTo(next.defaultColor) + // One colour, no per-state variation: nothing here depends on the enabled state. + assertThat(previous.isStateful).isFalse() + } + @Test fun `the arrow tint is the colour the title uses`() { val binding = strip() diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 64d2025174..5086eb33e1 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -235,6 +235,15 @@ object TooltipTag { const val WINDOW_DOCK = "window-dock" const val WINDOW_UNDOCK = "window-undock" + /** + * The floating window's close control. + * + * Distinct from [WINDOW_UNDOCK], which names the opposite action and belongs to the editor + * controls that open something in a window. The chrome's close button borrowed that tag, so a + * long press on it answered "Opens the file in a separate window". + */ + const val WINDOW_CLOSE = "window-close" + // Delete project const val DELETE_PROJECT = "project.delete" const val DELETE_PROJECT_SELECT = "project.delete.select" From bbf3ab184812f0118488a51d6533224e58a65643 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 15:56:14 -0700 Subject: [PATCH 062/128] 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 063/128] 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 498f3d4f7858be090f9f200602d12b368e9be428 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 17:50:10 -0700 Subject: [PATCH 064/128] ADFA-5510: sweep the last setOnLongClickListener(null) site TooltipMaterialCheckBox clears its tooltip listener the same way everything else did before this ticket: setOnLongClickListener(null) without unsetting isLongClickable, so it goes on consuming long presses for a tooltip it no longer offers. Missed by my own sweep, and for a dull reason: I grepped *.kt, and this one is Java in the resources module. It cannot use the Kotlin extension from there, so it does both halves inline. Also records why the chart's long-press help takes the default haptic feedback while every view-based site passes false. Those rely on View.performLongClick buzzing for them; BarLineChartBase.onTouchEvent never calls super, so the framework's long press never runs on a chart and the manual feedback is the only thing there is. The two look inconsistent and are not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsChartRenderer.kt | 4 ++ .../resources/TooltipMaterialCheckBox.java | 60 ++++++++++--------- 2 files changed, 36 insertions(+), 28 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 ad09aef9fb..6d72601dba 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -343,6 +343,10 @@ abstract class MetricsChartRenderer( override fun onChartLongPressed(me: MotionEvent?) { val y = me?.y ?: return val tag = helpTagAt(y) ?: return + // Haptic feedback left at its default, unlike every view-based help site, which + // passes false. Those rely on View.performLongClick buzzing for them; + // BarLineChartBase.onTouchEvent never calls super, so the framework's long press -- + // and its feedback -- never runs here and this is the only thing that provides it. showIdeCategoryTooltipIfPresent(chart.context, chart, tag) } diff --git a/resources/src/main/java/com/itsaky/androidide/resources/TooltipMaterialCheckBox.java b/resources/src/main/java/com/itsaky/androidide/resources/TooltipMaterialCheckBox.java index 49fb9eb6f2..e291a9fc8e 100644 --- a/resources/src/main/java/com/itsaky/androidide/resources/TooltipMaterialCheckBox.java +++ b/resources/src/main/java/com/itsaky/androidide/resources/TooltipMaterialCheckBox.java @@ -9,34 +9,38 @@ import com.google.android.material.checkbox.MaterialCheckBox; /** - * A MaterialCheckBox that implements ITooltipView to provide a unified - * long-press listener for tooltips. + * A MaterialCheckBox that implements ITooltipView to provide a unified long-press listener for tooltips. */ public class TooltipMaterialCheckBox extends MaterialCheckBox implements ITooltipView { - public TooltipMaterialCheckBox(@NonNull Context context) { - super(context); - } - - public TooltipMaterialCheckBox(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public TooltipMaterialCheckBox(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public void setTooltipLongPressListener(OnTooltipLongPressListener listener) { - if (listener == null) { - setOnLongClickListener(null); - return; - } - // Bridge our interface listener to the standard Android OnLongClickListener - setOnLongClickListener(v -> { - listener.onLongPress(); - // Return true to consume the event, preventing other actions - return true; - }); - } -} \ No newline at end of file + public TooltipMaterialCheckBox(@NonNull Context context) { + super(context); + } + + public TooltipMaterialCheckBox(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public TooltipMaterialCheckBox(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void setTooltipLongPressListener(OnTooltipLongPressListener listener) { + if (listener == null) { + setOnLongClickListener(null); + // setOnLongClickListener sets isLongClickable when it installs a listener but does not + // unset it when the listener is removed, so without this the checkbox goes on + // consuming long presses -- and showing the platform's long-press feedback -- for a + // tooltip it no longer offers. + setLongClickable(false); + return; + } + // Bridge our interface listener to the standard Android OnLongClickListener + setOnLongClickListener(v -> { + listener.onLongPress(); + // Return true to consume the event, preventing other actions + return true; + }); + } +} From d5c67c5fd4a9780bc1cfcebc64257972d0cde95a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 18:23:54 -0700 Subject: [PATCH 065/128] ADFA-5509: clear the build flags before the activity check Two review findings on this PR, both about state outliving the thing it described. cancelRequested was reset after prepareBuild's activity guard. This listener outlives any one activity, so a cancelled build whose onBuildFailed arrived with none attached left the flag set, and the next build to fail inherited it and was drawn as cancelled. The outcome callbacks decided for themselves whether to draw the second half of a marker pair, from the task list they are handed -- which is not the list prepareBuild sees. If those two ever disagreed the chart got a start with no finish, or a finish with no start, which is the one thing a pair exists to avoid. The build that started now decides, through a flag meaning "a start marker was drawn for the build now running", and the outcome follows it. Both flags are cleared before the guard and set only where the marker is actually drawn. Writing it the other way round -- recording the pairing from the task list before knowing whether a marker could be drawn -- would have swapped one asymmetry for its mirror image, a finish with no start, which is what the first attempt did and what the tests caught. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../handlers/EditorBuildEventListener.kt | 33 ++++++++++++++++--- .../EditorBuildEventListenerAnnotationTest.kt | 27 +++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) 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 d388099ee3..c40e68d7b2 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -53,7 +53,19 @@ class EditorBuildEventListener : GradleBuildService.EventListener { * Set when the user asks for the running build to stop, so [onBuildFailed] can tell a cancel * from a real failure. Cleared as each build is prepared. */ - private var cancelRequested = false + @VisibleForTesting + internal var cancelRequested = false + + /** + * Whether the build now running drew a "Build started" marker. + * + * The outcome callbacks used to decide for themselves, from the task list they are handed -- + * a different list from the one prepareBuild sees. If those two ever disagreed the chart got + * a start with no finish, or a finish with no start, which is the one thing a pair of markers + * exists to avoid. The build that started decides, and its outcome follows. + */ + @VisibleForTesting + internal var annotatedBuild = false private var enabled = true private var activityReference: WeakReference = WeakReference(null) @@ -87,14 +99,23 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } override fun prepareBuild(buildInfo: BuildInfo) { - val act = checkActivity("prepareBuild") ?: return - + // Before the activity check, not after: this listener outlives any one activity, so a + // build whose outcome arrived with none attached would otherwise leave both flags set for + // the next build to inherit -- a stale cancel mislabelling a real failure, or a stale + // pairing drawing a finish for a build that never started. cancelRequested = false + annotatedBuild = false + + val act = checkActivity("prepareBuild") ?: return // A project sync runs through the same callbacks with no tasks, so annotating every // prepareBuild put a "Build started" marker on the chart merely for opening a project -- // and blamed the sync's own memory spike on a build the user never ran. + // + // The outcome callbacks are handed their own task list, which is not this one. Recorded + // here so the pair is decided once, by the build that started. if (buildInfo.tasks.isNotEmpty()) { + annotatedBuild = true act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_STARTED) } @@ -130,9 +151,10 @@ class EditorBuildEventListener : GradleBuildService.EventListener { override fun onBuildSuccessful(tasks: List) { val act = checkActivity("onBuildSuccessful") ?: return - if (tasks.isNotEmpty()) { + if (annotatedBuild) { act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FINISHED) } + annotatedBuild = false pluginBuildService?.notifyBuildFinished() @@ -193,7 +215,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { override fun onBuildFailed(tasks: List) { val act = checkActivity("onBuildFailed") ?: return - if (tasks.isNotEmpty()) { + if (annotatedBuild) { // A build the user stopped arrives through this same callback. Marking it as a failure // would report their own deliberate action back to them in the error colour. act.recordBuildAnnotation( @@ -204,6 +226,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { }, ) } + annotatedBuild = false cancelRequested = false analyzeCurrentFile() diff --git a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt index 8eb6db3252..5fe152155e 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt @@ -18,6 +18,8 @@ package com.itsaky.androidide.handlers import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.result.BuildInfo import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.tooling.events.internal.DefaultOperationDescriptor import com.itsaky.androidide.tooling.events.internal.DefaultProgressEvent @@ -71,6 +73,31 @@ class EditorBuildEventListenerAnnotationTest { descriptor = DefaultOperationDescriptor(name = "configure", displayName = "Configure"), ) + @Test + fun `preparing a build clears a stale cancel, even with no activity attached`() { + listener.cancelRequested = true + + // No activity is attached here, so prepareBuild returns early -- which is the point. This + // listener outlives any one activity, and a cancel whose onBuildFailed arrived without one + // would otherwise leave the flag set for the next build to inherit and be mislabelled. + listener.prepareBuild(BuildInfo(BuildId.Unknown, listOf(":app:assembleDebug"))) + + assertThat(listener.cancelRequested).isFalse() + } + + @Test + fun `preparing a build clears a stale pairing`() { + listener.annotatedBuild = true + + // The flag means "a start marker was drawn for the build now running", so a new build + // must not inherit it: the outcome callbacks read it to decide whether to draw the other + // half of the pair, and they are handed a different task list from this one. Cleared + // before the activity check for the same reason as the cancel flag. + listener.prepareBuild(BuildInfo(BuildId.Unknown, listOf(":app:assembleDebug"))) + + assertThat(listener.annotatedBuild).isFalse() + } + @Test fun `a task starting is annotated`() { assertThat(listener.isAnnotated(taskStart())).isTrue() From 2d4453c87772238b9c8ad6357553a9415b152de0 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 18:51:15 -0700 Subject: [PATCH 066/128] 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 1f9c4984fdfaa8e674a8618d471459f0e3313d51 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 19:15:41 -0700 Subject: [PATCH 067/128] ADFA-5527: follow the font scale in the charts, up to a ceiling MPAndroidChart sizes its text in dp, so nothing the charts drew responded to the system font scale: a user who asked for larger text got it everywhere in the IDE except inside these plots, where the text is already the smallest on the screen. Measured before this change, an axis label went 19px to 20px between font scales 1.0 and 2.0 while an ordinary TextView beside it went 36px to 54px. Followed only to 1.5, because a plot is dense by nature and the strip is a fixed 248dp. Option 1 of the four written up on the ticket. Growing the text is not enough on its own, and the first version of this change proved it: the label count stayed put, so the memory page's nine value labels went from 29px apart to 6px. Bigger text, crowded axis -- worse, not better. The count now falls as the text grows, from six at scale 1 to four at the ceiling, as a hint rather than a command so that granularity still has the last word. That matters on the temperature axis, which is pinned to whole degrees. The annotation rows scale too. Eight rows sized for scale-1 text would have overlapped exactly when the labels grew, which is what the staggering exists to prevent. Verified on device, not by eye. At font scale 2.0 the memory page shows five value labels instead of nine, with the smallest gap between them at 28px against 31px at scale 1.0 -- the same readability with visibly larger text. On the temperature and power page, five labels per axis, whole degrees and whole watts, no repeats, smallest gaps 97px and 28px. Not in the metrics carousel stack, deliberately. It touches the shared renderer and the annotation geometry, and five reviewed PRs are waiting on approval; adding it there would invalidate all of them to fix something that predates them. Branched off ADFA-5509 because MetricsChartRenderer does not exist on stage yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsChartRenderer.kt | 74 ++++++++- .../ui/MetricsChartTextScaleTest.kt | 151 ++++++++++++++++++ 2 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.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 ca152daaad..d06591e9b5 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -40,6 +40,7 @@ import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent import kotlin.math.ceil import kotlin.math.floor +import kotlin.math.roundToInt import kotlin.math.roundToLong /** @@ -419,6 +420,7 @@ abstract class MetricsChartRenderer( xAxis.textColor = textColor data.setValueTextColor(textColor) + applyTextScale(this) styleValueAxes(this, textColor) setBackgroundColor(bgColor) setGridBackgroundColor(bgColor) @@ -435,6 +437,37 @@ abstract class MetricsChartRenderer( chart.invalidate() } + /** + * Sizes every piece of text the chart draws, following the system font scale up to a ceiling. + * + * MPAndroidChart sizes its text in dp, so nothing it draws responded to the font scale at all: + * a user who asked for larger text got it everywhere in the IDE except inside these plots, + * where the text is already the smallest on the screen (ADFA-5527). + * + * Followed only to [MAX_TEXT_SCALE], because a plot is dense by nature and the strip is a + * fixed [R.dimen.editor_mem_usage_view_height]. At the full 2.0 the axis labels collide with + * each other and the eight staggered annotation rows overlap, so honouring the scale + * literally would make the chart less readable rather than more. A ceiling gives most of the + * benefit and keeps the plot legible at the extreme. + */ + @UiThread + private fun applyTextScale(chart: SafeLineChart) { + val scale = textScaleFor(chart.context) + chart.legend.textSize = BASE_TEXT_SIZE_DP * scale + chart.xAxis.textSize = BASE_TEXT_SIZE_DP * scale + chart.axisLeft.textSize = BASE_TEXT_SIZE_DP * scale + chart.axisRight.textSize = BASE_TEXT_SIZE_DP * scale + chart.data?.setValueTextSize(BASE_VALUE_TEXT_SIZE_DP * scale) + + // Bigger text needs fewer labels. Growing the text alone left the count untouched, so the + // memory page's nine value labels went from 29px apart to 6px -- crowded enough that the + // change made the axis worse rather than better. The count is a hint: granularity still + // has the last word, which is what keeps the temperature axis on whole degrees. + val labels = (BASE_LABEL_COUNT / scale).roundToInt().coerceAtLeast(MIN_LABEL_COUNT) + chart.axisLeft.setLabelCount(labels, false) + chart.axisRight.setLabelCount(labels, false) + } + /** * Colours the value axes' labels. Called from [setData], not [configure], because the styling * here is re-applied on every redraw and would otherwise overwrite whatever a subclass had set @@ -501,7 +534,7 @@ abstract class MetricsChartRenderer( labelPosition = LimitLine.LimitLabelPosition.RIGHT_BOTTOM // Rows are counted up from the bottom of the plot, and the offset is in dp: // LimitLine converts it on the way in. - yOffset = ANNOTATION_LABEL_ROW_HEIGHT_DP * slotFor(annotation.sequence) + yOffset = annotationRowHeightFor(chart.context) * slotFor(annotation.sequence) }, ) } @@ -590,7 +623,8 @@ abstract class MetricsChartRenderer( chart.invalidate() } - private companion object { + @VisibleForTesting + internal companion object { /** * Samples shown at once. Thousands are retained; a minute is what fits legibly in the strip. */ @@ -609,7 +643,41 @@ abstract class MetricsChartRenderer( */ const val ANNOTATION_LABEL_SLOTS = 8 - /** One row, in dp. The label text is 10dp, so this leaves a little air between rows. */ + /** + * One row, in dp, at a font scale of 1. The label text is [BASE_TEXT_SIZE_DP], so this + * leaves a little air between rows; it is scaled with the text by [annotationRowHeightFor], + * or the rows would overlap exactly when the labels grew (ADFA-5527). + */ const val ANNOTATION_LABEL_ROW_HEIGHT_DP = 12f + + /** MPAndroidChart's own default for axis and legend text, which this matches at scale 1. */ + const val BASE_TEXT_SIZE_DP = 10f + + /** MPAndroidChart's own default for value labels. */ + const val BASE_VALUE_TEXT_SIZE_DP = 9f + + /** + * The most the chart will grow its text by, whatever the system font scale. + * + * 1.5 rather than the platform's maximum of 2.0: see [applyTextScale]. Eight annotation + * rows at 1.5 still fit the plot, where at 2.0 they do not. + */ + const val MAX_TEXT_SCALE = 1.5f + + /** Value-axis labels at a font scale of 1, which is MPAndroidChart's own default. */ + const val BASE_LABEL_COUNT = 6 + + /** Never fewer than this, or the axis stops conveying a scale at all. */ + const val MIN_LABEL_COUNT = 3 + + /** The font scale the charts follow: the system's, held to [MAX_TEXT_SCALE]. */ + @JvmStatic + fun textScaleFor(context: Context): Float = + context.resources.configuration.fontScale + .coerceIn(1f, MAX_TEXT_SCALE) + + /** One annotation row, scaled with the label text it has to leave room for. */ + @JvmStatic + fun annotationRowHeightFor(context: Context): Float = ANNOTATION_LABEL_ROW_HEIGHT_DP * textScaleFor(context) } } diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt new file mode 100644 index 0000000000..6d3680cbdc --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.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 android.content.Context +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 +import org.robolectric.annotation.Config + +/** + * The chart text-scale policy of ADFA-5527: follow the system font scale, up to a ceiling. + * + * MPAndroidChart sizes its text in dp, so before this the charts ignored the font scale entirely + * -- a user who asked for larger text got it everywhere in the IDE except inside these plots. The + * scale is followed only to [MetricsChartRenderer.MAX_TEXT_SCALE], because the strip is a fixed + * height and at the platform's full 2.0 the axis labels collide and the eight staggered annotation + * rows overlap. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartTextScaleTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private fun chart(): SafeLineChart { + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + }, + ).attach(chart) + return chart + } + + private val base get() = MetricsChartRenderer.BASE_TEXT_SIZE_DP + + @Test + fun `at the default scale the text is the size it always was`() { + val chart = chart() + + // Matching MPAndroidChart's own default, so nothing moves for a user who has not changed + // the setting. + assertThat(chart.xAxis.textSize).isWithin(TOLERANCE).of(base) + assertThat(chart.legend.textSize).isWithin(TOLERANCE).of(base) + assertThat(chart.axisRight.textSize).isWithin(TOLERANCE).of(base) + } + + @Test + @Config(fontScale = 1.3f) + fun `a modest font scale is followed exactly`() { + val chart = chart() + + assertThat(chart.xAxis.textSize).isWithin(TOLERANCE).of(base * 1.3f) + assertThat(chart.legend.textSize).isWithin(TOLERANCE).of(base * 1.3f) + } + + @Test + @Config(fontScale = 2.0f) + fun `the largest font scale is held to the ceiling`() { + val chart = chart() + + // Not base * 2: eight annotation rows at that size do not fit the plot, and the axis + // labels collide with each other. + assertThat(chart.xAxis.textSize) + .isWithin(TOLERANCE) + .of(base * MetricsChartRenderer.MAX_TEXT_SCALE) + } + + @Test + @Config(fontScale = 0.85f) + fun `a font scale below one does not shrink the chart further`() { + val chart = chart() + + // The chart's text is already the smallest on the screen; following a reduction would + // make the labels unreadable rather than merely small. + assertThat(chart.xAxis.textSize).isWithin(TOLERANCE).of(base) + } + + @Test + @Config(fontScale = 2.0f) + fun `the annotation rows a chart actually draws grow with the labels`() { + // Asserted on the drawn marker, not on the helper: an earlier version of this test called + // annotationRowHeightFor directly, so it passed even with the renderer still using the + // unscaled constant at the call site. + var now = 1_000_000L + val store = MetricsAnnotationStore(nowMillis = { now }) + store.record("first") + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("second") + + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + }, + annotations = store, + sampleInterval = { 1_000L }, + ).attach(chart) + + // Rows sized for scale-1 text would overlap exactly when the text grew, which is what the + // staggering exists to prevent. Two consecutive markers sit one row apart. + val offsets = + chart.xAxis.limitLines + .map { it.yOffset } + .sorted() + assertThat(offsets).hasSize(2) + val expected = + MetricsChartRenderer.ANNOTATION_LABEL_ROW_HEIGHT_DP * MetricsChartRenderer.MAX_TEXT_SCALE + assertThat(offsets[1] - offsets[0]).isWithin(TOLERANCE).of(expected) + } + + @Test + @Config(fontScale = 2.0f) + fun `eight annotation rows still fit the plot at the ceiling`() { + // The reason the ceiling is 1.5. The strip is a fixed height, and this is the constraint + // that sets the limit -- if it ever fails, the ceiling is too high or the strip too short. + val rows = MetricsChartRenderer.ANNOTATION_LABEL_SLOTS + val used = rows * MetricsChartRenderer.annotationRowHeightFor(context) + + assertThat(used).isLessThan(PLOT_HEIGHT_DP) + } + + private companion object { + const val SAMPLES = 60 + const val TOLERANCE = 0.01f + + /** + * The plot's share of editor_mem_usage_view_height (248dp), less the title row, the + * legend and the x axis. Deliberately conservative. + */ + const val PLOT_HEIGHT_DP = 150f + } +} From 38e68786d3922909493cbc1fb95c31628aa0a8a2 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 05:40:00 -0700 Subject: [PATCH 068/128] ADFA-5527: measure the plot the eight rows have to fit in The test that justifies the 1.5 ceiling compared eight rows against a hand-picked 150dp, with a comment admitting the number was a guess. A guess pins nothing: no layout change could move it, so the test could not tell a shorter strip or a taller title row from a safe one. It now measures the whole way down -- the strip at the dimen the layout uses, the pager after a real measure and layout at 360dp wide, and the plot as the content rect a real chart page reports once a renderer has put its legend and axis on it. Nothing is allowed for by hand. Raising MAX_TEXT_SCALE to 2.0 now fails it, 192dp of rows against a 169dp plot, which is the claim the test makes about why the ceiling is where it is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsChartTextScaleTest.kt | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt index 6d3680cbdc..29ace075c5 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt @@ -18,8 +18,13 @@ package com.itsaky.androidide.ui import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.view.ContextThemeWrapper 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.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher import org.junit.Test @@ -132,20 +137,58 @@ class MetricsChartTextScaleTest { fun `eight annotation rows still fit the plot at the ceiling`() { // The reason the ceiling is 1.5. The strip is a fixed height, and this is the constraint // that sets the limit -- if it ever fails, the ceiling is too high or the strip too short. + // + // Measured, not guessed. This used to compare against a hand-picked 150dp with a comment + // admitting it was conservative, which pinned the ceiling against a number no layout change + // could ever move. The strip is laid out at the ceiling font scale and the pager reports + // what the title row -- itself grown by that scale -- left it. val rows = MetricsChartRenderer.ANNOTATION_LABEL_SLOTS val used = rows * MetricsChartRenderer.annotationRowHeightFor(context) - assertThat(used).isLessThan(PLOT_HEIGHT_DP) + assertThat(used).isLessThan(plotHeightDp()) + } + + /** + * The plot area a chart page actually gets, in dp, with the system font scale at its largest. + * + * Measured the whole way down, with nothing allowed for by hand: the strip's height is the + * dimen the layout uses, the pager's share of it comes from a real measure and layout of the + * real strip, and the plot's share of *that* is the content rect a real chart page reports + * after a real renderer has put its legend and axis on it. So shortening the strip fails this, + * and so does anything above or inside the plot growing with the font scale. + */ + private fun plotHeightDp(): Float { + val themed = ContextThemeWrapper(context, R.style.Theme_AndroidIDE) + val strip = LayoutMemUsageBinding.inflate(LayoutInflater.from(themed)) + val metrics = context.resources.displayMetrics + val stripHeightPx = context.resources.getDimensionPixelSize(R.dimen.editor_mem_usage_view_height) + val widthPx = (STRIP_WIDTH_DP * metrics.density).toInt() + + strip.root.measure( + View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(stripHeightPx, View.MeasureSpec.EXACTLY), + ) + strip.root.layout(0, 0, widthPx, stripHeightPx) + + val page = + LayoutInflater + .from(themed) + .inflate(R.layout.item_metrics_chart, strip.metricsPager, false) as SafeLineChart + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + }, + ).attach(page) + page.layOutAndDraw(width = strip.metricsPager.width, height = strip.metricsPager.height) + + return page.viewPortHandler.contentHeight() / metrics.density } private companion object { const val SAMPLES = 60 const val TOLERANCE = 0.01f - /** - * The plot's share of editor_mem_usage_view_height (248dp), less the title row, the - * legend and the x axis. Deliberately conservative. - */ - const val PLOT_HEIGHT_DP = 150f + /** A narrow phone, so the title row wraps here if it is ever going to. */ + const val STRIP_WIDTH_DP = 360f } } From bbf5c35af0c12b5982fafdc68220137705b6c8c5 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 00:21:37 -0700 Subject: [PATCH 069/128] ADFA-5531: record when each sample was taken The watchers kept values and no times. The chart does not need them -- it reads a sample's age from its position in the buffer -- but the exported metrics file states a time per row, and inferring one from an index would be wrong three ways: the newest sample was taken up to an interval before the export, the sampling loop delays *after* doing its work so its true period runs long, and a series can stop and restart without its buffer being cleared. Not a small error, either. Measured on a Pixel 6 Pro at a nominal 1s rate, the real gap between samples averaged 1.108s and reached 1.758s. Over a full ten-thousand-sample buffer that is eighteen minutes of skew at the old end of the file. One ring buffer per watcher, appended where the values are appended and cleared alongside them, so a zero at an index means nothing was ever sampled there -- which is what will tell an empty cell apart from a measured zero. A per-process "watched since" goes with it: a process can start being watched long after the others, and the Gradle daemon will (ADFA-5514), so its buffer reaches back to the start of the session however late it appeared. MetricsAnnotationStore gains allAnnotations(), since the file carries the whole retained history and recentAnnotations() has no argument meaning "all of it" that does not overflow its cutoff arithmetic. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/utils/MemoryUsageWatcher.kt | 42 +++++++++++++++++++ .../utils/MetricsAnnotationStore.kt | 10 +++++ .../androidide/utils/NetworkUsageWatcher.kt | 30 +++++++++++++ .../androidide/utils/PowerUsageWatcher.kt | 29 +++++++++++++ 4 files changed, 111 insertions(+) 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 229a103e27..498b8451de 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -58,6 +58,7 @@ class MemoryUsageWatcher updateInterval: Long = DEFAULT_UPDATE_INTERVAL, private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("MemoryUsageWatcher"), private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, + private val nowMillis: () -> Long = System::currentTimeMillis, ) { /** * Milliseconds between samples. Changing it clears the history: the chart reads a sample's @@ -84,6 +85,21 @@ class MemoryUsageWatcher private var samplingJob: Job? = null private val memoryUsage = ConcurrentHashMap() + /** + * When each sample was taken, in the same order and at the same indices as the values. + * + * Recorded rather than reconstructed. The chart infers a sample's age from its position, + * which is close enough for placing a marker on a plot, but the exported metrics file states + * a time per row (ADFA-5531) and inference would be wrong three ways: the newest sample was + * taken up to an interval before the export, the loop delays *after* doing its work so the + * true period drifts past the nominal one, and sampling can stop and restart without the + * buffer being cleared. + * + * A zero means no sample was ever recorded at that index, which is what tells a blank cell + * apart from a measured zero. + */ + private val sampleTimes = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + /** * Guards the per-process ring buffers, matching [NetworkUsageWatcher] and * [PowerUsageWatcher]. The sampler appends to them; [clearHistory] wipes them from whatever @@ -196,6 +212,13 @@ class MemoryUsageWatcher return } + // Once per sample, not once per process: every process is read in this one pass, so + // they share a time, and that is what makes a row of the exported file a single moment. + synchronized(historyLock) { + sampleTimes[0] = nowMillis() + sampleTimes.shift(1) + } + val pids = memoryUsage.keys.toIntArray() pids.forEach { pid -> @@ -263,6 +286,11 @@ class MemoryUsageWatcher pid, pname, MutableShiftedLongArray(MAX_USAGE_ENTRIES), + // A process can start being watched long after the others -- the Gradle daemon + // appears when a build does -- and its buffer is zero-filled back to the start + // of the session. Without this, the exported file could not tell those zeros + // from a process that really was using no memory (ADFA-5531). + watchedSinceMillis = nowMillis(), ) } @@ -277,9 +305,21 @@ class MemoryUsageWatcher // so this is reachable, not theoretical. synchronized(historyLock) { memoryUsage.values.forEach { it._history.clear() } + sampleTimes.clear() } } + /** + * When each retained sample was taken, oldest first, as milliseconds since the epoch. + * + * A zero at an index means nothing was ever sampled there -- the buffer is fixed-length and + * starts, and is cleared, full of them. A copy, for the same reason the values are copied. + */ + fun sampleTimes(): LongArray = + synchronized(historyLock) { + sampleTimes.toLongArray() + } + /** * Returns the memory usage of all the registered processes. */ @@ -377,6 +417,8 @@ class MemoryUsageWatcher val pid: Int, val pname: String, internal val _history: MutableShiftedLongArray, + /** When this process started being watched, as milliseconds since the epoch. */ + val watchedSinceMillis: Long = 0L, ) { internal val memInfo: MemoryInfo = MemoryInfo() 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 fe70af6b2a..0aba44c264 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -181,6 +181,16 @@ class MetricsAnnotationStore( return annotations.filter { it.atMillis >= cutoff } } + /** + * Every annotation the store holds, oldest first. + * + * The exported metrics file carries the whole retained history rather than a window of it, so + * it cannot go through [recentAnnotations] -- there is no "within" that means "all of it" + * without the cutoff arithmetic overflowing (ADFA-5531). + */ + @Synchronized + fun allAnnotations(): List = annotations.toList() + @Synchronized fun clear() { annotations.clear() 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 784f13d286..95aa4e1fb3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -68,6 +68,7 @@ class NetworkUsageWatcher // touching Dispatchers.Main at construction throws in a plain JVM test, and most of these // tests never start the sampling loop at all. private val mainDispatcher: CoroutineContext? = null, + private val nowMillis: () -> Long = System::currentTimeMillis, ) { private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher) private val watching = AtomicBoolean(false) @@ -103,6 +104,21 @@ class NetworkUsageWatcher /** Guards the two ring buffers: the sampler writes them, the UI thread snapshots them. */ private val historyLock = Any() + /** + * When each sample was taken, in the same order and at the same indices as the values. + * + * Recorded rather than reconstructed. The chart infers a sample's age from its position, + * which is close enough for placing a marker on a plot, but the exported metrics file states + * a time per row (ADFA-5531) and inference would be wrong three ways: the newest sample was + * taken up to an interval before the export, the loop delays *after* doing its work so the + * true period drifts past the nominal one, and sampling can stop and restart without the + * buffer being cleared. + * + * A zero means no sample was ever recorded at that index, which is what tells a blank cell + * apart from a measured zero. + */ + private val sampleTimes = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + private val received = MutableShiftedLongArray(MAX_USAGE_ENTRIES) private val transmitted = MutableShiftedLongArray(MAX_USAGE_ENTRIES) @@ -143,6 +159,17 @@ class NetworkUsageWatcher NetworkUsage(received.toLongArray(), transmitted.toLongArray()) } + /** + * When each retained sample was taken, oldest first, as milliseconds since the epoch. + * + * A zero at an index means nothing was ever sampled there -- the buffer is fixed-length and + * starts, and is cleared, full of them. A copy, for the same reason the values are copied. + */ + fun sampleTimes(): LongArray = + synchronized(historyLock) { + sampleTimes.toLongArray() + } + /** * 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. @@ -151,6 +178,7 @@ class NetworkUsageWatcher synchronized(historyLock) { received.clear() transmitted.clear() + sampleTimes.clear() lastRx = null lastTx = null } @@ -265,6 +293,8 @@ class NetworkUsageWatcher // and the second block then put the pre-reset values straight back, so the next // sample counted traffic from before the change. synchronized(historyLock) { + sampleTimes[0] = nowMillis() + sampleTimes.shift(1) record(received, previous = lastRx, current = rx) record(transmitted, previous = lastTx, current = tx) lastRx = rx diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 4753e9e730..212b16511b 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -58,6 +58,7 @@ class PowerUsageWatcher private val source: PowerSource, private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("PowerUsageWatcher"), private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, + private val nowMillis: () -> Long = System::currentTimeMillis, ) { private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher) private val watching = AtomicBoolean(false) @@ -75,6 +76,21 @@ class PowerUsageWatcher /** Guards the ring buffers: the sampler writes them, the UI thread snapshots them. */ private val historyLock = Any() + /** + * When each sample was taken, in the same order and at the same indices as the values. + * + * Recorded rather than reconstructed. The chart infers a sample's age from its position, + * which is close enough for placing a marker on a plot, but the exported metrics file states + * a time per row (ADFA-5531) and inference would be wrong three ways: the newest sample was + * taken up to an interval before the export, the loop delays *after* doing its work so the + * true period drifts past the nominal one, and sampling can stop and restart without the + * buffer being cleared. + * + * A zero means no sample was ever recorded at that index, which is what tells a blank cell + * apart from a measured zero. + */ + private val sampleTimes = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + private val temperature = MutableShiftedLongArray(MAX_USAGE_ENTRIES) private val power = MutableShiftedLongArray(MAX_USAGE_ENTRIES) @@ -128,8 +144,20 @@ class PowerUsageWatcher PowerUsage(temperature.toLongArray(), power.toLongArray(), thermal.toLongArray()) } + /** + * When each retained sample was taken, oldest first, as milliseconds since the epoch. + * + * A zero at an index means nothing was ever sampled there -- the buffer is fixed-length and + * starts, and is cleared, full of them. A copy, for the same reason the values are copied. + */ + fun sampleTimes(): LongArray = + synchronized(historyLock) { + sampleTimes.toLongArray() + } + fun clearHistory() { synchronized(historyLock) { + sampleTimes.clear() temperature.clear() power.clear() thermal.clear() @@ -195,6 +223,7 @@ class PowerUsageWatcher latestBattery = reading.battery synchronized(historyLock) { + append(sampleTimes, nowMillis()) append(temperature, reading.temperatureMilliCelsius) append(power, reading.powerMicroWatts) append(thermal, reading.thermalStatus.toLong()) From 73930561c99d56a5b04eccda5c5b3f30ba28db7e Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 00:21:58 -0700 Subject: [PATCH 070/128] ADFA-5531: define the canonical metrics file, and name every export the same way The format section of the ticket is the single definition of this file, and ADFA-5494, ADFA-5526 and ADFA-5534 each produce or consume it -- they differ only in compressing it. So it lives in one place, with no Android types, and the whole format is tested without a device. A row is a sampling tick. Its stated time is the memory watcher's, recorded when it sampled rather than reconstructed, and the network and power values on that row are the samples at the same index, taken within one interval of it. The three watchers share an interval, are started together and are cleared together, which is what makes the index mean the same thing in all three; they do not read their sources at the same instant, and the file says so rather than implying otherwise. Decisions the ticket left open: - The row timestamp is ISO 8601 with the device's offset, which round-trips exactly for ADFA-5494 and stays legible to someone triaging a report from another timezone. Written with an explicit three-digit fraction rather than ISO_OFFSET_DATE_TIME, which drops trailing zeros and gives a column of varying width -- ".38" on one row and ".123" on the next. - The filename keeps its own format, deliberately different: rule (c) is built from what a filesystem allows and what sorts lexicographically. - An export always produces a file. With nothing sampled it is a header and no rows, which a consumer can handle more easily than a file that may or may not exist -- and the empty case is not exotic, since changing the sampling rate clears every buffer. - The memory columns are fixed at the three the chart can plot rather than taken from what is being watched, because that set changes during a session and a header that followed it would describe a different file each time. Item (2) of the ticket is the same rule applied to the image: the PNG used to lead with the chart's title, which sorted a pair exported at the same moment apart. Both writers now take an injectable clock, which is also what lets a test write distinguishable files without racing the millisecond the name is built from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../com/itsaky/androidide/utils/MetricsCsv.kt | 251 +++++++++++++++ .../itsaky/androidide/utils/MetricsCsvFile.kt | 79 +++++ .../androidide/utils/MetricsFileName.kt | 50 +++ .../androidide/utils/MetricsSnapshot.kt | 31 +- .../itsaky/androidide/utils/MetricsCsvTest.kt | 299 ++++++++++++++++++ .../androidide/utils/MetricsFileNameTest.kt | 65 ++++ .../androidide/utils/MetricsSnapshotTest.kt | 55 ++-- 7 files changed, 778 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsFileName.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MetricsFileNameTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt new file mode 100644 index 0000000000..c52891e086 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt @@ -0,0 +1,251 @@ +/* + * 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 java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.abs + +/** + * The canonical metrics file: everything the carousel has sampled, as CSV (ADFA-5531). + * + * One definition, because the file has several producers and consumers -- the export button here, + * ADFA-5494's restore across process death, and the copies ADFA-5526 and ADFA-5534 attach to crash + * reports and to feedback. Those differ from this only in compressing it. + * + * A row is a sampling tick, and its columns come from three watchers that each keep their own ring + * buffer and their own coroutine. They are started together and share one interval, and every one + * of them is cleared when that interval changes, so the tick at index *i* is the same tick in all + * three -- but they do not read their sources at the same instant. The row's stated time is the + * memory watcher's, recorded when it sampled; the network and power values on that row were taken + * within one interval of it. Nothing here reconstructs a time from an index. + * + * Formatting only, with no Android types, so the whole format can be tested without a device. + */ +object MetricsCsv { + /** Media type for the written file, for the sharing intent. */ + const val MIME_TYPE = "text/csv" + + /** + * A sample time of zero means no sample was taken at that index: the ring buffers are + * fixed-length and start, and are cleared, full of zeros. + */ + const val NO_SAMPLE = 0L + + /** + * A row's time, ISO 8601 with the offset the device was on. + * + * Deliberately not the filename's format, which is built from what a filesystem allows and what + * sorts lexicographically. This one has to round-trip exactly for ADFA-5494 and be read by a + * person triaging a report from another timezone, which is what the offset is for. + * + * Spelled out rather than [DateTimeFormatter.ISO_OFFSET_DATE_TIME], which drops trailing zeros + * from the fraction and so writes a column of varying width -- ".38" for one row and ".123" for + * the next. Both parse, but a fixed three digits matches the millisecond the value is recorded + * at and the three the filename carries. + */ + private val TIMESTAMP_FORMAT: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.ROOT) + + /** + * The memory series, in column order. + * + * Fixed rather than taken from whatever is being watched at export time. The set changes during + * a session -- the Gradle daemon appears when a build starts and goes when it exits (ADFA-5514) + * -- and a header that depended on it would describe a different file each time. A process that + * is not being watched leaves its column empty. + */ + val MEMORY_COLUMNS = listOf("IDE", "Gradle Tooling", "Gradle Daemon") + + @JvmStatic + val HEADER: List = + listOf("timestamp") + + MEMORY_COLUMNS.map { "${it.lowercase().replace(' ', '_')}_pss_bytes" } + + listOf( + "net_rx_bytes", + "net_tx_bytes", + "battery_temp_millicelsius", + "power_microwatts", + "thermal_status", + "annotation", + "annotation_kind", + ) + + /** + * One series of samples and the times they were recorded at. + * + * @property times When each value was sampled, oldest first and parallel to [values]. A + * [NO_SAMPLE] entry marks an index nothing was ever recorded at, which is what tells an empty + * cell apart from a measured zero. + * @property values The samples themselves. + * @property since When this series started being recorded. Samples timed before it belong to + * the buffer's zero-filled past rather than to this series -- the Gradle daemon's buffer reaches + * back to the start of the session however late in it the daemon appeared. + */ + class Series( + private val times: LongArray, + private val values: LongArray, + private val since: Long = 0L, + ) { + /** The value at index [i], or `null` if this series has nothing to say there. */ + fun at(i: Int): Long? { + if (i < 0 || i >= times.size || i >= values.size) { + return null + } + val time = times[i] + return if (time == NO_SAMPLE || time < since) null else values[i] + } + + companion object { + val EMPTY = Series(LongArray(0), LongArray(0)) + } + } + + /** + * @property atMillis When the event happened. + * @property label Its text, already resolved. + * @property kind The sort of event, as the annotation store names it. + */ + data class Marker( + val atMillis: Long, + val label: String, + val kind: String, + ) + + /** + * Everything one export writes. + * + * @property rowTimes The memory watcher's sample times, oldest first. They are the rows, because + * memory is the one series always being recorded. + */ + class Snapshot( + val rowTimes: LongArray, + val memory: Map, + val networkReceived: Series = Series.EMPTY, + val networkTransmitted: Series = Series.EMPTY, + val temperature: Series = Series.EMPTY, + val power: Series = Series.EMPTY, + val thermal: Series = Series.EMPTY, + val annotations: List = emptyList(), + ) + + /** + * Writes [snapshot] to [out], timestamps in [zone]. + * + * The header is always written, even when nothing has been sampled. A file that exists and + * reports no rows is easier for a consumer to handle than one that may or may not be there, and + * the empty case is not exotic: changing the sampling rate clears every buffer. + */ + fun write( + snapshot: Snapshot, + zone: ZoneId, + out: Appendable, + ) { + out.append(HEADER.joinToString(",", transform = ::quote)).append('\n') + + val markerRows = markerRows(snapshot) + val row = StringBuilder() + snapshot.rowTimes.forEachIndexed { i, at -> + if (at == NO_SAMPLE) { + return@forEachIndexed + } + + row.setLength(0) + row.append(quote(formatTime(at, zone))) + MEMORY_COLUMNS.forEach { name -> row.append(',').append(number(snapshot.memory[name]?.at(i))) } + row.append(',').append(number(snapshot.networkReceived.at(i))) + row.append(',').append(number(snapshot.networkTransmitted.at(i))) + row.append(',').append(number(snapshot.temperature.at(i))) + row.append(',').append(number(snapshot.power.at(i))) + row.append(',').append(number(snapshot.thermal.at(i))) + val marker = markerRows[i] + row.append(',').append(marker?.let { quote(it.label) } ?: "") + row.append(',').append(marker?.let { quote(it.kind) } ?: "") + out.append(row).append('\n') + } + } + + /** + * An annotation's time, moved onto the clock the samples are stamped with. + * + * [MetricsAnnotationStore] records on [android.os.SystemClock.elapsedRealtime], which is + * monotonic and immune to the wall clock being set, and is what the chart wants: it only ever + * asks how long ago something happened. A file has to say *when*, so the samples carry epoch + * milliseconds, and the two cannot be compared without this. + * + * Mixing them is not a small error. A monotonic time is a few hours since boot and an epoch time + * is decades, so every row looks about equally far from the marker and the nearest-row search + * lands on whichever row has the smallest number -- the oldest one in the buffer, every time. + * + * @param monotonicAtMillis The time the store recorded. + * @param nowEpochMillis Now, on the samples' clock. + * @param nowMonotonicMillis Now, on the store's clock. Read as close together as possible. + */ + fun epochFor( + monotonicAtMillis: Long, + nowEpochMillis: Long, + nowMonotonicMillis: Long, + ): Long = monotonicAtMillis + (nowEpochMillis - nowMonotonicMillis) + + /** [atMillis] as ISO 8601 in [zone]. */ + fun formatTime( + atMillis: Long, + zone: ZoneId, + ): String = TIMESTAMP_FORMAT.format(Instant.ofEpochMilli(atMillis).atZone(zone)) + + /** + * The row each marker belongs on, resolved once for the whole file. + * + * A marker goes on the row whose sample is nearest it in time. An annotation is recorded when + * something happened, not when a sample was taken, so requiring an exact match would drop + * almost all of them; and doing this per row rather than once would walk every marker against + * every row, which at ten thousand of each is not a cost worth paying for a button. + * + * Where two markers land on one row the earlier wins, and the later is dropped rather than + * silently overwriting it -- the file has one annotation column per row by definition. + */ + private fun markerRows(snapshot: Snapshot): Map { + if (snapshot.annotations.isEmpty()) { + return emptyMap() + } + + val sampled = snapshot.rowTimes.withIndex().filter { it.value != NO_SAMPLE } + if (sampled.isEmpty()) { + return emptyMap() + } + + val rows = mutableMapOf() + snapshot.annotations.sortedBy { it.atMillis }.forEach { marker -> + val nearest = sampled.minByOrNull { abs(it.value - marker.atMillis) } ?: return@forEach + rows.putIfAbsent(nearest.index, marker) + } + return rows + } + + private fun number(value: Long?): String = value?.toString() ?: "" + + /** + * A CSV string cell. + * + * Quoted per the format's rule (b), with any quote inside it doubled -- a task name is text the + * IDE was given, and nothing guarantees it has no quotes in it. + */ + private fun quote(value: String): String = "\"" + value.replace("\"", "\"\"") + "\"" +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt new file mode 100644 index 0000000000..81310f3182 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt @@ -0,0 +1,79 @@ +/* + * 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 androidx.annotation.VisibleForTesting +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException +import java.time.ZoneId + +/** + * Writes a [MetricsCsv.Snapshot] to a file the IDE can share (ADFA-5531). + * + * The same scratch-directory arrangement as [MetricsSnapshot], and for the same reason: exports go + * under the cache so the platform can reclaim them, and the sharing intent grants the recipient a + * read on the file before that matters. + */ +object MetricsCsvFile { + private val log = LoggerFactory.getLogger(MetricsCsvFile::class.java) + + private const val DIRECTORY = "metrics-exports" + + /** + * How many exports to keep. + * + * A share hands the recipient a URI and returns long before the recipient reads it, so the + * previous file cannot be deleted on the next export. Fewer than the images are kept: a full + * buffer is around a megabyte of text, against a few hundred kilobytes for a PNG. + */ + @VisibleForTesting + internal const val KEEP_RECENT = 3 + + /** + * Writes [snapshot] and returns the file, or `null` if it could not be written. + */ + fun write( + context: Context, + snapshot: MetricsCsv.Snapshot, + nowMillis: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + ): File? { + val directory = File(context.cacheDir, DIRECTORY) + return try { + if (!directory.exists() && !directory.mkdirs()) { + log.error("Could not create the metrics export directory at {}", directory) + return null + } + + val file = File(directory, MetricsFileName.forTime(nowMillis, "csv", zone)) + // Buffered and streamed rather than built into a string: a full buffer is ten thousand + // rows, and holding the whole file in memory to write it is a megabyte of char array + // the export does not need. + file.bufferedWriter().use { writer -> + MetricsCsv.write(snapshot, zone, writer) + } + MetricsSnapshot.pruneTo(directory, KEEP_RECENT, file) + file + } catch (io: IOException) { + log.error("Could not write the metrics export", io) + null + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsFileName.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsFileName.kt new file mode 100644 index 0000000000..97a13b3419 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsFileName.kt @@ -0,0 +1,50 @@ +/* + * 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 java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * What every exported metrics file is called (ADFA-5531). + * + * One rule for the CSV and the chart image alike, differing only in extension, so a pair exported + * together sorts together and a consumer can tell when a file was written without opening it. + * + * Underscores and no offset, which is what makes it a filename rather than a timestamp: it has to + * survive every filesystem the IDE can write to and sort lexicographically in a directory listing. + * The times *inside* the file are ISO 8601 -- see [MetricsCsv]. + */ +object MetricsFileName { + /** `YYYY_MM_DD_HH_MM_SS_SSS`, as the format section of ADFA-5531 specifies it. */ + private val PATTERN: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ss_SSS", Locale.ROOT) + + /** + * The name for a file written at [atMillis], with [extension] and no leading dot. + * + * Local time, because this is the name a person reads in a share sheet or a file manager. + */ + fun forTime( + atMillis: Long, + extension: String, + zone: ZoneId = ZoneId.systemDefault(), + ): String = "${PATTERN.format(Instant.ofEpochMilli(atMillis).atZone(zone))}.$extension" +} 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 8f1f07d0b5..7159482843 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt @@ -23,9 +23,6 @@ import androidx.annotation.VisibleForTesting 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). @@ -47,13 +44,16 @@ object MetricsSnapshot { */ @VisibleForTesting internal const val KEEP_RECENT = 5 - 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. + * Writes [bitmap] as a PNG, named by [MetricsFileName] like every other exported metrics file. + * + * The name used to lead with the chart's title. ADFA-5531 made one naming rule for the image and + * the CSV so that a pair exported together sorts together, and a title in front of the timestamp + * would have sorted them apart. * * 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 @@ -66,7 +66,7 @@ object MetricsSnapshot { fun write( context: Context, bitmap: Bitmap, - label: String, + nowMillis: Long = System.currentTimeMillis(), ): File? { val directory = File(context.cacheDir, DIRECTORY) return try { @@ -75,7 +75,7 @@ object MetricsSnapshot { return null } - val file = File(directory, "${fileNameFor(label)}.png") + val file = File(directory, MetricsFileName.forTime(nowMillis, "png")) file.outputStream().use { output -> if (!bitmap.compress(Bitmap.CompressFormat.PNG, QUALITY, output)) { log.error("Could not encode the chart snapshot") @@ -97,7 +97,7 @@ object MetricsSnapshot { * trusted to sort newest: two exports in the same second share a timestamp, and the filename * carries only whole seconds. */ - private fun pruneTo( + internal fun pruneTo( directory: File, limit: Int, newest: File, @@ -112,19 +112,4 @@ object MetricsSnapshot { } } } - - /** - * 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/MetricsCsvTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt new file mode 100644 index 0000000000..a546d023cd --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt @@ -0,0 +1,299 @@ +/* + * 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 +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.time.ZoneId + +/** + * The canonical metrics file format (ADFA-5531). + * + * Pinned closely because it is not this ticket's file alone: ADFA-5494 reads it back to restore the + * chart history, and ADFA-5526 and ADFA-5534 attach copies to crash reports and to feedback. A + * column that quietly changes shape breaks a consumer that is not in front of you. + */ +@RunWith(JUnit4::class) +class MetricsCsvTest { + private val zone: ZoneId = ZoneId.of("America/Los_Angeles") + + private fun render(snapshot: MetricsCsv.Snapshot): List = + StringBuilder() + .also { MetricsCsv.write(snapshot, zone, it) } + .toString() + .trimEnd('\n') + .split('\n') + + private fun snapshot( + rowTimes: LongArray = longArrayOf(T0, T0 + 1_000L), + memory: Map = emptyMap(), + networkReceived: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + networkTransmitted: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + temperature: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + power: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + thermal: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + annotations: List = emptyList(), + ) = MetricsCsv.Snapshot( + rowTimes = rowTimes, + memory = memory, + networkReceived = networkReceived, + networkTransmitted = networkTransmitted, + temperature = temperature, + power = power, + thermal = thermal, + annotations = annotations, + ) + + private fun series( + times: LongArray, + values: LongArray, + since: Long = 0L, + ) = MetricsCsv.Series(times, values, since) + + @Test + fun `an export with nothing sampled is a header and no rows`() { + // The empty case is not exotic: changing the sampling rate clears every buffer, so the very + // next export has nothing to say. It still produces a file. + val lines = render(snapshot(rowTimes = LongArray(4))) + + assertThat(lines).hasSize(1) + assertThat(lines.single()).isEqualTo(MetricsCsv.HEADER.joinToString(",") { "\"$it\"" }) + } + + @Test + fun `a row's time is the one recorded for that sample`() { + val lines = render(snapshot(rowTimes = longArrayOf(T0))) + + // Read back, not reconstructed from an index and an interval: 2026-09-06T22:33:40.123 in + // Los Angeles, with the offset that says which 22:33 it was. + assertThat(lines[1]).startsWith("\"2026-09-06T22:33:40.123-07:00\"") + } + + @Test + fun `the fraction is always three digits, even when it ends in zero`() { + // ISO_OFFSET_DATE_TIME drops trailing zeros and would write ".38" here, giving a column of + // varying width. Both parse; only one lines up with the milliseconds the filename carries. + val lines = render(snapshot(rowTimes = longArrayOf(T0 - 43L))) + + assertThat(lines[1]).startsWith("\"2026-09-06T22:33:40.080-07:00\"") + } + + @Test + fun `an index nothing was sampled at is not a row`() { + // The buffers are fixed-length and start full of zeros, so most of a young session's buffer + // has never been written. Those are absent rows, not rows of zeros. + val lines = render(snapshot(rowTimes = longArrayOf(0L, 0L, T0, 0L, T0 + 1_000L))) + + assertThat(lines).hasSize(3) + assertThat(lines[1]).contains("22:33:40.123") + assertThat(lines[2]).contains("22:33:41.123") + } + + @Test + fun `every row has as many cells as the header`() { + val times = longArrayOf(T0, T0 + 1_000L) + val lines = + render( + snapshot( + rowTimes = times, + memory = mapOf("IDE" to series(times, longArrayOf(1L, 2L))), + networkReceived = series(times, longArrayOf(3L, 4L)), + annotations = listOf(MetricsCsv.Marker(T0, "assemble", "TASK")), + ), + ) + + lines.forEach { line -> + assertThat(cellsIn(line)).hasSize(MetricsCsv.HEADER.size) + } + } + + @Test + fun `a process that was not being watched yet leaves the cell empty, not zero`() { + val times = longArrayOf(T0, T0 + 1_000L) + val lines = + render( + snapshot( + rowTimes = times, + // The Gradle daemon appears when a build starts, and its buffer is zero-filled + // back to the beginning of the session (ADFA-5514). Reporting those zeros as + // measurements would say the daemon was running and using nothing. + memory = mapOf("Gradle Daemon" to series(times, longArrayOf(0L, 900L), since = T0 + 1_000L)), + ), + ) + + val daemon = MetricsCsv.HEADER.indexOf("gradle_daemon_pss_bytes") + assertThat(cellsIn(lines[1])[daemon]).isEmpty() + assertThat(cellsIn(lines[2])[daemon]).isEqualTo("900") + } + + @Test + fun `a series that never recorded leaves its columns empty`() { + val times = longArrayOf(T0, T0 + 1_000L) + val lines = + render( + snapshot( + rowTimes = times, + memory = mapOf("IDE" to series(times, longArrayOf(5L, 6L))), + // A device whose traffic counters are unsupported never records a sample, and + // zero bytes transferred is a different statement from no measurement. + networkReceived = MetricsCsv.Series.EMPTY, + ), + ) + + val rx = MetricsCsv.HEADER.indexOf("net_rx_bytes") + assertThat(cellsIn(lines[1])[rx]).isEmpty() + } + + @Test + fun `strings are quoted, numbers are not, and a quote inside one is doubled`() { + val times = longArrayOf(T0) + val lines = + render( + snapshot( + rowTimes = times, + memory = mapOf("IDE" to series(times, longArrayOf(7L))), + annotations = listOf(MetricsCsv.Marker(T0, ":app:say \"hi\"", "TASK")), + ), + ) + + val cells = cellsIn(lines[1]) + assertThat(cells[MetricsCsv.HEADER.indexOf("ide_pss_bytes")]).isEqualTo("7") + assertThat(cells[MetricsCsv.HEADER.indexOf("annotation")]).isEqualTo("\":app:say \"\"hi\"\"\"") + assertThat(cells[MetricsCsv.HEADER.indexOf("annotation_kind")]).isEqualTo("\"TASK\"") + } + + @Test + fun `an annotation lands on the sample nearest in time, not only an exact match`() { + val times = longArrayOf(T0, T0 + 1_000L, T0 + 2_000L) + val lines = + render( + snapshot( + rowTimes = times, + // Recorded when the build started, which is between two samples. Requiring an + // exact match would drop nearly every marker in the file. + annotations = listOf(MetricsCsv.Marker(T0 + 1_600L, "Build started", "BUILD_STARTED")), + ), + ) + + val column = MetricsCsv.HEADER.indexOf("annotation") + assertThat(cellsIn(lines[1])[column]).isEmpty() + assertThat(cellsIn(lines[2])[column]).isEmpty() + assertThat(cellsIn(lines[3])[column]).isEqualTo("\"Build started\"") + } + + @Test + fun `two annotations falling on one sample keep the earlier one`() { + val times = longArrayOf(T0) + val lines = + render( + snapshot( + rowTimes = times, + annotations = + listOf( + MetricsCsv.Marker(T0 + 40L, "second", "TASK"), + MetricsCsv.Marker(T0 + 10L, "first", "TASK"), + ), + ), + ) + + // One annotation column per row by definition, so the loser is dropped rather than + // overwriting the winner or being appended into the same cell. + assertThat(cellsIn(lines[1])[MetricsCsv.HEADER.indexOf("annotation")]).isEqualTo("\"first\"") + } + + @Test + fun `an annotation recorded on the monotonic clock lands on the right row`() { + // The store stamps annotations with elapsedRealtime and the samples carry epoch millis. + // Recorded three seconds ago, on a device up for two hours. + val upFor = 2 * 60 * 60 * 1000L + val recordedAt = upFor - 3_000L + val times = longArrayOf(T0, T0 + 1_000L, T0 + 2_000L, T0 + 3_000L) + val onEpoch = MetricsCsv.epochFor(recordedAt, nowEpochMillis = T0 + 3_000L, nowMonotonicMillis = upFor) + + val lines = + render( + snapshot( + rowTimes = times, + annotations = listOf(MetricsCsv.Marker(onEpoch, "Build started", "BUILD_STARTED")), + ), + ) + + val column = MetricsCsv.HEADER.indexOf("annotation") + assertThat(cellsIn(lines[1])[column]).isEqualTo("\"Build started\"") + } + + @Test + fun `an unconverted monotonic time would land on the oldest row`() { + // What the mix-up looked like on a device: a monotonic time is a few hours and an epoch time + // is decades, so every row is about equally far away and the nearest-row search picks + // whichever number is smallest -- the oldest sample, whenever the event really happened. + val times = longArrayOf(T0, T0 + 1_000L, T0 + 2_000L) + val lines = + render( + snapshot( + rowTimes = times, + annotations = listOf(MetricsCsv.Marker(2 * 60 * 60 * 1000L, "Build started", "BUILD_STARTED")), + ), + ) + + val column = MetricsCsv.HEADER.indexOf("annotation") + assertThat(cellsIn(lines[1])[column]).isEqualTo("\"Build started\"") + assertThat(cellsIn(lines[3])[column]).isEmpty() + } + + @Test + fun `the memory columns are the three the chart can plot`() { + // Fixed, not derived from what is being watched: the set changes mid-session, and a header + // that followed it would describe a different file each time. + assertThat(MetricsCsv.MEMORY_COLUMNS).containsExactly("IDE", "Gradle Tooling", "Gradle Daemon").inOrder() + assertThat(MetricsCsv.HEADER).containsAtLeast("ide_pss_bytes", "gradle_tooling_pss_bytes", "gradle_daemon_pss_bytes") + } + + /** Splits a row on commas that are not inside a quoted cell. */ + private fun cellsIn(line: String): List { + val cells = mutableListOf() + val cell = StringBuilder() + var quoted = false + line.forEach { c -> + when { + c == '"' -> { + quoted = !quoted + cell.append(c) + } + + c == ',' && !quoted -> { + cells += cell.toString() + cell.setLength(0) + } + + else -> { + cell.append(c) + } + } + } + cells += cell.toString() + return cells + } + + private companion object { + /** 2026-09-06T22:33:40.123 in America/Los_Angeles, which is UTC-7 at that date. */ + const val T0 = 1_788_759_220_123L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsFileNameTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsFileNameTest.kt new file mode 100644 index 0000000000..fd98e4ffbf --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsFileNameTest.kt @@ -0,0 +1,65 @@ +/* + * 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 +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.time.ZoneId + +/** The one name every exported metrics file gets (ADFA-5531's rule (c)). */ +@RunWith(JUnit4::class) +class MetricsFileNameTest { + private val zone: ZoneId = ZoneId.of("America/Los_Angeles") + + @Test + fun `the name is the local time to the millisecond, and the extension`() { + val name = MetricsFileName.forTime(T0, "csv", zone) + + assertThat(name).isEqualTo("2026_09_06_22_33_40_123.csv") + } + + @Test + fun `the image and the data exported at one moment differ only in extension`() { + // Which is the point of rule (c): a pair exported together sorts together, and neither + // leads with a chart title that would sort them apart. + val csv = MetricsFileName.forTime(T0, "csv", zone) + val png = MetricsFileName.forTime(T0, "png", zone) + + assertThat(csv.removeSuffix(".csv")).isEqualTo(png.removeSuffix(".png")) + } + + @Test + fun `names sort in the order the files were written`() { + val earlier = MetricsFileName.forTime(T0, "csv", zone) + val later = MetricsFileName.forTime(T0 + 1L, "csv", zone) + val muchLater = MetricsFileName.forTime(T0 + 86_400_000L, "csv", zone) + + // A directory listing is sorted lexicographically, so the format has to be too -- which is + // why it is fixed-width and big-endian rather than anything friendlier to read. + assertThat(listOf(muchLater, later, earlier).sorted()) + .containsExactly(earlier, later, muchLater) + .inOrder() + } + + private companion object { + /** 2026-09-06T22:33:40.123 in America/Los_Angeles. */ + const val T0 = 1_788_759_220_123L + } +} 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 53ca31b780..613d699915 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt @@ -27,8 +27,10 @@ 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. + * Pins ADFA-5486's snapshot export: a chart becomes a PNG in the cache, with a few recent ones kept. + * + * The name used to lead with the chart's title. ADFA-5531 gave the image and the CSV one naming rule + * so a pair exported together sorts together, which is what the naming tests here now pin. */ @RunWith(RobolectricTestRunner::class) class MetricsSnapshotTest { @@ -38,7 +40,7 @@ class MetricsSnapshotTest { @Test fun `writes a png into the cache`() { - val file = MetricsSnapshot.write(context, bitmap(), "Memory usage") + val file = MetricsSnapshot.write(context, bitmap()) assertThat(file).isNotNull() assertThat(file!!.exists()).isTrue() @@ -49,54 +51,39 @@ class MetricsSnapshotTest { } @Test - fun `names the file after the chart`() { - val file = MetricsSnapshot.write(context, bitmap(), "Network traffic") + fun `the name is the shared metrics naming rule`() { + val file = MetricsSnapshot.write(context, bitmap(), AT) - 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-") + // The same name the CSV exported at that moment would get, differing only in extension -- + // no chart title in front of it to sort the pair apart (ADFA-5531). + assertThat(file!!.name).isEqualTo(MetricsFileName.forTime(AT, "png")) } @Test fun `a shared snapshot survives the next few exports`() { - val shared = MetricsSnapshot.write(context, bitmap(), "Memory usage")!! + val shared = MetricsSnapshot.write(context, bitmap(), AT)!! // 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") } + repeat(3) { index -> MetricsSnapshot.write(context, bitmap(), AT + index + 1L) } assertThat(shared.exists()).isTrue() } @Test fun `the directory stays bounded across many exports`() { - repeat(20) { index -> MetricsSnapshot.write(context, bitmap(), "Chart $index") } + repeat(20) { index -> MetricsSnapshot.write(context, bitmap(), AT + index) } // Bounded, not unbounded: this is a scratch directory, not a gallery. - val directory = MetricsSnapshot.write(context, bitmap(), "Last")!!.parentFile!! + val directory = MetricsSnapshot.write(context, bitmap(), AT + 100L)!!.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") + MetricsSnapshot.write(context, bitmap(), AT) + val newest = MetricsSnapshot.write(context, bitmap(), AT + 1L) // 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 @@ -105,4 +92,14 @@ class MetricsSnapshotTest { assertThat(newest!!.exists()).isTrue() assertThat(newest.parentFile).isEqualTo(File(context.cacheDir, "metrics-snapshots")) } + + private companion object { + /** + * A fixed export time. + * + * The name carries milliseconds, so two writes in the same millisecond would be one file. + * Real exports are a tap apart; a test loop is not. + */ + const val AT = 1_788_759_220_123L + } } From 3ba8bbcc8ae41a23f66f77d0fc086846dab6ca13 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 00:22:17 -0700 Subject: [PATCH 071/128] ADFA-5531: add the export button to the carousel A spreadsheet icon to the left of the camera, sharing its corner and its touch target. Both are exports, and every other gesture over the chart is already spoken for -- paging, the two-finger tap that undocks, pinch to zoom, and a tap on the x axis for the sampling rate. It exports the whole buffer, not the visible window and not the page on screen: this is the file the format is defined around, and its other consumers want everything there is. Assembly happens on the UI thread because it is snapshots of the watchers' buffers; formatting and writing happen off it, because ten thousand rows is not a click listener's work. The annotations needed converting, not just copying. MetricsAnnotationStore records on SystemClock.elapsedRealtime, which is monotonic and is what the chart wants -- it only ever asks how long ago something happened -- while the samples carry epoch milliseconds. Comparing the two directly is not a small error: a monotonic time is a few hours since boot and an epoch time is decades, so every row looks about equally far from a marker and the nearest-row search lands on whichever number is smallest, which is the oldest row in the buffer every time. On a device the build marker appeared four minutes before the build. The conversion is a pure function with the failure mode pinned by a test of its own. The button carries a long-press help tag like every other control in the carousel, and its Tier 1 and Tier 2 text is written into the documentation database. ADFA-5513 still owes it a Tier 3 destination, as it does the other twelve; that ticket has been told. Verified on a Pixel 6 Pro. A tap writes 2026_09_07_00_20_09_926.csv and opens the share sheet; the image exported beside it differs only in extension. Against a real build: sixty-five rows, timestamps a second apart and irregular as recorded, "Build started" on the row 1.3s after the Run tap, a task marker carrying the Gradle task's own name, and "Build finished" nineteen seconds later. The Gradle daemon column is present and empty, which is correct until ADFA-5514 lands. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsCarouselController.kt | 119 +++++++++++++++++- app/src/main/res/drawable/ic_spreadsheet.xml | 25 ++++ app/src/main/res/layout/layout_mem_usage.xml | 14 +++ .../androidide/idetooltips/TooltipTag.kt | 1 + resources/src/main/res/values/strings.xml | 2 + 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/ic_spreadsheet.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 c948fb6216..7f3d4db9c7 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 android.content.Context import android.content.ContextWrapper import android.content.Intent import android.content.res.ColorStateList +import android.os.SystemClock import android.util.TypedValue import android.view.View import android.view.ViewGroup @@ -46,6 +47,8 @@ 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.MetricsCsv +import com.itsaky.androidide.utils.MetricsCsvFile import com.itsaky.androidide.utils.MetricsSamplingRates import com.itsaky.androidide.utils.MetricsSnapshot import com.itsaky.androidide.utils.NetworkUsageWatcher @@ -253,6 +256,7 @@ class MetricsCarouselController( // 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() } + binding.metricsExport.setOnClickListener { exportCsv() } // 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 @@ -308,6 +312,7 @@ class MetricsCarouselController( binding.metricsPrevious to TooltipTag.CAROUSEL_PREVIOUS, binding.metricsNext to TooltipTag.CAROUSEL_NEXT, binding.metricsSnapshot to TooltipTag.CAROUSEL_SNAPSHOT, + binding.metricsExport to TooltipTag.CAROUSEL_EXPORT, binding.metricsBattery to TooltipTag.CAROUSEL_BATTERY, // Wired even though it is only visible while undocked: the message is the one control // that outlives unbind(), so its help must not be torn down with the rest. @@ -334,6 +339,7 @@ class MetricsCarouselController( networkRenderer.onXAxisTap = null powerRenderer.onXAxisTap = null binding?.metricsSnapshot?.setOnClickListener(null) + binding?.metricsExport?.setOnClickListener(null) binding?.let { bound -> helpTargets(bound) // All but the undocked message: that view becomes visible *because* the carousel @@ -617,7 +623,7 @@ class MetricsCarouselController( // 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) + MetricsSnapshot.write(appContext, bitmap) } finally { bitmap.recycle() } @@ -651,6 +657,117 @@ class MetricsCarouselController( return true } + /** + * Writes every retained sample to a CSV file and offers it to another app (ADFA-5531). + * + * The whole buffer, not the visible window and not the current page: this is the file the + * metrics format is defined around, and ADFA-5494, ADFA-5526 and ADFA-5534 all want everything + * there is. Assembled on the UI thread because it is snapshots of the watchers' buffers, then + * formatted and written off it -- ten thousand rows is not a click listener's work. + * + * @return whether an export could be started. The write itself completes later. + */ + @UiThread + fun exportCsv(): Boolean { + val binding = this.binding ?: return false + if (exportInFlight) { + log.debug("Ignoring an export request while one is already being written") + return false + } + + val context = binding.root.context + val appContext = context.applicationContext + val snapshot = snapshot() + exportInFlight = true + scope.launch { + // Guarded for the same reason exportSnapshot is: the scope has no exception handler, so + // anything escaping here is filed as a crash. + runCatching { + val file = withContext(Dispatchers.IO) { MetricsCsvFile.write(appContext, snapshot) } + val host = this@MetricsCarouselController.binding?.root?.context + if (file == null || host == null) { + Toast.makeText(appContext, string.msg_metrics_export_failed, Toast.LENGTH_SHORT).show() + return@runCatching + } + val extraFlags = + if (host.findActivityOrNull() == null) Intent.FLAG_ACTIVITY_NEW_TASK else 0 + IntentUtils.shareFile(host, file, MetricsCsv.MIME_TYPE, extraFlags) + }.onFailure { failure -> + if (failure is CancellationException) { + exportInFlight = false + throw failure + } + log.error("Could not share the metrics export", failure) + Toast.makeText(appContext, string.msg_metrics_export_failed, Toast.LENGTH_SHORT).show() + } + exportInFlight = false + } + return true + } + + /** + * The watchers' buffers, as the export format's view of them. + * + * Rows come from the memory watcher: it is the only one always recording, the network watcher + * stops for good on a device whose counters are unsupported, and a power source can be missing. + * The other series are read at the same index -- the watchers share an interval, are started + * together and are cleared together -- and each carries its own sample times, so a series that + * was not recording leaves empty cells rather than zeros. + */ + @UiThread + @VisibleForTesting + internal fun snapshot(): MetricsCsv.Snapshot { + val memoryTimes = memoryUsageWatcher.sampleTimes() + val networkTimes = networkUsageWatcher.sampleTimes() + val powerTimes = powerUsageWatcher.sampleTimes() + val network = networkUsageWatcher.getUsage() + val power = powerUsageWatcher.getUsage() + + return MetricsCsv.Snapshot( + rowTimes = memoryTimes, + memory = + memoryUsageWatcher.getMemoryUsages().associate { process -> + process.pname to + MetricsCsv.Series( + times = memoryTimes, + values = process.usageHistory.toLongArray(), + since = process.watchedSinceMillis, + ) + }, + networkReceived = MetricsCsv.Series(networkTimes, network.received), + networkTransmitted = MetricsCsv.Series(networkTimes, network.transmitted), + temperature = MetricsCsv.Series(powerTimes, power.temperatureMilliCelsius), + power = MetricsCsv.Series(powerTimes, power.powerMicroWatts), + thermal = MetricsCsv.Series(powerTimes, power.thermalStatus), + annotations = markers(), + ) + } + + /** + * The annotations, with their times moved onto the clock the samples carry. + * + * The store records on the monotonic clock and the samples on the wall clock, and the two are + * read here as close together as they can be so the offset between them is the right one. + */ + private fun markers(): List { + val store = annotations ?: return emptyList() + val nowEpoch = System.currentTimeMillis() + val nowMonotonic = SystemClock.elapsedRealtime() + return store.allAnnotations().map { annotation -> + MetricsCsv.Marker( + atMillis = MetricsCsv.epochFor(annotation.atMillis, nowEpoch, nowMonotonic), + label = labelFor(annotation), + kind = annotation.kind.name, + ) + } + } + + /** An annotation's text: a build outcome carries a string id, a task carries its own name. */ + private fun labelFor(annotation: MetricsAnnotationStore.Annotation): String { + val context = binding?.root?.context ?: return annotation.label + return annotation.kind.labelRes?.let(context::getString) ?: annotation.label + } + /** * 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. diff --git a/app/src/main/res/drawable/ic_spreadsheet.xml b/app/src/main/res/drawable/ic_spreadsheet.xml new file mode 100644 index 0000000000..db16b3dd48 --- /dev/null +++ b/app/src/main/res/drawable/ic_spreadsheet.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index f267ae6466..aee15c1e87 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -88,6 +88,20 @@ app:layout_constraintBottom_toBottomOf="@id/metrics_pager" app:layout_constraintEnd_toEndOf="@id/metrics_pager" /> + + + Next metric Save chart image Couldn\'t save the chart image. + Save metrics data + Couldn\'t save the metrics data. Received Sent From 00dbafaf7a62c8a3e4bd5faa0c52e91cc45e5bab Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 22:37:20 -0700 Subject: [PATCH 072/128] ADFA-5515: apply the chart's newest window against the transform it was measured on The carousel is unbound on pause and rebound on resume, and a rebind gives the pager a new adapter, so every page is a freshly inflated chart. onBindViewHolder attaches the renderer while RecyclerView is still laying that chart out, which puts showNewestWindow on a view with no plotting area: setVisibleXRangeMaximum clamps its scale against an empty content rect, and moveViewToX cannot run at all, so it is queued as a viewport job. The layout that follows resets the chart's transform to identity. The queued job then converts its x value through *that* transform, and the clamp afterwards restores the scale around a translation computed for a different one -- so the viewport lands neither where it was nor where it was asked to go, and nothing corrects it until the next sample lands a redraw. Two halves, matching the two ways it goes wrong: - Don't place a window before there is a plot to place it in. Bail while hasChartDimens() is false rather than leaving state behind for the layout to corrupt. - Re-apply on every layout, and apply it synchronously. SafeLineChart gains moveViewToXNow, which is MoveViewJob's body run inline, so the scale and the translation are computed against one transform. A layout that changes the chart's size resets the transform, which is precisely when the window has to go back. This also covers the rotation case ADFA-5486 fixed by re-applying on every redraw: the window now returns on the layout itself rather than on the next sample. Two existing tests turned out to be passing because of the defect. Both panned with chart.moveViewToX(0f) to simulate a user dragging to the oldest samples, and on a Robolectric chart that is never attached to a window View.post drops the job in the run queue, which is only flushed on attach -- so the pan moved nothing. They passed only while the chart already happened to be sitting on the oldest samples. Both now pan for real through moveViewToXNow, and pass with or without this change. ChartLayout's doc said the draw in layOutAndDraw was what ran the scroll; that was never the mechanism, and it is no longer needed for the window. Verified: the three new tests fail without the change, each for the symptom it is named for -- bound-before-layout and resize read lowestVisibleX 0.0 against an expected 139.0 (no window at all, the whole buffer from its oldest end), and bound-after-layout reads highestVisibleX 60.0 against an expected 199.0, a window sitting on the oldest samples, which is the shape of the reported -9999s to -9939s. Full :app unit suite and spotlessCheck green. Not confirmed as the cause of the field report. On a Pixel 6 Pro, with the sampling rate set to 60s so a mis-parked viewport would persist for up to a minute, neither a home/resume round trip nor the ticket's own repro -- Run, build, dismiss the install prompt -- parked the chart on this build, with or without the change. The defect fixed here is real and reproducible in isolation; whether it is what ADFA-5515 saw in the field is still open. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 34 ++++- .../com/itsaky/androidide/ui/SafeLineChart.kt | 21 +++ .../com/itsaky/androidide/ui/ChartLayout.kt | 9 +- .../ui/MetricsAnnotationSpanTest.kt | 2 +- .../androidide/ui/MetricsChartAxisTapTest.kt | 2 +- .../ui/MetricsChartNewestWindowTest.kt | 121 ++++++++++++++++++ 6 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.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 d06591e9b5..f20ed304ce 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.graphics.Bitmap import android.os.SystemClock import android.util.TypedValue import android.view.MotionEvent +import android.view.View import androidx.annotation.CallSuper import androidx.annotation.UiThread import androidx.annotation.VisibleForTesting @@ -155,8 +156,12 @@ abstract class MetricsChartRenderer( */ @UiThread fun attach(chart: SafeLineChart) { + // A rebind can attach the replacement before the view it replaced is recycled, so the + // outgoing chart is let go of here rather than waiting for a [detach] that names it. + this.chart?.removeOnLayoutChangeListener(newestWindowOnLayout) this.chart = chart configure(chart) + chart.addOnLayoutChangeListener(newestWindowOnLayout) rebuild() } @@ -167,6 +172,7 @@ abstract class MetricsChartRenderer( @CallSuper open fun detach() { userHasZoomed = false + chart?.removeOnLayoutChangeListener(newestWindowOnLayout) chart = null } @@ -277,10 +283,36 @@ abstract class MetricsChartRenderer( return } + // Before the first layout there is no plot area to place a window in, and applying one + // anyway is worse than waiting: the scale is clamped against an empty content rect, and the + // layout that follows resets the chart's transform. [newestWindowOnLayout] re-applies it as + // soon as there is something to apply it to (ADFA-5515). + if (!chart.viewPortHandler.hasChartDimens()) { + return + } + chart.setVisibleXRangeMaximum(VISIBLE_SAMPLES.toFloat()) - chart.moveViewToX(newestIndex - VISIBLE_SAMPLES.toFloat() + 1f) + // Not moveViewToX: its scroll is deferred to a later frame and would be converted through + // a different transform from the scale just set here (ADFA-5515). + chart.moveViewToXNow(newestIndex - VISIBLE_SAMPLES.toFloat() + 1f) } + /** + * Re-applies the newest window whenever the chart is laid out. + * + * A layout that changes the chart's size resets its transform, which drops the window and shows + * the whole buffer from its oldest end. Nothing put it back until the next sample landed a + * redraw, so every rebind -- and the carousel is rebound on every resume -- opened on an empty + * plot for a second or more (ADFA-5515). + */ + private val newestWindowOnLayout = + View.OnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + val chart = view as? SafeLineChart ?: return@OnLayoutChangeListener + if (chart === this.chart) { + showNewestWindow(chart) + } + } + /** * The sample indices currently on screen, for a series of [sampleCount] samples. * diff --git a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt index 8362a2565c..b5acd0f684 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt @@ -125,6 +125,27 @@ class SafeLineChart : LineChart { } } + /** + * Scrolls the plot so that [xValue] is its leftmost value, now rather than on a later frame. + * + * What [moveViewToX] does, minus the deferral. That one queues the scroll as a viewport job + * which MPAndroidChart hands to `View.post`, and by the time it runs the transform it converts + * its x value through is no longer the one the caller set it up against -- a layout in between + * resets the transform to identity, and the clamp afterwards restores the scale around a + * translation computed for a different one. The viewport then lands neither where it was nor + * where it was asked to go (ADFA-5515). + * + * On a chart that is not attached to a window the job is worse than late: `View.post` drops it + * in the view's run queue, which is only flushed on attach, so it never runs at all. + */ + fun moveViewToXNow(xValue: Float) { + // The left axis, as moveViewToX itself uses; only the x component is read back. + val transformer = getTransformer(YAxis.AxisDependency.LEFT) ?: return + val target = floatArrayOf(xValue, 0f) + transformer.pointValuesToPixel(target) + viewPortHandler.centerViewPort(target, this) + } + override fun onDraw(canvas: Canvas) { try { super.onDraw(canvas) diff --git a/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt b/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt index 4bc1d534c8..3c76eb0eee 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt @@ -29,12 +29,9 @@ const val CHART_HEIGHT = 400 /** * Lays this chart out and draws it once, which is what every assertion about its viewport needs. * - * Two separate reasons, both easy to leave out and neither of which fails loudly. Without the - * layout the plot area has no extent, so every coordinate lands on its edge and a hit test cannot - * tell inside from outside. Without the draw the scroll to the newest samples has not run -- - * MPAndroidChart queues `moveViewToX` as a job that only executes during a draw pass -- so the - * chart still reports the *oldest* samples as visible and a window assertion reads the wrong end - * of the buffer. + * Without the layout the plot area has no extent, so every coordinate lands on its edge, a hit test + * cannot tell inside from outside, and the renderer has nothing to place its viewport in. The draw + * is what renders the axes and annotations that assertions about them read back. * * Four test classes had grown their own copy of this, with the comment explaining it in three of * them and the draw missing from one. diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt index 16c953db28..23e24a84d1 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt @@ -84,7 +84,7 @@ class MetricsAnnotationSpanTest { // Pan back to where that marker lives, and record that the user drove the viewport. chart.setVisibleXRangeMaximum(VISIBLE_WINDOW.toFloat()) - chart.moveViewToX(0f) + chart.moveViewToXNow(0f) draw(chart) val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, 10f, 0) chart.onChartGestureListener.onChartTranslate(event, -50f, 0f) 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 8653db671e..e651e41daf 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -84,7 +84,7 @@ class MetricsChartAxisTapTest { // Zoom first: an unzoomed chart shows everything, so there is nothing a pan could move. chart.setVisibleXRangeMaximum(VISIBLE_WINDOW.toFloat()) - chart.moveViewToX(0f) + chart.moveViewToXNow(0f) drawOnce(chart) assertThat(chart.lowestVisibleX).isLessThan(10f) assertThat(chart.highestVisibleX).isLessThan(SAMPLES / 2f) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt new file mode 100644 index 0000000000..7f44cfb2e0 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt @@ -0,0 +1,121 @@ +/* + * 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 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 + +/** + * Where the chart's viewport ends up when a page is bound before it has been laid out. + * + * That is the order the carousel always binds in -- `onBindViewHolder` attaches the renderer while + * RecyclerView is still laying the page out -- and the editor rebinds the whole carousel on every + * resume. Returning from the APK install prompt therefore left the chart parked in the zeroed head + * of the buffer, reading -9999s and drawing nothing, until the next sample landed a redraw a second + * or more later: an empty plot at the moment the user has just run a build and is looking straight + * at it (ADFA-5515). + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartNewestWindowTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun renderer() = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + }, + ) + + /** + * The window the chart is meant to settle on: the newest [MetricsChartRenderer.VISIBLE_SAMPLES] + * of them, ending on the newest sample. + * + * The window is asked to start one sample further right than this and is clamped back, since it + * cannot extend past the end of the data -- so the newest sample sits exactly on the right edge. + */ + private fun assertShowsNewestSamples(chart: SafeLineChart) { + val newest = (SAMPLES - 1).toFloat() + assertThat(chart.highestVisibleX).isWithin(TOLERANCE).of(newest) + assertThat(chart.lowestVisibleX) + .isWithin(TOLERANCE) + .of(newest - MetricsChartRenderer.VISIBLE_SAMPLES) + } + + @Test + fun `a page bound before it is laid out still opens on the newest samples`() { + val chart = SafeLineChart(context) + + // The carousel's order: attach first, lay out second. + renderer().attach(chart) + chart.layOutAndDraw() + + assertShowsNewestSamples(chart) + } + + @Test + fun `a page bound after it is laid out opens on the newest samples`() { + val chart = SafeLineChart(context) + chart.layOutAndDraw() + + renderer().attach(chart) + + assertShowsNewestSamples(chart) + } + + @Test + fun `a resize puts the window back without waiting for a sample`() { + val chart = SafeLineChart(context) + renderer().attach(chart) + chart.layOutAndDraw() + + // A size change resets the chart's transform, dropping the window. Only a redraw used to + // restore it, which is why a rotation showed samples from half an hour ago until the next + // tick. + chart.layOutAndDraw(width = CHART_WIDTH, height = CHART_HEIGHT + 40) + + assertShowsNewestSamples(chart) + } + + @Test + fun `a detached renderer stops following the chart it left`() { + val chart = SafeLineChart(context) + val renderer = renderer() + renderer.attach(chart) + chart.layOutAndDraw() + renderer.detach() + + // A resize drops the window, and nothing should put it back: the page is on its way to + // another renderer, and a stale listener would fight whichever one binds next. + chart.layOutAndDraw(width = CHART_WIDTH, height = CHART_HEIGHT + 40) + + assertThat(chart.lowestVisibleX).isWithin(TOLERANCE).of(0f) + } + + private companion object { + /** Longer than the visible window, so there is a wrong end of the buffer to park in. */ + const val SAMPLES = 200 + + /** The viewport is computed in pixels and read back as a value, so it lands near-exactly. */ + const val TOLERANCE = 0.01f + } +} From 3e3869b8839632722373055ccf9b15f1d0ed86a7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 05:20:44 -0700 Subject: [PATCH 073/128] ADFA-5531: read a row's time and its values as one moment The export asked each watcher for its sample times and then for its values, in two separate locked calls. A sample landing between them shifts every value one index against the times, so each row of the file carries its neighbour's timestamp -- for the whole buffer, not just the newest row. That is the one property a row of this file has. Two halves, because both sides had a window: The memory sampler appended the time in one critical section and each process's value in another, so a reader could catch a buffer holding one more timestamp than values. It now reads every process first, off the lock, and appends the time and all the values together. The reflective PSS read becomes an injectable reader, matching the other watchers' readRxBytes/readTxBytes, which is also what lets a test read from inside a sample. Readers get one call per watcher. MemoryUsageWatcher.history() returns times and per-process values from one critical section; the network and power sample times ride on the NetworkUsage and PowerUsage they were recorded with. The times-only accessors are gone, so the window cannot be reintroduced by asking for the halves separately. The test reads from inside a sample and fails without the fix with one timestamp against zero values. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsCarouselController.kt | 25 ++-- .../androidide/utils/MemoryUsageWatcher.kt | 120 +++++++++++------ .../androidide/utils/NetworkUsageWatcher.kt | 30 +++-- .../androidide/utils/PowerUsageWatcher.kt | 29 ++-- .../MemoryUsageWatcherSampleAlignmentTest.kt | 124 ++++++++++++++++++ 5 files changed, 250 insertions(+), 78 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.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 7f3d4db9c7..d2e130ad02 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -717,28 +717,29 @@ class MetricsCarouselController( @UiThread @VisibleForTesting internal fun snapshot(): MetricsCsv.Snapshot { - val memoryTimes = memoryUsageWatcher.sampleTimes() - val networkTimes = networkUsageWatcher.sampleTimes() - val powerTimes = powerUsageWatcher.sampleTimes() + // One call per watcher, not one per array. Each returns its times and its values from a + // single critical section, which is what keeps a row of the file a single moment: two calls + // let the sampler append between them and every value came out one row off its timestamp. + val memory = memoryUsageWatcher.history() val network = networkUsageWatcher.getUsage() val power = powerUsageWatcher.getUsage() return MetricsCsv.Snapshot( - rowTimes = memoryTimes, + rowTimes = memory.times, memory = - memoryUsageWatcher.getMemoryUsages().associate { process -> + memory.processes.associate { process -> process.pname to MetricsCsv.Series( - times = memoryTimes, - values = process.usageHistory.toLongArray(), + times = memory.times, + values = process.usage, since = process.watchedSinceMillis, ) }, - networkReceived = MetricsCsv.Series(networkTimes, network.received), - networkTransmitted = MetricsCsv.Series(networkTimes, network.transmitted), - temperature = MetricsCsv.Series(powerTimes, power.temperatureMilliCelsius), - power = MetricsCsv.Series(powerTimes, power.powerMicroWatts), - thermal = MetricsCsv.Series(powerTimes, power.thermalStatus), + networkReceived = MetricsCsv.Series(network.sampleTimes, network.received), + networkTransmitted = MetricsCsv.Series(network.sampleTimes, network.transmitted), + temperature = MetricsCsv.Series(power.sampleTimes, power.temperatureMilliCelsius), + power = MetricsCsv.Series(power.sampleTimes, power.powerMicroWatts), + thermal = MetricsCsv.Series(power.sampleTimes, power.thermalStatus), annotations = markers(), ) } 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 498b8451de..eeb47d03ce 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.utils import android.app.ActivityManager import android.os.Debug import android.os.Debug.MemoryInfo +import androidx.annotation.VisibleForTesting import androidx.collection.IntObjectMap import androidx.collection.MutableIntObjectMap import androidx.core.content.getSystemService @@ -59,6 +60,17 @@ class MemoryUsageWatcher private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("MemoryUsageWatcher"), private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, private val nowMillis: () -> Long = System::currentTimeMillis, + // Injectable for the same reason the other watchers' readers are: it is the one part of a + // sample that needs a device. ActivityManager.getProcessMemoryInfo is rate-limited and + // internally uses Debug.getMemoryInfo, so the reflective call goes around the limit. + private val readTotalPssKb: (Int, MemoryInfo) -> Int = { pid, into -> + ReflectionUtils.invokeMethod(android_os_Debug_getMemoryInfo, null, pid, into) + + // 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." + into.totalPss + }, ) { /** * Milliseconds between samples. Changing it clears the history: the chart reads a sample's @@ -199,7 +211,8 @@ class MemoryUsageWatcher } } - private fun readUsages() { + @VisibleForTesting + internal 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. @@ -212,49 +225,35 @@ class MemoryUsageWatcher return } - // Once per sample, not once per process: every process is read in this one pass, so - // they share a time, and that is what makes a row of the exported file a single moment. - synchronized(historyLock) { - sampleTimes[0] = nowMillis() - sampleTimes.shift(1) - } - + // Read every process first, append nothing yet. The reading is the slow part and must + // not hold the lock; the append is the part a reader can see, and all of it -- the time + // and every process's value -- has to land in one critical section. A reader that + // caught the time appended but not the values got a file whose every row sat on its + // neighbour's timestamp, which is the one thing a row of this file is for (ADFA-5531). + val at = nowMillis() val pids = memoryUsage.keys.toIntArray() + val sampled = ArrayList>(pids.size) 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) - - // 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 - - // 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) + sampled += proc to readTotalPssKb(pid, proc.memInfo) * 1024L + } - synchronized(historyLock) { - _history[0] = usageBytes - _history.shift(1) - } + synchronized(historyLock) { + // The entry goes in at the start of the array and the shift amount goes up by one, + // which makes it the last element and the oldest the first -- so + // _history[_history.size - 1] is always the newest. The shift is the array's start + // index, wrapping back to 0 once it passes the end. + sampleTimes[0] = at + sampleTimes.shift(1) + sampled.forEach { (proc, usageBytes) -> + proc._history[0] = usageBytes + proc._history.shift(1) } } } @@ -310,14 +309,31 @@ class MemoryUsageWatcher } /** - * When each retained sample was taken, oldest first, as milliseconds since the epoch. + * Every retained sample, with the times the samples were taken at. * - * A zero at an index means nothing was ever sampled there -- the buffer is fixed-length and - * starts, and is cleared, full of them. A copy, for the same reason the values are copied. + * One lock around the whole read, and it has to be: asking for the times and the values + * separately let the sampler append between the two calls, which shifts every value one + * index against its timestamp and puts each row of the exported file on its neighbour's + * time (ADFA-5531). There is no accessor for the times alone, deliberately. + * + * A zero time at an index means nothing was ever sampled there -- the buffers are + * fixed-length and start, and are cleared, full of them. Copies, for the same reason the + * values have always been copied. */ - fun sampleTimes(): LongArray = + fun history(): MemoryHistory = synchronized(historyLock) { - sampleTimes.toLongArray() + MemoryHistory( + times = sampleTimes.toLongArray(), + processes = + memoryUsage.values.map { proc -> + ProcessHistory( + pid = proc.pid, + pname = proc.pname, + usage = proc._history.toLongArray(), + watchedSinceMillis = proc.watchedSinceMillis, + ) + }, + ) } /** @@ -393,6 +409,32 @@ class MemoryUsageWatcher (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() } + /** + * One process's retained samples, detached from the watcher. + * + * @property usage The samples, oldest first, in bytes. + * @property watchedSinceMillis When this process started being watched. Its buffer reaches + * back to the start of the session however late in it the process appeared, and this is what + * tells those zeros from a measurement. + */ + class ProcessHistory( + val pid: Int, + val pname: String, + val usage: LongArray, + val watchedSinceMillis: Long, + ) + + /** + * Every watched process's samples and the times they were taken at, read together. + * + * @property times When each sample was taken, oldest first, as milliseconds since the epoch, + * parallel to every entry in [processes]. + */ + class MemoryHistory( + val times: LongArray, + val processes: List, + ) + /** * Registers a listener to be notified when the memory usage of a process changes. */ 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 95aa4e1fb3..2166a87d74 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -156,18 +156,7 @@ class NetworkUsageWatcher */ fun getUsage(): NetworkUsage = synchronized(historyLock) { - NetworkUsage(received.toLongArray(), transmitted.toLongArray()) - } - - /** - * When each retained sample was taken, oldest first, as milliseconds since the epoch. - * - * A zero at an index means nothing was ever sampled there -- the buffer is fixed-length and - * starts, and is cleared, full of them. A copy, for the same reason the values are copied. - */ - fun sampleTimes(): LongArray = - synchronized(historyLock) { - sampleTimes.toLongArray() + NetworkUsage(received.toLongArray(), transmitted.toLongArray(), sampleTimes.toLongArray()) } /** @@ -332,20 +321,33 @@ class NetworkUsageWatcher * * @property received Bytes received during each interval. * @property transmitted Bytes transmitted during each interval. + * @property sampleTimes When each sample was taken, oldest first, as milliseconds since the + * epoch, parallel to the values. Read in the same critical section as them, because reading + * the two separately let the sampler append between the calls and shifted every value one + * index against its timestamp (ADFA-5531). A zero means nothing was ever sampled at that + * index -- the buffers are fixed-length and start, and are cleared, full of them. Defaulted + * empty for the chart, which asks only how long ago a sample was and never when. */ data class NetworkUsage( val received: LongArray, val transmitted: LongArray, + val sampleTimes: LongArray = LongArray(0), ) { override fun equals(other: Any?): Boolean = this === other || ( other is NetworkUsage && received.contentEquals(other.received) && - transmitted.contentEquals(other.transmitted) + transmitted.contentEquals(other.transmitted) && + sampleTimes.contentEquals(other.sampleTimes) ) - override fun hashCode(): Int = 31 * received.contentHashCode() + transmitted.contentHashCode() + override fun hashCode(): Int { + var result = received.contentHashCode() + result = 31 * result + transmitted.contentHashCode() + result = 31 * result + sampleTimes.contentHashCode() + return result + } } fun interface NetworkUsageListener { diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 212b16511b..3c38817837 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -141,18 +141,12 @@ class PowerUsageWatcher */ fun getUsage(): PowerUsage = synchronized(historyLock) { - PowerUsage(temperature.toLongArray(), power.toLongArray(), thermal.toLongArray()) - } - - /** - * When each retained sample was taken, oldest first, as milliseconds since the epoch. - * - * A zero at an index means nothing was ever sampled there -- the buffer is fixed-length and - * starts, and is cleared, full of them. A copy, for the same reason the values are copied. - */ - fun sampleTimes(): LongArray = - synchronized(historyLock) { - sampleTimes.toLongArray() + PowerUsage( + temperature.toLongArray(), + power.toLongArray(), + thermal.toLongArray(), + sampleTimes.toLongArray(), + ) } fun clearHistory() { @@ -285,11 +279,18 @@ class PowerUsageWatcher * @property temperatureMilliCelsius Battery temperature per sample. * @property powerMicroWatts Instantaneous draw per sample. * @property thermalStatus Throttling level per sample, for the chart's shading. + * @property sampleTimes When each sample was taken, oldest first, as milliseconds since the + * epoch, parallel to the values. Read in the same critical section as them, because reading + * the two separately let the sampler append between the calls and shifted every value one + * index against its timestamp (ADFA-5531). A zero means nothing was ever sampled at that + * index -- the buffers are fixed-length and start, and are cleared, full of them. Defaulted + * empty for the chart, which asks only how long ago a sample was and never when. */ data class PowerUsage( val temperatureMilliCelsius: LongArray, val powerMicroWatts: LongArray, val thermalStatus: LongArray, + val sampleTimes: LongArray = LongArray(0), ) { override fun equals(other: Any?): Boolean = this === other || @@ -297,13 +298,15 @@ class PowerUsageWatcher other is PowerUsage && temperatureMilliCelsius.contentEquals(other.temperatureMilliCelsius) && powerMicroWatts.contentEquals(other.powerMicroWatts) && - thermalStatus.contentEquals(other.thermalStatus) + thermalStatus.contentEquals(other.thermalStatus) && + sampleTimes.contentEquals(other.sampleTimes) ) override fun hashCode(): Int { var result = temperatureMilliCelsius.contentHashCode() result = 31 * result + powerMicroWatts.contentHashCode() result = 31 * result + thermalStatus.contentHashCode() + result = 31 * result + sampleTimes.contentHashCode() return result } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt new file mode 100644 index 0000000000..035e57cf68 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt @@ -0,0 +1,124 @@ +/* + * 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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * That a row of the exported metrics file is one moment (ADFA-5531). + * + * The exported file states a time per row, and every value on that row has to be the one recorded + * at it. The sampler runs on its own thread and the export reads from the UI thread, so the only + * thing making that true is where the sampler's appends happen and how many calls the reader makes. + */ +@RunWith(RobolectricTestRunner::class) +class MemoryUsageWatcherSampleAlignmentTest { + private var clock = 1_700_000_000_000L + + private fun watcher(readPssKb: (Int, android.os.Debug.MemoryInfo) -> Int = { _, _ -> PSS_KB }) = + MemoryUsageWatcher( + nowMillis = { + clock += TICK_MILLIS + clock + }, + readTotalPssKb = readPssKb, + ) + + @Test + fun `a read taken during a sample sees times and values that agree`() { + lateinit var watcher: MemoryUsageWatcher + var midSample: MemoryUsageWatcher.MemoryHistory? = null + + // Read from inside the sample, which is the interleaving the sampling thread and the UI + // thread can produce for real. Appending the time and the values in separate critical + // sections left this window: the reader caught a buffer with one more timestamp in it than + // values, so every value in the exported file sat on the row below its own timestamp. + watcher = + watcher { _, _ -> + if (midSample == null) { + midSample = watcher.history() + } + PSS_KB + } + watcher.watchProcess(PID, "IDE") + + watcher.readUsages() + watcher.readUsages() + + val history = checkNotNull(midSample) + val stamped = history.times.count { it != MetricsCsv.NO_SAMPLE } + val measured = + history.processes + .single() + .usage + .count { it != 0L } + assertThat(measured).isEqualTo(stamped) + } + + @Test + fun `a completed sample stamps every process with the same time`() { + val watcher = watcher() + watcher.watchProcess(PID, "IDE") + watcher.watchProcess(OTHER_PID, "Gradle Daemon") + + watcher.readUsages() + + // The two processes are read one after the other, but they belong to one row, so they share + // its time -- and each has exactly one value against it. + val history = watcher.history() + assertThat(history.times.count { it != MetricsCsv.NO_SAMPLE }).isEqualTo(1) + history.processes.forEach { process -> + assertThat(process.usage.count { it != 0L }).isEqualTo(1) + } + } + + @Test + fun `the times come back with the values, not from a call of their own`() { + val watcher = watcher() + watcher.watchProcess(PID, "IDE") + watcher.readUsages() + + // The guard on the fix above: one accessor, so a caller cannot reintroduce the window by + // asking for the halves separately. There is deliberately no times-only accessor. + val history = watcher.history() + assertThat(history.times).hasLength(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(history.processes.single().usage).hasLength(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(history.times.last()).isNotEqualTo(MetricsCsv.NO_SAMPLE) + assertThat( + history.processes + .single() + .usage + .last(), + ).isEqualTo(PSS_KB * 1024L) + } + + private companion object { + const val PID = 4242 + + const val OTHER_PID = 4243 + + /** Any non-zero reading; the test counts measured samples rather than reading values. */ + const val PSS_KB = 512 + + /** Enough that no two sample times collide. */ + const val TICK_MILLIS = 1_000L + } +} From 53ef2d25ad18bff6ff55b4ff9f29e12de42fb28b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 04:50:08 -0700 Subject: [PATCH 074/128] ADFA-5515: clear the pan flag when a rebind replaces the chart attach() released only the outgoing chart's layout listener, so userHasZoomed survived onto the replacement. A user who had panned once got every later page bound with the follow-window already disabled -- showNewestWindow returns early on the flag, and so does every redraw after it -- leaving the chart parked in the zeroed head of the buffer for good rather than until the next sample. That is the field symptom this ticket was filed for, and why it reproduces on resume and never on a fresh chart: it needs a pan first. The earlier commits fixed the bind-before-layout ordering, which was a real defect but not this one. Running the whole detach() also clears MemoryUsageChartRenderer's pidToDatasetIdx, which maps pids to dataset indexes in the chart that is going away. The test fails without the fix with lowestVisibleX 0.0 where 139.0 is expected -- the oldest samples, which is the report. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 13 +++++++++-- .../ui/MetricsChartNewestWindowTest.kt | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 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 f20ed304ce..edb96d9592 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -157,8 +157,17 @@ abstract class MetricsChartRenderer( @UiThread fun attach(chart: SafeLineChart) { // A rebind can attach the replacement before the view it replaced is recycled, so the - // outgoing chart is let go of here rather than waiting for a [detach] that names it. - this.chart?.removeOnLayoutChangeListener(newestWindowOnLayout) + // outgoing chart is let go of here rather than waiting for a [detachIfAttached] that, by + // then, no longer names it. + // + // The whole teardown, not just the listener. [detach] also clears [userHasZoomed], and + // releasing only the listener leaked it onto the replacement: a user who had panned once + // got a chart whose follow-window was disabled for good, because showNewestWindow returns + // early on the flag and every later redraw takes the same early return. That is the + // oldest-samples symptom this ticket was filed for -- reachable only after a pan, which is + // why the resume paths reproduce it and a fresh chart never does. Subclasses clear their own + // per-chart state through the same override. + this.chart?.let { outgoing -> if (outgoing !== chart) detach() } this.chart = chart configure(chart) chart.addOnLayoutChangeListener(newestWindowOnLayout) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt index 7f44cfb2e0..d9636409b2 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt @@ -111,6 +111,28 @@ class MetricsChartNewestWindowTest { assertThat(chart.lowestVisibleX).isWithin(TOLERANCE).of(0f) } + @Test + fun `a rebind onto a new page forgets a pan on the old one`() { + val renderer = renderer() + val first = SafeLineChart(context) + renderer.attach(first) + first.layOutAndDraw() + + // The user pans. From here the viewport on *this* chart is theirs, not the renderer's. + checkNotNull(first.onChartGestureListener).onChartTranslate(null, -20f, 0f) + + // A resume rebinds the carousel, which attaches the replacement page before the outgoing + // one is recycled -- so the detach naming the old chart arrives afterwards and finds a + // different one bound. The pan belonged to the page the user left; the fresh page must + // still open on the newest samples. + val second = SafeLineChart(context) + renderer.attach(second) + renderer.detachIfAttached(first) + second.layOutAndDraw() + + assertShowsNewestSamples(second) + } + private companion object { /** Longer than the visible window, so there is a wrong end of the buffer to park in. */ const val SAMPLES = 200 From 68d7529f3a7e7a28fa0b527573654f294398a84b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 06:55:57 -0700 Subject: [PATCH 075/128] ADFA-5487: drop the emptied init block, and put the KDoc back on its member 092b633f8 emptied this init { } when it removed the two dead drag helpers, and the blank line it left pushed isDownInDragHandle above its own doc comment -- so the doc described dragHandleLocation, an IntArray scratch, as the flag that gates the vertical drag capture. Both found by review on #1784. Nothing above this branch touches the file, so both were live at the top of the stack. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../java/com/itsaky/androidide/ui/SwipeRevealLayout.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt index 429b9f6a18..86a0ee2503 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt @@ -69,17 +69,15 @@ open class SwipeRevealLayout private var isVerticalDragEnabled = true - private var isDownInDragHandle = false - /** * Whether the most recent touch-down landed within the configured drag handle. The vertical * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting * in the middle of the overlapping content (e.g. the editor) are not stolen. */ - private val dragHandleLocation = IntArray(2) + private var isDownInDragHandle = false - init { - } + /** Scratch for [View.getLocationOnScreen] while testing a touch against the handle's bounds. */ + private val dragHandleLocation = IntArray(2) private val dragHelperCallback = object : ViewDragHelper.Callback() { From 53bdaa8cf40c13fabaab8806c0afbdc515cbb098 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 07:00:14 -0700 Subject: [PATCH 076/128] ADFA-5531: spell the header out, instead of deriving it from the code The header test built its expectation with MetricsCsv.HEADER.joinToString, from the same constant MetricsCsv.write builds the file from -- so it asserted only that the code agrees with itself. Renaming a column, reordering one, or changing how cells are quoted would have renamed the expectation too and stayed green. It is one literal line now. ADFA-5494 reads this format back and ADFA-5526 and ADFA-5534 ship it inside reports, so a schema change should have to come and edit it on purpose. Verified: renaming a memory column now fails this test, where before it did not. Agreed with review on #1799 and not carried out at the time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../com/itsaky/androidide/utils/MetricsCsvTest.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt index a546d023cd..f44619461a 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt @@ -74,7 +74,7 @@ class MetricsCsvTest { val lines = render(snapshot(rowTimes = LongArray(4))) assertThat(lines).hasSize(1) - assertThat(lines.single()).isEqualTo(MetricsCsv.HEADER.joinToString(",") { "\"$it\"" }) + assertThat(lines.single()).isEqualTo(EXPECTED_HEADER) } @Test @@ -293,6 +293,18 @@ class MetricsCsvTest { } private companion object { + /** + * The header line, spelled out rather than derived from [MetricsCsv.HEADER]. + * + * This is the file's contract, and a test that builds its expectation from the same constant + * the code builds the file from asserts only that the code is self-consistent -- a renamed + * column or a change in how cells are quoted would rename it here too and stay green. + * ADFA-5494 reads this format back, and ADFA-5526 and ADFA-5534 ship it inside reports, so a + * schema change should have to come and edit this line on purpose. + */ + const val EXPECTED_HEADER = + "\"timestamp\",\"ide_pss_bytes\",\"gradle_tooling_pss_bytes\",\"gradle_daemon_pss_bytes\",\"net_rx_bytes\",\"net_tx_bytes\",\"battery_temp_millicelsius\",\"power_microwatts\",\"thermal_status\",\"annotation\",\"annotation_kind\"" + /** 2026-09-06T22:33:40.123 in America/Los_Angeles, which is UTC-7 at that date. */ const val T0 = 1_788_759_220_123L } From f16e460afc7ddd89dfeba89ee133eac51028253e Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 00:55:15 -0700 Subject: [PATCH 077/128] ADFA-5534: assemble the metrics snapshot without a carousel The assembly began on MetricsCarouselController, which reads the binding for a Context and returns early when the carousel is unbound. That is right for a button on the carousel and wrong for everything else that wants the file: the feedback FAB can be tapped with the strip closed, and a crash report (ADFA-5526) is assembled with no UI at all. Moved to MetricsSnapshotAssembler, which needs only the three watchers, the annotation store and a Context. The controller delegates and passes the context it already has. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsCarouselController.kt | 70 +++--------- .../utils/MetricsSnapshotAssembler.kt | 102 ++++++++++++++++++ 2 files changed, 114 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.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 d2e130ad02..a9fe3af1ee 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -51,6 +51,7 @@ import com.itsaky.androidide.utils.MetricsCsv import com.itsaky.androidide.utils.MetricsCsvFile import com.itsaky.androidide.utils.MetricsSamplingRates import com.itsaky.androidide.utils.MetricsSnapshot +import com.itsaky.androidide.utils.MetricsSnapshotAssembler import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.PowerUsageWatcher import com.itsaky.androidide.utils.clearLongPressHelp @@ -677,7 +678,7 @@ class MetricsCarouselController( val context = binding.root.context val appContext = context.applicationContext - val snapshot = snapshot() + val snapshot = snapshot(context) exportInFlight = true scope.launch { // Guarded for the same reason exportSnapshot is: the scope has no exception handler, so @@ -706,68 +707,21 @@ class MetricsCarouselController( } /** - * The watchers' buffers, as the export format's view of them. + * The watchers' buffers, for the export. * - * Rows come from the memory watcher: it is the only one always recording, the network watcher - * stops for good on a device whose counters are unsupported, and a power source can be missing. - * The other series are read at the same index -- the watchers share an interval, are started - * together and are cleared together -- and each carries its own sample times, so a series that - * was not recording leaves empty cells rather than zeros. + * The assembly itself is [MetricsSnapshotAssembler]: the same file is wanted by things that + * have no carousel bound at all (ADFA-5534, ADFA-5526), so it cannot live here. */ @UiThread @VisibleForTesting - internal fun snapshot(): MetricsCsv.Snapshot { - // One call per watcher, not one per array. Each returns its times and its values from a - // single critical section, which is what keeps a row of the file a single moment: two calls - // let the sampler append between them and every value came out one row off its timestamp. - val memory = memoryUsageWatcher.history() - val network = networkUsageWatcher.getUsage() - val power = powerUsageWatcher.getUsage() - - return MetricsCsv.Snapshot( - rowTimes = memory.times, - memory = - memory.processes.associate { process -> - process.pname to - MetricsCsv.Series( - times = memory.times, - values = process.usage, - since = process.watchedSinceMillis, - ) - }, - networkReceived = MetricsCsv.Series(network.sampleTimes, network.received), - networkTransmitted = MetricsCsv.Series(network.sampleTimes, network.transmitted), - temperature = MetricsCsv.Series(power.sampleTimes, power.temperatureMilliCelsius), - power = MetricsCsv.Series(power.sampleTimes, power.powerMicroWatts), - thermal = MetricsCsv.Series(power.sampleTimes, power.thermalStatus), - annotations = markers(), + internal fun snapshot(context: Context): MetricsCsv.Snapshot = + MetricsSnapshotAssembler.assemble( + context = context, + memory = memoryUsageWatcher, + network = networkUsageWatcher, + power = powerUsageWatcher, + annotations = annotations, ) - } - - /** - * The annotations, with their times moved onto the clock the samples carry. - * - * The store records on the monotonic clock and the samples on the wall clock, and the two are - * read here as close together as they can be so the offset between them is the right one. - */ - private fun markers(): List { - val store = annotations ?: return emptyList() - val nowEpoch = System.currentTimeMillis() - val nowMonotonic = SystemClock.elapsedRealtime() - return store.allAnnotations().map { annotation -> - MetricsCsv.Marker( - atMillis = MetricsCsv.epochFor(annotation.atMillis, nowEpoch, nowMonotonic), - label = labelFor(annotation), - kind = annotation.kind.name, - ) - } - } - - /** An annotation's text: a build outcome carries a string id, a task carries its own name. */ - private fun labelFor(annotation: MetricsAnnotationStore.Annotation): String { - val context = binding?.root?.context ?: return annotation.label - return annotation.kind.labelRes?.let(context::getString) ?: annotation.label - } /** * Releases the controller for good. Distinct from [unbind], which runs on every dock, undock diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt new file mode 100644 index 0000000000..5e7a531007 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.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 android.content.Context +import android.os.SystemClock +import androidx.annotation.UiThread + +/** + * Reads the watchers' buffers into a [MetricsCsv.Snapshot]. + * + * Deliberately free of the carousel. This began on MetricsCarouselController, which bailed when the + * carousel was unbound -- fine for a button on the carousel, wrong for everything else that wants + * the file: the feedback FAB can be tapped with the strip closed (ADFA-5534), and a crash report is + * assembled with no UI at all (ADFA-5526). + * + * All of it must be read on the UI thread; formatting and writing must not be. + */ +object MetricsSnapshotAssembler { + /** + * The watchers' current buffers, as the export format's view of them. + * + * Rows come from the memory watcher: it is the only one always recording, the network watcher + * stops for good on a device whose counters are unsupported, and a power source can be missing. + * The other series are read at the same index -- the watchers share an interval, are started + * together and are cleared together -- and each carries its own sample times, so a series that + * was not recording leaves empty cells rather than zeros. + * + * @param context resolves an annotation's label, which a build outcome carries as a string id + * so its marker follows the system language. + */ + @UiThread + fun assemble( + context: Context, + memory: MemoryUsageWatcher, + network: NetworkUsageWatcher, + power: PowerUsageWatcher, + annotations: MetricsAnnotationStore?, + ): MetricsCsv.Snapshot { + val memoryTimes = memory.sampleTimes() + val networkTimes = network.sampleTimes() + val powerTimes = power.sampleTimes() + val networkUsage = network.getUsage() + val powerUsage = power.getUsage() + + return MetricsCsv.Snapshot( + rowTimes = memoryTimes, + memory = + memory.getMemoryUsages().associate { process -> + process.pname to + MetricsCsv.Series( + times = memoryTimes, + values = process.usageHistory.toLongArray(), + since = process.watchedSinceMillis, + ) + }, + networkReceived = MetricsCsv.Series(networkTimes, networkUsage.received), + networkTransmitted = MetricsCsv.Series(networkTimes, networkUsage.transmitted), + temperature = MetricsCsv.Series(powerTimes, powerUsage.temperatureMilliCelsius), + power = MetricsCsv.Series(powerTimes, powerUsage.powerMicroWatts), + thermal = MetricsCsv.Series(powerTimes, powerUsage.thermalStatus), + annotations = markers(context, annotations), + ) + } + + /** + * The annotations, with their times moved onto the clock the samples carry. + * + * The store records on the monotonic clock and the samples on the wall clock, and the two are + * read here as close together as they can be so the offset between them is the right one. + */ + private fun markers( + context: Context, + annotations: MetricsAnnotationStore?, + ): List { + val store = annotations ?: return emptyList() + val nowEpoch = System.currentTimeMillis() + val nowMonotonic = SystemClock.elapsedRealtime() + return store.allAnnotations().map { annotation -> + MetricsCsv.Marker( + atMillis = MetricsCsv.epochFor(annotation.atMillis, nowEpoch, nowMonotonic), + label = annotation.kind.labelRes?.let(context::getString) ?: annotation.label, + kind = annotation.kind.name, + ) + } + } +} From 51a2b24b24f3077e63b770034e2c69d40931dce9 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 01:38:58 -0700 Subject: [PATCH 078/128] ADFA-5526: copy a history into a destination, and retain fewer samples Two changes that pay for each other. copyInto writes a ring buffer into an array the caller already owns. toLongArray now delegates to it. Snapshotting the watchers otherwise takes eleven fresh arrays, and the next commit needs a caller that allocates nothing at all -- a crash handler must not ask for memory, because the crash it is reporting may be the heap running out. It requires an exact-length destination: a short one truncates the history and a long one leaves a stale tail behind it, and both read as data. MAX_USAGE_ENTRIES drops from 10,000 to 3,600. Ten thousand samples is nearly three hours at the default rate, of which the chart shows sixty at a time, and eleven buffers of it is 859KB held for the life of the process. At 3,600 the live buffers plus the pre-allocated destinations cost 619KB together -- less than the live buffers alone did before. Note what the constant is not: it counts samples, not time, so at the fastest offered rate of 100ms it is six minutes rather than an hour. If the fast rates are meant to give hours of history, this is the wrong number, and it is one constant. Also relaxes readUsages to internal so a test can drive one sample without starting the sampling loop, matching what ADFA-5514 does to it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/utils/MemoryUsageWatcher.kt | 44 ++++++++++- .../androidide/utils/NetworkUsageWatcher.kt | 37 +++++++-- .../androidide/utils/PowerUsageWatcher.kt | 33 ++++++-- .../androidide/utils/ShiftedLongArray.kt | 21 ++++- .../utils/ShiftedLongArrayCopyIntoTest.kt | 79 +++++++++++++++++++ 5 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/ShiftedLongArrayCopyIntoTest.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 eeb47d03ce..567d11d670 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -157,11 +157,18 @@ class MemoryUsageWatcher } /** - * Samples retained per series: nearly three hours at [DEFAULT_UPDATE_INTERVAL] (ADFA-5486). - * About 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. + * Samples retained per series. + * + * An hour at [DEFAULT_UPDATE_INTERVAL], and the chart shows sixty of them at a time + * (ADFA-5486). It was 10,000, which is nearly three hours nobody was looking at -- and + * eleven buffers of that is 859KB held for the life of the process, doubled by the + * pre-allocated snapshot destinations [MetricsScratch] adds so a crash handler never has + * to allocate. At 3,600 the two together cost less than the one did (ADFA-5526). + * + * A count of samples, not a duration: at the fastest offered rate of 100ms it is six + * minutes rather than an hour. */ - const val MAX_USAGE_ENTRIES = 10000 + const val MAX_USAGE_ENTRIES = 3600 const val DEFAULT_UPDATE_INTERVAL = 1000L private val log = LoggerFactory.getLogger(MemoryUsageWatcher::class.java) } @@ -336,6 +343,32 @@ class MemoryUsageWatcher ) } + /** + * [history], into destinations the caller owns (ADFA-5526). + * + * Processes beyond the destinations given are dropped rather than allocated for -- the caller + * sized itself for [MetricsCsv.MEMORY_COLUMNS], which is every process the chart can plot. + */ + fun copyHistoryInto( + timesDest: LongArray, + destinations: List, + ): MemoryHistory = + synchronized(historyLock) { + MemoryHistory( + times = sampleTimes.copyInto(timesDest), + processes = + memoryUsage.values.take(destinations.size).mapIndexed { index, proc -> + ProcessHistory( + pid = proc.pid, + pname = proc.pname, + usage = proc._history.copyInto(destinations[index]), + watchedSinceMillis = proc.watchedSinceMillis, + ) + }, + ) + } + } + /** * Returns the memory usage of all the registered processes. */ @@ -412,6 +445,9 @@ class MemoryUsageWatcher /** * One process's retained samples, detached from the watcher. * + * Deliberately not [ProcessMemoryInfo], which carries a MemoryInfo and a ring buffer of its + * own and is what [getMemoryUsages] allocates. + * * @property usage The samples, oldest first, in bytes. * @property watchedSinceMillis When this process started being watched. Its buffer reaches * back to the start of the session however late in it the process appeared, and this is what 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 2166a87d74..0194ee904a 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -155,8 +155,27 @@ class NetworkUsageWatcher * the sampler thread is midway through appending, and the chart renderer reads all 30 entries. */ fun getUsage(): NetworkUsage = + copyUsageInto(LongArray(received.size), LongArray(transmitted.size), LongArray(sampleTimes.size)) + + /** + * [getUsage], into destinations the caller owns (ADFA-5526). + * + * The times come back with the values because they are read in the same critical section: + * asking separately let a sample land between the calls and shifted every value one index + * against its timestamp (ADFA-5531). + */ + fun copyUsageInto( + receivedDest: LongArray, + transmittedDest: LongArray, + timesDest: LongArray, + ): NetworkUsage = synchronized(historyLock) { - NetworkUsage(received.toLongArray(), transmitted.toLongArray(), sampleTimes.toLongArray()) + NetworkUsage( + received.copyInto(receivedDest), + transmitted.copyInto(transmittedDest), + sampleTimes.copyInto(timesDest), + ) + } } /** @@ -356,12 +375,18 @@ class NetworkUsageWatcher companion object { /** - * 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. + * Samples retained per series. + * + * An hour at [DEFAULT_UPDATE_INTERVAL], and the chart shows sixty of them at a time + * (ADFA-5486). It was 10,000, which is nearly three hours nobody was looking at -- and + * eleven buffers of that is 859KB held for the life of the process, doubled by the + * pre-allocated snapshot destinations [MetricsScratch] adds so a crash handler never has + * to allocate. At 3,600 the two together cost less than the one did (ADFA-5526). + * + * A count of samples, not a duration: at the fastest offered rate of 100ms it is six + * minutes rather than an hour. */ - const val MAX_USAGE_ENTRIES = 10000 + 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/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 3c38817837..4d94b102a7 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -140,14 +140,35 @@ class PowerUsageWatcher * live ring buffers would let a reader see them mid-append. */ fun getUsage(): PowerUsage = + copyUsageInto( + LongArray(temperature.size), + LongArray(power.size), + LongArray(thermal.size), + LongArray(sampleTimes.size), + ) + + /** + * [getUsage], into destinations the caller owns (ADFA-5526). + * + * The times come back with the values because they are read in the same critical section: + * asking separately let a sample land between the calls and shifted every value one index + * against its timestamp (ADFA-5531). + */ + fun copyUsageInto( + temperatureDest: LongArray, + powerDest: LongArray, + thermalDest: LongArray, + timesDest: LongArray, + ): PowerUsage = synchronized(historyLock) { PowerUsage( - temperature.toLongArray(), - power.toLongArray(), - thermal.toLongArray(), - sampleTimes.toLongArray(), + temperature.copyInto(temperatureDest), + power.copyInto(powerDest), + thermal.copyInto(thermalDest), + sampleTimes.copyInto(timesDest), ) } + } fun clearHistory() { synchronized(historyLock) { @@ -316,8 +337,8 @@ class PowerUsageWatcher } companion object { - /** Samples retained per series, matching the other watchers. */ - const val MAX_USAGE_ENTRIES = 10000 + /** Samples retained per series, matching the other watchers (ADFA-5526). */ + const val MAX_USAGE_ENTRIES = 3600 const val DEFAULT_UPDATE_INTERVAL = 1000L /** A reading the device does not provide. */ diff --git a/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt b/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt index 6392e21f7c..36cf8ba013 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt @@ -134,4 +134,23 @@ open class ShiftedLongArray( * Shared so the watchers' snapshots cannot drift from [ShiftedLongArray]'s shift semantics; each * of them had its own private copy of this one line. */ -internal fun ShiftedLongArray.toLongArray(): LongArray = LongArray(size) { this[it] } +internal fun ShiftedLongArray.toLongArray(): LongArray = copyInto(LongArray(size)) + +/** + * Copies this ring buffer into [dest] in logical order, oldest first, and returns it. + * + * For a caller that owns its destination already. A crash handler must not allocate -- the crash it + * is reporting may be the heap running out -- so ADFA-5526 pre-allocates one set of destinations at + * startup and fills them here instead of taking eleven fresh arrays per snapshot. + * + * @throws IllegalArgumentException when [dest] is not exactly this buffer's length. A short + * destination would silently truncate the history and a long one would leave a stale tail behind + * it, and both read as data. + */ +internal fun ShiftedLongArray.copyInto(dest: LongArray): LongArray { + require(dest.size == size) { "Destination is ${dest.size} long, buffer is $size" } + for (i in 0 until size) { + dest[i] = this[i] + } + return dest +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/ShiftedLongArrayCopyIntoTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ShiftedLongArrayCopyIntoTest.kt new file mode 100644 index 0000000000..2ba1d776cc --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ShiftedLongArrayCopyIntoTest.kt @@ -0,0 +1,79 @@ +/* + * 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 +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** Copying a ring buffer into a destination the caller owns (ADFA-5526). */ +@RunWith(JUnit4::class) +class ShiftedLongArrayCopyIntoTest { + private fun buffer(): MutableShiftedLongArray { + val buffer = MutableShiftedLongArray(4) + // Appended the way the watchers append: newest in at 0, then shift. + listOf(10L, 20L, 30L).forEach { value -> + buffer[0] = value + buffer.shift(1) + } + return buffer + } + + @Test + fun `it writes the same order toLongArray produces`() { + val buffer = buffer() + + val dest = LongArray(buffer.size) + assertThat(buffer.copyInto(dest).toList()).isEqualTo(buffer.toLongArray().toList()) + } + + @Test + fun `it returns the destination it was given, not a copy`() { + val buffer = buffer() + val dest = LongArray(buffer.size) + + // The whole point: the caller pre-allocated this, so nothing new may be handed back. + assertThat(buffer.copyInto(dest)).isSameInstanceAs(dest) + } + + @Test + fun `a destination of the wrong length is refused`() { + val buffer = buffer() + + // A short destination truncates the history and a long one leaves a stale tail behind it, + // and both read as data. Better to fail where the mistake is than to file a wrong graph. + listOf(LongArray(buffer.size - 1), LongArray(buffer.size + 1)).forEach { wrong -> + val failure = runCatching { buffer.copyInto(wrong) }.exceptionOrNull() + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + } + } + + @Test + fun `a second copy overwrites the first, leaving nothing of it`() { + val dest = LongArray(4) + buffer().copyInto(dest) + + val fresh = MutableShiftedLongArray(4) + fresh.copyInto(dest) + + // The scratch is reused across snapshots, so a stale value surviving into the next one + // would be reported as a measurement. + assertThat(dest.toList()).containsExactly(0L, 0L, 0L, 0L) + } +} From 766d0ff5a6b2adec861954a6455a0c4f080e038d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 00:55:39 -0700 Subject: [PATCH 079/128] ADFA-5534: a gzipped copy of the metrics file, and a way to ask if it is empty Two additions to the format, both for callers that send the file rather than hand it to the user. hasRows answers "is this worth sending". The writer always produces a file, header included, because the export button asked for one whatever the buffers held -- rule (d), now spelled out on ADFA-5531. Sending one is a different question, and this ticket omits the attachment when there is nothing in it rather than posting an empty file. writeForReport writes the same CSV gzipped. Streamed through GZIPOutputStream, so the uncompressed megabyte never has to exist. Gzip rather than zip: it is one file, so an archive container adds a name and nothing else, and java.util.zip gives us either with no dependency. The repo had no compression of any kind before this; ADFA-5526 says "compress, as this ticket does", so this is where that gets decided. It writes to its own directory. The exports prune to three, and a feedback send must not evict an export the user is part-way through handing to another app. Measured on a real 86-row attachment: 6425 bytes to 1395, 4.6x. The rows are near-identical by nature -- the timestamp advances by a constant and the magnitudes barely move -- so a full buffer does better. A test pins the ratio at better than 4x, so that if it ever stops being true the extra step gets questioned rather than kept out of habit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../com/itsaky/androidide/utils/MetricsCsv.kt | 13 +- .../itsaky/androidide/utils/MetricsCsvFile.kt | 52 +++++++- .../androidide/utils/MetricsCsvFileTest.kt | 120 ++++++++++++++++++ .../itsaky/androidide/utils/MetricsCsvTest.kt | 9 ++ 4 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt index c52891e086..87aa13d81f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt @@ -144,7 +144,18 @@ object MetricsCsv { val power: Series = Series.EMPTY, val thermal: Series = Series.EMPTY, val annotations: List = emptyList(), - ) + ) { + /** + * Whether this snapshot has any sample to report. + * + * [write] always produces a file, header included, because the export button was asked for + * one whatever the state of the buffers. Sending one is a different question: ADFA-5534 + * attaches the file to feedback only when there is something in it, rather than posting an + * empty attachment, and ADFA-5526 will want the same of a crash report. This is how a caller + * asks. + */ + val hasRows: Boolean get() = rowTimes.any { it != NO_SAMPLE } + } /** * Writes [snapshot] to [out], timestamps in [zone]. diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt index 81310f3182..1d1c5fdc9d 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt @@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory import java.io.File import java.io.IOException import java.time.ZoneId +import java.util.zip.GZIPOutputStream /** * Writes a [MetricsCsv.Snapshot] to a file the IDE can share (ADFA-5531). @@ -36,6 +37,20 @@ object MetricsCsvFile { private const val DIRECTORY = "metrics-exports" + /** + * Where a file written for a report goes, rather than for the user. + * + * Separate from the exports because they prune independently: a feedback send must not evict an + * export the user is about to hand to another app (ADFA-5534). + */ + private const val REPORT_DIRECTORY = "metrics-reports" + + /** Gzip, not zip: one file, so an archive container adds a name and nothing else. */ + private const val COMPRESSED_EXTENSION = "csv.gz" + + /** Media type for a compressed file. */ + const val COMPRESSED_MIME_TYPE = "application/gzip" + /** * How many exports to keep. * @@ -54,19 +69,44 @@ object MetricsCsvFile { snapshot: MetricsCsv.Snapshot, nowMillis: Long = System.currentTimeMillis(), zone: ZoneId = ZoneId.systemDefault(), + ): File? = write(context, snapshot, DIRECTORY, compress = false, nowMillis, zone) + + /** + * Writes [snapshot] gzipped, for attaching to a report, or `null` if it could not be written. + * + * Compressed because it travels: a full buffer is around a megabyte of text and it is highly + * compressible -- the timestamps advance by a constant and the magnitudes barely move -- so this + * is a large saving on an email attachment for no loss (ADFA-5534, and ADFA-5526 to come). + */ + fun writeForReport( + context: Context, + snapshot: MetricsCsv.Snapshot, + nowMillis: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + ): File? = write(context, snapshot, REPORT_DIRECTORY, compress = true, nowMillis, zone) + + private fun write( + context: Context, + snapshot: MetricsCsv.Snapshot, + directoryName: String, + compress: Boolean, + nowMillis: Long, + zone: ZoneId, ): File? { - val directory = File(context.cacheDir, DIRECTORY) + val directory = File(context.cacheDir, directoryName) return try { if (!directory.exists() && !directory.mkdirs()) { log.error("Could not create the metrics export directory at {}", directory) return null } - val file = File(directory, MetricsFileName.forTime(nowMillis, "csv", zone)) - // Buffered and streamed rather than built into a string: a full buffer is ten thousand - // rows, and holding the whole file in memory to write it is a megabyte of char array - // the export does not need. - file.bufferedWriter().use { writer -> + val extension = if (compress) COMPRESSED_EXTENSION else "csv" + val file = File(directory, MetricsFileName.forTime(nowMillis, extension, zone)) + // Streamed, not built into a string: a full buffer is ten thousand rows, and holding the + // whole file in memory to write it is a megabyte of char array nobody needs. Compressed + // on the way out for the same reason -- the uncompressed file never has to exist. + val sink = if (compress) GZIPOutputStream(file.outputStream()) else file.outputStream() + sink.bufferedWriter().use { writer -> MetricsCsv.write(snapshot, zone, writer) } MetricsSnapshot.pruneTo(directory, KEEP_RECENT, file) diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt new file mode 100644 index 0000000000..266849503d --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt @@ -0,0 +1,120 @@ +/* + * 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 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 +import java.time.ZoneId +import java.util.zip.GZIPInputStream + +/** + * The two files the metrics format is written to: the user's export, and the compressed copy that + * travels with a report (ADFA-5534). + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCsvFileTest { + private val context = ApplicationProvider.getApplicationContext() + + /** + * Fixed, not the machine's own. + * + * The names below are a rendering of [AT] in a particular zone, so leaving the zone to the + * default made them pass here and fail wherever CI happens to be. + */ + private val zone: ZoneId = ZoneId.of("America/Los_Angeles") + + private fun snapshot(rows: Int): MetricsCsv.Snapshot { + val times = LongArray(rows) { AT + it * 1_000L } + return MetricsCsv.Snapshot( + rowTimes = times, + memory = mapOf("IDE" to MetricsCsv.Series(times, LongArray(rows) { 600_000_000L + it })), + ) + } + + @Test + fun `an export is plain csv the user can open`() { + val file = MetricsCsvFile.write(context, snapshot(3), AT, zone)!! + + assertThat(file.name).isEqualTo("2026_09_06_22_33_40_123.csv") + assertThat(file.readText().lineSequence().first()).startsWith("\"timestamp\"") + } + + @Test + fun `a report copy is gzipped, and unzips to the same csv`() { + val plain = MetricsCsvFile.write(context, snapshot(50), AT, zone)!!.readText() + val compressed = MetricsCsvFile.writeForReport(context, snapshot(50), AT, zone)!! + + assertThat(compressed.name).isEqualTo("2026_09_06_22_33_40_123.csv.gz") + val unzipped = GZIPInputStream(compressed.inputStream()).bufferedReader().use { it.readText() } + assertThat(unzipped).isEqualTo(plain) + } + + @Test + fun `compressing is worth doing`() { + // The rows are near-identical by nature -- the timestamp advances by a constant and the + // magnitudes barely move -- so this travels far smaller than it reads. If that ever stops + // being true, the compression is buying nothing and the extra step should go. + val plain = MetricsCsvFile.write(context, snapshot(500), AT, zone)!!.length() + val compressed = MetricsCsvFile.writeForReport(context, snapshot(500), AT, zone)!!.length() + + assertThat(compressed).isLessThan(plain / 4) + } + + @Test + fun `a report copy does not evict the user's exports`() { + // They prune independently. A feedback send must not delete an export the user is part-way + // through handing to another app. + val export = MetricsCsvFile.write(context, snapshot(2), AT, zone)!! + repeat(MetricsCsvFile.KEEP_RECENT + 3) { i -> + MetricsCsvFile.writeForReport(context, snapshot(2), AT + i + 1L, zone) + } + + assertThat(export.exists()).isTrue() + assertThat(export.parentFile).isNotEqualTo( + MetricsCsvFile.writeForReport(context, snapshot(2), AT + 99L, zone)!!.parentFile, + ) + } + + @Test + fun `both directories stay bounded`() { + repeat(20) { i -> MetricsCsvFile.write(context, snapshot(2), AT + i.toLong(), zone) } + repeat(20) { i -> MetricsCsvFile.writeForReport(context, snapshot(2), AT + i.toLong(), zone) } + + val exports = MetricsCsvFile.write(context, snapshot(2), AT + 500L, zone)!!.parentFile!! + val reports = MetricsCsvFile.writeForReport(context, snapshot(2), AT + 500L, zone)!!.parentFile!! + assertThat(exports.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) + assertThat(reports.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) + } + + @Test + fun `files land under the cache, which the platform may reclaim`() { + val file: File = MetricsCsvFile.writeForReport(context, snapshot(2), AT, zone)!! + + assertThat(file.absolutePath).startsWith(context.cacheDir.absolutePath) + } + + private companion object { + /** 2026-09-06T22:33:40.123 local. */ + const val AT = 1_788_759_220_123L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt index f44619461a..610e3d3570 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt @@ -258,6 +258,15 @@ class MetricsCsvTest { assertThat(cellsIn(lines[3])[column]).isEmpty() } + @Test + fun `hasRows tells a caller whether the file is worth sending`() { + // The writer always produces a file, header included, because the export button asked for + // one. Attaching it to feedback is a different question (ADFA-5534): an empty attachment on + // a report from a freshly started IDE is worse than no attachment. + assertThat(snapshot(rowTimes = LongArray(8)).hasRows).isFalse() + assertThat(snapshot(rowTimes = longArrayOf(0L, 0L, T0, 0L)).hasRows).isTrue() + } + @Test fun `the memory columns are the three the chart can plot`() { // Fixed, not derived from what is being watched: the set changes mid-session, and a header From e4c43d8be7f9a677638bc80f2c7f34919da3fa6f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 01:39:23 -0700 Subject: [PATCH 080/128] ADFA-5526: pre-allocate the snapshot destinations, and make them reachable A crash handler is the wrong place to ask for memory. The crash being reported may be the heap running out, and a handler that throws replaces a useful report with no report. So the eleven destinations a snapshot needs are taken at startup, where failing to get them is survivable and obvious, and filled rather than allocated when a snapshot is taken. MetricsScratch is single-use at a time rather than thread-confined: claim hands it to one caller and release gives it back. A caller that cannot claim it allocates for itself instead of waiting, because two writers into one array is a scrambled file and a crash must not block on an export. withSnapshot scopes the claim to a block, because the snapshot points into the scratch and so the scratch has to outlive whatever reads it -- which is a file write, not the call that assembled it. MetricsSource is the other half: the watchers live in an activity-scoped ViewModel, which is right for the carousel and no use to a crash handler that has no activity and arrives on whatever thread threw. The ViewModel registers itself and clears on onCleared, conditionally, since an activity recreation can register the replacement before the outgoing one is cleared. The assembler's @UiThread comes off. Every read it makes takes the watcher's own history lock, so it was always safe from any thread; the annotation was conservative rather than load-bearing, and a crash does not get to choose its thread. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../itsaky/androidide/utils/MetricsScratch.kt | 95 +++++++++++++++++++ .../utils/MetricsSnapshotAssembler.kt | 67 +++++++++++-- .../itsaky/androidide/utils/MetricsSource.kt | 59 ++++++++++++ .../androidide/viewmodel/MetricsViewModel.kt | 18 +++- .../androidide/utils/MetricsScratchTest.kt | 92 ++++++++++++++++++ 5 files changed, 319 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsSource.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt new file mode 100644 index 0000000000..df7a145159 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.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 androidx.annotation.VisibleForTesting +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Destinations for one metrics snapshot, allocated once so that taking one needs no memory. + * + * A crash handler is the wrong place to ask for memory: the crash being reported may be the heap + * running out, and a handler that throws replaces a useful report with a useless one. Snapshotting + * the watchers otherwise takes eleven fresh arrays -- around 300KB at the retained length -- so the + * arrays are taken at startup instead, when failing to get them is survivable and obvious. + * + * Held for the life of the process, which is the trade: this is memory reserved against a crash that + * may never come, in a process that is already a fat target for the low-memory killer. It is paid + * for by [MemoryUsageWatcher.MAX_USAGE_ENTRIES] coming down at the same time -- the live buffers plus + * these cost less than the live buffers alone did before (ADFA-5526). + * + * Not thread-confined but single-use at a time: [claim] hands it to one caller and [release] gives it + * back. A caller that cannot claim it allocates for itself rather than waiting or sharing, because + * two writers into one array is a scrambled file and a crash must not block on an export. + */ +class MetricsScratch( + @VisibleForTesting internal val entries: Int, + memorySeries: Int, +) { + private val inUse = AtomicBoolean(false) + + val memoryTimes = LongArray(entries) + val memoryValues: List = List(memorySeries) { LongArray(entries) } + val networkTimes = LongArray(entries) + val networkReceived = LongArray(entries) + val networkTransmitted = LongArray(entries) + val powerTimes = LongArray(entries) + val temperature = LongArray(entries) + val power = LongArray(entries) + val thermal = LongArray(entries) + + /** Takes this scratch, or returns false if something else already has it. */ + fun claim(): Boolean = inUse.compareAndSet(false, true) + + fun release() { + inUse.set(false) + } + + companion object { + /** + * The process-wide scratch, or `null` before [install] or if it could not be allocated. + * + * A crash arrives on whatever thread threw, from anywhere in the process, so this cannot + * live on an activity-scoped ViewModel the way the watchers do. + */ + @Volatile + var instance: MetricsScratch? = null + private set + + /** + * Allocates the process-wide scratch. Call once, from application startup. + * + * Failure is not fatal and not worth retrying: the crash path simply allocates for itself, + * which is what it did before this existed. + */ + fun install( + entries: Int = MemoryUsageWatcher.MAX_USAGE_ENTRIES, + memorySeries: Int = MetricsCsv.MEMORY_COLUMNS.size, + ) { + if (instance != null) { + return + } + instance = runCatching { MetricsScratch(entries, memorySeries) }.getOrNull() + } + + @VisibleForTesting + internal fun resetForTesting() { + instance = null + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt index 31c50a0bcf..9d37c36566 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt @@ -19,7 +19,7 @@ package com.itsaky.androidide.utils import android.content.Context import android.os.SystemClock -import androidx.annotation.UiThread +import androidx.annotation.AnyThread /** * Reads the watchers' buffers into a [MetricsCsv.Snapshot]. @@ -29,7 +29,9 @@ import androidx.annotation.UiThread * the file: the feedback FAB can be tapped with the strip closed (ADFA-5534), and a crash report is * assembled with no UI at all (ADFA-5526). * - * All of it must be read on the UI thread; formatting and writing must not be. + * Every read here takes the watcher's own history lock, so this is safe from any thread -- which it + * has to be, because a crash arrives on whatever thread threw (ADFA-5526). Formatting and writing + * are a different matter and must stay off the main thread. */ object MetricsSnapshotAssembler { /** @@ -44,21 +46,72 @@ object MetricsSnapshotAssembler { * @param context resolves an annotation's label, which a build outcome carries as a string id * so its marker follows the system language. */ - @UiThread + @AnyThread fun assemble( context: Context, memory: MemoryUsageWatcher, network: NetworkUsageWatcher, power: PowerUsageWatcher, annotations: MetricsAnnotationStore?, + ): MetricsCsv.Snapshot = assemble(context, memory, network, power, annotations, scratch = null) + + /** + * Assembles a snapshot, hands it to [block], and only then gives the scratch back. + * + * The snapshot points *into* the scratch, so the scratch cannot be released when this returns -- + * it has to outlive whatever reads the snapshot, which is a file write. Scoping it to a block is + * how that is made hard to get wrong. + * + * Falls back to allocating when the scratch is already taken. A crash must not wait on an export, + * and two writers into one array is a scrambled file. + */ + @AnyThread + fun withSnapshot( + context: Context, + memory: MemoryUsageWatcher, + network: NetworkUsageWatcher, + power: PowerUsageWatcher, + annotations: MetricsAnnotationStore?, + block: (MetricsCsv.Snapshot) -> T, + ): T { + val scratch = MetricsScratch.instance?.takeIf { it.claim() } + return try { + block(assemble(context, memory, network, power, annotations, scratch)) + } finally { + scratch?.release() + } + } + + private fun assemble( + context: Context, + memory: MemoryUsageWatcher, + network: NetworkUsageWatcher, + power: PowerUsageWatcher, + annotations: MetricsAnnotationStore?, + scratch: MetricsScratch?, ): MetricsCsv.Snapshot { // One call per watcher, not one per array. Each hands back its times and its values from a // single critical section, which is what keeps a row of the file a single moment: asking // separately let a sample land between the two calls, and every value came out one row off - // its own timestamp. - val memoryHistory = memory.history() - val networkUsage = network.getUsage() - val powerUsage = power.getUsage() + // its own timestamp (ADFA-5531). + val memoryHistory = + if (scratch == null) { + memory.history() + } else { + memory.copyHistoryInto(scratch.memoryTimes, scratch.memoryValues) + } + val networkUsage = + if (scratch == null) { + network.getUsage() + } else { + network.copyUsageInto(scratch.networkReceived, scratch.networkTransmitted, scratch.networkTimes) + } + val powerUsage = + if (scratch == null) { + power.getUsage() + } else { + power.copyUsageInto(scratch.temperature, scratch.power, scratch.thermal, scratch.powerTimes) + } return MetricsCsv.Snapshot( rowTimes = memoryHistory.times, diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSource.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSource.kt new file mode 100644 index 0000000000..d84e9e19f3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSource.kt @@ -0,0 +1,59 @@ +/* + * 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 + +/** + * Where a process-wide caller finds the live metrics watchers (ADFA-5526). + * + * The watchers belong to an activity-scoped ViewModel, which is right for the carousel and no use to + * a crash handler: a crash arrives on any thread, from anywhere, with no activity in hand. This is + * the one indirection that lets the handler reach them. + * + * Deliberately thin, and deliberately nullable. There is no source before the editor has run -- a + * crash during onboarding, in the project chooser, or in direct boot has no history to report -- and + * a caller that cannot find one attaches nothing rather than inventing something. + */ +object MetricsSource { + /** What a crash handler needs to build a snapshot. */ + interface Metrics { + val memoryUsageWatcher: MemoryUsageWatcher + val networkUsageWatcher: NetworkUsageWatcher + val powerUsageWatcher: PowerUsageWatcher + val annotations: MetricsAnnotationStore + } + + @Volatile + var current: Metrics? = null + private set + + fun register(metrics: Metrics) { + current = metrics + } + + /** + * Clears [current] if [metrics] is still the registered one. + * + * Conditional because an activity recreation can register the replacement before the outgoing + * one is cleared, and an unconditional clear would then drop the live source. + */ + fun unregister(metrics: Metrics) { + if (current === metrics) { + current = null + } + } +} 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 2400fe4cc5..3a511114d5 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.AndroidViewModel import com.itsaky.androidide.utils.DevicePowerSource import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsSource import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.PowerUsageWatcher @@ -39,22 +40,29 @@ import com.itsaky.androidide.utils.PowerUsageWatcher */ class MetricsViewModel( application: Application, -) : AndroidViewModel(application) { - val memoryUsageWatcher = MemoryUsageWatcher() +) : AndroidViewModel(application), + MetricsSource.Metrics { + override val memoryUsageWatcher = MemoryUsageWatcher() - val networkUsageWatcher = NetworkUsageWatcher() + override val networkUsageWatcher = NetworkUsageWatcher() /** * Temperature and power (ADFA-5499). Needs a Context for the battery broadcast, which is why * this is an AndroidViewModel. */ - val powerUsageWatcher = PowerUsageWatcher(source = DevicePowerSource(application)) + override val powerUsageWatcher = PowerUsageWatcher(source = DevicePowerSource(application)) /** Significant events for the charts to annotate (ADFA-5486). */ - val annotations = MetricsAnnotationStore() + override val annotations = MetricsAnnotationStore() + + init { + // So a crash handler can reach the history (ADFA-5526). It has no activity to ask. + MetricsSource.register(this) + } override fun onCleared() { super.onCleared() + MetricsSource.unregister(this) // 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() diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt new file mode 100644 index 0000000000..9bbbed0a13 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt @@ -0,0 +1,92 @@ +/* + * 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.After +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * The destinations a crash handler snapshots into, so that it never has to allocate (ADFA-5526). + */ +@RunWith(JUnit4::class) +class MetricsScratchTest { + @After + fun tearDown() = MetricsScratch.resetForTesting() + + @Test + fun `one claim at a time`() { + val scratch = MetricsScratch(entries = 4, memorySeries = 2) + + assertThat(scratch.claim()).isTrue() + // Two writers into one array is a scrambled file, so the second caller is refused and + // allocates for itself rather than waiting -- a crash must not block on an export. + assertThat(scratch.claim()).isFalse() + + scratch.release() + assertThat(scratch.claim()).isTrue() + } + + @Test + fun `every destination is the retained length`() { + val scratch = MetricsScratch(entries = 7, memorySeries = 3) + + // copyInto requires an exact-length destination, so a mismatch here is a crash-time failure. + val all = + listOf( + scratch.memoryTimes, + scratch.networkTimes, + scratch.networkReceived, + scratch.networkTransmitted, + scratch.powerTimes, + scratch.temperature, + scratch.power, + scratch.thermal, + ) + scratch.memoryValues + all.forEach { assertThat(it.size).isEqualTo(7) } + assertThat(scratch.memoryValues).hasSize(3) + } + + @Test + fun `installing is idempotent, so a second call keeps the first arrays`() { + MetricsScratch.install(entries = 4, memorySeries = 1) + val first = MetricsScratch.instance + + MetricsScratch.install(entries = 99, memorySeries = 1) + + // Replacing it would hand a second set of destinations to whoever already held the first. + assertThat(MetricsScratch.instance).isSameInstanceAs(first) + assertThat(MetricsScratch.instance!!.entries).isEqualTo(4) + } + + @Test + fun `there is no scratch until it is installed`() { + assertThat(MetricsScratch.instance).isNull() + } + + @Test + fun `the default size matches the retained history`() { + MetricsScratch.install() + + // If these drift apart, copyInto throws at crash time -- exactly when nothing may throw. + assertThat(MetricsScratch.instance!!.entries).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(MetricsScratch.instance!!.memoryValues).hasSize(MetricsCsv.MEMORY_COLUMNS.size) + } +} From 77db440319778eb871f894ee701dd8f0025c0e79 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 00:55:40 -0700 Subject: [PATCH 081/128] ADFA-5534: attach the metrics file to feedback "It got slow" arrives with nothing to correlate it against. The session's own samples turn it into something diagnosable. The feedback path lives in common and the metrics live in app, which depends on common and not the other way round, so the feedback code cannot reach the metrics. It takes a provider instead, mirroring the getLogContent hook already there for the log. Suspending, unlike that one: the snapshot has to be read on the main thread and the compressed file written off it, and neither belongs in a click listener. The provider returns null when nothing has been sampled, so feedback from a freshly started IDE carries no empty attachment. It is also guarded -- feedback about a broken IDE has to send even if this part fails. FeedbackEmailHandler already built ACTION_SEND_MULTIPLE from a URI list and fell back to ACTION_SENDTO on an empty one, so a third attachment is one more URI. FeedbackButtonManager's constructor gains @JvmOverloads, because TermuxActivity constructs it from Java where Kotlin's default arguments are invisible. Verified on a Pixel 6 Pro: the send writes 2026_09_07_00_52_54_706.csv.gz and the chooser opens with "clip={message/rfc822 3 items}", the URI grants naming the metrics file, the log and the screenshot. The attachment gunzips to 86 valid rows. The empty case is unit-tested rather than device-tested: clearing the buffers via a rate change is the only way to reach it, and the next sample lands about a second later, so the window is shorter than a tap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../activities/editor/BaseEditorActivity.kt | 33 +++ .../androidide/FeedbackButtonManager.kt | 220 ++++++++++-------- .../androidide/utils/FeedbackEmailHandler.kt | 4 + .../androidide/utils/FeedbackManager.kt | 13 +- 4 files changed, 167 insertions(+), 103 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 ea101b159d..42cd97b2d1 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 @@ -130,6 +130,8 @@ import com.itsaky.androidide.utils.InstallationResultHandler.onResult import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsCsvFile +import com.itsaky.androidide.utils.MetricsSnapshotAssembler import com.itsaky.androidide.utils.StringsInjectionException import com.itsaky.androidide.utils.StringsXmlInjector import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation @@ -209,6 +211,36 @@ abstract class BaseEditorActivity : ) } + /** + * The metrics file to attach to feedback, or `null` when there is nothing to say (ADFA-5534). + * + * A report of "it got slow" arrives with no way to correlate it against anything; the session's + * own samples turn that into something diagnosable. + * + * Assembled on the main thread because it reads the watchers' buffers, then written off it: the + * file is up to a megabyte and it is gzipped on the way out. Returns null when nothing has been + * sampled, so feedback sent from a freshly started IDE carries no empty attachment -- the writer + * would happily produce a header-only file, and sending one is the caller's decision, not its. + */ + private suspend fun metricsAttachmentForFeedback(): File? { + val snapshot = + withContext(Dispatchers.Main.immediate) { + MetricsSnapshotAssembler.assemble( + context = this@BaseEditorActivity, + memory = memoryUsageWatcher, + network = networkUsageWatcher, + power = powerUsageWatcher, + annotations = metricsViewModel.annotations, + ) + } + if (!snapshot.hasRows) { + return null + } + return withContext(Dispatchers.IO) { + MetricsCsvFile.writeForReport(applicationContext, snapshot) + } + } + /** Records a significant event for the charts to annotate (ADFA-5486). */ fun recordMetricsAnnotation(label: String) { metricsViewModel.annotations.record(label) @@ -907,6 +939,7 @@ abstract class BaseEditorActivity : activity = this, feedbackFab = binding.fabFeedback.root, getLogContent = ::getLogContent, + getMetricsAttachment = ::metricsAttachmentForFeedback, ) feedbackButtonManager?.setupDraggableFab() diff --git a/common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt b/common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt index 7992626eda..e0e954cb0f 100644 --- a/common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt +++ b/common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt @@ -16,105 +16,121 @@ import kotlinx.coroutines.launch * Uses normalized ratios instead of absolute coordinates to keep the FAB correctly * positioned across layout size changes (e.g. resizing, multi-window, DeX). */ -class FeedbackButtonManager( - private val activity: AppCompatActivity, - private val feedbackFab: FloatingActionButton?, - private val getLogContent: (() -> String?)? = null, -) { - private val repository = FabPositionRepository(activity.applicationContext) - private val calculator = FabPositionCalculator() - - // This function is called in the onCreate method of the activity that contains the FAB - fun setupDraggableFab() { - val fab = feedbackFab ?: return - loadFabPosition() - setupLayoutChangeListener(fab) - setupTouchAndClickListeners(fab) - } - - // Called in onResume for returning activities to reload FAB position - fun loadFabPosition() { - val fab = feedbackFab ?: return - activity.lifecycleScope.launch { - val (xRatio, yRatio) = repository.readPositionRatios() - if (xRatio == -1f || yRatio == -1f) return@launch - - fab.post { applySavedPosition(fab, xRatio, yRatio) } - } - } - - private fun applySavedPosition(fab: FloatingActionButton, xRatio: Float, yRatio: Float) { - val parentView = fab.parent as? ViewGroup ?: return - val safeBounds = calculator.getSafeDraggingBounds(parentView, fab) - val availableWidth = (safeBounds.right - safeBounds.left).toFloat() - val availableHeight = (safeBounds.bottom - safeBounds.top).toFloat() - - val x = calculator.fromRatio(xRatio, safeBounds.left, availableWidth) - val y = calculator.fromRatio(yRatio, safeBounds.top, availableHeight) - val (validX, validY) = calculator.validateAndCorrectPosition(x, y, parentView, fab) - - fab.x = validX - fab.y = validY - - if (validX != x || validY != y) { - saveFabPosition(fab, validX, validY) - } - } - - private fun setupLayoutChangeListener(fab: FloatingActionButton) { - fab.post { - val parentView = fab.parent as? ViewGroup ?: return@post - - parentView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> - val newWidth = right - left - val newHeight = bottom - top - val oldWidth = oldRight - oldLeft - val oldHeight = oldBottom - oldTop - if (newWidth != oldWidth || newHeight != oldHeight) { - loadFabPosition() - } - } - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun setupTouchAndClickListeners(fab: FloatingActionButton) { - val touchListener = DraggableTouchListener( - context = activity, - calculator = calculator, - onSavePosition = { x, y -> saveFabPosition(fab, x, y) }, - onShowTooltip = { showTooltip(fab) } - ) - - fab.setOnTouchListener(touchListener) - fab.setOnClickListener { performFeedbackAction() } - } - - private fun saveFabPosition(fab: FloatingActionButton, x: Float, y: Float) { - val parentView = fab.parent as? ViewGroup ?: return - // Get safe dragging bounds that account for system UI - val safeBounds = calculator.getSafeDraggingBounds(parentView, fab) - val availableWidth = (safeBounds.right - safeBounds.left).toFloat() - val availableHeight = (safeBounds.bottom - safeBounds.top).toFloat() - - val xRatio = calculator.toRatio(x, safeBounds.left, availableWidth) - val yRatio = calculator.toRatio(y, safeBounds.top, availableHeight) - - repository.savePositionRatios(xRatio, yRatio) - } - - private fun showTooltip(fab: FloatingActionButton) { - TooltipManager.showIdeCategoryTooltip( - context = activity, - anchorView = fab, - tag = TooltipTag.FEEDBACK, - ) - } - - private fun performFeedbackAction() { - FeedbackManager.showFeedbackDialog( - activity = activity, - logContent = getLogContent?.invoke() - ) - } -} +class FeedbackButtonManager + // Java callers construct this positionally (TermuxActivity), and Kotlin's default arguments are + // invisible from Java, so the shorter forms have to be generated. + @JvmOverloads + constructor( + private val activity: AppCompatActivity, + private val feedbackFab: FloatingActionButton?, + private val getLogContent: (() -> String?)? = null, + /** The metrics file to attach, or null for none. Suspending: it writes a file (ADFA-5534). */ + private val getMetricsAttachment: (suspend () -> java.io.File?)? = null, + ) { + private val repository = FabPositionRepository(activity.applicationContext) + private val calculator = FabPositionCalculator() + + // This function is called in the onCreate method of the activity that contains the FAB + fun setupDraggableFab() { + val fab = feedbackFab ?: return + loadFabPosition() + setupLayoutChangeListener(fab) + setupTouchAndClickListeners(fab) + } + + // Called in onResume for returning activities to reload FAB position + fun loadFabPosition() { + val fab = feedbackFab ?: return + activity.lifecycleScope.launch { + val (xRatio, yRatio) = repository.readPositionRatios() + if (xRatio == -1f || yRatio == -1f) return@launch + + fab.post { applySavedPosition(fab, xRatio, yRatio) } + } + } + + private fun applySavedPosition( + fab: FloatingActionButton, + xRatio: Float, + yRatio: Float, + ) { + val parentView = fab.parent as? ViewGroup ?: return + val safeBounds = calculator.getSafeDraggingBounds(parentView, fab) + val availableWidth = (safeBounds.right - safeBounds.left).toFloat() + val availableHeight = (safeBounds.bottom - safeBounds.top).toFloat() + + val x = calculator.fromRatio(xRatio, safeBounds.left, availableWidth) + val y = calculator.fromRatio(yRatio, safeBounds.top, availableHeight) + val (validX, validY) = calculator.validateAndCorrectPosition(x, y, parentView, fab) + + fab.x = validX + fab.y = validY + + if (validX != x || validY != y) { + saveFabPosition(fab, validX, validY) + } + } + + private fun setupLayoutChangeListener(fab: FloatingActionButton) { + fab.post { + val parentView = fab.parent as? ViewGroup ?: return@post + + parentView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + val newWidth = right - left + val newHeight = bottom - top + val oldWidth = oldRight - oldLeft + val oldHeight = oldBottom - oldTop + if (newWidth != oldWidth || newHeight != oldHeight) { + loadFabPosition() + } + } + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupTouchAndClickListeners(fab: FloatingActionButton) { + val touchListener = + DraggableTouchListener( + context = activity, + calculator = calculator, + onSavePosition = { x, y -> saveFabPosition(fab, x, y) }, + onShowTooltip = { showTooltip(fab) }, + ) + + fab.setOnTouchListener(touchListener) + fab.setOnClickListener { performFeedbackAction() } + } + + private fun saveFabPosition( + fab: FloatingActionButton, + x: Float, + y: Float, + ) { + val parentView = fab.parent as? ViewGroup ?: return + // Get safe dragging bounds that account for system UI + val safeBounds = calculator.getSafeDraggingBounds(parentView, fab) + val availableWidth = (safeBounds.right - safeBounds.left).toFloat() + val availableHeight = (safeBounds.bottom - safeBounds.top).toFloat() + + val xRatio = calculator.toRatio(x, safeBounds.left, availableWidth) + val yRatio = calculator.toRatio(y, safeBounds.top, availableHeight) + + repository.savePositionRatios(xRatio, yRatio) + } + + private fun showTooltip(fab: FloatingActionButton) { + TooltipManager.showIdeCategoryTooltip( + context = activity, + anchorView = fab, + tag = TooltipTag.FEEDBACK, + ) + } + + private fun performFeedbackAction() { + FeedbackManager.showFeedbackDialog( + activity = activity, + logContent = getLogContent?.invoke(), + metricsAttachment = getMetricsAttachment, + ) + } + } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt index 19e208e1c8..969d164336 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt @@ -137,10 +137,14 @@ class FeedbackEmailHandler( emailRecipient: String, subject: String, body: String, + metricsUri: Uri? = null, ): Intent { val attachmentUris = mutableListOf() screenshotUri?.let { attachmentUris.add(it) } logContentUri?.let { attachmentUris.add(it) } + // The performance history from the session being complained about (ADFA-5534). Absent when + // nothing has been sampled yet, which is the one case worth sending nothing for. + metricsUri?.let { attachmentUris.add(it) } return getIntentBasedOnAttachments( emailRecipient = emailRecipient, diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt index b3b749e596..60e348072d 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt @@ -48,6 +48,7 @@ object FeedbackManager { fun showFeedbackDialog( activity: AppCompatActivity, logContent: String?, + metricsAttachment: (suspend () -> File?)? = null, ) { val builder = DialogUtils.newMaterialDialogBuilder(activity) @@ -61,7 +62,7 @@ object FeedbackManager { ).setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } .setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() - sendFeedbackWithAttachments(activity, logContent) + sendFeedbackWithAttachments(activity, logContent, metricsAttachment) }.show() } @@ -302,12 +303,21 @@ object FeedbackManager { private fun sendFeedbackWithAttachments( activity: AppCompatActivity, logContent: String?, + metricsAttachment: (suspend () -> File?)? = null, ) { activity.lifecycleScope.launch { val handler = FeedbackEmailHandler(activity) val screenshotUri = handler.captureAndPrepareScreenshotUri(activity) val logContentUri = handler.getLogUri(activity, logContent) + // Suspending, unlike the log: the caller has to read the sample buffers on the main + // thread and write a compressed file off it, and neither belongs in a click listener. + // Guarded, because feedback about a broken IDE must still send if this part fails. + val metricsUri = + runCatching { metricsAttachment?.invoke() } + .onFailure { error -> logger.error("Could not attach the metrics file", error) } + .getOrNull() + ?.let { file -> activity.fileProviderUriFor(file) } val feedbackRecipient = activity.getString(R.string.feedback_email) val feedbackSubject = @@ -340,6 +350,7 @@ object FeedbackManager { feedbackRecipient, feedbackSubject, feedbackBody, + metricsUri, ) runCatching { From 98141c73a25fa4e01a03c4b347afd1356517fa45 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 01:39:23 -0700 Subject: [PATCH 082/128] ADFA-5526: attach the metrics to every report A crash arrives with a stack and no idea what the machine was doing. The minutes of memory, network, temperature and power before it are what turn "it died" into a diagnosis, and for an out-of-memory kill they are most of the answer. Registered as a Sentry EventProcessor beside GlitchTipDiagnosticsContext rather than on the uncaught exception handler, so it also covers the non-fatal captureException calls the IDE makes deliberately, and events raised from anywhere rather than only uncaught throws. Everything is inside runCatching, which in Kotlin catches Throwable. That is deliberate: this runs while the process is dying, and an OutOfMemoryError raised in here would cost the whole report rather than just the attachment. Losing the attachment is the right way to fail. Attaches nothing when the editor has never run -- onboarding, the project chooser and direct boot have no history, and direct boot has no credential-protected cache to write to either -- and nothing when nothing has been sampled, since a header-only file on every early crash would be noise rather than context. This does not need ADFA-5494, despite having been blocked on it. A crash is the one loss cause with a hookable moment, which is why it can be served alone; the low-memory kill 5494 exists for produces no report at all, because nothing runs on a SIGKILL. Verified by unit test: an attachment appears with history and gunzips to a valid CSV, nothing is attached without a source or without samples, and the event comes back unchanged when the attachment throws. NOT verified on device: `am crash` aborts the WebView renderer natively, which never reaches the Java uncaught handler, and I did not find another reachable path that raises a reported Java exception in the main process. The device leg of this is open. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../app/DeviceProtectedApplicationLoader.kt | 8 ++ .../handlers/MetricsCrashAttachment.kt | 96 +++++++++++++ .../handlers/MetricsCrashAttachmentTest.kt | 135 ++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt create mode 100644 app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt index a5a1ed921c..96c4430352 100644 --- a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt @@ -18,6 +18,7 @@ import com.itsaky.androidide.events.LspJavaEventsIndex import com.itsaky.androidide.events.ProjectsApiEventsIndex import com.itsaky.androidide.handlers.CrashEventSubscriber import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext +import com.itsaky.androidide.handlers.MetricsCrashAttachment import com.itsaky.androidide.logging.provider.IdeLogRouter import com.itsaky.androidide.preferences.internal.StatPreferences import com.itsaky.androidide.preferences.internal.TelemetryConsent @@ -25,6 +26,7 @@ import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.MetricsScratch import com.termux.shared.reflection.ReflectionUtils import io.github.rosemoe.sora.widget.schemes.EditorColorScheme import io.sentry.Breadcrumb @@ -126,6 +128,12 @@ internal object DeviceProtectedApplicationLoader : // Enrich every GlitchTip event with app-specific diagnostic context. GlitchTipDiagnosticsContext.install(options) + + // And with what the machine was doing in the minutes before it (ADFA-5526). The + // destinations that snapshot writes into are taken now, while failing to get them is + // survivable -- a crash handler is the wrong place to ask for memory. + MetricsScratch.install() + MetricsCrashAttachment.install(options, app) } // Forward INFO+ logs to GlitchTip as breadcrumbs (never as events; crash events are diff --git a/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt b/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt new file mode 100644 index 0000000000..153b953947 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt @@ -0,0 +1,96 @@ +/* + * 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 android.content.Context +import com.itsaky.androidide.utils.MetricsCsvFile +import com.itsaky.androidide.utils.MetricsSnapshotAssembler +import com.itsaky.androidide.utils.MetricsSource +import io.sentry.Attachment +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Attaches the carousel's metrics to every report the IDE sends (ADFA-5526). + * + * A crash arrives with a stack and no idea what the machine was doing. The minutes of memory, + * network, temperature and power leading up to it are what turn "it died" into a diagnosis -- and + * for an out-of-memory kill they are most of the answer. + * + * Registered as a Sentry [EventProcessor] beside [GlitchTipDiagnosticsContext], not on the uncaught + * exception handler, so it also covers the non-fatal `Sentry.captureException` calls the IDE makes + * deliberately. + * + * ADFA-5494 will keep this history across process death; this does not need it. A crash is the one + * loss cause with a hookable moment, which is exactly why it can be served on its own. The kill that + * 5494 exists for produces no report at all -- nothing runs on a SIGKILL -- so it was never this + * ticket's case. + */ +class MetricsCrashAttachment( + private val context: Context, +) : EventProcessor { + override fun process( + event: SentryEvent, + hint: Hint, + ): SentryEvent { + // Everything, including Errors. This runs while the process is dying, and an OutOfMemoryError + // raised in here would replace a useful report with no report -- losing the attachment is the + // right way to fail. runCatching is what makes that true: it catches Throwable. + runCatching { attach(hint) } + .onFailure { failure -> log.warn("Could not attach the metrics file to the report", failure) } + return event + } + + private fun attach(hint: Hint) { + // No source before the editor has run: a crash in onboarding, in the project chooser or in + // direct boot has no history to report, and direct boot has no credential-protected cache to + // write it to either. + val metrics = MetricsSource.current ?: return + val file = writeSnapshot(metrics) ?: return + hint.addAttachment(Attachment(file.absolutePath, file.name, MetricsCsvFile.COMPRESSED_MIME_TYPE)) + } + + private fun writeSnapshot(metrics: MetricsSource.Metrics): File? = + MetricsSnapshotAssembler.withSnapshot( + context = context, + memory = metrics.memoryUsageWatcher, + network = metrics.networkUsageWatcher, + power = metrics.powerUsageWatcher, + annotations = metrics.annotations, + ) { snapshot -> + // Nothing sampled yet is nothing to say. A header-only attachment on every early crash + // would be noise in the reports rather than context. + if (!snapshot.hasRows) null else MetricsCsvFile.writeForReport(context, snapshot) + } + + companion object { + private val log = LoggerFactory.getLogger(MetricsCrashAttachment::class.java) + + /** Registers this processor. Call once, from within `SentryAndroid.init`. */ + fun install( + options: SentryOptions, + context: Context, + ) { + options.addEventProcessor(MetricsCrashAttachment(context)) + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt new file mode 100644 index 0000000000..41f5be08e6 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt @@ -0,0 +1,135 @@ +/* + * 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 android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsCsvFile +import com.itsaky.androidide.utils.MetricsScratch +import com.itsaky.androidide.utils.MetricsSource +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import io.sentry.Hint +import io.sentry.SentryEvent +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.zip.GZIPInputStream + +/** + * What a report carries about the machine that produced it (ADFA-5526). + * + * The failure modes matter more than the happy path here: this runs while the process is dying, so + * anything it throws costs the whole report rather than just the attachment. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCrashAttachmentTest { + private val context = ApplicationProvider.getApplicationContext() + + private val watchers = mutableListOf() + + @After + fun tearDown() { + watchers.forEach { it.stopWatching() } + watchers.clear() + MetricsSource.current?.let(MetricsSource::unregister) + MetricsScratch.resetForTesting() + } + + private class FakeMetrics( + override val memoryUsageWatcher: MemoryUsageWatcher, + override val networkUsageWatcher: NetworkUsageWatcher = NetworkUsageWatcher(uid = 0), + override val powerUsageWatcher: PowerUsageWatcher = + PowerUsageWatcher( + source = { + PowerUsageWatcher.PowerReading( + temperatureMilliCelsius = 29_700L, + powerMicroWatts = -3_400_000L, + thermalStatus = 0, + battery = PowerUsageWatcher.BatteryState.UNKNOWN, + ) + }, + ), + override val annotations: MetricsAnnotationStore = MetricsAnnotationStore(), + ) : MetricsSource.Metrics + + private fun sampledWatcher(): MemoryUsageWatcher = + MemoryUsageWatcher().also { watcher -> + watchers += watcher + watcher.watchProcess(android.os.Process.myPid(), "IDE") + watcher.readUsages() + } + + private fun process(): Hint { + val hint = Hint() + MetricsCrashAttachment(context).process(SentryEvent(), hint) + return hint + } + + @Test + fun `a report from a session with history carries it, gzipped`() { + MetricsSource.register(FakeMetrics(sampledWatcher())) + + val attachments = process().attachments + + assertThat(attachments).hasSize(1) + val attachment = attachments.single() + assertThat(attachment.contentType).isEqualTo(MetricsCsvFile.COMPRESSED_MIME_TYPE) + assertThat(attachment.filename).endsWith(".csv.gz") + // A named file that does not unzip is worse than none: it looks like context and is not. + val unzipped = GZIPInputStream(File(attachment.pathname!!).inputStream()).bufferedReader().use { it.readText() } + assertThat(unzipped.lineSequence().first()).startsWith("\"timestamp\"") + assertThat(unzipped.lineSequence().count()).isAtLeast(2) + } + + @Test + fun `a crash before the editor ran attaches nothing`() { + // Onboarding, the project chooser, direct boot: no watchers exist, and direct boot has no + // credential-protected cache to write to either. + assertThat(MetricsSource.current).isNull() + + assertThat(process().attachments).isEmpty() + } + + @Test + fun `a session that has sampled nothing attaches nothing`() { + // A header-only file on every early crash would be noise in the reports, not context. + MetricsSource.register(FakeMetrics(MemoryUsageWatcher().also(watchers::add))) + + assertThat(process().attachments).isEmpty() + } + + @Test + fun `the event is returned unchanged even when the attachment fails`() { + // The whole point of the guard: a report with no metrics beats no report. A watcher whose + // buffers are a different length than the scratch makes copyInto throw, which is the + // closest stand-in for the crash-time failures this has to survive. + MetricsScratch.install(entries = MemoryUsageWatcher.MAX_USAGE_ENTRIES + 1, memorySeries = 3) + MetricsSource.register(FakeMetrics(sampledWatcher())) + + val event = SentryEvent() + val returned = MetricsCrashAttachment(context).process(event, Hint()) + + assertThat(returned).isSameInstanceAs(event) + } +} From 50aacfc0eb95dad346625099e0a75212fbb3831f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 05:28:45 -0700 Subject: [PATCH 083/128] ADFA-5534: fix pruning here, where its test lives `both directories stay bounded` fails on this branch: pruneTo picked the oldest n across every file and then skipped the one just written, so it deleted one too few whenever that one sorted into the set, and the directory crept one over the limit each time. The fix was sitting two branches up on ADFA-5526, which left this PR red on its own test. It belongs where the test that catches it is. The tie-case test that came with it was vacuous, so it is rewritten here. Tying every existing file to one *past* timestamp does not reproduce the bug: the file written last still carries a real mtime, so it sorts last, is never in the deleted set, and the skip never fires. Dating the existing files into the future puts the new one at the front of the sort deterministically. It now fails without the fix, four files where three are allowed, as does the bounded test. Also carries the row-alignment fix into MetricsSnapshotAssembler, which is where the assembly moved on this branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/utils/MetricsSnapshot.kt | 13 +++++++--- .../utils/MetricsSnapshotAssembler.kt | 26 ++++++++++--------- .../androidide/utils/MetricsCsvFileTest.kt | 21 +++++++++++++++ 3 files changed, 44 insertions(+), 16 deletions(-) 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 7159482843..113b9c89fe 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt @@ -102,12 +102,17 @@ object MetricsSnapshot { limit: Int, newest: File, ) { - val files = directory.listFiles()?.sortedBy { it.lastModified() } ?: return - if (files.size <= limit) { + // [newest] is excluded from the candidates rather than skipped among them. Skipping it after + // choosing "the oldest n" left one file too many whenever it sorted into that set, and the + // directory then crept one over the limit per collision. Two writes inside one filesystem + // timestamp are enough to sort it there. + val candidates = directory.listFiles()?.filter { it != newest }?.sortedBy { it.lastModified() } ?: return + val excess = candidates.size - (limit - 1) + if (excess <= 0) { return } - files.take(files.size - limit).forEach { file -> - if (file != newest && !file.delete()) { + candidates.take(excess).forEach { file -> + if (!file.delete()) { log.warn("Could not delete the stale chart snapshot at {}", file) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt index 5e7a531007..31c50a0bcf 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt @@ -52,28 +52,30 @@ object MetricsSnapshotAssembler { power: PowerUsageWatcher, annotations: MetricsAnnotationStore?, ): MetricsCsv.Snapshot { - val memoryTimes = memory.sampleTimes() - val networkTimes = network.sampleTimes() - val powerTimes = power.sampleTimes() + // One call per watcher, not one per array. Each hands back its times and its values from a + // single critical section, which is what keeps a row of the file a single moment: asking + // separately let a sample land between the two calls, and every value came out one row off + // its own timestamp. + val memoryHistory = memory.history() val networkUsage = network.getUsage() val powerUsage = power.getUsage() return MetricsCsv.Snapshot( - rowTimes = memoryTimes, + rowTimes = memoryHistory.times, memory = - memory.getMemoryUsages().associate { process -> + memoryHistory.processes.associate { process -> process.pname to MetricsCsv.Series( - times = memoryTimes, - values = process.usageHistory.toLongArray(), + times = memoryHistory.times, + values = process.usage, since = process.watchedSinceMillis, ) }, - networkReceived = MetricsCsv.Series(networkTimes, networkUsage.received), - networkTransmitted = MetricsCsv.Series(networkTimes, networkUsage.transmitted), - temperature = MetricsCsv.Series(powerTimes, powerUsage.temperatureMilliCelsius), - power = MetricsCsv.Series(powerTimes, powerUsage.powerMicroWatts), - thermal = MetricsCsv.Series(powerTimes, powerUsage.thermalStatus), + networkReceived = MetricsCsv.Series(networkUsage.sampleTimes, networkUsage.received), + networkTransmitted = MetricsCsv.Series(networkUsage.sampleTimes, networkUsage.transmitted), + temperature = MetricsCsv.Series(powerUsage.sampleTimes, powerUsage.temperatureMilliCelsius), + power = MetricsCsv.Series(powerUsage.sampleTimes, powerUsage.powerMicroWatts), + thermal = MetricsCsv.Series(powerUsage.sampleTimes, powerUsage.thermalStatus), annotations = markers(context, annotations), ) } diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt index 266849503d..0cbc2f653f 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt @@ -106,6 +106,27 @@ class MetricsCsvFileTest { assertThat(reports.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) } + @Test + fun `the limit holds when the file just written is not the newest on disk`() { + // Pruning used to pick "the oldest n" across every file and then skip the one just written, + // which deleted one too few whenever that one sorted into the set -- and the directory crept + // one over the limit each time. Two writes inside a single filesystem timestamp are enough + // to sort it there. + // + // Dating the existing files into the future is what puts the new one at the front of the + // sort deterministically. Tying them all to one *past* value does not: the file written last + // still carries a real mtime, so it sorts last, is never in the set, and the skip never + // fires -- which is how the first version of this test passed against the unfixed code. + val future = System.currentTimeMillis() + 1_000_000L + repeat(MetricsCsvFile.KEEP_RECENT + 3) { i -> + MetricsCsvFile.write(context, snapshot(1), AT + i, zone)!!.setLastModified(future) + } + + val directory = MetricsCsvFile.write(context, snapshot(1), AT + 900L, zone)!!.parentFile!! + + assertThat(directory.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) + } + @Test fun `files land under the cache, which the platform may reclaim`() { val file: File = MetricsCsvFile.writeForReport(context, snapshot(2), AT, zone)!! From 020459c7d9bdba99e55654d2dde685153df91e3a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 01:54:40 -0700 Subject: [PATCH 084/128] ADFA-5526: prove the attachment reaches the envelope The processor putting an Attachment on the Hint is our half of the hop; whether Sentry carries it into the envelope it sends is the SDK's, and asserting on our own call says nothing about that. So this runs the real SDK against a transport that keeps what it is handed, captures an exception, and reads the envelope back: an attachment item named *.csv.gz, content type application/gzip, whose bytes gunzip to a CSV with the expected header and at least one data row. Confirmed to fail when the addAttachment call is removed. The negative case is pinned too -- a session with no samples produces an envelope with no metrics attachment. Why this rather than a device run. A crash on device does reach the processor: verified on a Pixel 6 Pro with a build carrying a non-resolvable DSN, where `am crash` on the app's main pid wrote 2026_09_07_01_48_52_523.csv.gz at crash time. But no event envelope survives to disk to read back, because the IDE's own uncaught handler captures and then calls exitProcess without waiting for Sentry to flush. Session and log envelopes cache fine, so the cache works; the crash event never gets written. That looks like ordinary crash reports being lost whatever this ticket does, and it is worth its own ticket rather than a change here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../MetricsCrashAttachmentEnvelopeTest.kt | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentEnvelopeTest.kt diff --git a/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentEnvelopeTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentEnvelopeTest.kt new file mode 100644 index 0000000000..f5b3b05960 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentEnvelopeTest.kt @@ -0,0 +1,177 @@ +/* + * 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 android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsScratch +import com.itsaky.androidide.utils.MetricsSource +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import io.sentry.Hint +import io.sentry.ITransportFactory +import io.sentry.Sentry +import io.sentry.SentryEnvelope +import io.sentry.SentryItemType +import io.sentry.SentryOptions +import io.sentry.transport.ITransport +import io.sentry.transport.RateLimiter +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.zip.GZIPInputStream + +/** + * The last hop: that the attachment this handler puts on a [Hint] reaches the envelope Sentry sends. + * + * Everything up to the Hint is covered by [MetricsCrashAttachmentTest]. This runs the real SDK with + * a transport that keeps what it is handed, because the hop itself is the SDK's to make and asserting + * on our own call proves nothing about it. + * + * Why not on a device: a crash there does reach this processor -- verified, it writes its file -- but + * the IDE's own uncaught handler calls exitProcess straight after capturing, so no event envelope + * survives to disk to be read back. That is worth its own ticket and is not this hop. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCrashAttachmentEnvelopeTest { + private val context = ApplicationProvider.getApplicationContext() + + private val sent = mutableListOf() + + private val watchers = mutableListOf() + + @After + fun tearDown() { + Sentry.close() + watchers.forEach { it.stopWatching() } + watchers.clear() + MetricsSource.current?.let(MetricsSource::unregister) + MetricsScratch.resetForTesting() + sent.clear() + } + + private inner class RecordingTransport : ITransport { + override fun send( + envelope: SentryEnvelope, + hint: Hint, + ) { + sent += envelope + } + + override fun flush(timeoutMillis: Long) = Unit + + override fun getRateLimiter(): RateLimiter? = null + + override fun close(isRestarting: Boolean) = Unit + + override fun close() = Unit + } + + private fun sampledMetrics(): MetricsSource.Metrics { + val memory = + MemoryUsageWatcher().also { watcher -> + watchers += watcher + watcher.watchProcess(android.os.Process.myPid(), "IDE") + watcher.readUsages() + } + return object : MetricsSource.Metrics { + override val memoryUsageWatcher = memory + override val networkUsageWatcher = NetworkUsageWatcher(uid = 0) + override val powerUsageWatcher = + PowerUsageWatcher( + source = { + PowerUsageWatcher.PowerReading( + temperatureMilliCelsius = 29_700L, + powerMicroWatts = -3_400_000L, + thermalStatus = 0, + battery = PowerUsageWatcher.BatteryState.UNKNOWN, + ) + }, + ) + override val annotations = MetricsAnnotationStore() + } + } + + private fun startSentry() { + Sentry.init { options: SentryOptions -> + // A DSN that cannot resolve, and a transport that never touches the network anyway. + options.dsn = "https://0123456789abcdef0123456789abcdef@sentry.invalid/1" + options.isEnableUncaughtExceptionHandler = false + options.setTransportFactory { _, _ -> RecordingTransport() } + MetricsCrashAttachment.install(options, context) + } + } + + private fun attachmentsOf(envelope: SentryEnvelope) = envelope.items.filter { it.header.type == SentryItemType.Attachment } + + @Test + fun `the metrics file arrives in the envelope Sentry sends`() { + MetricsSource.register(sampledMetrics()) + startSentry() + + Sentry.captureException(RuntimeException("boom")) + + assertThat(sent).isNotEmpty() + val attachments = sent.flatMap(::attachmentsOf) + val metrics = + attachments.single { + it.header.fileName + .orEmpty() + .endsWith(".csv.gz") + } + assertThat(metrics.header.contentType).isEqualTo("application/gzip") + // The bytes have to survive the trip, not just the filename: an envelope carrying a name and + // no readable payload would look like context and be none. + val csv = GZIPInputStream(metrics.data.inputStream()).bufferedReader().use { it.readText() } + assertThat(csv.lineSequence().first()).startsWith("\"timestamp\"") + assertThat(csv.lineSequence().count()).isAtLeast(2) + } + + @Test + fun `an envelope from a session with no samples carries no metrics attachment`() { + MetricsSource.register( + object : MetricsSource.Metrics { + override val memoryUsageWatcher = MemoryUsageWatcher().also(watchers::add) + override val networkUsageWatcher = NetworkUsageWatcher(uid = 0) + override val powerUsageWatcher = + PowerUsageWatcher( + source = { + PowerUsageWatcher.PowerReading(0L, 0L, 0, PowerUsageWatcher.BatteryState.UNKNOWN) + }, + ) + override val annotations = MetricsAnnotationStore() + }, + ) + startSentry() + + Sentry.captureException(RuntimeException("boom")) + + assertThat(sent).isNotEmpty() + assertThat( + sent.flatMap(::attachmentsOf).filter { + it.header.fileName + .orEmpty() + .endsWith(".csv.gz") + }, + ).isEmpty() + } +} From 60547805ad0fc15e880cc786a39d80f4cc77c19c Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 05:42:34 -0700 Subject: [PATCH 085/128] ADFA-5534: measure compression against noise, not a ramp The ratio test ran on a snapshot whose every column advanced by a constant, which gzip compresses about fiftyfold -- so "better than 4x" was met with room to spare by data no session produces, and the test said nothing about whether the extra step earns its place. It now runs on a seeded session whose columns move the way a device's do: memory in steps of megabytes, network in bursts, temperature and power drifting, thermal status flipping. That is deliberately noisier than reality -- 2.8x here against 4.6x measured on a real attachment -- so it is a floor. The bound is halving, which compression bypassed fails and a shift in gzip's tuning does not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/utils/MetricsCsvFileTest.kt | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt index 0cbc2f653f..e2e12a0092 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt @@ -26,6 +26,7 @@ import org.robolectric.RobolectricTestRunner import java.io.File import java.time.ZoneId import java.util.zip.GZIPInputStream +import kotlin.random.Random /** * The two files the metrics format is written to: the user's export, and the compressed copy that @@ -51,6 +52,35 @@ class MetricsCsvFileTest { ) } + /** + * A session whose columns move the way a device's do, rather than climbing by one per row. + * + * Seeded, so the sizes above are the same on every run and in CI. + */ + private fun noisySnapshot(rows: Int): MetricsCsv.Snapshot { + val random = Random(20260907L) + val times = LongArray(rows) { AT + it * 1_000L + random.nextInt(80) } + + fun series(next: () -> Long) = MetricsCsv.Series(times, LongArray(rows) { next() }) + var ide = 600_000_000L + var daemon = 780_000_000L + var celsius = 32_000L + return MetricsCsv.Snapshot( + rowTimes = times, + memory = + mapOf( + "IDE" to series { (ide + random.nextInt(-6_000_000, 6_000_000)).also { ide = it } }, + "Gradle Daemon" to series { (daemon + random.nextInt(-40_000_000, 40_000_000)).also { daemon = it } }, + ), + // Bursty: mostly idle, occasionally a download. + networkReceived = series { if (random.nextInt(6) == 0) random.nextLong(2_000_000) else random.nextLong(4_000) }, + networkTransmitted = series { if (random.nextInt(8) == 0) random.nextLong(300_000) else random.nextLong(1_500) }, + temperature = series { (celsius + random.nextInt(-300, 300)).also { celsius = it } }, + power = series { 1_200_000L + random.nextLong(3_500_000) }, + thermal = series { if (random.nextInt(10) == 0) random.nextLong(4) else 0L }, + ) + } + @Test fun `an export is plain csv the user can open`() { val file = MetricsCsvFile.write(context, snapshot(3), AT, zone)!! @@ -70,14 +100,22 @@ class MetricsCsvFileTest { } @Test - fun `compressing is worth doing`() { - // The rows are near-identical by nature -- the timestamp advances by a constant and the - // magnitudes barely move -- so this travels far smaller than it reads. If that ever stops - // being true, the compression is buying nothing and the extra step should go. - val plain = MetricsCsvFile.write(context, snapshot(500), AT, zone)!!.length() - val compressed = MetricsCsvFile.writeForReport(context, snapshot(500), AT, zone)!!.length() - - assertThat(compressed).isLessThan(plain / 4) + fun `compressing is worth doing on a session that is not a straight line`() { + // Against noise, not against [snapshot]'s ramp. A file whose every column advances by a + // constant compresses about fifty-fold, so a bound met by that says nothing about a real + // session -- and this test exists to notice if the extra step ever stops earning its place. + // Every column here moves the way its metric does on a device: memory in steps of megabytes, + // network in bursts, temperature and power drifting, thermal status flipping. + // + // This session is deliberately noisier than a real one -- uniformly random power draw and + // network bursts, where a device gives smooth drifts -- so what it achieves is a floor, not + // an estimate: 40896 -> 14722 bytes, 2.8x, against 4.6x measured on a real 86-row + // attachment on a Pixel 6 Pro. Halving is the bound, which compression bypassed fails and + // a shift in gzip's tuning does not. + val plain = MetricsCsvFile.write(context, noisySnapshot(500), AT, zone)!!.length() + val compressed = MetricsCsvFile.writeForReport(context, noisySnapshot(500), AT, zone)!!.length() + + assertThat(compressed).isLessThan(plain / 2) } @Test From f89e523311e11c262424388c8e19852dc6fc7769 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 05:34:18 -0700 Subject: [PATCH 086/128] ADFA-5526: close the blocks the rebase resolution left open Three stray braces from resolving this branch onto the row-alignment fix, where copyUsageInto and copyHistoryInto grew a times destination. No behaviour: the files did not parse before this. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt | 1 - .../java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt | 4 +--- .../java/com/itsaky/androidide/utils/PowerUsageWatcher.kt | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) 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 567d11d670..b32ebd5980 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -367,7 +367,6 @@ class MemoryUsageWatcher }, ) } - } /** * Returns the memory usage of all the registered processes. 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 0194ee904a..2132ba3d0e 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -154,8 +154,7 @@ class NetworkUsageWatcher * The arrays are copies. Handing out the live ring buffers would let the caller read them while * the sampler thread is midway through appending, and the chart renderer reads all 30 entries. */ - fun getUsage(): NetworkUsage = - copyUsageInto(LongArray(received.size), LongArray(transmitted.size), LongArray(sampleTimes.size)) + fun getUsage(): NetworkUsage = copyUsageInto(LongArray(received.size), LongArray(transmitted.size), LongArray(sampleTimes.size)) /** * [getUsage], into destinations the caller owns (ADFA-5526). @@ -176,7 +175,6 @@ class NetworkUsageWatcher sampleTimes.copyInto(timesDest), ) } - } /** * Discards every recorded sample and drops the cumulative baseline, so the next sample diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 4d94b102a7..94a6136b82 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -168,7 +168,6 @@ class PowerUsageWatcher sampleTimes.copyInto(timesDest), ) } - } fun clearHistory() { synchronized(historyLock) { From 7a17f2c9db670755811bed07594006852cdbebbf Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 07:20:03 -0700 Subject: [PATCH 087/128] 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) + } +} From a8225f21afc0053b260f01f5f9866311412b7af1 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 07:27:35 -0700 Subject: [PATCH 088/128] ADFA-5510: keep the legend out of the axis tap band The band was everything below the plot, and MPAndroidChart aligns the legend to the bottom by default -- under the axis labels, inside the band. So tapping the legend, the one part of a chart a reader expects to be tappable, opened the sampling-rate chooser; choosing a rate there clears every buffer, so a mis-tap costs the history being looked at. The band now stops at the legend's top edge, taken from what the chart reserves for it, and is never narrower than one axis label -- otherwise a legend that measured larger than expected could squeeze the rate chooser out of reach entirely. Verified by probe before fixing: a laid-out chart reports legendVerticalAlignment=BOTTOM and legendEnabled=true, with the legend below contentBottom(). Two tests, one per side of the bound; the legend one fails against the unbounded band. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 17 ++++++++++- .../androidide/ui/MetricsChartAxisTapTest.kt | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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 aaf31d5fc8..75d8bf21eb 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -97,10 +97,25 @@ abstract class MetricsChartRenderer( * One predicate, because the tap that opens the sampling-rate chooser and the long press that * explains it have to agree on where that band is: written twice, they can drift apart and the * tooltip then describes a control the tap no longer reaches. + * + * Bounded below, not just above. Everything under the plot used to count, and the legend lives + * there too -- MPAndroidChart aligns it to the bottom by default, under the axis labels. So + * tapping the legend, which is the one thing in a chart a reader expects to be tappable, opened + * the sampling-rate chooser; picking a rate there clears every buffer, and the user loses the + * history they were looking at for an action they did not ask for. + * + * The band stops at the legend's top edge, and is never narrower than one axis label, so a + * legend that measures larger than expected cannot squeeze the rate chooser out of reach. */ private fun isOnAxisBand(y: Float): Boolean { val chart = this.chart ?: return false - return y >= chart.viewPortHandler.contentBottom() + val top = chart.viewPortHandler.contentBottom() + val legend = chart.legend + // What the chart reserves for the legend at the bottom: its measured height plus the + // offset it keeps above itself. Both are pixels, as MPAndroidChart stores them. + val reservedForLegend = if (legend.isEnabled) legend.mNeededHeight + legend.yOffset else 0f + val bottom = maxOf(chart.height - reservedForLegend, top + chart.xAxis.textSize) + return y >= top && y < bottom } /** 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 8653db671e..6cfdfa6ed6 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -128,6 +128,34 @@ class MetricsChartAxisTapTest { assertThat(taps).isEqualTo(1) } + @Test + fun `a tap on the legend does not open the chooser`() { + val chart = laidOutChart() + + // MPAndroidChart aligns the legend to the bottom by default, below the axis labels, so + // "everything under the plot" included it -- and the legend is the one part of a chart a + // reader expects to be tappable. Opening the rate chooser there is bad enough; picking a + // rate in it clears every buffer, so a mis-tap costs the history being looked at. + // + // Robolectric measures no real text, so the legend here is a few pixels rather than the + // ~10dp row a device draws. That is enough: the assertion is about which side of the + // boundary the legend's own rows fall on, and the bottom row is the legend's. + tapAt(chart, CHART_HEIGHT - 1f) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `the axis labels still open the chooser, with the legend excluded`() { + val chart = laidOutChart() + + // The other half of the bound: narrowing the band must not put the rate chooser out of + // reach. One axis label's height below the plot always stays in it. + tapAt(chart, chart.viewPortHandler.contentBottom() + chart.xAxis.textSize / 2f) + + assertThat(taps).isEqualTo(1) + } + @Test fun `a tap above the plot does not open the chooser`() { val chart = laidOutChart() From 31e10df3a1ea33c70e6998de7ad9761ec633029b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 07:31:40 -0700 Subject: [PATCH 089/128] ADFA-5531: stop the file reporting sentinels and a lost watch time Two review findings, both making the exported file say something untrue while staying perfectly well-formed. ProcessMemoryInfo.snapshot dropped watchedSinceMillis, and getMemoryUsages hands out snapshots. Defaulted to 0 it reads as "watched since the epoch", so the guard that blanks a process's zero-filled past never fired and the Gradle daemon exported measured zeros for the whole session before the daemon existed. Latent as of the row-alignment commit, which moved the export onto history(); the trap is removed rather than left for the next getMemoryUsages consumer. PowerUsageWatcher stores Long.MIN_VALUE for a reading the platform will not give, and the writer stringified it -- so battery_temp_millicelsius and power_microwatts carried -9223372036854775808. The chart maps that to zero; the file did not. Series gains an optional `absent` sentinel written as an empty cell, which is what the format already means by "nothing to say here". The caller names the value, because MetricsCsv deliberately has no Android types in it. thermal_status gets the same treatment for THERMAL_UNKNOWN. Both tests fail against the unfixed code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsCarouselController.kt | 23 +++++++++++++--- .../androidide/utils/MemoryUsageWatcher.kt | 7 ++++- .../com/itsaky/androidide/utils/MetricsCsv.kt | 14 +++++++++- .../MemoryUsageWatcherSampleAlignmentTest.kt | 15 +++++++++++ .../itsaky/androidide/utils/MetricsCsvTest.kt | 27 +++++++++++++++++++ 5 files changed, 81 insertions(+), 5 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 d2e130ad02..8709b5a8f9 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -737,9 +737,26 @@ class MetricsCarouselController( }, networkReceived = MetricsCsv.Series(network.sampleTimes, network.received), networkTransmitted = MetricsCsv.Series(network.sampleTimes, network.transmitted), - temperature = MetricsCsv.Series(power.sampleTimes, power.temperatureMilliCelsius), - power = MetricsCsv.Series(power.sampleTimes, power.powerMicroWatts), - thermal = MetricsCsv.Series(power.sampleTimes, power.thermalStatus), + // The power series carry in-band sentinels for a reading the device does not provide. + // Named here rather than in MetricsCsv, which has no Android types in it. + temperature = + MetricsCsv.Series( + power.sampleTimes, + power.temperatureMilliCelsius, + absent = PowerUsageWatcher.UNAVAILABLE, + ), + power = + MetricsCsv.Series( + power.sampleTimes, + power.powerMicroWatts, + absent = PowerUsageWatcher.UNAVAILABLE, + ), + thermal = + MetricsCsv.Series( + power.sampleTimes, + power.thermalStatus, + absent = PowerUsageWatcher.THERMAL_UNKNOWN.toLong(), + ), annotations = markers(), ) } 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 eeb47d03ce..6d1387274e 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -473,7 +473,12 @@ class MemoryUsageWatcher * The MemoryInfo instance is shared deliberately: it is the sampler's scratch buffer * for the next reading and no reader looks at it. */ - internal fun snapshot(): ProcessMemoryInfo = ProcessMemoryInfo(pid, pname, _history.copy()) + internal fun snapshot(): ProcessMemoryInfo = + // Every field, including watchedSinceMillis. Dropping it let it default to 0, which + // reads as "watched since the epoch" -- so the guard that blanks a process's + // zero-filled past never fired, and the Gradle daemon's buffer exported as + // measured zeros from before it existed (ADFA-5531). + ProcessMemoryInfo(pid, pname, _history.copy(), watchedSinceMillis) override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt index c52891e086..0fb2721720 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt @@ -95,6 +95,13 @@ object MetricsCsv { * [NO_SAMPLE] entry marks an index nothing was ever recorded at, which is what tells an empty * cell apart from a measured zero. * @property values The samples themselves. + * @property absent The in-band value this series uses for "the device did not provide a + * reading", or `null` if it has none. Written as an empty cell, the same as an unsampled index. + * A watcher that stores a sentinel -- `PowerUsageWatcher.UNAVAILABLE` is [Long.MIN_VALUE] -- + * would otherwise put `-9223372036854775808` in a numeric column, and every consumer that + * averages or plots that column gets an answer that is not merely wrong but spectacular. The + * sentinel is named by the caller rather than known here, because this file deliberately has no + * Android types in it. * @property since When this series started being recorded. Samples timed before it belong to * the buffer's zero-filled past rather than to this series -- the Gradle daemon's buffer reaches * back to the start of the session however late in it the daemon appeared. @@ -103,6 +110,7 @@ object MetricsCsv { private val times: LongArray, private val values: LongArray, private val since: Long = 0L, + private val absent: Long? = null, ) { /** The value at index [i], or `null` if this series has nothing to say there. */ fun at(i: Int): Long? { @@ -110,7 +118,11 @@ object MetricsCsv { return null } val time = times[i] - return if (time == NO_SAMPLE || time < since) null else values[i] + if (time == NO_SAMPLE || time < since) { + return null + } + val value = values[i] + return if (value == absent) null else value } companion object { diff --git a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt index 035e57cf68..5b7e2a21aa 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt @@ -110,6 +110,21 @@ class MemoryUsageWatcherSampleAlignmentTest { ).isEqualTo(PSS_KB * 1024L) } + @Test + fun `a copied process still says when it started being watched`() { + val watcher = watcher() + watcher.watchProcess(PID, "Gradle Daemon") + watcher.readUsages() + + // getMemoryUsages hands out copies, and the copy used to drop watchedSinceMillis -- which + // defaults to 0, i.e. "watched since the epoch". The export's guard for a process's + // zero-filled past then never fired, so the daemon's buffer from before the daemon existed + // came out as measured zeros rather than empty cells (ADFA-5531). + val copied = watcher.getMemoryUsages().single() + assertThat(copied.watchedSinceMillis).isNotEqualTo(0L) + assertThat(copied.watchedSinceMillis).isEqualTo(watcher.getMemoryUsage(PID)!!.watchedSinceMillis) + } + private companion object { const val PID = 4242 diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt index f44619461a..cb9cceba0f 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt @@ -292,7 +292,34 @@ class MetricsCsvTest { return cells } + @Test + fun `a reading the device does not provide is an empty cell, not a sentinel`() { + val times = longArrayOf(T0, T0 + 1_000L) + val lines = + render( + snapshot( + rowTimes = times, + temperature = + MetricsCsv.Series( + times, + longArrayOf(Long.MIN_VALUE, 31_500L), + absent = Long.MIN_VALUE, + ), + ), + ) + + // PowerUsageWatcher stores Long.MIN_VALUE for a reading the platform will not give. Written + // straight out, a numeric column gets -9223372036854775808, and anything that averages or + // plots it -- ADFA-5494 reads this format back -- gets an answer that is not merely wrong + // but spectacular. Empty is what the format already means by "nothing to say here". + assertThat(cellsIn(lines[1])[TEMPERATURE_COLUMN]).isEmpty() + assertThat(cellsIn(lines[2])[TEMPERATURE_COLUMN]).isEqualTo("31500") + } + private companion object { + /** Index of `battery_temp_millicelsius`, from the header contract above. */ + val TEMPERATURE_COLUMN = EXPECTED_HEADER.split(",").indexOf("\"battery_temp_millicelsius\"") + /** * The header line, spelled out rather than derived from [MetricsCsv.HEADER]. * From d7a6756341e930167f7463129eab323e6adbbf4f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 07:35:50 -0700 Subject: [PATCH 090/128] ADFA-5534: put the whole attachment step inside the guard it claims The comment said feedback about a broken IDE must still send if the metrics part fails. Two ways it did not. fileProviderUriFor was chained outside the runCatching. FileProvider throws IllegalArgumentException for a path outside its configured roots, and this feature added a directory -- so the throw went past the guard and took the whole send with it. Unreachable today only because the paths XML has a root-path of ".", which is not a thing to depend on. CancellationException was swallowed. runCatching catches Throwable and the provider genuinely suspends -- withContext twice -- so an activity destroyed mid-write had its cancellation eaten and the coroutine ran on to startActivity() on a dead activity. Rethrown now. The step is extracted so it can be tested at all: sendFeedbackWithAttachments needs a live activity and its lifecycle scope, and this is the part with the failure modes. Five tests; the two that name these defects fail against the old shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/utils/FeedbackManager.kt | 42 +++++++-- .../utils/FeedbackMetricsAttachmentTest.kt | 91 +++++++++++++++++++ 2 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 common/src/test/java/com/itsaky/androidide/utils/FeedbackMetricsAttachmentTest.kt diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt index 60e348072d..cd492ab4d5 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt @@ -14,6 +14,7 @@ import android.view.PixelCopy import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.createBitmap import androidx.core.net.toUri @@ -21,6 +22,7 @@ import androidx.core.text.HtmlCompat import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.eventbus.events.editor.ReportCaughtExceptionEvent import com.itsaky.androidide.resources.R +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -300,6 +302,37 @@ object FeedbackManager { else -> "Unknown Screen" } + /** + * The URI for the metrics attachment, or `null` if there is nothing to attach or it failed. + * + * Suspending, unlike the log: the caller reads the sample buffers on the main thread and writes + * a compressed file off it, and neither belongs in a click listener. Guarded, because feedback + * about a broken IDE has to send even when this part does not work. + * + * Both steps are inside the guard. [toUri] used to be chained outside it, so a FileProvider not + * told about the attachment's directory threw past the guard and killed the whole send -- + * precisely what the guard is for. + * + * [CancellationException] is rethrown rather than swallowed. `runCatching` catches `Throwable`, + * and [provider] really does suspend, so a destroyed activity had its cancellation eaten here + * and the caller ran on to `startActivity()` on a dead activity. + * + * Separated from [sendFeedbackWithAttachments] so it can be tested: that one needs a live + * activity and its lifecycle scope, and this is the part with the failure modes. + */ + @VisibleForTesting + internal suspend fun metricsAttachmentUri( + provider: (suspend () -> File?)?, + toUri: (File) -> Uri, + ): Uri? = + runCatching { provider?.invoke()?.let(toUri) } + .onFailure { error -> + if (error is CancellationException) { + throw error + } + logger.error("Could not attach the metrics file", error) + }.getOrNull() + private fun sendFeedbackWithAttachments( activity: AppCompatActivity, logContent: String?, @@ -310,14 +343,7 @@ object FeedbackManager { val screenshotUri = handler.captureAndPrepareScreenshotUri(activity) val logContentUri = handler.getLogUri(activity, logContent) - // Suspending, unlike the log: the caller has to read the sample buffers on the main - // thread and write a compressed file off it, and neither belongs in a click listener. - // Guarded, because feedback about a broken IDE must still send if this part fails. - val metricsUri = - runCatching { metricsAttachment?.invoke() } - .onFailure { error -> logger.error("Could not attach the metrics file", error) } - .getOrNull() - ?.let { file -> activity.fileProviderUriFor(file) } + val metricsUri = metricsAttachmentUri(metricsAttachment, activity::fileProviderUriFor) val feedbackRecipient = activity.getString(R.string.feedback_email) val feedbackSubject = diff --git a/common/src/test/java/com/itsaky/androidide/utils/FeedbackMetricsAttachmentTest.kt b/common/src/test/java/com/itsaky/androidide/utils/FeedbackMetricsAttachmentTest.kt new file mode 100644 index 0000000000..3b67c459fb --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/FeedbackMetricsAttachmentTest.kt @@ -0,0 +1,91 @@ +/* + * 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.net.Uri +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File +import java.io.IOException + +/** + * What the metrics attachment is allowed to cost the feedback send (ADFA-5534). + * + * Feedback about a broken IDE has to reach us even when the part that describes the breakage does + * not work. The two ways that was not true: a throw from the URI step, which sat outside the guard, + * and a cancellation, which the guard caught and hid. + */ +class FeedbackMetricsAttachmentTest { + private val uri = mockk() + + private val file = File("metrics.csv.gz") + + @Test + fun `an attachment that writes becomes a uri`() = + runTest { + val result = FeedbackManager.metricsAttachmentUri({ file }) { uri } + + assertThat(result).isSameInstanceAs(uri) + } + + @Test + fun `nothing to attach is not a failure`() = + runTest { + assertThat(FeedbackManager.metricsAttachmentUri(null) { uri }).isNull() + assertThat(FeedbackManager.metricsAttachmentUri({ null }) { uri }).isNull() + } + + @Test + fun `a write that fails costs the attachment, not the send`() = + runTest { + val result = + FeedbackManager.metricsAttachmentUri({ throw IOException("no space") }) { uri } + + assertThat(result).isNull() + } + + @Test + fun `a uri that cannot be granted costs the attachment, not the send`() = + runTest { + // FileProvider throws IllegalArgumentException for a path outside its configured roots, + // and the metrics reports live in a directory this feature added. Chained outside the + // guard, as it was, this threw past it and took the whole feedback send with it. + val result = + FeedbackManager.metricsAttachmentUri({ file }) { + throw IllegalArgumentException("Failed to find configured root") + } + + assertThat(result).isNull() + } + + @Test + fun `a cancelled send is not carried on with`() = + runTest { + // runCatching catches Throwable, so this used to be swallowed and the caller ran on to + // startActivity() on an activity that had already been destroyed. + try { + FeedbackManager.metricsAttachmentUri({ throw CancellationException("destroyed") }) { uri } + throw AssertionError("expected the cancellation to propagate") + } catch (expected: CancellationException) { + assertThat(expected).hasMessageThat().isEqualTo("destroyed") + } + } +} From 489cf63f5c9e742b3fdd95fcd77beb4c9f8888ea Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 07:40:01 -0700 Subject: [PATCH 091/128] ADFA-5526: stop paying for a fresh file on every report This processor is registered for every Sentry event, not only crashes, and the IDE captures non-fatals on purpose -- in bursts, on whatever thread noticed, the main one included. Each one formatted and gzipped a full 3600-row buffer synchronously on that thread: 10-15ms measured on a desktop JVM, more on a phone. A crash pays that once and it does not matter; a burst pays it per event, and on the main thread that is a stutter per event. A file written within the last five seconds is handed out again instead. Every event still gets an attachment, and a crash gives up at most five seconds of its own tail -- short on purpose, because that tail is the part worth having. The existence check is not belt and braces: MetricsCsvFile prunes to the few most recent, so a file handed out here can be deleted by a later write. Not split by event type, which is the obvious alternative: the IDE reports its own crashes through a plain Sentry.captureException, so SentryEvent.isCrashed is false for them and there is nothing at this level that tells a crash from a deliberate capture. Three tests; the burst one fails without the reuse. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../handlers/MetricsCrashAttachment.kt | 86 ++++++++++++++++--- .../handlers/MetricsCrashAttachmentTest.kt | 82 ++++++++++++++++++ 2 files changed, 157 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt b/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt index 153b953947..e5c1796db9 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt @@ -18,6 +18,8 @@ package com.itsaky.androidide.handlers import android.content.Context +import android.os.SystemClock +import com.itsaky.androidide.utils.MetricsCsv import com.itsaky.androidide.utils.MetricsCsvFile import com.itsaky.androidide.utils.MetricsSnapshotAssembler import com.itsaky.androidide.utils.MetricsSource @@ -47,7 +49,26 @@ import java.io.File */ class MetricsCrashAttachment( private val context: Context, + private val nowMillis: () -> Long = SystemClock::elapsedRealtime, + private val writeFile: (MetricsCsv.Snapshot) -> File? = { snapshot -> + MetricsCsvFile.writeForReport(context, snapshot) + }, ) : EventProcessor { + /** + * The last file written, and when. Reused rather than rewritten for a moment afterwards. + * + * Not synchronised: two events racing here write two files and one of them wins the field, + * which costs a write and loses nothing. A lock would be the more expensive mistake, since this + * runs on the thread of whatever is being reported. + */ + @Volatile + private var recent: Recent? = null + + private class Recent( + val atMillis: Long, + val file: File, + ) + override fun process( event: SentryEvent, hint: Hint, @@ -69,22 +90,65 @@ class MetricsCrashAttachment( hint.addAttachment(Attachment(file.absolutePath, file.name, MetricsCsvFile.COMPRESSED_MIME_TYPE)) } - private fun writeSnapshot(metrics: MetricsSource.Metrics): File? = - MetricsSnapshotAssembler.withSnapshot( - context = context, - memory = metrics.memoryUsageWatcher, - network = metrics.networkUsageWatcher, - power = metrics.powerUsageWatcher, - annotations = metrics.annotations, - ) { snapshot -> - // Nothing sampled yet is nothing to say. A header-only attachment on every early crash - // would be noise in the reports rather than context. - if (!snapshot.hasRows) null else MetricsCsvFile.writeForReport(context, snapshot) + /** + * The file to attach, writing one if the last is too old to stand in. + * + * This runs on the thread of whatever is being reported, and it is not cheap: a full buffer is + * 3600 rows, which format and gzip in 10-15ms on a desktop JVM and a good deal more on a phone. + * A crash pays that once and it does not matter. But this processor is deliberately registered + * for *every* event, including the non-fatal `Sentry.captureException` calls the IDE makes on + * purpose -- and those arrive in bursts, on whatever thread noticed, the main one included. Paid + * per event that is a visible stutter per event. + * + * So a file written moments ago is handed out again instead. The window is short because + * freshness matters most at exactly the moment this is for: a crash gets at most + * [REUSE_WINDOW_MS] less of its own tail, while a burst of non-fatals collapses to one write. + * Every event still gets an attachment, which distinguishing crashes from non-fatals would not + * manage here -- the IDE reports its own crashes through a plain `captureException`, so + * `SentryEvent.isCrashed` is false for them and there is nothing at this level to tell the two + * apart. + * + * The existence check is not belt and braces: [MetricsCsvFile] prunes its directory to the few + * most recent, so a file handed out here can be deleted by a later write. + */ + private fun writeSnapshot(metrics: MetricsSource.Metrics): File? { + val now = nowMillis() + recent?.let { last -> + if (now - last.atMillis < REUSE_WINDOW_MS && last.file.exists()) { + return last.file + } + } + + val file = + MetricsSnapshotAssembler.withSnapshot( + context = context, + memory = metrics.memoryUsageWatcher, + network = metrics.networkUsageWatcher, + power = metrics.powerUsageWatcher, + annotations = metrics.annotations, + ) { snapshot -> + // Nothing sampled yet is nothing to say. A header-only attachment on every early + // crash would be noise in the reports rather than context. + if (!snapshot.hasRows) null else writeFile(snapshot) + } + if (file != null) { + recent = Recent(now, file) } + return file + } companion object { private val log = LoggerFactory.getLogger(MetricsCrashAttachment::class.java) + /** + * How long a written file stands in for the next one. + * + * Short deliberately: the cost this bounds is a burst of non-fatals, which arrive far + * faster than this, and the thing it risks is the tail of a crash, which is the part worth + * having. + */ + const val REUSE_WINDOW_MS = 5_000L + /** Registers this processor. Call once, from within `SentryAndroid.init`. */ fun install( options: SentryOptions, diff --git a/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt index 41f5be08e6..f80fa709d4 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt @@ -119,6 +119,88 @@ class MetricsCrashAttachmentTest { assertThat(process().attachments).isEmpty() } + @Test + fun `a burst of reports pays for one file, not one each`() { + MetricsSource.register(FakeMetrics(sampledWatcher())) + var writes = 0 + var clock = 1_000L + val processor = + MetricsCrashAttachment( + context = context, + nowMillis = { clock }, + writeFile = { snapshot -> + writes++ + MetricsCsvFile.writeForReport(context, snapshot) + }, + ) + + // This is registered for every event, not only crashes, and the IDE captures non-fatals + // deliberately -- in bursts, on whatever thread noticed, the main one included. A full + // buffer formats and gzips in 10-15ms on a desktop JVM and more on a phone, so paid per + // event that is a visible stutter per event. + val filenames = + (1..5).map { + clock += 100L + val hint = Hint() + processor.process(SentryEvent(), hint) + hint.attachments.single().filename + } + + assertThat(writes).isEqualTo(1) + assertThat(filenames.toSet()).hasSize(1) + } + + @Test + fun `a report after the window gets a file of its own`() { + MetricsSource.register(FakeMetrics(sampledWatcher())) + var writes = 0 + var clock = 1_000L + val processor = + MetricsCrashAttachment( + context = context, + nowMillis = { clock }, + writeFile = { snapshot -> + writes++ + MetricsCsvFile.writeForReport(context, snapshot) + }, + ) + + processor.process(SentryEvent(), Hint()) + // Freshness is what matters at a crash, so the reuse has to expire rather than latch. + clock += MetricsCrashAttachment.REUSE_WINDOW_MS + processor.process(SentryEvent(), Hint()) + + assertThat(writes).isEqualTo(2) + } + + @Test + fun `a reused file that has been pruned away is written again`() { + MetricsSource.register(FakeMetrics(sampledWatcher())) + var writes = 0 + val clock = 1_000L + val processor = + MetricsCrashAttachment( + context = context, + nowMillis = { clock }, + writeFile = { snapshot -> + writes++ + MetricsCsvFile.writeForReport(context, snapshot) + }, + ) + + val first = Hint() + processor.process(SentryEvent(), first) + // MetricsCsvFile keeps only the few most recent, so a file handed out here can be deleted + // by a later write. An attachment naming a file that is gone is worse than none. + File(first.attachments.single().pathname!!).delete() + + val second = Hint() + processor.process(SentryEvent(), second) + + assertThat(writes).isEqualTo(2) + assertThat(File(second.attachments.single().pathname!!).exists()).isTrue() + } + @Test fun `the event is returned unchanged even when the attachment fails`() { // The whole point of the guard: a report with no metrics beats no report. A watcher whose From 8786d3fe5ed1d28db201d9d2efcbdc83d496d252 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 15:28:49 -0700 Subject: [PATCH 092/128] ADFA-5553: a dot for the legend, set where it actually holds The 15dp squares crowded the label beside them and the axis below. They are 8dp circles now -- MPAndroidChart's own default, and about the cap height of the 10dp legend text, so the marker reads as part of its label rather than as a block next to it. A circle is also about 79% of the area of a square at the same nominal size, so the drop in visual weight is larger than 15 to 8 suggests. Set on the legend, in configure, and NOT on the datasets -- which is the whole difficulty. All three renderers set formSize = 15f per dataset, and LegendRenderer resolves each entry as isNaN(entry.formSize) ? legend.formSize : entry.formSize, taking the legend's value only when the dataset leaves it NaN. Setting the legend alone would have changed nothing. The per-dataset lines are gone, so one place governs all three pages and a new renderer inherits it instead of having to remember. formLineWidth went with them: it only applies to the LINE form, which none of these use. The dot scales with its label, to the same 1.5 ceiling. A fixed marker beside text at 1.5 reads as though it were shrinking. The test worth having is not "legend.form == CIRCLE", which restates a setter and would pass against the code this ticket exists to change. It pins the deference: every computed LegendEntry must still be DEFAULT with a NaN formSize, so the legend's value is the one that reaches the screen. Restoring formSize = 15f on a dataset fails it. Verified on a Pixel 6 Pro at font scale 1.0 and 2.0. Note the 2.0 check needs an app restart rather than a live setting change: applyTextScale runs only from setData, and EditorActivityKt declares fontScale in configChanges, so a live change never reaches the chart. That is V28 in ADFA-5544, seen here rather than argued. This does NOT fix the legend clipping in ADFA-5544 (V30). Going 15dp to 8dp recovers about 7dp per entry, which reduces the pressure, but the cause is fixed geometry with word wrap off. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MemoryUsageChartRenderer.kt | 2 - .../androidide/ui/MetricsChartRenderer.kt | 19 +++ .../ui/NetworkUsageChartRenderer.kt | 2 - .../androidide/ui/PowerUsageChartRenderer.kt | 2 - .../ui/MetricsChartLegendFormTest.kt | 130 ++++++++++++++++++ 5 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.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 1772089b39..a313351855 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -106,8 +106,6 @@ class MemoryUsageChartRenderer( setDrawCircles(false) setDrawCircleHole(false) setDrawValues(false) - formLineWidth = 1f - formSize = 15f isHighlightEnabled = false label = labelFor(proc.pname, entries.lastOrNull()?.y ?: 0f) } 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 a4dffae4e3..b6952fb583 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -27,6 +27,7 @@ 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.Legend import com.github.mikephil.charting.components.LimitLine import com.github.mikephil.charting.components.XAxis import com.github.mikephil.charting.data.LineData @@ -277,6 +278,14 @@ abstract class MetricsChartRenderer( // axis, and AxisBase defaults to drawing them. axisLeft.setDrawGridLines(false) + // A dot, not a square, and smaller than the 15dp square each renderer used to ask for + // per dataset (ADFA-5553). The squares crowded the labels beside them and the axis + // below. Set here rather than on the datasets because that is the only place it holds: + // LegendRenderer takes the dataset's value whenever it is not NaN and only falls back + // to the legend otherwise, so a dataset that sets formSize silently wins and every new + // renderer has to remember not to. + legend.form = Legend.LegendForm.CIRCLE + onChartGestureListener = XAxisTapListener(this) xAxis.valueFormatter = ElapsedTimeFormatter(sampleIntervalMillis) @@ -510,6 +519,8 @@ abstract class MetricsChartRenderer( private fun applyTextScale(chart: SafeLineChart) { val scale = textScaleFor(chart.context) chart.legend.textSize = BASE_TEXT_SIZE_DP * scale + // Scaled with its label: a fixed dot beside text at 1.5 reads as though it were shrinking. + chart.legend.formSize = BASE_LEGEND_FORM_DP * scale chart.xAxis.textSize = BASE_TEXT_SIZE_DP * scale chart.axisLeft.textSize = BASE_TEXT_SIZE_DP * scale chart.axisRight.textSize = BASE_TEXT_SIZE_DP * scale @@ -712,6 +723,14 @@ abstract class MetricsChartRenderer( /** MPAndroidChart's own default for value labels. */ const val BASE_VALUE_TEXT_SIZE_DP = 9f + /** + * The legend's dot, in dp, at a font scale of 1. + * + * MPAndroidChart's own default, and about the cap height of [BASE_TEXT_SIZE_DP] text, so the + * marker reads as part of its label rather than as a block beside it. + */ + const val BASE_LEGEND_FORM_DP = 8f + /** * The most the chart will grow its text by, whatever the system font scale. * 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 af4b093d9f..eeb6d833de 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -133,8 +133,6 @@ class NetworkUsageChartRenderer( setDrawCircles(false) setDrawCircleHole(false) setDrawValues(false) - formLineWidth = 1f - formSize = 15f isHighlightEnabled = false this.label = labelFor(label, samples.lastOrNull() ?: 0L) } diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index ef16b749c1..48d07ebe15 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -275,8 +275,6 @@ class PowerUsageChartRenderer( setDrawCircles(false) setDrawCircleHole(false) setDrawValues(false) - formLineWidth = 1f - formSize = 15f isHighlightEnabled = false this.label = labelFor(label, values.lastOrNull(), axis) } diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt new file mode 100644 index 0000000000..8b49275adc --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt @@ -0,0 +1,130 @@ +/* + * 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 androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.components.Legend +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MutableShiftedLongArray +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The legend marker is a small dot, and it stays one (ADFA-5553). + * + * The squares were 15dp and set per dataset, which crowded the label beside them and the axis + * below. The size now comes from the legend, and that only works while no dataset overrides it: + * `LegendRenderer` takes the dataset's `formSize` whenever it is not NaN and falls back to the + * legend's only otherwise, so one renderer setting it again would silently take the setting back + * without anything failing. That deference is what these tests pin -- asserting `legend.form` alone + * would restate a setter and pass against the code this ticket exists to change. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartLegendFormTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun memoryChart(): SafeLineChart { + val chart = SafeLineChart(context) + MemoryUsageChartRenderer( + usagesProvider = { + arrayOf( + MemoryUsageWatcher.ProcessMemoryInfo(1, "IDE", MutableShiftedLongArray(SAMPLES)), + MemoryUsageWatcher.ProcessMemoryInfo(2, "Gradle Tooling", MutableShiftedLongArray(SAMPLES)), + ) + }, + lineColorFor = { 0 }, + ).attach(chart) + return chart + } + + private fun networkChart(): SafeLineChart { + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1L }, LongArray(SAMPLES) { 1L }) + }, + ).attach(chart) + return chart + } + + private fun powerChart(): SafeLineChart { + val chart = SafeLineChart(context) + PowerUsageChartRenderer( + usageProvider = { + PowerUsageWatcher.PowerUsage( + LongArray(SAMPLES) { 30_000L }, + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 0L }, + ) + }, + batteryProvider = { PowerUsageWatcher.BatteryState.UNKNOWN }, + ).attach(chart) + return chart + } + + private fun charts() = listOf("memory" to memoryChart(), "network" to networkChart(), "power" to powerChart()) + + @Test + fun `every page's legend marker is a dot`() { + charts().forEach { (name, chart) -> + assertThat(chart.legend.form).isEqualTo(Legend.LegendForm.CIRCLE) + assertThat(name to chart.legend.formSize) + .isEqualTo(name to MetricsChartRenderer.BASE_LEGEND_FORM_DP) + } + } + + @Test + fun `no dataset overrides the legend, which is the only reason the legend's value applies`() { + charts().forEach { (name, chart) -> + chart.layOutAndDraw() + + // LegendRenderer resolves each entry as `isNaN(entry.formSize) ? legend.formSize : + // entry.formSize`, and likewise takes the legend's form only for an entry left at + // DEFAULT. A dataset that sets either one wins silently -- which is what all three + // renderers used to do with `formSize = 15f`. + val entries = chart.legend.entries + assertThat(name to entries.isNotEmpty()).isEqualTo(name to true) + entries.forEach { entry -> + assertThat(name to entry.form).isEqualTo(name to Legend.LegendForm.DEFAULT) + assertThat(name to entry.formSize.isNaN()).isEqualTo(name to true) + } + } + } + + @Test + @Config(fontScale = 2.0f) + fun `the dot grows with its label, to the same ceiling`() { + // A fixed marker beside text at the 1.5 ceiling reads as though it were shrinking. The + // ceiling is the chart's, not the platform's, so this is 1.5 rather than 2.0. + val expected = MetricsChartRenderer.BASE_LEGEND_FORM_DP * MetricsChartRenderer.MAX_TEXT_SCALE + + charts().forEach { (name, chart) -> + assertThat(name to chart.legend.formSize).isEqualTo(name to expected) + } + } + + private companion object { + const val SAMPLES = 60 + } +} From 1b5deed247612624f9701367ed3dc4662e8a87d7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 15:51:11 -0700 Subject: [PATCH 093/128] ADFA-5553: fix the review findings, including a vacuous assertion of my own The size assertion at the default scale could not fail. MPAndroidChart's Legend constructor sets mFormSize to 8f -- verified in the 3.1.0.21 bytecode -- which is exactly the value this renderer asks for, so the assertion passed with the production line deleted. Removed, with a note saying why there is no size assertion at scale 1, and the scale test now asserts the literal 12f rather than recomputing BASE_LEGEND_FORM_DP * MAX_TEXT_SCALE, which could only ever show that a multiplication happened. Deleting the formSize line now fails that test. applyTextScale is called from redraw as well as setData. Without it nothing this commit sets could ever change in a running app: EditorActivityKt declares fontScale in configChanges, so the activity is not recreated, and redraw is the only path a live chart takes per sample -- while applyAnnotations, called from redraw, does re-read the scale, so the annotation rows spread for text that never grew. That is V28 in ADFA-5544, closed here because this change depends on it. The legend's spacing goes with the dot: formToTextSpace and xEntrySpace scale too. Fixed, they close up as the text grows, which is the same argument the dot is changed for. formLineWidth is kept at 1f on the legend. Removing it from the datasets dropped the fallback to the library's 3f, which is inert while the form is a circle and wrong the day anyone picks LINE. Test fixes: the memory page passed colour 0, which LegendRenderer skips outright, so that page never drew a form at all; assertions carry the page name through assertWithMessage instead of a Pair, which also restores a float tolerance; and the NaN rule is stated once rather than three times. Left alone deliberately: charts() still enumerates the three renderers by hand, so a fourth would not be covered. Reflective subclass discovery costs more than it is worth here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 33 +++++++++++++++---- .../ui/MetricsChartLegendFormTest.kt | 31 +++++++++-------- 2 files changed, 45 insertions(+), 19 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 b6952fb583..4613156a64 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -278,13 +278,17 @@ abstract class MetricsChartRenderer( // axis, and AxisBase defaults to drawing them. axisLeft.setDrawGridLines(false) - // A dot, not a square, and smaller than the 15dp square each renderer used to ask for - // per dataset (ADFA-5553). The squares crowded the labels beside them and the axis - // below. Set here rather than on the datasets because that is the only place it holds: - // LegendRenderer takes the dataset's value whenever it is not NaN and only falls back - // to the legend otherwise, so a dataset that sets formSize silently wins and every new - // renderer has to remember not to. + // A dot, not the 15dp square each renderer used to ask for per dataset (ADFA-5553): + // the squares crowded the labels beside them and the axis below. + // + // On the legend, never on a dataset. LegendRenderer resolves each entry as + // `isNaN(entry.formSize) ? legend.formSize : entry.formSize`, and takes the legend's + // form only for an entry left at DEFAULT -- so a dataset that sets either one wins + // silently. The size itself is set in [applyTextScale], which has to re-apply it. legend.form = Legend.LegendForm.CIRCLE + // Kept at the 1f the renderers used to ask for. Inert while the form is a circle, but + // leaving it NaN would silently adopt the library's 3f the day anyone chooses LINE. + legend.formLineWidth = 1f onChartGestureListener = XAxisTapListener(this) @@ -520,7 +524,12 @@ abstract class MetricsChartRenderer( val scale = textScaleFor(chart.context) chart.legend.textSize = BASE_TEXT_SIZE_DP * scale // Scaled with its label: a fixed dot beside text at 1.5 reads as though it were shrinking. + // See [configure] for why the size is the legend's business and not a dataset's. chart.legend.formSize = BASE_LEGEND_FORM_DP * scale + // The gaps go with them. Left fixed they close up as the text grows -- the same argument + // as the dot, applied to the space around it. + chart.legend.formToTextSpace = BASE_LEGEND_FORM_TO_TEXT_DP * scale + chart.legend.xEntrySpace = BASE_LEGEND_ENTRY_SPACE_DP * scale chart.xAxis.textSize = BASE_TEXT_SIZE_DP * scale chart.axisLeft.textSize = BASE_TEXT_SIZE_DP * scale chart.axisRight.textSize = BASE_TEXT_SIZE_DP * scale @@ -678,6 +687,12 @@ abstract class MetricsChartRenderer( // Same order as [setData], and for the same reason: the bounds have to be in place before // the notify that turns them into a transform. applyAxisRanges(chart) + // Re-read the font scale here too, not only in [setData]. EditorActivityKt declares + // fontScale in configChanges, so the activity is never recreated for one -- and this is + // the only path a running chart takes per sample. Left out, a live scale change moved the + // annotation rows, which [applyAnnotations] re-reads below, while none of the text or the + // legend dot it spaces them for ever grew. + applyTextScale(chart) chart.apply { data.notifyDataChanged() notifyDataSetChanged() @@ -731,6 +746,12 @@ abstract class MetricsChartRenderer( */ const val BASE_LEGEND_FORM_DP = 8f + /** The gap between a legend dot and its label, in dp, at a font scale of 1. */ + const val BASE_LEGEND_FORM_TO_TEXT_DP = 5f + + /** The gap between one legend entry and the next, in dp, at a font scale of 1. */ + const val BASE_LEGEND_ENTRY_SPACE_DP = 6f + /** * The most the chart will grow its text by, whatever the system font scale. * diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt index 8b49275adc..c0102b6f00 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt @@ -21,6 +21,7 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import com.github.mikephil.charting.components.Legend import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MutableShiftedLongArray import com.itsaky.androidide.utils.NetworkUsageWatcher @@ -53,7 +54,7 @@ class MetricsChartLegendFormTest { MemoryUsageWatcher.ProcessMemoryInfo(2, "Gradle Tooling", MutableShiftedLongArray(SAMPLES)), ) }, - lineColorFor = { 0 }, + lineColorFor = { android.graphics.Color.BLUE }, ).attach(chart) return chart } @@ -88,21 +89,20 @@ class MetricsChartLegendFormTest { @Test fun `every page's legend marker is a dot`() { charts().forEach { (name, chart) -> - assertThat(chart.legend.form).isEqualTo(Legend.LegendForm.CIRCLE) - assertThat(name to chart.legend.formSize) - .isEqualTo(name to MetricsChartRenderer.BASE_LEGEND_FORM_DP) + assertWithMessage(name).that(chart.legend.form).isEqualTo(Legend.LegendForm.CIRCLE) } } + // Deliberately no size assertion at the default scale: MPAndroidChart's own Legend constructor + // sets formSize to 8f, which is the value this renderer asks for, so such an assertion passes + // with the production line deleted. The scale test below is what pins the size, because 12f is + // a number only this code produces. + @Test fun `no dataset overrides the legend, which is the only reason the legend's value applies`() { charts().forEach { (name, chart) -> chart.layOutAndDraw() - // LegendRenderer resolves each entry as `isNaN(entry.formSize) ? legend.formSize : - // entry.formSize`, and likewise takes the legend's form only for an entry left at - // DEFAULT. A dataset that sets either one wins silently -- which is what all three - // renderers used to do with `formSize = 15f`. val entries = chart.legend.entries assertThat(name to entries.isNotEmpty()).isEqualTo(name to true) entries.forEach { entry -> @@ -115,16 +115,21 @@ class MetricsChartLegendFormTest { @Test @Config(fontScale = 2.0f) fun `the dot grows with its label, to the same ceiling`() { - // A fixed marker beside text at the 1.5 ceiling reads as though it were shrinking. The - // ceiling is the chart's, not the platform's, so this is 1.5 rather than 2.0. - val expected = MetricsChartRenderer.BASE_LEGEND_FORM_DP * MetricsChartRenderer.MAX_TEXT_SCALE - + // A fixed marker beside text at the ceiling reads as though it were shrinking. Spelled out + // rather than derived from BASE_LEGEND_FORM_DP * MAX_TEXT_SCALE: computing the expectation + // from the same two constants the code multiplies can only show that a multiplication + // happened. 12f is 8dp at the chart's 1.5 ceiling -- which is the chart's, not the + // platform's 2.0 -- so raising either constant has to come and change this line. charts().forEach { (name, chart) -> - assertThat(name to chart.legend.formSize).isEqualTo(name to expected) + assertWithMessage(name).that(chart.legend.formSize).isWithin(TOLERANCE).of(12f) + assertWithMessage(name).that(chart.legend.textSize).isWithin(TOLERANCE).of(15f) } } private companion object { const val SAMPLES = 60 + + /** The sizes are computed in floats and read back, so they land near-exactly. */ + const val TOLERANCE = 0.01f } } From c82cb5b3a05c3089083643ec679abef0d3b08901 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 15:56:07 -0700 Subject: [PATCH 094/128] ADFA-5553: scale the legend's third gap, and stop claiming a shared ceiling Two review findings I had left. The commit before this said "the legend's spacing goes with the dot" while scaling two of the three values it named. yOffset now scales with the rest, and it is not only cosmetic: isOnAxisBand measures the sampling-rate tap band as height - (legend.mNeededHeight + legend.yOffset). Left fixed while the text and the dot grow, the band creeps back over the legend -- the ADFA-5510 defect this stack has already fixed once. The dot and its label do not share a ceiling, whatever applyTextScale reads like: ComponentBase.setTextSize clamps to 6..24dp on the way in and Legend.setFormSize does not. Inert at today's 15dp, and wrong the moment BASE_TEXT_SIZE_DP goes past 16 -- the text would stop growing while the dot kept going, until the marker was bigger than the label it sits inside. Recorded on the constant rather than fixed, because clamping a value nothing reaches would be dead code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../itsaky/androidide/ui/MetricsChartRenderer.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 4613156a64..ce4526ec53 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -530,6 +530,11 @@ abstract class MetricsChartRenderer( // as the dot, applied to the space around it. chart.legend.formToTextSpace = BASE_LEGEND_FORM_TO_TEXT_DP * scale chart.legend.xEntrySpace = BASE_LEGEND_ENTRY_SPACE_DP * scale + // yOffset is not only cosmetic: [isOnAxisBand] measures the band the sampling-rate tap + // lives in as `height - (legend.mNeededHeight + legend.yOffset)`. Left unscaled, the band + // creeps over the legend again as the text grows -- which is the ADFA-5510 defect this + // stack already fixed once. + chart.legend.yOffset = BASE_LEGEND_Y_OFFSET_DP * scale chart.xAxis.textSize = BASE_TEXT_SIZE_DP * scale chart.axisLeft.textSize = BASE_TEXT_SIZE_DP * scale chart.axisRight.textSize = BASE_TEXT_SIZE_DP * scale @@ -743,6 +748,13 @@ abstract class MetricsChartRenderer( * * MPAndroidChart's own default, and about the cap height of [BASE_TEXT_SIZE_DP] text, so the * marker reads as part of its label rather than as a block beside it. + * + * The dot and its label do not in fact share a ceiling, whatever [applyTextScale] reads + * like: `ComponentBase.setTextSize` clamps to 6..24dp on the way in and `Legend.setFormSize` + * does not. It makes no difference while [BASE_TEXT_SIZE_DP] times [MAX_TEXT_SCALE] stays + * under 24 -- 15dp today -- and above that the text would stop growing while the dot kept + * going, until the marker was larger than the label it is meant to sit inside. Raising + * [BASE_TEXT_SIZE_DP] past 16 means clamping this too. */ const val BASE_LEGEND_FORM_DP = 8f @@ -752,6 +764,9 @@ abstract class MetricsChartRenderer( /** The gap between one legend entry and the next, in dp, at a font scale of 1. */ const val BASE_LEGEND_ENTRY_SPACE_DP = 6f + /** The gap the legend keeps above itself, in dp, at a font scale of 1. */ + const val BASE_LEGEND_Y_OFFSET_DP = 3f + /** * The most the chart will grow its text by, whatever the system font scale. * From 0d475fca7d533200dff32f709d71e63b4f56dce4 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 16:17:46 -0700 Subject: [PATCH 095/128] ADFA-5554: hold, rather than brush, to get help Help was appearing at the platform's long-press timeout -- 400ms, a brisk tap, and shorter still on a device where the accessibility "touch and hold delay" has been lowered -- so the carousel's buttons answered with a tooltip instead of doing their job. The hold is now max(platform * 2, 800ms). The timing had to become ours rather than the framework's, and that is the whole difficulty. setOnLongClickListener fires at the platform timeout, and returning true from it sets mHasPerformedLongPress, which cancels the click. Simply deferring the tooltip would therefore leave a 500ms press doing nothing at all: no help, and no button either -- worse than the complaint. So performOnHold takes the touch over and performs the click itself, only when no hold completed. Never shorter than the platform's own value. That setting exists as an accessibility control and someone who lengthened it meant to. The long-click listener stays installed for accessibility. Touch never reaches View.onTouchEvent, so the framework cannot fire it from a finger; TalkBack's long press calls performLongClick directly, and that path still shows help at once, as it should -- it is already deliberate. The chart needs its own handling because MPAndroidChart's GestureDetector has already suppressed the tap by the time onChartLongPressed arrives. Its help is deferred for the rest of the hold and cancelled on gesture end -- and when the finger lifts early the axis-band tap is invoked directly, because the detector ate the one that would have opened the sampling-rate chooser. Without that a 500ms press on the axis would do nothing. Two bugs my own tests caught while writing them: View.postDelayed parks work in a HandlerActionQueue that only drains on attach, so the hold never timed out for a detached view -- an explicit Handler now; and a press that wandered off the control still clicked, where the framework would have done neither. performOnHold is split out from displayTooltipOnLongPress so the timing can be tested at all: TooltipManager reads the docs database from device storage in its static initialiser and cannot load off-device. Seven tests. The first is the 500ms case, and it fails if the click is suppressed the way the framework would. Verified on a Pixel 6 Pro: a 500ms press on the next arrow pages Memory to Network; a 1200ms hold opens the tooltip and does not page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 50 +++++- .../utils/LongPressHelpExtensions.kt | 4 + .../androidide/ui/LongPressHelpTimingTest.kt | 169 ++++++++++++++++++ .../com/itsaky/androidide/utils/ViewUtils.kt | 115 +++++++++++- 4 files changed, 328 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.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 ce4526ec53..fb5b0bf3fd 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -23,6 +23,7 @@ import android.os.SystemClock import android.util.TypedValue import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import androidx.annotation.CallSuper import androidx.annotation.UiThread import androidx.annotation.VisibleForTesting @@ -38,6 +39,7 @@ import com.github.mikephil.charting.listener.OnChartGestureListener import com.itsaky.androidide.R import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent import kotlin.math.ceil @@ -395,6 +397,15 @@ abstract class MetricsChartRenderer( private inner class XAxisTapListener( private val chart: SafeLineChart, ) : OnChartGestureListener { + /** The deferred half of a long press, waiting out the rest of the hold. */ + private var pendingHelp: Runnable? = null + + /** Whether this gesture already showed help, so its lift must not also count as a tap. */ + private var helpShown = false + + /** Whether the press that became a long press had started on the axis band. */ + private var pendingTapOnAxis = false + override fun onChartSingleTapped(me: MotionEvent?) { val y = me?.y ?: return if (isOnAxisBand(y)) { @@ -410,16 +421,43 @@ abstract class MetricsChartRenderer( override fun onChartGestureEnd( me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?, - ) = Unit + ) { + pendingHelp?.let(chart::removeCallbacks) + pendingHelp = null + // Lifted before the hold completed: the detector ate the tap, so stand in for it. + if (!helpShown && pendingTapOnAxis) { + onXAxisTap?.invoke() + } + helpShown = false + pendingTapOnAxis = false + } override fun onChartLongPressed(me: MotionEvent?) { val y = me?.y ?: return val tag = helpTagAt(y) ?: return - // Haptic feedback left at its default, unlike every view-based help site, which - // passes false. Those rely on View.performLongClick buzzing for them; - // BarLineChartBase.onTouchEvent never calls super, so the framework's long press -- - // and its feedback -- never runs here and this is the only thing that provides it. - showIdeCategoryTooltipIfPresent(chart.context, chart, tag) + + // This arrives at the platform's own timeout -- 400ms by default, a brisk tap -- and + // help at that speed is what ADFA-5554 is about. Wait out the rest of the hold and + // show it only if the finger is still down; [onChartGestureEnd] cancels otherwise. + pendingHelp?.let(chart::removeCallbacks) + val onAxisBand = isOnAxisBand(y) + pendingHelp = + Runnable { + pendingHelp = null + helpShown = true + // Haptic feedback left at its default, unlike every view-based help site, + // which passes false. Those rely on View.performLongClick buzzing for them; + // BarLineChartBase.onTouchEvent never calls super, so the framework's long + // press -- and its feedback -- never runs here and this is the only thing + // that provides it. + showIdeCategoryTooltipIfPresent(chart.context, chart, tag) + }.also { chart.postDelayed(it, longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout()) } + + // GestureDetector has already decided this gesture is a long press, so it will not + // report the tap that would have opened the sampling-rate chooser. Remember whether + // this one was headed there, so a finger lifted before the hold completes still gets + // the tap it asked for rather than nothing at all. + pendingTapOnAxis = onAxisBand } override fun onChartDoubleTapped(me: MotionEvent?) = Unit diff --git a/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt b/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt index 43e3b76b00..97e8765212 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt @@ -31,4 +31,8 @@ import android.view.View fun View.clearLongPressHelp() { setOnLongClickListener(null) isLongClickable = false + // The hold is timed by a touch listener rather than the framework (ADFA-5554), so leaving that + // installed would keep the view swallowing every touch -- and performing its own clicks -- for + // help it no longer offers. + setOnTouchListener(null) } diff --git a/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt new file mode 100644 index 0000000000..60a17e6014 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt @@ -0,0 +1,169 @@ +/* + * 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.Looper +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.widget.Button +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.clearLongPressHelp +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis +import com.itsaky.androidide.utils.performOnHold +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +/** + * How long a press has to last before help replaces the control (ADFA-5554). + * + * The platform fires a long press at 400ms, which is a brisk tap, so the carousel's buttons were + * answering with a tooltip instead of doing their job. The interesting case is neither the long + * press nor the short one -- it is the press in between. At 500ms the framework has already + * decided the gesture is a long press and cancelled the click, so a fix that merely defers the + * tooltip leaves that press doing nothing whatsoever: no help, and no button either. That is the + * first test here, and it is why the timing is this code's rather than the framework's. + * + * The hold's payload is a lambda rather than a real tooltip because `TooltipManager` reads the + * docs database from device storage in its static initialiser and cannot be loaded off-device -- + * the same reason the renderer separates deciding a help tag from showing one. + */ +@RunWith(RobolectricTestRunner::class) +class LongPressHelpTimingTest { + private val context = ApplicationProvider.getApplicationContext() + + private var holds = 0 + + private var clicks = 0 + + private fun target(): Button = + Button(context).apply { + setOnClickListener { clicks++ } + performOnHold { holds++ } + } + + private fun send( + view: View, + action: Int, + x: Float = 0f, + ) { + val event = MotionEvent.obtain(0L, 0L, action, x, 0f, 0) + view.dispatchTouchEvent(event) + event.recycle() + } + + /** Runs the main looper forward by [millis] of virtual time. */ + private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) + + @Test + fun `a press past the platform timeout but short of the hold still clicks`() { + // The regression the obvious fix introduces, and the reason this class exists. The + // framework's long press is 400ms and the hold is 800ms; everything between the two would + // otherwise be dead. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(ViewConfiguration.getLongPressTimeout() + 100L) + send(view, MotionEvent.ACTION_UP) + + assertThat(clicks).isEqualTo(1) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a quick tap clicks`() { + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_UP) + + assertThat(clicks).isEqualTo(1) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a press held past the hold shows help and does not click`() { + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(longPressHelpTimeoutMillis() + 50L) + send(view, MotionEvent.ACTION_UP) + + assertThat(holds).isEqualTo(1) + assertThat(clicks).isEqualTo(0) + } + + @Test + fun `a press that wanders off the control does neither`() { + val view = target() + val slop = ViewConfiguration.get(context).scaledTouchSlop + + send(view, MotionEvent.ACTION_DOWN) + elapse(100L) + send(view, MotionEvent.ACTION_MOVE, x = slop + 10f) + elapse(longPressHelpTimeoutMillis()) + send(view, MotionEvent.ACTION_UP) + + // The framework treats a drag out of a view as neither, so taking the touch over means + // saying so rather than inventing a third behaviour. + assertThat(clicks).isEqualTo(0) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a cancelled gesture does neither`() { + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(100L) + send(view, MotionEvent.ACTION_CANCEL) + elapse(longPressHelpTimeoutMillis()) + + assertThat(clicks).isEqualTo(0) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `the hold is longer than the platform's, and never shorter`() { + // The floor matters: the platform value is exposed as an accessibility "touch and hold + // delay", and someone who lengthened it meant to. + assertThat(longPressHelpTimeoutMillis()).isAtLeast(800L) + assertThat(longPressHelpTimeoutMillis()).isAtLeast(ViewConfiguration.getLongPressTimeout().toLong()) + } + + @Test + fun `clearing the help stops the timing`() { + val view = target() + view.clearLongPressHelp() + + send(view, MotionEvent.ACTION_DOWN) + elapse(longPressHelpTimeoutMillis() + 50L) + send(view, MotionEvent.ACTION_UP) + + // Left installed, the listener would go on timing holds -- and swallowing every touch -- + // for help the view no longer offers. + assertThat(holds).isEqualTo(0) + assertThat(view.isLongClickable).isFalse() + } +} diff --git a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt index 02213fd571..24fb584b7b 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt @@ -1,10 +1,15 @@ package com.itsaky.androidide.utils import android.content.Context +import android.os.Handler +import android.os.Looper import android.view.HapticFeedbackConstants +import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager +import kotlin.math.abs /** * Shows [tag]'s tooltip (under [category]) anchored to [anchor], or does nothing if [tag] is @@ -40,17 +45,119 @@ fun showIdeCategoryTooltipIfPresent( ) = showTooltipIfPresent(context, anchor, TooltipCategory.CATEGORY_IDE, tag, playHapticFeedback) /** - * Installs a long-click listener on this view that consumes the click and shows [tooltipTag]'s - * tooltip (under [tooltipCategory]) anchored to this view, or does nothing if [tooltipTag] is - * blank. See [showTooltipIfPresent] - no manual haptic feedback here for the same reason. + * How long a press has to be held before help appears, in milliseconds. + * + * Twice the platform's own long-press timeout, floored at 800ms. The platform default is 400ms, + * which is a brisk tap, so help was appearing instead of the control activating (ADFA-5554). + * + * Never *shorter* than the platform's value: that setting is exposed as an accessibility + * "touch and hold delay", and someone who has lengthened it did so deliberately. + */ +fun longPressHelpTimeoutMillis(): Long = maxOf(ViewConfiguration.getLongPressTimeout() * 2L, 800L) + +/** + * Shows [tooltipTag]'s tooltip (under [tooltipCategory]) when this view is held for + * [holdMillis], and lets a shorter press through as an ordinary click. + * + * The timing is this function's rather than the framework's, and that is the whole point. + * `setOnLongClickListener` fires at [ViewConfiguration.getLongPressTimeout] -- 400ms by default -- + * and returning `true` from it sets `mHasPerformedLongPress`, which cancels the click. So simply + * deferring the tooltip would leave a 500ms press doing nothing at all: no help, and no button + * press either. Instead the touch is taken over outright, and the click is performed here only + * when no tooltip was shown. + * + * The long-click listener stays installed for accessibility. Touch never reaches + * [View.onTouchEvent], so the framework cannot fire it from a finger; TalkBack's own long-press + * calls [View.performLongClick] directly, and that path shows help immediately, as it should -- + * it is already a deliberate gesture. + * + * On a [android.view.ViewGroup] this only sees touches its children did not take, which is what + * makes it safe to install on a container for the gaps between its controls. */ fun View.displayTooltipOnLongPress( context: Context, tooltipTag: String, tooltipCategory: String = TooltipCategory.CATEGORY_IDE, + holdMillis: Long = longPressHelpTimeoutMillis(), ) { - this.setOnLongClickListener { + if (tooltipTag.isBlank()) { + return + } + + setOnLongClickListener { showTooltipIfPresent(context, this, tooltipCategory, tooltipTag, playHapticFeedback = false) true } + + // Haptic feedback on, unlike the long-click path above: nothing else buzzes here, because the + // framework's own long press never runs for this view. + performOnHold(holdMillis) { showTooltipIfPresent(context, this, tooltipCategory, tooltipTag) } +} + +/** + * Runs [onHold] when this view is held for [holdMillis], and lets a shorter press through as an + * ordinary click. + * + * Separated from [displayTooltipOnLongPress] so the timing can be tested: `TooltipManager` reads + * the docs database from device storage in its static initialiser and cannot be loaded off-device, + * so a test that showed a real tooltip could not run at all. + */ +fun View.performOnHold( + holdMillis: Long = longPressHelpTimeoutMillis(), + onHold: () -> Unit, +) { + val slop = ViewConfiguration.get(context).scaledTouchSlop + // An explicit handler, not View.postDelayed: a view not attached to a window parks posted work + // in its HandlerActionQueue and only runs it on attach, so the hold would never time out. + val handler = Handler(Looper.getMainLooper()) + var held = false + var holding = false + var downX = 0f + var downY = 0f + val fire = + Runnable { + held = true + isPressed = false + onHold() + } + + setOnTouchListener { view, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + held = false + holding = true + downX = event.x + downY = event.y + view.isPressed = true + handler.postDelayed(fire, holdMillis) + } + + MotionEvent.ACTION_MOVE -> { + if (holding && (abs(event.x - downX) > slop || abs(event.y - downY) > slop)) { + // Wandered off the control: neither a click nor help, which is how the + // framework treats a drag out of a view. Taking the touch over means saying so. + holding = false + handler.removeCallbacks(fire) + view.isPressed = false + } + } + + MotionEvent.ACTION_UP -> { + handler.removeCallbacks(fire) + view.isPressed = false + // The click belongs to a press that stayed put and did not become a hold. + if (holding && !held) { + view.performClick() + } + holding = false + } + + MotionEvent.ACTION_CANCEL -> { + holding = false + handler.removeCallbacks(fire) + view.isPressed = false + } + } + true + } } From 49a6c7d632add2d873382015ae4ae0dddd13e675 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 16:42:50 -0700 Subject: [PATCH 096/128] ADFA-5542: say which build a cancel belongs to The listener held a bare flag meaning "a cancel happened recently", and the only thing tying it to the build it belonged to was the order two main-thread runnables happened to run in. onBuildCancelRequested is raised on the UI thread and so runs inline; prepareBuild is raised from the build's own thread and so is posted. A cancel landing after that post and before it ran was cleared by it, and the build the user stopped was annotated as a failure -- which is the one thing BUILD_CANCELLED exists to prevent. Fixing the order would only move the window, so the cancel is keyed to a build instead. BuildInfo and BuildResult already carry a BuildId; the listener interface was dropping it one line from where it was needed. onBuildCancelRequested, onBuildSuccessful and onBuildFailed now carry the id, the service remembers which build is running, and the listener compares rather than sequences. That subsumes the other flag too. annotatedBuild existed for the same missing information -- the outcome callbacks are handed the server's task list, not the one prepareBuild saw, so the listener kept a flag to know whether the finish it was looking at belonged to the start it drew. Both are now nullable ids, and neither needs clearing per build: an id left from a build whose outcome never arrived cannot match the next build's, so prepareBuild no longer resets anything. The interface has no defaulted members (ADFA-5509), so every widening is a compile error for both implementers rather than a silent drop; the wrapper test now also asserts the id survives the forward, since a wrapper that passed the call and dropped the argument would be that same defect one layer in with nothing to complain about it. The decision is extracted as outcomeKind so it can be asserted without a live activity, matching isAnnotated beside it. Restoring the clear prepareBuild used to do fails the new test, expected BUILD_CANCELLED but was BUILD_FAILED, and fails nothing else. Not reproduced on device: the Stop control only appears after a 150ms delayed menu invalidation queued behind prepareBuild, so the window is not reachable by hand. It is reachable in a test, which is what the regression case drives. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../handlers/EditorBuildEventListener.kt | 92 +++++++++++++------ .../services/builder/GradleBuildService.kt | 73 ++++++++++++--- .../EditorBuildEventListenerAnnotationTest.kt | 63 +++++++++---- .../GradleBuildServiceListenerWrapperTest.kt | 26 +++++- 4 files changed, 189 insertions(+), 65 deletions(-) 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 c40e68d7b2..cd9fb59616 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -26,6 +26,7 @@ import com.itsaky.androidide.projects.builder.BuildResult import com.itsaky.androidide.projects.builder.LaunchResult import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.services.builder.GradleBuildService +import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.result.BuildInfo import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.tooling.events.configuration.ProjectConfigurationStartEvent @@ -50,14 +51,22 @@ class EditorBuildEventListener : GradleBuildService.EventListener { private var lastOutputTimeMs: Long = SystemClock.elapsedRealtime() /** - * Set when the user asks for the running build to stop, so [onBuildFailed] can tell a cancel - * from a real failure. Cleared as each build is prepared. + * The build the user asked to stop, so [onBuildFailed] can tell a cancel from a real failure. + * + * A build id and not a flag. A flag said only "a cancel happened recently", and the one thing + * tying it to the build it belonged to was the order two main-thread runnables happened to run + * in: [onBuildCancelRequested] is raised on the UI thread and so runs inline, while + * [prepareBuild] is raised from the build's own thread and so is posted. A cancel arriving + * after that post and before it ran was cleared by it, and the build the user stopped was + * annotated as a failure (ADFA-5542). Identity does not depend on that order, and there is + * nothing to clear per build: an id left over from a build whose outcome never arrived cannot + * match the next build's. */ @VisibleForTesting - internal var cancelRequested = false + internal var cancelledBuildId: BuildId? = null /** - * Whether the build now running drew a "Build started" marker. + * The build that drew a "Build started" marker, or null if the one running drew none. * * The outcome callbacks used to decide for themselves, from the task list they are handed -- * a different list from the one prepareBuild sees. If those two ever disagreed the chart got @@ -65,7 +74,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { * exists to avoid. The build that started decides, and its outcome follows. */ @VisibleForTesting - internal var annotatedBuild = false + internal var annotatedBuildId: BuildId? = null private var enabled = true private var activityReference: WeakReference = WeakReference(null) @@ -99,13 +108,6 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } override fun prepareBuild(buildInfo: BuildInfo) { - // Before the activity check, not after: this listener outlives any one activity, so a - // build whose outcome arrived with none attached would otherwise leave both flags set for - // the next build to inherit -- a stale cancel mislabelling a real failure, or a stale - // pairing drawing a finish for a build that never started. - cancelRequested = false - annotatedBuild = false - val act = checkActivity("prepareBuild") ?: return // A project sync runs through the same callbacks with no tasks, so annotating every @@ -115,7 +117,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { // The outcome callbacks are handed their own task list, which is not this one. Recorded // here so the pair is decided once, by the build that started. if (buildInfo.tasks.isNotEmpty()) { - annotatedBuild = true + annotatedBuildId = buildInfo.buildId act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_STARTED) } @@ -143,18 +145,50 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } } + /** + * Drops what is held for [buildId] now that its outcome has been reported. + * + * Only for that build: another id in either field belongs to a build whose outcome has not + * arrived, and clearing it would lose the pairing or the cancel that build is owed. + */ + private fun forget(buildId: BuildId) { + if (cancelledBuildId == buildId) { + cancelledBuildId = null + } + if (annotatedBuildId == buildId) { + annotatedBuildId = null + } + } + + /** + * Whether the failure of [buildId] is the user's own cancel or a real failure (ADFA-5542). + * + * Separated from [onBuildFailed] 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 outcomeKind(buildId: BuildId): MetricsAnnotationStore.Kind = + if (cancelledBuildId == buildId) { + MetricsAnnotationStore.Kind.BUILD_CANCELLED + } else { + MetricsAnnotationStore.Kind.BUILD_FAILED + } + private fun resetBuildTimers() { buildStartTimeMs = System.currentTimeMillis() lastOutputTimeMs = SystemClock.elapsedRealtime() } - override fun onBuildSuccessful(tasks: List) { + override fun onBuildSuccessful( + buildId: BuildId, + tasks: List, + ) { val act = checkActivity("onBuildSuccessful") ?: return - if (annotatedBuild) { + if (annotatedBuildId == buildId) { act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FINISHED) } - annotatedBuild = false + forget(buildId) pluginBuildService?.notifyBuildFinished() @@ -183,8 +217,12 @@ class EditorBuildEventListener : GradleBuildService.EventListener { lastStatusLine = "" } - override fun onBuildCancelRequested() { - cancelRequested = true + override fun onBuildCancelRequested(buildId: BuildId?) { + // A cancel with no build running names nothing to attribute it to. Leaving the id already + // held alone keeps a build still finishing from being relabelled by it. + if (buildId != null) { + cancelledBuildId = buildId + } } override fun onProgressEvent(event: ProgressEvent) { @@ -212,22 +250,18 @@ class EditorBuildEventListener : GradleBuildService.EventListener { @VisibleForTesting internal fun isAnnotated(event: ProgressEvent): Boolean = event is TaskStartEvent || event is TaskFinishEvent - override fun onBuildFailed(tasks: List) { + override fun onBuildFailed( + buildId: BuildId, + tasks: List, + ) { val act = checkActivity("onBuildFailed") ?: return - if (annotatedBuild) { + if (annotatedBuildId == buildId) { // A build the user stopped arrives through this same callback. Marking it as a failure // would report their own deliberate action back to them in the error colour. - act.recordBuildAnnotation( - if (cancelRequested) { - MetricsAnnotationStore.Kind.BUILD_CANCELLED - } else { - MetricsAnnotationStore.Kind.BUILD_FAILED - }, - ) + act.recordBuildAnnotation(outcomeKind(buildId)) } - annotatedBuild = false - cancelRequested = false + forget(buildId) analyzeCurrentFile() GeneralPreferences.isFirstBuild = false diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 437698d64d..4062a25a01 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -129,6 +129,16 @@ class GradleBuildService : private val buildSessionId = UUID.randomUUID().toString() private val buildId = AtomicLong(0) + /** + * The build a cancel request would belong to, or null when none is running. + * + * The listener cannot work this out from the order it is called in (ADFA-5542), so it is told, + * and this is what it is told. Written from the build's own thread in [prepareBuild] and read + * from the UI thread by [cancelCurrentBuild], hence volatile. + */ + @Volatile + private var runningBuildId: BuildId? = null + @Volatile private var tuningConfig: GradleTuningConfig? = null @@ -181,24 +191,30 @@ class GradleBuildService : null } else { object : EventListener { - override fun onBuildCancelRequested() { - runOnUiThread { listener.onBuildCancelRequested() } + override fun onBuildCancelRequested(buildId: BuildId?) { + runOnUiThread { listener.onBuildCancelRequested(buildId) } } override fun prepareBuild(buildInfo: BuildInfo) { runOnUiThread { listener.prepareBuild(buildInfo) } } - override fun onBuildSuccessful(tasks: List) { - runOnUiThread { listener.onBuildSuccessful(tasks) } + override fun onBuildSuccessful( + buildId: BuildId, + tasks: List, + ) { + runOnUiThread { listener.onBuildSuccessful(buildId, tasks) } } override fun onProgressEvent(event: ProgressEvent) { runOnUiThread { listener.onProgressEvent(event) } } - override fun onBuildFailed(tasks: List) { - runOnUiThread { listener.onBuildFailed(tasks) } + override fun onBuildFailed( + buildId: BuildId, + tasks: List, + ) { + runOnUiThread { listener.onBuildFailed(buildId, tasks) } } override fun onOutput(line: String?) { @@ -387,6 +403,12 @@ class GradleBuildService : override fun prepareBuild(buildInfo: BuildInfo): CompletableFuture = CompletableFuture.supplyAsync { + // The server raises this only for a build that really started, so a second request + // rejected as already-in-progress cannot take the running build's name off the cancel. + // It is also well before the editor is told the build began, and the Stop control + // follows from that, so a cancel can never arrive with this unset. + runningBuildId = buildInfo.buildId + updateNotification(getString(R.string.build_status_in_progress), true) val projectPath = ProjectManagerImpl.getInstance().projectDirPath ?: "unknown" @@ -457,20 +479,26 @@ class GradleBuildService : updateNotification(getString(R.string.build_status_sucess), false) dispatchBuildResult(result, true) - eventListener?.onBuildSuccessful(result.tasks) + eventListener?.onBuildSuccessful(result.buildId, result.tasks) } override fun onBuildFailed(result: BuildResult) { updateNotification(getString(R.string.build_status_failed), false) dispatchBuildResult(result, false) - eventListener?.onBuildFailed(result.tasks) + eventListener?.onBuildFailed(result.buildId, result.tasks) } private fun dispatchBuildResult( result: BuildResult, isSuccess: Boolean, ) { + // Only if it is still this build's: a build whose result never arrived leaves its id here, + // and clearing that on someone else's outcome would drop the name off a live cancel. + if (runningBuildId == result.buildId) { + runningBuildId = null + } + val buildType = getBuildType(result.tasks) analyticsManager.trackBuildCompleted( metric = @@ -666,8 +694,9 @@ class GradleBuildService : override fun cancelCurrentBuild(): CompletableFuture { checkServerStarted() // Before delegating: the cancellation surfaces as a build failure, and the listener needs - // to know it was asked for rather than reporting the user's own action as an error. - eventListener?.onBuildCancelRequested() + // to know it was asked for rather than reporting the user's own action as an error. It is + // told which build, because it cannot infer that from when this arrives (ADFA-5542). + eventListener?.onBuildCancelRequested(runningBuildId) return server!!.cancelCurrentBuild() } @@ -826,8 +855,13 @@ class GradleBuildService : * then quietly inherited the no-op instead of passing it on -- so the cancel never reached * the real listener, and a build the user stopped went on being annotated as a failure. A * member with no default cannot be forgotten by a wrapper; the compiler asks for it. + * + * @param buildId The build being stopped, or null if none was running. Carried because a + * listener cannot tell from arrival order which build a cancel belongs to: this call is + * made on the UI thread and runs inline, while [prepareBuild] is made from the build's + * own thread and is posted (ADFA-5542). */ - fun onBuildCancelRequested() + fun onBuildCancelRequested(buildId: BuildId?) /** * Called just before a build is started. @@ -840,10 +874,15 @@ class GradleBuildService : /** * Called when a build is successful. * + * @param buildId The build that succeeded. [tasks] is the server's own list and not the one + * [prepareBuild] was given, so this is the only thing that names the build. * @param tasks The tasks that were run. * @see IToolingApiClient.onBuildSuccessful */ - fun onBuildSuccessful(tasks: List) + fun onBuildSuccessful( + buildId: BuildId, + tasks: List, + ) /** * Called when a progress event is received from the Tooling API server. @@ -855,10 +894,18 @@ class GradleBuildService : /** * Called when a build fails. * + * A build the user cancelled arrives here too; compare [buildId] with the one + * [onBuildCancelRequested] named to tell the two apart. + * + * @param buildId The build that failed. [tasks] is the server's own list and not the one + * [prepareBuild] was given, so this is the only thing that names the build. * @param tasks The tasks that were run. * @see IToolingApiClient.onBuildFailed */ - fun onBuildFailed(tasks: List) + fun onBuildFailed( + buildId: BuildId, + tasks: List, + ) /** * Called when the output line is received. diff --git a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt index 5fe152155e..466bdf8b39 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.handlers import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.result.BuildInfo import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.tooling.events.internal.DefaultOperationDescriptor @@ -28,20 +29,25 @@ 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 com.itsaky.androidide.utils.MetricsAnnotationStore import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner /** - * Which Gradle progress events the metrics charts annotate (ADFA-5486). + * What the metrics charts annotate, and which build each annotation belongs to. * - * 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. + * Two decisions, both asserted against the predicate that makes them rather than through the + * callback that acts on it -- those need a live activity before they get this far. Which progress + * events are marked at all (ADFA-5486), and whether a build that failed was really the user + * stopping it (ADFA-5542). */ @RunWith(RobolectricTestRunner::class) class EditorBuildEventListenerAnnotationTest { private val listener = EditorBuildEventListener() + private fun buildId(id: Long) = BuildId(buildSessionId = "session", buildId = id, runType = BuildRunType.TaskRun) + private fun taskDescriptor() = TaskOperationDescriptor( dependencies = emptySet(), @@ -74,28 +80,45 @@ class EditorBuildEventListenerAnnotationTest { ) @Test - fun `preparing a build clears a stale cancel, even with no activity attached`() { - listener.cancelRequested = true - - // No activity is attached here, so prepareBuild returns early -- which is the point. This - // listener outlives any one activity, and a cancel whose onBuildFailed arrived without one - // would otherwise leave the flag set for the next build to inherit and be mislabelled. - listener.prepareBuild(BuildInfo(BuildId.Unknown, listOf(":app:assembleDebug"))) - - assertThat(listener.cancelRequested).isFalse() + fun `a cancel that lands before its build is prepared still marks that build cancelled`() { + // The interleaving ADFA-5542 is about, and the one the main thread can really produce: + // onBuildCancelRequested is raised on the UI thread and runs inline, prepareBuild is + // raised from the build's own thread and is posted, so the cancel can overtake it. When + // the listener held a bare flag, prepareBuild cleared it and the build the user stopped + // was reported back to them as a failure. + listener.onBuildCancelRequested(buildId(7)) + listener.prepareBuild(BuildInfo(buildId(7), listOf(":app:assembleDebug"))) + + assertThat(listener.outcomeKind(buildId(7))) + .isEqualTo(MetricsAnnotationStore.Kind.BUILD_CANCELLED) } @Test - fun `preparing a build clears a stale pairing`() { - listener.annotatedBuild = true + fun `a cancel is not inherited by the next build`() { + // The other half of keying to a build rather than to a moment. This listener outlives any + // one activity, so a cancel whose outcome never arrived stays held -- and must not relabel + // the next build's genuine failure. + listener.onBuildCancelRequested(buildId(7)) + + assertThat(listener.outcomeKind(buildId(8))) + .isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) + } - // The flag means "a start marker was drawn for the build now running", so a new build - // must not inherit it: the outcome callbacks read it to decide whether to draw the other - // half of the pair, and they are handed a different task list from this one. Cleared - // before the activity check for the same reason as the cancel flag. - listener.prepareBuild(BuildInfo(BuildId.Unknown, listOf(":app:assembleDebug"))) + @Test + fun `a build nobody stopped is a failure`() { + assertThat(listener.outcomeKind(buildId(7))) + .isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) + } - assertThat(listener.annotatedBuild).isFalse() + @Test + fun `a cancel naming no build leaves the one already held alone`() { + // cancelCurrentBuild passes null when nothing is running. Taking that as "forget the + // cancel" would lose the attribution for a build still finishing. + listener.onBuildCancelRequested(buildId(7)) + listener.onBuildCancelRequested(null) + + assertThat(listener.outcomeKind(buildId(7))) + .isEqualTo(MetricsAnnotationStore.Kind.BUILD_CANCELLED) } @Test diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt index d515017ffa..a8d8ca2f33 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.services.builder import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.services.builder.GradleBuildService.EventListener +import com.itsaky.androidide.tooling.api.messages.BuildId import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -40,12 +41,15 @@ class GradleBuildServiceListenerWrapperTest { private class Recorder { val calls = mutableListOf() + var lastArgs: List = emptyList() + val listener: EventListener = Proxy.newProxyInstance( EventListener::class.java.classLoader, arrayOf(EventListener::class.java), - ) { _, method, _ -> + ) { _, method, args -> calls += method.name + lastArgs = args.orEmpty().toList() null } as EventListener } @@ -71,14 +75,30 @@ class GradleBuildServiceListenerWrapperTest { } @Test - fun `a cancel request reaches the listener`() { + fun `a cancel request reaches the listener, naming its build`() { val recorder = Recorder() val wrapped = GradleBuildService.wrap(recorder.listener)!! - wrapped.onBuildCancelRequested() + wrapped.onBuildCancelRequested(BuildId.Unknown) // The one this went wrong on, kept as its own case so the reason is legible in a report. assertThat(recorder.calls).containsExactly("onBuildCancelRequested") + + // The id is the whole of what makes a cancel attributable (ADFA-5542). A wrapper that + // forwarded the call and dropped the argument would be the same defect one layer in, and + // no signature would complain about it. + assertThat(recorder.lastArgs).containsExactly(BuildId.Unknown) + } + + @Test + fun `an outcome reaches the listener with the build it belongs to`() { + val recorder = Recorder() + val wrapped = GradleBuildService.wrap(recorder.listener)!! + + wrapped.onBuildFailed(BuildId.Unknown, listOf(":app:assembleDebug")) + + assertThat(recorder.calls).containsExactly("onBuildFailed") + assertThat(recorder.lastArgs).containsExactly(BuildId.Unknown, listOf(":app:assembleDebug")).inOrder() } @Test From ac5ff676f32a24145a6890a62c08980d8385f529 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 16:53:45 -0700 Subject: [PATCH 097/128] ADFA-5553: fix the review findings - units, per-tick cost, untested gaps Three, all from the xhigh review. The size test compared a pixel getter against a dp constant and only passed because Robolectric defaults to a density of 1.0. Confirmed against the 3.1.0.21 bytecode rather than assumed: Legend stores formSize, formToTextSpace and xEntrySpace as the dp it is given and converts at draw time, while ComponentBase.setTextSize and setYOffset convert on the way in. So two of these properties are pixels and three are dp, and at density 1.0 nothing tells them apart. The test now runs at xhdpi, asserts the density it depends on, and expects pixels where pixels are stored. The old expectation fails there. Three of the four newly scaled legend properties had no assertion at all, including yOffset -- which isOnAxisBand measures the sampling-rate tap band from, so an unscaled offset walks that band back over the legend as text grows, the ADFA-5510 defect this stack already fixed once. All four are asserted now, and unscaling any one of them fails the test. applyTextScale ran from redraw, which is once per sampling tick per attached page: a Configuration read, four setTextSize calls each doing a dp conversion, four legend setters, a setValueTextSize across every dataset (wasted outright -- all three renderers setDrawValues(false)), and two setLabelCount calls, all to catch a change that happens a handful of times in a session. The applied scale is cached per chart and redraw applies only when it has moved; setData still applies unconditionally, since it installs fresh LineData and the value text size belongs to the data. detach clears the cache so a rebind re-applies. The saving is only safe while a live scale change still lands, so that has a test: EditorActivityKt handles fontScale itself, no activity is recreated, and a redraw is the only thing a running chart does. It drives the in-place path deliberately -- the same sample, so the series keep their shape and it cannot fall back to a rebuild and pass by another route. Making the guarded call a no-op fails it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 31 +++++++++- .../ui/MetricsChartLegendFormTest.kt | 61 ++++++++++++++++--- 2 files changed, 81 insertions(+), 11 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 ce4526ec53..12f70924d0 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -133,6 +133,16 @@ abstract class MetricsChartRenderer( */ private var userHasZoomed = false + /** + * The font scale [applyTextScale] last wrote to the attached chart, or NaN if none. + * + * [redraw] runs once per sampling tick per attached page, and re-applying the scale there + * rewrites nine chart properties and re-measures four text sizes to catch a change that + * happens at most a handful of times in a session. Per chart, so a rebind re-applies: [detach] + * clears it. + */ + private var appliedTextScale = Float.NaN + /** * The attached chart, or `null` when no carousel page is bound to this renderer. */ @@ -197,6 +207,7 @@ abstract class MetricsChartRenderer( @CallSuper open fun detach() { userHasZoomed = false + appliedTextScale = Float.NaN chart?.removeOnLayoutChangeListener(newestWindowOnLayout) chart = null } @@ -522,6 +533,7 @@ abstract class MetricsChartRenderer( @UiThread private fun applyTextScale(chart: SafeLineChart) { val scale = textScaleFor(chart.context) + appliedTextScale = scale chart.legend.textSize = BASE_TEXT_SIZE_DP * scale // Scaled with its label: a fixed dot beside text at 1.5 reads as though it were shrinking. // See [configure] for why the size is the legend's business and not a dataset's. @@ -549,6 +561,19 @@ abstract class MetricsChartRenderer( chart.axisRight.setLabelCount(labels, false) } + /** + * Applies the font scale only if it has moved since the last time it was applied. + * + * For [redraw], which runs per sample. [setData] applies unconditionally: it installs fresh + * [LineData], and the value text size is a property of the data rather than of the chart. + */ + @UiThread + private fun applyTextScaleIfChanged(chart: SafeLineChart) { + if (textScaleFor(chart.context) != appliedTextScale) { + applyTextScale(chart) + } + } + /** * Colours the value axes' labels. Called from [setData], not [configure], because the styling * here is re-applied on every redraw and would otherwise overwrite whatever a subclass had set @@ -696,8 +721,10 @@ abstract class MetricsChartRenderer( // fontScale in configChanges, so the activity is never recreated for one -- and this is // the only path a running chart takes per sample. Left out, a live scale change moved the // annotation rows, which [applyAnnotations] re-reads below, while none of the text or the - // legend dot it spaces them for ever grew. - applyTextScale(chart) + // legend dot it spaces them for ever grew. Only when it has actually moved, though: this + // runs on every tick of every attached page and the answer changes a handful of times a + // session. + applyTextScaleIfChanged(chart) chart.apply { data.notifyDataChanged() notifyDataSetChanged() diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt index c0102b6f00..f9101d76ce 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt @@ -40,6 +40,9 @@ import org.robolectric.annotation.Config * legend's only otherwise, so one renderer setting it again would silently take the setting back * without anything failing. That deference is what these tests pin -- asserting `legend.form` alone * would restate a setter and pass against the code this ticket exists to change. + * + * The size test runs at xhdpi on purpose. MPAndroidChart stores half of these properties in dp and + * half in pixels, and at Robolectric's default density of 1.0 nothing tells the two apart. */ @RunWith(RobolectricTestRunner::class) class MetricsChartLegendFormTest { @@ -113,19 +116,59 @@ class MetricsChartLegendFormTest { } @Test - @Config(fontScale = 2.0f) - fun `the dot grows with its label, to the same ceiling`() { - // A fixed marker beside text at the ceiling reads as though it were shrinking. Spelled out - // rather than derived from BASE_LEGEND_FORM_DP * MAX_TEXT_SCALE: computing the expectation - // from the same two constants the code multiplies can only show that a multiplication - // happened. 12f is 8dp at the chart's 1.5 ceiling -- which is the chart's, not the - // platform's 2.0 -- so raising either constant has to come and change this line. + @Config(fontScale = 2.0f, qualifiers = "xhdpi") + fun `the dot, its gaps and its label all grow together, to the same ceiling`() { + // A fixed marker beside text at the ceiling reads as though it were shrinking, and so do + // the gaps around it. Spelled out rather than derived from BASE_LEGEND_FORM_DP * + // MAX_TEXT_SCALE: computing the expectation from the same two constants the code + // multiplies can only show that a multiplication happened. Each figure below is its dp + // constant at the chart's 1.5 ceiling -- the chart's, not the platform's 2.0 -- so raising + // either constant has to come and change this line. + // + // Half of these properties are stored in pixels and half in dp, which is MPAndroidChart's + // doing and not ours: Legend keeps formSize, formToTextSpace and xEntrySpace as the dp it + // was given and converts them when it draws, while ComponentBase.setTextSize and + // setYOffset convert on the way in. At Robolectric's default density of 1.0 the two are + // indistinguishable and a pixel getter compared against a dp constant passes anyway, so + // this runs at xhdpi where they differ by 2x. + assertThat(context.resources.displayMetrics.density).isWithin(TOLERANCE).of(2f) + charts().forEach { (name, chart) -> - assertWithMessage(name).that(chart.legend.formSize).isWithin(TOLERANCE).of(12f) - assertWithMessage(name).that(chart.legend.textSize).isWithin(TOLERANCE).of(15f) + val legend = chart.legend + assertWithMessage("$name formSize").that(legend.formSize).isWithin(TOLERANCE).of(12f) + assertWithMessage("$name formToTextSpace").that(legend.formToTextSpace).isWithin(TOLERANCE).of(7.5f) + assertWithMessage("$name xEntrySpace").that(legend.xEntrySpace).isWithin(TOLERANCE).of(9f) + + // 15dp and 4.5dp, in pixels. yOffset is the one with a consequence beyond looks: + // isOnAxisBand measures the sampling-rate tap band as + // `height - (legend.mNeededHeight + legend.yOffset)`, so an unscaled offset walks the + // band back over the legend as the text grows -- the ADFA-5510 defect this stack has + // already fixed once. + assertWithMessage("$name textSize").that(legend.textSize).isWithin(TOLERANCE).of(30f) + assertWithMessage("$name yOffset").that(legend.yOffset).isWithin(TOLERANCE).of(9f) } } + @Test + fun `a font scale changed mid-session still reaches the chart through a redraw`() { + // The per-tick path applies the scale only when it has moved, so this is the case that + // guards the saving: EditorActivityKt handles fontScale itself, so no activity is + // recreated and a redraw is the only thing a running chart does. + val usage = NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1L }, LongArray(SAMPLES) { 1L }) + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage }) + renderer.attach(chart) + assertThat(chart.legend.formSize).isWithin(TOLERANCE).of(8f) + + context.resources.configuration.fontScale = 2.0f + // The same sample, so the series keep their shape and this takes the in-place redraw + // rather than falling back to a rebuild, which would apply the scale by another route + // and prove nothing. + renderer.onUsageChanged(usage) + + assertThat(chart.legend.formSize).isWithin(TOLERANCE).of(12f) + } + private companion object { const val SAMPLES = 60 From ac03b125882726a51afacca9e8cd6ec4882f10e6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 17:24:09 -0700 Subject: [PATCH 098/128] ADFA-5554: fix the review findings across the chart and the hold Twelve findings from the xhigh review. The chart half had none of its own tests, which is why most of them are there. A press that became a pan opened the sampling-rate chooser. The detector calls a press a long press at 400ms and a drag can start from it, so onChartGestureEnd stood in for a tap that was never a tap -- and picking a rate in that chooser clears every sample buffer, the history loss the tap band's lower bound exists to prevent, reached by another route. The stand-in now requires the gesture to still be a long press when it ended, and a translate or a scale gives it up as the gesture escalates. The deferred help had the same shape of problem one step along: it was cancelled at the end of a gesture but not when the gesture turned into something else, so the tooltip opened over a chart the user was in the middle of panning. Same fix, same place. detach did not cancel a hold that was counting down, and attach installs a fresh listener whose own pendingHelp is null -- so nothing could ever have cancelled the old one. It fired the outgoing page's help against whatever replaced it. The renderer holds the listener now and drops the hold with the chart. The chart timed its hold with View.postDelayed, which is the HandlerActionQueue trap this PR's own message says it found and replaced in performOnHold. It worked only because a chart that receives a long press happens to be attached. Explicit Handler, as on the other side, which is also what makes any of the above testable. Seven tests for that, in a class of their own. Help is a seam rather than a real tooltip: TooltipManager reads the docs database from device storage in its static initialiser and cannot load off-device. They live in MetricsChartHoldHelpTest and not beside the axis-tap tests because the two sets together exhaust the test JVM -- unbounded, not large; 4GB fails the same way -- while either alone is fine and no pair of them reproduces it. That is a Robolectric interaction, not a product defect, and splitting the class is where it belongs anyway. On the view side: performOnHold cancelled the click on any movement of one touch slop from the down point. The framework's rule is leaving the view grown by the slop, which the comment beside it already claimed. Measured from the down point, an ordinary thumb tap on a large target rolls far enough to cancel its own click without leaving the control, and these targets are large -- the carousel strip is the full width of the editor. It now uses the framework's rule, and a test rolls a slop and ten pixels across a 400px-wide button and expects a click. A hold already counting down could not be cancelled: the handler and the runnable were captured in a closure, so a teardown could only stop the next hold, not the one running. The listener is an object now, kept in a keyed view tag, and the teardown reaches through it. That also answers the complaint that the teardown nulled any touch listener at all: it removes one only where the tag says performOnHold installed it, which is one of the six views it is called on. The teardown moved to :idetooltips beside performOnHold, in two halves -- clearLongPressHelp for both, clearOnHold for the hold alone -- so a module that installed only a hold can undo only a hold. The name is unchanged, so call sites keep their import. The click ran inside touch dispatch. View.onTouchEvent posts it so the pressed state is drawn first, and these actions open dialogs and re-page the carousel from inside the dispatch of the event that triggered them. Posted now, through the same handler rather than View.post, which parks work on an unattached view and returns true having run nothing. The ripple also had no hotspot, so every one of these controls rippled from the centre of its drawable rather than the finger. A blank tooltip tag returned before installing anything, which left the previous tag's listeners in place. It now clears them: a blank tag says this view offers no help, and that has to replace what was wired before. The timeout test was vacuous. maxOf(x * 2, 800) is at least 800 and at least x for every x by construction, so both assertions held with the whole rule deleted. The platform timeout is a parameter now, which is the only way to name a value that separates the doubling from the floor, and there is a case for each. Sibling sweep: the six EditorBottomSheet action buttons -- share, clear, search, filter, word wrap, view options -- were still wired to the framework's 400ms long click and still returning true from it, so a 450ms press on "clear output" showed a tooltip and cleared nothing. That is the defect this ticket exists to fix, in a file this PR already touches. All six converted, and generateTooltipListener has no callers left. Deliberately left alone: the TabLayout tab long-press in the same file, because taking over a TabView's touch stream is a different risk and not one this can check off-device, and ActionMenuUtils, which the PR body already named as out of scope. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../itsaky/androidide/ui/EditorBottomSheet.kt | 25 +-- .../androidide/ui/MetricsChartRenderer.kt | 77 ++++++- .../utils/LongPressHelpExtensions.kt | 38 ---- .../androidide/ui/LongPressHelpTimingTest.kt | 135 ++++++++++- .../androidide/ui/MetricsChartHoldHelpTest.kt | 210 ++++++++++++++++++ .../com/itsaky/androidide/utils/ViewUtils.kt | 147 ++++++++++-- idetooltips/src/main/res/values/ids.xml | 8 + 7 files changed, 546 insertions(+), 94 deletions(-) delete mode 100644 app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt create mode 100644 idetooltips/src/main/res/values/ids.xml diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 7f464dbd2f..bc078478d4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -68,6 +68,7 @@ import com.itsaky.androidide.utils.DiagnosticsFormatter import com.itsaky.androidide.utils.IntentUtils.shareFile import com.itsaky.androidide.utils.Symbols.forFile import com.itsaky.androidide.utils.clearLongPressHelp +import com.itsaky.androidide.utils.displayTooltipOnLongPress import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess @@ -252,7 +253,7 @@ class EditorBottomSheet } } } - binding.shareOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SHARE_EXTERNAL)) + binding.shareOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_SHARE_EXTERNAL) binding.clearOutputAction.setOnClickListener { val fragment = @@ -263,7 +264,7 @@ class EditorBottomSheet } (fragment as ShareableOutputFragment).clearOutput() } - binding.clearOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_CLEAR)) + binding.clearOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_CLEAR) binding.copyDiagnosticsFab.setOnClickListener { copyDiagnosticsToClipboard() @@ -279,7 +280,7 @@ class EditorBottomSheet viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) fragment.beginSearch() } - binding.searchOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SEARCH)) + binding.searchOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_SEARCH) binding.filterOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) @@ -290,7 +291,7 @@ class EditorBottomSheet viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) fragment.toggleFilterBar() } - binding.filterOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_FILTER)) + binding.filterOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_FILTER) updateWordWrapButtonState(EditorPreferences.outputWordWrap) binding.wordWrapOutputAction.setOnClickListener { @@ -298,7 +299,7 @@ class EditorBottomSheet EditorPreferences.outputWordWrap = newState updateWordWrapButtonState(newState) } - binding.wordWrapOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_WORD_WRAP)) + binding.wordWrapOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_WORD_WRAP) binding.viewOptionsOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) @@ -306,7 +307,7 @@ class EditorBottomSheet fragment.showViewOptions(it) } } - binding.viewOptionsOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_VIEW_OPTIONS)) + binding.viewOptionsOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_VIEW_OPTIONS) binding.headerContainer.setOnClickListener { viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) @@ -388,18 +389,6 @@ class EditorBottomSheet } } - private fun generateTooltipListener(tooltipTag: String): OnLongClickListener = - OnLongClickListener { view: View -> - TooltipManager.showIdeCategoryTooltip( - context = context, - anchorView = view, - tag = tooltipTag, - ) - - // A long-click listener must return true to indicate it has consumed the event. - true - } - fun setCurrentTab( @BottomSheetViewModel.TabDef tabIndex: Int, ) { 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 e6f4eedd04..15b494d0a5 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,8 @@ package com.itsaky.androidide.ui import android.content.Context import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper import android.os.SystemClock import android.util.TypedValue import android.view.MotionEvent @@ -145,6 +147,23 @@ abstract class MetricsChartRenderer( */ private var appliedTextScale = Float.NaN + /** + * The gesture listener installed on the attached chart, kept so [detach] can reach its + * pending hold. Nothing else can: it lives on the chart, and a rebind installs a new one. + */ + private var axisTapListener: XAxisTapListener? = null + + /** + * How the chart's hold shows its help. + * + * A seam, not a setting: `TooltipManager` reads the docs database from device storage in its + * static initialiser and cannot be loaded off-device, so without this the whole deferred-help + * path -- when it fires, when it is given up -- could not be tested at all. + */ + @VisibleForTesting + internal var showHelp: (Context, SafeLineChart, String) -> Unit = + { context, anchor, tag -> showIdeCategoryTooltipIfPresent(context, anchor, tag) } + /** * The attached chart, or `null` when no carousel page is bound to this renderer. */ @@ -210,6 +229,12 @@ abstract class MetricsChartRenderer( open fun detach() { userHasZoomed = false appliedTextScale = Float.NaN + // A hold counting down survives the chart it was started on: the timer is on the main + // thread's queue. Left running it shows the outgoing page's help over whatever replaced + // it, and the replacement's listener -- a new object with its own null pendingHelp -- + // could never have cancelled it. + axisTapListener?.cancelPendingHelp() + axisTapListener = null chart?.removeOnLayoutChangeListener(newestWindowOnLayout) chart = null } @@ -303,7 +328,7 @@ abstract class MetricsChartRenderer( // leaving it NaN would silently adopt the library's 3f the day anyone chooses LINE. legend.formLineWidth = 1f - onChartGestureListener = XAxisTapListener(this) + onChartGestureListener = XAxisTapListener(this).also { axisTapListener = it } xAxis.valueFormatter = ElapsedTimeFormatter(sampleIntervalMillis) // One label per 15 samples keeps the window readable without crowding. @@ -408,6 +433,12 @@ abstract class MetricsChartRenderer( private inner class XAxisTapListener( private val chart: SafeLineChart, ) : OnChartGestureListener { + // An explicit handler, not View.postDelayed, which parks work on an unattached view's + // HandlerActionQueue until it attaches. The chart that receives a long press is attached, + // so that would happen to work -- but only by accident, and it puts the hold out of reach + // of a test. The same handler [performOnHold] uses, for the same reason. + private val handler = Handler(Looper.getMainLooper()) + /** The deferred half of a long press, waiting out the rest of the hold. */ private var pendingHelp: Runnable? = null @@ -433,16 +464,33 @@ abstract class MetricsChartRenderer( me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?, ) { - pendingHelp?.let(chart::removeCallbacks) - pendingHelp = null + cancelPendingHelp() // Lifted before the hold completed: the detector ate the tap, so stand in for it. - if (!helpShown && pendingTapOnAxis) { + // + // Only for a gesture that was still a long press when it ended. A press that became a + // pan or a pinch is not a tap by any reading, and standing in for one there opened the + // sampling-rate chooser from a drag -- which clears every sample buffer, the exact + // history loss [isOnAxisBand] was narrowed to prevent. [onChartTranslate] and + // [onChartScale] give up the stand-in as the gesture escalates; this is the check for + // an escalation neither of them reports. + if (!helpShown && pendingTapOnAxis && lastPerformedGesture == ChartTouchListener.ChartGesture.LONG_PRESS) { onXAxisTap?.invoke() } helpShown = false pendingTapOnAxis = false } + /** + * Drops a hold that has not fired and the tap it was standing in for. + * + * For the end of a gesture, for a gesture that turns into something else, and for + * [detach], which is the one caller outside the touch stream. + */ + fun cancelPendingHelp() { + pendingHelp?.let(handler::removeCallbacks) + pendingHelp = null + } + override fun onChartLongPressed(me: MotionEvent?) { val y = me?.y ?: return val tag = helpTagAt(y) ?: return @@ -450,7 +498,7 @@ abstract class MetricsChartRenderer( // This arrives at the platform's own timeout -- 400ms by default, a brisk tap -- and // help at that speed is what ADFA-5554 is about. Wait out the rest of the hold and // show it only if the finger is still down; [onChartGestureEnd] cancels otherwise. - pendingHelp?.let(chart::removeCallbacks) + cancelPendingHelp() val onAxisBand = isOnAxisBand(y) pendingHelp = Runnable { @@ -461,8 +509,8 @@ abstract class MetricsChartRenderer( // BarLineChartBase.onTouchEvent never calls super, so the framework's long // press -- and its feedback -- never runs here and this is the only thing // that provides it. - showIdeCategoryTooltipIfPresent(chart.context, chart, tag) - }.also { chart.postDelayed(it, longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout()) } + showHelp(chart.context, chart, tag) + }.also { handler.postDelayed(it, longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout()) } // GestureDetector has already decided this gesture is a long press, so it will not // report the tap that would have opened the sampling-rate chooser. Remember whether @@ -486,6 +534,7 @@ abstract class MetricsChartRenderer( scaleY: Float, ) { userHasZoomed = true + abandonGesture() } override fun onChartTranslate( @@ -497,6 +546,20 @@ abstract class MetricsChartRenderer( // 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 + abandonGesture() + } + + /** + * Gives up the deferred help and the stand-in tap, because this gesture has become + * something neither is meant for. + * + * A drag or a pinch can begin from a press the detector already called a long press, and + * the finger is then still down: the hold would go on to open a tooltip over a chart the + * user is in the middle of panning, and the lift would open the sampling-rate chooser. + */ + private fun abandonGesture() { + cancelPendingHelp() + pendingTapOnAxis = false } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt b/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt deleted file mode 100644 index 97e8765212..0000000000 --- a/app/src/main/java/com/itsaky/androidide/utils/LongPressHelpExtensions.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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.view.View - -/** - * Stops this view answering a long press. - * - * `setOnLongClickListener(null)` alone is not enough: [View.setOnLongClickListener] sets - * `isLongClickable` when it installs a listener but does not unset it when the listener is - * removed, so the view goes on consuming long presses -- and showing the system's own - * "performLongClick" feedback -- for help it no longer offers. Every teardown that clears a - * long-press help listener wants both halves, so it is one call. - */ -fun View.clearLongPressHelp() { - setOnLongClickListener(null) - isLongClickable = false - // The hold is timed by a touch listener rather than the framework (ADFA-5554), so leaving that - // installed would keep the view swallowing every touch -- and performing its own clicks -- for - // help it no longer offers. - setOnTouchListener(null) -} diff --git a/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt index 60a17e6014..4e3a41a07b 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt @@ -26,6 +26,7 @@ import android.widget.Button import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.utils.clearLongPressHelp +import com.itsaky.androidide.utils.displayTooltipOnLongPress import com.itsaky.androidide.utils.longPressHelpTimeoutMillis import com.itsaky.androidide.utils.performOnHold import org.junit.Test @@ -56,8 +57,14 @@ class LongPressHelpTimingTest { private var clicks = 0 + /** + * A control with a real size, which the move cases need: whether a touch is still on the view + * is measured against the view's bounds, so an unmeasured one collapses every position onto + * the same answer. + */ private fun target(): Button = Button(context).apply { + layout(0, 0, WIDTH, HEIGHT) setOnClickListener { clicks++ } performOnHold { holds++ } } @@ -65,9 +72,10 @@ class LongPressHelpTimingTest { private fun send( view: View, action: Int, - x: Float = 0f, + x: Float = CENTRE_X, + y: Float = CENTRE_Y, ) { - val event = MotionEvent.obtain(0L, 0L, action, x, 0f, 0) + val event = MotionEvent.obtain(0L, 0L, action, x, y, 0) view.dispatchTouchEvent(event) event.recycle() } @@ -75,6 +83,14 @@ class LongPressHelpTimingTest { /** Runs the main looper forward by [millis] of virtual time. */ private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) + /** + * Runs whatever is already due on the main looper without advancing the clock. + * + * The click is posted rather than performed inside the touch dispatch, as the framework does + * it, so nothing has clicked until the looper turns. + */ + private fun drain() = shadowOf(Looper.getMainLooper()).idle() + @Test fun `a press past the platform timeout but short of the hold still clicks`() { // The regression the obvious fix introduces, and the reason this class exists. The @@ -85,6 +101,7 @@ class LongPressHelpTimingTest { send(view, MotionEvent.ACTION_DOWN) elapse(ViewConfiguration.getLongPressTimeout() + 100L) send(view, MotionEvent.ACTION_UP) + drain() assertThat(clicks).isEqualTo(1) assertThat(holds).isEqualTo(0) @@ -97,6 +114,7 @@ class LongPressHelpTimingTest { send(view, MotionEvent.ACTION_DOWN) elapse(50L) send(view, MotionEvent.ACTION_UP) + drain() assertThat(clicks).isEqualTo(1) assertThat(holds).isEqualTo(0) @@ -109,21 +127,59 @@ class LongPressHelpTimingTest { send(view, MotionEvent.ACTION_DOWN) elapse(longPressHelpTimeoutMillis() + 50L) send(view, MotionEvent.ACTION_UP) + drain() assertThat(holds).isEqualTo(1) assertThat(clicks).isEqualTo(0) } @Test - fun `a press that wanders off the control does neither`() { + fun `the click is posted, not run inside the touch that ended it`() { + // View.onTouchEvent posts its click so the pressed state is drawn before the action runs, + // and these actions open dialogs and re-page the carousel from inside the dispatch of the + // event that triggered them. Taking the touch over means taking that over too. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_UP) + + assertThat(clicks).isEqualTo(0) + drain() + assertThat(clicks).isEqualTo(1) + } + + @Test + fun `a press that rolls but stays on the control still clicks`() { + // The framework gives up on a press when the finger leaves the view grown by the slop -- + // not when it has travelled slop from where it went down. Measured from the down point + // instead, an ordinary thumb tap on a large target rolls far enough to cancel its own + // click without ever leaving the control, and every one of these targets is large: the + // carousel strip is the full width of the editor. + val view = target() + val slop = ViewConfiguration.get(context).scaledTouchSlop + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_MOVE, x = CENTRE_X + slop + 10f) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(clicks).isEqualTo(1) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a press that leaves the control does neither`() { val view = target() val slop = ViewConfiguration.get(context).scaledTouchSlop send(view, MotionEvent.ACTION_DOWN) elapse(100L) - send(view, MotionEvent.ACTION_MOVE, x = slop + 10f) + send(view, MotionEvent.ACTION_MOVE, x = WIDTH + slop + 10f) elapse(longPressHelpTimeoutMillis()) send(view, MotionEvent.ACTION_UP) + drain() // The framework treats a drag out of a view as neither, so taking the touch over means // saying so rather than inventing a third behaviour. @@ -145,11 +201,28 @@ class LongPressHelpTimingTest { } @Test - fun `the hold is longer than the platform's, and never shorter`() { - // The floor matters: the platform value is exposed as an accessibility "touch and hold - // delay", and someone who lengthened it meant to. - assertThat(longPressHelpTimeoutMillis()).isAtLeast(800L) - assertThat(longPressHelpTimeoutMillis()).isAtLeast(ViewConfiguration.getLongPressTimeout().toLong()) + fun `a lengthened touch-and-hold delay is doubled, not ignored`() { + // Asserting isAtLeast against the live platform value pins nothing: maxOf(x * 2, 800) is + // at least 800 and at least x for every x by construction, so the whole rule could be + // deleted and such a test would still pass. Named values, and each of the two terms + // decides one of them. + // + // The delay is exposed as an accessibility setting, and someone who lengthened it meant + // to -- so the hold has to grow with it rather than staying at the floor. + assertThat(longPressHelpTimeoutMillis(platformTimeoutMillis = 1_000L)).isEqualTo(2_000L) + } + + @Test + fun `a shortened touch-and-hold delay still gets the floor`() { + // Doubling alone would put help back inside a brisk tap, which is the defect. + assertThat(longPressHelpTimeoutMillis(platformTimeoutMillis = 100L)).isEqualTo(800L) + } + + @Test + fun `the platform default lands on the floor`() { + // 400ms doubled is exactly the floor, so the two terms agree at the value almost every + // device reports -- which is why neither can be tested at it. + assertThat(longPressHelpTimeoutMillis(platformTimeoutMillis = 400L)).isEqualTo(800L) } @Test @@ -166,4 +239,48 @@ class LongPressHelpTimingTest { assertThat(holds).isEqualTo(0) assertThat(view.isLongClickable).isFalse() } + + @Test + fun `clearing the help cancels a hold already counting down`() { + // The teardown runs while a finger is down -- the carousel unbinds, the strip is replaced, + // the sheet is torn down. The timer is on the main thread's queue rather than on the view, + // so clearing the listeners does not reach it: held in a closure it was unreachable + // altogether, and the tooltip appeared over a control that had just been unwired. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(100L) + view.clearLongPressHelp() + elapse(longPressHelpTimeoutMillis()) + + assertThat(holds).isEqualTo(0) + } + + @Test + fun `re-wiring with a blank tag takes the previous tag's help away`() { + // A blank tag says this view offers no help, which has to replace whatever was wired here + // before. Returning early instead left the previous listeners in place, still timing holds + // and still swallowing every touch. + val view = target() + view.displayTooltipOnLongPress(context, tooltipTag = "") + + send(view, MotionEvent.ACTION_DOWN) + elapse(longPressHelpTimeoutMillis() + 50L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(holds).isEqualTo(0) + assertThat(view.isLongClickable).isFalse() + } + + private companion object { + /** Big enough that a roll of one touch slop is still well inside it. */ + const val WIDTH = 400 + + const val HEIGHT = 200 + + const val CENTRE_X = WIDTH / 2f + + const val CENTRE_Y = HEIGHT / 2f + } } diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt new file mode 100644 index 0000000000..019a406191 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt @@ -0,0 +1,210 @@ +/* + * 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.Looper +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.listener.ChartTouchListener +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +/** + * When the chart answers a hold with help, and when it gives that help up (ADFA-5554). + * + * The platform reports its long press at 400ms, which is a brisk tap, so the chart waits out the + * rest of the hold before showing anything. Two things have to be true of that wait: it happens, + * and it is abandoned when the gesture turns into something a hold is not -- a pan, a pinch, or a + * page being unbound underneath it. + * + * The help itself is a seam rather than a real tooltip. `TooltipManager` reads the docs database + * from device storage in its static initialiser and cannot be loaded off-device, which is the same + * reason the renderer separates deciding a help tag from showing one. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartHoldHelpTest { + private val context = ApplicationProvider.getApplicationContext() + + private var taps = 0 + + private var helps = 0 + + private lateinit var renderer: NetworkUsageChartRenderer + + private fun laidOutChart(): SafeLineChart { + val chart = SafeLineChart(context) + // Any concrete renderer will do -- the hold is the base class's, and every page wires it + // the same way. + renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + }, + ) + renderer.attach(chart) + renderer.onXAxisTap = { taps++ } + renderer.showHelp = { _, _, _ -> helps++ } + + chart.layOutAndDraw() + return chart + } + + private fun eventAt(y: Float) = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, y, 0) + + /** The platform's own long press, which is where the chart's hold starts counting from. */ + private fun longPressAt( + chart: SafeLineChart, + y: Float, + ) { + val event = eventAt(y) + chart.onChartGestureListener.onChartLongPressed(event) + event.recycle() + } + + private fun panBy( + chart: SafeLineChart, + dx: Float, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartTranslate(event, dx, 0f) + event.recycle() + } + + private fun endGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartGestureEnd(event, gesture) + event.recycle() + } + + /** Runs the main looper forward by [millis] of virtual time. */ + private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) + + /** The rest of the hold, after the platform's long press has already been reported. */ + private fun remainderOfHold() = longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout() + 50L + + /** A y inside the plot, where a hold means help for the page rather than for the axis. */ + private fun insidePlot(chart: SafeLineChart) = (chart.viewPortHandler.contentTop() + chart.viewPortHandler.contentBottom()) / 2f + + @Test + fun `a press held past the hold shows help`() { + val chart = laidOutChart() + + longPressAt(chart, insidePlot(chart)) + elapse(remainderOfHold()) + + // The deferral is the point of ADFA-5554: the platform reports its long press at 400ms, + // which is a brisk tap, and help at that speed is what the ticket is about. + assertThat(helps).isEqualTo(1) + } + + @Test + fun `a press lifted before the hold completes shows no help`() { + val chart = laidOutChart() + + longPressAt(chart, insidePlot(chart)) + endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + @Test + fun `a press on the axis lifted before the hold still opens the chooser`() { + val chart = laidOutChart() + + // The detector has already called this a long press, so it will not report the tap. The + // stand-in is what keeps a brisk press on the axis doing what it always did. + longPressAt(chart, chart.viewPortHandler.contentBottom() + 1f) + endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + + assertThat(taps).isEqualTo(1) + assertThat(helps).isEqualTo(0) + } + + @Test + fun `a press on the axis that becomes a pan does not open the chooser`() { + val chart = laidOutChart() + + // A drag begins from a press the detector has already called a long press, so the + // stand-in fired for it: panning the chart opened the sampling-rate chooser, and picking + // a rate there clears every buffer -- the history loss the band's lower bound exists to + // prevent, reached by another route. + longPressAt(chart, chart.viewPortHandler.contentBottom() + 1f) + panBy(chart, -50f) + endGesture(chart, ChartTouchListener.ChartGesture.DRAG) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a press that becomes a pan shows no help either`() { + val chart = laidOutChart() + + // The finger is still down and still dragging when the hold would come due, so the + // tooltip opened over a chart the user was in the middle of panning. + longPressAt(chart, insidePlot(chart)) + panBy(chart, -50f) + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + @Test + fun `a press that becomes a pinch shows no help`() { + val chart = laidOutChart() + + longPressAt(chart, insidePlot(chart)) + val event = eventAt(0f) + chart.onChartGestureListener.onChartScale(event, 1.2f, 1.2f) + event.recycle() + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + @Test + fun `detaching cancels a hold already counting down`() { + val chart = laidOutChart() + + // The timer is on the main thread's queue, not on the chart, so unbinding the page does + // not reach it. Worse, the rebind installs a fresh listener whose own pending hold is + // null -- so nobody could have cancelled the old one, and it fired the outgoing page's + // help over whatever replaced it. + longPressAt(chart, insidePlot(chart)) + renderer.detach() + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + private companion object { + /** Longer than the chart's visible window, matching the axis-tap tests' fixture. */ + const val SAMPLES = 200 + } +} diff --git a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt index 24fb584b7b..49e826ce92 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt @@ -7,9 +7,9 @@ import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration +import com.itsaky.androidide.idetooltips.R import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager -import kotlin.math.abs /** * Shows [tag]'s tooltip (under [category]) anchored to [anchor], or does nothing if [tag] is @@ -52,8 +52,19 @@ fun showIdeCategoryTooltipIfPresent( * * Never *shorter* than the platform's value: that setting is exposed as an accessibility * "touch and hold delay", and someone who has lengthened it did so deliberately. + * + * [platformTimeoutMillis] is a parameter only so a test can name one. Asserted against the live + * value, both the doubling and the floor are implied by the expression itself and a test of them + * pins nothing. */ -fun longPressHelpTimeoutMillis(): Long = maxOf(ViewConfiguration.getLongPressTimeout() * 2L, 800L) +fun longPressHelpTimeoutMillis(platformTimeoutMillis: Long = ViewConfiguration.getLongPressTimeout().toLong()): Long = + maxOf(platformTimeoutMillis * PLATFORM_TIMEOUT_MULTIPLE, MIN_HOLD_MILLIS) + +/** The shortest hold that will ever be asked for, whatever the platform's own timeout. */ +private const val MIN_HOLD_MILLIS = 800L + +/** How much longer than the platform's long press a hold is, above the floor. */ +private const val PLATFORM_TIMEOUT_MULTIPLE = 2L /** * Shows [tooltipTag]'s tooltip (under [tooltipCategory]) when this view is held for @@ -81,6 +92,10 @@ fun View.displayTooltipOnLongPress( holdMillis: Long = longPressHelpTimeoutMillis(), ) { if (tooltipTag.isBlank()) { + // Not a no-op. This call replaces whatever help was wired here before, and a blank tag + // says there is none now; returning early would leave the previous tag's listeners + // answering holds -- and swallowing every touch -- for help this view no longer offers. + clearLongPressHelp() return } @@ -106,48 +121,136 @@ fun View.performOnHold( holdMillis: Long = longPressHelpTimeoutMillis(), onHold: () -> Unit, ) { - val slop = ViewConfiguration.get(context).scaledTouchSlop + // The hold only. [displayTooltipOnLongPress] installs its long-click listener first and this + // second, so clearing that here would take away what the caller had just wired. + clearOnHold() + val listener = HoldTouchListener(this, holdMillis, onHold) + // Tagged so [clearLongPressHelp] can tell that the listener it is about to remove is this one. + setTag(R.id.tooltip_hold_listener, listener) + setOnTouchListener(listener) +} + +/** + * Stops this view answering a hold or a long press with help, and cancels one already timing. + * + * `setOnLongClickListener(null)` alone is not enough: [View.setOnLongClickListener] sets + * `isLongClickable` when it installs a listener but does not unset it when the listener is + * removed, so the view goes on consuming long presses -- and showing the system's own + * "performLongClick" feedback -- for help it no longer offers. The hold half is [clearOnHold]. + */ +fun View.clearLongPressHelp() { + setOnLongClickListener(null) + isLongClickable = false + clearOnHold() +} + +/** + * Stops this view timing a hold, and cancels one already counting down. + * + * The half of [clearLongPressHelp] that undoes [performOnHold], separately callable because a + * caller that installed only a hold should be able to undo only a hold. + * + * The touch listener is removed only when [performOnHold] is the one that installed it, which the + * tag says. Five of the six views [clearLongPressHelp] is called on are wired through the + * framework's long click and never had one, and a blanket `setOnTouchListener(null)` there would + * silently take away an unrelated listener the next contributor adds. + */ +fun View.clearOnHold() { + val hold = getTag(R.id.tooltip_hold_listener) as? HoldTouchListener ?: return + // A hold already counting down outlives its listener: the timer is on the main thread's + // queue, not on the view. Left running it fires against a control that has just been unwired + // -- or a carousel page that has just been replaced (ADFA-5554). + hold.cancel() + setTag(R.id.tooltip_hold_listener, null) + setOnTouchListener(null) +} + +/** + * Times a hold on [view] and stands in for the framework's own press handling while it does. + * + * A class rather than a lambda so the pending hold can be cancelled from outside the touch stream; + * captured in a closure it was unreachable, and a teardown could only stop the *next* hold. + */ +private class HoldTouchListener( + private val view: View, + private val holdMillis: Long, + private val onHold: () -> Unit, +) : View.OnTouchListener { // An explicit handler, not View.postDelayed: a view not attached to a window parks posted work // in its HandlerActionQueue and only runs it on attach, so the hold would never time out. - val handler = Handler(Looper.getMainLooper()) - var held = false - var holding = false - var downX = 0f - var downY = 0f - val fire = + private val handler = Handler(Looper.getMainLooper()) + + private val slop = ViewConfiguration.get(view.context).scaledTouchSlop + + private var held = false + + private var holding = false + + private val fire = Runnable { held = true - isPressed = false + view.isPressed = false onHold() } - setOnTouchListener { view, event -> + fun cancel() { + holding = false + handler.removeCallbacks(fire) + view.isPressed = false + } + + /** + * Whether a touch at ([x], [y]) is still on the view, by the framework's rule. + * + * `View.onTouchEvent` gives up on a press when `!pointInView(x, y, mTouchSlop)` -- when the + * finger leaves the view's bounds grown by the slop, not when it has travelled slop from + * where it went down. Measured from the down point instead, an ordinary thumb tap on a large + * target rolls far enough to cancel its own click without ever leaving the control, and the + * carousel strip is the full width of the editor. + */ + private fun isInside( + x: Float, + y: Float, + ): Boolean = x >= -slop && y >= -slop && x < view.width + slop && y < view.height + slop + + override fun onTouch( + v: View, + event: MotionEvent, + ): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { held = false holding = true - downX = event.x - downY = event.y - view.isPressed = true + v.isPressed = true + // The framework starts the ripple from the touch point. Without this every ripple + // on these controls begins at the centre of the drawable instead. + v.drawableHotspotChanged(event.x, event.y) handler.postDelayed(fire, holdMillis) } MotionEvent.ACTION_MOVE -> { - if (holding && (abs(event.x - downX) > slop || abs(event.y - downY) > slop)) { - // Wandered off the control: neither a click nor help, which is how the - // framework treats a drag out of a view. Taking the touch over means saying so. + if (holding && !isInside(event.x, event.y)) { + // Left the control: neither a click nor help, which is how the framework + // treats a drag out of a view. Taking the touch over means saying so. holding = false handler.removeCallbacks(fire) - view.isPressed = false + v.isPressed = false } } MotionEvent.ACTION_UP -> { handler.removeCallbacks(fire) - view.isPressed = false + v.isPressed = false // The click belongs to a press that stayed put and did not become a hold. if (holding && !held) { - view.performClick() + // Posted rather than called here, as View.onTouchEvent does, so the pressed + // state is drawn before the action runs -- these open dialogs and re-page the + // carousel from inside the dispatch of the event that triggered them. + // + // Through this handler and not View.post, which parks work on an unattached + // view's HandlerActionQueue and returns true having run nothing. Same trap as + // the hold timer, one method along. + handler.post { v.performClick() } } holding = false } @@ -155,9 +258,9 @@ fun View.performOnHold( MotionEvent.ACTION_CANCEL -> { holding = false handler.removeCallbacks(fire) - view.isPressed = false + v.isPressed = false } } - true + return true } } diff --git a/idetooltips/src/main/res/values/ids.xml b/idetooltips/src/main/res/values/ids.xml new file mode 100644 index 0000000000..53b7a6f9d8 --- /dev/null +++ b/idetooltips/src/main/res/values/ids.xml @@ -0,0 +1,8 @@ + + + + + From c6d6393d56b0e3797c0ee5b3ab4023b740735c61 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 17:40:04 -0700 Subject: [PATCH 099/128] ADFA-5542: route the server's own verdict instead of guessing it Replaces the approach in the commit before this one, which had the client reconstruct whether a failure was a cancel by remembering which build was running and matching ids against it. The review found the premise wrong: the Stop control goes live at build submission, not when the server calls back prepareBuild, so the window was still open and now failed silently -- runningBuildId was null and the cancel was dropped by the very guard meant to protect it. It found the better answer too. The tooling server already knows. It catches the throwable Gradle raised and getTaskFailureType maps a BuildCancelledException to Failure.BUILD_CANCELLED -- one classification, no threading, no ambiguity -- and then hands that answer only to the caller of executeTasks while the BuildResult it notifies the client with carries tasks, an id and a duration. The question was answered exactly, one line from where it was needed, and thrown away. So BuildResult carries the failure now, the same value the return path gets, from one call. Both server failure sites classify once and use it twice. The listener reads it. That deletes the whole of the previous attempt: runningBuildId, the volatile it needed and its non-atomic compare-and-clear, cancelledBuildId, forget(), the build id on the outcome callbacks, and onBuildCancelRequested itself, which existed only to tell the listener something it can now be shown. annotatedBuild goes back to a boolean cleared per build, above the activity check, which is where it was before and where it belongs -- the reason to key it to a build was to avoid a reset that was never the problem. Net 30 lines shorter than the code it replaces, in a change that fixes more. ADFA-5509's abstractness invariant on EventListener stays and still guards every remaining member. The wrapper test that covered the removed callback now covers the failure reason instead, for the same reason it existed: a wrapper that forwarded the call and dropped the argument would be that defect one layer in, with no signature to complain. The regression test is on the server, because that is where the fix is: a build that fails with BuildCancelledException must report BUILD_CANCELLED to the client and not only to its caller. Dropping the field from the notified BuildResult fails it, and nothing else. Also fixes the compile of :subprojects:tooling-api-impl's tests, which have been broken since ADFA-2784 added buildId to InitializeProjectParams: testInitParams never passed it. It is broken on stage too. No workflow runs :subprojects: tests, which is why nothing said so -- worth a ticket of its own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../handlers/EditorBuildEventListener.kt | 78 ++++++------------ .../services/builder/GradleBuildService.kt | 80 +++---------------- .../EditorBuildEventListenerAnnotationTest.kt | 69 ++++++++-------- .../GradleBuildServiceListenerWrapperTest.kt | 47 +++++------ .../tooling/impl/ToolingApiServerImpl.kt | 10 ++- .../tooling/impl/ToolingApiServerImplTest.kt | 58 ++++++++++++-- .../api/messages/result/BuildResult.kt | 10 +++ 7 files changed, 161 insertions(+), 191 deletions(-) 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 cd9fb59616..4f4196f731 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -26,8 +26,8 @@ import com.itsaky.androidide.projects.builder.BuildResult import com.itsaky.androidide.projects.builder.LaunchResult import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.services.builder.GradleBuildService -import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.result.BuildInfo +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.tooling.events.configuration.ProjectConfigurationStartEvent import com.itsaky.androidide.tooling.events.task.TaskFinishEvent @@ -51,22 +51,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { private var lastOutputTimeMs: Long = SystemClock.elapsedRealtime() /** - * The build the user asked to stop, so [onBuildFailed] can tell a cancel from a real failure. - * - * A build id and not a flag. A flag said only "a cancel happened recently", and the one thing - * tying it to the build it belonged to was the order two main-thread runnables happened to run - * in: [onBuildCancelRequested] is raised on the UI thread and so runs inline, while - * [prepareBuild] is raised from the build's own thread and so is posted. A cancel arriving - * after that post and before it ran was cleared by it, and the build the user stopped was - * annotated as a failure (ADFA-5542). Identity does not depend on that order, and there is - * nothing to clear per build: an id left over from a build whose outcome never arrived cannot - * match the next build's. - */ - @VisibleForTesting - internal var cancelledBuildId: BuildId? = null - - /** - * The build that drew a "Build started" marker, or null if the one running drew none. + * Whether the build now running drew a "Build started" marker. * * The outcome callbacks used to decide for themselves, from the task list they are handed -- * a different list from the one prepareBuild sees. If those two ever disagreed the chart got @@ -74,7 +59,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { * exists to avoid. The build that started decides, and its outcome follows. */ @VisibleForTesting - internal var annotatedBuildId: BuildId? = null + internal var annotatedBuild = false private var enabled = true private var activityReference: WeakReference = WeakReference(null) @@ -108,6 +93,11 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } override fun prepareBuild(buildInfo: BuildInfo) { + // Before the activity check, not after: this listener outlives any one activity, so a + // build whose outcome arrived with none attached would otherwise leave the flag set for + // the next build to inherit and draw a finish for a build that never started. + annotatedBuild = false + val act = checkActivity("prepareBuild") ?: return // A project sync runs through the same callbacks with no tasks, so annotating every @@ -117,7 +107,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { // The outcome callbacks are handed their own task list, which is not this one. Recorded // here so the pair is decided once, by the build that started. if (buildInfo.tasks.isNotEmpty()) { - annotatedBuildId = buildInfo.buildId + annotatedBuild = true act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_STARTED) } @@ -146,29 +136,20 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } /** - * Drops what is held for [buildId] now that its outcome has been reported. + * Which marker a failed build gets: the user's own cancel, or a real failure (ADFA-5542). * - * Only for that build: another id in either field belongs to a build whose outcome has not - * arrived, and clearing it would lose the pairing or the cancel that build is owed. - */ - private fun forget(buildId: BuildId) { - if (cancelledBuildId == buildId) { - cancelledBuildId = null - } - if (annotatedBuildId == buildId) { - annotatedBuildId = null - } - } - - /** - * Whether the failure of [buildId] is the user's own cancel or a real failure (ADFA-5542). + * [failure] is the server's own classification of the throwable Gradle raised. The listener + * used to answer this from a flag it set when the cancel was requested, which meant deciding + * from the order two main-thread runnables happened to run in -- and a cancel that overtook + * [prepareBuild] was cleared by it, so the build the user stopped was reported back to them as + * an error. * * Separated from [onBuildFailed] 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 outcomeKind(buildId: BuildId): MetricsAnnotationStore.Kind = - if (cancelledBuildId == buildId) { + internal fun outcomeKind(failure: TaskExecutionResult.Failure?): MetricsAnnotationStore.Kind = + if (failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { MetricsAnnotationStore.Kind.BUILD_CANCELLED } else { MetricsAnnotationStore.Kind.BUILD_FAILED @@ -179,16 +160,13 @@ class EditorBuildEventListener : GradleBuildService.EventListener { lastOutputTimeMs = SystemClock.elapsedRealtime() } - override fun onBuildSuccessful( - buildId: BuildId, - tasks: List, - ) { + override fun onBuildSuccessful(tasks: List) { val act = checkActivity("onBuildSuccessful") ?: return - if (annotatedBuildId == buildId) { + if (annotatedBuild) { act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FINISHED) } - forget(buildId) + annotatedBuild = false pluginBuildService?.notifyBuildFinished() @@ -217,14 +195,6 @@ class EditorBuildEventListener : GradleBuildService.EventListener { lastStatusLine = "" } - override fun onBuildCancelRequested(buildId: BuildId?) { - // A cancel with no build running names nothing to attribute it to. Leaving the id already - // held alone keeps a build still finishing from being relabelled by it. - if (buildId != null) { - cancelledBuildId = buildId - } - } - override fun onProgressEvent(event: ProgressEvent) { val act = checkActivity("onProgressEvent") ?: return @@ -251,17 +221,17 @@ class EditorBuildEventListener : GradleBuildService.EventListener { internal fun isAnnotated(event: ProgressEvent): Boolean = event is TaskStartEvent || event is TaskFinishEvent override fun onBuildFailed( - buildId: BuildId, tasks: List, + failure: TaskExecutionResult.Failure?, ) { val act = checkActivity("onBuildFailed") ?: return - if (annotatedBuildId == buildId) { + if (annotatedBuild) { // A build the user stopped arrives through this same callback. Marking it as a failure // would report their own deliberate action back to them in the error colour. - act.recordBuildAnnotation(outcomeKind(buildId)) + act.recordBuildAnnotation(outcomeKind(failure)) } - forget(buildId) + annotatedBuild = false analyzeCurrentFile() GeneralPreferences.isFirstBuild = false diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 4062a25a01..a2809db546 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -129,16 +129,6 @@ class GradleBuildService : private val buildSessionId = UUID.randomUUID().toString() private val buildId = AtomicLong(0) - /** - * The build a cancel request would belong to, or null when none is running. - * - * The listener cannot work this out from the order it is called in (ADFA-5542), so it is told, - * and this is what it is told. Written from the build's own thread in [prepareBuild] and read - * from the UI thread by [cancelCurrentBuild], hence volatile. - */ - @Volatile - private var runningBuildId: BuildId? = null - @Volatile private var tuningConfig: GradleTuningConfig? = null @@ -191,19 +181,12 @@ class GradleBuildService : null } else { object : EventListener { - override fun onBuildCancelRequested(buildId: BuildId?) { - runOnUiThread { listener.onBuildCancelRequested(buildId) } - } - override fun prepareBuild(buildInfo: BuildInfo) { runOnUiThread { listener.prepareBuild(buildInfo) } } - override fun onBuildSuccessful( - buildId: BuildId, - tasks: List, - ) { - runOnUiThread { listener.onBuildSuccessful(buildId, tasks) } + override fun onBuildSuccessful(tasks: List) { + runOnUiThread { listener.onBuildSuccessful(tasks) } } override fun onProgressEvent(event: ProgressEvent) { @@ -211,10 +194,10 @@ class GradleBuildService : } override fun onBuildFailed( - buildId: BuildId, tasks: List, + failure: TaskExecutionResult.Failure?, ) { - runOnUiThread { listener.onBuildFailed(buildId, tasks) } + runOnUiThread { listener.onBuildFailed(tasks, failure) } } override fun onOutput(line: String?) { @@ -403,12 +386,6 @@ class GradleBuildService : override fun prepareBuild(buildInfo: BuildInfo): CompletableFuture = CompletableFuture.supplyAsync { - // The server raises this only for a build that really started, so a second request - // rejected as already-in-progress cannot take the running build's name off the cancel. - // It is also well before the editor is told the build began, and the Stop control - // follows from that, so a cancel can never arrive with this unset. - runningBuildId = buildInfo.buildId - updateNotification(getString(R.string.build_status_in_progress), true) val projectPath = ProjectManagerImpl.getInstance().projectDirPath ?: "unknown" @@ -479,26 +456,20 @@ class GradleBuildService : updateNotification(getString(R.string.build_status_sucess), false) dispatchBuildResult(result, true) - eventListener?.onBuildSuccessful(result.buildId, result.tasks) + eventListener?.onBuildSuccessful(result.tasks) } override fun onBuildFailed(result: BuildResult) { updateNotification(getString(R.string.build_status_failed), false) dispatchBuildResult(result, false) - eventListener?.onBuildFailed(result.buildId, result.tasks) + eventListener?.onBuildFailed(result.tasks, result.failure) } private fun dispatchBuildResult( result: BuildResult, isSuccess: Boolean, ) { - // Only if it is still this build's: a build whose result never arrived leaves its id here, - // and clearing that on someone else's outcome would drop the name off a live cancel. - if (runningBuildId == result.buildId) { - runningBuildId = null - } - val buildType = getBuildType(result.tasks) analyticsManager.trackBuildCompleted( metric = @@ -693,10 +664,6 @@ class GradleBuildService : override fun cancelCurrentBuild(): CompletableFuture { checkServerStarted() - // Before delegating: the cancellation surfaces as a build failure, and the listener needs - // to know it was asked for rather than reporting the user's own action as an error. It is - // told which build, because it cannot infer that from when this arrives (ADFA-5542). - eventListener?.onBuildCancelRequested(runningBuildId) return server!!.cancelCurrentBuild() } @@ -845,24 +812,6 @@ class GradleBuildService : /** Handles events received from a Gradle build. */ interface EventListener { - /** - * Called when the user asks for the running build to stop. - * - * The tooling API reports a cancelled build through [onBuildFailed], so a listener that - * wants to tell the two apart has to be told here. - * - * Deliberately not defaulted. It was, and the forwarding wrapper in [GradleBuildService] - * then quietly inherited the no-op instead of passing it on -- so the cancel never reached - * the real listener, and a build the user stopped went on being annotated as a failure. A - * member with no default cannot be forgotten by a wrapper; the compiler asks for it. - * - * @param buildId The build being stopped, or null if none was running. Carried because a - * listener cannot tell from arrival order which build a cancel belongs to: this call is - * made on the UI thread and runs inline, while [prepareBuild] is made from the build's - * own thread and is posted (ADFA-5542). - */ - fun onBuildCancelRequested(buildId: BuildId?) - /** * Called just before a build is started. * @@ -874,15 +823,10 @@ class GradleBuildService : /** * Called when a build is successful. * - * @param buildId The build that succeeded. [tasks] is the server's own list and not the one - * [prepareBuild] was given, so this is the only thing that names the build. * @param tasks The tasks that were run. * @see IToolingApiClient.onBuildSuccessful */ - fun onBuildSuccessful( - buildId: BuildId, - tasks: List, - ) + fun onBuildSuccessful(tasks: List) /** * Called when a progress event is received from the Tooling API server. @@ -894,17 +838,17 @@ class GradleBuildService : /** * Called when a build fails. * - * A build the user cancelled arrives here too; compare [buildId] with the one - * [onBuildCancelRequested] named to tell the two apart. + * A build the user cancelled arrives here too, and [failure] is what tells the two apart. + * It comes from the server, which classifies the throwable Gradle raised -- the only place + * the answer is known rather than inferred (ADFA-5542). * - * @param buildId The build that failed. [tasks] is the server's own list and not the one - * [prepareBuild] was given, so this is the only thing that names the build. * @param tasks The tasks that were run. + * @param failure Why the build failed, or null if the server did not say. * @see IToolingApiClient.onBuildFailed */ fun onBuildFailed( - buildId: BuildId, tasks: List, + failure: TaskExecutionResult.Failure?, ) /** diff --git a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt index 466bdf8b39..c2dc48a8b8 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt @@ -18,9 +18,10 @@ package com.itsaky.androidide.handlers import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import com.itsaky.androidide.tooling.api.messages.BuildId -import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.result.BuildInfo +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.tooling.events.internal.DefaultOperationDescriptor import com.itsaky.androidide.tooling.events.internal.DefaultProgressEvent @@ -37,17 +38,16 @@ import org.robolectric.RobolectricTestRunner /** * What the metrics charts annotate, and which build each annotation belongs to. * - * Two decisions, both asserted against the predicate that makes them rather than through the + * Two decisions, both asserted against the function that makes them rather than through the * callback that acts on it -- those need a live activity before they get this far. Which progress * events are marked at all (ADFA-5486), and whether a build that failed was really the user - * stopping it (ADFA-5542). + * stopping it (ADFA-5542). The second is now a reading of what the server said rather than a + * conclusion drawn on this side, so what is worth pinning is which answers are *not* a cancel. */ @RunWith(RobolectricTestRunner::class) class EditorBuildEventListenerAnnotationTest { private val listener = EditorBuildEventListener() - private fun buildId(id: Long) = BuildId(buildSessionId = "session", buildId = id, runType = BuildRunType.TaskRun) - private fun taskDescriptor() = TaskOperationDescriptor( dependencies = emptySet(), @@ -80,45 +80,48 @@ class EditorBuildEventListenerAnnotationTest { ) @Test - fun `a cancel that lands before its build is prepared still marks that build cancelled`() { - // The interleaving ADFA-5542 is about, and the one the main thread can really produce: - // onBuildCancelRequested is raised on the UI thread and runs inline, prepareBuild is - // raised from the build's own thread and is posted, so the cancel can overtake it. When - // the listener held a bare flag, prepareBuild cleared it and the build the user stopped - // was reported back to them as a failure. - listener.onBuildCancelRequested(buildId(7)) - listener.prepareBuild(BuildInfo(buildId(7), listOf(":app:assembleDebug"))) - - assertThat(listener.outcomeKind(buildId(7))) + fun `the server saying a build was cancelled is what marks it cancelled`() { + // The listener used to answer this from a flag it set when the cancel was asked for, which + // meant deciding from the order two main-thread runnables happened to run in -- and a + // cancel that overtook prepareBuild was cleared by it. Nothing here depends on order any + // more: the server classifies the throwable Gradle raised and this reads the answer. + assertThat(listener.outcomeKind(TaskExecutionResult.Failure.BUILD_CANCELLED)) .isEqualTo(MetricsAnnotationStore.Kind.BUILD_CANCELLED) } @Test - fun `a cancel is not inherited by the next build`() { - // The other half of keying to a build rather than to a moment. This listener outlives any - // one activity, so a cancel whose outcome never arrived stays held -- and must not relabel - // the next build's genuine failure. - listener.onBuildCancelRequested(buildId(7)) - - assertThat(listener.outcomeKind(buildId(8))) - .isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) + fun `every other reason the server gives is a failure`() { + // The substance of the mapping, and the half worth pinning: a connection that dropped or a + // Gradle version that is not supported is not the user stopping anything, and reporting it + // as one would tell them their own action broke a build they never touched. + val notCancels = + TaskExecutionResult.Failure.entries.filterNot { it == TaskExecutionResult.Failure.BUILD_CANCELLED } + notCancels.forEach { failure -> + assertWithMessage(failure.name) + .that(listener.outcomeKind(failure)) + .isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) + } } @Test - fun `a build nobody stopped is a failure`() { - assertThat(listener.outcomeKind(buildId(7))) - .isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) + fun `a failure the server did not classify is a failure`() { + // Null reaches here from any path that reports a build result without a reason. Treating + // an absent answer as a cancel would put the user's name on something they did not do. + assertThat(listener.outcomeKind(null)).isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) } @Test - fun `a cancel naming no build leaves the one already held alone`() { - // cancelCurrentBuild passes null when nothing is running. Taking that as "forget the - // cancel" would lose the attribution for a build still finishing. - listener.onBuildCancelRequested(buildId(7)) - listener.onBuildCancelRequested(null) + fun `preparing a build clears a stale pairing, even with no activity attached`() { + listener.annotatedBuild = true - assertThat(listener.outcomeKind(buildId(7))) - .isEqualTo(MetricsAnnotationStore.Kind.BUILD_CANCELLED) + // No activity is attached here, so prepareBuild returns early -- which is the point. The + // flag means "a start marker was drawn for the build now running", and this listener + // outlives any one activity, so a build whose outcome arrived without one would otherwise + // leave it set for the next build to inherit and draw a finish for a build that never + // started. + listener.prepareBuild(BuildInfo(BuildId.Unknown, listOf(":app:assembleDebug"))) + + assertThat(listener.annotatedBuild).isFalse() } @Test diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt index a8d8ca2f33..5eac7a65cb 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt @@ -19,7 +19,7 @@ package com.itsaky.androidide.services.builder import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.services.builder.GradleBuildService.EventListener -import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -28,12 +28,16 @@ import java.lang.reflect.Proxy /** * Pins that the build service's listener wrapper forwards every callback it is given. * - * It did not. `onBuildCancelRequested` was declared with a `= Unit` default so that only listeners - * that cared had to implement it; the wrapper then inherited that no-op rather than passing the - * call on, so the cancel never reached the real listener and a build the user had stopped went on - * being annotated as a failure -- which is what BUILD_CANCELLED exists to prevent. The feature was - * unreachable, and the store-level test for it passed the whole time, because it called the store - * directly and nothing exercised the path to it. + * It did not. A now-removed `onBuildCancelRequested` was declared with a `= Unit` default so that + * only listeners that cared had to implement it; the wrapper then inherited that no-op rather than + * passing the call on, so the cancel never reached the real listener and a build the user had + * stopped went on being annotated as a failure -- which is what BUILD_CANCELLED exists to prevent. + * The feature was unreachable, and the store-level test for it passed the whole time, because it + * called the store directly and nothing exercised the path to it. + * + * That callback is gone: the server classifies the throwable Gradle raised and says so on the + * BuildResult, so nothing on this side has to be told separately (ADFA-5542). The invariant it + * left behind outlives it and still guards every remaining member. */ @RunWith(RobolectricTestRunner::class) class GradleBuildServiceListenerWrapperTest { @@ -75,30 +79,21 @@ class GradleBuildServiceListenerWrapperTest { } @Test - fun `a cancel request reaches the listener, naming its build`() { - val recorder = Recorder() - val wrapped = GradleBuildService.wrap(recorder.listener)!! - - wrapped.onBuildCancelRequested(BuildId.Unknown) - - // The one this went wrong on, kept as its own case so the reason is legible in a report. - assertThat(recorder.calls).containsExactly("onBuildCancelRequested") - - // The id is the whole of what makes a cancel attributable (ADFA-5542). A wrapper that - // forwarded the call and dropped the argument would be the same defect one layer in, and - // no signature would complain about it. - assertThat(recorder.lastArgs).containsExactly(BuildId.Unknown) - } - - @Test - fun `an outcome reaches the listener with the build it belongs to`() { + fun `a failure reaches the listener with the server's reason for it`() { val recorder = Recorder() val wrapped = GradleBuildService.wrap(recorder.listener)!! - wrapped.onBuildFailed(BuildId.Unknown, listOf(":app:assembleDebug")) + wrapped.onBuildFailed(listOf(":app:assembleDebug"), TaskExecutionResult.Failure.BUILD_CANCELLED) assertThat(recorder.calls).containsExactly("onBuildFailed") - assertThat(recorder.lastArgs).containsExactly(BuildId.Unknown, listOf(":app:assembleDebug")).inOrder() + + // The reason is the whole of what tells a cancel from a failure (ADFA-5542), and it is the + // server's answer rather than one this side worked out. A wrapper that forwarded the call + // and dropped the argument would be the earlier defect one layer in, with no signature to + // complain about it. + assertThat(recorder.lastArgs) + .containsExactly(listOf(":app:assembleDebug"), TaskExecutionResult.Failure.BUILD_CANCELLED) + .inOrder() } @Test diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt index 9a05bacaac..09e29b2ecf 100644 --- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt +++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt @@ -143,14 +143,18 @@ internal class ToolingApiServerImpl : IToolingApiServer { return@runBuild doInitialize(params, start) } catch (err: Throwable) { log.error("Failed to initialize project", err) + // One classification, used twice. Told only through the return value, the client + // had no way to tell a sync the user stopped from one that broke (ADFA-5542). + val failure = getTaskFailureType(err) notifyBuildFailure( BuildResult( tasks = emptyList(), buildId = params.buildId, durationMs = System.currentTimeMillis() - start, + failure = failure, ), ) - return@runBuild InitializeResult.Failure(getTaskFailureType(err)) + return@runBuild InitializeResult.Failure(failure) } } } @@ -317,15 +321,17 @@ internal class ToolingApiServerImpl : IToolingApiServer { return@runBuild TaskExecutionResult.SUCCESS } catch (error: Throwable) { log.error("Failed to run tasks: {}", message.tasks, error) + val failure = getTaskFailureType(error) notifyBuildFailure( result = BuildResult( tasks = message.tasks, buildId = message.buildId, durationMs = System.currentTimeMillis() - start, + failure = failure, ), ) - return@runBuild TaskExecutionResult(false, getTaskFailureType(error)) + return@runBuild TaskExecutionResult(false, failure) } } } diff --git a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt index 21a346df7e..20fa2844a1 100644 --- a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt +++ b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt @@ -1,7 +1,10 @@ package com.itsaky.androidide.tooling.impl import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.tooling.api.IToolingApiClient +import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams +import com.itsaky.androidide.tooling.api.messages.result.BuildResult import com.itsaky.androidide.tooling.api.messages.result.InitializeResult import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.tooling.api.messages.result.isSuccessful @@ -10,8 +13,10 @@ import com.itsaky.androidide.tooling.impl.sync.RootModelBuilder import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.slot import io.mockk.spyk import io.mockk.verify +import org.gradle.tooling.BuildCancelledException import org.gradle.tooling.GradleConnector import org.gradle.tooling.ProjectConnection import org.junit.Test @@ -25,18 +30,21 @@ import java.util.concurrent.TimeUnit */ @RunWith(JUnit4::class) class ToolingApiServerImplTest { - private fun testInitParams( directory: String = "/does/not/exist", forceSync: Boolean = false, ) = InitializeProjectParams( - directory = directory, needsGradleSync = forceSync + // Required since ADFA-2784 added it, and never supplied here: this file has not compiled + // on stage since, and no workflow runs :subprojects: tests, so nothing said so. + buildId = BuildId.Unknown, + directory = directory, + needsGradleSync = forceSync, ) private data class MockServer( val server: ToolingApiServerImpl, val connector: GradleConnector, - val connection: ProjectConnection + val connection: ProjectConnection, ) private fun mockkToolingServer(): MockServer { @@ -47,7 +55,10 @@ class ToolingApiServerImplTest { // ensure that we do not start actual Gradle build every { server.getOrConnectProject( - projectDir = any(), forceConnect = true, initParams = any(), gradleDist = any() + projectDir = any(), + forceConnect = true, + initParams = any(), + gradleDist = any(), ) } returns (connector to connection) @@ -56,12 +67,12 @@ class ToolingApiServerImplTest { @Test fun `GIVEN any initialization params WHEN project init fails THEN report as failure`() { - mockkObject(RootModelBuilder) every { // Simulate a Gradle sync failure RootModelBuilder.build( - any(), any() + any(), + any(), ) } throws RuntimeException("intentional failure") @@ -82,8 +93,38 @@ class ToolingApiServerImplTest { } @Test - fun `GIVEN force sync not requested WHEN sync files are unreadable THEN sync anyway`() { + fun `GIVEN a build the user stopped WHEN it fails THEN the client is told it was a cancel`() { + mockkObject(RootModelBuilder) + every { + // Gradle raises this, and only this, for a build that was cancelled. + RootModelBuilder.build(any(), any()) + } throws BuildCancelledException("stopped by the user") + val (server) = mockkToolingServer() + + every { + server.validateProjectDirectory(any()) + } returns null + + val client = mockk(relaxed = true) + server.connect(client) + + val result = server.initialize(testInitParams()).get(5, TimeUnit.SECONDS) + assertThat((result as InitializeResult.Failure).failure) + .isEqualTo(TaskExecutionResult.Failure.BUILD_CANCELLED) + + // The same verdict has to reach the client, not only the caller of initialize. It did not, + // and the editor was left reconstructing "was that a cancel?" from the order its own + // callbacks happened to arrive in -- which it got wrong, annotating a build the user had + // stopped as a failure (ADFA-5542). This is the one place the answer is known rather than + // inferred, and it is one classification of one throwable, used for both. + val reported = slot() + verify { client.onBuildFailed(capture(reported)) } + assertThat(reported.captured.failure).isEqualTo(TaskExecutionResult.Failure.BUILD_CANCELLED) + } + + @Test + fun `GIVEN force sync not requested WHEN sync files are unreadable THEN sync anyway`() { val initParams = testInitParams(forceSync = false) val cacheFile = ProjectSyncHelper.cacheFileForProject(File(initParams.directory)) @@ -91,7 +132,8 @@ class ToolingApiServerImplTest { every { // simulate a successful cache write RootModelBuilder.build( - any(), any() + any(), + any(), ) } returns cacheFile diff --git a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt index 46fb9babba..3732a072d5 100644 --- a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt +++ b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt @@ -28,4 +28,14 @@ data class BuildResult( val buildId: BuildId, val tasks: List, val durationMs: Long, + /** + * Why the build failed, or `null` if it succeeded. + * + * The server is the only party that can answer this: Gradle raises a + * `BuildCancelledException` for a build the user stopped, and the same throwable that decides + * the [TaskExecutionResult] decides this. Without it a client had to reconstruct "was that a + * cancel?" from the order its own callbacks happened to arrive in, and got it wrong + * (ADFA-5542). + */ + val failure: TaskExecutionResult.Failure? = null, ) From a3c90adde1fb9fc839d565b288b42d1d908e2452 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 18:29:36 -0700 Subject: [PATCH 100/128] ADFA-5499: hide the battery readout when the carousel undocks setUndocked hid the pager, the title, both arrows and the snapshot button, and left the battery readout showing. This ticket added that readout after setUndocked was written and did not extend the list, so undocking left a battery level sitting over the "tap to bring them back" message. Hiding it is one way only. The readout belongs to the power page alone, and which page is showing is the controller's to know, not this view's: docking restores it on the rebind that follows. The controller's own test for it grows a second term, because that runs on every page change and every refresh -- without it the next battery tick put the readout straight back over the message. The test that should have caught this was already named for it -- "undocking hides every carousel control, not just the chart" -- and listed five ids by hand. It now enumerates the strip's children and asserts none is left showing, so a control added later cannot be missed the same way. Two cases fail without the fix with "expected to be empty but was: [metrics_battery]". The readout starts `gone` in the layout, so both cases show it first. Without that they passed against a strip where the readout had never been visible -- which is how the original test missed the defect, and how the first draft of this one passed with the fix removed. Found by CodeRabbit on #1790 as an "outside diff range" comment. Those cannot become review threads, so nothing tracked it: the PR showed no unresolved threads. Note for whoever runs the suite on this branch: MetricsViewModelTest fails here with "Cannot create an instance of class MetricsViewModel", before and after this change, and passes at the top of the stack. It is not this commit's, and it is filed separately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsCarouselController.kt | 5 ++- .../androidide/ui/MetricsCarouselLayout.kt | 25 ++++++++++- .../ui/MetricsCarouselLayoutTest.kt | 42 +++++++++++++++---- 3 files changed, 62 insertions(+), 10 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 3885016001..4a6523fa1c 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -349,7 +349,10 @@ class MetricsCarouselController( val readout = renderer?.readout() binding.metricsBattery.text = readout.orEmpty() - binding.metricsBattery.isVisible = readout != null + // Not on a page that has no readout, and not while the carousel is undocked: this runs on + // every page change and every refresh, so without the second test the next battery tick + // put the readout back over the "tap to bring them back" message. + binding.metricsBattery.isVisible = readout != null && !binding.root.isUndocked // lineHeight rather than the measured height: this runs on bind, before the readout has // been laid out, and it is the text's own size that grows with the font scale. 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 c55989037d..b568754857 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -60,16 +60,31 @@ class MetricsCarouselLayout /** Invoked as each gesture begins. */ var onTouchDown: (() -> Unit)? = null + /** + * Whether the carousel has been moved out to a floating window. + * + * Read by the controller: a control whose visibility depends on something else as well -- + * the battery readout, which only belongs on the page that has one -- cannot be restored by + * [setUndocked] alone, so it has to be able to ask. + */ + var isUndocked = false + private set + /** * 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. + * + * Keeping the set here was supposed to stop a control added later from being forgotten. + * It did not: the battery readout arrived afterwards and was missed, so the readout sat + * over the message. A list in one place is still easier to extend than a list at every + * call site, but nothing about it is self-maintaining -- what actually guards this is the + * test, which enumerates the strip's children rather than naming them. */ fun setUndocked(undocked: Boolean) { + isUndocked = undocked val carouselIds = intArrayOf( R.id.metrics_pager, @@ -81,6 +96,12 @@ class MetricsCarouselLayout carouselIds.forEach { id -> findViewById(id)?.isVisible = !undocked } + // One way only. Undocking hides the battery readout like everything else, but docking + // must not show it: it belongs to the power page alone, and which page is showing is + // the controller's to say. It restores the readout on the rebind that follows. + if (undocked) { + findViewById(R.id.metrics_battery)?.isVisible = false + } findViewById(R.id.metrics_undocked_message)?.isVisible = undocked } 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 9697966d47..6a9847be89 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt @@ -23,6 +23,7 @@ import android.view.LayoutInflater import android.view.MotionEvent import android.view.ViewConfiguration import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.view.children import androidx.core.view.isVisible import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat @@ -116,24 +117,37 @@ class MetricsCarouselLayoutTest { } } + /** Every control in the strip except the message that replaces them, named for a failure. */ + private fun MetricsCarouselLayout.stillShowing(): List = + children + .filter { it.id != R.id.metrics_undocked_message && it.isVisible } + .map { resources.getResourceEntryName(it.id) } + .toList() + @Test - fun `undocking hides every carousel control, not just the chart`() { + fun `undocking hides every control in the strip, whatever it is`() { val binding = inflatedStrip() + // The readout starts `gone` in the layout and is shown by the controller on the power page. + // It has to be showing before this, or the assertion runs against a strip where it never + // was -- which is how the defect survived a test already named for it, and how the first + // version of this one passed with the fix removed. + binding.metricsBattery.isVisible = true 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() + // + // Enumerated from the layout rather than listed by hand. The hand-list this replaces was + // named for the invariant it did not check: it named five ids and missed the battery + // readout, which had been added to the strip after setUndocked was written, so the readout + // sat over the message. A list that reads the layout cannot be out of date. + assertThat(binding.root.stillShowing()).isEmpty() assertThat(binding.metricsUndockedMessage.isVisible).isTrue() } @Test - fun `re-docking brings every control back`() { + fun `re-docking brings the carousel back`() { val binding = inflatedStrip() binding.root.setUndocked(true) @@ -147,6 +161,20 @@ class MetricsCarouselLayoutTest { assertThat(binding.metricsUndockedMessage.isVisible).isFalse() } + @Test + fun `re-docking does not put the battery readout back by itself`() { + val binding = inflatedStrip() + + // It belongs to the power page alone, and which page is showing is not this view's to + // know. Restoring it here would show a battery level over every other chart; the + // controller puts it back on the rebind that follows a dock. + binding.metricsBattery.isVisible = true + binding.root.setUndocked(true) + binding.root.setUndocked(false) + + assertThat(binding.metricsBattery.isVisible).isFalse() + } + @Test fun `a two-finger tap fires the callback`() { var taps = 0 From 757dec1729c12e0258d15e632f3dceca63a59970 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 18:32:49 -0700 Subject: [PATCH 101/128] ADFA-5531: hide the export button when the carousel undocks This ticket added the export button to the strip and did not extend setUndocked's list, so undocking left a live CSV export button sitting over the "tap to bring them back" message -- the same omission the battery readout made one ticket earlier, and for the same reason. Found by the test rather than by reading: the case that ADFA-5499's fix rewrote enumerates the strip's children instead of naming five ids, so merging that branch up turned this into a failure here, naming it -- "expected to be empty but was: [metrics_export]". That is what the rewrite was for. Straight into carouselIds, with no second term: unlike the battery readout, the export button has no other reason to be hidden, so docking can put it back unconditionally. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../com/itsaky/androidide/ui/MetricsCarouselLayout.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 b568754857..f5a6ddc746 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -73,9 +73,10 @@ class MetricsCarouselLayout /** * 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. + * The whole strip switches, not just the pager. The arrows, the snapshot button and the + * export button are chrome for a chart that is not here: left behind they sit over the + * message, and each is inert anyway because undocking unbinds the controller that listens + * to them. * * Keeping the set here was supposed to stop a control added later from being forgotten. * It did not: the battery readout arrived afterwards and was missed, so the readout sat @@ -92,6 +93,7 @@ class MetricsCarouselLayout R.id.metrics_previous, R.id.metrics_next, R.id.metrics_snapshot, + R.id.metrics_export, ) carouselIds.forEach { id -> findViewById(id)?.isVisible = !undocked From be353d9bb2f2dc4b0e42b560cca3fff288000ed8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 18:58:54 -0700 Subject: [PATCH 102/128] ADFA-5554: fix the second review round, and the heap that hid it Nine findings from the xhigh review, and one thing I got wrong twice. A two-finger tap on the axis band still opened the sampling-rate chooser. The gate added last round asks whether the gesture ended as a LONG_PRESS, and ChartTouchListener never assigns its mLastGesture from ACTION_POINTER_DOWN -- only from a drag, a zoom, a long press, a tap or a fling -- so a second finger that lands and lifts without moving leaves the label untouched and nothing reports a move. The chooser clears every sample buffer, and the carousel undocks on a two-finger tap, so one gesture undocked the strip and threw away the history it was showing. SafeLineChart now reports the second pointer, because MPAndroidChart's listener cannot, and the renderer gives the gesture up on it. attach() skipped the teardown when handed the chart it already had, so a rebind of a bound holder installed a second gesture listener while the first stayed queued with a hold nothing could cancel, and added a second layout listener that one removal cannot undo. It tears down unconditionally now. detach() also left the chart holding the listener, which is an inner class holding the renderer -- so a detached chart kept the whole renderer alive and answered a later press through a listener whose own chart reference was null. The remaining hold was computed by subtracting the platform timeout from the total, which assumes GestureDetector reports a long press exactly that long after the finger landed. It does not: below Q it adds TAP_TIMEOUT, and it caches the timeout in a static read at class-load, so someone who lengthens the accessibility touch-and-hold delay moves the buttons' hold and not the chart's. minSdk here is 28. It is measured from the event's own downTime now, and the test drives a press reported 700ms late. The stand-in tap ran inside the chart's touch dispatch while the click in performOnHold, added in the same PR, was posted for exactly the reason that is wrong -- it opens a dialog. One PR, two paths, opposite rules. Posted now. On the view side: the click ignored isClickable, which is how the carousel dims the arrow at either end while keeping it able to answer a hold, so a tap on a dimmed arrow played the click sound and announced a click for a control the screen reader is told is unavailable. cancel() removed the hold but not the posted click, which is unheld and so unreachable, so a teardown between the lift and the looper's next turn still clicked a control it had just unwired. And a second finger was not noticed at all, so the undock gesture also paged the carousel. One finding is deliberately not fixed. View.CheckForLongPress refuses to fire once the view's window has gone, and reproducing that guard here was tried and backed out: a Robolectric view is never window-attached, so it turned every timing test into a no-op, and attaching one needs the activity harness that takes this JVM down. What it protects is already protected where it matters -- TooltipManager re-checks isAttachedToWindow, and clearLongPressHelp runs from every teardown in this module. The reasoning is in the code, not just here. The heap, which is the thing I got wrong. Adding these tests killed the test JVM: exit 3, no failure recorded, and the tests that had not run reported zeroes that read as assertion failures. I diagnosed that twice as a mysterious Robolectric interaction -- "unbounded, since 4g fails too" -- and split test classes around it. It was heap the whole time. The 4g experiment set maxHeapSize in :app's own testOptions, and the root subprojects block overwrites that, so the run was never at 4g. It is 2g in the root now, where the setting actually takes effect, with a note saying why. Robolectric builds a sandbox per distinct @Config and :app now has enough of them. The two splits those wrong diagnoses produced are kept: they group sensibly either way, and re-merging them is churn for no gain. But they were not necessary, and the comment in one of them said so wrongly -- that is corrected. Every fix above has a test that fails without it, except the two noted: the window-attach guard, which is not implemented, and detach() releasing the gesture listener, which is a leak rather than a behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 37 +++- .../com/itsaky/androidide/ui/SafeLineChart.kt | 19 ++ .../androidide/ui/LongPressHelpTimingTest.kt | 68 +++++++ .../ui/MetricsChartGestureTeardownTest.kt | 190 ++++++++++++++++++ .../androidide/ui/MetricsChartHoldHelpTest.kt | 63 ++++-- build.gradle.kts | 9 +- .../com/itsaky/androidide/utils/ViewUtils.kt | 45 ++++- 7 files changed, 408 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.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 15b494d0a5..f7be8f449f 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -25,7 +25,6 @@ import android.os.SystemClock import android.util.TypedValue import android.view.MotionEvent import android.view.View -import android.view.ViewConfiguration import androidx.annotation.CallSuper import androidx.annotation.UiThread import androidx.annotation.VisibleForTesting @@ -214,7 +213,12 @@ abstract class MetricsChartRenderer( // oldest-samples symptom this ticket was filed for -- reachable only after a pan, which is // why the resume paths reproduce it and a fresh chart never does. Subclasses clear their own // per-chart state through the same override. - this.chart?.let { outgoing -> if (outgoing !== chart) detach() } + // + // Unconditionally, including when the same chart is handed back. Skipping the teardown + // there let [configure] install a second gesture listener while the first stayed queued on + // the main thread with a hold nothing could reach, and added a second layout listener that + // one removeOnLayoutChangeListener cannot undo. + detach() this.chart = chart configure(chart) chart.addOnLayoutChangeListener(newestWindowOnLayout) @@ -235,6 +239,11 @@ abstract class MetricsChartRenderer( // could never have cancelled it. axisTapListener?.cancelPendingHelp() axisTapListener = null + // The chart holds the listener, and the listener is an inner class holding this renderer, + // so a detached chart left with it keeps the whole renderer alive -- and answers a later + // press through a listener whose own chart reference is now null. + chart?.onChartGestureListener = null + chart?.onSecondPointerDown = null chart?.removeOnLayoutChangeListener(newestWindowOnLayout) chart = null } @@ -329,6 +338,10 @@ abstract class MetricsChartRenderer( legend.formLineWidth = 1f onChartGestureListener = XAxisTapListener(this).also { axisTapListener = it } + // A two-finger tap is the carousel's undock gesture, and it starts as a press like any + // other. Without this the stand-in tap fired for it and opened the sampling-rate + // chooser -- so one gesture both undocked the strip and cleared every buffer. + onSecondPointerDown = { axisTapListener?.abandonGesture() } xAxis.valueFormatter = ElapsedTimeFormatter(sampleIntervalMillis) // One label per 15 samples keeps the window readable without crowding. @@ -474,7 +487,11 @@ abstract class MetricsChartRenderer( // [onChartScale] give up the stand-in as the gesture escalates; this is the check for // an escalation neither of them reports. if (!helpShown && pendingTapOnAxis && lastPerformedGesture == ChartTouchListener.ChartGesture.LONG_PRESS) { - onXAxisTap?.invoke() + // Posted, not called here. This runs inside the chart's onTouchEvent, and the tap + // opens a dialog; showing one mid-dispatch leaves the chart's touch state and its + // velocity tracker part-way through a gesture. performOnHold posts its click for + // the same reason, and the two paths should not disagree. + handler.post { onXAxisTap?.invoke() } } helpShown = false pendingTapOnAxis = false @@ -492,7 +509,8 @@ abstract class MetricsChartRenderer( } override fun onChartLongPressed(me: MotionEvent?) { - val y = me?.y ?: return + val event = me ?: return + val y = event.y val tag = helpTagAt(y) ?: return // This arrives at the platform's own timeout -- 400ms by default, a brisk tap -- and @@ -500,6 +518,13 @@ abstract class MetricsChartRenderer( // show it only if the finger is still down; [onChartGestureEnd] cancels otherwise. cancelPendingHelp() val onAxisBand = isOnAxisBand(y) + // From the event's own downTime, not by subtracting the platform timeout from the hold. + // GestureDetector does not report a long press exactly getLongPressTimeout() after the + // finger landed: below Android Q it adds TAP_TIMEOUT, and it caches LONGPRESS_TIMEOUT + // in a static read once at class-load, so a user who lengthens the accessibility + // touch-and-hold delay moves the buttons' hold and not this one. minSdk here is 28. + val elapsed = SystemClock.uptimeMillis() - event.downTime + val remaining = (longPressHelpTimeoutMillis() - elapsed).coerceAtLeast(0L) pendingHelp = Runnable { pendingHelp = null @@ -510,7 +535,7 @@ abstract class MetricsChartRenderer( // press -- and its feedback -- never runs here and this is the only thing // that provides it. showHelp(chart.context, chart, tag) - }.also { handler.postDelayed(it, longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout()) } + }.also { handler.postDelayed(it, remaining) } // GestureDetector has already decided this gesture is a long press, so it will not // report the tap that would have opened the sampling-rate chooser. Remember whether @@ -557,7 +582,7 @@ abstract class MetricsChartRenderer( * the finger is then still down: the hold would go on to open a tooltip over a chart the * user is in the middle of panning, and the lift would open the sampling-rate chooser. */ - private fun abandonGesture() { + fun abandonGesture() { cancelPendingHelp() pendingTapOnAxis = false } diff --git a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt index b5acd0f684..f7916f9dce 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt @@ -21,6 +21,7 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.util.AttributeSet +import android.view.MotionEvent import com.github.mikephil.charting.charts.LineChart import com.github.mikephil.charting.components.YAxis import org.slf4j.LoggerFactory @@ -84,6 +85,24 @@ class SafeLineChart : LineChart { /** Reused by [drawBackgroundSpans]: two (x, y) pairs, transformed in place. */ private val spanPoints = FloatArray(4) + /** + * Called when a second finger lands, which ends whatever one-finger gesture was in progress. + * + * MPAndroidChart's gesture listener cannot report this. `ChartTouchListener` assigns its + * `mLastGesture` only from a drag, a zoom, a long press, a tap or a fling -- never from + * ACTION_POINTER_DOWN -- so a second finger that lands and lifts without moving leaves the + * gesture still labelled LONG_PRESS, and the listener cannot tell that from a finger simply + * being lifted (ADFA-5554). + */ + var onSecondPointerDown: (() -> Unit)? = null + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) { + onSecondPointerDown?.invoke() + } + return super.onTouchEvent(event) + } + /** * Draws the spans immediately after the grid background, which is an opaque fill of the plot: a * span painted before [onDraw] delegates upwards is covered by it and never reaches the screen. diff --git a/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt index 4e3a41a07b..7b81c07063 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt @@ -273,6 +273,74 @@ class LongPressHelpTimingTest { assertThat(view.isLongClickable).isFalse() } + @Test + fun `a control that does not answer taps is not clicked`() { + // View.onTouchEvent performs a click only for a clickable view, and taking the touch over + // means taking that test over too. The carousel dims the arrow at either end by clearing + // isClickable rather than isEnabled -- deliberately, so it still answers a hold -- so + // without this a tap on the dimmed arrow played the click sound and announced a click for + // a control the screen reader is being told is unavailable. + val view = target() + view.isClickable = false + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(clicks).isEqualTo(0) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a control that does not answer taps still answers a hold`() { + // The other half, and the reason isClickable was chosen over isEnabled in the first place. + val view = target() + view.isClickable = false + + send(view, MotionEvent.ACTION_DOWN) + elapse(longPressHelpTimeoutMillis() + 50L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(holds).isEqualTo(1) + assertThat(clicks).isEqualTo(0) + } + + @Test + fun `a second finger gives up the press`() { + // The carousel undocks on a two-finger tap anywhere in the strip, and one of those fingers + // lands on a control. Counting it as a press meant the gesture both undocked the strip and + // paged it, or held long enough to open that button's help over a strip on its way out. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_POINTER_DOWN) + elapse(longPressHelpTimeoutMillis()) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(clicks).isEqualTo(0) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `clearing the help takes back a click that has not run yet`() { + // The click is posted, so there is a turn of the looper between the finger lifting and the + // action running. A teardown landing in it -- the sheet detaching, the carousel unbinding + // -- would otherwise still click a control it has just unwired. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_UP) + view.clearLongPressHelp() + drain() + + assertThat(clicks).isEqualTo(0) + } + private companion object { /** Big enough that a roll of one touch slop is still well inside it. */ const val WIDTH = 400 diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt new file mode 100644 index 0000000000..2243484f4e --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt @@ -0,0 +1,190 @@ +/* + * 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.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.listener.ChartTouchListener +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +/** + * What happens to a hold in progress when the gesture or the chart under it goes away (ADFA-5554). + * + * A hold is a timer on the main thread's queue, not state on the view, so it outlives whatever + * started it: a second finger landing, the page being rebound, the renderer letting the chart go. + * Each of those has to reach the timer, and none of them can once the listener holding it has been + * replaced. + * + * Split from [MetricsChartHoldHelpTest] only because these three are about teardown rather than + * timing. An earlier version of this comment blamed a Robolectric interaction: the suite was + * killing the test JVM as cases were added, and splitting appeared to help. It was heap -- + * Robolectric builds a sandbox per distinct `@Config` and `:app` had outgrown the 1g in the root + * build file. The split is kept because it reads better, not because it fixes anything. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartGestureTeardownTest { + private val context = ApplicationProvider.getApplicationContext() + + private var taps = 0 + + private var helps = 0 + + private lateinit var renderer: NetworkUsageChartRenderer + + private fun laidOutChart(): SafeLineChart { + val chart = SafeLineChart(context) + // Any concrete renderer will do -- the hold is the base class's, and every page wires it + // the same way. + renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + }, + ) + renderer.attach(chart) + renderer.onXAxisTap = { taps++ } + renderer.showHelp = { _, _, _ -> helps++ } + + chart.layOutAndDraw() + return chart + } + + /** + * An event whose finger landed [sincePressMillis] ago. + * + * The down time is what the chart measures its remaining hold from, so it has to be real here. + * Defaults to the platform's long-press timeout, which is when a detector on a current device + * reports one. + */ + private fun eventAt( + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ): MotionEvent { + val now = SystemClock.uptimeMillis() + return MotionEvent.obtain(now - sincePressMillis, now, MotionEvent.ACTION_MOVE, 10f, y, 0) + } + + /** The platform's own long press, which is where the chart's hold started counting from. */ + private fun longPressAt( + chart: SafeLineChart, + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ) { + val event = eventAt(y, sincePressMillis) + chart.onChartGestureListener.onChartLongPressed(event) + event.recycle() + } + + private fun panBy( + chart: SafeLineChart, + dx: Float, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartTranslate(event, dx, 0f) + event.recycle() + } + + private fun endGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartGestureEnd(event, gesture) + event.recycle() + } + + /** Runs the main looper forward by [millis] of virtual time. */ + private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) + + /** + * Runs what is already due on the main looper without advancing the clock. + * + * The stand-in tap is posted rather than invoked inside the chart's touch dispatch, so nothing + * has been tapped until the looper turns. + */ + private fun drain() = shadowOf(Looper.getMainLooper()).idle() + + /** The rest of the hold, after a long press reported at the platform's own timeout. */ + private fun remainderOfHold() = longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout() + 50L + + /** A y inside the plot, where a hold means help for the page rather than for the axis. */ + private fun insidePlot(chart: SafeLineChart) = (chart.viewPortHandler.contentTop() + chart.viewPortHandler.contentBottom()) / 2f + + @Test + fun `a second finger gives up the gesture, even without a move`() { + val chart = laidOutChart() + + // The carousel undocks on a two-finger tap, and that starts as a press like any other. + // MPAndroidChart cannot report it -- ACTION_POINTER_DOWN never touches its mLastGesture -- + // so the gesture still ends labelled LONG_PRESS and the stand-in tap fired, opening the + // sampling-rate chooser. Picking a rate there clears every buffer, so one gesture both + // undocked the strip and threw away the history it was showing. + longPressAt(chart, chart.viewPortHandler.contentBottom() + 1f) + chart.onSecondPointerDown?.invoke() + endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + elapse(remainderOfHold()) + drain() + + assertThat(taps).isEqualTo(0) + assertThat(helps).isEqualTo(0) + } + + @Test + fun `re-attaching the same chart leaves no second listener behind`() { + val chart = laidOutChart() + + // attach() used to skip the teardown when handed the chart it already had, so configure() + // installed a second gesture listener while the first stayed queued with a hold nothing + // could reach. A rebind of a bound holder does exactly that. + longPressAt(chart, insidePlot(chart)) + renderer.attach(chart) + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + @Test + fun `detaching cancels a hold already counting down`() { + val chart = laidOutChart() + + // The timer is on the main thread's queue, not on the chart, so unbinding the page does + // not reach it. Worse, the rebind installs a fresh listener whose own pending hold is + // null -- so nobody could have cancelled the old one, and it fired the outgoing page's + // help over whatever replaced it. + longPressAt(chart, insidePlot(chart)) + renderer.detach() + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + private companion object { + const val SAMPLES = 200 + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt index 019a406191..7e799a6823 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.ui import android.content.Context import android.os.Looper +import android.os.SystemClock import android.view.MotionEvent import android.view.ViewConfiguration import androidx.test.core.app.ApplicationProvider @@ -72,14 +73,28 @@ class MetricsChartHoldHelpTest { return chart } - private fun eventAt(y: Float) = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, y, 0) + /** + * An event whose finger landed [sincePressMillis] ago. + * + * The down time is what the chart measures its remaining hold from, so it has to be real here. + * Defaults to the platform's long-press timeout, which is when a detector on a current device + * reports one. + */ + private fun eventAt( + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ): MotionEvent { + val now = SystemClock.uptimeMillis() + return MotionEvent.obtain(now - sincePressMillis, now, MotionEvent.ACTION_MOVE, 10f, y, 0) + } - /** The platform's own long press, which is where the chart's hold starts counting from. */ + /** The platform's own long press, which is where the chart's hold started counting from. */ private fun longPressAt( chart: SafeLineChart, y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), ) { - val event = eventAt(y) + val event = eventAt(y, sincePressMillis) chart.onChartGestureListener.onChartLongPressed(event) event.recycle() } @@ -105,7 +120,15 @@ class MetricsChartHoldHelpTest { /** Runs the main looper forward by [millis] of virtual time. */ private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) - /** The rest of the hold, after the platform's long press has already been reported. */ + /** + * Runs what is already due on the main looper without advancing the clock. + * + * The stand-in tap is posted rather than invoked inside the chart's touch dispatch, so nothing + * has been tapped until the looper turns. + */ + private fun drain() = shadowOf(Looper.getMainLooper()).idle() + + /** The rest of the hold, after a long press reported at the platform's own timeout. */ private fun remainderOfHold() = longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout() + 50L /** A y inside the plot, where a hold means help for the page rather than for the axis. */ @@ -143,6 +166,11 @@ class MetricsChartHoldHelpTest { longPressAt(chart, chart.viewPortHandler.contentBottom() + 1f) endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + // Nothing has been tapped inside the dispatch itself: the tap opens a dialog, and doing + // that mid-gesture leaves the chart's touch state part-way through one. + assertThat(taps).isEqualTo(0) + drain() + assertThat(taps).isEqualTo(1) assertThat(helps).isEqualTo(0) } @@ -158,6 +186,7 @@ class MetricsChartHoldHelpTest { longPressAt(chart, chart.viewPortHandler.contentBottom() + 1f) panBy(chart, -50f) endGesture(chart, ChartTouchListener.ChartGesture.DRAG) + drain() assertThat(taps).isEqualTo(0) } @@ -189,22 +218,30 @@ class MetricsChartHoldHelpTest { } @Test - fun `detaching cancels a hold already counting down`() { + fun `the hold is measured from the finger landing, not from when the press was reported`() { val chart = laidOutChart() - // The timer is on the main thread's queue, not on the chart, so unbinding the page does - // not reach it. Worse, the rebind installs a fresh listener whose own pending hold is - // null -- so nobody could have cancelled the old one, and it fired the outgoing page's - // help over whatever replaced it. - longPressAt(chart, insidePlot(chart)) - renderer.detach() - elapse(remainderOfHold()) + // GestureDetector does not report a long press exactly getLongPressTimeout() after the + // finger lands: below Q it adds TAP_TIMEOUT, and it caches the timeout in a static read at + // class-load, so a lengthened accessibility touch-and-hold delay moves the buttons' hold + // and not the detector's. Subtracting the platform timeout from the total assumed + // otherwise, and stretched the chart's hold by however far the detector was late. + longPressAt(chart, insidePlot(chart), sincePressMillis = LATE_REPORT_MILLIS) + elapse(longPressHelpTimeoutMillis() - LATE_REPORT_MILLIS + 50L) - assertThat(helps).isEqualTo(0) + assertThat(helps).isEqualTo(1) } private companion object { /** Longer than the chart's visible window, matching the axis-tap tests' fixture. */ const val SAMPLES = 200 + + /** + * A long press reported well after the finger landed. + * + * Comfortably past the platform timeout, so the two ways of computing the remaining hold + * give different answers and the test can tell them apart. + */ + const val LATE_REPORT_MILLIS = 700L } } diff --git a/build.gradle.kts b/build.gradle.kts index 60208faa6a..ba897ec108 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -93,7 +93,14 @@ subprojects { // Gradle's default test-worker heap is 512m, too small for the Robolectric + // Kotlin Analysis API suites (:lsp:kotlin peaks near 240m and keeps growing). // Keep it explicit so the suites fail on a real regression, not on the default. - maxHeapSize = "1g" + // + // 1g -> 2g (ADFA-5554): Robolectric builds a separate sandbox per distinct @Config, each + // loading the framework again, and :app now has enough of them that 1g died mid-run -- + // exit code 3, no failure recorded, and whichever tests had not run yet reported zeroes + // that look like assertion failures. Raising it here rather than in :app because this is + // the block that wins: a maxHeapSize set in a module's own testOptions is overwritten by + // this one, which is why an experiment that appeared to rule heap out did not. + maxHeapSize = "2g" // Backstop: kill any individual Test task that runs longer than 10 minutes. // Prevents a single hung test JVM (e.g. the Tooling API child) from burning diff --git a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt index 49e826ce92..f7638a16fb 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt @@ -180,12 +180,24 @@ private class HoldTouchListener( // in its HandlerActionQueue and only runs it on attach, so the hold would never time out. private val handler = Handler(Looper.getMainLooper()) + // Deliberately without View.CheckForLongPress's window-attach test. The framework refuses to + // fire a long press for a view whose window has gone, and reproducing that here was tried and + // backed out: a Robolectric view is never window-attached, so the guard turned every timing + // test into a no-op, and attaching one needs the activity harness that takes this JVM down. + // The exposure it covers is already covered where it matters -- TooltipManager re-checks + // isAttachedToWindow before showing, and [clearLongPressHelp] is called from every teardown + // this module has. A caller of [performOnHold] doing something else with the callback would + // not be covered, and there is no such caller today. + private val slop = ViewConfiguration.get(view.context).scaledTouchSlop private var held = false private var holding = false + /** The click waiting for the next turn of the looper, so a teardown can still take it back. */ + private var pendingClick: Runnable? = null + private val fire = Runnable { held = true @@ -196,6 +208,11 @@ private class HoldTouchListener( fun cancel() { holding = false handler.removeCallbacks(fire) + // The click too. It is posted rather than run inline, so a teardown landing between the + // finger lifting and the looper's next turn would otherwise still click a control it has + // just unwired -- the same defect the hold timer has, one method along. + pendingClick?.let(handler::removeCallbacks) + pendingClick = null view.isPressed = false } @@ -228,6 +245,16 @@ private class HoldTouchListener( handler.postDelayed(fire, holdMillis) } + MotionEvent.ACTION_POINTER_DOWN -> { + // A second finger means this is no longer the single-finger press this listener + // times. The carousel undocks on a two-finger tap anywhere in the strip, and + // without this the finger that started on a button also clicked it, or held long + // enough to open that button's help over a strip that was undocking. + holding = false + handler.removeCallbacks(fire) + v.isPressed = false + } + MotionEvent.ACTION_MOVE -> { if (holding && !isInside(event.x, event.y)) { // Left the control: neither a click nor help, which is how the framework @@ -241,8 +268,14 @@ private class HoldTouchListener( MotionEvent.ACTION_UP -> { handler.removeCallbacks(fire) v.isPressed = false - // The click belongs to a press that stayed put and did not become a hold. - if (holding && !held) { + // The click belongs to a press that stayed put, did not become a hold, and landed + // on something that answers taps. That last test is View.onTouchEvent's, and + // taking the touch over means taking it over too: the carousel dims the arrow at + // either end by clearing isClickable rather than isEnabled, precisely so it still + // answers a hold, and without this it went back to answering taps -- playing the + // click sound and announcing a click for a control a screen reader is being told + // is unavailable. + if (holding && !held && v.isClickable) { // Posted rather than called here, as View.onTouchEvent does, so the pressed // state is drawn before the action runs -- these open dialogs and re-page the // carousel from inside the dispatch of the event that triggered them. @@ -250,7 +283,13 @@ private class HoldTouchListener( // Through this handler and not View.post, which parks work on an unattached // view's HandlerActionQueue and returns true having run nothing. Same trap as // the hold timer, one method along. - handler.post { v.performClick() } + val click = + Runnable { + pendingClick = null + v.performClick() + } + pendingClick = click + handler.post(click) } holding = false } From 29e07894dace442442b7279af24dc5e47b215c44 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 19:03:41 -0700 Subject: [PATCH 103/128] ADFA-5542: report a cancelled build as cancelled everywhere, not just on the chart Two findings from the second review. The sibling sweep was not done. The chart marker was fixed and the three reports beside it were left saying "failed", so a user who pressed Stop got a red "Build failed" bar, a "Build failed" notification in the shade, and an isSuccess=false result carrying failure text posted to every plugin listener -- their own deliberate action read back to them as an error in every place but one. The argument the PR gave for the marker applies verbatim to all four, and `failure` was already in hand at each. The notification, the bar and the message now follow it. The plugin API cannot express a cancel at all -- IdeServices.onBuildFailed takes an error string and nothing else -- so the message is the whole of what a plugin can be told, and that is now said in the code. The regression test drove initialize(), not executeTasks(), which is the path the ticket's defect actually travels: both sites got the same two-line edit, so deleting one left the suite green. Rather than stand up a live ProjectConnection to test the second site, the two are now one call. notifyBuildFailure classifies the throwable, notifies the client and returns the answer to its caller, so a site cannot report a failure without saying which, or tell the client one thing and its caller another. There is one place left to get this wrong, and the existing test covers it -- dropping the field still fails it and nothing else. The message is extracted as failureMessage for the same reason outcomeKind is: onBuildFailed returns early without an activity, so anything decided inside it cannot be reached from a test. The cancelled text is passed in rather than resolved, which is what makes it assertable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../handlers/EditorBuildEventListener.kt | 36 +++++++++++-- .../services/builder/GradleBuildService.kt | 10 +++- .../EditorBuildEventListenerAnnotationTest.kt | 22 ++++++++ .../tooling/impl/ToolingApiServerImpl.kt | 54 +++++++++++-------- .../tooling/impl/ToolingApiServerImplTest.kt | 9 +++- 5 files changed, 102 insertions(+), 29 deletions(-) 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 4f4196f731..4abae99862 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -34,6 +34,7 @@ import com.itsaky.androidide.tooling.events.task.TaskFinishEvent import com.itsaky.androidide.tooling.events.task.TaskStartEvent import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.BuildOutputViewModel import org.slf4j.LoggerFactory @@ -135,6 +136,24 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } } + /** + * What a failed build is reported as, to the plugins and in the result the editor posts. + * + * [cancelledText] is passed in rather than resolved here so this can be asserted without an + * activity, for the same reason [outcomeKind] is separate: [onBuildFailed] returns early + * without one, so anything decided inside it is unreachable from a test. + */ + @VisibleForTesting + internal fun failureMessage( + failure: TaskExecutionResult.Failure?, + cancelledText: String, + ): String = + when { + failure == TaskExecutionResult.Failure.BUILD_CANCELLED -> cancelledText + lastStatusLine.contains("BUILD FAILED") -> lastStatusLine + else -> "Build failed. Check build output for details." + } + /** * Which marker a failed build gets: the user's own cancel, or a real failure (ADFA-5542). * @@ -226,6 +245,8 @@ class EditorBuildEventListener : GradleBuildService.EventListener { ) { val act = checkActivity("onBuildFailed") ?: return + val cancelled = failure == TaskExecutionResult.Failure.BUILD_CANCELLED + if (annotatedBuild) { // A build the user stopped arrives through this same callback. Marking it as a failure // would report their own deliberate action back to them in the error colour. @@ -236,11 +257,20 @@ class EditorBuildEventListener : GradleBuildService.EventListener { analyzeCurrentFile() GeneralPreferences.isFirstBuild = false act.editorViewModel.isBuildInProgress = false - act.flashError(R.string.build_status_failed) + // Everything this method says, not only the chart marker. The annotation was fixed first + // and the three reports beside it were not, so a user who pressed Stop still got a red + // "Build failed" bar, a "Build failed" notification and an isSuccess=false result -- their + // own action read back to them as an error in every place but one. + if (cancelled) { + act.flashInfo(R.string.info_build_cancelled) + } else { + act.flashError(R.string.build_status_failed) + } - val message = - if (lastStatusLine.contains("BUILD FAILED")) lastStatusLine else "Build failed. Check build output for details." + val message = failureMessage(failure, act.getString(R.string.info_build_cancelled)) + // The plugin API has no way to say "cancelled" -- IdeServices.onBuildFailed takes an error + // string and nothing else -- so the message is the whole of what a plugin can be told. pluginBuildService?.notifyBuildFailed(message) act.notifyBuildResult(BuildResult(isSuccess = false, message = message, launchResult = null)) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index a2809db546..dcc0860a2f 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -460,7 +460,15 @@ class GradleBuildService : } override fun onBuildFailed(result: BuildResult) { - updateNotification(getString(R.string.build_status_failed), false) + // The notification too, not only what reaches the listener: a build the user stopped left + // "Build failed" in the shade whatever the chart said (ADFA-5542). + val status = + if (result.failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { + R.string.info_build_cancelled + } else { + R.string.build_status_failed + } + updateNotification(getString(status), false) dispatchBuildResult(result, false) eventListener?.onBuildFailed(result.tasks, result.failure) diff --git a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt index c2dc48a8b8..effb235731 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt @@ -110,6 +110,23 @@ class EditorBuildEventListenerAnnotationTest { assertThat(listener.outcomeKind(null)).isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) } + @Test + fun `a build the user stopped is not reported as an error anywhere`() { + // The annotation was fixed first and the reports beside it were not, so a user who pressed + // Stop still got a red "Build failed" bar, a "Build failed" notification and an + // isSuccess=false result carrying failure text -- their own action read back to them as an + // error in every place but the chart. + assertThat(listener.failureMessage(TaskExecutionResult.Failure.BUILD_CANCELLED, CANCELLED_TEXT)) + .isEqualTo(CANCELLED_TEXT) + } + + @Test + fun `a build that really failed still says so`() { + assertThat(listener.failureMessage(TaskExecutionResult.Failure.BUILD_FAILED, CANCELLED_TEXT)) + .isNotEqualTo(CANCELLED_TEXT) + assertThat(listener.failureMessage(null, CANCELLED_TEXT)).isNotEqualTo(CANCELLED_TEXT) + } + @Test fun `preparing a build clears a stale pairing, even with no activity attached`() { listener.annotatedBuild = true @@ -140,4 +157,9 @@ class EditorBuildEventListenerAnnotationTest { // that matter under configuration noise. assertThat(listener.isAnnotated(plainEvent())).isFalse() } + + private companion object { + /** Stands in for the string the activity would resolve, which a test has no activity for. */ + const val CANCELLED_TEXT = "Build was cancelled by the user." + } } diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt index 09e29b2ecf..f76315071f 100644 --- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt +++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.tooling.impl import com.itsaky.androidide.tooling.api.IToolingApiClient import com.itsaky.androidide.tooling.api.IToolingApiServer +import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.ClientGradleBuildConfig import com.itsaky.androidide.tooling.api.messages.GradleDistributionParams import com.itsaky.androidide.tooling.api.messages.GradleDistributionType @@ -143,18 +144,9 @@ internal class ToolingApiServerImpl : IToolingApiServer { return@runBuild doInitialize(params, start) } catch (err: Throwable) { log.error("Failed to initialize project", err) - // One classification, used twice. Told only through the return value, the client - // had no way to tell a sync the user stopped from one that broke (ADFA-5542). - val failure = getTaskFailureType(err) - notifyBuildFailure( - BuildResult( - tasks = emptyList(), - buildId = params.buildId, - durationMs = System.currentTimeMillis() - start, - failure = failure, - ), + return@runBuild InitializeResult.Failure( + notifyBuildFailure(params.buildId, emptyList(), start, err), ) - return@runBuild InitializeResult.Failure(failure) } } } @@ -321,17 +313,10 @@ internal class ToolingApiServerImpl : IToolingApiServer { return@runBuild TaskExecutionResult.SUCCESS } catch (error: Throwable) { log.error("Failed to run tasks: {}", message.tasks, error) - val failure = getTaskFailureType(error) - notifyBuildFailure( - result = - BuildResult( - tasks = message.tasks, - buildId = message.buildId, - durationMs = System.currentTimeMillis() - start, - failure = failure, - ), + return@runBuild TaskExecutionResult( + false, + notifyBuildFailure(message.buildId, message.tasks, start, error), ) - return@runBuild TaskExecutionResult(false, failure) } } } @@ -363,8 +348,31 @@ internal class ToolingApiServerImpl : IToolingApiServer { } } - private fun notifyBuildFailure(result: BuildResult) { - client?.onBuildFailed(result) + /** + * Tells the client a build failed, and answers with why. + * + * Both in one call on purpose. The classification and the notification used to be written + * separately at each failure site, which is how the notified [BuildResult] came to carry + * everything except the answer while the caller of the request got it (ADFA-5542). A site + * cannot now report a failure without saying which, or say one thing to the client and another + * to its caller. + */ + private fun notifyBuildFailure( + buildId: BuildId, + tasks: List, + startedAtMillis: Long, + error: Throwable, + ): Failure { + val failure = getTaskFailureType(error) + client?.onBuildFailed( + BuildResult( + buildId = buildId, + tasks = tasks, + durationMs = System.currentTimeMillis() - startedAtMillis, + failure = failure, + ), + ) + return failure } private fun notifyBuildSuccess(result: BuildResult) { diff --git a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt index 20fa2844a1..8c399e4cc7 100644 --- a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt +++ b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt @@ -116,8 +116,13 @@ class ToolingApiServerImplTest { // The same verdict has to reach the client, not only the caller of initialize. It did not, // and the editor was left reconstructing "was that a cancel?" from the order its own // callbacks happened to arrive in -- which it got wrong, annotating a build the user had - // stopped as a failure (ADFA-5542). This is the one place the answer is known rather than - // inferred, and it is one classification of one throwable, used for both. + // stopped as a failure (ADFA-5542). + // + // This drives the sync path. The task-run path is the one the ticket is really about, and + // standing it up needs a live ProjectConnection; instead of testing the two separately, + // notifyBuildFailure now classifies and notifies in one call and hands the answer back, so + // neither site can report a failure without saying which, or tell the client one thing and + // its caller another. There is one place left to get this wrong and this covers it. val reported = slot() verify { client.onBuildFailed(capture(reported)) } assertThat(reported.captured.failure).isEqualTo(TaskExecutionResult.Failure.BUILD_CANCELLED) From 3c61b469a1b06eba694779601fbc91ba2eb732fa Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 01:07:07 -0700 Subject: [PATCH 104/128] ADFA-5574: read each watched process the cheapest correct way The carousel plots three processes and read all three with Debug.getMemoryInfo, which walks every mapping in /proc/pid/smaps. That costs the same as reading the most expensive one, three times. The two kinds of process are not alike. The IDE is a Zygote fork with GPU memory. The tooling server and the Gradle daemon are plain OpenJDK processes exec'd from the app's Termux prefix, parented to the IDE, with no boot.art, no libandroid_runtime and no libart mapped at all. Measured on a Pixel 6 Pro, in-process, 200 iterations, median: getMemoryInfo 31.4ms, smaps_rollup 13.4ms, status VmRSS 0.1ms. For a JVM the rollup agrees with getMemoryInfo to 0.009% -- 66,018 against 66,012 kB, stable over three runs. For the IDE it reads about 124MB low, because dumpsys accounts EGL mtrack 89MB and GL mtrack 36MB through the memtrack HAL rather than through smaps, where a rollup cannot see them. That is 23% of the IDE's total, so the IDE keeps the expensive read and only it does. pid == Process.myPid() is the whole test and costs nothing: the only Zygote-forked process the carousel plots is the app itself. Nothing inspects /proc to decide. The choice is made once, when a process starts being watched, and lives on ProcessMemoryInfo beside the MemoryInfo scratch it already holds. Per sample it would mean a file-existence check every second, and the runtime fallback would have nowhere to latch. A rollup that cannot be read -- the process exited, a permission this build lacks -- costs one failed attempt and then that process uses the reflective read for the rest of the session. The injectable seam changes shape rather than disappearing, from readTotalPssKb to readerFor, so it is still one seam and still injectable. VmRSS is deliberately not used. It would take the read to 0.1ms, but it is about 4% high on the JVMs and it is not additive across processes, which is the property that lets the three lines be summed. Rollup gives the same number as today for less than half the cost. Also removes the ActivityManager lookup in readUsages. It has been dead since the switch to the reflective read -- the constructor's own comment says why the reflective call exists -- but its null check was not: had getSystemService returned null, the sampler would have taken no sample at all, for a service it does not use. Scope, honestly: this is about 94ms/sec of CPU down to about 57. The IDE's own read is 31 of that 57, so no read strategy gets below ~31 while the IDE is sampled at 1Hz. Taking it to zero when nobody is looking is ADFA-5570's visibility gate, not this. What is tested: the rule, both halves of the parse, and the fallback latch. Removing the latch fails its test with two cheap attempts instead of one. Loosening the Pss prefix to "Pss" fails the parse test with 379,731 -- Pss_Dirty -- instead of 441,070; the first draft of that test did its own line-picking and so pinned only the number extraction, which is why the reader now exposes pssKbFrom. What is not tested: that the two reads agree. Debug.getMemoryInfo is not meaningfully callable under Robolectric, so the equivalence rests on the device measurement above and is recorded as such rather than implied. A debug-only check warns if a process given the cheap read turns out to map libandroid_runtime.so, so a future fourth watched process that does use graphics fails loudly instead of quietly reading a quarter low. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/utils/MemoryUsageWatcher.kt | 72 +++--- .../androidide/utils/ProcessMemoryReader.kt | 225 ++++++++++++++++++ .../MemoryUsageWatcherReaderFallbackTest.kt | 70 ++++++ .../MemoryUsageWatcherSampleAlignmentTest.kt | 5 +- .../utils/ProcessMemoryReaderTest.kt | 122 ++++++++++ 5 files changed, 455 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherReaderFallbackTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/ProcessMemoryReaderTest.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 3935717d5e..ba6bf746c2 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -17,16 +17,11 @@ package com.itsaky.androidide.utils -import android.app.ActivityManager -import android.os.Debug import android.os.Debug.MemoryInfo import androidx.annotation.VisibleForTesting import androidx.collection.IntObjectMap import androidx.collection.MutableIntObjectMap -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 @@ -61,16 +56,9 @@ class MemoryUsageWatcher private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, private val nowMillis: () -> Long = System::currentTimeMillis, // Injectable for the same reason the other watchers' readers are: it is the one part of a - // sample that needs a device. ActivityManager.getProcessMemoryInfo is rate-limited and - // internally uses Debug.getMemoryInfo, so the reflective call goes around the limit. - private val readTotalPssKb: (Int, MemoryInfo) -> Int = { pid, into -> - ReflectionUtils.invokeMethod(android_os_Debug_getMemoryInfo, null, pid, into) - - // 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." - into.totalPss - }, + // sample that needs a device. A factory rather than a reader, because which read is correct + // depends on the process -- see [ProcessMemoryReaders] (ADFA-5574). + private val readerFor: (Int) -> ProcessMemoryReader = ProcessMemoryReaders::chooseReader, ) { /** * Milliseconds between samples. Changing it clears the history: the chart reads a sample's @@ -143,19 +131,6 @@ class MemoryUsageWatcher 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" - } - } - /** * Samples retained per series. * @@ -221,14 +196,6 @@ class MemoryUsageWatcher @VisibleForTesting internal 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 activityManager = BaseApplication.baseInstance.getSystemService() - if (activityManager == null) { - log.error("ActivityManager is null") return } @@ -248,7 +215,7 @@ class MemoryUsageWatcher } // values are in kB, convert to bytes - sampled += proc to readTotalPssKb(pid, proc.memInfo) * 1024L + sampled += proc to readKb(proc) * 1024L } synchronized(historyLock) { @@ -265,6 +232,27 @@ class MemoryUsageWatcher } } + /** + * This process's footprint in kB, falling back to the reflective read if the cheap one + * fails. + * + * The fallback latches on the process, so a rollup that cannot be read -- the process gone, + * a permission this build does not have -- costs one failed attempt rather than one every + * second for the rest of the session. + */ + private fun readKb(proc: ProcessMemoryInfo): Int { + val kb = proc.reader.totalKb(proc.pid, proc.memInfo) + if (kb != ProcessMemoryReaders.UNAVAILABLE) { + return kb + } + if (proc.reader !== DebugMemoryInfoReader) { + ProcessMemoryReaders.logFallback(proc.pid) + proc.reader = DebugMemoryInfoReader + return proc.reader.totalKb(proc.pid, proc.memInfo) + } + return 0 + } + /** * Watches the memory usage of the given process. * @@ -297,7 +285,7 @@ class MemoryUsageWatcher // of the session. Without this, the exported file could not tell those zeros // from a process that really was using no memory (ADFA-5531). watchedSinceMillis = nowMillis(), - ) + ).also { it.reader = readerFor(pid) } } /** @@ -499,6 +487,14 @@ class MemoryUsageWatcher ) { internal val memInfo: MemoryInfo = MemoryInfo() + /** + * How this process's footprint is read, chosen once when it starts being watched. + * + * Per process rather than per sample: the choice needs a file-existence check, and a + * read that fails at runtime latches here so it is not retried every second. + */ + internal var reader: ProcessMemoryReader = DebugMemoryInfoReader + val usageHistory: ShiftedLongArray get() = _history diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt b/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt new file mode 100644 index 0000000000..1580e08977 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt @@ -0,0 +1,225 @@ +/* + * 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.Debug +import android.os.Debug.MemoryInfo +import android.os.Process +import androidx.annotation.VisibleForTesting +import com.itsaky.androidide.BuildConfig +import com.termux.shared.reflection.ReflectionUtils +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Reads one watched process's total memory footprint, in kB (ADFA-5574). + * + * A seam with two implementations, because the processes the metrics carousel plots are not alike + * and reading them all the same way costs the same as reading the most expensive one, three times. + */ +fun interface ProcessMemoryReader { + /** + * This process's footprint in kB, or [ProcessMemoryReaders.UNAVAILABLE] if it could not be + * read. + * + * @param scratch A reusable [MemoryInfo]. Readers that do not need one ignore it; it is a + * parameter rather than an allocation because this runs on every sample. + */ + fun totalKb( + pid: Int, + scratch: MemoryInfo, + ): Int +} + +/** + * Picks the cheapest reader that is still correct for a given process. + * + * The IDE is a Zygote fork and has GPU memory; the tooling server and the Gradle daemon are plain + * OpenJDK processes exec'd from the app's Termux prefix and have none. Measured on a Pixel 6 Pro: + * for the JVMs `smaps_rollup` and `Debug.getMemoryInfo` agree to 0.009% (66,018 against 66,012 kB) + * while the rollup costs 13.4ms against 31.4ms; for the IDE the rollup reads ~124MB low, because + * `dumpsys meminfo` accounts EGL mtrack 89MB and GL mtrack 36MB through the memtrack HAL rather + * than through `/proc/pid/smaps`, where a rollup cannot see them. That is 23% of the IDE's total, + * so the IDE keeps the expensive read. + */ +object ProcessMemoryReaders { + /** Returned when a process's footprint could not be read at all. */ + const val UNAVAILABLE = -1 + + private val log = LoggerFactory.getLogger(ProcessMemoryReaders::class.java) + + /** + * Whether this kernel offers a rollup at all. + * + * Checked once. `smaps_rollup` arrived in Linux 4.14, so Android 10 in practice, and minSdk + * here is 28 -- a device below that gets the reflective read for everything, which is what it + * had before. + */ + @VisibleForTesting + internal val isRollupSupported: Boolean by lazy { + File("/proc/self/smaps_rollup").exists() + } + + /** + * The reader for [pid], decided once when a process starts being watched. + * + * `pid == Process.myPid()` is the whole test, and it costs nothing: the only Zygote-forked + * process the carousel plots is the app itself. Nothing has to inspect `/proc` to find out. + */ + fun chooseReader(pid: Int): ProcessMemoryReader = + chooseReader(pid, Process.myPid(), isRollupSupported).also { chosen -> + if (BuildConfig.DEBUG && chosen === SmapsRollupReader) { + warnIfProcessHasGraphicsMemory(pid) + } + } + + /** + * Complains if a process given the cheap read turns out to be an Android runtime process. + * + * The rule rests on an assumption about the three processes plotted today: only the app's own + * is Zygote-forked, and only a Zygote fork has graphics memory a rollup cannot see. Add a + * fourth watched process that is one, and its line would quietly read about a quarter low -- + * the failure this whole ticket is about, arriving silently. One maps scan when a process starts + * being watched, in debug builds only, turns that into something someone notices. + */ + private fun warnIfProcessHasGraphicsMemory(pid: Int) { + val isRuntimeProcess = + runCatching { + File("/proc/$pid/maps").useLines { lines -> + lines.any { it.contains("libandroid_runtime.so") } + } + }.getOrDefault(false) + if (isRuntimeProcess) { + log.error( + "pid {} maps libandroid_runtime.so, so it may hold graphics memory that " + + "smaps_rollup cannot see. Its memory line will read low. See ADFA-5574.", + pid, + ) + } + } + + @VisibleForTesting + internal fun chooseReader( + pid: Int, + ownPid: Int, + rollupSupported: Boolean, + ): ProcessMemoryReader = + if (pid == ownPid || !rollupSupported) { + DebugMemoryInfoReader + } else { + SmapsRollupReader + } + + internal fun logFallback(pid: Int) { + log.warn("smaps_rollup unreadable for pid {}; falling back to Debug.getMemoryInfo", pid) + } +} + +/** + * `Debug.getMemoryInfo`, reached reflectively. + * + * The only source that includes graphics memory, which is why the app's own process uses it. + * Reflective because `ActivityManager.getProcessMemoryInfo` is rate-limited and internally calls + * this, so going straight to it sidesteps the limit. + */ +object DebugMemoryInfoReader : ProcessMemoryReader { + private val getMemoryInfo: java.lang.reflect.Method 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" + } + } + + override fun totalKb( + pid: Int, + scratch: MemoryInfo, + ): Int { + ReflectionUtils.invokeMethod(getMemoryInfo, null, pid, scratch) + + // 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." + return scratch.totalPss + } +} + +/** + * The kernel's own PSS total, from `/proc/pid/smaps_rollup`. + * + * The `Pss:` field alone, not `Pss` plus `SwapPss`. Measured against `Debug.getMemoryInfo` on a + * JVM process, `Pss` alone was 6kB *higher* out of 66MB, so adding swap would move it further + * away rather than closer. + * + * Cheaper than walking `/proc/pid/smaps` because the kernel does the summation and hands back one + * short file rather than one stanza per mapping -- 22 lines against 93,120 for the IDE. The kernel + * still walks every mapping to compute it, which is why this is 2.3x cheaper and not 40x. + */ +object SmapsRollupReader : ProcessMemoryReader { + override fun totalKb( + pid: Int, + scratch: MemoryInfo, + ): Int = + runCatching { + File("/proc/$pid/smaps_rollup").useLines { lines -> pssKbFrom(lines) } + }.getOrDefault(ProcessMemoryReaders.UNAVAILABLE) + + /** + * Picks the rollup's `Pss` out of [lines] and reads its value. + * + * Separate from [totalKb] so both halves can be tested: choosing the right line matters as much + * as parsing it, and a test that does its own line-picking would pin only the parse. + */ + @VisibleForTesting + internal fun pssKbFrom(lines: Sequence): Int = + lines + .firstOrNull { it.startsWith(PSS_PREFIX) } + ?.let(::firstIntOrUnavailable) + ?: ProcessMemoryReaders.UNAVAILABLE + + /** + * The first run of digits in a line, without allocating. + * + * `Pss: 425176 kB`. Hand-scanned rather than split, because this runs on every + * sample for every watched process. + */ + private fun firstIntOrUnavailable(line: String): Int { + var value = 0 + var seen = false + for (c in line) { + if (c in '0'..'9') { + value = value * 10 + (c - '0') + seen = true + } else if (seen) { + break + } + } + return if (seen) value else ProcessMemoryReaders.UNAVAILABLE + } + + /** + * Deliberately with the colon. The rollup also carries `Pss_Anon`, `Pss_File`, `Pss_Shmem` and + * `Pss_Dirty`, and a prefix of `Pss` alone would match whichever came first. + */ + private const val PSS_PREFIX = "Pss:" +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherReaderFallbackTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherReaderFallbackTest.kt new file mode 100644 index 0000000000..48130182f1 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherReaderFallbackTest.kt @@ -0,0 +1,70 @@ +/* + * 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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What the sampler does when the cheap read fails (ADFA-5574). + * + * A rollup can be unreadable for reasons that are not the kernel's capability -- the process exited + * between being listed and being read, most likely. The sampler must not lose the series over it, + * and must not pay for the failure once a second for the rest of the session. + */ +@RunWith(RobolectricTestRunner::class) +class MemoryUsageWatcherReaderFallbackTest { + private var clock = 1_700_000_000_000L + + @Test + fun `a read that fails latches the process onto the reflective one`() { + var cheapAttempts = 0 + val alwaysUnavailable = + ProcessMemoryReader { _, _ -> + cheapAttempts++ + ProcessMemoryReaders.UNAVAILABLE + } + val watcher = + MemoryUsageWatcher( + nowMillis = { + clock += TICK_MILLIS + clock + }, + readerFor = { alwaysUnavailable }, + ) + watcher.watchProcess(PID, "IDE") + + watcher.readUsages() + watcher.readUsages() + + val proc = checkNotNull(watcher.getMemoryUsage(PID)) + assertThat(proc.reader).isSameInstanceAs(DebugMemoryInfoReader) + + // Once, not once per sample. The latch is the point: without it the sampler would try the + // unreadable file every second and take the failure path every time. + assertThat(cheapAttempts).isEqualTo(1) + } + + private companion object { + const val PID = 4242 + + const val TICK_MILLIS = 1_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt index 5b7e2a21aa..db76f4ee39 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt @@ -39,7 +39,10 @@ class MemoryUsageWatcherSampleAlignmentTest { clock += TICK_MILLIS clock }, - readTotalPssKb = readPssKb, + // The seam is a factory now (ADFA-5574): which read is correct depends on the process. + // These cases are about when values are appended, not how they are obtained, so every + // process gets the same stub. + readerFor = { ProcessMemoryReader { pid, scratch -> readPssKb(pid, scratch) } }, ) @Test diff --git a/app/src/test/java/com/itsaky/androidide/utils/ProcessMemoryReaderTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ProcessMemoryReaderTest.kt new file mode 100644 index 0000000000..ded0b8b0ce --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ProcessMemoryReaderTest.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.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Which read is used for which process, and what the cheap one makes of a rollup (ADFA-5574). + * + * The equivalence of the two reads is deliberately not asserted here. `Debug.getMemoryInfo` is not + * meaningfully callable off a device, and the interesting part of the claim is a device fact: for a + * plain JVM the rollup agrees with it to 0.009%, while for the app's own process it reads ~124MB low + * because graphics memory is accounted through memtrack rather than through `/proc/pid/smaps`. That + * is recorded on the ticket from a real measurement. What can be pinned here is the rule that acts + * on it, and the parse. + */ +@RunWith(RobolectricTestRunner::class) +class ProcessMemoryReaderTest { + @Test + fun `the app's own process keeps the expensive read`() { + // It is the only Zygote fork the carousel plots, and the only one with GPU memory. A rollup + // cannot see EGL or GL mtrack, so this process would silently lose about a quarter of its + // footprint. + val reader = ProcessMemoryReaders.chooseReader(pid = OWN_PID, ownPid = OWN_PID, rollupSupported = true) + + assertThat(reader).isSameInstanceAs(DebugMemoryInfoReader) + } + + @Test + fun `every other process gets the rollup`() { + // The tooling server and the Gradle daemon: plain OpenJDK processes with no graphics + // memory, where the rollup is the same number for less than half the cost. + val reader = ProcessMemoryReaders.chooseReader(pid = OTHER_PID, ownPid = OWN_PID, rollupSupported = true) + + assertThat(reader).isSameInstanceAs(SmapsRollupReader) + } + + @Test + fun `a kernel without a rollup falls back for everything`() { + // smaps_rollup arrived in Linux 4.14, so Android 10 in practice, and minSdk here is 28. + // Such a device gets exactly what it had before this change. + val reader = ProcessMemoryReaders.chooseReader(pid = OTHER_PID, ownPid = OWN_PID, rollupSupported = false) + + assertThat(reader).isSameInstanceAs(DebugMemoryInfoReader) + } + + @Test + fun `the rollup's own Pss is read, not one of the fields that start like it`() { + // A rollup carries Pss_Anon, Pss_File, Pss_Shmem and Pss_Dirty as well, and matching on + // "Pss" alone would take whichever came first -- here Pss_Dirty, a different number. + val value = + parse( + """ + 02000000-7ffc009000 ---p 00000000 00:00 0 [rollup] + Rss: 653352 kB + Pss_Dirty: 379731 kB + Pss: 441070 kB + Pss_Anon: 385191 kB + SwapPss: 15 kB + """.trimIndent(), + ) + + assertThat(value).isEqualTo(441070) + } + + @Test + fun `a rollup with no Pss line is unavailable rather than zero`() { + // Zero is a measurement -- a process really using no memory. Unavailable is the absence of + // one, and the caller falls back rather than plotting it. + assertThat(parse("Rss: 653352 kB")).isEqualTo(ProcessMemoryReaders.UNAVAILABLE) + } + + @Test + fun `a Pss line with no number is unavailable`() { + assertThat(parse("Pss: kB")).isEqualTo(ProcessMemoryReaders.UNAVAILABLE) + } + + @Test + fun `a process with no rollup at all is unavailable`() { + // The pid is gone, or the kernel has no rollup. Either way this must not throw: it runs on + // the sampling thread once a second. + val value = SmapsRollupReader.totalKb(NO_SUCH_PID, android.os.Debug.MemoryInfo()) + + assertThat(value).isEqualTo(ProcessMemoryReaders.UNAVAILABLE) + } + + /** + * The reader's own line-picking and parsing, over a fixture. + * + * Through [SmapsRollupReader.pssKbFrom], not by finding the line here first: an earlier version + * of this helper did its own `startsWith("Pss:")` and so pinned only the number extraction -- + * loosening the reader's prefix to "Pss" left every case below green. + */ + private fun parse(rollup: String): Int = SmapsRollupReader.pssKbFrom(rollup.lineSequence()) + + private companion object { + const val OWN_PID = 4242 + + const val OTHER_PID = 4243 + + /** Comfortably above any real pid on a device, so `/proc/` cannot exist. */ + const val NO_SUCH_PID = 999_999 + } +} From 62464537d09808441f227027268bbc090a98b2f4 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 06:13:10 -0700 Subject: [PATCH 105/128] ADFA-5499: fix the re-review findings on the power page Tier A and B from the second xhigh pass. Six items. SafeLineChart raced itself. drawBackgroundSpans mutated two instance fields -- a FloatArray scratch and a Paint -- inside onDraw, and the entire reason this class exists is that onDraw is entered from two threads at once: Sentry Session Replay records the screen by drawing the view hierarchy off the main thread. So the replay thread could overwrite all four coordinate slots, or the colour, between the main thread's write and its read, and a band would be painted at another span's coordinates or in another span's hue. The reused buffer was introduced to avoid pooled MPPointD churn; a local array and a local Paint per draw is still far less churn than that, and cannot be raced. backgroundSpans is volatile now too, since it is published across the same two threads. The power plausibility envelope did not catch the case its own comment named. The comment said a milliamp-reporting kernel makes "a five-watt build read as five milliwatts"; five watts misreported is 5,000uW, comfortably inside the old 1,000uW floor. A single sample cannot tell that from a genuinely tiny draw -- both are 5,000uW -- so this is a plausibility floor, not a detector, and the floor is now placed where a misreported build actually lands: 10mW, a thousandth of the 10W ceiling a phone can reach. The residual gaps are stated rather than papered over. The arithmetic is extracted as microWattsOrUnavailable so the envelope can be asserted without a BatteryManager, and the five-watt case fails against the old floor. Power buffers were filled with zero, so every unsampled slot plotted as a real 0 C and 0 W reading. That forced applyAxisRanges to special-case `!= 0L`, which also discarded a genuine freezing-battery sample -- a phone left in a car charted its own temperature as absent. The buffers fill with UNAVAILABLE now, clear() takes the fill value because zero is a measurement for memory and an absence for temperature, and the special case is gone. The test that encoded the workaround is rewritten, and a second case pins that a real 0 C is ranged over. reserveTopSpace claimed calculateOffsets was protected. It is public in AndroidChart 3.1.0.21 -- checked with javap -- which is why this went the long way round through notifyDataSetChanged(). That did far more work, and worse, returns early when the chart has no data: exactly the state at bind time, when this is first called, so the reserve silently did not apply until the next sample. It calls calculateOffsets directly now and skips an unchanged value, which also stops every power tick recomputing the viewport of whichever chart is on screen. powerUsageWatcher.listener was missing from the terminal teardown while its two siblings were there. Only metricsCarousel.unbind() was releasing it, under an identity check, and skipped entirely for an undocked carousel. MetricsViewModelTest has been failing since this ticket made the view model an AndroidViewModel: NewInstanceFactory reflects on a no-arg constructor that no longer exists. Nothing noticed because the only CI job that runs unit tests runs them with ignoreFailures set (ADFA-5559). It uses AndroidViewModelFactory now and asserts all three watchers rather than two. Only the memory watcher is asserted to *start*: the other two refuse when the platform cannot supply their metric, so requiring that would pin the test environment rather than the teardown. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../activities/editor/BaseEditorActivity.kt | 5 ++ .../androidide/ui/MetricsChartRenderer.kt | 22 +++++- .../androidide/ui/PowerUsageChartRenderer.kt | 2 +- .../com/itsaky/androidide/ui/SafeLineChart.kt | 43 ++++++----- .../androidide/utils/DevicePowerSource.kt | 43 +++++++++-- .../utils/MutableShiftedLongArray.kt | 13 +++- .../androidide/utils/PowerUsageWatcher.kt | 16 ++-- .../ui/PowerUsageChartRendererTest.kt | 25 ++++++- .../utils/DevicePowerEnvelopeTest.kt | 74 +++++++++++++++++++ .../viewmodel/MetricsViewModelTest.kt | 28 ++++++- 10 files changed, 227 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.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 a2ea7e2f27..f8179c6780 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 @@ -569,6 +569,11 @@ abstract class BaseEditorActivity : // recreation, so it must not be torn down whenever this activity goes away. memoryUsageWatcher.listener = null networkUsageWatcher.listener = null + // The third one too. It was missed when the power page was added, and only + // metricsCarousel.unbind() a few lines above was releasing it -- under an identity + // check, and skipped entirely for an undocked carousel. Asymmetry here is what hides + // which watcher is holding a dead controller. + powerUsageWatcher.listener = null editorActivityScope.cancelIfActive("Activity is being destroyed") unbindDebuggerService() 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 2d6a66927c..3e640942e6 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -76,6 +76,14 @@ abstract class MetricsChartRenderer( */ private var userHasZoomed = false + /** + * The top inset last reserved, so an unchanged value costs nothing. + * + * [reserveTopSpace] is called from the power listener on every sample, and the height it + * reserves changes only when the readout appears or disappears or the font scale moves. + */ + private var reservedTopPixels = Float.NaN + /** * The attached chart, or `null` when no carousel page is bound to this renderer. */ @@ -103,10 +111,18 @@ abstract class MetricsChartRenderer( @UiThread fun reserveTopSpace(pixels: Float) { val chart = this.chart ?: return + if (pixels == reservedTopPixels) { + return + } + reservedTopPixels = pixels chart.setExtraTopOffset(pixels / chart.resources.displayMetrics.density) - // setExtraTopOffset only stores the value; the viewport is recomputed by calculateOffsets, - // which is protected and otherwise runs only when the chart's size changes. - chart.notifyDataSetChanged() + // setExtraTopOffset only stores the value; calculateOffsets is what turns it into a + // viewport. It is public in AndroidChart 3.1.0.21 -- an earlier comment here called it + // protected, which is why this used to go the long way round through + // notifyDataSetChanged(). That did far more work (initBuffers, calcMinMax, three + // computeAxis calls, computeLegend) and, worse, returns early when the chart has no data + // yet -- which is exactly the state at bind time, when this is first called. + chart.calculateOffsets() chart.invalidate() } diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index 31d6948ffb..042e2f19e6 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -179,7 +179,7 @@ class PowerUsageChartRenderer( val milliCelsius = usage.temperatureMilliCelsius[index] // Skip the unsampled prefix and anything the device does not report: both plot at // zero, and letting zero into the range is what flattened the real readings. - if (milliCelsius != PowerUsageWatcher.UNAVAILABLE && milliCelsius != 0L) { + if (milliCelsius != PowerUsageWatcher.UNAVAILABLE) { val celsius = milliCelsiusToCelsius(milliCelsius) hottest = max(hottest, celsius) coldest = min(coldest, celsius) diff --git a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt index 8b96943750..e9121ff366 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt @@ -61,6 +61,7 @@ class SafeLineChart : LineChart { * Drawn here rather than by the caller because the chart owns the transformer that maps an * x value to a pixel, and that mapping changes with every zoom, pan and layout. */ + @Volatile var backgroundSpans: List = emptyList() set(value) { field = value @@ -80,11 +81,6 @@ class SafeLineChart : LineChart { val color: Int, ) - private val spanPaint = Paint(Paint.ANTI_ALIAS_FLAG) - - /** Reused by [drawBackgroundSpans]: two (x, y) pairs, transformed in place. */ - private val spanPoints = FloatArray(4) - /** * Draws the spans immediately after the grid background, which is an opaque fill of the plot: a * span painted before [onDraw] delegates upwards is covered by it and never reaches the screen. @@ -96,24 +92,33 @@ class SafeLineChart : LineChart { } private fun drawBackgroundSpans(canvas: Canvas) { - if (backgroundSpans.isEmpty()) { + val spans = backgroundSpans + if (spans.isEmpty()) { return } val content = viewPortHandler.contentRect val transformer = getTransformer(YAxis.AxisDependency.LEFT) ?: return - backgroundSpans.forEach { span -> - // A reused buffer through pointValuesToPixel, not two getPixelForValues calls: those - // hand back pooled MPPointD instances that have to be recycled, and this runs inside - // onDraw for every span on every frame of every pan and zoom. - spanPoints[0] = span.startX - spanPoints[1] = 0f - spanPoints[2] = span.endX - spanPoints[3] = 0f - transformer.pointValuesToPixel(spanPoints) - val left = spanPoints[0] - val right = spanPoints[2] + // Locals, not fields. This runs inside [onDraw], and the whole reason this class exists is + // that onDraw is entered from two threads at once -- Sentry Session Replay draws the + // hierarchy off the main thread. A scratch buffer and a Paint held as fields are a data + // race on exactly the hazard the class guards: the replay thread can overwrite all four + // slots, or the colour, between the main thread's write and its read, and the band is then + // painted at another span's coordinates or in another span's hue. One array and one Paint + // per draw is still far less churn than the pooled MPPointD instances this replaced, and + // it cannot be raced. + val points = FloatArray(4) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + spans.forEach { span -> + points[0] = span.startX + points[1] = 0f + points[2] = span.endX + points[3] = 0f + transformer.pointValuesToPixel(points) + val left = points[0] + val right = points[2] // A span scrolled out of view still maps to a pixel, so clip to the plot. val clippedLeft = left.coerceAtLeast(content.left) val clippedRight = right.coerceAtMost(content.right) @@ -121,8 +126,8 @@ class SafeLineChart : LineChart { return@forEach } - spanPaint.color = span.color - canvas.drawRect(clippedLeft, content.top, clippedRight, content.bottom, spanPaint) + paint.color = span.color + canvas.drawRect(clippedLeft, content.top, clippedRight, content.bottom, paint) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt index c05b5d8b33..512fe62b49 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -23,6 +23,7 @@ import android.content.IntentFilter import android.os.BatteryManager import android.os.Build import android.os.PowerManager +import androidx.annotation.VisibleForTesting import androidx.core.content.getSystemService import com.itsaky.androidide.services.builder.ThermalInfo import com.itsaky.androidide.services.builder.ThermalState @@ -93,12 +94,36 @@ class DevicePowerSource( return PowerUsageWatcher.UNAVAILABLE } + return microWattsOrUnavailable(microAmps, milliVolts) + } + + /** + * Turns a current and a voltage into microwatts, or [PowerUsageWatcher.UNAVAILABLE]. + * + * Separated so the envelope can be asserted: [readPower] needs a BatteryManager and a sticky + * intent, and the part worth testing is arithmetic. + */ + @VisibleForTesting + internal fun microWattsOrUnavailable( + microAmps: Int, + milliVolts: Int, + ): Long { val microWatts = microAmps.toLong() * milliVolts.toLong() / NANOWATTS_PER_MICROWATT // The sign of CURRENT_NOW is documented and not always honoured; the unit is the same - // story. Several OEM kernels report milliamps, which makes a five-watt build read as five - // milliwatts -- indistinguishable from an idle device, with no error path at all. Outside - // a plausible envelope, report the reading as unavailable rather than as a believable lie. + // story. Several OEM kernels report milliamps, which divides the reading by a thousand: + // a five-watt build then reads as five milliwatts, with no error path at all. + // + // A single sample cannot tell that apart from a genuinely tiny draw -- both are 5,000 + // microwatts -- so this is a plausibility floor, not a detector. It is set to reject the + // range a misreported build actually lands in: a real draw of 0.01W to 100W misreported as + // milliwatts gives 10 to 100,000 microwatts, and a phone running a Gradle build draws + // watts, not milliwatts. The residual gaps are stated rather than papered over: a real + // draw below MIN_PLAUSIBLE_MICROWATTS is rejected as implausible, and a misreport of a + // draw above 10W would pass -- neither happens on a phone. + // + // The earlier comment here claimed the envelope caught the milliamp case at a 1,000 + // microwatt floor. It did not: five watts misreported is 5,000, comfortably inside it. val magnitude = abs(microWatts) return if (magnitude == 0L || magnitude in MIN_PLAUSIBLE_MICROWATTS..MAX_PLAUSIBLE_MICROWATTS) { microWatts @@ -162,8 +187,16 @@ class DevicePowerSource( /** Microamps times millivolts gives nanowatts; this scales the product to microwatts. */ const val NANOWATTS_PER_MICROWATT = 1_000L - /** A milliwatt: below this a non-zero reading is likelier a unit mismatch than a real draw. */ - const val MIN_PLAUSIBLE_MICROWATTS = 1_000L + /** + * Ten milliwatts. + * + * Below this, a non-zero reading is likelier a milliamp-for-microamp kernel than a real + * draw: it is a thousandth of the 10W ceiling a phone can actually reach, so any build + * misreported this way lands under it. A device deep in doze can draw single-digit + * milliwatts, which this would reject -- acceptable, because the chart exists to show what + * a build costs and a dozing device is not running one. + */ + const val MIN_PLAUSIBLE_MICROWATTS = 10_000L /** A hundred watts: no phone draws this, so that is a unit mismatch the other way. */ const val MAX_PLAUSIBLE_MICROWATTS = 100_000_000L 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 3d3417646c..6add3e6902 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt @@ -63,11 +63,16 @@ class MutableShiftedLongArray( fun copy(): MutableShiftedLongArray = MutableShiftedLongArray(LongArray(size) { this[it] }) /** - * Resets every element to zero and returns the shift to its starting position, so the array reads - * as though nothing had ever been recorded. + * Fills every element with [fillWith] and returns the shift to its starting position, so the + * array reads as though nothing had ever been recorded. + * + * The fill value is a parameter because zero is a measurement for some series and an absence + * for others: a memory buffer of zeros means "no memory used", while a temperature buffer of + * zeros would plot a flat 0 C line and present it as a reading (ADFA-5499). */ - fun clear() { - array.fill(0L) + @JvmOverloads + fun clear(fillWith: Long = 0L) { + array.fill(fillWith) shift = 0 } diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 4753e9e730..94a9577249 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -75,8 +75,12 @@ class PowerUsageWatcher /** Guards the ring buffers: the sampler writes them, the UI thread snapshots them. */ private val historyLock = Any() - private val temperature = MutableShiftedLongArray(MAX_USAGE_ENTRIES) - private val power = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + // Filled with UNAVAILABLE, not zero. A slot that has never been sampled is an absence, and + // zero is a reading: a zero-filled prefix plotted a flat 0 C and 0 W line and presented it + // as measurement, which then forced applyAxisRanges to special-case `!= 0L` -- discarding + // a genuine freezing-battery sample along with the fake ones. + private val temperature = MutableShiftedLongArray(MAX_USAGE_ENTRIES) { UNAVAILABLE } + private val power = MutableShiftedLongArray(MAX_USAGE_ENTRIES) { UNAVAILABLE } /** * The thermal throttling level at each sample, or [THERMAL_UNKNOWN]. @@ -84,7 +88,7 @@ class PowerUsageWatcher * Kept per sample rather than as a separate timestamped log so the chart's shading lines up * with the sample grid exactly: a shaded span is just a run of equal values here. */ - private val thermal = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + private val thermal = MutableShiftedLongArray(MAX_USAGE_ENTRIES) { UNAVAILABLE } /** * Milliseconds between samples. Changing it clears the history, for the reason given on @@ -130,9 +134,9 @@ class PowerUsageWatcher fun clearHistory() { synchronized(historyLock) { - temperature.clear() - power.clear() - thermal.clear() + temperature.clear(UNAVAILABLE) + power.clear(UNAVAILABLE) + thermal.clear(UNAVAILABLE) } } diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index 880ca7526d..0ee4a56e20 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -326,21 +326,38 @@ class PowerUsageChartRendererTest { } @Test - fun `the temperature axis ignores the buffer's unsampled zeros`() { + fun `the temperature axis ignores the buffer's unsampled slots`() { // A real reading only in the newest slots; the rest of the buffer has never been written. - val temperature = LongArray(SAMPLES) + // Unsampled now means UNAVAILABLE rather than zero -- the watcher fills its buffers with + // it, because a zero-filled prefix plotted a flat 0 C line and presented it as a reading. + val temperature = LongArray(SAMPLES) { PowerUsageWatcher.UNAVAILABLE } for (index in SAMPLES - 10 until SAMPLES) { temperature[index] = 30_000L } val (_, chart) = rendererFor(usage(temperature = temperature)) laidOut(chart) - // Ranged over the zeros the 30C band is squeezed into the top tenth of the plot, with a - // negative gridline below it. + // Ranged over the unsampled slots the 30C band is squeezed into a corner of the plot. assertThat(chart.axisLeft.axisMinimum).isGreaterThan(20f) assertThat(chart.axisLeft.axisMaximum).isLessThan(40f) } + @Test + fun `a genuine zero degrees is a reading and is ranged over`() { + // The half the old workaround got wrong. Ignoring the unsampled prefix used to be done by + // discarding every zero, which also discarded a real freezing-battery sample -- so a phone + // left in a car overnight charted its own temperature as absent. + val temperature = LongArray(SAMPLES) { PowerUsageWatcher.UNAVAILABLE } + for (index in SAMPLES - 10 until SAMPLES) { + temperature[index] = 0L + } + val (_, chart) = rendererFor(usage(temperature = temperature)) + laidOut(chart) + + // The axis has to include it rather than falling back to its default band. + assertThat(chart.axisLeft.axisMinimum).isAtMost(0f) + } + @Test fun `the battery readout gets room, and gives it back`() { val (renderer, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) diff --git a/app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.kt b/app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.kt new file mode 100644 index 0000000000..04ec17ed5d --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.kt @@ -0,0 +1,74 @@ +/* + * 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 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 + +/** + * Which battery readings the power page will believe (ADFA-5499). + * + * A kernel that reports CURRENT_NOW in milliamps rather than microamps divides every reading by a + * thousand, and a single sample cannot tell that from a genuinely tiny draw. So the envelope is a + * plausibility floor rather than a detector, and what these cases pin is that the floor is placed + * where a misreported build actually lands. The earlier floor was 1,000uW and the comment claimed + * it caught the case; five watts misreported is 5,000uW, which sailed through. + */ +@RunWith(RobolectricTestRunner::class) +class DevicePowerEnvelopeTest { + private val source = DevicePowerSource(ApplicationProvider.getApplicationContext()) + + @Test + fun `a five-watt build misreported in milliamps is rejected`() { + // The case the old floor let through. 5W at 4V is 1.25A; a milliamp kernel reports 1250 + // where microamps would say 1_250_000, so the product comes out a thousand times small. + assertThat(source.microWattsOrUnavailable(microAmps = 1_250, milliVolts = 4_000)) + .isEqualTo(PowerUsageWatcher.UNAVAILABLE) + } + + @Test + fun `the same build reported correctly is believed`() { + assertThat(source.microWattsOrUnavailable(microAmps = 1_250_000, milliVolts = 4_000)) + .isEqualTo(5_000_000L) + } + + @Test + fun `a discharging reading keeps its sign`() { + // CURRENT_NOW is negative for current leaving the battery. The envelope tests the + // magnitude; the sign survives, because the chart decides for itself what to plot. + assertThat(source.microWattsOrUnavailable(microAmps = -1_250_000, milliVolts = 4_000)) + .isEqualTo(-5_000_000L) + } + + @Test + fun `an exactly-zero reading is a reading, not an absence`() { + // A device on mains with a full battery really does draw nothing through it. + assertThat(source.microWattsOrUnavailable(microAmps = 0, milliVolts = 4_000)).isEqualTo(0L) + } + + @Test + fun `an absurdly large reading is rejected the other way`() { + // The mismatch in the opposite direction: nanoamps read as microamps. No phone draws 400W. + assertThat(source.microWattsOrUnavailable(microAmps = 100_000_000, milliVolts = 4_000)) + .isEqualTo(PowerUsageWatcher.UNAVAILABLE) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt index a2eb18665d..e6b0b9c6ff 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt @@ -17,8 +17,10 @@ package com.itsaky.androidide.viewmodel +import android.app.Application import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore +import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith @@ -38,15 +40,33 @@ class MetricsViewModelTest { private val store = ViewModelStore() private fun viewModel(): MetricsViewModel { - val provider = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory()) + // AndroidViewModelFactory, not NewInstanceFactory: MetricsViewModel became an + // AndroidViewModel when the power page needed a Context for the battery broadcast, and + // NewInstanceFactory reflects on a no-arg constructor that no longer exists. This class + // has been failing with "Cannot create an instance of class MetricsViewModel" ever since, + // which nothing noticed because the only CI job that runs unit tests runs them with + // ignoreFailures set (ADFA-5559). + val application = ApplicationProvider.getApplicationContext() + val provider = ViewModelProvider(store, ViewModelProvider.AndroidViewModelFactory(application)) return provider[MetricsViewModel::class.java] } @Test - fun `clearing the view model closes both watchers for good`() { + fun `clearing the view model closes every watcher for good`() { val model = viewModel() + // All three, not two. The power watcher was added later and left out of this case, so its + // close() -- and the sampling thread it owns -- was unasserted. Spelled out rather than + // looped: the three watchers share no supertype that exposes isWatching. model.memoryUsageWatcher.startWatching() model.networkUsageWatcher.startWatching() + model.powerUsageWatcher.startWatching() + + // Only the memory watcher is asserted to have started. The other two refuse when the + // platform cannot supply their metric -- TrafficStats and the battery properties are both + // unsupported off a device -- so requiring them to start here would pin the test + // environment rather than the teardown. What the terminal property needs is that clear() + // stops whatever was running and that nothing restarts afterwards, which is asserted for + // all three below. assertThat(model.memoryUsageWatcher.isWatching).isTrue() cleared() @@ -55,11 +75,14 @@ class MetricsViewModelTest { // to restart, which is what makes this the terminal teardown rather than a pause. assertThat(model.memoryUsageWatcher.isWatching).isFalse() assertThat(model.networkUsageWatcher.isWatching).isFalse() + assertThat(model.powerUsageWatcher.isWatching).isFalse() model.memoryUsageWatcher.startWatching() model.networkUsageWatcher.startWatching() + model.powerUsageWatcher.startWatching() assertThat(model.memoryUsageWatcher.isWatching).isFalse() assertThat(model.networkUsageWatcher.isWatching).isFalse() + assertThat(model.powerUsageWatcher.isWatching).isFalse() } @Test @@ -70,6 +93,7 @@ class MetricsViewModelTest { // back a new watcher per read would quietly defeat that. assertThat(model.memoryUsageWatcher).isSameInstanceAs(model.memoryUsageWatcher) assertThat(model.networkUsageWatcher).isSameInstanceAs(model.networkUsageWatcher) + assertThat(model.powerUsageWatcher).isSameInstanceAs(model.powerUsageWatcher) assertThat(model.annotations).isSameInstanceAs(model.annotations) } } From fdcb241963f11bb92073eaf69d238aef8e55af54 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 09:51:19 -0700 Subject: [PATCH 106/128] ADFA-5531: fix the review's export findings Four of the second round's findings, and the KDoc claim that was not true. A cell the export writes can be a formula. quote() doubled an embedded quote and stopped there, so a task name beginning = + - @ tab or CR went into the file as itself. The annotation columns carry names read from the user's own build script, and ADFA-5526 and ADFA-5534 attach this file to crash reports and feedback that someone opens. Such a cell now gets a leading apostrophe. Quoting alone does not prevent the evaluation. The numeric columns are written by number() and are left alone, because a negative reading has to stay a number. markerRows had no distance cap, so an annotation older than the ring buffer reaches -- the ordinary case in a long session -- was pulled onto whichever row was nearest, which for every one of them is row 0. putIfAbsent then kept the first and dropped the rest, so the file carried one arbitrary ancient marker on its oldest row and silently lost both the others and the real annotation that belonged there. A marker further than one sampling interval from its nearest row is dropped now. The chart already had both guards; only the export was missing them. Snapshot gained sampleIntervalMillis to say what that bound is, required rather than defaulted, because a default would pick it for a caller who never considered it. Three defaults that read as lies are gone: sampleTimes on NetworkUsage and on PowerUsage, and watchedSinceMillis on ProcessMemoryInfo, which this PR had already removed at its other site. An omitted sampleTimes produced a history whose every sample read as never-taken -- invisible to the chart, which asks only how long ago a sample was, and silently empty in every one of that watcher's CSV columns. The chart tests that relied on the default now state it, which is the point. exportCsv reused the camera button's in-flight flag. The two write different files into different directories and cannot race, so the single flag only meant that exporting ten thousand rows made the camera button dead for as long as it ran, and dead without saying so. And the file's own KDoc claimed the network and power values on a row "were taken within one interval of it". Nothing enforces that. The series are paired to a row by array index, each watcher runs its own loop, and the drift accumulates backwards through the buffer. Every column is still a real reading with a real time behind it, so nothing in the file is invented, but a consumer must not read one row as three simultaneous measurements. The comment says that now. Merging the series on time instead is its own change and its own PR. Each fix has a test that fails without it: the formula prefix, the distance cap, the stale marker that stole a real one's row, and the export that refused the camera. The monotonic-clock test changed its expectation rather than its subject -- an unconverted time now reaches no row at all instead of landing on the oldest one, which is still the outcome epochFor exists to prevent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsCarouselController.kt | 34 +++++-- .../androidide/utils/MemoryUsageWatcher.kt | 2 +- .../com/itsaky/androidide/utils/MetricsCsv.kt | 53 ++++++++++- .../androidide/utils/NetworkUsageWatcher.kt | 9 +- .../androidide/utils/PowerUsageWatcher.kt | 9 +- .../editor/MemUsageLineColorTest.kt | 1 + .../ui/MemoryUsageChartRendererTest.kt | 2 + .../ui/MetricsAnnotationSpanTest.kt | 1 + .../androidide/ui/MetricsCarouselHelpTest.kt | 6 +- .../ui/MetricsCarouselRebindTest.kt | 25 +++++ .../androidide/ui/MetricsChartAxisTapTest.kt | 1 + .../ui/MetricsChartNewestWindowTest.kt | 6 +- .../ui/MetricsChartTextScaleTest.kt | 18 +++- .../ui/NetworkUsageChartRendererTest.kt | 5 +- .../ui/PowerUsageChartRendererTest.kt | 4 +- .../itsaky/androidide/utils/MetricsCsvTest.kt | 91 +++++++++++++++++-- 16 files changed, 232 insertions(+), 35 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 e383b3179f..4c77e71308 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -173,14 +173,26 @@ class MetricsCarouselController( private var currentPage = 0 /** - * Whether an export is already running. + * Whether a PNG snapshot is already being written. * * 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. + * + * A rapid second tap of the same button is refused silently: it is the double tap this exists + * to swallow, and a message for it would be noise on the gesture a user did not mean to make. + */ + private var snapshotInFlight = false + + /** + * Whether a CSV export is already being written, tracked apart from [snapshotInFlight]. + * + * The two write different files into different directories and cannot race each other, so one + * flag for both only meant that starting a ten-thousand-row export refused the camera button + * for as long as it ran -- and refused it silently, which reads as a dead control. */ - private var exportInFlight = false + private var csvExportInFlight = false /** * The pager of the bound carousel, or `null` when nothing is bound. Exposed so a host can apply @@ -589,7 +601,7 @@ class MetricsCarouselController( @UiThread fun exportSnapshot(): Boolean { val binding = this.binding ?: return false - if (exportInFlight) { + if (snapshotInFlight) { log.debug("Ignoring a snapshot request while one is already being written") return false } @@ -613,7 +625,7 @@ 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 + snapshotInFlight = 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 @@ -649,13 +661,13 @@ class MetricsCarouselController( // 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 + snapshotInFlight = 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 + snapshotInFlight = false } return true } @@ -673,7 +685,7 @@ class MetricsCarouselController( @UiThread fun exportCsv(): Boolean { val binding = this.binding ?: return false - if (exportInFlight) { + if (csvExportInFlight) { log.debug("Ignoring an export request while one is already being written") return false } @@ -681,7 +693,7 @@ class MetricsCarouselController( val context = binding.root.context val appContext = context.applicationContext val snapshot = snapshot() - exportInFlight = true + csvExportInFlight = true scope.launch { // Guarded for the same reason exportSnapshot is: the scope has no exception handler, so // anything escaping here is filed as a crash. @@ -697,13 +709,13 @@ class MetricsCarouselController( IntentUtils.shareFile(host, file, MetricsCsv.MIME_TYPE, extraFlags) }.onFailure { failure -> if (failure is CancellationException) { - exportInFlight = false + csvExportInFlight = false throw failure } log.error("Could not share the metrics export", failure) Toast.makeText(appContext, string.msg_metrics_export_failed, Toast.LENGTH_SHORT).show() } - exportInFlight = false + csvExportInFlight = false } return true } @@ -729,6 +741,8 @@ class MetricsCarouselController( return MetricsCsv.Snapshot( rowTimes = memory.times, + // The memory watcher's, because its times are the rows. + sampleIntervalMillis = memoryUsageWatcher.updateInterval, memory = memory.processes.associate { process -> process.pname to 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 6d1387274e..99c477dd0b 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -460,7 +460,7 @@ class MemoryUsageWatcher val pname: String, internal val _history: MutableShiftedLongArray, /** When this process started being watched, as milliseconds since the epoch. */ - val watchedSinceMillis: Long = 0L, + val watchedSinceMillis: Long, ) { internal val memInfo: MemoryInfo = MemoryInfo() diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt index 0fb2721720..0ae7657fd0 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt @@ -32,10 +32,16 @@ import kotlin.math.abs * * A row is a sampling tick, and its columns come from three watchers that each keep their own ring * buffer and their own coroutine. They are started together and share one interval, and every one - * of them is cleared when that interval changes, so the tick at index *i* is the same tick in all - * three -- but they do not read their sources at the same instant. The row's stated time is the - * memory watcher's, recorded when it sampled; the network and power values on that row were taken - * within one interval of it. Nothing here reconstructs a time from an index. + * of them is cleared when that interval changes. The row's stated time is the memory watcher's, + * recorded when it sampled, and nothing here reconstructs a time from an index. + * + * The other series are paired to that row by array index rather than by time, which is not the same + * thing. Each watcher runs its own loop and does its own per-tick work, so their ticks drift apart, + * and because the buffers are filled oldest-first the drift accumulates backwards: the further back + * a row is, the further its network and power values can sit from its stated time. Every column is + * a real reading with a real time behind it, so nothing in the file is invented -- but a consumer + * must not read one row as three simultaneous measurements. Merging the series on time instead is + * the subject of its own change; this comment says what is true until then. * * Formatting only, with no Android types, so the whole format can be tested without a device. */ @@ -146,9 +152,13 @@ object MetricsCsv { * * @property rowTimes The memory watcher's sample times, oldest first. They are the rows, because * memory is the one series always being recorded. + * @property sampleIntervalMillis How often the watchers sample. Required rather than defaulted, + * because it decides which annotations are near enough to a row to be written on it and a + * default would pick that bound for a caller who never considered it. */ class Snapshot( val rowTimes: LongArray, + val sampleIntervalMillis: Long, val memory: Map, val networkReceived: Series = Series.EMPTY, val networkTransmitted: Series = Series.EMPTY, @@ -230,6 +240,16 @@ object MetricsCsv { * almost all of them; and doing this per row rather than once would walk every marker against * every row, which at ten thousand of each is not a cost worth paying for a button. * + * A marker further than one sampling interval from its nearest row is dropped rather than + * pulled onto it. Sampling at a fixed interval leaves every marker that happened while the + * buffer was filling within half an interval of some sample, so a greater distance means the + * marker falls outside the sampled window -- an annotation older than the buffer reaches, which + * is the ordinary case in a long session. Without the cap every one of those lands on row 0, + * where [MutableMap.putIfAbsent] keeps the first and drops the rest: the file would carry one + * arbitrary ancient marker on its oldest row and lose the others silently. The chart has both + * guards already -- it asks the store only for the annotations in the visible span, and drops + * any whose x falls before the first sample. + * * Where two markers land on one row the earlier wins, and the later is dropped rather than * silently overwriting it -- the file has one annotation column per row by definition. */ @@ -246,6 +266,9 @@ object MetricsCsv { val rows = mutableMapOf() snapshot.annotations.sortedBy { it.atMillis }.forEach { marker -> val nearest = sampled.minByOrNull { abs(it.value - marker.atMillis) } ?: return@forEach + if (abs(nearest.value - marker.atMillis) > snapshot.sampleIntervalMillis) { + return@forEach + } rows.putIfAbsent(nearest.index, marker) } return rows @@ -253,11 +276,31 @@ object MetricsCsv { private fun number(value: Long?): String = value?.toString() ?: "" + /** + * The characters that make a spreadsheet read a cell as a formula rather than as text. + * + * Tab and carriage return are here because a leading one of either is stripped on import, which + * exposes whatever follows it: a cell of "\t=cmd" is a formula too. + */ + private val FORMULA_LEAD = charArrayOf('=', '+', '-', '@', '\t', '\r') + /** * A CSV string cell. * * Quoted per the format's rule (b), with any quote inside it doubled -- a task name is text the * IDE was given, and nothing guarantees it has no quotes in it. + * + * A cell beginning with one of [FORMULA_LEAD] additionally gets a leading apostrophe. Quoting + * alone does not stop a spreadsheet evaluating the cell on import, and the annotation columns + * carry Gradle task names taken from the user's own build script -- into a file ADFA-5526 and + * ADFA-5534 attach to crash reports and to feedback, which a support engineer then opens. The + * apostrophe is part of the cell as written, so a reader parsing this file back has to strip it. + * + * The numeric columns do not come through here. They are written by [number], where a negative + * value has to stay a number rather than become text with a quote in front of it. */ - private fun quote(value: String): String = "\"" + value.replace("\"", "\"\"") + "\"" + private fun quote(value: String): String { + val guarded = if (value.isNotEmpty() && value[0] in FORMULA_LEAD) "'" + value else value + return "\"" + guarded.replace("\"", "\"\"") + "\"" + } } 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 2166a87d74..3be34da8f1 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -325,13 +325,16 @@ class NetworkUsageWatcher * epoch, parallel to the values. Read in the same critical section as them, because reading * the two separately let the sampler append between the calls and shifted every value one * index against its timestamp (ADFA-5531). A zero means nothing was ever sampled at that - * index -- the buffers are fixed-length and start, and are cleared, full of them. Defaulted - * empty for the chart, which asks only how long ago a sample was and never when. + * index -- the buffers are fixed-length and start, and are cleared, full of them. + * + * Required, with no empty default. A caller that omitted it produced a history whose every + * sample read as never-taken, which the chart cannot see -- it asks only how long ago a + * sample was -- but which silently emptied both of this watcher's columns in the CSV. */ data class NetworkUsage( val received: LongArray, val transmitted: LongArray, - val sampleTimes: LongArray = LongArray(0), + val sampleTimes: LongArray, ) { override fun equals(other: Any?): Boolean = this === other || diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 5cb23f3788..d6fd12025f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -288,14 +288,17 @@ class PowerUsageWatcher * epoch, parallel to the values. Read in the same critical section as them, because reading * the two separately let the sampler append between the calls and shifted every value one * index against its timestamp (ADFA-5531). A zero means nothing was ever sampled at that - * index -- the buffers are fixed-length and start, and are cleared, full of them. Defaulted - * empty for the chart, which asks only how long ago a sample was and never when. + * index -- the buffers are fixed-length and start, and are cleared, full of them. + * + * Required, with no empty default. A caller that omitted it produced a history whose every + * sample read as never-taken, which the chart cannot see -- it asks only how long ago a + * sample was -- but which silently emptied every one of this watcher's columns in the CSV. */ data class PowerUsage( val temperatureMilliCelsius: LongArray, val powerMicroWatts: LongArray, val thermalStatus: LongArray, - val sampleTimes: LongArray = LongArray(0), + val sampleTimes: LongArray, ) { override fun equals(other: Any?): Boolean = this === other || 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 index d82bb0b9aa..8bb19fa47f 100644 --- a/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt @@ -39,6 +39,7 @@ class MemUsageLineColorTest { pid = 1234, pname = name, _history = MutableShiftedLongArray(4), + watchedSinceMillis = 0L, ) @Test 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 f47a5561d2..20c57604df 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -58,6 +58,7 @@ class MemoryUsageChartRendererTest { PID_IDE, "IDE", MutableShiftedLongArray(LongArray(history.size) { history[it] }), + watchedSinceMillis = 0L, ) renderer { arrayOf(process) }.attach(chart) chart.layOutAndDraw() @@ -73,6 +74,7 @@ class MemoryUsageChartRendererTest { pid, pname, MutableShiftedLongArray(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { (firstMegabytes + it) * BYTES_PER_MB }, + watchedSinceMillis = 0L, ) private fun datasetFor( diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt index 23e24a84d1..ab36922fa2 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt @@ -54,6 +54,7 @@ class MetricsAnnotationSpanTest { NetworkUsageWatcher.NetworkUsage( LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), ) }, annotations = store, diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt index e2bf17635c..44c35a3d3c 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt @@ -210,7 +210,11 @@ class MetricsCarouselHelpTest { val renderer = NetworkUsageChartRenderer( usageProvider = { - NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) }, ) renderer.attach(chart) 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 b563a51c9b..79d6e02646 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt @@ -128,6 +128,31 @@ class MetricsCarouselRebindTest { assertThat(controller.exportSnapshot()).isFalse() } + @Test + fun `a running CSV export does not refuse the camera button`() { + val controller = controller() + val binding = strip() + controller.bind(binding) + laidOut(binding) + + // The two write different files into different directories and cannot race each other. One + // flag for both meant that starting an export of ten thousand rows made the camera button + // dead for as long as it ran, and dead silently -- the tap returned false and said nothing. + assertThat(controller.exportCsv()).isTrue() + assertThat(controller.exportSnapshot()).isTrue() + } + + @Test + fun `a second CSV export is refused while the first is still being written`() { + val controller = controller() + val binding = strip() + controller.bind(binding) + laidOut(binding) + + assertThat(controller.exportCsv()).isTrue() + assertThat(controller.exportCsv()).isFalse() + } + @Test fun `both arrows are tinted, whatever inflated them`() { val binding = strip() 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 c9713c73f9..3396443bb7 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -57,6 +57,7 @@ class MetricsChartAxisTapTest { NetworkUsageWatcher.NetworkUsage( LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), ) }, ) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt index d9636409b2..a8443a7894 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt @@ -42,7 +42,11 @@ class MetricsChartNewestWindowTest { private fun renderer() = NetworkUsageChartRenderer( usageProvider = { - NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) }, ) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt index 29ace075c5..b131aee423 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt @@ -49,7 +49,11 @@ class MetricsChartTextScaleTest { val chart = SafeLineChart(context) NetworkUsageChartRenderer( usageProvider = { - NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) }, ).attach(chart) return chart @@ -114,7 +118,11 @@ class MetricsChartTextScaleTest { val chart = SafeLineChart(context) NetworkUsageChartRenderer( usageProvider = { - NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) }, annotations = store, sampleInterval = { 1_000L }, @@ -176,7 +184,11 @@ class MetricsChartTextScaleTest { .inflate(R.layout.item_metrics_chart, strip.metricsPager, false) as SafeLineChart NetworkUsageChartRenderer( usageProvider = { - NetworkUsageWatcher.NetworkUsage(LongArray(SAMPLES) { 1_000L }, LongArray(SAMPLES) { 500L }) + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) }, ).attach(page) page.layOutAndDraw(width = strip.metricsPager.width, height = strip.metricsPager.height) 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 fa9394ce11..b104b450be 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -44,10 +44,13 @@ class NetworkUsageChartRendererTest { private val context = ApplicationProvider.getApplicationContext() + // No sample times: these tests are about what the chart draws, and the chart asks only how long + // ago a sample was. Stated rather than defaulted, because the same emptiness in production + // silently blanks the CSV's network columns. private fun usage( received: LongArray, transmitted: LongArray = received, - ) = NetworkUsageWatcher.NetworkUsage(received, transmitted) + ) = NetworkUsageWatcher.NetworkUsage(received, transmitted, LongArray(received.size)) private fun rendererFor(usage: NetworkUsageWatcher.NetworkUsage): Pair { val chart = SafeLineChart(context) diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index 8e03637b38..f1738a164e 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -40,11 +40,13 @@ import org.robolectric.RobolectricTestRunner class PowerUsageChartRendererTest { private val context = ApplicationProvider.getApplicationContext() + // No sample times, for the reason NetworkUsageChartRendererTest gives: a chart test says so + // rather than letting a default say it. private fun usage( temperature: LongArray, power: LongArray = LongArray(temperature.size), thermal: LongArray = LongArray(temperature.size), - ) = PowerUsageWatcher.PowerUsage(temperature, power, thermal) + ) = PowerUsageWatcher.PowerUsage(temperature, power, thermal, LongArray(temperature.size)) private fun rendererFor( usage: PowerUsageWatcher.PowerUsage, diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt index cb9cceba0f..22886b6128 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt @@ -43,6 +43,7 @@ class MetricsCsvTest { private fun snapshot( rowTimes: LongArray = longArrayOf(T0, T0 + 1_000L), + sampleIntervalMillis: Long = INTERVAL_MS, memory: Map = emptyMap(), networkReceived: MetricsCsv.Series = MetricsCsv.Series.EMPTY, networkTransmitted: MetricsCsv.Series = MetricsCsv.Series.EMPTY, @@ -52,6 +53,7 @@ class MetricsCsvTest { annotations: List = emptyList(), ) = MetricsCsv.Snapshot( rowTimes = rowTimes, + sampleIntervalMillis = sampleIntervalMillis, memory = memory, networkReceived = networkReceived, networkTransmitted = networkTransmitted, @@ -179,6 +181,78 @@ class MetricsCsvTest { assertThat(cells[MetricsCsv.HEADER.indexOf("annotation_kind")]).isEqualTo("\"TASK\"") } + @Test + fun `a cell a spreadsheet would run as a formula is prefixed`() { + // The annotation columns carry Gradle task names read from the user's own build script, and + // this file is attached to crash reports (ADFA-5526) and feedback (ADFA-5534) that someone + // opens. Quoting alone does not stop the evaluation; the apostrophe does. + val column = MetricsCsv.HEADER.indexOf("annotation") + listOf("=1+1", "+1", "-1", "@SUM(A1)", "\tlater", "\rlater").forEach { label -> + val lines = + render( + snapshot( + rowTimes = longArrayOf(T0), + annotations = listOf(MetricsCsv.Marker(T0, label, "TASK")), + ), + ) + + assertThat(cellsIn(lines[1])[column]).isEqualTo("\"'" + label + "\"") + } + } + + @Test + fun `an ordinary label is not prefixed`() { + // The guard has to be narrow: prefixing every cell would put an apostrophe in front of every + // task name a reader sees. + val lines = + render( + snapshot( + rowTimes = longArrayOf(T0), + annotations = listOf(MetricsCsv.Marker(T0, ":app:assembleV8Debug", "TASK")), + ), + ) + + assertThat(cellsIn(lines[1])[MetricsCsv.HEADER.indexOf("annotation")]) + .isEqualTo("\":app:assembleV8Debug\"") + } + + @Test + fun `an annotation further than one interval from every row is dropped`() { + // An annotation older than the buffer reaches is the ordinary case in a long session: the + // store keeps its own history and the ring buffer has already rolled past it. + val lines = + render( + snapshot( + rowTimes = longArrayOf(T0, T0 + INTERVAL_MS), + annotations = listOf(MetricsCsv.Marker(T0 - 60_000L, "Build started", "BUILD_STARTED")), + ), + ) + + val column = MetricsCsv.HEADER.indexOf("annotation") + assertThat(lines.drop(1).map { cellsIn(it)[column] }).containsExactly("", "") + } + + @Test + fun `a stale annotation does not take the first row from a real one`() { + // Both markers' nearest row is the first one. Sorted by time the stale one comes first, so + // without the cap it took the row and putIfAbsent then dropped the marker that actually + // belongs there -- the file gained an ancient annotation on its oldest row and lost a real + // one, with nothing to say either had happened. + val lines = + render( + snapshot( + rowTimes = longArrayOf(T0, T0 + INTERVAL_MS), + annotations = + listOf( + MetricsCsv.Marker(T0 - 60 * 60 * 1000L, "stale", "TASK"), + MetricsCsv.Marker(T0 + 10L, "real", "TASK"), + ), + ), + ) + + assertThat(cellsIn(lines[1])[MetricsCsv.HEADER.indexOf("annotation")]).isEqualTo("\"real\"") + } + @Test fun `an annotation lands on the sample nearest in time, not only an exact match`() { val times = longArrayOf(T0, T0 + 1_000L, T0 + 2_000L) @@ -240,10 +314,13 @@ class MetricsCsvTest { } @Test - fun `an unconverted monotonic time would land on the oldest row`() { - // What the mix-up looked like on a device: a monotonic time is a few hours and an epoch time - // is decades, so every row is about equally far away and the nearest-row search picks - // whichever number is smallest -- the oldest sample, whenever the event really happened. + fun `an unconverted monotonic time reaches no row at all`() { + // What the mix-up looks like on a device: a monotonic time is a few hours and an epoch time + // is decades, so every row is about equally far away. Before the distance cap the + // nearest-row search picked whichever number was smallest -- the oldest sample, whenever + // the event really happened -- and wrote the marker there. Now it is further from every row + // than a sampling interval, so it is dropped, and the file loses it rather than lying about + // when it happened. Either way [MetricsCsv.epochFor] is what makes it land correctly. val times = longArrayOf(T0, T0 + 1_000L, T0 + 2_000L) val lines = render( @@ -254,8 +331,7 @@ class MetricsCsvTest { ) val column = MetricsCsv.HEADER.indexOf("annotation") - assertThat(cellsIn(lines[1])[column]).isEqualTo("\"Build started\"") - assertThat(cellsIn(lines[3])[column]).isEmpty() + assertThat(lines.drop(1).map { cellsIn(it)[column] }).containsExactly("", "", "") } @Test @@ -334,5 +410,8 @@ class MetricsCsvTest { /** 2026-09-06T22:33:40.123 in America/Los_Angeles, which is UTC-7 at that date. */ const val T0 = 1_788_759_220_123L + + /** The gap between the default rows, and so the distance a marker may sit from one. */ + const val INTERVAL_MS = 1_000L } } From 572cffc6cb2699811070d6b4f33190cd9237a9e6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 09:58:06 -0700 Subject: [PATCH 107/128] ADFA-5553: stop reserving the legend's offset twice isOnAxisBand reserved `mNeededHeight + yOffset` for the legend, but a Legend has already added its offset into that height: the last thing calculateDimensions does, on both of its orientation branches, is `mNeededHeight += mYOffset` -- offsets 879-887 in 3.1.0.21, reached from the horizontal branch by `366: goto 877`. Counting it twice handed the legend a strip yOffset tall that nothing draws in, taken off the bottom of the only target that opens the sampling-rate chooser. Scaling yOffset with the font made the strip half again as tall at the ceiling. The comment beside that scaling had the argument backwards. It said an unscaled offset would let the band creep over the legend; over-reserving can only shrink the band away from it, and the legend accounts for the offset itself in any case. The offset is looks. So are the gaps. The test carried the same wrong reasoning and now says what is actually true. Two more comments described states the code cannot be in. The legend's formLineWidth is never NaN -- NaN is a dataset entry's "defer to the legend" marker, and a Legend's own field defaults to 3f -- so setting 1f is a real change from 3f rather than a guard against a default. And appliedTextScale's KDoc sold detach()'s reset as what makes a rebind re-apply the scale; attach ends in rebuild, which reaches setData, which applies it unconditionally, so the reset is tidiness and nothing rests on it. Recorded rather than fixed: the legend's furniture is no longer a fixed budget. Three entries' dots and gaps were 3*(15+5) + 2*6 = 72dp at every scale before this ticket and are 51dp times the scale now -- narrower up to 1.41 and wider above it, 76.5dp at the 1.5 ceiling. The PR claimed the smaller dot relieves the clipping on a V30; that holds at ordinary sizes and gives 4.5dp back at the extreme. Whether it clips there is a device measurement nobody has taken, so the comment says so and names the fix if it does -- a ceiling on the legend's scale, not a smaller dot. The new test taps the recovered strip and fails without the fix, with the tap not reaching the chooser. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 36 +++++++++++++------ .../androidide/ui/MetricsChartAxisTapTest.kt | 19 ++++++++++ .../ui/MetricsChartLegendFormTest.kt | 8 ++--- 3 files changed, 47 insertions(+), 16 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 12f70924d0..2c8b89407d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -116,9 +116,12 @@ abstract class MetricsChartRenderer( val chart = this.chart ?: return false val top = chart.viewPortHandler.contentBottom() val legend = chart.legend - // What the chart reserves for the legend at the bottom: its measured height plus the - // offset it keeps above itself. Both are pixels, as MPAndroidChart stores them. - val reservedForLegend = if (legend.isEnabled) legend.mNeededHeight + legend.yOffset else 0f + // What the chart reserves for the legend at the bottom, in pixels. mNeededHeight already + // includes yOffset: the last thing Legend.calculateDimensions does, on both of its + // orientation branches, is `mNeededHeight += mYOffset` (3.1.0.21, offsets 879-887, reached + // from the horizontal branch by `366: goto 877`). Adding the offset again reserved it twice + // and took a strip the height of yOffset off the bottom of the tap target. + val reservedForLegend = if (legend.isEnabled) legend.mNeededHeight else 0f val bottom = maxOf(chart.height - reservedForLegend, top + chart.xAxis.textSize) return y >= top && y < bottom } @@ -138,8 +141,11 @@ abstract class MetricsChartRenderer( * * [redraw] runs once per sampling tick per attached page, and re-applying the scale there * rewrites nine chart properties and re-measures four text sizes to catch a change that - * happens at most a handful of times in a session. Per chart, so a rebind re-applies: [detach] - * clears it. + * happens at most a handful of times in a session. + * + * [detach] resets it, but nothing depends on that: [attach] ends in `rebuild`, which reaches + * `setData`, which applies the scale unconditionally. The reset keeps a detached renderer from + * holding a claim about a chart it no longer has, and is not what makes a rebind re-apply. */ private var appliedTextScale = Float.NaN @@ -297,8 +303,10 @@ abstract class MetricsChartRenderer( // form only for an entry left at DEFAULT -- so a dataset that sets either one wins // silently. The size itself is set in [applyTextScale], which has to re-apply it. legend.form = Legend.LegendForm.CIRCLE - // Kept at the 1f the renderers used to ask for. Inert while the form is a circle, but - // leaving it NaN would silently adopt the library's 3f the day anyone chooses LINE. + // Kept at the 1f the renderers used to ask for, down from the 3f a Legend defaults to. + // NaN is a dataset entry's "defer to the legend" marker and is not a state the legend's + // own field can be in, so this is a real change rather than a guard against one -- inert + // while the form is a circle, and load-bearing only if anyone chooses LINE. legend.formLineWidth = 1f onChartGestureListener = XAxisTapListener(this) @@ -540,12 +548,18 @@ abstract class MetricsChartRenderer( chart.legend.formSize = BASE_LEGEND_FORM_DP * scale // The gaps go with them. Left fixed they close up as the text grows -- the same argument // as the dot, applied to the space around it. + // + // Which means the legend's furniture is no longer a fixed budget. Three entries' dots and + // gaps came to 3*(15+5) + 2*6 = 72dp before this ticket, at every scale; they now come to + // 51dp times the scale. That is narrower up to 1.41 and wider above it -- 76.5dp at the 1.5 + // ceiling. The smaller dot buys width at the sizes most people run and gives 4.5dp of it + // back at the extreme. Whether that clips on the narrowest screen we support has not been + // measured; if it does, the answer is a ceiling on the scale used here, not a smaller dot. chart.legend.formToTextSpace = BASE_LEGEND_FORM_TO_TEXT_DP * scale chart.legend.xEntrySpace = BASE_LEGEND_ENTRY_SPACE_DP * scale - // yOffset is not only cosmetic: [isOnAxisBand] measures the band the sampling-rate tap - // lives in as `height - (legend.mNeededHeight + legend.yOffset)`. Left unscaled, the band - // creeps over the legend again as the text grows -- which is the ADFA-5510 defect this - // stack already fixed once. + // The gap above the legend, scaled for the same reason as the gaps beside the dot. It has + // no bearing on where [isOnAxisBand] puts the band: the legend folds yOffset into + // mNeededHeight itself, so the band already accounts for it at whatever size it is. chart.legend.yOffset = BASE_LEGEND_Y_OFFSET_DP * scale chart.xAxis.textSize = BASE_TEXT_SIZE_DP * scale chart.axisLeft.textSize = BASE_TEXT_SIZE_DP * scale 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 c9713c73f9..e3a5334ca3 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -156,6 +156,25 @@ class MetricsChartAxisTapTest { assertThat(taps).isEqualTo(1) } + @Test + fun `the gap the legend keeps above itself still opens the chooser`() { + val chart = laidOutChart() + val legend = chart.legend + + // Guards the assertion below: with no legend, or no gap, there is no strip to test. + assertThat(legend.isEnabled).isTrue() + assertThat(legend.mNeededHeight).isGreaterThan(0f) + assertThat(legend.yOffset).isGreaterThan(0f) + + // Legend.calculateDimensions ends with `mNeededHeight += mYOffset`, so the offset is + // already inside the measured height. Reserving `mNeededHeight + yOffset` counted it twice + // and handed the legend a strip yOffset tall that nothing draws in -- taken off the bottom + // of the one target that opens the sampling-rate chooser. + tapAt(chart, CHART_HEIGHT - legend.mNeededHeight - legend.yOffset / 2f) + + assertThat(taps).isEqualTo(1) + } + @Test fun `a tap above the plot does not open the chooser`() { val chart = laidOutChart() diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt index f9101d76ce..496cc6057e 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt @@ -139,11 +139,9 @@ class MetricsChartLegendFormTest { assertWithMessage("$name formToTextSpace").that(legend.formToTextSpace).isWithin(TOLERANCE).of(7.5f) assertWithMessage("$name xEntrySpace").that(legend.xEntrySpace).isWithin(TOLERANCE).of(9f) - // 15dp and 4.5dp, in pixels. yOffset is the one with a consequence beyond looks: - // isOnAxisBand measures the sampling-rate tap band as - // `height - (legend.mNeededHeight + legend.yOffset)`, so an unscaled offset walks the - // band back over the legend as the text grows -- the ADFA-5510 defect this stack has - // already fixed once. + // 15dp and 4.5dp, in pixels. Both are looks only. An earlier version of this comment + // claimed the offset also moved the sampling-rate tap band, which it cannot: the + // legend adds yOffset into mNeededHeight itself, and the band is measured from that. assertWithMessage("$name textSize").that(legend.textSize).isWithin(TOLERANCE).of(30f) assertWithMessage("$name yOffset").that(legend.yOffset).isWithin(TOLERANCE).of(9f) } From b7d9b697dc11104a77de65ee59742dcf80589e3d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 10:38:31 -0700 Subject: [PATCH 108/128] ADFA-5554: a cancelled gesture is not a lift, and the heap raise was wrong A press an ancestor steals opened the sampling-rate chooser. ChartTouchListener.endAction runs for ACTION_CANCEL as well as ACTION_UP -- case 3 and case 1 of one tableswitch, both reaching it with the original event -- and it reports mLastGesture untouched, because startAction never resets it. So the reveal layout, the bottom sheet or the pager taking the stream mid-press looked exactly like a finger lifting early, and the stand-in tap fired. The chooser clears every sample buffer. The listener now asks the event whether this was a lift. The stand-in tap was posted and then forgotten, so cancelPendingHelp could not take it back although its own KDoc says it does -- the asymmetry the comment two lines up forbids. A detach landing between the lift and the looper's next turn opened the chooser for a chart the renderer no longer had. It is held now, like performOnHold's click. attach()'s unconditional teardown, added last round to stop a second gesture listener being installed, also cleared userHasZoomed. Rebinding an already-bound holder therefore threw away the user's pan and the next tick scrolled the chart back to the newest samples underneath them. The teardown stays; the viewport survives it. A control did not wait out the tap timeout before lighting up. View.onTouchEvent delays the pressed state for a view in a scrolling container so that a flick starting on a button scrolls without flashing it, and this listener lit up on DOWN regardless -- for the bottom sheet's own output-action buttons, which this ticket wired for help and which sit in a HorizontalScrollView. Note ViewGroup defaults shouldDelayChildPressedState to true but FrameLayout and LinearLayout both override it to false, so the container has to be one that scrolls. The long-press haptic fired before anything checked whether a tooltip could appear, so a hold completing after its window had gone still buzzed for help that never showed -- which was the stated reason for backing out the window-attach guard. canShowPopup is asked first now. Untested: TooltipManager reads the docs database in its static initialiser and cannot be loaded off-device, which is why no test in this module reaches it. Three comments described things that are not so. The isClickable gate was attributed to View.onTouchEvent, which reads `clickable` once as CLICKABLE || LONG_CLICKABLE || CONTEXT_CLICKABLE and never re-tests it -- our gate is deliberately stricter, and the comment now says why. clearOnHold counted "five of the six" views without a touch listener, which stopped being true in the PR that wrote it. And the two-finger test now says plainly that it drives the callback directly and cannot reach the gesture that matters, because a ViewGroup rewrites ACTION_POINTER_DOWN to ACTION_MOVE for the child holding the first pointer; driving that from MetricsCarouselLayout is its own change. The 2g heap raise is reverted, and my reasoning for it was wrong twice over. The three test classes this PR adds contribute one @Config between them, so they add no Robolectric sandbox -- :app has ten either way -- and the full suite runs green at 1g from scratch. Worse, the symptom I was chasing is not heap at all: the app installs an uncaught exception handler that calls exitProcess, so any background throw inside the test JVM kills it with no failure recorded. That is exit 1; the exit 3 I saw is ExitOnOutOfMemoryError arriving while the handler chain runs. Running this class alone still kills the JVM with every change here reverted, and the same full-suite command failed once and passed on rerun. None of that is this ticket's, and doubling heap for every subproject at workers.max=30 to hide it was the wrong trade. It belongs to ADFA-5559. The pressed-state test asserts only that the control is dark on DOWN. That it lights up after the timeout could not be asserted: running the delayed press from the looper trips the instability above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- REVIEW.md | 2 +- .../androidide/ui/MetricsChartRenderer.kt | 42 +++++++- .../androidide/ui/LongPressHelpTimingTest.kt | 52 ++++++++++ .../androidide/ui/MetricsChartAxisTapTest.kt | 23 +++++ .../ui/MetricsChartGestureTeardownTest.kt | 57 +++++++++++ build.gradle.kts | 9 +- .../androidide/idetooltips/ToolTipManager.kt | 10 +- .../com/itsaky/androidide/utils/ViewUtils.kt | 99 +++++++++++++++---- 8 files changed, 261 insertions(+), 33 deletions(-) diff --git a/REVIEW.md b/REVIEW.md index dec26df2a1..036de3a0bc 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -176,7 +176,7 @@ Help in CoGo is reached by **long-press**, anywhere: a progressive three-tier ex - **Wire up help on new interactive elements.** Anything tappable — buttons, icon controls, menu items, list rows, toolbar actions — gets long-press help. A new actionable view with no tooltip is as incomplete as a missing `contentDescription`. - **Cover new screens and panels too.** Even where pixels aren't interactive, a new screen/panel/dialog needs a top-level help entry so help is always reachable. - **The affordance is the requirement, not finished copy.** Tooltip content may still be in authoring — fine — but the long-press must be wired and routed into the tier system. Don't ship UI that can never surface help. -- **Reuse the system.** Wire help through `idetooltips` — today the `View.displayTooltipOnLongPress(context, anchorView, category, tag)` extension (`setOnLongClickListener` → `TooltipManager.showTooltip`) — not a one-off popup. +- **Reuse the system.** Wire help through `idetooltips` — today the `View.displayTooltipOnLongPress(context, tooltipTag, tooltipCategory, holdMillis)` extension (a long-click listener for the framework's own gesture, plus a touch listener that times the longer hold ADFA-5554 asks for, both reaching `TooltipManager.showTooltip`) — not a one-off popup. - **Compose has no native entry point yet** (tracked by **ADFA-4381**). The helper is View-based (needs an `anchorView`), so until `idetooltips` grows a Compose API, a composable wires help via `AndroidView` interop. Flag it in review rather than skipping help, and build the reusable `Modifier`/wrapper once instead of copy-pasting interop. ## 10. Architecture alignment 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 f7be8f449f..3caceafd64 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -218,7 +218,16 @@ abstract class MetricsChartRenderer( // there let [configure] install a second gesture listener while the first stayed queued on // the main thread with a hold nothing could reach, and added a second layout listener that // one removeOnLayoutChangeListener cannot undo. + // + // The one thing that must survive it is the user's own viewport. detach() clears + // userHasZoomed, which is what turns the auto-follow window back on, so a rebind of an + // already-bound holder would snap a chart the user had panned back to the newest samples. + val sameChart = this.chart === chart + val hadZoomed = userHasZoomed detach() + if (sameChart) { + userHasZoomed = hadZoomed + } this.chart = chart configure(chart) chart.addOnLayoutChangeListener(newestWindowOnLayout) @@ -455,6 +464,16 @@ abstract class MetricsChartRenderer( /** The deferred half of a long press, waiting out the rest of the hold. */ private var pendingHelp: Runnable? = null + /** + * The stand-in tap waiting for the next turn of the looper. + * + * Held for the same reason [performOnHold] holds its click: posted rather than run inline, + * it outlives the dispatch that queued it, so a [detach] landing in between would otherwise + * still open the sampling-rate chooser for a chart the renderer no longer has -- and that + * chooser clears every sample buffer. + */ + private var pendingTap: Runnable? = null + /** Whether this gesture already showed help, so its lift must not also count as a tap. */ private var helpShown = false @@ -486,12 +505,29 @@ abstract class MetricsChartRenderer( // history loss [isOnAxisBand] was narrowed to prevent. [onChartTranslate] and // [onChartScale] give up the stand-in as the gesture escalates; this is the check for // an escalation neither of them reports. - if (!helpShown && pendingTapOnAxis && lastPerformedGesture == ChartTouchListener.ChartGesture.LONG_PRESS) { + // A cancel is not a lift. ChartTouchListener.endAction runs for ACTION_CANCEL as well + // as ACTION_UP -- case 3 and case 1 of the same tableswitch, both reaching it with the + // original event -- and it reports mLastGesture untouched, because startAction never + // resets it. So a press an ancestor steals mid-gesture (the reveal layout, the bottom + // sheet, the pager) arrived here looking exactly like a finger lifted early, and stood + // in for a tap the user never completed. The chooser it opens clears every buffer. + val lifted = me?.actionMasked != MotionEvent.ACTION_CANCEL + if (lifted && + !helpShown && + pendingTapOnAxis && + lastPerformedGesture == ChartTouchListener.ChartGesture.LONG_PRESS + ) { // Posted, not called here. This runs inside the chart's onTouchEvent, and the tap // opens a dialog; showing one mid-dispatch leaves the chart's touch state and its // velocity tracker part-way through a gesture. performOnHold posts its click for // the same reason, and the two paths should not disagree. - handler.post { onXAxisTap?.invoke() } + val tap = + Runnable { + pendingTap = null + onXAxisTap?.invoke() + } + pendingTap = tap + handler.post(tap) } helpShown = false pendingTapOnAxis = false @@ -506,6 +542,8 @@ abstract class MetricsChartRenderer( fun cancelPendingHelp() { pendingHelp?.let(handler::removeCallbacks) pendingHelp = null + pendingTap?.let(handler::removeCallbacks) + pendingTap = null } override fun onChartLongPressed(me: MotionEvent?) { diff --git a/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt index 7b81c07063..30e722393c 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt @@ -23,6 +23,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.widget.Button +import android.widget.HorizontalScrollView import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.utils.clearLongPressHelp @@ -69,6 +70,21 @@ class LongPressHelpTimingTest { performOnHold { holds++ } } + /** + * The same control inside a container that delays its children's pressed state. + * + * A `HorizontalScrollView` because that is the real case: the bottom sheet's output-action + * buttons, which this ticket wired for help, sit in one. Not any container -- `ViewGroup` + * defaults to true but `FrameLayout` and `LinearLayout` both override it to false, so the + * choice here has to be a container that actually scrolls. + */ + private fun targetInScrollingContainer(): Button { + val button = target() + HorizontalScrollView(context).addView(button) + button.layout(0, 0, WIDTH, HEIGHT) + return button + } + private fun send( view: View, action: Int, @@ -91,6 +107,42 @@ class LongPressHelpTimingTest { */ private fun drain() = shadowOf(Looper.getMainLooper()).idle() + @Test + fun `a control with no scrolling ancestor lights up the moment the finger lands`() { + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + + assertThat(view.isPressed).isTrue() + } + + @Test + fun `a control inside a scrolling container waits out the tap timeout first`() { + val view = targetInScrollingContainer() + + send(view, MotionEvent.ACTION_DOWN) + + // View.onTouchEvent does not light a control up straight away when it can be scrolled: + // it waits a tap timeout, so a flick that happens to start on a button scrolls without + // flashing it. Taking the touch over means taking that over too, and this listener did + // not -- every drag off one of these controls blinked it first. + assertThat(view.isPressed).isFalse() + } + + @Test + fun `a flick off a control in a scrolling container never lights it up`() { + val view = targetInScrollingContainer() + + send(view, MotionEvent.ACTION_DOWN) + send(view, MotionEvent.ACTION_MOVE, x = WIDTH * 4f, y = HEIGHT * 4f) + elapse(ViewConfiguration.getTapTimeout().toLong()) + + // The pressed state is on the queue when the finger leaves, so dropping the hold is not + // enough: the flash arrives after the gesture that cancelled it. + assertThat(view.isPressed).isFalse() + assertThat(holds).isEqualTo(0) + } + @Test fun `a press past the platform timeout but short of the hold still clicks`() { // The regression the obvious fix introduces, and the reason this class exists. The 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 c9713c73f9..e27603e510 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -105,6 +105,29 @@ class MetricsChartAxisTapTest { .isLessThan(SAMPLES - 1) } + @Test + fun `re-attaching the same chart keeps the viewport the user drove`() { + val chart = laidOutChart() + drawOnce(chart) + chart.setVisibleXRangeMaximum(VISIBLE_WINDOW.toFloat()) + chart.moveViewToXNow(0f) + drawOnce(chart) + + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, 10f, 0) + chart.onChartGestureListener.onChartTranslate(event, -50f, 0f) + event.recycle() + assertThat(attachedRenderer.visibleSampleRange(chart, SAMPLES).last).isLessThan(SAMPLES - 1) + + // A rebind of an already-bound holder. The teardown it runs is what stops a second gesture + // listener being installed, so it has to happen -- but it also cleared the flag that says + // the user has driven the viewport, and the next tick then scrolled the chart back to the + // newest samples underneath them. + attachedRenderer.attach(chart) + drawOnce(chart) + + 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(CHART_WIDTH, CHART_HEIGHT, Bitmap.Config.ARGB_8888))) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt index 2243484f4e..0b870553a9 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt @@ -119,6 +119,26 @@ class MetricsChartGestureTeardownTest { event.recycle() } + /** + * The end of a gesture an ancestor took away. + * + * ChartTouchListener.endAction is reached from ACTION_CANCEL as well as ACTION_UP, with the + * original event and with mLastGesture untouched, so this is what the listener actually sees + * when the reveal layout, the bottom sheet or the pager claims the stream mid-press. + */ + private fun cancelGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) { + val now = SystemClock.uptimeMillis() + val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 10f, 0f, 0) + chart.onChartGestureListener.onChartGestureEnd(event, gesture) + event.recycle() + } + + /** A y on the axis band, where a tap opens the sampling-rate chooser. */ + private fun onAxisBand(chart: SafeLineChart) = chart.viewPortHandler.contentBottom() + 1f + /** Runs the main looper forward by [millis] of virtual time. */ private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) @@ -136,10 +156,47 @@ class MetricsChartGestureTeardownTest { /** A y inside the plot, where a hold means help for the page rather than for the axis. */ private fun insidePlot(chart: SafeLineChart) = (chart.viewPortHandler.contentTop() + chart.viewPortHandler.contentBottom()) / 2f + @Test + fun `a gesture an ancestor cancels does not stand in for a tap`() { + val chart = laidOutChart() + + longPressAt(chart, onAxisBand(chart)) + cancelGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + drain() + + // A cancel is not a lift. The chooser this would open clears every sample buffer, so a + // press the sheet or the pager steals mid-gesture must not be read as a finger lifting + // early -- which is exactly what it looked like, because endAction reports the same + // LONG_PRESS for both. + assertThat(taps).isEqualTo(0) + } + + @Test + fun `detaching takes back a stand-in tap that has been posted`() { + val chart = laidOutChart() + + longPressAt(chart, onAxisBand(chart)) + endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + // The tap is on the looper now, not yet run. Letting the chart go in that window used to + // leave it there: it opened the chooser, and cleared every buffer, for a chart this + // renderer no longer had. + renderer.detach() + drain() + + assertThat(taps).isEqualTo(0) + } + @Test fun `a second finger gives up the gesture, even without a move`() { val chart = laidOutChart() + // This tests what the renderer does when the second pointer is reported, by calling the + // callback directly. It does NOT test that the callback fires for the gesture that matters, + // and it cannot: a ViewGroup rewrites ACTION_POINTER_DOWN to ACTION_MOVE for the child + // already holding the first pointer, so on the realistic undock -- one finger on an arrow, + // one on the strip -- SafeLineChart.onTouchEvent never sees a pointer-down at all. Driving + // this from MetricsCarouselLayout.dispatchTouchEvent, which does see it, is its own change. + // // The carousel undocks on a two-finger tap, and that starts as a press like any other. // MPAndroidChart cannot report it -- ACTION_POINTER_DOWN never touches its mLastGesture -- // so the gesture still ends labelled LONG_PRESS and the stand-in tap fired, opening the diff --git a/build.gradle.kts b/build.gradle.kts index ba897ec108..60208faa6a 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -93,14 +93,7 @@ subprojects { // Gradle's default test-worker heap is 512m, too small for the Robolectric + // Kotlin Analysis API suites (:lsp:kotlin peaks near 240m and keeps growing). // Keep it explicit so the suites fail on a real regression, not on the default. - // - // 1g -> 2g (ADFA-5554): Robolectric builds a separate sandbox per distinct @Config, each - // loading the framework again, and :app now has enough of them that 1g died mid-run -- - // exit code 3, no failure recorded, and whichever tests had not run yet reported zeroes - // that look like assertion failures. Raising it here rather than in :app because this is - // the block that wins: a maxHeapSize set in a module's own testOptions is overwritten by - // this one, which is why an experiment that appeared to rule heap out did not. - maxHeapSize = "2g" + maxHeapSize = "1g" // Backstop: kill any individual Test task that runs longer than 10 minutes. // Prevents a single hung test JVM (e.g. the Tooling API child) from burning diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt index 92d5221b68..6e2e4de7b3 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt @@ -252,7 +252,15 @@ object TooltipManager { ) } - private fun canShowPopup(context: Context, view: View): Boolean { + /** + * Whether a popup anchored to [view] can actually be shown right now. + * + * Internal so [com.itsaky.androidide.utils.showTooltipIfPresent] can ask before it plays the + * long-press haptic. Asking after is too late: the buzz is the user's signal that help arrived, + * and a hold that completes 800ms after its window has gone fired it for a tooltip that never + * appeared. + */ + internal fun canShowPopup(context: Context, view: View): Boolean { tailrec fun Context.findActivity(): Activity? { return when (this) { is Activity -> this diff --git a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt index f7638a16fb..304270eac2 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt @@ -7,6 +7,7 @@ import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration +import android.view.ViewGroup import com.itsaky.androidide.idetooltips.R import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager @@ -28,12 +29,16 @@ fun showTooltipIfPresent( tag: String, playHapticFeedback: Boolean = true, ) { - if (tag.isNotBlank()) { - if (playHapticFeedback) { - anchor.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - } - TooltipManager.showTooltip(context, anchor, category, tag) + if (tag.isBlank() || !TooltipManager.canShowPopup(context, anchor)) { + // Asked before the haptic, not after. The buzz is what tells the user help has arrived, and + // showTooltip declines silently for a detached anchor -- so a hold completing after its + // window has gone used to buzz for a tooltip that never appeared. + return } + if (playHapticFeedback) { + anchor.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + TooltipManager.showTooltip(context, anchor, category, tag) } /** Shows [tag]'s IDE-category tooltip anchored to [anchor]. See [showTooltipIfPresent]. */ @@ -151,9 +156,11 @@ fun View.clearLongPressHelp() { * caller that installed only a hold should be able to undo only a hold. * * The touch listener is removed only when [performOnHold] is the one that installed it, which the - * tag says. Five of the six views [clearLongPressHelp] is called on are wired through the - * framework's long click and never had one, and a blanket `setOnTouchListener(null)` there would - * silently take away an unrelated listener the next contributor adds. + * tag says. Most views [clearLongPressHelp] is called on are wired through the framework's long + * click and never had one, and a blanket `setOnTouchListener(null)` there would silently take away + * an unrelated listener the next contributor adds. (An earlier version of this line counted them -- + * "five of the six" -- which stopped being true in the same PR that wrote it, when the bottom + * sheet's buttons were converted.) */ fun View.clearOnHold() { val hold = getTag(R.id.tooltip_hold_listener) as? HoldTouchListener ?: return @@ -195,27 +202,61 @@ private class HoldTouchListener( private var holding = false + /** + * The pressed state waiting out [ViewConfiguration.getTapTimeout], or `null` when there is none. + * + * `View.onTouchEvent` does not light a control up the instant a finger lands on it when the + * control sits in a scrolling container: it waits a tap timeout first, so that a flick which + * happens to start on a button scrolls without flashing it. Taking the touch over means taking + * that over too. The bottom sheet's output-action buttons, which ADFA-5554 wired for help, sit + * in a HorizontalScrollView, so this is a real case here and not a hypothetical one. + */ + private var pendingPress: Runnable? = null + /** The click waiting for the next turn of the looper, so a teardown can still take it back. */ private var pendingClick: Runnable? = null private val fire = Runnable { held = true - view.isPressed = false + releasePress() onHold() } fun cancel() { holding = false handler.removeCallbacks(fire) + releasePress() // The click too. It is posted rather than run inline, so a teardown landing between the // finger lifting and the looper's next turn would otherwise still click a control it has // just unwired -- the same defect the hold timer has, one method along. pendingClick?.let(handler::removeCallbacks) pendingClick = null + } + + /** Drops a press that has not been drawn yet, and any that has. */ + private fun releasePress() { + pendingPress?.let(handler::removeCallbacks) + pendingPress = null view.isPressed = false } + /** + * Whether any ancestor delays the pressed state of its children, which is what + * `View.isInScrollingContainer` asks. That method is not in the public SDK; the question it + * answers is, one `ViewGroup` at a time. + */ + private fun isInScrollingContainer(): Boolean { + var parent = view.parent + while (parent is ViewGroup) { + if (parent.shouldDelayChildPressedState()) { + return true + } + parent = parent.parent + } + return false + } + /** * Whether a touch at ([x], [y]) is still on the view, by the framework's rule. * @@ -238,10 +279,22 @@ private class HoldTouchListener( MotionEvent.ACTION_DOWN -> { held = false holding = true - v.isPressed = true // The framework starts the ripple from the touch point. Without this every ripple // on these controls begins at the centre of the drawable instead. - v.drawableHotspotChanged(event.x, event.y) + val x = event.x + val y = event.y + val press = + Runnable { + pendingPress = null + v.isPressed = true + v.drawableHotspotChanged(x, y) + } + if (isInScrollingContainer()) { + pendingPress = press + handler.postDelayed(press, ViewConfiguration.getTapTimeout().toLong()) + } else { + press.run() + } handler.postDelayed(fire, holdMillis) } @@ -252,7 +305,7 @@ private class HoldTouchListener( // enough to open that button's help over a strip that was undocking. holding = false handler.removeCallbacks(fire) - v.isPressed = false + releasePress() } MotionEvent.ACTION_MOVE -> { @@ -261,20 +314,24 @@ private class HoldTouchListener( // treats a drag out of a view. Taking the touch over means saying so. holding = false handler.removeCallbacks(fire) - v.isPressed = false + releasePress() } } MotionEvent.ACTION_UP -> { handler.removeCallbacks(fire) - v.isPressed = false + releasePress() // The click belongs to a press that stayed put, did not become a hold, and landed - // on something that answers taps. That last test is View.onTouchEvent's, and - // taking the touch over means taking it over too: the carousel dims the arrow at - // either end by clearing isClickable rather than isEnabled, precisely so it still - // answers a hold, and without this it went back to answering taps -- playing the - // click sound and announcing a click for a control a screen reader is being told - // is unavailable. + // on something that answers taps. + // + // That last test is stricter than the framework's, deliberately. View.onTouchEvent + // reads `clickable` once at the top, as CLICKABLE || LONG_CLICKABLE || + // CONTEXT_CLICKABLE, and never re-tests isClickable before performing the click -- + // so a long-clickable view still clicks there. The carousel dims the arrow at + // either end by clearing isClickable rather than isEnabled, precisely so it keeps + // answering a hold, and matching the framework here would have it answer taps too: + // playing the click sound and announcing a click for a control a screen reader is + // being told is unavailable. if (holding && !held && v.isClickable) { // Posted rather than called here, as View.onTouchEvent does, so the pressed // state is drawn before the action runs -- these open dialogs and re-page the @@ -297,7 +354,7 @@ private class HoldTouchListener( MotionEvent.ACTION_CANCEL -> { holding = false handler.removeCallbacks(fire) - v.isPressed = false + releasePress() } } return true From a18fdbdce912c138ef6e712442fbc0674c0e459f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 11:16:57 -0700 Subject: [PATCH 109/128] ADFA-5542: finish the sweep, and stop leaking the cancellation token The sibling sweep missed the path its own test drives. A sync the user stops arrives at ProjectHandlerActivity.postProjectInit as a failure carrying BUILD_CANCELLED, and the `when` there has no arm for it, so pressing Stop during a sync produced an indefinite red "Project initialization failed". The server-side regression test added last round exercises exactly this path, which makes "not reported as an error anywhere" false where it was most confidently claimed. The cancellation token outlived every build it belonged to. executeTasks cleared it on success and not on failure; the sync path never cleared it on any outcome at all, only shutdown() and an actual Stop did. So after any sync, and after any failed build, a token for a finished source sat in the server: the next Stop cancelled that dead source and answered wasEnqueued = true with nothing running -- and once a real build had started, left it running while telling the user it was being stopped. initialize(), which cancels first whenever a token is set, paid for a build that had ended long before. Both paths clear it in a finally now, and two tests fail without it. Removing onBuildCancelRequested took away the only request-time signal and nothing replaced the half that mattered. A Stop the server refuses -- NO_RUNNING_BUILD, or Gradle declining -- reached a log line at one call site and nothing whatsoever at the other: EditorPanelDockableContent threw the result away entirely. Both go through one reporter now, which says so. It also stops dereferencing failureReason with !!, which would have crashed on a refusal that carried no reason. Three more places still called a cancel a failure. BuildViewModel threw RuntimeException("Task execution failed: BUILD_CANCELLED") -- a cancel does not arrive as a CancellationException, so its catch could not tell, and the enum name was shown to the user as an error. The status line under the build output kept Gradle's own "BUILD FAILED", which onOutput copies there, contradicting the bar that had just said the build was stopped. And telemetry recorded success=false with no reason at all, so a deliberate cancel and a broken build were indistinguishable and every build-success rate counted the one the IDE makes easiest to press. The predicate is defined once now rather than derived three times in one callback, which is the duplicated-comparison finding from the previous round, reintroduced. The two KDocs contradicted each other about null. BuildResult.failure said null meant success; EventListener.onBuildFailed said it meant the server did not say. From this server it can be neither on a failure -- notifyBuildFailure classifies and returns non-null, and both catch paths go through it -- so the nullability exists for a server that does not classify. Both say that now, and the tests that cover null say they cover a defensive default rather than a reachable path. The test named "not reported as an error anywhere" asserted one of the four regressions it listed, so deleting the notification branch left it green. It is renamed to what it checks, and the notification and the telemetry reason now have tests of their own. Still unpinned, and said so in the test: the bar itself, and the cancelled-sync branch. Both need a live activity, which is what onBuildFailed returns early without. Not fixed, deliberately: info_build_cancelled exists in 3 of 14 locales and build_status_failed in 13, so ten locales trade a translated "Build failed" for an English "Build was cancelled by the user." I am not inventing translations. Every recently added string here is 3/14, so English fallback is this project's standing state rather than something this ticket introduced -- but the trade is real, and correct-in-English beats translated-and-wrong for a message that accuses the user of a failure they did not cause. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../build/AbstractCancellableRunAction.kt | 24 +----- .../editor/ProjectHandlerActivity.kt | 14 ++++ .../analytics/gradle/BuildCompletedMetric.kt | 6 ++ .../floating/EditorPanelDockableContent.kt | 5 +- .../handlers/EditorBuildEventListener.kt | 24 +++++- .../services/builder/GradleBuildService.kt | 29 +++++-- .../androidide/utils/BuildCancellation.kt | 63 +++++++++++++++ .../androidide/viewmodel/BuildViewModel.kt | 13 +++- .../analytics/BuildCompletedMetricTest.kt | 78 +++++++++++++++++++ .../EditorBuildEventListenerAnnotationTest.kt | 14 ++-- ...radleBuildServiceNotificationStatusTest.kt | 62 +++++++++++++++ resources/src/main/res/values/strings.xml | 1 + .../tooling/impl/ToolingApiServerImpl.kt | 39 +++++++--- .../tooling/impl/ToolingApiServerImplTest.kt | 39 ++++++++++ .../api/messages/result/BuildResult.kt | 8 +- 15 files changed, 366 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/BuildCancellation.kt create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceNotificationStatusTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt b/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt index aea4c2b0e0..445a19d48f 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt @@ -12,6 +12,7 @@ import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.requestBuildCancellation import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -77,34 +78,13 @@ abstract class AbstractCancellableRunAction( protected abstract fun doExec(data: ActionData): Any protected fun cancelBuild(): Boolean { - log.info("Sending build cancellation request...") val builder = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) if (builder?.isToolingServerStarted() != true) { flashError(com.itsaky.androidide.projects.R.string.msg_tooling_server_unavailable) return false } - builder.cancelCurrentBuild().whenComplete { - result, - error, - -> - if (error != null) { - log.error("Failed to send build cancellation request", error) - return@whenComplete - } - - if (!result.wasEnqueued) { - log.warn( - "Unable to enqueue cancellation request reason={} reason.message={}", - result.failureReason, - result.failureReason!!.message, - ) - return@whenComplete - } - - log.info("Build cancellation request was successfully enqueued...") - } - + requestBuildCancellation(builder) return true } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index afc6707577..716a225cf6 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -80,6 +80,7 @@ import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams import com.itsaky.androidide.tooling.api.messages.result.InitializeResult import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.BUILD_CANCELLED import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.CACHE_READ_ERROR import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_DIRECTORY_INACCESSIBLE import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY @@ -95,6 +96,7 @@ import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt import com.itsaky.androidide.utils.RecursiveFileSearcher import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.flashbarBuilder import com.itsaky.androidide.utils.onLongPress @@ -797,6 +799,18 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { manager.projectDir.name } + // A sync the user stopped is not a failure, and arrives here through the same callback + // as one. ADFA-5542 fixed that for builds and missed this path, which is the one a + // cancelled *sync* takes: the user pressed Stop and got an indefinite red "Project + // initialization failed" for doing so. + if (failure == BUILD_CANCELLED) { + val cancelled = getString(string.info_build_cancelled) + setStatus(cancelled) + flashInfo(cancelled) + editorViewModel.isInitializing = false + return + } + val initFailed = if (projectName.isNotEmpty()) { getString(string.msg_project_initialization_failed_with_name, projectName) diff --git a/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt b/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt index c095b08320..f216a7e0ac 100644 --- a/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt +++ b/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt @@ -20,5 +20,11 @@ class BuildCompletedMetric( putString("build_type", buildType) putBoolean("success", isSuccess) putLong("duration_ms", buildResult.durationMs) + // Why it was not successful, which the metric used to drop. A build the user stopped + // and a build that broke both arrive as success=false, so without this the two are + // indistinguishable and every build-success rate counts deliberate cancels as + // failures. isSuccess keeps its meaning -- a cancelled build did not succeed -- and a + // consumer that wants the rate excluding cancels can now compute it. + buildResult.failure?.let { putString("failure_reason", it.name) } } } diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt index cee0b2ad74..918a2ef2f2 100644 --- a/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt @@ -19,6 +19,7 @@ import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.ui.CodeEditorView +import com.itsaky.androidide.utils.requestBuildCancellation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -114,7 +115,9 @@ class EditorPanelDockableContent( private fun cancelBuild() { val builder = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) if (builder?.isToolingServerStarted() == true) { - builder.cancelCurrentBuild() + // Through the shared reporter, because this copy threw the result away entirely: a + // Stop the server refused here said nothing at all, not even to the log. + requestBuildCancellation(builder) } } 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 4abae99862..685b859113 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -136,6 +136,16 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } } + /** + * Whether [failure] is the user's own Stop rather than something going wrong. + * + * One definition, because this callback used to ask the same question three times -- once in + * [failureMessage], once in [outcomeKind] and once inline -- which is how the chart and the + * messages beside it came to disagree in the first place. + */ + @VisibleForTesting + internal fun isCancelled(failure: TaskExecutionResult.Failure?): Boolean = failure == TaskExecutionResult.Failure.BUILD_CANCELLED + /** * What a failed build is reported as, to the plugins and in the result the editor posts. * @@ -149,7 +159,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { cancelledText: String, ): String = when { - failure == TaskExecutionResult.Failure.BUILD_CANCELLED -> cancelledText + isCancelled(failure) -> cancelledText lastStatusLine.contains("BUILD FAILED") -> lastStatusLine else -> "Build failed. Check build output for details." } @@ -168,7 +178,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { */ @VisibleForTesting internal fun outcomeKind(failure: TaskExecutionResult.Failure?): MetricsAnnotationStore.Kind = - if (failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { + if (isCancelled(failure)) { MetricsAnnotationStore.Kind.BUILD_CANCELLED } else { MetricsAnnotationStore.Kind.BUILD_FAILED @@ -245,7 +255,7 @@ class EditorBuildEventListener : GradleBuildService.EventListener { ) { val act = checkActivity("onBuildFailed") ?: return - val cancelled = failure == TaskExecutionResult.Failure.BUILD_CANCELLED + val cancelled = isCancelled(failure) if (annotatedBuild) { // A build the user stopped arrives through this same callback. Marking it as a failure @@ -261,13 +271,19 @@ class EditorBuildEventListener : GradleBuildService.EventListener { // and the three reports beside it were not, so a user who pressed Stop still got a red // "Build failed" bar, a "Build failed" notification and an isSuccess=false result -- their // own action read back to them as an error in every place but one. + val cancelledText = act.getString(R.string.info_build_cancelled) if (cancelled) { act.flashInfo(R.string.info_build_cancelled) + // The status line under the output too. Gradle prints "BUILD FAILED" for a cancelled + // build like any other, and [onOutput] copies that line into the label, so the label + // sat there contradicting the bar that had just said the build was stopped. This runs + // after onOutput, so it has the last word. + act.setStatus(cancelledText) } else { act.flashError(R.string.build_status_failed) } - val message = failureMessage(failure, act.getString(R.string.info_build_cancelled)) + val message = failureMessage(failure, cancelledText) // The plugin API has no way to say "cancelled" -- IdeServices.onBuildFailed takes an error // string and nothing else -- so the message is the whole of what a plugin can be told. diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index dcc0860a2f..e5feb398ef 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -459,16 +459,26 @@ class GradleBuildService : eventListener?.onBuildSuccessful(result.tasks) } + /** + * What the notification in the shade says about a build that did not succeed. + * + * Extracted so it can be asserted: [onBuildFailed] needs a live service before it reaches this + * point, which is the same reason [EditorBuildEventListener] separates its own two decisions. + * Without a test here, deleting the cancelled arm left every test green while a build the user + * stopped went back to saying "Build failed" in the shade. + */ + @VisibleForTesting + internal fun notificationStatusFor(failure: TaskExecutionResult.Failure?): Int = + if (failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { + R.string.info_build_cancelled + } else { + R.string.build_status_failed + } + override fun onBuildFailed(result: BuildResult) { // The notification too, not only what reaches the listener: a build the user stopped left // "Build failed" in the shade whatever the chart said (ADFA-5542). - val status = - if (result.failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { - R.string.info_build_cancelled - } else { - R.string.build_status_failed - } - updateNotification(getString(status), false) + updateNotification(getString(notificationStatusFor(result.failure)), false) dispatchBuildResult(result, false) eventListener?.onBuildFailed(result.tasks, result.failure) @@ -851,7 +861,10 @@ class GradleBuildService : * the answer is known rather than inferred (ADFA-5542). * * @param tasks The tasks that were run. - * @param failure Why the build failed, or null if the server did not say. + * @param failure Why the build failed. Never null from this server, which classifies every + * failure before reporting it; nullable because the wire type allows a server that does + * not. A null is treated as an ordinary failure, which is the safe reading -- reporting + * a real failure as a cancel would hide it. * @see IToolingApiClient.onBuildFailed */ fun onBuildFailed( diff --git a/app/src/main/java/com/itsaky/androidide/utils/BuildCancellation.kt b/app/src/main/java/com/itsaky/androidide/utils/BuildCancellation.kt new file mode 100644 index 0000000000..af0b83cb2c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/BuildCancellation.kt @@ -0,0 +1,63 @@ +/* + * 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.projects.builder.BuildService +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory + +private val log = LoggerFactory.getLogger("BuildCancellation") + +/** + * Asks [service] to stop the running build, and says so when it will not. + * + * A Stop the server turns down -- there is no build to cancel, or Gradle refused the request -- + * reached a log line at one call site and nothing at all at the other, so the button appeared to + * do nothing and the build carried on. ADFA-5542 removed `EventListener.onBuildCancelRequested`, + * which was the only request-time signal, because it guessed the outcome rather than reporting + * one; nothing replaced the half of it that mattered. This is that half, in one place, because + * two call sites written separately are how they came to disagree. + * + * Success is deliberately silent: the build stopping is its own feedback, and + * [com.itsaky.androidide.handlers.EditorBuildEventListener.onBuildFailed] reports the outcome the + * server actually reached. + */ +fun requestBuildCancellation(service: BuildService) { + log.info("Sending build cancellation request...") + service.cancelCurrentBuild().whenComplete { result, error -> + if (error != null) { + log.error("Failed to send build cancellation request", error) + flashError(R.string.msg_build_cancel_failed) + return@whenComplete + } + + if (!result.wasEnqueued) { + // failureReason is nullable on the wire, so this reads it rather than asserting it. + // A refusal with no reason still has to reach the user. + log.warn( + "Unable to enqueue cancellation request reason={} reason.message={}", + result.failureReason, + result.failureReason?.message, + ) + flashError(R.string.msg_build_cancel_failed) + return@whenComplete + } + + log.info("Build cancellation request was successfully enqueued...") + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 8c90924c2a..b5234746c1 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -13,6 +13,7 @@ import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.GradleBuildParams import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -110,7 +111,17 @@ class BuildViewModel : ViewModel() { }.await() if (result == null || !result.isSuccessful) { - throw RuntimeException("Task execution failed: ${result.failure}") + // A build the user stopped is not a failure, and it does not arrive as a + // CancellationException -- the catch below only recognises the coroutine kind. + // It comes back as a result carrying BUILD_CANCELLED, so without this the + // user's own Stop finished as BuildState.Error("Task execution failed: + // BUILD_CANCELLED"), with the enum name shown to them. + if (result?.failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { + log.info("Build was cancelled by the user.") + finish(BuildState.Idle) + return@launch + } + throw RuntimeException("Task execution failed: ${result?.failure}") } if (isPluginProject) { diff --git a/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt new file mode 100644 index 0000000000..7a4671d0eb --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt @@ -0,0 +1,78 @@ +/* + * 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.analytics + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.analytics.gradle.BuildCompletedMetric +import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.result.BuildResult +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What build telemetry says about a build that did not succeed (ADFA-5542). + * + * A build the user stopped and a build that broke both report `success=false`. Without the reason + * beside it the two are indistinguishable, so every build-success rate counts deliberate cancels + * as failures -- and the cancel is the one the IDE deliberately makes easy to press. + */ +@RunWith(RobolectricTestRunner::class) +class BuildCompletedMetricTest { + private fun metric( + isSuccess: Boolean, + failure: TaskExecutionResult.Failure?, + ) = BuildCompletedMetric( + buildId = BuildId.Unknown, + buildType = "assemble", + isSuccess = isSuccess, + buildResult = + BuildResult( + buildId = BuildId.Unknown, + tasks = listOf(":app:assembleDebug"), + durationMs = 1_234L, + failure = failure, + ), + ) + + @Test + fun `a cancelled build carries the reason that says so`() { + val bundle = metric(isSuccess = false, failure = TaskExecutionResult.Failure.BUILD_CANCELLED).asBundle() + + assertThat(bundle.getString("failure_reason")).isEqualTo("BUILD_CANCELLED") + // isSuccess keeps its plain meaning: the build did not succeed. The reason is what lets a + // consumer separate the user's own Stop from a broken build. + assertThat(bundle.getBoolean("success")).isFalse() + } + + @Test + fun `a build that really failed carries its own reason`() { + val bundle = metric(isSuccess = false, failure = TaskExecutionResult.Failure.BUILD_FAILED).asBundle() + + assertThat(bundle.getString("failure_reason")).isEqualTo("BUILD_FAILED") + } + + @Test + fun `a successful build carries no reason at all`() { + val bundle = metric(isSuccess = true, failure = null).asBundle() + + assertThat(bundle.containsKey("failure_reason")).isFalse() + assertThat(bundle.getBoolean("success")).isTrue() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt index effb235731..186cfa6873 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt @@ -111,11 +111,15 @@ class EditorBuildEventListenerAnnotationTest { } @Test - fun `a build the user stopped is not reported as an error anywhere`() { - // The annotation was fixed first and the reports beside it were not, so a user who pressed - // Stop still got a red "Build failed" bar, a "Build failed" notification and an - // isSuccess=false result carrying failure text -- their own action read back to them as an - // error in every place but the chart. + fun `the message for a build the user stopped is the cancelled text`() { + // One of the four places a cancel used to be reported as an error. The others are pinned + // separately -- the notification by GradleBuildServiceNotificationStatusTest, the chart + // marker by the outcomeKind cases above. The bar itself (flashInfo rather than flashError) + // and the cancelled-sync branch in ProjectHandlerActivity are not pinned: both need a live + // activity, which is what onBuildFailed returns early without. + // + // This test was previously named for all four and asserted only this one, so deleting the + // flashInfo branch or the notification branch left it green. assertThat(listener.failureMessage(TaskExecutionResult.Failure.BUILD_CANCELLED, CANCELLED_TEXT)) .isEqualTo(CANCELLED_TEXT) } diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceNotificationStatusTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceNotificationStatusTest.kt new file mode 100644 index 0000000000..4327102618 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceNotificationStatusTest.kt @@ -0,0 +1,62 @@ +/* + * 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.services.builder + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.R +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What the shade says about a build that did not succeed (ADFA-5542). + * + * The notification is one of the four places a cancelled build used to be reported as a failure, + * and the only one of them with no test: the chart marker and the message both had one, so the + * notification could have been reverted without a single test noticing. + */ +@RunWith(RobolectricTestRunner::class) +class GradleBuildServiceNotificationStatusTest { + private val service = GradleBuildService() + + @Test + fun `a build the user stopped says so in the shade`() { + assertThat(service.notificationStatusFor(TaskExecutionResult.Failure.BUILD_CANCELLED)) + .isEqualTo(R.string.info_build_cancelled) + } + + @Test + fun `every other failure says the build failed`() { + TaskExecutionResult.Failure.entries + .filter { it != TaskExecutionResult.Failure.BUILD_CANCELLED } + .forEach { failure -> + assertWithMessage(failure.name) + .that(service.notificationStatusFor(failure)) + .isEqualTo(R.string.build_status_failed) + } + } + + @Test + fun `a failure the server did not classify says the build failed`() { + // Reporting an unclassified failure as a cancel would put the user's name on something + // they did not do. + assertThat(service.notificationStatusFor(null)).isEqualTo(R.string.build_status_failed) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 52e99fa8ca..9ec7372fe6 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1162,6 +1162,7 @@ No APK found in output listing file. APK file specified does not exist: %1$s Build was cancelled by the user. + Could not stop the build. Quick Run failed. Building… Installing plugin… diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt index f76315071f..c9549d8985 100644 --- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt +++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt @@ -214,7 +214,17 @@ internal class ToolingApiServerImpl : IToolingApiServer { clientConfig = clientConfig, ) - RootModelBuilder.build(params, modelBuilderParams) + try { + RootModelBuilder.build(params, modelBuilderParams) + } finally { + // The sync path never cleared this on any outcome -- only shutdown() and an actual + // Stop did. So after every sync a token for a finished build sat here: the next + // Stop cancelled that dead source and answered wasEnqueued = true with no build + // running, and the check at the top of this method cancelled it again on the next + // initialize. The sibling of the same omission in executeTasks. + buildCancellationToken = null + } + notifyBuildSuccess( BuildResult( tasks = emptyList(), @@ -301,23 +311,30 @@ internal class ToolingApiServerImpl : IToolingApiServer { try { builder.run() - this.buildCancellationToken = null - notifyBuildSuccess( - result = - BuildResult( - tasks = message.tasks, - buildId = message.buildId, - durationMs = System.currentTimeMillis() - start, - ), - ) - return@runBuild TaskExecutionResult.SUCCESS } catch (error: Throwable) { log.error("Failed to run tasks: {}", message.tasks, error) return@runBuild TaskExecutionResult( false, notifyBuildFailure(message.buildId, message.tasks, start, error), ) + } finally { + // On both paths. Only the success path cleared it, so every failed build left a + // token behind for a source that was already finished. The next Stop then + // cancelled that dead source and answered wasEnqueued = true while the live build + // ran on, and [initialize] -- which cancels first whenever one is set -- paid for + // a build that had ended long before. + this.buildCancellationToken = null } + + notifyBuildSuccess( + result = + BuildResult( + tasks = message.tasks, + buildId = message.buildId, + durationMs = System.currentTimeMillis() - start, + ), + ) + return@runBuild TaskExecutionResult.SUCCESS } } diff --git a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt index 8c399e4cc7..9f1c4501ba 100644 --- a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt +++ b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.tooling.api.IToolingApiClient import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams +import com.itsaky.androidide.tooling.api.messages.result.BuildCancellationRequestResult import com.itsaky.androidide.tooling.api.messages.result.BuildResult import com.itsaky.androidide.tooling.api.messages.result.InitializeResult import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult @@ -128,6 +129,44 @@ class ToolingApiServerImplTest { assertThat(reported.captured.failure).isEqualTo(TaskExecutionResult.Failure.BUILD_CANCELLED) } + @Test + fun `GIVEN a sync that finished WHEN a Stop arrives THEN there is no build to cancel`() { + mockkObject(RootModelBuilder) + every { RootModelBuilder.build(any(), any()) } returns File("/does/not/exist/cache") + + val (server) = mockkToolingServer() + every { server.validateProjectDirectory(any()) } returns null + server.connect(mockk(relaxed = true)) + + server.initialize(testInitParams()).get(5, TimeUnit.SECONDS) + + // The token for the sync's own build was never cleared on any outcome, so it outlived the + // build it belonged to. A Stop pressed afterwards cancelled that dead source and answered + // "enqueued" -- telling the user a build was being stopped when none was running, and, once + // a real build had started, leaving it running while claiming otherwise. + val result = server.cancelCurrentBuild().get(5, TimeUnit.SECONDS) + + assertThat(result.wasEnqueued).isFalse() + assertThat(result.failureReason).isEqualTo(BuildCancellationRequestResult.Reason.NO_RUNNING_BUILD) + } + + @Test + fun `GIVEN a sync that failed WHEN a Stop arrives THEN there is no build to cancel`() { + mockkObject(RootModelBuilder) + every { RootModelBuilder.build(any(), any()) } throws RuntimeException("intentional failure") + + val (server) = mockkToolingServer() + every { server.validateProjectDirectory(any()) } returns null + server.connect(mockk(relaxed = true)) + + server.initialize(testInitParams()).get(5, TimeUnit.SECONDS) + + // The failing path leaked it the same way the succeeding one did. + val result = server.cancelCurrentBuild().get(5, TimeUnit.SECONDS) + + assertThat(result.wasEnqueued).isFalse() + } + @Test fun `GIVEN force sync not requested WHEN sync files are unreadable THEN sync anyway`() { val initParams = testInitParams(forceSync = false) diff --git a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt index 3732a072d5..baf6359b9d 100644 --- a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt +++ b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt @@ -29,7 +29,13 @@ data class BuildResult( val tasks: List, val durationMs: Long, /** - * Why the build failed, or `null` if it succeeded. + * Why the build failed. + * + * `null` on a successful build, which this type also carries. On a failed one this server + * always fills it in -- `notifyBuildFailure` classifies the throwable and returns a + * non-null [TaskExecutionResult.Failure], and both catch paths go through it -- so a client + * seeing `null` here alongside a failure is talking to a server that does not classify, not + * to this one. Nullable on the wire for exactly that case. * * The server is the only party that can answer this: Gradle raises a * `BuildCancelledException` for a build the user stopped, and the same throwable that decides From 2f4cc8e5a6b450611412c70aef8677e09ae478b3 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 11:35:22 -0700 Subject: [PATCH 110/128] ADFA-5553: reindent the fixture the merge resolution left Formatting only. The extra argument added while resolving the merge from ADFA-5526 was indented to the old call's continuation rather than to ktlint's, and the pre-push hook rewrites it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartLegendFormTest.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt index 5ba1abf80f..bce619c6ba 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt @@ -162,11 +162,12 @@ class MetricsChartLegendFormTest { // The per-tick path applies the scale only when it has moved, so this is the case that // guards the saving: EditorActivityKt handles fontScale itself, so no activity is // recreated and a redraw is the only thing a running chart does. - val usage = NetworkUsageWatcher.NetworkUsage( - LongArray(SAMPLES) { 1L }, - LongArray(SAMPLES) { 1L }, - LongArray(SAMPLES), - ) + val usage = + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1L }, + LongArray(SAMPLES) { 1L }, + LongArray(SAMPLES), + ) val chart = SafeLineChart(context) val renderer = NetworkUsageChartRenderer(usageProvider = { usage }) renderer.attach(chart) From 349db0ed0e66a999abfc88c0a3a18e51c4a017db Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 11:36:01 -0700 Subject: [PATCH 111/128] ADFA-5554: reindent the fixtures the merge resolution left Formatting only. The sampleTimes argument added while resolving the merge from ADFA-5553 was indented to the old call's continuation rather than to ktlint's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartGestureTeardownTest.kt | 8 ++++---- .../com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt index 93888ea9c9..8b77a43037 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt @@ -65,10 +65,10 @@ class MetricsChartGestureTeardownTest { NetworkUsageChartRenderer( usageProvider = { NetworkUsageWatcher.NetworkUsage( - LongArray(SAMPLES) { 1_000L }, - LongArray(SAMPLES) { 500L }, - LongArray(SAMPLES), - ) + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) }, ) renderer.attach(chart) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt index f857052e93..02e80bf619 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt @@ -63,10 +63,10 @@ class MetricsChartHoldHelpTest { NetworkUsageChartRenderer( usageProvider = { NetworkUsageWatcher.NetworkUsage( - LongArray(SAMPLES) { 1_000L }, - LongArray(SAMPLES) { 500L }, - LongArray(SAMPLES), - ) + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) }, ) renderer.attach(chart) From c697bd3767e61deba296ab8dec0ccacfb96f7108 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 13:21:50 -0700 Subject: [PATCH 112/128] ADFA-5531: give the three process names one home MEMORY_COLUMNS spelled "IDE", "Gradle Tooling" and "Gradle Daemon" as literals, and BaseEditorActivity's PROC_* constants spelled the same three again. Nothing joined them but string equality, and the failure mode is silent: a name that matches nothing in the snapshot is written as an absent value rather than as an error, so renaming a process would have gone on emitting the old header and quietly emptied the column. The names live in MetricsCsv now because that is the file that cannot move -- a column name is its published contract, read back by ADFA-5494 and by whoever opens the copy ADFA-5526 and ADFA-5534 attach to a report. The activity's constants alias them rather than repeating them, and stay protected because subclasses use them. No new test: the drift is now unrepresentable rather than merely detected. MemUsageLineColorTest already asserts the colour mapping against the literal names, so a rename that broke the mapping still fails there, and MetricsCsvTest still pins the full header as a hand-written string rather than deriving it from these constants. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../activities/editor/BaseEditorActivity.kt | 10 ++++++--- .../com/itsaky/androidide/utils/MetricsCsv.kt | 22 ++++++++++++++++++- 2 files changed, 28 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 5990f8507f..767233f2d6 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 @@ -130,6 +130,7 @@ import com.itsaky.androidide.utils.InstallationResultHandler.onResult import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsCsv import com.itsaky.androidide.utils.StringsInjectionException import com.itsaky.androidide.utils.StringsXmlInjector import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation @@ -487,13 +488,16 @@ abstract class BaseEditorActivity : else -> Color.GRAY } - protected val PROC_IDE = "IDE" + // Aliases, not copies. The names belong to the CSV, whose header is a published contract; + // see MetricsCsv.PROC_IDE for why they live there. Kept as protected members because + // subclasses use them. + protected val PROC_IDE = MetricsCsv.PROC_IDE @JvmStatic - protected val PROC_GRADLE_TOOLING = "Gradle Tooling" + protected val PROC_GRADLE_TOOLING = MetricsCsv.PROC_GRADLE_TOOLING @JvmStatic - protected val PROC_GRADLE_DAEMON = "Gradle Daemon" + protected val PROC_GRADLE_DAEMON = MetricsCsv.PROC_GRADLE_DAEMON @JvmStatic protected val log: Logger = LoggerFactory.getLogger(BaseEditorActivity::class.java) diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt index 0ae7657fd0..68f6c695b9 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt @@ -70,6 +70,26 @@ object MetricsCsv { private val TIMESTAMP_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.ROOT) + /** + * The names the memory watcher is given for the three processes the IDE plots. + * + * Here rather than beside the watcher because this file is the one that cannot move: a column + * name is the file's published contract, read back by ADFA-5494 and by whoever opens the copy + * ADFA-5526 and ADFA-5534 attach to a report. Everything else looks these up. + * + * They were literals in two places -- these and `BaseEditorActivity.PROC_*` -- joined by + * nothing but string equality. Renaming a process there would have gone on writing the old + * header here and quietly emptied the column, because a name that matches nothing in the + * snapshot is written as an absent value rather than as an error. + */ + const val PROC_IDE = "IDE" + + /** @see PROC_IDE */ + const val PROC_GRADLE_TOOLING = "Gradle Tooling" + + /** @see PROC_IDE */ + const val PROC_GRADLE_DAEMON = "Gradle Daemon" + /** * The memory series, in column order. * @@ -78,7 +98,7 @@ object MetricsCsv { * -- and a header that depended on it would describe a different file each time. A process that * is not being watched leaves its column empty. */ - val MEMORY_COLUMNS = listOf("IDE", "Gradle Tooling", "Gradle Daemon") + val MEMORY_COLUMNS = listOf(PROC_IDE, PROC_GRADLE_TOOLING, PROC_GRADLE_DAEMON) @JvmStatic val HEADER: List = From 01ffa5813de11b6f8c2fad25905f89e3a681f489 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 13:47:48 -0700 Subject: [PATCH 113/128] ADFA-5554: one harness for the two chart-gesture test classes The class that tests when help fires and the class that tests what happens to a hold when the gesture or the chart goes away had grown byte-identical copies of their setup: 74 lines each, down to the comments, covering the laid-out chart, the renderer, the tap and help counters, and every gesture helper. `diff` on the two regions reported no differences at all. One copy had a `panBy` that nothing called, which is the usual end of a duplicated fixture -- a helper is copied for symmetry and then only one side grows a test for it. Both delegate to ChartGestureHarness now. The one test that reached past its helpers to build a MotionEvent by hand, for a pinch, gets a `scaleBy` alongside the existing `panBy` instead, so no test handles raw events any more. `elapse`, `drain` and `remainderOfHold` are top-level: they are about the looper and the timeout rather than about a chart. Fifteen imports went dead with the extraction and are gone. Spotless does not flag those, which is worth knowing. No behaviour change and no new coverage: every test still asserts exactly what it did before, and the suite is green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/ChartGestureHarness.kt | 171 ++++++++++++++++++ .../ui/MetricsChartGestureTeardownTest.kt | 110 ++--------- .../androidide/ui/MetricsChartHoldHelpTest.kt | 103 ++--------- 3 files changed, 202 insertions(+), 182 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/ChartGestureHarness.kt diff --git a/app/src/test/java/com/itsaky/androidide/ui/ChartGestureHarness.kt b/app/src/test/java/com/itsaky/androidide/ui/ChartGestureHarness.kt new file mode 100644 index 0000000000..5790369e71 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/ChartGestureHarness.kt @@ -0,0 +1,171 @@ +/* + * 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.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewConfiguration +import com.github.mikephil.charting.listener.ChartTouchListener +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +/** Samples a harnessed chart is given; more than a window's worth, so a pan has somewhere to go. */ +const val HARNESS_SAMPLES = 200 + +/** + * A laid-out chart with a renderer attached, and the gestures to drive it. + * + * The two classes that test the chart's hold -- when help fires, and what happens to a hold when + * the gesture or the chart goes away -- had grown byte-identical copies of all of this, 74 lines + * each, down to the comments. One of the copies had a `panBy` nothing called. + * + * The gesture helpers all go through `chart.onChartGestureListener` rather than dispatching real + * touches, because that is the seam the renderer actually listens on: MPAndroidChart's own + * detector is what decides a press is a long press, and standing that up would be testing the + * library rather than the renderer. + */ +class ChartGestureHarness( + private val context: Context, +) { + /** Times [MetricsChartRenderer.onXAxisTap] fired -- the sampling-rate chooser opening. */ + var taps = 0 + private set + + /** Times the renderer asked for help to be shown. */ + var helps = 0 + private set + + lateinit var renderer: NetworkUsageChartRenderer + private set + + fun laidOutChart(): SafeLineChart { + val chart = SafeLineChart(context) + // Any concrete renderer will do -- the hold is the base class's, and every page wires it + // the same way. + renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(HARNESS_SAMPLES) { 1_000L }, + LongArray(HARNESS_SAMPLES) { 500L }, + LongArray(HARNESS_SAMPLES), + ) + }, + ) + renderer.attach(chart) + renderer.onXAxisTap = { taps++ } + renderer.showHelp = { _, _, _ -> helps++ } + + chart.layOutAndDraw() + return chart + } + + /** + * An event whose finger landed [sincePressMillis] ago. + * + * The down time is what the chart measures its remaining hold from, so it has to be real here. + * Defaults to the platform's long-press timeout, which is when a detector on a current device + * reports one. + */ + fun eventAt( + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ): MotionEvent { + val now = SystemClock.uptimeMillis() + return MotionEvent.obtain(now - sincePressMillis, now, MotionEvent.ACTION_MOVE, 10f, y, 0) + } + + /** The platform's own long press, which is where the chart's hold started counting from. */ + fun longPressAt( + chart: SafeLineChart, + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ) { + val event = eventAt(y, sincePressMillis) + chart.onChartGestureListener.onChartLongPressed(event) + event.recycle() + } + + fun panBy( + chart: SafeLineChart, + dx: Float, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartTranslate(event, dx, 0f) + event.recycle() + } + + fun scaleBy( + chart: SafeLineChart, + factor: Float, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartScale(event, factor, factor) + event.recycle() + } + + fun endGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartGestureEnd(event, gesture) + event.recycle() + } + + /** + * The end of a gesture an ancestor took away. + * + * ChartTouchListener.endAction is reached from ACTION_CANCEL as well as ACTION_UP, with the + * original event and with mLastGesture untouched, so this is what the listener actually sees + * when the reveal layout, the bottom sheet or the pager claims the stream mid-press. + */ + fun cancelGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) { + val now = SystemClock.uptimeMillis() + val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 10f, 0f, 0) + chart.onChartGestureListener.onChartGestureEnd(event, gesture) + event.recycle() + } + + /** A y on the axis band, where a tap opens the sampling-rate chooser. */ + fun onAxisBand(chart: SafeLineChart) = chart.viewPortHandler.contentBottom() + 1f + + /** A y inside the plot, where a hold means help for the page rather than for the axis. */ + fun insidePlot(chart: SafeLineChart) = (chart.viewPortHandler.contentTop() + chart.viewPortHandler.contentBottom()) / 2f +} + +/** Runs the main looper forward by [millis] of virtual time. */ +fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) + +/** + * Runs what is already due on the main looper without advancing the clock. + * + * The stand-in tap and the stand-in click are posted rather than run inside the touch dispatch, so + * nothing has been tapped until the looper turns. + */ +fun drain() = shadowOf(Looper.getMainLooper()).idle() + +/** The rest of the hold, after a long press reported at the platform's own timeout. */ +fun remainderOfHold() = longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout() + 50L diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt index 8b77a43037..9723b88c74 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt @@ -17,21 +17,13 @@ package com.itsaky.androidide.ui -import android.content.Context -import android.os.Looper -import android.os.SystemClock -import android.view.MotionEvent import android.view.ViewConfiguration import androidx.test.core.app.ApplicationProvider import com.github.mikephil.charting.listener.ChartTouchListener import com.google.common.truth.Truth.assertThat -import com.itsaky.androidide.utils.NetworkUsageWatcher -import com.itsaky.androidide.utils.longPressHelpTimeoutMillis import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import java.util.concurrent.TimeUnit /** * What happens to a hold in progress when the gesture or the chart under it goes away (ADFA-5554). @@ -49,116 +41,40 @@ import java.util.concurrent.TimeUnit */ @RunWith(RobolectricTestRunner::class) class MetricsChartGestureTeardownTest { - private val context = ApplicationProvider.getApplicationContext() - - private var taps = 0 - - private var helps = 0 - - private lateinit var renderer: NetworkUsageChartRenderer - - private fun laidOutChart(): SafeLineChart { - val chart = SafeLineChart(context) - // Any concrete renderer will do -- the hold is the base class's, and every page wires it - // the same way. - renderer = - NetworkUsageChartRenderer( - usageProvider = { - NetworkUsageWatcher.NetworkUsage( - LongArray(SAMPLES) { 1_000L }, - LongArray(SAMPLES) { 500L }, - LongArray(SAMPLES), - ) - }, - ) - renderer.attach(chart) - renderer.onXAxisTap = { taps++ } - renderer.showHelp = { _, _, _ -> helps++ } + private val harness = ChartGestureHarness(ApplicationProvider.getApplicationContext()) - chart.layOutAndDraw() - return chart - } + private val taps get() = harness.taps - /** - * An event whose finger landed [sincePressMillis] ago. - * - * The down time is what the chart measures its remaining hold from, so it has to be real here. - * Defaults to the platform's long-press timeout, which is when a detector on a current device - * reports one. - */ - private fun eventAt( - y: Float, - sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), - ): MotionEvent { - val now = SystemClock.uptimeMillis() - return MotionEvent.obtain(now - sincePressMillis, now, MotionEvent.ACTION_MOVE, 10f, y, 0) - } + private val helps get() = harness.helps + + private val renderer get() = harness.renderer + + private fun laidOutChart() = harness.laidOutChart() - /** The platform's own long press, which is where the chart's hold started counting from. */ private fun longPressAt( chart: SafeLineChart, y: Float, sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), - ) { - val event = eventAt(y, sincePressMillis) - chart.onChartGestureListener.onChartLongPressed(event) - event.recycle() - } + ) = harness.longPressAt(chart, y, sincePressMillis) private fun panBy( chart: SafeLineChart, dx: Float, - ) { - val event = eventAt(0f) - chart.onChartGestureListener.onChartTranslate(event, dx, 0f) - event.recycle() - } + ) = harness.panBy(chart, dx) private fun endGesture( chart: SafeLineChart, gesture: ChartTouchListener.ChartGesture, - ) { - val event = eventAt(0f) - chart.onChartGestureListener.onChartGestureEnd(event, gesture) - event.recycle() - } + ) = harness.endGesture(chart, gesture) - /** - * The end of a gesture an ancestor took away. - * - * ChartTouchListener.endAction is reached from ACTION_CANCEL as well as ACTION_UP, with the - * original event and with mLastGesture untouched, so this is what the listener actually sees - * when the reveal layout, the bottom sheet or the pager claims the stream mid-press. - */ private fun cancelGesture( chart: SafeLineChart, gesture: ChartTouchListener.ChartGesture, - ) { - val now = SystemClock.uptimeMillis() - val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 10f, 0f, 0) - chart.onChartGestureListener.onChartGestureEnd(event, gesture) - event.recycle() - } - - /** A y on the axis band, where a tap opens the sampling-rate chooser. */ - private fun onAxisBand(chart: SafeLineChart) = chart.viewPortHandler.contentBottom() + 1f - - /** Runs the main looper forward by [millis] of virtual time. */ - private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) - - /** - * Runs what is already due on the main looper without advancing the clock. - * - * The stand-in tap is posted rather than invoked inside the chart's touch dispatch, so nothing - * has been tapped until the looper turns. - */ - private fun drain() = shadowOf(Looper.getMainLooper()).idle() + ) = harness.cancelGesture(chart, gesture) - /** The rest of the hold, after a long press reported at the platform's own timeout. */ - private fun remainderOfHold() = longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout() + 50L + private fun onAxisBand(chart: SafeLineChart) = harness.onAxisBand(chart) - /** A y inside the plot, where a hold means help for the page rather than for the axis. */ - private fun insidePlot(chart: SafeLineChart) = (chart.viewPortHandler.contentTop() + chart.viewPortHandler.contentBottom()) / 2f + private fun insidePlot(chart: SafeLineChart) = harness.insidePlot(chart) @Test fun `a gesture an ancestor cancels does not stand in for a tap`() { diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt index 02e80bf619..99851457d5 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt @@ -17,21 +17,14 @@ package com.itsaky.androidide.ui -import android.content.Context -import android.os.Looper -import android.os.SystemClock -import android.view.MotionEvent import android.view.ViewConfiguration import androidx.test.core.app.ApplicationProvider import com.github.mikephil.charting.listener.ChartTouchListener import com.google.common.truth.Truth.assertThat -import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.longPressHelpTimeoutMillis import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import java.util.concurrent.TimeUnit /** * When the chart answers a hold with help, and when it gives that help up (ADFA-5554). @@ -47,96 +40,38 @@ import java.util.concurrent.TimeUnit */ @RunWith(RobolectricTestRunner::class) class MetricsChartHoldHelpTest { - private val context = ApplicationProvider.getApplicationContext() - - private var taps = 0 - - private var helps = 0 - - private lateinit var renderer: NetworkUsageChartRenderer - - private fun laidOutChart(): SafeLineChart { - val chart = SafeLineChart(context) - // Any concrete renderer will do -- the hold is the base class's, and every page wires it - // the same way. - renderer = - NetworkUsageChartRenderer( - usageProvider = { - NetworkUsageWatcher.NetworkUsage( - LongArray(SAMPLES) { 1_000L }, - LongArray(SAMPLES) { 500L }, - LongArray(SAMPLES), - ) - }, - ) - renderer.attach(chart) - renderer.onXAxisTap = { taps++ } - renderer.showHelp = { _, _, _ -> helps++ } - - chart.layOutAndDraw() - return chart - } + private val harness = ChartGestureHarness(ApplicationProvider.getApplicationContext()) - /** - * An event whose finger landed [sincePressMillis] ago. - * - * The down time is what the chart measures its remaining hold from, so it has to be real here. - * Defaults to the platform's long-press timeout, which is when a detector on a current device - * reports one. - */ - private fun eventAt( - y: Float, - sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), - ): MotionEvent { - val now = SystemClock.uptimeMillis() - return MotionEvent.obtain(now - sincePressMillis, now, MotionEvent.ACTION_MOVE, 10f, y, 0) - } + private val taps get() = harness.taps + + private val helps get() = harness.helps + + private val renderer get() = harness.renderer + + private fun laidOutChart() = harness.laidOutChart() - /** The platform's own long press, which is where the chart's hold started counting from. */ private fun longPressAt( chart: SafeLineChart, y: Float, sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), - ) { - val event = eventAt(y, sincePressMillis) - chart.onChartGestureListener.onChartLongPressed(event) - event.recycle() - } + ) = harness.longPressAt(chart, y, sincePressMillis) private fun panBy( chart: SafeLineChart, dx: Float, - ) { - val event = eventAt(0f) - chart.onChartGestureListener.onChartTranslate(event, dx, 0f) - event.recycle() - } + ) = harness.panBy(chart, dx) + + private fun scaleBy( + chart: SafeLineChart, + factor: Float, + ) = harness.scaleBy(chart, factor) private fun endGesture( chart: SafeLineChart, gesture: ChartTouchListener.ChartGesture, - ) { - val event = eventAt(0f) - chart.onChartGestureListener.onChartGestureEnd(event, gesture) - event.recycle() - } - - /** Runs the main looper forward by [millis] of virtual time. */ - private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) - - /** - * Runs what is already due on the main looper without advancing the clock. - * - * The stand-in tap is posted rather than invoked inside the chart's touch dispatch, so nothing - * has been tapped until the looper turns. - */ - private fun drain() = shadowOf(Looper.getMainLooper()).idle() - - /** The rest of the hold, after a long press reported at the platform's own timeout. */ - private fun remainderOfHold() = longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout() + 50L + ) = harness.endGesture(chart, gesture) - /** A y inside the plot, where a hold means help for the page rather than for the axis. */ - private fun insidePlot(chart: SafeLineChart) = (chart.viewPortHandler.contentTop() + chart.viewPortHandler.contentBottom()) / 2f + private fun insidePlot(chart: SafeLineChart) = harness.insidePlot(chart) @Test fun `a press held past the hold shows help`() { @@ -213,9 +148,7 @@ class MetricsChartHoldHelpTest { val chart = laidOutChart() longPressAt(chart, insidePlot(chart)) - val event = eventAt(0f) - chart.onChartGestureListener.onChartScale(event, 1.2f, 1.2f) - event.recycle() + scaleBy(chart, 1.2f) elapse(remainderOfHold()) assertThat(helps).isEqualTo(0) From 0b68ae3832956b060d388f5039f82d8f38aefa0b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 15:39:23 -0700 Subject: [PATCH 114/128] ADFA-5531: test the export file, and fix the pruning it exposed MetricsCsvFile made KEEP_RECENT and the clock injectable for a test and then had none, so neither the bound on the export directory nor the name the file is given was asserted anywhere on this branch. Writing that test found a real defect in this ticket's own path. pruneTo chose "the oldest n" across every file and then skipped the one just written, which deleted one too few whenever that file sorted into the set -- and the export directory crept one over KEEP_RECENT each time. Two writes inside a single filesystem timestamp are enough to sort it there. The new file is excluded from the candidates now rather than skipped among them. Both new bound tests fail without that change with "expected to be at most: 3 but was: 4", which is the defect exactly. The fix already existed -- two branches up, on ADFA-5534, along with the test. That is the third time today a fix for one ticket's file has been found sitting on a later ticket's branch, after ADFA-5489's sampler race and this same file's test. Anyone building or QA'ing #1799 alone gets the unfixed pruning; that is what this corrects. Only the cases that belong here came down. The gzip and report-copy tests stay on ADFA-5534, which is the ticket that owns writeForReport. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/utils/MetricsSnapshot.kt | 13 ++- .../androidide/utils/MetricsCsvFileTest.kt | 110 ++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt 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 7159482843..113b9c89fe 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt @@ -102,12 +102,17 @@ object MetricsSnapshot { limit: Int, newest: File, ) { - val files = directory.listFiles()?.sortedBy { it.lastModified() } ?: return - if (files.size <= limit) { + // [newest] is excluded from the candidates rather than skipped among them. Skipping it after + // choosing "the oldest n" left one file too many whenever it sorted into that set, and the + // directory then crept one over the limit per collision. Two writes inside one filesystem + // timestamp are enough to sort it there. + val candidates = directory.listFiles()?.filter { it != newest }?.sortedBy { it.lastModified() } ?: return + val excess = candidates.size - (limit - 1) + if (excess <= 0) { return } - files.take(files.size - limit).forEach { file -> - if (file != newest && !file.delete()) { + candidates.take(excess).forEach { file -> + if (!file.delete()) { log.warn("Could not delete the stale chart snapshot at {}", file) } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt new file mode 100644 index 0000000000..61e2aad19d --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt @@ -0,0 +1,110 @@ +/* + * 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 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 +import java.time.ZoneId + +/** + * The file the export button writes (ADFA-5531). + * + * [MetricsCsvFile.KEEP_RECENT] and the clock were made injectable for a test and then had none, so + * the bound on the directory and the name the file is given were both unasserted. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCsvFileTest { + private val context = ApplicationProvider.getApplicationContext() + + /** + * Fixed, not the machine's own. + * + * The name asserted below is a rendering of [AT] in a particular zone, so leaving the zone to + * the default made this pass here and fail wherever CI happens to be. + */ + private val zone: ZoneId = ZoneId.of("America/Los_Angeles") + + private fun snapshot(rows: Int): MetricsCsv.Snapshot { + val times = LongArray(rows) { AT + it * 1_000L } + return MetricsCsv.Snapshot( + rowTimes = times, + sampleIntervalMillis = INTERVAL_MS, + memory = mapOf(MetricsCsv.PROC_IDE to MetricsCsv.Series(times, LongArray(rows) { 600_000_000L + it })), + ) + } + + @Test + fun `an export is plain csv the user can open`() { + val file = MetricsCsvFile.write(context, snapshot(3), AT, zone)!! + + assertThat(file.name).isEqualTo("2026_09_06_22_33_40_123.csv") + assertThat(file.readText().lineSequence().first()).startsWith("\"timestamp\"") + } + + @Test + fun `the export directory stays bounded`() { + repeat(MetricsCsvFile.KEEP_RECENT + 4) { i -> + MetricsCsvFile.write(context, snapshot(1), AT + i * 1_000L, zone) + } + + val directory = MetricsCsvFile.write(context, snapshot(1), AT + 90_000L, zone)!!.parentFile!! + + assertThat(directory.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) + } + + @Test + fun `the limit holds when the file just written is not the newest on disk`() { + // Pruning used to pick "the oldest n" across every file and then skip the one just written, + // which deleted one too few whenever that one sorted into the set -- and the directory crept + // one over the limit each time. Two writes inside a single filesystem timestamp are enough + // to sort it there. + // + // Dating the existing files into the future is what puts the new one at the front of the + // sort deterministically. Tying them all to one *past* value does not: the file written last + // still carries a real mtime, so it sorts last, is never in the set, and the skip never + // fires -- which is how the first version of this test passed against the unfixed code. + val future = System.currentTimeMillis() + 1_000_000L + repeat(MetricsCsvFile.KEEP_RECENT + 3) { i -> + MetricsCsvFile.write(context, snapshot(1), AT + i, zone)!!.setLastModified(future) + } + + val directory = MetricsCsvFile.write(context, snapshot(1), AT + 900L, zone)!!.parentFile!! + + assertThat(directory.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) + } + + @Test + fun `files land under the cache, which the platform may reclaim`() { + val file: File = MetricsCsvFile.write(context, snapshot(2), AT, zone)!! + + assertThat(file.absolutePath).startsWith(context.cacheDir.absolutePath) + } + + private companion object { + /** 2026-09-06T22:33:40.123 local. */ + const val AT = 1_788_759_220_123L + + /** The gap between the rows these fixtures build. */ + const val INTERVAL_MS = 1_000L + } +} From 63c994b7a13a39cd3da483dbd9813609d63e891f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 16:33:12 -0700 Subject: [PATCH 115/128] ADFA-5574: chart guards at the size the strip actually gives the plot Every other chart test lays out at 400px. The carousel strip is 248dp and the plot is what is left after the title row, the legend and the arrows -- around 150dp. At 400px there is room for the axis text to grow and nothing is ever tight, which is why the whole font-scale suite is green against a chart that was reported blank on a device. These four cases lay out at 150px instead and assert the plot keeps a usable area, at 1.0 and at 2x, and that the time axis does not label every tick "now". They pin nothing about ADFA-5602. They pass before and after, because Robolectric cannot reproduce it: instrumented at this size it reports xLabelWidth=0 and a legend needing 3.0px at 1.0 against 4.5px at 2x, where the device reports 51.5 and 77.3, and its content rect and axis range come out identical at both scales. That measurement is the useful part -- it says why no test here can catch a text-driven layout fault, and it is recorded on ADFA-5602 along with the device numbers that disprove the cause I originally proposed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsChartLargeTextTest.kt | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt new file mode 100644 index 0000000000..d644802693 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt @@ -0,0 +1,129 @@ +/* + * 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 androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The chart at the size the carousel actually gives it, with the text a low-vision user runs + * (ADFA-5602). + * + * Every other chart test lays out at [CHART_HEIGHT], 400px, which is far taller than the strip: + * `editor_mem_usage_view_height` is 248dp and the plot is only the part of it left over after the + * title row, the legend and the arrows. At 400px there is room for the axis text to grow and + * nothing collapses, which is why the whole suite passed while the chart on the device drew + * nothing at all at 2x. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartLargeTextTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun laidOutChart(height: Int): SafeLineChart { + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L + it }, + LongArray(SAMPLES) { 500L + it }, + LongArray(SAMPLES), + ) + }, + ).attach(chart) + chart.layOutAndDraw(CHART_WIDTH, height) + return chart + } + + /** The x labels the axis would draw, as the reader sees them. */ + private fun xLabels(chart: SafeLineChart): List { + val axis = chart.xAxis + val formatter = axis.valueFormatter ?: return emptyList() + return axis.mEntries.map { formatter.getFormattedValue(it, axis).orEmpty() } + } + + @Test + fun `the plot keeps a usable area in the strip the carousel gives it`() { + val chart = laidOutChart(STRIP_PLOT_HEIGHT) + + val handler = chart.viewPortHandler + assertWithMessage("content width").that(handler.contentWidth()).isGreaterThan(0f) + assertWithMessage("content height").that(handler.contentHeight()).isGreaterThan(0f) + } + + @Test + @Config(fontScale = 2.0f) + fun `the plot keeps a usable area at 2x font scale`() { + // The strip's height is fixed, so everything the axes and the legend reserve comes out of + // the plot. At 2x that reservation grew past what was there. + val chart = laidOutChart(STRIP_PLOT_HEIGHT) + + val handler = chart.viewPortHandler + assertWithMessage("content width").that(handler.contentWidth()).isGreaterThan(0f) + assertWithMessage("content height").that(handler.contentHeight()).isGreaterThan(0f) + } + + @Test + @Config(fontScale = 2.0f) + fun `the time axis still says how long ago, not 'now' for every label`() { + // The reported symptom. ElapsedTimeFormatter answers "now" whenever a label's value equals + // the axis maximum, so an axis whose range has collapsed labels every tick "now" -- and the + // same collapse is why nothing is drawn. + val chart = laidOutChart(STRIP_PLOT_HEIGHT) + + val labels = xLabels(chart) + assertThat(labels).isNotEmpty() + assertWithMessage("labels were $labels").that(labels.any { it != "now" }).isTrue() + } + + @Test + fun `a series with no readings at all does not collapse the time axis`() { + // What the device showed when this was reported: the legend read "Power - n/a", the plot was + // empty, and every x label read "now". A power source that stops answering gives the chart a + // series of pure sentinels, which is not the same as no chart at all -- the axis still has to + // say how long ago each sample was. + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(0), LongArray(0), LongArray(0)) + }, + ).attach(chart) + chart.layOutAndDraw(CHART_WIDTH, STRIP_PLOT_HEIGHT) + + val labels = xLabels(chart) + assertWithMessage("labels were $labels, xRange=${chart.xAxis.mAxisMinimum}..${chart.xAxis.mAxisMaximum}") + .that(labels.all { it == "now" } && labels.isNotEmpty()) + .isFalse() + } + + private companion object { + const val SAMPLES = 200 + + /** + * What the plot gets inside the 248dp strip once the title row, legend and arrows have + * taken theirs. Robolectric's density is 1.0, so dp and px are the same here. + */ + const val STRIP_PLOT_HEIGHT = 150 + } +} From 37a2a80c9bd91b4d113be0db7f9c09cc25f7838f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 17:38:04 -0700 Subject: [PATCH 116/128] ADFA-5574: take the carousel's last English words out of the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of every user-visible string the carousel produces found the split in the wrong place: the nouns were externalised -- page titles, series names, the four build annotations, the dialogs, both error toasts, 25 strings in all -- while the numbers, the units and two actual English words were literals in Kotlin. "now" labelled the x axis whenever a sample was less than half an interval old, and "n/a" stood in for a power reading the device would not give. Both are words rather than symbols, both are on screen, and neither could be translated. They are string resources now, like the labels they sit beside. The legend composed itself in code: "%s - %.2fMB", "%s - %s/s", "%s - %.1fC". A translator got "Battery temp" and never "Battery temp - 27.0C", because the separator and the ordering lived outside any resource -- so a right-to-left locale could not reorder them either. All four renderers build a legend entry through metrics_legend_entry now. The unit symbols stay in code deliberately: MB, kB, GB, B, W, mW, % and /s are international, and a resource per unit would be ceremony without a reader. One screen was using two decimal conventions. NetworkUsageChartRenderer pinned Locale.US in four byte formats while the memory and power pages passed no locale and followed the device, so a German phone showed "1.5 MB" beside "27,0C". The byte formats follow the device now, which is what a user-facing number should do; the tests that assert those strings derive their expectations the same way, so they stay locale-agnostic. Temperature reads "27" with a degree symbol rather than "27C". The symbol is narrower, which matters on an axis inside a 248dp strip, and it marks the number as a temperature rather than leaving a bare C to be read as something else. Written as ° rather than the character, to keep the source ASCII. Not fixed here, and filed separately: all 25 of these strings exist in one locale of fourteen. That is the standing state of every recently added string in this project rather than anything this stack did, and it needs a translation pass rather than a code change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MemoryUsageChartRenderer.kt | 8 ++-- .../androidide/ui/MetricsChartRenderer.kt | 6 ++- .../ui/NetworkUsageChartRenderer.kt | 47 ++++++++++++++----- .../androidide/ui/PowerUsageChartRenderer.kt | 32 ++++++++----- resources/src/main/res/values/strings.xml | 4 ++ 5 files changed, 67 insertions(+), 30 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 a313351855..a7fe80d1b4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.ui +import android.content.Context import androidx.annotation.UiThread import androidx.collection.IntObjectMap import androidx.collection.MutableIntIntMap @@ -107,7 +108,7 @@ class MemoryUsageChartRenderer( setDrawCircleHole(false) setDrawValues(false) isHighlightEnabled = false - label = labelFor(proc.pname, entries.lastOrNull()?.y ?: 0f) + label = labelFor(chart.context, proc.pname, entries.lastOrNull()?.y ?: 0f) } } @@ -182,7 +183,7 @@ class MemoryUsageChartRenderer( dataset.entries[index].y = proc.usageHistory.megabytesAt(index) } - dataset.label = labelFor(proc.pname, dataset.entries.lastOrNull()?.y ?: 0f) + dataset.label = labelFor(chart.context, proc.pname, dataset.entries.lastOrNull()?.y ?: 0f) dataset.notifyDataSetChanged() dataChanged = true } @@ -218,9 +219,10 @@ class MemoryUsageChartRenderer( } private fun labelFor( + context: Context, pname: String, megabytes: Float, - ): String = "%s - %.2fMB".format(pname, megabytes) + ): String = context.getString(R.string.metrics_legend_entry, pname, "%.2fMB".format(megabytes)) } internal const val BYTES_PER_MEGABYTE = 1024.0 * 1024.0 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 a2202c154a..8ba64dbabe 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -376,7 +376,8 @@ abstract class MetricsChartRenderer( // chooser -- so one gesture both undocked the strip and cleared every buffer. onSecondPointerDown = { axisTapListener?.abandonGesture() } - xAxis.valueFormatter = ElapsedTimeFormatter(sampleIntervalMillis) + xAxis.valueFormatter = + ElapsedTimeFormatter(sampleIntervalMillis, context.getString(R.string.metrics_axis_now)) // One label per 15 samples keeps the window readable without crowding. xAxis.granularity = X_LABEL_GRANULARITY_SAMPLES xAxis.isGranularityEnabled = true @@ -656,6 +657,7 @@ abstract class MetricsChartRenderer( */ private class ElapsedTimeFormatter( private val sampleIntervalMillis: () -> Long, + private val nowLabel: String, ) : IAxisValueFormatter { override fun getFormattedValue( value: Float, @@ -663,7 +665,7 @@ abstract class MetricsChartRenderer( ): String { val newestIndex = (axis?.mAxisMaximum ?: value) val secondsAgo = ((newestIndex - value) * sampleIntervalMillis() / 1000f).roundToLong() - return if (secondsAgo <= 0L) "now" else "-%ds".format(secondsAgo) + return if (secondsAgo <= 0L) nowLabel else "-%ds".format(secondsAgo) } } 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 eeb6d833de..63113b946d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.ui +import android.content.Context import android.graphics.Color import androidx.annotation.UiThread import com.github.mikephil.charting.components.AxisBase @@ -29,7 +30,6 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.NetworkUsageWatcher.NetworkUsage -import java.util.Locale import kotlin.math.ceil import kotlin.math.log10 import kotlin.math.max @@ -77,8 +77,18 @@ class NetworkUsageChartRenderer( val datasets = arrayOf( - dataset(usage.received, chart.context.getString(R.string.metrics_network_received), RECEIVED_COLOR), - dataset(usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted), TRANSMITTED_COLOR), + dataset( + chart.context, + usage.received, + chart.context.getString(R.string.metrics_network_received), + RECEIVED_COLOR, + ), + dataset( + chart.context, + usage.transmitted, + chart.context.getString(R.string.metrics_network_transmitted), + TRANSMITTED_COLOR, + ), ) setData(chart, datasets) { applyAxisRange(it, usage) } @@ -108,13 +118,19 @@ class NetworkUsageChartRenderer( return } - update(received, usage.received, chart.context.getString(R.string.metrics_network_received)) - update(transmitted, usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted)) + update(chart.context, received, usage.received, chart.context.getString(R.string.metrics_network_received)) + update( + chart.context, + transmitted, + usage.transmitted, + chart.context.getString(R.string.metrics_network_transmitted), + ) redraw(chart) { applyAxisRange(it, usage) } } private fun dataset( + context: Context, samples: LongArray, label: String, lineColor: Int, @@ -134,10 +150,11 @@ class NetworkUsageChartRenderer( setDrawCircleHole(false) setDrawValues(false) isHighlightEnabled = false - this.label = labelFor(label, samples.lastOrNull() ?: 0L) + this.label = labelFor(context, label, samples.lastOrNull() ?: 0L) } private fun update( + context: Context, dataset: LineDataSet, samples: LongArray, label: String, @@ -145,7 +162,7 @@ class NetworkUsageChartRenderer( for (index in samples.indices) { dataset.entries[index].y = samples[index].toLogBytes() } - dataset.label = labelFor(label, samples.lastOrNull() ?: 0L) + dataset.label = labelFor(context, label, samples.lastOrNull() ?: 0L) dataset.notifyDataSetChanged() } @@ -158,9 +175,15 @@ class NetworkUsageChartRenderer( * throughput fivefold, with the axis agreeing. */ private fun labelFor( + context: Context, label: String, bytes: Long, - ): String = "%s - %s/s".format(label, formatBytes(bytesPerSecond(bytes), decimals = 1)) + ): String = + context.getString( + R.string.metrics_legend_entry, + label, + "%s/s".format(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) @@ -265,9 +288,9 @@ private fun formatBytes( ): String { val clamped = bytes.coerceAtLeast(0.0) return when { - clamped < 1_000 -> "%d B".format(Locale.US, clamped.roundToLong()) - clamped < 1_000_000 -> "%.${decimals}f kB".format(Locale.US, clamped / 1_000) - clamped < 1_000_000_000 -> "%.${decimals}f MB".format(Locale.US, clamped / 1_000_000) - else -> "%.${decimals}f GB".format(Locale.US, clamped / 1_000_000_000) + clamped < 1_000 -> "%d B".format(clamped.roundToLong()) + clamped < 1_000_000 -> "%.${decimals}f kB".format(clamped / 1_000) + clamped < 1_000_000_000 -> "%.${decimals}f MB".format(clamped / 1_000_000) + else -> "%.${decimals}f GB".format(clamped / 1_000_000_000) } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt index f83ee47705..bdd73cf251 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.ui +import android.content.Context import android.graphics.Color import androidx.annotation.UiThread import androidx.core.graphics.ColorUtils @@ -82,6 +83,7 @@ class PowerUsageChartRenderer( val datasets = arrayOf( series( + context = context, values = usage.temperatureMilliCelsius, label = context.getString(R.string.metrics_power_temperature), lineColor = TEMPERATURE_COLOR, @@ -89,6 +91,7 @@ class PowerUsageChartRenderer( transform = ::milliCelsiusToCelsius, ), series( + context = context, values = usage.powerMicroWatts, label = context.getString(R.string.metrics_power_draw), lineColor = POWER_COLOR, @@ -126,6 +129,7 @@ class PowerUsageChartRenderer( val context = chart.context update( + context = context, dataset = temperature, values = usage.temperatureMilliCelsius, label = context.getString(R.string.metrics_power_temperature), @@ -133,6 +137,7 @@ class PowerUsageChartRenderer( transform = ::milliCelsiusToCelsius, ) update( + context = context, dataset = power, values = usage.powerMicroWatts, label = context.getString(R.string.metrics_power_draw), @@ -146,6 +151,7 @@ class PowerUsageChartRenderer( /** Rewrites one series' values in place and refreshes its legend entry. */ private fun update( + context: Context, dataset: LineDataSet, values: LongArray, label: String, @@ -155,7 +161,7 @@ class PowerUsageChartRenderer( for (index in values.indices) { dataset.entries[index].y = transform(values[index]) } - dataset.label = labelFor(label, values.lastOrNull(), axis) + dataset.label = labelFor(context, label, values.lastOrNull(), axis) dataset.notifyDataSetChanged() } @@ -259,6 +265,7 @@ class PowerUsageChartRenderer( } private fun series( + context: Context, values: LongArray, label: String, lineColor: Int, @@ -276,24 +283,23 @@ class PowerUsageChartRenderer( setDrawCircleHole(false) setDrawValues(false) isHighlightEnabled = false - this.label = labelFor(label, values.lastOrNull(), axis) + this.label = labelFor(context, label, values.lastOrNull(), axis) } private fun labelFor( + context: Context, label: String, latest: Long?, axis: YAxis.AxisDependency, ): String { val value = latest ?: PowerUsageWatcher.UNAVAILABLE - if (value == PowerUsageWatcher.UNAVAILABLE) { - return "%s - n/a".format(label) - } - - return if (axis == YAxis.AxisDependency.LEFT) { - "%s - %.1fC".format(label, milliCelsiusToCelsius(value)) - } else { - "%s - %s".format(label, formatPower(value)) - } + val reading = + when { + value == PowerUsageWatcher.UNAVAILABLE -> context.getString(R.string.metrics_value_unavailable) + axis == YAxis.AxisDependency.LEFT -> "%.1f\u00b0".format(milliCelsiusToCelsius(value)) + else -> formatPower(value) + } + return context.getString(R.string.metrics_legend_entry, label, reading) } /** @@ -318,7 +324,7 @@ class PowerUsageChartRenderer( // Integer labels need integer grid lines, exactly as the watt axis below does. Now that // the range is tight -- 29 to 33 rather than 0 to 36 -- the axis would otherwise place - // lines half a degree apart and "%dC" would print 29C, 30C, 30C, 31C, 31C. + // lines half a degree apart and the integer format would print 29, 30, 30, 31, 31. chart.axisLeft.granularity = 1f chart.axisLeft.isGranularityEnabled = true @@ -327,7 +333,7 @@ class PowerUsageChartRenderer( override fun getFormattedValue( value: Float, axis: AxisBase?, - ): String = "%dC".format(value.roundToLong()) + ): String = "%d\u00b0".format(value.roundToLong()) } // Watts, not milliwatts: a build peaks in single digit watts, so mW labels spent three diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 9ec7372fe6..26f3208492 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1700,6 +1700,10 @@ Couldn\'t save the metrics data. Received Sent + now + n/a + + %1$s - %2$s From f607f03e5ed714f77b97cf0c290f2a4a44a001d7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 18:08:24 -0700 Subject: [PATCH 117/128] ADFA-5542: follow stage's move of finish() onto the reporter Merging stage through the stack broke this branch without a merge conflict. The cancelled-build arm added here calls finish(BuildState.Idle); stage has since moved that function onto reporter, so every other call site in the file arrived already saying reporter.finish and this one did not. Git had nothing to flag -- the two changes touch different lines -- and the branch simply stopped compiling. Fixed where the call was introduced rather than at the stack tip, so every branch above inherits it by merge instead of carrying its own copy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index cc2338f173..1d56122c16 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -99,7 +99,7 @@ class BuildViewModel( // BUILD_CANCELLED"), with the enum name shown to them. if (result?.failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { log.info("Build was cancelled by the user.") - finish(BuildState.Idle) + reporter.finish(BuildState.Idle) return@launch } throw RuntimeException("Task execution failed: ${result?.failure}") From ce905e03fbce5174d44858a25ed75a84be20c09e Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 21:18:17 -0700 Subject: [PATCH 118/128] ADFA-5530: fix two defects review found in the carousel detach() cleared userHasZoomed and appliedTextScale but not reservedTopPixels. The reservation is memoised per chart, so carrying it across a detach meant a rebind onto a fresh SafeLineChart asking for the same inset took reserveTopSpace's early return and never got setExtraTopOffset at all -- and nothing else applies it, unlike the text scale, which setData re-applies on every rebuild. The battery readout then covered the right axis's topmost label again, which is the whole reason the inset exists. Reachable by undocking the power page and docking it back. MetricsCsvFile wrapped only the outer sink in use(), so a GZIPOutputStream constructor that throws -- it writes the gzip header there -- leaked the FileOutputStream it had already been handed. That is the crash-attachment path, so a device whose cache cannot be written leaked a descriptor per reported event. The raw stream now has its own use(). The rebind case has a regression test; removing the reset fails it. The leak does not: making the gzip header write fail needs a fault injection seam this class does not have, and adding one for it seemed a worse trade than the four-line change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/ui/MetricsChartRenderer.kt | 6 ++++++ .../itsaky/androidide/utils/MetricsCsvFile.kt | 11 +++++++--- .../ui/PowerUsageChartRendererTest.kt | 21 +++++++++++++++++++ 3 files changed, 35 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 8ba64dbabe..3e29d29dd8 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -264,6 +264,12 @@ abstract class MetricsChartRenderer( open fun detach() { userHasZoomed = false appliedTextScale = Float.NaN + // Memoised per chart, so it has to go with the chart. Left set, a rebind onto a fresh + // SafeLineChart asking for the same inset takes reserveTopSpace's early return and never + // calls setExtraTopOffset on it -- and nothing else does, unlike appliedTextScale, which + // setData re-applies. The battery readout then covers the right axis's topmost label again, + // which is the whole reason the inset exists. + reservedTopPixels = Float.NaN // A hold counting down survives the chart it was started on: the timer is on the main // thread's queue. Left running it shows the outgoing page's help over whatever replaced // it, and the replacement's listener -- a new object with its own null pendingHelp -- diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt index 1d1c5fdc9d..b73c686894 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt @@ -105,9 +105,14 @@ object MetricsCsvFile { // Streamed, not built into a string: a full buffer is ten thousand rows, and holding the // whole file in memory to write it is a megabyte of char array nobody needs. Compressed // on the way out for the same reason -- the uncompressed file never has to exist. - val sink = if (compress) GZIPOutputStream(file.outputStream()) else file.outputStream() - sink.bufferedWriter().use { writer -> - MetricsCsv.write(snapshot, zone, writer) + // The raw stream is opened into its own `use`: GZIPOutputStream writes the gzip header in + // its constructor and can throw, and wrapping only the outer sink leaked the descriptor it + // had already been handed -- once per reported crash on a device whose cache is full. + file.outputStream().use { raw -> + val sink = if (compress) GZIPOutputStream(raw) else raw + sink.bufferedWriter().use { writer -> + MetricsCsv.write(snapshot, zone, writer) + } } MetricsSnapshot.pruneTo(directory, KEEP_RECENT, file) file diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt index f1738a164e..1771a8ebf0 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -370,6 +370,27 @@ class PowerUsageChartRendererTest { assertThat(chart.viewPortHandler.contentTop()).isEqualTo(unreserved) } + @Test + fun `the battery readout gets room again on a replacement chart`() { + val (renderer, first) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) + laidOut(first) + renderer.reserveTopSpace(READOUT_HEIGHT_PX) + laidOut(first) + + // Undocking recycles the strip, so the same renderer is handed a brand new chart that asks + // for the same inset. The reservation is memoised per chart: carried across the detach, the + // early return meant the replacement never got setExtraTopOffset at all -- and nothing else + // applies it, unlike the text scale, which setData re-applies on every rebuild. + val second = SafeLineChart(context) + renderer.attach(second) + laidOut(second) + val unreserved = second.viewPortHandler.contentTop() + + renderer.reserveTopSpace(READOUT_HEIGHT_PX) + laidOut(second) + assertThat(second.viewPortHandler.contentTop()).isGreaterThan(unreserved) + } + @Test fun `only one axis rules the plot`() { val (_, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) From ab280033fb2fdf25d7e103e072c8df79a9960d55 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 8 Sep 2026 21:25:26 -0700 Subject: [PATCH 119/128] ADFA-5530: guard the undocked message at 2x text The strip is a fixed editor_mem_usage_view_height and the undocked message fills it at 0dp/0dp with nowhere to scroll, so the only thing keeping it readable at a 2.0 font scale is that it still fits. Nothing measured that. It does fit -- 140px of the 496px it has, at 2x on xhdpi -- and now a test says so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsChartLargeTextTest.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt index d644802693..2594759b6f 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt @@ -18,9 +18,15 @@ package com.itsaky.androidide.ui import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.appcompat.view.ContextThemeWrapper import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutMemUsageBinding import com.itsaky.androidide.utils.NetworkUsageWatcher import org.junit.Test import org.junit.runner.RunWith @@ -41,6 +47,9 @@ import org.robolectric.annotation.Config class MetricsChartLargeTextTest { private val context = ApplicationProvider.getApplicationContext() + private val themed: Context = + ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.Theme_AndroidIDE) + private fun laidOutChart(height: Int): SafeLineChart { val chart = SafeLineChart(context) NetworkUsageChartRenderer( @@ -126,4 +135,29 @@ class MetricsChartLargeTextTest { */ const val STRIP_PLOT_HEIGHT = 150 } + + @Test + @Config(fontScale = 2.0f, qualifiers = "xhdpi") + fun `the undocked message fits the strip at 2x text`() { + // The strip is a fixed editor_mem_usage_view_height and the message fills it with no room to + // scroll, so the only thing keeping it readable at 2x is that it still fits. Measured at the + // real height rather than the 400px the other tests use. + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(themed)) + val strip = binding.root as MetricsCarouselLayout + strip.setUndocked(true) + + val height = themed.resources.getDimensionPixelSize(R.dimen.editor_mem_usage_view_height) + strip.measure( + View.MeasureSpec.makeMeasureSpec(CHART_WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + strip.layout(0, 0, CHART_WIDTH, height) + + val message = strip.findViewById(R.id.metrics_undocked_message) + val needed = message.layout.height + message.paddingTop + message.paddingBottom + + assertWithMessage("undocked message needs %spx of the %spx it has", needed, message.height) + .that(needed) + .isAtMost(message.height) + } } From edc86775222cfdc03c3abc4371497a913ff47fa9 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 01:57:22 -0700 Subject: [PATCH 120/128] ADFA-5530: stop handing out live buffers, and fix the thermal sentinel The sampling loop copied the map but handed the listener the live ProcessMemoryInfo objects, so the renderer read all 3600 slots of the live ring buffer on the main thread while the sampler was mid-append -- it could see the advanced shift against the not-yet-written value and plot every point one slot out of place. That is the failure getMemoryUsages() snapshots to prevent (ADFA-5531); the listener path was bypassing the discipline this same PR added. It snapshots under historyLock now. The thermal series was filled and cleared with UNAVAILABLE (Long.MIN_VALUE) while its own KDoc, every consumer and MetricsSnapshotAssembler's `absent` all use THERMAL_UNKNOWN (-1). It rendered unshaded only by accident: Long.MIN_VALUE.toInt() is 0, which is THERMAL_STATUS_NONE -- "measured and not throttled" -- and the CSV would have written the raw MIN_VALUE into the thermal_status column, the spectacular-wrong-answer case Series.absent exists to prevent. `history is all zeros before the first sample` did not catch that, because it asserted sum() == 0 and 3600 * Long.MIN_VALUE wraps to exactly 0. The assertion held whether the buffers carried the sentinel, zeros, or the wrong sentinel. It asserts per slot now. markerRows walked every marker against every sampled row -- the O(markers x rows) cost its own KDoc says it avoids, ~2.6M compares at a full buffer, on the thread that just threw -- and boxed a 3600-element IndexedValue list to do it. Binary search over two parallel arrays. warnIfProcessHasGraphicsMemory line-scanned /proc//maps on the caller's thread, and for the Gradle daemon that caller is a main-dispatched build callback: a StrictMode DiskReadViolation and visible jank in the build a developer is watching, for a debug-only warning. Moved to its own IO scope. DevicePowerSource re-fetched the sticky ACTION_BATTERY_CHANGED Intent per sample with registerReceiver(null, ...) -- a binder round trip to the system server, up to ten a second, to re-read values that move on the order of seconds. One registered receiver caches the last Intent. metricsAttachmentForFeedback forced MetricsSnapshotAssembler onto the main thread, though it is @AnyThread precisely because a crash arrives on whatever thread threw. It allocated eleven LongArray(3600) and copied 39,600 longs there while contending for three sampler locks. Corrected two claims rather than the code: EventListener's KDoc said the daemon callbacks were "Defaulted" when the declaration has none and GradleBuildServiceListenerWrapperTest forbids one; MetricsScratch claimed snapshotting "needs no memory" when the write path it feeds still takes a Deflater, a writer buffer and a String per cell. Not fixed here, and why: - onPause still samples while backgrounded. Review argued the "evenly spaced samples" justification is dead now that sampleTimes exists. It is not: ElapsedTimeFormatter positions every point as (newestIndex - value) * sampleInterval, so a gap still misreports ages on the chart, which is ADFA-5486's original bug. Only the CSV carries real timestamps. Stopping while backgrounded needs the timestamp-based x axis first, which is ADFA-5596. - The per-tick rewrite of all 3600 entries is real but cannot be narrowed to the visible window: the history is a shifted ring buffer, so every index's value changes on append and a windowed update would leave the panned region stale. - The per-tick getUsage() copy is likewise not a simple win: the copy is what makes the hand-off to the main thread safe, which is the same reason the snapshot above was needed. Removing it needs double buffering. Those three are on ADFA-5619 with this reasoning. Tests: full app unit suite green, spotlessCheck clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../activities/editor/BaseEditorActivity.kt | 6 ++- .../services/builder/GradleBuildService.kt | 5 ++- .../androidide/utils/DevicePowerSource.kt | 41 ++++++++++++++++-- .../androidide/utils/MemoryUsageWatcher.kt | 19 +++++++-- .../com/itsaky/androidide/utils/MetricsCsv.kt | 42 ++++++++++++++++--- .../itsaky/androidide/utils/MetricsScratch.kt | 8 +++- .../androidide/utils/PowerUsageWatcher.kt | 13 +++--- .../androidide/utils/ProcessMemoryReader.kt | 14 ++++++- .../androidide/utils/PowerUsageWatcherTest.kt | 12 ++++-- 9 files changed, 134 insertions(+), 26 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 fa7a8119d2..09b792fb1c 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 @@ -224,8 +224,12 @@ abstract class BaseEditorActivity : * would happily produce a header-only file, and sending one is the caller's decision, not its. */ private suspend fun metricsAttachmentForFeedback(): File? { + // Off the main thread. MetricsSnapshotAssembler is @AnyThread precisely because a crash + // arrives on whatever thread threw -- every read inside takes the watcher's own history + // lock. Forcing it onto the UI thread allocated eleven LongArray(3600) and copied 39,600 + // longs there, while contending for three locks the samplers hold. val snapshot = - withContext(Dispatchers.Main.immediate) { + withContext(Dispatchers.IO) { MetricsSnapshotAssembler.assemble( context = this@BaseEditorActivity, memory = memoryUsageWatcher, diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 6df6c82d2f..121356f0cc 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -898,8 +898,9 @@ class GradleBuildService : /** * Called when the Gradle daemon has been identified by the tooling server. * - * Defaulted, because a daemon is only of interest to a listener that plots it and every - * other implementer would otherwise gain two empty methods. + * Deliberately not defaulted. An interface default here let the wrapper satisfy the + * interface without forwarding, so the daemon callbacks were silently swallowed; + * GradleBuildServiceListenerWrapperTest asserts no callback on this interface has one. * * @param pid The process id of the Gradle daemon. * @see IToolingApiClient.onGradleDaemonStarted diff --git a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt index 512fe62b49..417f0e68e8 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.utils +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -29,15 +30,19 @@ import com.itsaky.androidide.services.builder.ThermalInfo import com.itsaky.androidide.services.builder.ThermalState import com.itsaky.androidide.utils.PowerUsageWatcher.BatteryState import com.itsaky.androidide.utils.PowerUsageWatcher.PowerReading +import org.slf4j.LoggerFactory import kotlin.math.abs /** * Reads temperature and power from the battery, which is all a normally-installed app can see * (ADFA-5499). * - * `ACTION_BATTERY_CHANGED` is a sticky broadcast, so the current values can be read on demand with a - * null receiver rather than by registering one and waiting -- which suits being polled on the - * sampling tick. + * `ACTION_BATTERY_CHANGED` is a broadcast, and this registers one receiver for it and keeps the + * last Intent. Re-fetching the sticky Intent per sample with `registerReceiver(null, ...)` is a + * synchronous binder round trip to the system server, and at the fastest offered rate that was ten + * of them a second, for the life of the process, to re-read values that move on the order of + * seconds. Registering costs one call and the broadcast then pushes every change (ADFA-5172 is the + * repo's precedent: eliminate the operation rather than make it cheaper). * * Not read here, deliberately: the per-zone CPU, GPU and skin temperatures from * `HardwarePropertiesManager`. Those need `android.permission.DEVICE_POWER`, which is signature @@ -50,8 +55,34 @@ class DevicePowerSource( private val batteryManager = context.getSystemService() private val powerManager = context.getSystemService() + @Volatile + private var lastBattery: Intent? = null + + private val batteryReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + lastBattery = intent + } + } + + init { + // The registration returns the sticky Intent, so the first sample has a value without + // waiting for a change. Registered on the main looper: the receiver only stores a + // reference, and the field it stores into is volatile for the sampling thread. + lastBattery = context.registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } + + /** Stops listening. The source is unusable afterwards; [read] would go on reporting the last Intent. */ + fun close() { + runCatching { context.unregisterReceiver(batteryReceiver) } + .onFailure { log.warn("Could not unregister the battery receiver", it) } + } + override fun read(): PowerReading { - val battery = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val battery = lastBattery return PowerReading( temperatureMilliCelsius = readTemperature(battery), @@ -184,6 +215,8 @@ class DevicePowerSource( } private companion object { + private val log = LoggerFactory.getLogger(DevicePowerSource::class.java) + /** Microamps times millivolts gives nanowatts; this scales the product to microwatts. */ const val NANOWATTS_PER_MICROWATT = 1_000L 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 dfa428a321..e50788886a 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -174,9 +174,18 @@ class MemoryUsageWatcher // don't bother to update if no listeners are set listener?.also { listener -> + // Snapshots, not the live objects. Handing the renderer the live + // ProcessMemoryInfo hands it the live ring buffer: it reads all + // 3600 slots on the main thread while the sampler is mid-append, + // so it can see the advanced shift against the not-yet-written + // value and plot every point one slot out of place. That is the + // failure getMemoryUsages() snapshots to prevent (ADFA-5531); the + // listener path was bypassing it. val usages = MutableIntObjectMap(memoryUsage.size) - for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { - usages[pid] = usage + synchronized(historyLock) { + for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { + usages[pid] = usage.snapshot() + } } withContext(mainDispatcher) { listener.onMemoryUsageChanged(usages) @@ -523,8 +532,10 @@ class MemoryUsageWatcher /** * A copy of this process's history, safe to read while the sampler keeps appending. * - * The MemoryInfo instance is shared deliberately: it is the sampler's scratch buffer - * for the next reading and no reader looks at it. + * The copy gets a fresh [MemoryInfo] and the default [reader]: neither is part of what + * a reader of a snapshot looks at, which is the history and the process's identity. An + * earlier version of this comment claimed the MemoryInfo was shared with the original; + * it never was, because it is a property initialiser. */ internal fun snapshot(): ProcessMemoryInfo = // Every field, including watchedSinceMillis. Dropping it let it default to 0, which diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt index 1f8c82224b..b2454dfeed 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt @@ -289,22 +289,54 @@ object MetricsCsv { return emptyMap() } - val sampled = snapshot.rowTimes.withIndex().filter { it.value != NO_SAMPLE } - if (sampled.isEmpty()) { + // Two parallel arrays rather than a list of IndexedValue: this used to build a 3600-element + // boxed list per call, on the crashing thread. + val sampledTimes = LongArray(snapshot.rowTimes.size) + val sampledRows = IntArray(snapshot.rowTimes.size) + var sampledCount = 0 + snapshot.rowTimes.forEachIndexed { row, time -> + if (time != NO_SAMPLE) { + sampledTimes[sampledCount] = time + sampledRows[sampledCount] = row + sampledCount++ + } + } + if (sampledCount == 0) { return emptyMap() } val rows = mutableMapOf() snapshot.annotations.sortedBy { it.atMillis }.forEach { marker -> - val nearest = sampled.minByOrNull { abs(it.value - marker.atMillis) } ?: return@forEach - if (abs(nearest.value - marker.atMillis) > snapshot.sampleIntervalMillis) { + // A binary search, not a scan. rowTimes is ascending among sampled entries, and the + // scan this replaced was the O(markers x rows) walk the KDoc above claims to avoid -- + // ~2.6M compares at a full buffer and MAX_ANNOTATIONS, on the thread that just threw. + val nearest = nearestSampleTo(marker.atMillis, sampledTimes, sampledCount) + if (abs(sampledTimes[nearest] - marker.atMillis) > snapshot.sampleIntervalMillis) { return@forEach } - rows.putIfAbsent(nearest.index, marker) + rows.putIfAbsent(sampledRows[nearest], marker) } return rows } + /** The index in [times]`[0, count)` whose value is closest to [target]. */ + private fun nearestSampleTo( + target: Long, + times: LongArray, + count: Int, + ): Int { + var low = 0 + var high = count - 1 + while (low < high) { + val mid = (low + high) / 2 + if (times[mid] < target) low = mid + 1 else high = mid + } + // binarySearch lands on the first entry at or after the target; the one before it can be + // closer, and is when the target falls between two samples. + val previous = (low - 1).coerceAtLeast(0) + return if (abs(times[previous] - target) <= abs(times[low] - target)) previous else low + } + private fun number(value: Long?): String = value?.toString() ?: "" /** diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt index df7a145159..f77b34fb42 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt @@ -23,11 +23,17 @@ import java.util.concurrent.atomic.AtomicBoolean /** * Destinations for one metrics snapshot, allocated once so that taking one needs no memory. * - * A crash handler is the wrong place to ask for memory: the crash being reported may be the heap + * A crash handler is a poor place to ask for memory: the crash being reported may be the heap * running out, and a handler that throws replaces a useful report with a useless one. Snapshotting * the watchers otherwise takes eleven fresh arrays -- around 300KB at the retained length -- so the * arrays are taken at startup instead, when failing to get them is survivable and obvious. * + * This removes the largest single allocation on that path, not all of it: writing the file still + * takes a Deflater and its buffer, an 8KB writer buffer and a String per cell. So it improves the + * odds of getting a report out under memory pressure rather than guaranteeing one, and under a + * genuine OutOfMemoryError the write can still fail and the attachment still be dropped. Removing + * the rest means streaming the CSV without per-cell Strings, which is a bigger change than this. + * * Held for the life of the process, which is the trade: this is memory reserved against a crash that * may never come, in a process that is already a fat target for the low-memory killer. It is paid * for by [MemoryUsageWatcher.MAX_USAGE_ENTRIES] coming down at the same time -- the live buffers plus diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 723b45e30a..0b40766cbe 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -104,8 +104,14 @@ class PowerUsageWatcher * * Kept per sample rather than as a separate timestamped log so the chart's shading lines up * with the sample grid exactly: a shaded span is just a run of equal values here. + * + * Filled with [THERMAL_UNKNOWN], not [UNAVAILABLE]: this series has its own sentinel, and + * every consumer and [MetricsSnapshotAssembler]'s `absent` already use it. Filled with + * UNAVAILABLE instead, `Long.MIN_VALUE.toInt()` is 0 -- THERMAL_STATUS_NONE, "measured and + * not throttled" -- and the CSV would not recognise it as absent, writing the raw + * MIN_VALUE into the column. */ - private val thermal = MutableShiftedLongArray(MAX_USAGE_ENTRIES) { UNAVAILABLE } + private val thermal = MutableShiftedLongArray(MAX_USAGE_ENTRIES) { THERMAL_UNKNOWN.toLong() } /** * Milliseconds between samples. Changing it clears the history, for the reason given on @@ -113,9 +119,6 @@ class PowerUsageWatcher * * 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: 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) @@ -179,7 +182,7 @@ class PowerUsageWatcher sampleTimes.clear() temperature.clear(UNAVAILABLE) power.clear(UNAVAILABLE) - thermal.clear(UNAVAILABLE) + thermal.clear(THERMAL_UNKNOWN.toLong()) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt b/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt index 1580e08977..867699dfb7 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt @@ -23,6 +23,10 @@ import android.os.Process import androidx.annotation.VisibleForTesting import com.itsaky.androidide.BuildConfig import com.termux.shared.reflection.ReflectionUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import org.slf4j.LoggerFactory import java.io.File @@ -84,10 +88,18 @@ object ProcessMemoryReaders { fun chooseReader(pid: Int): ProcessMemoryReader = chooseReader(pid, Process.myPid(), isRollupSupported).also { chosen -> if (BuildConfig.DEBUG && chosen === SmapsRollupReader) { - warnIfProcessHasGraphicsMemory(pid) + // Off the caller's thread. The only caller is watchProcess, and for the Gradle + // daemon it reaches there from a main-dispatched build callback -- so this + // sequential scan of a JVM's maps file (93,120 lines for the IDE's own) ran on the + // UI thread, in exactly the build a developer is watching. A StrictMode + // DiskReadViolation, and visible jank, for a debug-only warning. + diagnosticsScope.launch { warnIfProcessHasGraphicsMemory(pid) } } } + /** Debug-only diagnostics, off whatever thread started watching a process. */ + private val diagnosticsScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** * Complains if a process given the cheap read turns out to be an Android runtime process. * diff --git a/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt b/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt index 6032bcbd8e..40871c2b30 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt @@ -73,14 +73,20 @@ class PowerUsageWatcherTest { private fun LongArray.recent(count: Int): List = takeLast(count) @Test - fun `history is all zeros before the first sample`() { + fun `every slot reads as absent before the first sample`() { val fixture = Fixture(listOf(reading())) val usage = fixture.watcher.getUsage() + // Asserted per slot, not as a sum. This test used to check `sum() == 0`, which passed for + // a reason that had nothing to do with absence: 3600 * Long.MIN_VALUE wraps to exactly 0, + // so the assertion held whether the buffers were filled with the sentinel or with zeros -- + // and went on holding when the thermal series was filled with the wrong sentinel entirely. assertThat(usage.temperatureMilliCelsius).hasLength(PowerUsageWatcher.MAX_USAGE_ENTRIES) - assertThat(usage.powerMicroWatts.sum()).isEqualTo(0L) - assertThat(usage.thermalStatus.sum()).isEqualTo(0L) + assertThat(usage.temperatureMilliCelsius.toSet()).containsExactly(PowerUsageWatcher.UNAVAILABLE) + assertThat(usage.powerMicroWatts.toSet()).containsExactly(PowerUsageWatcher.UNAVAILABLE) + // Its own sentinel, which is what every consumer and the CSV's `absent` use. + assertThat(usage.thermalStatus.toSet()).containsExactly(PowerUsageWatcher.THERMAL_UNKNOWN.toLong()) } @Test From 08f45edca6da55ff967d59ce30e98c72d3270aef Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 06:56:55 -0700 Subject: [PATCH 121/128] ADFA-5530: close the registration race, and stop leaking the battery receiver getMemoryUsages() read memoryUsage.size and then values.elementAt(index) per element. watchProcess(unique = true) removes an entry, and it runs from the tooling server's own thread and from a CompletableFuture completion -- so a removal between the two threw IndexOutOfBoundsException on the main thread, from inside a chart redraw. One walk over the values now, which is also O(n) rather than O(n^2). watchProcess, unwatchProcess and unwatchAll take historyLock, and readUsages appends to every watched process rather than only the ones it read. Taking the lock alone was not enough: a registration that blocked until the append finished still started one append behind sampleTimes, which is the same permanent misalignment. A process registered mid-read gets a zero for the sample it was not present for, which watchedSinceMillis already tells the exporter to blank. DevicePowerSource.close() had no caller, so the receiver registered in its constructor against the application context outlived the watcher and the editor: one more receiver per editor session, for the life of the process. That was a regression from the previous commit, which added the receiver to remove a per-sample binder call and never wired its teardown. PowerUsageWatcher.close() now closes an AutoCloseable source, and PowerSource stays a fun interface so tests can still pass a lambda. MetricsCarouselDockableContent.onDestroyView() unbound the shared controller unconditionally. The undock and redock paths are two independent collectors of the same DockingManager emission with nothing ordering them, so the editor can rebind before the window tears down, and the unbind then stripped the editor's own carousel. Now identity-guarded through unbindIfBoundTo, like every sibling teardown here. setSamplingInterval cleared the annotation store unconditionally, but the watchers' setters return early on an unchanged value -- so re-picking the rate already in effect, which the dialog allows, wiped every build marker off a chart whose samples were untouched. MetricsScratch sizes its arrays from the largest of the three retentions rather than the memory watcher's alone. They agree today, and ShiftedLongArray.copyInto require()s an exact match, so the day they stop agreeing the throw lands where runCatching swallows it and every crash report silently loses its metrics. Tests: full app unit suite green, spotlessCheck clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../MetricsCarouselDockableContent.kt | 8 ++- .../ui/MetricsCarouselController.kt | 32 ++++++++++-- .../androidide/utils/DevicePowerSource.kt | 5 +- .../androidide/utils/MemoryUsageWatcher.kt | 52 ++++++++++++++----- .../itsaky/androidide/utils/MetricsScratch.kt | 12 ++++- .../androidide/utils/PowerUsageWatcher.kt | 8 +++ 6 files changed, 96 insertions(+), 21 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 294fb33857..225ad0a398 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 @@ -66,6 +66,7 @@ class MetricsCarouselDockableContent( host: FloatingWindowHost, ): View { val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + this.binding = binding // The editor sizes the carousel to a fixed strip; in a window it should fill whatever the // user has dragged the frame out to. @@ -95,9 +96,14 @@ class MetricsCarouselDockableContent( } override fun onDestroyView() { - controller.unbind() + // Only if the controller is still bound to this window's views. The redock path rebinds it + // to the editor's, and nothing orders the two collectors of the same docking emission. + binding?.let(controller::unbindIfBoundTo) + binding = null } + private var binding: LayoutMemUsageBinding? = null + private fun hideSoftInput(view: View) { val manager = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager manager?.hideSoftInputFromWindow(view.windowToken, 0) 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 f80700bead..59722866e0 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -332,6 +332,23 @@ class MetricsCarouselController( binding.metricsUndockedMessage to TooltipTag.CAROUSEL_UNDOCKED, ) + /** + * Unbinds only if [binding] is still the bound one. + * + * The undock and redock paths are two independent collectors of the same DockingManager + * emission with nothing ordering them, so the editor can rebind this controller to its own + * views before the floating window's onDestroyView runs. An unconditional unbind there stripped + * the editor's freshly bound carousel -- adapter null, listeners cleared, renderers detached -- + * leaving a dead strip until the next onResume. Every sibling teardown here is identity-guarded + * for the same reason. + */ + @UiThread + fun unbindIfBoundTo(binding: LayoutMemUsageBinding) { + if (this.binding === binding) { + unbind() + } + } + /** * Stops feeding the carousel and releases the bound views. Sampling is unaffected -- the * watchers keep their history, so re-binding shows it in full. @@ -573,13 +590,20 @@ class MetricsCarouselController( intervalMillis, IDEBuildConfigProvider.getInstance().deviceArch, ) + // Whether anything actually changes, because the clear below must not run when nothing + // does: each watcher's setter returns early on an unchanged value, so re-picking the rate + // already in effect -- which the dialog allows, and which a user opening it to read the + // options does -- cleared every build marker off a chart whose samples were untouched. + val changed = memoryUsageWatcher.updateInterval != supported memoryUsageWatcher.updateInterval = supported networkUsageWatcher.updateInterval = supported powerUsageWatcher.updateInterval = supported - // The annotations go with the samples they annotate. Left behind, task markers stood over - // a flat zero line with nothing to mark -- and this is the only route by which the store's - // throttle window is ever reset. - annotations?.clear() + if (changed) { + // The annotations go with the samples they annotate. Left behind, task markers stood + // over a flat zero line with nothing to mark -- and this is the only route by which the + // store's throttle window is ever reset. + annotations?.clear() + } refresh() } diff --git a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt index 417f0e68e8..62792d91ad 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -51,7 +51,8 @@ import kotlin.math.abs */ class DevicePowerSource( private val context: Context, -) : PowerUsageWatcher.PowerSource { +) : PowerUsageWatcher.PowerSource, + AutoCloseable { private val batteryManager = context.getSystemService() private val powerManager = context.getSystemService() @@ -76,7 +77,7 @@ class DevicePowerSource( } /** Stops listening. The source is unusable afterwards; [read] would go on reporting the last Intent. */ - fun close() { + override fun close() { runCatching { context.unregisterReceiver(batteryReceiver) } .onFailure { log.warn("Could not unregister the battery receiver", it) } } 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 e50788886a..05327f8965 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -235,8 +235,16 @@ class MemoryUsageWatcher // index, wrapping back to 0 once it passes the end. sampleTimes[0] = at sampleTimes.shift(1) - sampled.forEach { (proc, usageBytes) -> - proc._history[0] = usageBytes + + val readings = sampled.associate { (proc, usageBytes) -> proc.pid to usageBytes } + // Every watched process advances, not only the ones read above. Alignment between + // these buffers is by append count, so a process registered after the pid set was + // snapshotted -- the Gradle daemon appears when a build does -- would otherwise + // miss the append sampleTimes just took and stay one slot out of step with the row + // timestamps for the rest of the session. It gets a zero for the sample it was not + // present for, which watchedSinceMillis already tells the exporter to blank. + memoryUsage.values.forEach { proc -> + proc._history[0] = readings[proc.pid] ?: 0L proc._history.shift(1) } } @@ -295,15 +303,23 @@ class MemoryUsageWatcher pid: Int, pname: String, unique: Boolean = true, - ) { + ) = synchronized(historyLock) { + // The same lock the sampler appends under. readUsages() snapshots the pid set, spends + // 13-31ms per process reading /proc, then appends to sampleTimes and to every process + // in that snapshot. A registration landing in that window missed the append that + // sampleTimes received, so the new buffer stayed one slot out of step with the row + // timestamps for the rest of the session -- the misalignment ADFA-5531's + // single-critical-section design exists to prevent. watchProcess also runs off the main + // thread (the tooling server's own, and a CompletableFuture completion), so this is not + // a UI-thread-only path that could rely on ordering. if (memoryUsage.containsKey(pid)) { log.warn("Process {} is already being watched", pid) - return + return@synchronized } if (unique) { // unwatch the process with the given process name - unwatchProcess(pname) + removeByName(pname) } memoryUsage[pid] = @@ -397,7 +413,11 @@ class MemoryUsageWatcher // an old value -- plotting a point one slot out of place, which is exactly the // scrambled history the lock's own doc says it prevents. NetworkUsageWatcher and // PowerUsageWatcher already hand out copies for this reason. - Array(memoryUsage.size) { index -> memoryUsage.values.elementAt(index).snapshot() } + // One walk, no indexing. Reading size and then values.elementAt(index) could throw + // IndexOutOfBoundsException on the main thread if a process was unwatched between + // the two -- watchProcess(unique = true) removes one, and it runs from the tooling + // server's own thread. elementAt on a values view is also O(n). + memoryUsage.values.map { it.snapshot() }.toTypedArray() } /** @@ -408,14 +428,19 @@ class MemoryUsageWatcher /** * Removes the given process from the watch list. */ - fun unwatchProcess(processId: Int) { - memoryUsage.remove(processId) - } + fun unwatchProcess(processId: Int) = + synchronized(historyLock) { + memoryUsage.remove(processId) + Unit + } /** * Removes the process with the given process name from the watch list. */ - fun unwatchProcess(procName: String) { + fun unwatchProcess(procName: String) = synchronized(historyLock) { removeByName(procName) } + + /** Removal without taking [historyLock], for callers that already hold it. */ + private fun removeByName(procName: String) { memoryUsage.values.forEach { if (it.pname == procName) { memoryUsage.remove(it.pid) @@ -426,9 +451,10 @@ class MemoryUsageWatcher /** * Unwatches all the registered processes. */ - fun unwatchAll() { - memoryUsage.clear() - } + fun unwatchAll() = + synchronized(historyLock) { + memoryUsage.clear() + } /** * Stop watching processes for their memory usage. diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt index f77b34fb42..2e07621804 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt @@ -84,7 +84,17 @@ class MetricsScratch( * which is what it did before this existed. */ fun install( - entries: Int = MemoryUsageWatcher.MAX_USAGE_ENTRIES, + // The largest of the three retentions, not the memory watcher's alone. These arrays are + // handed to all three watchers, and ShiftedLongArray.copyInto require()s an exact size + // match -- so if the three constants ever stop agreeing, the throw lands inside + // MetricsCrashAttachment.writeSnapshot, where runCatching swallows it and every crash + // report silently loses its metrics, which is the failure this class exists to prevent. + entries: Int = + maxOf( + MemoryUsageWatcher.MAX_USAGE_ENTRIES, + NetworkUsageWatcher.MAX_USAGE_ENTRIES, + PowerUsageWatcher.MAX_USAGE_ENTRIES, + ), memorySeries: Int = MetricsCsv.MEMORY_COLUMNS.size, ) { if (instance != null) { diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index 0b40766cbe..d5d01d974e 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -232,6 +232,14 @@ class PowerUsageWatcher closed.set(true) stopWatching() listener = null + // The source too, if it holds anything. DevicePowerSource registers a battery receiver + // against the application context, so a source left open outlives this watcher and the + // editor that created it -- one more receiver per editor session, for the life of the + // process. PowerSource stays a fun interface so a test can still pass a lambda. + (source as? AutoCloseable)?.let { closeable -> + runCatching { closeable.close() } + .onFailure { log.warn("Could not close the power source", it) } + } coroutineScope.cancelIfActive("Watcher closed") (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() } From 25b09a7e56954790c61e88b6bedd4ee0bc53d218 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 07:05:16 -0700 Subject: [PATCH 122/128] ADFA-5530: recycle the snapshot bitmap even when the export is cancelled The recycle sat in a finally inside withContext(Dispatchers.IO), so it was only reachable once that block had started running. The bitmap is taken before the launch, on the UI thread, and close() cancels the scope on undock and on activity destroy -- a cancellation at that suspension point skipped the finally and left a full-size ARGB_8888 copy of the plot, the largest thing this class allocates, to the collector. Tap the camera and immediately undock to reproduce. The finally now wraps the whole launch body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../ui/MetricsCarouselController.kt | 73 ++++++++++--------- 1 file changed, 39 insertions(+), 34 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 59722866e0..1458025d00 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -652,45 +652,50 @@ class MetricsCarouselController( val appContext = context.applicationContext snapshotInFlight = 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) { - // 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 { + // The recycle wraps the whole body, not just the IO block. getChartBitmap hands back a + // fresh full-size ARGB_8888 copy of the plot on every tap -- the largest thing this + // class allocates -- and it is taken before the launch. Recycling inside + // withContext(Dispatchers.IO) meant a cancellation at that suspension point, which + // close() causes on undock and on activity destroy, skipped the finally entirely and + // left it to the collector. + try { + // 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) - } 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. + 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 } - // 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) { + // 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) { + // 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. + snapshotInFlight = false + throw failure + } + log.error("Could not share the chart snapshot", failure) Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() - return@runCatching - } - // 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) { - // 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. - snapshotInFlight = false - throw failure } - log.error("Could not share the chart snapshot", failure) - Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + } finally { + bitmap.recycle() } snapshotInFlight = false } From a29a05156096bc20f2597bc81aaabaa3e50a3f5b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 14:09:29 -0700 Subject: [PATCH 123/128] ADFA-5530: take the floating carousel down with the editor Finishing the editor while the carousel was undocked unbound and closed the controller that the floating window was still driving: frozen charts, dead camera and CSV buttons, and nothing saying the data source had gone. The watchers stop with MetricsViewModel anyway, so there is no version of this where the floating carousel usefully outlives the editor -- the window has to go too, and now does, before the controller is released. The network watcher's resume gate reads isSupported as well as isWatching. Where TrafficStats has no per-UID counters the loop clears `watching` and breaks, so the gate alone relaunched a coroutine that sampled once, repainted a permanently-zero chart and died -- on every resume, for the life of the session. postProjectInit checks BUILD_CANCELLED before resolving the project name rather than after. The cancel path never used the name, but paid for a workspace-model walk and a catch-Throwable to build it. Not changed, on inspection: review called textScaleFor's lower bound of 1f an undocumented bug, on the grounds that a user on Android's "Small" setting gets chart text coerced up to 1.0. The floor is deliberate and documented -- `a font scale below one does not shrink the chart further` pins it, with the reason: this text is already the smallest on screen, so following a reduction makes it unreadable rather than merely small. I changed it, the test caught it, and it is back. Only the ceiling is the fixed-strip trade-off (ADFA-5634); the KDoc now says which is which. Tests: full app unit suite green, spotlessCheck clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../activities/editor/BaseEditorActivity.kt | 16 +++++++++-- .../editor/EditorHandlerActivity.kt | 4 +++ .../editor/ProjectHandlerActivity.kt | 28 +++++++++++-------- .../androidide/ui/MetricsChartRenderer.kt | 9 +++++- 4 files changed, 42 insertions(+), 15 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 09b792fb1c..f516b532e4 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 @@ -610,7 +610,13 @@ abstract class BaseEditorActivity : // 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. + // then is correct -- but the window has to be told, first. Closing the controller under a + // window that is still on screen left frozen charts and dead camera and CSV buttons, with + // nothing saying the data source had gone; the watchers stop with MetricsViewModel anyway, + // so there is no version of this where the floating carousel outlives the editor usefully. + if (isDestroying) { + closeFloatingMetricsCarousel() + } if (!isMetricsCarouselUndocked() || isDestroying) { metricsCarousel.unbind() } @@ -1100,6 +1106,9 @@ abstract class BaseEditorActivity : /** Whether the carousel is currently floating rather than docked here. */ protected open fun isMetricsCarouselUndocked(): Boolean = false + /** Dismisses the floating carousel window, if one is up. Overridden where docking is wired. */ + protected open fun closeFloatingMetricsCarousel() = Unit + /** A tap on the "tap to bring them back" message asks for the floating carousel to re-dock. */ protected open fun onMetricsCarouselRedockRequested() = Unit @@ -1198,7 +1207,10 @@ abstract class BaseEditorActivity : if (!memoryUsageWatcher.isWatching) { memoryUsageWatcher.startWatching() } - if (!networkUsageWatcher.isWatching) { + // isSupported too: where TrafficStats has no per-UID counters the loop clears `watching` + // and breaks, so this gate alone relaunched a coroutine that sampled once, repainted a + // permanently-zero chart and died -- on every single resume, for the life of the session. + if (!networkUsageWatcher.isWatching && networkUsageWatcher.isSupported) { networkUsageWatcher.startWatching() } if (!powerUsageWatcher.isWatching) { 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 f094761840..59e7ca8382 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 @@ -918,6 +918,10 @@ open class EditorHandlerActivity : override fun isMetricsCarouselUndocked(): Boolean = DockingManager.isFloating(MetricsCarouselDockableContent.ID) + override fun closeFloatingMetricsCarousel() { + DockingManager.close(MetricsCarouselDockableContent.ID) + } + /** The floating carousel has closed or re-docked; put the editor's own carousel back. */ fun onFloatingMetricsCarouselGone() { setMetricsCarouselUndocked(false) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 12cf27cb73..ca6f2e7add 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -808,6 +808,22 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { ) { val manager = ProjectManagerImpl.getInstance() if (!isSuccessful) { + // Before the project name is resolved, which the cancel path does not use: that lookup + // walks the workspace model and has a catch-Throwable around it, and a user who pressed + // Stop should not be waiting on it -- or be affected by it failing. + // + // A sync the user stopped is not a failure, and arrives here through the same callback + // as one. ADFA-5542 fixed that for builds and missed this path, which is the one a + // cancelled *sync* takes: the user pressed Stop and got an indefinite red "Project + // initialization failed" for doing so. + if (failure == BUILD_CANCELLED) { + val cancelled = getString(string.info_build_cancelled) + setStatus(cancelled) + flashInfo(cancelled) + editorViewModel.isInitializing = false + return + } + // Get project name for error message val projectName = try { @@ -822,18 +838,6 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { manager.projectDir.name } - // A sync the user stopped is not a failure, and arrives here through the same callback - // as one. ADFA-5542 fixed that for builds and missed this path, which is the one a - // cancelled *sync* takes: the user pressed Stop and got an indefinite red "Project - // initialization failed" for doing so. - if (failure == BUILD_CANCELLED) { - val cancelled = getString(string.info_build_cancelled) - setStatus(cancelled) - flashInfo(cancelled) - editorViewModel.isInitializing = false - return - } - val initFailed = if (projectName.isNotEmpty()) { getString(string.msg_project_initialization_failed_with_name, projectName) 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 3e29d29dd8..6b7bc0c739 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -1010,7 +1010,14 @@ abstract class MetricsChartRenderer( /** Never fewer than this, or the axis stops conveying a scale at all. */ const val MIN_LABEL_COUNT = 3 - /** The font scale the charts follow: the system's, held to [MAX_TEXT_SCALE]. */ + /** + * The font scale the charts follow: the system's, held to [MAX_TEXT_SCALE]. + * + * Both bounds are deliberate. The ceiling is the fixed-height strip's trade-off + * (ADFA-5634); the floor is legibility -- this text is already the smallest on the screen, + * so following a reduction below 1.0 makes it unreadable rather than merely small. Pinned + * by `a font scale below one does not shrink the chart further`. + */ @JvmStatic fun textScaleFor(context: Context): Float = context.resources.configuration.fontScale From 1c8f78bcba3d8eae1090fa54304d0e19b0345f1f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 15:48:00 -0700 Subject: [PATCH 124/128] ADFA-5530: make the scratch guard real, and stop two samplers racing MetricsScratch sized its buffers with maxOf of the three watchers' MAX_USAGE_ENTRIES. That was no guard at all: one size is handed to all three and ShiftedLongArray.copyInto require()s an exact match, so the moment the constants diverge the two smaller watchers throw -- inside MetricsCrashAttachment's runCatching, where it is swallowed and every crash report and feedback send quietly loses its metrics. Exactly the failure the comment claimed to prevent, which is worse than not commenting. sharedRetention() now require()s the three to agree and names them when they do not, so divergence is a loud startup failure instead of a silent hole in diagnostics. samplingJob is assigned only after launch returns. A stop landing in that gap cancels whatever the field held rather than the loop just started, and a later start can overwrite the field with a job nothing then cancels: two loops appending to the same buffers, at twice the sample rate, out of step with the row timestamps. Cancelling more carefully cannot fix it, because the assignments themselves can land out of order, so each loop now carries the generation it was started for and stops as soon as it is not the current one. All three watchers had the shape and all three have the guard. metricsAttachmentForFeedback's KDoc said the snapshot is assembled on the main thread; the body says the opposite two lines below and does the opposite. The KDoc was left over from before the assembly moved off the main thread. viewpager2 is declared through the version-catalog alias that carries a version.ref (1.1.0-beta02) rather than the sibling alias hardcoded to 1.0.0. Two aliases exist for the module; picking the older one left which version wins to conflict resolution across the whole graph. Tests: full app unit suite green, two new for the retention guard -- the disagreement test fails against the maxOf it replaces. The generation guard is not test-pinned: like the CAS in ADFA-5589, the interleaving is not reproducible on demand, and a test that passes either way would say less than this sentence does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- app/build.gradle.kts | 2 +- .../activities/editor/BaseEditorActivity.kt | 9 +++-- .../androidide/utils/MemoryUsageWatcher.kt | 18 ++++++++- .../itsaky/androidide/utils/MetricsScratch.kt | 39 +++++++++++++------ .../androidide/utils/NetworkUsageWatcher.kt | 18 ++++++++- .../androidide/utils/PowerUsageWatcher.kt | 18 ++++++++- .../androidide/utils/MetricsScratchTest.kt | 20 ++++++++++ 7 files changed, 105 insertions(+), 19 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d57f254ba4..be8c074e7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -331,7 +331,7 @@ dependencies { implementation(libs.google.material) // Metrics carousel (ADFA-5487). Already on the classpath transitively; declared so the // compile-time use in MetricsCarouselAdapter does not depend on another library's graph. - implementation(libs.androidx.viewpager2) + implementation(libs.androidx.viewpager2.v110beta02) implementation(libs.google.flexbox) implementation(libs.libsu.core) 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 f516b532e4..f9cc4c470f 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 @@ -218,10 +218,11 @@ abstract class BaseEditorActivity : * A report of "it got slow" arrives with no way to correlate it against anything; the session's * own samples turn that into something diagnosable. * - * Assembled on the main thread because it reads the watchers' buffers, then written off it: the - * file is up to a megabyte and it is gzipped on the way out. Returns null when nothing has been - * sampled, so feedback sent from a freshly started IDE carries no empty attachment -- the writer - * would happily produce a header-only file, and sending one is the caller's decision, not its. + * Assembled and written off the main thread: MetricsSnapshotAssembler is @AnyThread and takes + * each watcher's own history lock, and the file is up to a megabyte and gzipped on the way out. + * Returns null when nothing has been sampled, so feedback sent from a freshly started IDE + * carries no empty attachment -- the writer would happily produce a header-only file, and + * sending one is the caller's decision, not its. */ private suspend fun metricsAttachmentForFeedback(): File? { // Off the main thread. MetricsSnapshotAssembler is @AnyThread precisely because a crash 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 05327f8965..56c6386aff 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -38,6 +38,7 @@ import org.slf4j.LoggerFactory import java.io.File import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.CoroutineContext /** @@ -84,6 +85,19 @@ class MemoryUsageWatcher /** The running sampling loop, so [stopWatching] can actually stop it. */ private var samplingJob: Job? = null + + /** + * Which sampling loop is the current one. + * + * `samplingJob` is assigned only after `launch` returns, so a stop landing in that gap + * cancels whatever the field held rather than the loop just started, and a later start can + * overwrite the field with a job nothing then cancels -- leaving two loops appending to the + * same buffers, at twice the sample rate, out of step with the row timestamps. Cancelling + * more carefully cannot fix that; the assignments themselves can land out of order. So each + * loop carries the generation it was started for and stops as soon as it is not the current + * one, whichever assignment won. + */ + private val samplingGeneration = AtomicInteger(0) private val memoryUsage = ConcurrentHashMap() /** @@ -163,9 +177,10 @@ class MemoryUsageWatcher return } + val generation = samplingGeneration.incrementAndGet() samplingJob = coroutineScope.launch { - while (isWatching) { + while (isWatching && samplingGeneration.get() == generation) { // 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. @@ -467,6 +482,7 @@ class MemoryUsageWatcher // 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. + samplingGeneration.incrementAndGet() samplingJob?.cancel() samplingJob = null } diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt index 2e07621804..089aade934 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt @@ -84,17 +84,7 @@ class MetricsScratch( * which is what it did before this existed. */ fun install( - // The largest of the three retentions, not the memory watcher's alone. These arrays are - // handed to all three watchers, and ShiftedLongArray.copyInto require()s an exact size - // match -- so if the three constants ever stop agreeing, the throw lands inside - // MetricsCrashAttachment.writeSnapshot, where runCatching swallows it and every crash - // report silently loses its metrics, which is the failure this class exists to prevent. - entries: Int = - maxOf( - MemoryUsageWatcher.MAX_USAGE_ENTRIES, - NetworkUsageWatcher.MAX_USAGE_ENTRIES, - PowerUsageWatcher.MAX_USAGE_ENTRIES, - ), + entries: Int = sharedRetention(), memorySeries: Int = MetricsCsv.MEMORY_COLUMNS.size, ) { if (instance != null) { @@ -103,6 +93,33 @@ class MetricsScratch( instance = runCatching { MetricsScratch(entries, memorySeries) }.getOrNull() } + /** + * The one retention all three watchers keep, or a throw naming the ones that disagree. + * + * One buffer size is handed to all three, and `ShiftedLongArray.copyInto` require()s an + * *exact* match -- so `maxOf` of the three was no protection at all: it picks a size two of + * them would reject the moment they stopped agreeing. That throw lands inside + * `MetricsCrashAttachment`'s runCatching, where it is swallowed, and every crash report and + * feedback send silently loses its metrics -- the failure this class exists to prevent. + * + * Failing here instead makes divergence a loud startup failure with the numbers in the + * message, not a quiet hole in diagnostics nobody notices until they need one. The + * alternative, sizing a destination per watcher, is the right answer if these ever + * legitimately differ; today they are one number and this says so. + */ + @VisibleForTesting + internal fun sharedRetention( + memory: Int = MemoryUsageWatcher.MAX_USAGE_ENTRIES, + network: Int = NetworkUsageWatcher.MAX_USAGE_ENTRIES, + power: Int = PowerUsageWatcher.MAX_USAGE_ENTRIES, + ): Int { + require(memory == network && network == power) { + "The watchers must retain the same number of samples to share one scratch buffer, " + + "but memory=$memory, network=$network, power=$power" + } + return memory + } + @VisibleForTesting internal fun resetForTesting() { instance = null 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 6a01be91f0..bf597315a1 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.CoroutineContext /** @@ -83,6 +84,19 @@ class NetworkUsageWatcher /** The running sampling loop, so [stopWatching] can actually stop it. */ private var samplingJob: Job? = null + /** + * Which sampling loop is the current one. + * + * `samplingJob` is assigned only after `launch` returns, so a stop landing in that gap + * cancels whatever the field held rather than the loop just started, and a later start can + * overwrite the field with a job nothing then cancels -- leaving two loops appending to the + * same buffers, at twice the sample rate, out of step with the row timestamps. Cancelling + * more carefully cannot fix that; the assignments themselves can land out of order. So each + * loop carries the generation it was started for and stops as soon as it is not the current + * one, whichever assignment won. + */ + private val samplingGeneration = AtomicInteger(0) + /** * Milliseconds between samples. Changing it clears the history, for the reason given on * [MemoryUsageWatcher.updateInterval]. @@ -201,9 +215,10 @@ class NetworkUsageWatcher return } + val generation = samplingGeneration.incrementAndGet() samplingJob = coroutineScope.launch { - while (isWatching) { + while (isWatching && samplingGeneration.get() == generation) { // 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. @@ -255,6 +270,7 @@ class NetworkUsageWatcher lastRx = null lastTx = null } + samplingGeneration.incrementAndGet() samplingJob?.cancel() samplingJob = null } diff --git a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt index d5d01d974e..df1f9758af 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.CoroutineContext /** @@ -73,6 +74,19 @@ class PowerUsageWatcher /** The running sampling loop, so [stopWatching] can actually stop it. */ private var samplingJob: Job? = null + /** + * Which sampling loop is the current one. + * + * `samplingJob` is assigned only after `launch` returns, so a stop landing in that gap + * cancels whatever the field held rather than the loop just started, and a later start can + * overwrite the field with a job nothing then cancels -- leaving two loops appending to the + * same buffers, at twice the sample rate, out of step with the row timestamps. Cancelling + * more carefully cannot fix that; the assignments themselves can land out of order. So each + * loop carries the generation it was started for and stops as soon as it is not the current + * one, whichever assignment won. + */ + private val samplingGeneration = AtomicInteger(0) + /** Guards the ring buffers: the sampler writes them, the UI thread snapshots them. */ private val historyLock = Any() @@ -197,9 +211,10 @@ class PowerUsageWatcher return } + val generation = samplingGeneration.incrementAndGet() samplingJob = coroutineScope.launch { - while (isWatching) { + while (isWatching && samplingGeneration.get() == generation) { runCatching { sampleOnce() @@ -223,6 +238,7 @@ class PowerUsageWatcher fun stopWatching() { watching.set(false) + samplingGeneration.incrementAndGet() samplingJob?.cancel() samplingJob = null } diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt index 9bbbed0a13..c6d631860d 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.utils import com.google.common.truth.Truth.assertThat import org.junit.After +import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @@ -89,4 +90,23 @@ class MetricsScratchTest { assertThat(MetricsScratch.instance!!.entries).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) assertThat(MetricsScratch.instance!!.memoryValues).hasSize(MetricsCsv.MEMORY_COLUMNS.size) } + + @Test + fun `the shared retention is the one all three watchers keep`() { + assertThat(MetricsScratch.sharedRetention(memory = 3600, network = 3600, power = 3600)).isEqualTo(3600) + } + + @Test + fun `retentions that disagree fail loudly, naming them`() { + // maxOf was no guard: one size is handed to all three and copyInto require()s an exact match, + // so the two smaller watchers would throw inside MetricsCrashAttachment's runCatching -- and + // every crash report would quietly lose its metrics. + val thrown = + assertThrows(IllegalArgumentException::class.java) { + MetricsScratch.sharedRetention(memory = 3600, network = 1800, power = 3600) + } + + assertThat(thrown).hasMessageThat().contains("memory=3600") + assertThat(thrown).hasMessageThat().contains("network=1800") + } } From a07df0a9d3a8e578d8012629992e3169ddfde78b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 16:35:53 -0700 Subject: [PATCH 125/128] ADFA-5530: clear the next arrow of the resize grip, and drop the legend dash Undocked, the next arrow and the floating frame's resize grip share the bottom-right corner. The grip is a 28dp touch target, so the two sat close enough to read as one control and to invite a mis-hit on the one gesture that resizes the window. The arrow moves in by the grip's own width. Set from the dockable content rather than the layout because docked there is no grip and no reason to give up the space. Legend entries lose the dash: "IDE 561.89MB", not "IDE - 561.89MB". One format string covers all three pages, so memory, network and power all follow. It has no translations to update. Tests: full app unit suite green. Three label assertions in MemoryUsageChartRendererTest carried the dash and now do not. Not visually confirmed on device: the phone was wiped for the combined build and is back at first-run onboarding, so reaching an undocked carousel means re-provisioning the SDK first. 28dp is the grip's measured touch target, not an eyeballed offset, but "slightly left" is a judgment call and worth a look before it lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../floating/MetricsCarouselDockableContent.kt | 13 +++++++++++++ app/src/main/res/values/dimens.xml | 3 +++ .../androidide/ui/MemoryUsageChartRendererTest.kt | 6 +++--- resources/src/main/res/values/strings.xml | 2 +- 4 files changed, 20 insertions(+), 4 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 225ad0a398..85d8ebd386 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 @@ -22,10 +22,12 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager +import androidx.core.view.updateLayoutParams import com.itsaky.androidide.databinding.LayoutMemUsageBinding import com.itsaky.androidide.floating.model.ChromeControl import com.itsaky.androidide.floating.model.DockableContent import com.itsaky.androidide.floating.window.FloatingWindowHost +import com.itsaky.androidide.resources.R import com.itsaky.androidide.ui.MetricsCarouselController /** @@ -76,6 +78,17 @@ class MetricsCarouselDockableContent( ViewGroup.LayoutParams.MATCH_PARENT, ) + // The next arrow shares the bottom-right corner with the frame's resize grip, whose touch + // target is 28dp. Undocked they sat close enough to look like one control and to invite a + // mis-hit; the arrow moves in by the grip's own width. Docked there is no grip, so this is + // set here rather than in the layout. + binding.metricsNext.updateLayoutParams { + marginEnd = + context.resources.getDimensionPixelSize( + com.itsaky.androidide.R.dimen.metrics_carousel_undocked_arrow_margin_end, + ) + } + // 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 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 8ac6c22353..70c48d7d9b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -13,6 +13,9 @@ 10dp 48dp 12dp + + 28dp 28dp 28dp 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 20c57604df..a68214fdc7 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -110,7 +110,7 @@ class MemoryUsageChartRendererTest { assertThat(dataset.entries.map { it.y }).doesNotContain(0f) assertThat(dataset.entries.first().y).isEqualTo(100f) assertThat(dataset.entries.last().y).isEqualTo((100 + MemoryUsageWatcher.MAX_USAGE_ENTRIES - 1).toFloat()) - assertThat(dataset.label).isEqualTo("IDE - %.2fMB".format(dataset.entries.last().y)) + assertThat(dataset.label).isEqualTo("IDE %.2fMB".format(dataset.entries.last().y)) } @Test @@ -154,7 +154,7 @@ class MemoryUsageChartRendererTest { ) assertThat(chart.data.dataSetCount).isEqualTo(2) - assertThat(datasetFor(chart, 1).label).startsWith("Gradle Tooling - ") + assertThat(datasetFor(chart, 1).label).startsWith("Gradle Tooling ") assertThat(datasetFor(chart, 1).entries.first().y).isEqualTo(300f) } @@ -197,7 +197,7 @@ class MemoryUsageChartRendererTest { ) assertThat(chart.data.dataSetCount).isEqualTo(1) - assertThat(datasetFor(chart, 0).label).startsWith("Gradle Tooling - ") + assertThat(datasetFor(chart, 0).label).startsWith("Gradle Tooling ") assertThat(datasetFor(chart, 0).entries.first().y).isEqualTo(700f) } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 25db56b5a7..cc255055f8 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1710,7 +1710,7 @@ now n/a - %1$s - %2$s + %1$s %2$s From 7be4cd1962bafce0bbd98f41a36da6390bc7043d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 16:46:23 -0700 Subject: [PATCH 126/128] ADFA-5530: correct a milliamp power reading instead of discarding it On a device whose kernel reports CURRENT_NOW in milliamps -- documented as microamps, and not honoured by every OEM -- the Power series read "n/a" on every sample for the life of the session, while temperature, which has no such filter, plotted normally. MIN_PLAUSIBLE_MICROWATTS was added to catch exactly that misreport, and it does catch it: the product comes out a thousand times small and lands under the floor. It then threw the sample away. Identifying a unit mismatch and answering "no data" is strictly worse than applying the conversion the mismatch implies. Measured on a Galaxy Note 20 Ultra with the editor open after a build: CURRENT_NOW 318 at 3807mV. Taken at face value that is 1,210 microwatts -- 1.2mW for a phone running an IDE -- so it fell under the floor and was dropped. Corrected it is 1.21W, which is what the chart should have been showing all along. A single sample still cannot distinguish a milliamp kernel from a genuinely tiny draw, so this remains a judgement rather than a detector. It is the same judgement the floor already made, now acted on rather than used to drop the reading: the only device drawing single-digit milliwatts is one in deep doze, and a dozing device is not running the build this chart exists to measure. The ceiling still rejects, because that mismatch runs the other way and scaling up would widen it. The correction recomputes from the scaled current rather than scaling the product, which has already been through an integer division and would round to the nearest milliwatt. Tests: full app unit suite green. Three of the seven envelope tests fail against the discard-the-band behaviour, including one built from the device's own reading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../androidide/utils/DevicePowerSource.kt | 51 +++++++++++++------ .../utils/DevicePowerEnvelopeTest.kt | 28 ++++++++-- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt index 62792d91ad..872c1e2b63 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -143,24 +143,42 @@ class DevicePowerSource( val microWatts = microAmps.toLong() * milliVolts.toLong() / NANOWATTS_PER_MICROWATT // The sign of CURRENT_NOW is documented and not always honoured; the unit is the same - // story. Several OEM kernels report milliamps, which divides the reading by a thousand: - // a five-watt build then reads as five milliwatts, with no error path at all. + // story. Several OEM kernels report milliamps, which divides the reading by a thousand: a + // five-watt build then reads as five milliwatts, with no error path at all. // - // A single sample cannot tell that apart from a genuinely tiny draw -- both are 5,000 - // microwatts -- so this is a plausibility floor, not a detector. It is set to reject the - // range a misreported build actually lands in: a real draw of 0.01W to 100W misreported as - // milliwatts gives 10 to 100,000 microwatts, and a phone running a Gradle build draws - // watts, not milliwatts. The residual gaps are stated rather than papered over: a real - // draw below MIN_PLAUSIBLE_MICROWATTS is rejected as implausible, and a misreport of a - // draw above 10W would pass -- neither happens on a phone. + // Corrected, not discarded. Rejecting that band identified the misreport and then threw the + // sample away, so on a device with such a kernel every sample was UNAVAILABLE and the power + // series read "n/a" for the life of the session -- while temperature, which has no such + // filter, plotted normally. Measured on a Galaxy Note 20 Ultra: CURRENT_NOW 318 at 3807mV + // gives 1,210 microwatts, which is 1.21W of a phone with an IDE open reported as 1.2mW. // - // The earlier comment here claimed the envelope caught the milliamp case at a 1,000 - // microwatt floor. It did not: five watts misreported is 5,000, comfortably inside it. + // A single sample still cannot distinguish a milliamp kernel from a genuinely tiny draw, so + // this remains a judgement rather than a detector. It is the same judgement the floor + // already made, now acted on instead of used to drop the reading: below 10mW a non-zero + // draw is far likelier to be a unit mismatch than a real measurement, because the only + // device drawing single-digit milliwatts is one in deep doze -- and a dozing device is not + // running the build this chart exists to measure. val magnitude = abs(microWatts) - return if (magnitude == 0L || magnitude in MIN_PLAUSIBLE_MICROWATTS..MAX_PLAUSIBLE_MICROWATTS) { - microWatts - } else { - PowerUsageWatcher.UNAVAILABLE + return when { + magnitude == 0L -> { + microWatts + } + + magnitude in MIN_PLAUSIBLE_MICROWATTS..MAX_PLAUSIBLE_MICROWATTS -> { + microWatts + } + + // Recomputed from the scaled current rather than by scaling the product: the product + // has already been through an integer division, so multiplying it back up would round + // to the nearest milliwatt. + magnitude < MIN_PLAUSIBLE_MICROWATTS -> { + microAmps.toLong() * MICROAMPS_PER_MILLIAMP * milliVolts.toLong() / NANOWATTS_PER_MICROWATT + } + + // Above the ceiling is the mismatch the other way, and scaling up would only widen it. + else -> { + PowerUsageWatcher.UNAVAILABLE + } } } @@ -234,5 +252,8 @@ class DevicePowerSource( /** A hundred watts: no phone draws this, so that is a unit mismatch the other way. */ const val MAX_PLAUSIBLE_MICROWATTS = 100_000_000L + + /** What a milliamp-reporting kernel's reading must be multiplied by to become microamps. */ + const val MICROAMPS_PER_MILLIAMP = 1_000L } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.kt b/app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.kt index 04ec17ed5d..396bbc2147 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.kt @@ -38,11 +38,31 @@ class DevicePowerEnvelopeTest { private val source = DevicePowerSource(ApplicationProvider.getApplicationContext()) @Test - fun `a five-watt build misreported in milliamps is rejected`() { - // The case the old floor let through. 5W at 4V is 1.25A; a milliamp kernel reports 1250 - // where microamps would say 1_250_000, so the product comes out a thousand times small. + fun `a five-watt build misreported in milliamps is corrected, not dropped`() { + // 5W at 4V is 1.25A; a milliamp kernel reports 1250 where microamps would say 1_250_000, + // so the product comes out a thousand times small. This used to answer UNAVAILABLE, which + // identified the misreport and then discarded the sample. assertThat(source.microWattsOrUnavailable(microAmps = 1_250, milliVolts = 4_000)) - .isEqualTo(PowerUsageWatcher.UNAVAILABLE) + .isEqualTo(5_000_000L) + } + + @Test + fun `the Galaxy Note 20 Ultra's own reading becomes a number rather than n slash a`() { + // Measured on the device: CURRENT_NOW 318 at 3807mV, with the editor open after a build. + // Taken at face value that is 1,210 microwatts -- 1.2mW for a phone running an IDE -- and + // being below the floor it was dropped, so the Power series read "n/a" on every sample for + // the life of the session while temperature plotted normally. + val microWatts = source.microWattsOrUnavailable(microAmps = 318, milliVolts = 3_807) + + assertThat(microWatts).isNotEqualTo(PowerUsageWatcher.UNAVAILABLE) + assertThat(microWatts).isEqualTo(1_210_626L) + } + + @Test + fun `a discharging misreport keeps its sign through the correction`() { + // The same device discharging: CURRENT_NOW -496 at 3731mV, i.e. 1.85W leaving the battery. + assertThat(source.microWattsOrUnavailable(microAmps = -496, milliVolts = 3_731)) + .isEqualTo(-1_850_576L) } @Test From 7ada796175a24031bd4bb05a1ad0eae0565b0d86 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 16:51:33 -0700 Subject: [PATCH 127/128] ADFA-5530: say "single-tap" in the undocked carousel message "Tap to bring them back" now reads "Single-tap to bring them back". The strip's empty area also carries a two-finger gesture, so naming the number of fingers removes the ambiguity. No translations to update. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- resources/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index cc255055f8..bf47ef4a41 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1685,7 +1685,7 @@ Memory usage Network traffic chart Network traffic - Metrics are in a floating window.\nTap to bring them back. + Metrics are in a floating window.\nSingle-tap to bring them back. Metrics Sampling rate Every %1$s From cc08993176985064eb419a54ad31fe68af9ede85 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 9 Sep 2026 18:35:56 -0700 Subject: [PATCH 128/128] ADFA-5530: don't sample until the carousel is first shown The strip lives behind SwipeRevealLayout and is closed on launch, so a user who never drags the app bar down never sees it -- and paid for it anyway: three loops reading /proc, TrafficStats and the battery every tick, three listener chains, and three renderers redrawing a chart underneath an opaque card. Measured on a Pixel 6 Pro, editor idle, project loaded, carousel not revealed, using ADFA-5199's own protocol (/proc//stat fields 14+15 over 10s, and two /proc//task/*/stat snapshots 8s apart): before 197 ticks/10s main 77, MemoryUsageWatc 82, PowerUsageWatch 19, NetworkUsageWat 14 after 132 ticks/10s main 124, no watcher threads at all ADFA-5199 measured the single-chart version of this at 188 ticks/10s on a OnePlus and proposed pausing when the widget is not visible. That was never done, and the carousel tripled the watcher count in the meantime. Started late rather than paused and resumed: a pause would leave a hole in the middle of the buffers, and the renderer still positions samples by index rather than by their recorded time (ADFA-5660). A later start shortens the history without breaking that assumption. Undocking is covered too -- the floating window shows the carousel without the strip ever being dragged open -- and onResume now restarts only what was already running. Verified on device: no MemoryUsageWatc, PowerUsageWatch or NetworkUsageWat thread exists until the strip is revealed, and all three appear on the first reveal. Known artifact, and the reason this is worth a look before it lands: the buffers are zero-filled, so a chart revealed 30s into a session draws a flat zero line for the part of the window before sampling began. It reads as "the IDE used no memory", not as "not measured". watchedSinceMillis already records the truth and the CSV exporter already uses it; the chart does not. Filed rather than fixed here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../activities/editor/BaseEditorActivity.kt | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 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 f9cc4c470f..d71e11f3c5 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 @@ -271,6 +271,12 @@ abstract class BaseEditorActivity : var isDestroying = false protected set + /** + * Whether the metrics samplers have been started this session. See + * [startMetricsSamplingIfNeeded]; nothing samples until the carousel is first shown. + */ + private var metricsSamplingStarted = false + /** * Editor activity's [CoroutineScope] for executing tasks in the background. */ @@ -1059,7 +1065,40 @@ abstract class BaseEditorActivity : } } + /** + * Starts the three samplers, once per session, when the carousel is first shown. + * + * The strip lives behind [SwipeRevealLayout] and is closed on launch, so a user who never drags + * the app bar down never sees it -- and used to pay for it anyway: three loops reading /proc, + * TrafficStats and the battery every tick, three listener chains, and three renderers redrawing + * a chart underneath an opaque card. ADFA-5199 measured the single-chart version of this at + * ~19% of a core with the editor idle; there are three watchers now. + * + * Starting late rather than pausing and resuming, because a pause would leave a hole in the + * middle of the buffers and the renderer still positions samples by index rather than by their + * recorded time (ADFA-5660). A later start shortens the history without breaking that + * assumption, which is what `watchedSinceMillis` already exists to describe. + */ + private fun startMetricsSamplingIfNeeded() { + metricsSamplingStarted = true + if (!memoryUsageWatcher.isWatching) { + memoryUsageWatcher.startWatching() + } + // isSupported too: where TrafficStats has no per-UID counters the loop clears `watching` + // and breaks, so this gate alone relaunched a coroutine that sampled once, repainted a + // permanently-zero chart and died -- on every single resume, for the life of the session. + if (!networkUsageWatcher.isWatching && networkUsageWatcher.isSupported) { + networkUsageWatcher.startWatching() + } + if (!powerUsageWatcher.isWatching) { + powerUsageWatcher.startWatching() + } + } + private fun onSwipeRevealDragProgress(progress: Float) { + if (progress > 0f) { + startMetricsSamplingIfNeeded() + } _binding?.apply { contentCard.progress = progress val insetsTop = systemBarInsets?.top ?: 0 @@ -1205,17 +1244,10 @@ abstract class BaseEditorActivity : if (!isMetricsCarouselUndocked()) { _binding?.let { metricsCarousel.bind(it.memUsageView) } } - if (!memoryUsageWatcher.isWatching) { - memoryUsageWatcher.startWatching() - } - // isSupported too: where TrafficStats has no per-UID counters the loop clears `watching` - // and breaks, so this gate alone relaunched a coroutine that sampled once, repainted a - // permanently-zero chart and died -- on every single resume, for the life of the session. - if (!networkUsageWatcher.isWatching && networkUsageWatcher.isSupported) { - networkUsageWatcher.startWatching() - } - if (!powerUsageWatcher.isWatching) { - powerUsageWatcher.startWatching() + // Only what was already sampling, and the floating case, which shows the carousel without + // the strip ever being dragged open. Everything else waits for the first reveal. + if (metricsSamplingStarted || isMetricsCarouselUndocked()) { + startMetricsSamplingIfNeeded() } if (!isMetricsCarouselUndocked()) {