Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -130,6 +130,7 @@ import com.itsaky.androidide.utils.InstallationResultHandler.onResult
import com.itsaky.androidide.utils.IntentUtils
import com.itsaky.androidide.utils.MemoryUsageWatcher
import com.itsaky.androidide.utils.MetricsAnnotationStore
import com.itsaky.androidide.utils.MetricsCsv
import com.itsaky.androidide.utils.StringsInjectionException
import com.itsaky.androidide.utils.StringsXmlInjector
import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation
Expand Down Expand Up @@ -490,13 +491,16 @@ abstract class BaseEditorActivity :
else -> Color.GRAY
}

protected val PROC_IDE = "IDE"
// Aliases, not copies. The names belong to the CSV, whose header is a published contract;
// see MetricsCsv.PROC_IDE for why they live there. Kept as protected members because
// subclasses use them.
protected val PROC_IDE = MetricsCsv.PROC_IDE

@JvmStatic
protected val PROC_GRADLE_TOOLING = "Gradle Tooling"
protected val PROC_GRADLE_TOOLING = MetricsCsv.PROC_GRADLE_TOOLING

@JvmStatic
protected val PROC_GRADLE_DAEMON = "Gradle Daemon"
protected val PROC_GRADLE_DAEMON = MetricsCsv.PROC_GRADLE_DAEMON

@JvmStatic
protected val log: Logger = LoggerFactory.getLogger(BaseEditorActivity::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.res.ColorStateList
import android.os.SystemClock
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
Expand All @@ -46,6 +47,8 @@ import com.itsaky.androidide.utils.DialogUtils
import com.itsaky.androidide.utils.IntentUtils
import com.itsaky.androidide.utils.MemoryUsageWatcher
import com.itsaky.androidide.utils.MetricsAnnotationStore
import com.itsaky.androidide.utils.MetricsCsv
import com.itsaky.androidide.utils.MetricsCsvFile
import com.itsaky.androidide.utils.MetricsSamplingRates
import com.itsaky.androidide.utils.MetricsSnapshot
import com.itsaky.androidide.utils.NetworkUsageWatcher
Expand Down Expand Up @@ -170,14 +173,26 @@ class MetricsCarouselController(
private var currentPage = 0

/**
* Whether an export is already running.
* Whether a PNG snapshot is already being written.
*
* One at a time. The camera button is not debounced and each tap launched its own coroutine,
* so two quick taps raced over the same scratch directory -- and, within the same second, over
* the same filename, since the name is the chart label and a whole-second timestamp. Touched
* only on the main thread, which is where both the tap and the coroutine's continuations run.
*
* A rapid second tap of the same button is refused silently: it is the double tap this exists
* to swallow, and a message for it would be noise on the gesture a user did not mean to make.
*/
private var exportInFlight = false
private var snapshotInFlight = false

/**
* Whether a CSV export is already being written, tracked apart from [snapshotInFlight].
*
* The two write different files into different directories and cannot race each other, so one
* flag for both only meant that starting a ten-thousand-row export refused the camera button
* for as long as it ran -- and refused it silently, which reads as a dead control.
*/
private var csvExportInFlight = false

/**
* The pager of the bound carousel, or `null` when nothing is bound. Exposed so a host can apply
Expand Down Expand Up @@ -253,6 +268,7 @@ class MetricsCarouselController(
// A camera button in the graph's bottom-right corner exports the chart. The gestures over
// the chart are all spoken for, so this is a control rather than another gesture.
binding.metricsSnapshot.setOnClickListener { exportSnapshot() }
binding.metricsExport.setOnClickListener { exportCsv() }
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Arrows are the dependable way to move between pages: a swipe has to share the gesture
// with panning a zoomed chart and with the editor's drawer, and loses often enough to be
Expand Down Expand Up @@ -308,6 +324,7 @@ class MetricsCarouselController(
binding.metricsPrevious to TooltipTag.CAROUSEL_PREVIOUS,
binding.metricsNext to TooltipTag.CAROUSEL_NEXT,
binding.metricsSnapshot to TooltipTag.CAROUSEL_SNAPSHOT,
binding.metricsExport to TooltipTag.CAROUSEL_EXPORT,
binding.metricsBattery to TooltipTag.CAROUSEL_BATTERY,
// Wired even though it is only visible while undocked: the message is the one control
// that outlives unbind(), so its help must not be torn down with the rest.
Expand All @@ -334,6 +351,7 @@ class MetricsCarouselController(
networkRenderer.onXAxisTap = null
powerRenderer.onXAxisTap = null
binding?.metricsSnapshot?.setOnClickListener(null)
binding?.metricsExport?.setOnClickListener(null)
binding?.let { bound ->
helpTargets(bound)
// All but the undocked message: that view becomes visible *because* the carousel
Expand Down Expand Up @@ -583,7 +601,7 @@ class MetricsCarouselController(
@UiThread
fun exportSnapshot(): Boolean {
val binding = this.binding ?: return false
if (exportInFlight) {
if (snapshotInFlight) {
log.debug("Ignoring a snapshot request while one is already being written")
return false
}
Expand All @@ -607,7 +625,7 @@ class MetricsCarouselController(
// it ends in startActivity, which throws from a context with no task of its own unless it is
// given FLAG_ACTIVITY_NEW_TASK, so it keeps the context the carousel is hosted in.
val appContext = context.applicationContext
exportInFlight = true
snapshotInFlight = true
scope.launch {
// Everything here is guarded: the scope has no exception handler, so anything escaping
// reaches the global crash reporter and is filed as a crash. MetricsSnapshot.write
Expand All @@ -620,7 +638,7 @@ class MetricsCarouselController(
// fresh full-size ARGB_8888 copy of the plot on every tap, which is
// megabytes that would otherwise sit around until the collector noticed.
try {
MetricsSnapshot.write(appContext, bitmap, label)
MetricsSnapshot.write(appContext, bitmap)
} finally {
bitmap.recycle()
}
Expand All @@ -643,17 +661,148 @@ class MetricsCarouselController(
// Cleared before rethrowing: a cancelled export is finished either way, and
// leaving the flag set would refuse every later one for the life of the
// carousel.
exportInFlight = false
snapshotInFlight = false
throw failure
}
log.error("Could not share the chart snapshot", failure)
Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show()
}
exportInFlight = false
snapshotInFlight = false
}
return true
}

/**
* Writes every retained sample to a CSV file and offers it to another app (ADFA-5531).
*
* The whole buffer, not the visible window and not the current page: this is the file the
* metrics format is defined around, and ADFA-5494, ADFA-5526 and ADFA-5534 all want everything
* there is. Assembled on the UI thread because it is snapshots of the watchers' buffers, then
* formatted and written off it -- ten thousand rows is not a click listener's work.
*
* @return whether an export could be started. The write itself completes later.
*/
@UiThread
fun exportCsv(): Boolean {
val binding = this.binding ?: return false
if (csvExportInFlight) {
log.debug("Ignoring an export request while one is already being written")
return false
}

val context = binding.root.context
val appContext = context.applicationContext
val snapshot = snapshot()
csvExportInFlight = true
scope.launch {
// Guarded for the same reason exportSnapshot is: the scope has no exception handler, so
// anything escaping here is filed as a crash.
runCatching {
val file = withContext(Dispatchers.IO) { MetricsCsvFile.write(appContext, snapshot) }
val host = this@MetricsCarouselController.binding?.root?.context
if (file == null || host == null) {
Toast.makeText(appContext, string.msg_metrics_export_failed, Toast.LENGTH_SHORT).show()
return@runCatching
}
val extraFlags =
if (host.findActivityOrNull() == null) Intent.FLAG_ACTIVITY_NEW_TASK else 0
IntentUtils.shareFile(host, file, MetricsCsv.MIME_TYPE, extraFlags)
}.onFailure { failure ->
if (failure is CancellationException) {
csvExportInFlight = false
throw failure
}
log.error("Could not share the metrics export", failure)
Toast.makeText(appContext, string.msg_metrics_export_failed, Toast.LENGTH_SHORT).show()
}
csvExportInFlight = false
}
return true
}

/**
* The watchers' buffers, as the export format's view of them.
*
* Rows come from the memory watcher: it is the only one always recording, the network watcher
* stops for good on a device whose counters are unsupported, and a power source can be missing.
* The other series are read at the same index -- the watchers share an interval, are started
* together and are cleared together -- and each carries its own sample times, so a series that
* was not recording leaves empty cells rather than zeros.
*/
@UiThread
@VisibleForTesting
internal fun snapshot(): MetricsCsv.Snapshot {
// One call per watcher, not one per array. Each returns its times and its values from a
// single critical section, which is what keeps a row of the file a single moment: two calls
// let the sampler append between them and every value came out one row off its timestamp.
val memory = memoryUsageWatcher.history()
val network = networkUsageWatcher.getUsage()
val power = powerUsageWatcher.getUsage()

return MetricsCsv.Snapshot(
rowTimes = memory.times,
// The memory watcher's, because its times are the rows.
sampleIntervalMillis = memoryUsageWatcher.updateInterval,
memory =
memory.processes.associate { process ->
process.pname to
MetricsCsv.Series(
times = memory.times,
values = process.usage,
since = process.watchedSinceMillis,
)
},
networkReceived = MetricsCsv.Series(network.sampleTimes, network.received),
networkTransmitted = MetricsCsv.Series(network.sampleTimes, network.transmitted),
// The power series carry in-band sentinels for a reading the device does not provide.
// Named here rather than in MetricsCsv, which has no Android types in it.
temperature =
MetricsCsv.Series(
power.sampleTimes,
power.temperatureMilliCelsius,
absent = PowerUsageWatcher.UNAVAILABLE,
),
power =
MetricsCsv.Series(
power.sampleTimes,
power.powerMicroWatts,
absent = PowerUsageWatcher.UNAVAILABLE,
),
thermal =
MetricsCsv.Series(
power.sampleTimes,
power.thermalStatus,
absent = PowerUsageWatcher.THERMAL_UNKNOWN.toLong(),
),
annotations = markers(),
)
}

/**
* The annotations, with their times moved onto the clock the samples carry.
*
* The store records on the monotonic clock and the samples on the wall clock, and the two are
* read here as close together as they can be so the offset between them is the right one.
*/
private fun markers(): List<MetricsCsv.Marker> {
val store = annotations ?: return emptyList()
val nowEpoch = System.currentTimeMillis()
val nowMonotonic = SystemClock.elapsedRealtime()
return store.allAnnotations().map { annotation ->
MetricsCsv.Marker(
atMillis = MetricsCsv.epochFor(annotation.atMillis, nowEpoch, nowMonotonic),
label = labelFor(annotation),
kind = annotation.kind.name,
)
}
}

/** An annotation's text: a build outcome carries a string id, a task carries its own name. */
private fun labelFor(annotation: MetricsAnnotationStore.Annotation): String {
val context = binding?.root?.context ?: return annotation.label
return annotation.kind.labelRes?.let(context::getString) ?: annotation.label
}

/**
* Releases the controller for good. Distinct from [unbind], which runs on every dock, undock
* and recreation; this is the terminal teardown and cancels any snapshot still being written.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ class MetricsCarouselLayout
/**
* Shows either the carousel or the "it is in a floating window" message, never a mix.
*
* The whole strip switches, not just the pager. The arrows and the snapshot button are
* chrome for a chart that is not here: left behind they sit over the message, and the
* camera is inert anyway because undocking unbinds the controller that listens to it.
* The whole strip switches, not just the pager. The arrows, the snapshot button and the
* export button are chrome for a chart that is not here: left behind they sit over the
* message, and each is inert anyway because undocking unbinds the controller that listens
* to them.
*
* Keeping the set here was supposed to stop a control added later from being forgotten.
* It did not: the battery readout arrived afterwards and was missed, so the readout sat
Expand All @@ -92,6 +93,7 @@ class MetricsCarouselLayout
R.id.metrics_previous,
R.id.metrics_next,
R.id.metrics_snapshot,
R.id.metrics_export,
)
carouselIds.forEach { id ->
findViewById<View>(id)?.isVisible = !undocked
Expand Down
Loading
Loading