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 ce07d23658..24515c4782 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 = Companion::getMemUsageLineColorFor, annotations = metricsViewModel.annotations, ) @@ -569,6 +572,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() @@ -1112,6 +1120,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/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt index 7e9d48bac5..43d57e117a 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -110,8 +110,7 @@ class MemoryUsageChartRenderer( } } - applyAxisRange(chart, processes) - setData(chart, datasets) + setData(chart, datasets) { applyAxisRange(it, processes) } } /** @@ -190,10 +189,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/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt index 51dec3e2b0..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,28 +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 + 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 @@ -56,80 +74,56 @@ sealed interface MetricsPage { */ class MetricsCarouselAdapter( private val pages: List, - private val memoryChartRenderer: MemoryUsageChartRenderer, - private val networkChartRenderer: NetworkUsageChartRenderer, ) : RecyclerView.Adapter() { - sealed class PageViewHolder( - view: View, - ) : RecyclerView.ViewHolder(view) { - class MemoryChart( - val chart: SafeLineChart, - ) : PageViewHolder(chart) - - class NetworkChart( - 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 - } + /** + * 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, - ) - } - - 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) - } - } + 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) - } - } - - private companion object { - const val VIEW_TYPE_MEMORY_CHART = 0 - const val VIEW_TYPE_NETWORK_CHART = 1 + 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 822e29916e..4a6523fa1c 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,7 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Toast import androidx.annotation.UiThread +import androidx.core.view.isVisible import androidx.core.widget.ImageViewCompat import androidx.viewpager2.widget.ViewPager2 import com.itsaky.androidide.R @@ -42,6 +43,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 import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -68,6 +70,7 @@ import org.slf4j.LoggerFactory class MetricsCarouselController( private val memoryUsageWatcher: MemoryUsageWatcher, private val networkUsageWatcher: NetworkUsageWatcher, + private val powerUsageWatcher: PowerUsageWatcher, lineColorFor: (MemoryUsageWatcher.ProcessMemoryInfo) -> Int, private val annotations: MetricsAnnotationStore? = null, ) { @@ -86,14 +89,48 @@ class MetricsCarouselController( sampleInterval = { networkUsageWatcher.updateInterval }, ) - private val pages = + private val powerRenderer = + PowerUsageChartRenderer( + usageProvider = { powerUsageWatcher.getUsage() }, + batteryProvider = { powerUsageWatcher.latestBattery }, + annotations = annotations, + 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. - MetricsPage.MemoryChart(title = string.metrics_title_memory), - MetricsPage.NetworkChart(title = string.metrics_title_network), + 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) + updateBatteryReadout() + } + private val memoryListener = MemoryUsageWatcher.MemoryUsageListener { memoryUsage -> memoryRenderer.onUsagesChanged(memoryUsage) @@ -154,7 +191,7 @@ class MetricsCarouselController( this.binding = binding - binding.metricsPager.adapter = MetricsCarouselAdapter(pages, memoryRenderer, networkRenderer) + binding.metricsPager.adapter = MetricsCarouselAdapter(pages) // 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 @@ -180,9 +217,11 @@ class MetricsCarouselController( currentPage = position 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) } @@ -198,6 +237,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. @@ -212,6 +254,7 @@ class MetricsCarouselController( memoryUsageWatcher.listener = memoryListener networkUsageWatcher.listener = networkListener + powerUsageWatcher.listener = powerListener } /** @@ -226,9 +269,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) @@ -238,6 +285,7 @@ class MetricsCarouselController( binding?.metricsPager?.adapter = null memoryRenderer.detach() networkRenderer.detach() + powerRenderer.detach() binding = null } @@ -288,6 +336,43 @@ 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 renderer = currentRenderer() + val readout = renderer?.readout() + + binding.metricsBattery.text = readout.orEmpty() + // 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. + val reserved = + if (readout == null) { + 0f + } else { + binding.metricsBattery.lineHeight + binding.metricsBattery.paddingTop.toFloat() + } + renderer?.reserveTopSpace(reserved) + } + + /** + * 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 pages.getOrNull(binding.metricsPager.currentItem)?.renderer + } + /** * 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). @@ -373,6 +458,7 @@ class MetricsCarouselController( ) 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. @@ -407,11 +493,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 - } + val renderer = page.renderer val label = context.getString(page.title) val bitmap = renderer.snapshot() @@ -492,6 +574,7 @@ class MetricsCarouselController( fun refresh() { memoryRenderer.rebuild() networkRenderer.rebuild() + powerRenderer.rebuild() } /** 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/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index 1d2cbdf79a..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,12 +76,56 @@ 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. */ 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. + * + * 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 + if (pixels == reservedTopPixels) { + return + } + reservedTopPixels = pixels + chart.setExtraTopOffset(pixels / chart.resources.displayMetrics.density) + // 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() + } + /** * Attaches [chart], applies configuration, and renders the full current history. */ @@ -173,6 +217,11 @@ abstract class MetricsChartRenderer( // The right axis carries the labels. The left is unused by every page but the one with // two units, which enables it in its own configure(). 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) @@ -324,14 +373,13 @@ 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) 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 @@ -340,15 +388,38 @@ abstract class MetricsChartRenderer( xAxis.textColor = textColor data.setValueTextColor(textColor) + 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() } + /** + * 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). * @@ -356,6 +427,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 @@ -389,15 +464,29 @@ 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. */ - 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() @@ -420,5 +509,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/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 new file mode 100644 index 0000000000..042e2f19e6 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -0,0 +1,440 @@ +/* + * 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 kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToLong + +/** + * Renders [PowerUsageWatcher] samples: battery temperature against power draw (ADFA-5499). + * + * 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, + private val batteryProvider: () -> PowerUsageWatcher.BatteryState, + annotations: MetricsAnnotationStore? = null, + sampleIntervalMillis: () -> Long = { PowerUsageWatcher.DEFAULT_UPDATE_INTERVAL }, +) : MetricsChartRenderer( + sampleIntervalMillis = sampleIntervalMillis, + annotations = annotations, + ) { + @UiThread + 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 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 = ::microWattsToWatts, + ), + ) + + setData(chart, datasets) { applyAxisRanges(it, usage) } + applyThermalShading(chart, usage) + } + + /** + * 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) { + 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, + ) + + applyThermalShading(chart, usage) + redraw(chart) { applyAxisRanges(it, usage) } + } + + /** 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() + } + + /** + * 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) { + 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. + * + * 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(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. + * + * 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(level: Int): Int? { + val hue = + when (level) { + 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 + } + + return ColorUtils.setAlphaComponent(hue, SHADE_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 - %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) + } + } + + 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 + + // 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( + value: Float, + axis: AxisBase?, + ): 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 = "%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 + } + + /** + * 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 + override fun readout(): 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) + + /** 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 + + /** + * 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 TEMPERATURE_INDEX = 0 + const val POWER_INDEX = 1 + + 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 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 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/ui/SafeLineChart.kt b/app/src/main/java/com/itsaky/androidide/ui/SafeLineChart.kt index 3eda88b076..e9121ff366 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,82 @@ 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. + */ + @Volatile + 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, + ) + + /** + * 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) { + val spans = backgroundSpans + if (spans.isEmpty()) { + return + } + + val content = viewPortHandler.contentRect + val transformer = getTransformer(YAxis.AxisDependency.LEFT) ?: return + + // 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) + if (clippedRight <= clippedLeft) { + return@forEach + } + + paint.color = span.color + canvas.drawRect(clippedLeft, content.top, clippedRight, content.bottom, paint) + } + } + 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..512fe62b49 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -0,0 +1,204 @@ +/* + * 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.annotation.VisibleForTesting +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 +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. + * + * 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 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) + 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 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 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 + } else { + PowerUsageWatcher.UNAVAILABLE + } + } + + /** + * 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)) { + // 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 + } + } + + 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) + // 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) { + -1 + } else { + level * 100 / scale + } + + return BatteryState( + levelPercent = percent, + isCharging = plugged != 0, + ) + } + + private companion object { + /** Microamps times millivolts gives nanowatts; this scales the product to microwatts. */ + const val NANOWATTS_PER_MICROWATT = 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/MetricsAnnotationStore.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt index c684ed0c81..97e7d8e91e 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/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/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index 10370b00fe..784f13d286 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -140,7 +140,7 @@ class NetworkUsageWatcher */ fun getUsage(): NetworkUsage = synchronized(historyLock) { - NetworkUsage(received.snapshot(), transmitted.snapshot()) + NetworkUsage(received.toLongArray(), transmitted.toLongArray()) } /** @@ -338,8 +338,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 new file mode 100644 index 0000000000..94a9577249 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -0,0 +1,303 @@ +/* + * 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) + + /** + * 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 + + /** Guards the ring buffers: the sampler writes them, the UI thread snapshots them. */ + private val historyLock = Any() + + // 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]. + * + * 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) { UNAVAILABLE } + + /** + * 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) + if (field == safe) { + return + } + field = safe + 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. */ + @Volatile + 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.toLongArray(), power.toLongArray(), thermal.toLongArray()) + } + + fun clearHistory() { + synchronized(historyLock) { + temperature.clear(UNAVAILABLE) + power.clear(UNAVAILABLE) + thermal.clear(UNAVAILABLE) + } + } + + 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 + } + + 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() { + closed.set(true) + 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]. 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. + */ + 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) + } + } 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/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_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/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/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/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/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 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 70f8ee588a..5d811d3435 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt @@ -28,6 +28,7 @@ import com.itsaky.androidide.R import com.itsaky.androidide.databinding.LayoutMemUsageBinding import com.itsaky.androidide.utils.MemoryUsageWatcher 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 @@ -57,6 +58,17 @@ class MetricsCarouselRebindTest { 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 }, ).also(controllers::add) 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. 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..0ee4a56e20 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -0,0 +1,452 @@ +/* + * 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.View +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 `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( + 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.001f).of(6.358064f) + } + + @Test + fun `power is plotted as a magnitude, whichever way the current is signed`() { + val (_, chart) = + rendererFor( + usage( + temperature = longArrayOf(30_000L, 30_000L), + // 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), + ), + ) + + val ys = dataset(chart, 1).entries.map { it.y } + + assertThat(ys).containsExactly(2f, 3f).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 `each throttling level gets its own hue, green through red`() { + val (_, chart) = + rendererFor( + usage( + 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), + ), + ) + + // 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 + 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.readout()).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.readout()).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.readout()).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 slots`() { + // A real reading only in the newest slots; the rest of the buffer has never been written. + // 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 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 })) + 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 })) + 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) = + 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 + + /** 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. */ + val EXPECTED_HUES = + listOf(0xFF4CAF50, 0xFF00BCD4, 0xFFFDD835, 0xFFFB8C00, 0xFFB7410E, 0xFFE53935) + .map { it.toInt() } + } +} 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 + } +} 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/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) + } } 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..6032bcbd8e --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt @@ -0,0 +1,189 @@ +/* + * 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 `the sign of the current is recorded, not interpreted`() { + val fixture = Fixture(listOf(reading(power = -3_000_000L))) + + fixture.sample(1) + + // 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() + .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/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) } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 587d77ab9b..203486d159 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1689,6 +1689,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