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 5205da894e..5b046558f8 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -71,8 +71,11 @@ import com.blankj.utilcode.util.SizeUtils import com.blankj.utilcode.util.ThreadUtils import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.components.Legend +import com.github.mikephil.charting.components.LegendEntry import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.interfaces.datasets.ILineDataSet import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED @@ -165,6 +168,20 @@ import kotlin.math.abs import kotlin.math.roundToInt import kotlin.math.roundToLong +/** + * Build one explicit [LegendEntry] per dataset (label + line color) for the memory-usage chart. + * + * Setting these via `chart.legend.setCustom(...)` keeps the legend's entry list exactly in sync + * with the current datasets, so MPAndroidChart's `LegendRenderer` never lazily recomputes a stale + * list when the dataset count changes (Gradle daemons connecting) — the desync behind the + * `IndexOutOfBoundsException` in ADFA-4327. Top-level + `internal` so it is unit-testable without an + * Activity instance. + */ +internal fun buildMemUsageLegendEntries(datasets: List): List = + datasets.map { dataset -> + LegendEntry(dataset.label, Legend.LegendForm.DEFAULT, Float.NaN, Float.NaN, null, dataset.color) + } + /** * Base class for EditorActivity which handles most of the view related things. * @@ -284,11 +301,16 @@ abstract class BaseEditorActivity : } if (dataChanged) { - _binding?.memUsageView?.chart?.apply { - data.notifyDataChanged() - notifyDataSetChanged() - invalidate() - } + runCatching { + _binding?.memUsageView?.chart?.apply { + // Refresh explicit legend entries so the live " - " labels stay + // current while keeping the entry list consistent with the datasets. + legend.setCustom(buildMemUsageLegendEntries(data.dataSets)) + data.notifyDataChanged() + notifyDataSetChanged() + invalidate() + } + }.onFailure { log.warn("Failed to update memory usage chart", it) } } } @@ -818,6 +840,10 @@ abstract class BaseEditorActivity : isDragEnabled = false description.isEnabled = false + // Legend stays enabled; its entries are set EXPLICITLY in resetMemUsageChart / + // memoryUsageListener (see buildMemUsageLegendEntries) so the upstream LegendRenderer never + // auto-recomputes a stale entry list when the dataset count changes as Gradle daemons + // connect — that desync was the IndexOutOfBoundsException in ADFA-4327. xAxis.axisLineColor = colorAccent axisRight.axisLineColor = colorAccent @@ -872,18 +898,24 @@ abstract class BaseEditorActivity : binding.memUsageView.chart.setBackgroundColor(bgColor) - binding.memUsageView.chart.apply { - data = LineData(*datasets) - axisRight.textColor = textColor - axisLeft.textColor = textColor - legend.textColor = textColor - - data.setValueTextColor(textColor) - setBackgroundColor(bgColor) - setGridBackgroundColor(bgColor) - notifyDataSetChanged() - invalidate() - } + runCatching { + binding.memUsageView.chart.apply { + data = LineData(*datasets) + axisRight.textColor = textColor + axisLeft.textColor = textColor + legend.textColor = textColor + + // Set explicit, count-consistent legend entries so the auto-legend never + // recomputes a stale list against the new dataset count (ADFA-4327 crash). + legend.setCustom(buildMemUsageLegendEntries(datasets.toList())) + + data.setValueTextColor(textColor) + setBackgroundColor(bgColor) + setGridBackgroundColor(bgColor) + notifyDataSetChanged() + invalidate() + } + }.onFailure { log.warn("Failed to reset memory usage chart", it) } } private fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLegendEntriesTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLegendEntriesTest.kt new file mode 100644 index 0000000000..82e8d957cd --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLegendEntriesTest.kt @@ -0,0 +1,92 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities.editor + +import com.github.mikephil.charting.components.Legend +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.interfaces.datasets.ILineDataSet +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [buildMemUsageLegendEntries] (ADFA-4327 / Sentry APPDEVFORALL-31). + * + * The mem-usage chart crashed in MPAndroidChart's `LegendRenderer.renderLegend` with an + * `IndexOutOfBoundsException` when the auto-computed legend desynced from the dataset count as that + * count changed at runtime (Gradle daemons connecting → `resetMemUsageChart` rebuilds the datasets). + * The fix sets the legend entries EXPLICITLY so they always match the datasets. These tests pin that + * invariant — one entry per dataset, label/color carried through, and the entry count tracking the + * dataset count across a count change (the exact condition that triggered the renderer desync). + * + * The literal draw-time IndexOutOfBoundsException is a race inside the third-party renderer and is + * verified on-device (A56 recording on the PR); this test guards the anti-desync invariant the fix + * relies on. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class MemUsageLegendEntriesTest { + + private fun dataSet(label: String, color: Int): LineDataSet = + LineDataSet(listOf(Entry(0f, 0f), Entry(1f, 1f)), label).also { it.color = color } + + @Test + fun `produces one entry per dataset with matching label and color`() { + val datasets: List = + listOf( + dataSet("Code on the Go - 12.00MB", 0xFF0000FF.toInt()), + dataSet("gradle daemon - 34.00MB", 0xFF00FF00.toInt()), + ) + + val entries = buildMemUsageLegendEntries(datasets) + + assertThat(entries).hasSize(2) + assertThat(entries.map { it.label }) + .containsExactly("Code on the Go - 12.00MB", "gradle daemon - 34.00MB") + .inOrder() + assertThat(entries.map { it.formColor }) + .containsExactly(0xFF0000FF.toInt(), 0xFF00FF00.toInt()) + .inOrder() + assertThat(entries.map { it.form }) + .containsExactly(Legend.LegendForm.DEFAULT, Legend.LegendForm.DEFAULT) + } + + @Test + fun `entry count tracks dataset count across a count change`() { + // 1 dataset (IDE only) -> 3 datasets (Gradle tooling + daemon connect). The legend entry list + // must always equal the current dataset count; that consistency is exactly what stops the + // LegendRenderer from indexing a stale entry array (the ADFA-4327 crash). + val before = buildMemUsageLegendEntries(listOf(dataSet("ide", 1))) + assertThat(before).hasSize(1) + + val after = + buildMemUsageLegendEntries( + listOf(dataSet("ide", 1), dataSet("tooling", 2), dataSet("daemon", 3)), + ) + assertThat(after).hasSize(3) + assertThat(after.map { it.label }).containsExactly("ide", "tooling", "daemon").inOrder() + } + + @Test + fun `empty datasets produce empty entries`() { + assertThat(buildMemUsageLegendEntries(emptyList())).isEmpty() + } +}