Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ec28537
feat(metrics): annotate build start, finish and failure on the charts…
davidschachterADFA Sep 6, 2026
e1a7bf5
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
c0a1c16
fix(metrics): stop annotating syncs and cancels as builds (ADFA-5509)
davidschachterADFA Sep 6, 2026
203729b
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
fa15b29
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
5b4016c
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
531c072
ADFA-5509: address review findings on the build annotations
davidschachterADFA Sep 6, 2026
963e37b
ADFA-5509: dim the end arrows rather than disabling them
davidschachterADFA Sep 6, 2026
1573eef
ADFA-5509: move the end-arrow change to ADFA-5510, where it belongs
davidschachterADFA Sep 6, 2026
26b6126
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
62f0b6c
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
f22fc16
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
1440c4a
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
55c553d
ADFA-5509: forward the cancel request, so BUILD_CANCELLED can happen
davidschachterADFA Sep 6, 2026
a434d78
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
490bc97
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
4af1c8f
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 6, 2026
64abd62
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 7, 2026
39f494c
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 7, 2026
d5c67c5
ADFA-5509: clear the build flags before the activity check
davidschachterADFA Sep 7, 2026
c2b5c00
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 7, 2026
7509593
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 7, 2026
db67fff
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 8, 2026
9be288c
Merge branch 'feature/ADFA-5510-carousel-help' into feature/ADFA-5509…
davidschachterADFA Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import com.itsaky.androidide.utils.FlashType
import com.itsaky.androidide.utils.InstallationResultHandler.onResult
import com.itsaky.androidide.utils.IntentUtils
import com.itsaky.androidide.utils.MemoryUsageWatcher
import com.itsaky.androidide.utils.MetricsAnnotationStore
import com.itsaky.androidide.utils.StringsInjectionException
import com.itsaky.androidide.utils.StringsXmlInjector
import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation
Expand Down Expand Up @@ -213,6 +214,17 @@ abstract class BaseEditorActivity :
metricsViewModel.annotations.record(label)
}

/**
* Marks a build outcome on the charts (ADFA-5509).
*
* Separate from [recordMetricsAnnotation] so a build outcome cannot be recorded as an ordinary
* task marker, which the throttle is allowed to drop -- and so a task name cannot be recorded
* as an outcome, which would give it an unthrottled marker in the error colour.
*/
fun recordBuildAnnotation(kind: MetricsAnnotationStore.Kind) {
metricsViewModel.annotations.recordBuild(kind)
}

private val fileManagerViewModel by viewModels<FileManagerViewModel>()
private var feedbackButtonManager: FeedbackButtonManager? = null
private var fullscreenManager: FullscreenManager? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import com.itsaky.androidide.tooling.events.ProgressEvent
import com.itsaky.androidide.tooling.events.configuration.ProjectConfigurationStartEvent
import com.itsaky.androidide.tooling.events.task.TaskFinishEvent
import com.itsaky.androidide.tooling.events.task.TaskStartEvent
import com.itsaky.androidide.utils.MetricsAnnotationStore
import com.itsaky.androidide.utils.flashError
import com.itsaky.androidide.utils.flashSuccess
import com.itsaky.androidide.viewmodel.BuildOutputViewModel
Expand All @@ -48,6 +49,24 @@ class EditorBuildEventListener : GradleBuildService.EventListener {
private var buildStartTimeMs: Long = System.currentTimeMillis()
private var lastOutputTimeMs: Long = SystemClock.elapsedRealtime()

/**
* Set when the user asks for the running build to stop, so [onBuildFailed] can tell a cancel
* from a real failure. Cleared as each build is prepared.
*/
@VisibleForTesting
internal var cancelRequested = false

/**
* Whether the build now running drew a "Build started" marker.
*
* The outcome callbacks used to decide for themselves, from the task list they are handed --
* a different list from the one prepareBuild sees. If those two ever disagreed the chart got
* a start with no finish, or a finish with no start, which is the one thing a pair of markers
* exists to avoid. The build that started decides, and its outcome follows.
*/
@VisibleForTesting
internal var annotatedBuild = false

private var enabled = true
private var activityReference: WeakReference<EditorHandlerActivity> = WeakReference(null)

Expand Down Expand Up @@ -80,28 +99,46 @@ class EditorBuildEventListener : GradleBuildService.EventListener {
}

override fun prepareBuild(buildInfo: BuildInfo) {
checkActivity("prepareBuild") ?: return
// Before the activity check, not after: this listener outlives any one activity, so a
// build whose outcome arrived with none attached would otherwise leave both flags set for
// the next build to inherit -- a stale cancel mislabelling a real failure, or a stale
// pairing drawing a finish for a build that never started.
cancelRequested = false
annotatedBuild = false

val act = checkActivity("prepareBuild") ?: return

// A project sync runs through the same callbacks with no tasks, so annotating every
// prepareBuild put a "Build started" marker on the chart merely for opening a project --
// and blamed the sync's own memory spike on a build the user never ran.
//
// The outcome callbacks are handed their own task list, which is not this one. Recorded
// here so the pair is decided once, by the build that started.
if (buildInfo.tasks.isNotEmpty()) {
annotatedBuild = true
act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_STARTED)
}

pluginBuildService?.setBuildInProgress(true)

val isFirstBuild = GeneralPreferences.isFirstBuild
activity
act
.setStatus(
activity.getString(if (isFirstBuild) string.preparing_first else string.preparing),
act.getString(if (isFirstBuild) string.preparing_first else string.preparing),
)

if (isFirstBuild) {
activity.showFirstBuildNotice()
act.showFirstBuildNotice()
}

resetBuildTimers()

activity.editorViewModel.isBuildInProgress = true
activity.content.bottomSheet.clearBuildOutput()
act.editorViewModel.isBuildInProgress = true
act.content.bottomSheet.clearBuildOutput()

if (buildInfo.tasks.isNotEmpty()) {
onOutput(
activity.getString(R.string.title_run_tasks) + " : " + buildInfo.tasks,
act.getString(R.string.title_run_tasks) + " : " + buildInfo.tasks,
)
}
}
Expand All @@ -114,6 +151,11 @@ class EditorBuildEventListener : GradleBuildService.EventListener {
override fun onBuildSuccessful(tasks: List<String?>) {
val act = checkActivity("onBuildSuccessful") ?: return

if (annotatedBuild) {
act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FINISHED)
}
annotatedBuild = false

pluginBuildService?.notifyBuildFinished()

analyzeCurrentFile()
Expand Down Expand Up @@ -141,6 +183,10 @@ class EditorBuildEventListener : GradleBuildService.EventListener {
lastStatusLine = ""
}

override fun onBuildCancelRequested() {
cancelRequested = true
}

override fun onProgressEvent(event: ProgressEvent) {
val act = checkActivity("onProgressEvent") ?: return

Expand Down Expand Up @@ -169,6 +215,20 @@ class EditorBuildEventListener : GradleBuildService.EventListener {
override fun onBuildFailed(tasks: List<String?>) {
val act = checkActivity("onBuildFailed") ?: return

if (annotatedBuild) {
// A build the user stopped arrives through this same callback. Marking it as a failure
// would report their own deliberate action back to them in the error colour.
act.recordBuildAnnotation(
if (cancelRequested) {
MetricsAnnotationStore.Kind.BUILD_CANCELLED
} else {
MetricsAnnotationStore.Kind.BUILD_FAILED
},
)
}
annotatedBuild = false
cancelRequested = false

analyzeCurrentFile()
GeneralPreferences.isFirstBuild = false
act.editorViewModel.isBuildInProgress = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.text.TextUtils
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import com.itsaky.androidide.BuildConfig
import com.itsaky.androidide.analytics.IAnalyticsManager
Expand Down Expand Up @@ -174,6 +175,38 @@ class GradleBuildService :
)

companion object {
@VisibleForTesting
internal fun wrap(listener: EventListener?): EventListener? =
if (listener == null) {
null
} else {
object : EventListener {
override fun onBuildCancelRequested() {
runOnUiThread { listener.onBuildCancelRequested() }
}

override fun prepareBuild(buildInfo: BuildInfo) {
runOnUiThread { listener.prepareBuild(buildInfo) }
}

override fun onBuildSuccessful(tasks: List<String?>) {
runOnUiThread { listener.onBuildSuccessful(tasks) }
}

override fun onProgressEvent(event: ProgressEvent) {
runOnUiThread { listener.onProgressEvent(event) }
}

override fun onBuildFailed(tasks: List<String?>) {
runOnUiThread { listener.onBuildFailed(tasks) }
}

override fun onOutput(line: String?) {
runOnUiThread { listener.onOutput(line) }
}
}
}

private val log = LoggerFactory.getLogger(GradleBuildService::class.java)
private val NOTIFICATION_ID = R.string.app_name
private val SERVER_System_err = LoggerFactory.getLogger("ToolingApiErrorStream")
Expand Down Expand Up @@ -632,6 +665,9 @@ class GradleBuildService :

override fun cancelCurrentBuild(): CompletableFuture<BuildCancellationRequestResult> {
checkServerStarted()
// Before delegating: the cancellation surfaces as a build failure, and the listener needs
// to know it was asked for rather than reporting the user's own action as an error.
eventListener?.onBuildCancelRequested()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return server!!.cancelCurrentBuild()
}

Expand Down Expand Up @@ -747,33 +783,6 @@ class GradleBuildService :
return this
}

private fun wrap(listener: EventListener?): EventListener? =
if (listener == null) {
null
} else {
object : EventListener {
override fun prepareBuild(buildInfo: BuildInfo) {
runOnUiThread { listener.prepareBuild(buildInfo) }
}

override fun onBuildSuccessful(tasks: List<String?>) {
runOnUiThread { listener.onBuildSuccessful(tasks) }
}

override fun onProgressEvent(event: ProgressEvent) {
runOnUiThread { listener.onProgressEvent(event) }
}

override fun onBuildFailed(tasks: List<String?>) {
runOnUiThread { listener.onBuildFailed(tasks) }
}

override fun onOutput(line: String?) {
runOnUiThread { listener.onOutput(line) }
}
}
}

private fun startServerOutputReader(input: InputStream): Job {
outputReaderJob?.let { job ->
if (job.isActive) {
Expand Down Expand Up @@ -807,6 +816,19 @@ class GradleBuildService :

/** Handles events received from a Gradle build. */
interface EventListener {
/**
* Called when the user asks for the running build to stop.
*
* The tooling API reports a cancelled build through [onBuildFailed], so a listener that
* wants to tell the two apart has to be told here.
*
* Deliberately not defaulted. It was, and the forwarding wrapper in [GradleBuildService]
* then quietly inherited the no-op instead of passing it on -- so the cancel never reached
* the real listener, and a build the user stopped went on being annotated as a failure. A
* member with no default cannot be forgotten by a wrapper; the compiler asks for it.
*/
fun onBuildCancelRequested()

/**
* Called just before a build is started.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ class MemoryUsageChartRenderer(
* process's complete [ProcessMemoryInfo.usageHistory]. Call when the set of watched processes
* changes; [onUsagesChanged] calls it on its own when it detects such a change.
*/

@UiThread
override fun rebuild() {
val chart = this.chart ?: return
Expand Down
66 changes: 64 additions & 2 deletions app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@

package com.itsaky.androidide.ui

import android.content.Context
import android.graphics.Bitmap
import android.os.SystemClock
import android.util.TypedValue
import android.view.MotionEvent
import androidx.annotation.CallSuper
import androidx.annotation.UiThread
Expand Down Expand Up @@ -508,7 +510,10 @@ abstract class MetricsChartRenderer(
val oldestVisibleIndex = if (visible.isEmpty()) newestIndex else visible.first.toFloat()
val spanMillis = ((newestIndex - oldestVisibleIndex).toLong() + 1L) * interval
val now = nowMillis()
val markerColor = chart.context.resolveAttr(R.attr.colorOnSurface)
// Resolved once per redraw rather than once per annotation: applyAnnotations runs on every
// sampling tick, there can be MAX_ANNOTATIONS of them, and resolveAttr allocates a
// TypedValue per call.
val markerColors = MetricsAnnotationStore.Kind.entries.associateWith { markerColorFor(chart, it) }

store.recentAnnotations(spanMillis).forEach { annotation ->
val samplesAgo = (now - annotation.atMillis).toFloat() / interval
Expand All @@ -518,7 +523,8 @@ abstract class MetricsChartRenderer(
}

chart.xAxis.addLimitLine(
LimitLine(x, annotation.label).apply {
LimitLine(x, labelFor(chart, annotation)).apply {
val markerColor = markerColors.getValue(annotation.kind)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
lineWidth = ANNOTATION_LINE_WIDTH
lineColor = markerColor
textColor = markerColor
Expand All @@ -532,6 +538,62 @@ abstract class MetricsChartRenderer(
}
}

/**
* An annotation's label, resolved now rather than when it was recorded.
*
* A build outcome carries a string id instead of text, so its marker follows the system
* language even though the store holding it outlives the activity that recorded it.
*/
private fun labelFor(
chart: SafeLineChart,
annotation: MetricsAnnotationStore.Annotation,
): String = annotation.kind.labelRes?.let(chart.context::getString) ?: annotation.label

/**
* The colour a marker is drawn in, from the kind of event it marks (ADFA-5509).
*
* Build outcomes are the events a user came to the chart for, so they get the theme's semantic
* colours -- success for a build starting or finishing, error for one that failed -- while the
* task markers that surround them stay in the ordinary text colour. Both the line and the label
* take it; colouring only the line would leave the label unreadable against a coloured rule.
*/
private fun markerColorFor(
chart: SafeLineChart,
kind: MetricsAnnotationStore.Kind,
): Int {
val attr =
when (kind) {
MetricsAnnotationStore.Kind.BUILD_STARTED,
MetricsAnnotationStore.Kind.BUILD_FINISHED,
-> R.attr.colorSuccess

MetricsAnnotationStore.Kind.BUILD_FAILED -> R.attr.colorError

// A cancel is the user's own doing, so it is neither good news nor bad.
MetricsAnnotationStore.Kind.BUILD_CANCELLED,
MetricsAnnotationStore.Kind.TASK,
-> R.attr.colorOnSurface
}
// Not plain resolveAttr: it discards resolveAttribute's result and hands back TypedValue.data,
// which for an attribute the theme does not carry is 0 -- transparent. colorSuccess is
// ours rather than Material's, and a floating window is built against a window context
// whose theme is not the activity's, so a build marker could come out invisible. It falls
// back to the axis text colour, which configure has already set to something legible.
return chart.context.resolveColorAttr(attr, fallback = chart.xAxis.textColor)
}

/**
* The colour [attr] names in this context's theme, or [fallback] if the theme has no such
* attribute.
*/
private fun Context.resolveColorAttr(
attr: Int,
fallback: Int,
): Int {
val value = TypedValue()
return if (theme.resolveAttribute(attr, value, true)) value.data else fallback
}

/**
* The row an annotation's label sits on, cycling so that neighbours never share one.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ class NetworkUsageChartRenderer(
/**
* Rebuilds both series from the full sample history.
*/

@UiThread
override fun rebuild() {
val chart = this.chart ?: return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import org.slf4j.LoggerFactory
* hierarchy on a background thread, which races the main-thread updates of the memory-usage chart. The
* chart is a non-critical diagnostic view, so dropping the occasional frame is preferable to crashing the
* whole IDE. The next `invalidate()` recovers cleanly.
*
*/
class SafeLineChart : LineChart {
constructor(context: Context) : super(context)
Expand Down
Loading
Loading