diff --git a/REVIEW.md b/REVIEW.md index dec26df2a1..036de3a0bc 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -176,7 +176,7 @@ Help in CoGo is reached by **long-press**, anywhere: a progressive three-tier ex - **Wire up help on new interactive elements.** Anything tappable — buttons, icon controls, menu items, list rows, toolbar actions — gets long-press help. A new actionable view with no tooltip is as incomplete as a missing `contentDescription`. - **Cover new screens and panels too.** Even where pixels aren't interactive, a new screen/panel/dialog needs a top-level help entry so help is always reachable. - **The affordance is the requirement, not finished copy.** Tooltip content may still be in authoring — fine — but the long-press must be wired and routed into the tier system. Don't ship UI that can never surface help. -- **Reuse the system.** Wire help through `idetooltips` — today the `View.displayTooltipOnLongPress(context, anchorView, category, tag)` extension (`setOnLongClickListener` → `TooltipManager.showTooltip`) — not a one-off popup. +- **Reuse the system.** Wire help through `idetooltips` — today the `View.displayTooltipOnLongPress(context, tooltipTag, tooltipCategory, holdMillis)` extension (a long-click listener for the framework's own gesture, plus a touch listener that times the longer hold ADFA-5554 asks for, both reaching `TooltipManager.showTooltip`) — not a one-off popup. - **Compose has no native entry point yet** (tracked by **ADFA-4381**). The helper is View-based (needs an `anchorView`), so until `idetooltips` grows a Compose API, a composable wires help via `AndroidView` interop. Flag it in review rather than skipping help, and build the reusable `Modifier`/wrapper once instead of copy-pasting interop. ## 10. Architecture alignment diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b21c2d872f..be8c074e7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -329,6 +329,9 @@ dependencies { implementation(libs.androidx.work) implementation(libs.androidx.work.ktx) implementation(libs.google.material) + // Metrics carousel (ADFA-5487). Already on the classpath transitively; declared so the + // compile-time use in MetricsCarouselAdapter does not depend on another library's graph. + implementation(libs.androidx.viewpager2.v110beta02) implementation(libs.google.flexbox) implementation(libs.libsu.core) diff --git a/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt b/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt index aea4c2b0e0..445a19d48f 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt @@ -12,6 +12,7 @@ import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.requestBuildCancellation import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -77,34 +78,13 @@ abstract class AbstractCancellableRunAction( protected abstract fun doExec(data: ActionData): Any protected fun cancelBuild(): Boolean { - log.info("Sending build cancellation request...") val builder = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) if (builder?.isToolingServerStarted() != true) { flashError(com.itsaky.androidide.projects.R.string.msg_tooling_server_unavailable) return false } - builder.cancelCurrentBuild().whenComplete { - result, - error, - -> - if (error != null) { - log.error("Failed to send build cancellation request", error) - return@whenComplete - } - - if (!result.wasEnqueued) { - log.warn( - "Unable to enqueue cancellation request reason={} reason.message={}", - result.failureReason, - result.failureReason!!.message, - ) - return@whenComplete - } - - log.info("Build cancellation request was successfully enqueued...") - } - + requestBuildCancellation(builder) return true } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index a281e351ad..d71e11f3c5 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -52,7 +52,6 @@ import androidx.annotation.GravityInt import androidx.annotation.RequiresApi import androidx.annotation.UiThread import androidx.appcompat.app.ActionBarDrawerToggle -import androidx.collection.MutableIntIntMap import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import androidx.core.graphics.Insets @@ -60,6 +59,7 @@ import androidx.core.os.BundleCompat import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment @@ -67,11 +67,6 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.github.mikephil.charting.components.AxisBase -import com.github.mikephil.charting.data.Entry -import com.github.mikephil.charting.data.LineData -import com.github.mikephil.charting.data.LineDataSet -import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN @@ -124,6 +119,7 @@ import com.itsaky.androidide.tasks.cancelIfActive import com.itsaky.androidide.tasks.mainThreadHandler import com.itsaky.androidide.ui.CodeEditorView import com.itsaky.androidide.ui.ContentTranslatingDrawerLayout +import com.itsaky.androidide.ui.MetricsCarouselController import com.itsaky.androidide.ui.SwipeRevealLayout import com.itsaky.androidide.uidesigner.UIDesignerActivity import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow @@ -133,6 +129,10 @@ 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.MetricsCsv +import com.itsaky.androidide.utils.MetricsCsvFile +import com.itsaky.androidide.utils.MetricsSnapshotAssembler import com.itsaky.androidide.utils.StringsInjectionException import com.itsaky.androidide.utils.StringsXmlInjector import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation @@ -145,7 +145,6 @@ import com.itsaky.androidide.utils.flashMessage import com.itsaky.androidide.utils.getOrStoreInitialPadding import com.itsaky.androidide.utils.isAtLeastR import com.itsaky.androidide.utils.isDeepLinkTargetOfOpenProject -import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.viewmodel.ApkInstallationViewModel import com.itsaky.androidide.viewmodel.AppLogsCoordinator import com.itsaky.androidide.viewmodel.AppLogsViewModel @@ -155,6 +154,7 @@ import com.itsaky.androidide.viewmodel.DebuggerViewModel import com.itsaky.androidide.viewmodel.EditorViewModel import com.itsaky.androidide.viewmodel.FileManagerViewModel import com.itsaky.androidide.viewmodel.FileOpResult +import com.itsaky.androidide.viewmodel.MetricsViewModel import com.itsaky.androidide.viewmodel.RecentProjectsViewModel import com.itsaky.androidide.viewmodel.WADBConnectionViewModel import com.itsaky.androidide.xml.resources.ResourceTableRegistry @@ -173,7 +173,6 @@ import rikka.shizuku.Shizuku import java.io.File import kotlin.math.abs import kotlin.math.roundToInt -import kotlin.math.roundToLong /** * Base class for EditorActivity which handles most of the view related things. @@ -191,8 +190,78 @@ abstract class BaseEditorActivity : protected var editorBottomSheet: BottomSheetBehavior? = null private var drawerToggle: ActionBarDrawerToggle? = null private var bottomSheetCallback: BottomSheetBehavior.BottomSheetCallback? = null - protected val memoryUsageWatcher = MemoryUsageWatcher() - protected val pidToDatasetIdxMap = MutableIntIntMap(initialCapacity = 3) + private val metricsViewModel by viewModels() + + /** + * Sample history lives in [MetricsViewModel] so it survives configuration changes and activity + * recreation rather than depending on this activity's configChanges declaration (ADFA-5486). + */ + protected val memoryUsageWatcher get() = metricsViewModel.memoryUsageWatcher + + protected val networkUsageWatcher get() = metricsViewModel.networkUsageWatcher + + protected val powerUsageWatcher get() = metricsViewModel.powerUsageWatcher + + protected val metricsCarousel by lazy { + MetricsCarouselController( + memoryUsageWatcher = memoryUsageWatcher, + networkUsageWatcher = networkUsageWatcher, + powerUsageWatcher = powerUsageWatcher, + lineColorFor = Companion::getMemUsageLineColorFor, + annotations = metricsViewModel.annotations, + ) + } + + /** + * The metrics file to attach to feedback, or `null` when there is nothing to say (ADFA-5534). + * + * A report of "it got slow" arrives with no way to correlate it against anything; the session's + * own samples turn that into something diagnosable. + * + * Assembled and written off the main thread: MetricsSnapshotAssembler is @AnyThread and takes + * each watcher's own history lock, and the file is up to a megabyte and gzipped on the way out. + * Returns null when nothing has been sampled, so feedback sent from a freshly started IDE + * carries no empty attachment -- the writer would happily produce a header-only file, and + * sending one is the caller's decision, not its. + */ + private suspend fun metricsAttachmentForFeedback(): File? { + // Off the main thread. MetricsSnapshotAssembler is @AnyThread precisely because a crash + // arrives on whatever thread threw -- every read inside takes the watcher's own history + // lock. Forcing it onto the UI thread allocated eleven LongArray(3600) and copied 39,600 + // longs there, while contending for three locks the samplers hold. + val snapshot = + withContext(Dispatchers.IO) { + MetricsSnapshotAssembler.assemble( + context = this@BaseEditorActivity, + memory = memoryUsageWatcher, + network = networkUsageWatcher, + power = powerUsageWatcher, + annotations = metricsViewModel.annotations, + ) + } + if (!snapshot.hasRows) { + return null + } + return withContext(Dispatchers.IO) { + MetricsCsvFile.writeForReport(applicationContext, snapshot) + } + } + + /** Records a significant event for the charts to annotate (ADFA-5486). */ + fun recordMetricsAnnotation(label: String) { + metricsViewModel.annotations.record(label) + } + + /** + * 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() private var feedbackButtonManager: FeedbackButtonManager? = null @@ -202,6 +271,12 @@ abstract class BaseEditorActivity : var isDestroying = false protected set + /** + * Whether the metrics samplers have been started this session. See + * [startMetricsSamplingIfNeeded]; nothing samples until the carousel is first shown. + */ + private var metricsSamplingStarted = false + /** * Editor activity's [CoroutineScope] for executing tasks in the background. */ @@ -313,49 +388,6 @@ abstract class BaseEditorActivity : } } - private val memoryUsageListener = - MemoryUsageWatcher.MemoryUsageListener { memoryUsage -> - var dataChanged = false - memoryUsage.forEachValue { proc -> - _binding?.memUsageView?.chart?.apply { - val dataset = - ( - data.getDataSetByIndex( - pidToDatasetIdxMap.getOrDefault( - proc.pid, - -1, - ), - ) as LineDataSet? - ) - ?: run { - log.error( - "No dataset found for process: {}: {}", - proc.pid, - proc.pname, - ) - return@forEachValue - } - - dataset.entries.mapIndexed { index, entry -> - entry.y = - (proc.usageHistory[index] / (1024.0 * 1024.0)).toFloat() - } - - dataset.label = "%s - %.2fMB".format(proc.pname, dataset.entries.last().y) - dataset.notifyDataSetChanged() - dataChanged = true - } - } - - if (dataChanged) { - _binding?.memUsageView?.chart?.apply { - data.notifyDataChanged() - notifyDataSetChanged() - invalidate() - } - } - } - private val shizukuBinderReceivedListener = Shizuku.OnBinderReceivedListener { invalidateOptionsMenu() @@ -363,10 +395,6 @@ abstract class BaseEditorActivity : private var isImeVisible = false private var contentCardRealHeight: Int? = null - private val editorSurfaceContainerBackground by lazy { - resolveAttr(R.attr.colorSurfaceDim) - } - private var isDebuggerStarting = false @UiThread set(value) { field = value @@ -483,14 +511,39 @@ abstract class BaseEditorActivity : companion object { const val DEBUGGER_SERVICE_STOP_DELAY_MS: Long = 60 * 1000 + /** + * The plot colour for a watched process. + * + * Lives on the companion, not on the activity: a bound reference to an activity method is + * handed to [MetricsCarouselController], which is in turn handed to the floating window and + * outlives an activity recreation. A pure function of the process name has no business + * pinning an activity in memory, and this one is exactly that. + * + * An unrecognised name falls back rather than throwing. This is reached from the + * once-a-second sample listener and from RecyclerView's bind pass, so a name nobody added a + * colour for would take the editor down from a timer callback or mid-layout -- a crash for + * the sake of a line colour. 5d00a796a and 4c65554e5 each established that; this branch + * removed it again, so it is written down here rather than rediscovered a fourth time. + */ @JvmStatic - protected val PROC_IDE = "IDE" + fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = + when (proc.pname) { + PROC_IDE -> Color.BLUE + PROC_GRADLE_TOOLING -> Color.RED + PROC_GRADLE_DAEMON -> Color.GREEN + else -> Color.GRAY + } + + // 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) @@ -562,11 +615,33 @@ abstract class BaseEditorActivity : fullscreenManager?.destroy() fullscreenManager = null + // Same reasoning as onPause: a floating carousel is bound to the window, not to these + // views. On a real teardown the window goes with the editor, so releasing the controller + // then is correct -- but the window has to be told, first. Closing the controller under a + // window that is still on screen left frozen charts and dead camera and CSV buttons, with + // nothing saying the data source had gone; the watchers stop with MetricsViewModel anyway, + // so there is no version of this where the floating carousel outlives the editor usefully. + if (isDestroying) { + closeFloatingMetricsCarousel() + } + if (!isMetricsCarouselUndocked() || isDestroying) { + metricsCarousel.unbind() + } + if (isDestroying) { + metricsCarousel.close() + } _binding = null if (isDestroying) { - memoryUsageWatcher.stopWatching(true) + // Sampling itself is stopped by MetricsViewModel.onCleared; the history has to outlive a + // recreation, so it must not be torn down whenever this activity goes away. memoryUsageWatcher.listener = null + networkUsageWatcher.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() @@ -900,10 +975,11 @@ abstract class BaseEditorActivity : activity = this, feedbackFab = binding.fabFeedback.root, getLogContent = ::getLogContent, + getMetricsAttachment = ::metricsAttachmentForFeedback, ) feedbackButtonManager?.setupDraggableFab() - setupMemUsageChart() + setupMetricsCarousel() watchMemory() observeFileOperations() @@ -989,7 +1065,40 @@ abstract class BaseEditorActivity : } } + /** + * Starts the three samplers, once per session, when the carousel is first shown. + * + * The strip lives behind [SwipeRevealLayout] and is closed on launch, so a user who never drags + * the app bar down never sees it -- and used to pay for it anyway: three loops reading /proc, + * TrafficStats and the battery every tick, three listener chains, and three renderers redrawing + * a chart underneath an opaque card. ADFA-5199 measured the single-chart version of this at + * ~19% of a core with the editor idle; there are three watchers now. + * + * Starting late rather than pausing and resuming, because a pause would leave a hole in the + * middle of the buffers and the renderer still positions samples by index rather than by their + * recorded time (ADFA-5660). A later start shortens the history without breaking that + * assumption, which is what `watchedSinceMillis` already exists to describe. + */ + private fun startMetricsSamplingIfNeeded() { + metricsSamplingStarted = true + if (!memoryUsageWatcher.isWatching) { + memoryUsageWatcher.startWatching() + } + // isSupported too: where TrafficStats has no per-UID counters the loop clears `watching` + // and breaks, so this gate alone relaunched a coroutine that sampled once, repainted a + // permanently-zero chart and died -- on every single resume, for the life of the session. + if (!networkUsageWatcher.isWatching && networkUsageWatcher.isSupported) { + networkUsageWatcher.startWatching() + } + if (!powerUsageWatcher.isWatching) { + powerUsageWatcher.startWatching() + } + } + private fun onSwipeRevealDragProgress(progress: Float) { + if (progress > 0f) { + startMetricsSamplingIfNeeded() + } _binding?.apply { contentCard.progress = progress val insetsTop = systemBarInsets?.top ?: 0 @@ -1004,40 +1113,66 @@ abstract class BaseEditorActivity : content.editorAppBarLayout.updatePadding(top = topInset) } - memUsageView.chart.updateLayoutParams { + metricsCarousel.pager?.updateLayoutParams { topMargin = (insetsTop * progress).roundToInt() } } } - private fun setupMemUsageChart() { - binding.memUsageView.chart.apply { - val colorAccent = resolveAttr(R.attr.colorAccent) + private fun setupMetricsCarousel() { + binding.memUsageView.root.onTwoFingerTap = ::onMetricsCarouselUndockRequested + binding.memUsageView.metricsUndockedMessage.setOnClickListener { + onMetricsCarouselRedockRequested() + } + + // Ask where the carousel is before binding one here. Only one can be live at a time, and + // the floating one outlives this activity -- so an activity recreated while it is floating + // (a night-mode or locale change, or leaving the editor and coming back) used to bind a + // second carousel into the strip and leave the floating one attached to a destroyed + // activity's views, frozen, with the strip showing no sign that it had gone anywhere. + // + // [setMetricsCarouselUndocked] is the same call the undock request makes, so the strip + // shows the "tap to bring them back" message and tapping it re-docks onto *this* + // activity's controller. + setMetricsCarouselUndocked(isMetricsCarouselUndocked()) + } + + /** + * A two-finger tap on the carousel asks for it to be floated. Overridden where the floating + * window machinery lives; a no-op here. + */ + protected open fun onMetricsCarouselUndockRequested() = Unit - isDragEnabled = false - description.isEnabled = false - xAxis.axisLineColor = colorAccent - axisRight.axisLineColor = colorAccent + /** Whether the carousel is currently floating rather than docked here. */ + protected open fun isMetricsCarouselUndocked(): Boolean = false - setPinchZoom(false) - setBackgroundColor(editorSurfaceContainerBackground) - setDrawGridBackground(true) - setScaleEnabled(true) + /** Dismisses the floating carousel window, if one is up. Overridden where docking is wired. */ + protected open fun closeFloatingMetricsCarousel() = Unit - axisLeft.isEnabled = false - axisRight.valueFormatter = - object : - IAxisValueFormatter { - override fun getFormattedValue( - value: Float, - axis: AxisBase?, - ): String = "%dMB".format(value.roundToLong()) - } + /** A tap on the "tap to bring them back" message asks for the floating carousel to re-dock. */ + protected open fun onMetricsCarouselRedockRequested() = Unit + + /** + * Swaps the carousel for the message explaining where it has gone, or back again. + * + * Only one carousel can be live at a time, so undocking moves it out of the editor. Without the + * message the reveal would open on an empty strip, and a window dragged off screen would leave + * no way back. + */ + @UiThread + protected fun setMetricsCarouselUndocked(undocked: Boolean) { + val view = _binding?.memUsageView ?: return + view.root.setUndocked(undocked) + + if (undocked) { + metricsCarousel.unbind() + } else { + metricsCarousel.bind(view) + metricsCarousel.refresh() } } private fun watchMemory() { - memoryUsageWatcher.listener = memoryUsageListener memoryUsageWatcher.watchProcess(Process.myPid(), PROC_IDE) resetMemUsageChart() } @@ -1070,60 +1205,26 @@ abstract class BaseEditorActivity : resetMemUsageChart() } + /** + * Rebuilds the memory chart for the currently watched processes. Call after starting or stopping + * watching a process. + */ protected fun resetMemUsageChart() { - val processes = memoryUsageWatcher.getMemoryUsages() - val datasets = - Array(processes.size) { index -> - LineDataSet( - List(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { Entry(it.toFloat(), 0f) }, - processes[index].pname, - ) - } - - val bgColor = editorSurfaceContainerBackground - val textColor = resolveAttr(R.attr.colorOnSurface) - - for ((index, proc) in processes.withIndex()) { - val dataset = datasets[index] - dataset.color = getMemUsageLineColorFor(proc) - dataset.setDrawIcons(false) - dataset.setDrawCircles(false) - dataset.setDrawCircleHole(false) - dataset.setDrawValues(false) - dataset.formLineWidth = 1f - dataset.formSize = 15f - dataset.isHighlightEnabled = false - pidToDatasetIdxMap[proc.pid] = index - } - - binding.memUsageView.chart.setBackgroundColor(bgColor) - - binding.memUsageView.chart.apply { - data = LineData(*datasets) - axisRight.textColor = textColor - axisLeft.textColor = textColor - legend.textColor = textColor - - data.setValueTextColor(textColor) - setBackgroundColor(bgColor) - setGridBackgroundColor(bgColor) - notifyDataSetChanged() - invalidate() - } + metricsCarousel.onWatchedProcessesChanged() } - private fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = - when (proc.pname) { - PROC_IDE -> Color.BLUE - PROC_GRADLE_TOOLING -> Color.RED - PROC_GRADLE_DAEMON -> Color.GREEN - else -> throw IllegalArgumentException("Unknown process: $proc") - } - override fun onPause() { super.onPause() - memoryUsageWatcher.listener = null - memoryUsageWatcher.stopWatching(false) + // Sampling continues while backgrounded so the history has no gaps; the x axis assumes + // evenly spaced samples and would otherwise misreport their age (ADFA-5486). Only the + // carousel goes, so nothing updates a chart nobody is looking at. + // Not while it is floating: the controller is then bound to the window's own views, and + // unbinding would clear the watcher listeners and detach the renderers -- leaving the + // overlay showing a chart that never updates again, which is the one state undocking + // exists for. onResume already guards its rebind the same way. + if (!isMetricsCarouselUndocked()) { + metricsCarousel.unbind() + } this.isDestroying = isFinishing getFileTreeFragment()?.saveTreeState() @@ -1140,8 +1241,19 @@ abstract class BaseEditorActivity : log.warn("Unable to move debugger overlay to display {}", displayId, err) } - memoryUsageWatcher.listener = memoryUsageListener - memoryUsageWatcher.startWatching() + if (!isMetricsCarouselUndocked()) { + _binding?.let { metricsCarousel.bind(it.memUsageView) } + } + // Only what was already sampling, and the floating case, which shows the carousel without + // the strip ever being dragged open. Everything else waits for the first reveal. + if (metricsSamplingStarted || isMetricsCarouselUndocked()) { + startMetricsSamplingIfNeeded() + } + + if (!isMetricsCarouselUndocked()) { + // Draw whatever was sampled while away, rather than waiting for the next tick. + metricsCarousel.refresh() + } apkInstallationViewModel.reloadStatus(this) @@ -1917,8 +2029,12 @@ abstract class BaseEditorActivity : // Filter out diagonal flings so only an intentional right swipe opens the drawer. // A horizontal fling that started on the bottom-sheet tab strip is the user - // scrolling tabs, not asking for the drawer. - if (isDrawerOpenFling && !isTouchOnBottomSheetTabs(e1)) { + // scrolling tabs, not asking for the drawer; one that started on the metrics + // carousel is the user paging it backwards. + if (isDrawerOpenFling && + !isTouchOnBottomSheetTabs(e1) && + !isTouchOnMetricsCarousel(e1) + ) { binding.editorDrawerLayout.openDrawer(GravityCompat.START) return true } @@ -1940,9 +2056,50 @@ abstract class BaseEditorActivity : private fun isTouchOnBottomSheetTabs(ev: MotionEvent): Boolean { val tabs = contentOrNull?.bottomSheet?.binding?.tabs ?: return false - val rect = Rect() - if (!tabs.getGlobalVisibleRect(rect)) return false - return rect.contains(ev.rawX.toInt(), ev.rawY.toInt()) + return containsTouch(tabs, ev) + } + + private fun isTouchOnMetricsCarousel(ev: MotionEvent): Boolean { + val binding = _binding ?: return false + + // A left-to-right fling pages the carousel *backwards*, so there is nothing for it to do + // on the first page -- which is the page the carousel opens on. Excluding the strip + // regardless left the documented right-swipe drawer gesture dead over the whole panel + // while doing nothing in its place. + if (binding.memUsageView.metricsPager.currentItem <= 0) { + return false + } + + // The carousel is laid out at the top of the reveal even while the content card covers it, + // and siblings do not clip each other, so getGlobalVisibleRect reports it visible either + // way. Without this check the drawer gesture would be dead over the top of a closed editor. + if (binding.swipeReveal.dragProgress <= 0f) { + return false + } + + // The pager, not the whole strip: the title and its row are not something the carousel + // pages from, and MetricsCarouselLayout has already walled that row off from every + // ancestor, so a fling there would otherwise be swallowed twice over. + return containsTouch(binding.memUsageView.metricsPager, ev) + } + + private fun containsTouch( + view: View, + ev: MotionEvent, + ): Boolean { + if (!view.isShown) return false + + // getLocationOnScreen, not getGlobalVisibleRect: the latter reports window coordinates -- + // ViewRootImpl intersects with the window and never offsets by its position on screen -- + // while rawX/rawY are screen coordinates. In split-screen or freeform the window origin is + // not zero, so the two disagree and the hit test lands somewhere else entirely. + // SwipeRevealLayout.isTouchInDragHandle already uses this idiom. + val location = IntArray(2) + view.getLocationOnScreen(location) + val x = ev.rawX.toInt() + val y = ev.rawY.toInt() + return x >= location[0] && x < location[0] + view.width && + y >= location[1] && y < location[1] + view.height } private fun showTooltip(tag: String) { diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index f2e20620d4..59e7ca8382 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -66,6 +66,7 @@ import com.itsaky.androidide.databinding.FileActionPopupWindowBinding import com.itsaky.androidide.databinding.FileActionPopupWindowItemBinding import com.itsaky.androidide.deeplink.PendingDeepLinkOpen import com.itsaky.androidide.di.APPLICATION_SCOPE +import com.itsaky.androidide.editor.floating.MetricsCarouselDockableContent import com.itsaky.androidide.editor.language.treesitter.JavaLanguage import com.itsaky.androidide.editor.language.treesitter.JsonLanguage import com.itsaky.androidide.editor.language.treesitter.KotlinLanguage @@ -904,6 +905,28 @@ open class EditorHandlerActivity : return if (child is CodeEditorView) child else null } + override fun onMetricsCarouselUndockRequested() { + floatingTabController.floatMetricsCarousel( + controller = metricsCarousel, + title = getString(string.metrics_carousel_window_title), + ) { setMetricsCarouselUndocked(true) } + } + + override fun onMetricsCarouselRedockRequested() { + floatingTabController.redockMetricsCarousel() + } + + override fun isMetricsCarouselUndocked(): Boolean = DockingManager.isFloating(MetricsCarouselDockableContent.ID) + + override fun closeFloatingMetricsCarousel() { + DockingManager.close(MetricsCarouselDockableContent.ID) + } + + /** The floating carousel has closed or re-docked; put the editor's own carousel back. */ + fun onFloatingMetricsCarouselGone() { + setMetricsCarouselUndocked(false) + } + /** Undock the file tab at [fileIndex] into a floating window over other apps. */ fun undockFileTab(fileIndex: Int) { floatingTabController.undock(fileIndex) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 28c014f88d..ca6f2e7add 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -80,6 +80,7 @@ import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams import com.itsaky.androidide.tooling.api.messages.result.InitializeResult import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.BUILD_CANCELLED import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.CACHE_READ_ERROR import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_DIRECTORY_INACCESSIBLE import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY @@ -95,6 +96,7 @@ import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt import com.itsaky.androidide.utils.RecursiveFileSearcher import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.flashbarBuilder import com.itsaky.androidide.utils.onLongPress @@ -739,7 +741,11 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { service.startToolingServer { pid -> memoryUsageWatcher.watchProcess(pid, PROC_GRADLE_TOOLING) - resetMemUsageChart() + // The callback arrives on the tooling server's own thread, and the renderer is + // @UiThread: rebuild() clears and repopulates a non-thread-safe pid map that the + // once-a-second sample listener reads on the main thread, so racing it can plot one + // process's samples on another's line or throw out of the entry loop. + runOnUiThread { resetMemUsageChart() } service.metadata().whenComplete { metadata, err -> if (metadata == null || err != null) { @@ -754,7 +760,8 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { metadata.pid, ) memoryUsageWatcher.watchProcess(metadata.pid, PROC_GRADLE_TOOLING) - resetMemUsageChart() + // A CompletableFuture completion thread, for the same reason as above. + runOnUiThread { resetMemUsageChart() } } } @@ -801,6 +808,22 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { ) { val manager = ProjectManagerImpl.getInstance() if (!isSuccessful) { + // Before the project name is resolved, which the cancel path does not use: that lookup + // walks the workspace model and has a catch-Throwable around it, and a user who pressed + // Stop should not be waiting on it -- or be affected by it failing. + // + // A sync the user stopped is not a failure, and arrives here through the same callback + // as one. ADFA-5542 fixed that for builds and missed this path, which is the one a + // cancelled *sync* takes: the user pressed Stop and got an indefinite red "Project + // initialization failed" for doing so. + if (failure == BUILD_CANCELLED) { + val cancelled = getString(string.info_build_cancelled) + setStatus(cancelled) + flashInfo(cancelled) + editorViewModel.isInitializing = false + return + } + // Get project name for error message val projectName = try { diff --git a/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt b/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt index c095b08320..f216a7e0ac 100644 --- a/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt +++ b/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildCompletedMetric.kt @@ -20,5 +20,11 @@ class BuildCompletedMetric( putString("build_type", buildType) putBoolean("success", isSuccess) putLong("duration_ms", buildResult.durationMs) + // Why it was not successful, which the metric used to drop. A build the user stopped + // and a build that broke both arrive as success=false, so without this the two are + // indistinguishable and every build-success rate counts deliberate cancels as + // failures. isSuccess keeps its meaning -- a cancelled build did not succeed -- and a + // consumer that wants the rate excluding cancels can now compute it. + buildResult.failure?.let { putString("failure_reason", it.name) } } } diff --git a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt index a5a1ed921c..96c4430352 100644 --- a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt @@ -18,6 +18,7 @@ import com.itsaky.androidide.events.LspJavaEventsIndex import com.itsaky.androidide.events.ProjectsApiEventsIndex import com.itsaky.androidide.handlers.CrashEventSubscriber import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext +import com.itsaky.androidide.handlers.MetricsCrashAttachment import com.itsaky.androidide.logging.provider.IdeLogRouter import com.itsaky.androidide.preferences.internal.StatPreferences import com.itsaky.androidide.preferences.internal.TelemetryConsent @@ -25,6 +26,7 @@ import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.MetricsScratch import com.termux.shared.reflection.ReflectionUtils import io.github.rosemoe.sora.widget.schemes.EditorColorScheme import io.sentry.Breadcrumb @@ -126,6 +128,12 @@ internal object DeviceProtectedApplicationLoader : // Enrich every GlitchTip event with app-specific diagnostic context. GlitchTipDiagnosticsContext.install(options) + + // And with what the machine was doing in the minutes before it (ADFA-5526). The + // destinations that snapshot writes into are taken now, while failing to get them is + // survivable -- a crash handler is the wrong place to ask for memory. + MetricsScratch.install() + MetricsCrashAttachment.install(options, app) } // Forward INFO+ logs to GlitchTip as breadcrumbs (never as events; crash events are diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/ChromeControlTooltips.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/ChromeControlTooltips.kt index 20121ec1db..43a32c64c8 100644 --- a/app/src/main/java/com/itsaky/androidide/editor/floating/ChromeControlTooltips.kt +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/ChromeControlTooltips.kt @@ -7,7 +7,6 @@ import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag - object ChromeControlTooltips { val handler: (ChromeControl, View) -> Unit = { control, anchor -> tagFor(control)?.let { tag -> @@ -20,6 +19,6 @@ object ChromeControlTooltips { ChromeControl.MINIMIZE -> TooltipTag.WINDOW_MINIMIZE ChromeControl.MAXIMIZE -> TooltipTag.WINDOW_MAXIMIZE ChromeControl.DOCK -> TooltipTag.WINDOW_DOCK - ChromeControl.CLOSE -> TooltipTag.WINDOW_UNDOCK + ChromeControl.CLOSE -> TooltipTag.WINDOW_CLOSE } } diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt index cee0b2ad74..918a2ef2f2 100644 --- a/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt @@ -19,6 +19,7 @@ import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.ui.CodeEditorView +import com.itsaky.androidide.utils.requestBuildCancellation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -114,7 +115,9 @@ class EditorPanelDockableContent( private fun cancelBuild() { val builder = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) if (builder?.isToolingServerStarted() == true) { - builder.cancelCurrentBuild() + // Through the shared reporter, because this copy threw the result away entirely: a + // Stop the server refused here said nothing at all, not even to the log. + requestBuildCancellation(builder) } } diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt index 69a6b95210..c1ddf8c648 100644 --- a/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt @@ -12,6 +12,7 @@ import com.itsaky.androidide.floating.permission.OverlayPermission import com.itsaky.androidide.floating.service.FloatingTabService import com.itsaky.androidide.floating.window.InitialBounds import com.itsaky.androidide.resources.R +import com.itsaky.androidide.ui.MetricsCarouselController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import org.slf4j.LoggerFactory @@ -62,6 +63,36 @@ class IdeFloatingTabController( } } + /** + * Float the metrics carousel, moving it out of the editor. [MetricsCarouselDockableContent] + * rebinds the same controller, since only one carousel may be live at a time. + */ + fun floatMetricsCarousel( + controller: MetricsCarouselController, + title: String, + onUndocked: () -> Unit, + ) { + if (!OverlayPermission.canDrawOverlays(activity)) { + activity.startActivity(OverlayPermission.requestIntent(activity)) + return + } + if (DockingManager.isFloating(MetricsCarouselDockableContent.ID)) { + return + } + + onUndocked() + DockingManager.undock( + MetricsCarouselDockableContent(controller, title), + InitialBounds.cascaded(activity, undockCounter++), + ) + FloatingTabService.ensureRunning(activity.applicationContext) + } + + /** Bring the floating metrics carousel back into the editor. */ + fun redockMetricsCarousel() { + DockingManager.dock(MetricsCarouselDockableContent.ID) + } + fun floatPluginTab( tabId: String, title: String, @@ -101,6 +132,16 @@ class IdeFloatingTabController( } DockingManager.remove(tab.id) panel?.release() + + // A fallback, not the primary path: removing the tab makes the service's reconcile + // dismiss the window, and dismiss() already runs onDestroyView. This covers the case + // where no live window was there to dismiss -- the service not bound, or a tab removed + // before its window was created -- so content holding resources is still released. + // It follows that onDestroyView must be idempotent; the metrics carousel's unbind is. + if (panel == null) { + runCatching { tab.content.onDestroyView() } + .onFailure { log.error("Failed to release floating content {}", tab.id, it) } + } } } @@ -133,6 +174,16 @@ class IdeFloatingTabController( activity.selectPluginTabById(content.tabId) } } + + is MetricsCarouselDockableContent -> { + // onDestroyView has already unbound the controller from the window, so the editor + // only has to put its own carousel back. Done for Close as well as Redock: closing + // the window must not leave the editor showing "tap to bring them back" forever. + if (event is DockingEvent.Redock) { + bringIdeToFront() + } + activity.onFloatingMetricsCarouselGone() + } } } diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt new file mode 100644 index 0000000000..85d8ebd386 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt @@ -0,0 +1,129 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.editor.floating + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.view.updateLayoutParams +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.floating.model.ChromeControl +import com.itsaky.androidide.floating.model.DockableContent +import com.itsaky.androidide.floating.window.FloatingWindowHost +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.ui.MetricsCarouselController + +/** + * Adapts the editor's metrics carousel to [DockableContent] so it can float over other apps + * (ADFA-5486). + * + * The window rebinds the editor's own [MetricsCarouselController] rather than building a second + * one. Only one carousel can be live at a time -- the watchers hold a single listener each -- so + * undocking moves the carousel out of the editor rather than copying it, which is also how an + * editor file tab undocks. The editor shows a "tap to bring them back" message in the space it + * vacates. + * + * The sample history is unaffected by the move: the watchers own it, so the carousel is redrawn in + * full wherever it is bound. + * + * @property controller The carousel to rebind into this window. + * @property title Window title, resolved by the caller against the IDE's resources. + */ +class MetricsCarouselDockableContent( + private val controller: MetricsCarouselController, + override val title: String, +) : DockableContent { + override val id: String = ID + + /** + * The window chrome's own help, the same handler the editor and plugin tabs install. + * + * Without it the undocked carousel was the one floating window whose minimize, maximize and + * dock controls answered no long press -- and the dock control is the only way back, so it is + * the one that most needs explaining. ADFA-5510 wired help to everything inside the carousel + * and missed the frame around it. + */ + override val onChromeControlLongPress: (ChromeControl, View) -> Unit = + ChromeControlTooltips.handler + + override fun onCreateView( + context: Context, + host: FloatingWindowHost, + ): View { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + this.binding = binding + + // The editor sizes the carousel to a fixed strip; in a window it should fill whatever the + // user has dragged the frame out to. + binding.root.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + + // The next arrow shares the bottom-right corner with the frame's resize grip, whose touch + // target is 28dp. Undocked they sat close enough to look like one control and to invite a + // mis-hit; the arrow moves in by the grip's own width. Docked there is no grip, so this is + // set here rather than in the layout. + binding.metricsNext.updateLayoutParams { + marginEnd = + context.resources.getDimensionPixelSize( + com.itsaky.androidide.R.dimen.metrics_carousel_undocked_arrow_margin_end, + ) + } + + // A two-finger tap is what undocked it; inside the window the chrome's dock control is the + // way back, so the gesture would only be a second, less discoverable route. + binding.root.onTwoFingerTap = null + + // Nothing here is typed into, so nothing here should take focus. A focusable child in an + // overlay window makes the window focusable, and the soft keyboard then opens over the + // chart on every touch. + binding.root.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + binding.root.isFocusable = false + binding.root.isFocusableInTouchMode = false + + // Belt and braces: if something upstream has already opened the keyboard, a touch on the + // chart puts it away rather than leaving it covering the window. + binding.root.onTouchDown = { hideSoftInput(binding.root) } + + controller.bind(binding) + return binding.root + } + + override fun onDestroyView() { + // Only if the controller is still bound to this window's views. The redock path rebinds it + // to the editor's, and nothing orders the two collectors of the same docking emission. + binding?.let(controller::unbindIfBoundTo) + binding = null + } + + private var binding: LayoutMemUsageBinding? = null + + private fun hideSoftInput(view: View) { + val manager = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + manager?.hideSoftInputFromWindow(view.windowToken, 0) + } + + companion object { + /** Stable id, shared with the docked carousel this content was undocked from. */ + const val ID = "ide.metrics.carousel" + } +} diff --git a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt index 207c5de5e9..8f38990380 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.handlers import android.os.SystemClock +import androidx.annotation.VisibleForTesting import com.itsaky.androidide.R import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.preferences.internal.GeneralPreferences @@ -26,10 +27,14 @@ import com.itsaky.androidide.projects.builder.LaunchResult import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.services.builder.GradleBuildService import com.itsaky.androidide.tooling.api.messages.result.BuildInfo +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.tooling.events.configuration.ProjectConfigurationStartEvent +import com.itsaky.androidide.tooling.events.task.TaskFinishEvent import com.itsaky.androidide.tooling.events.task.TaskStartEvent +import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.BuildOutputViewModel import org.slf4j.LoggerFactory @@ -46,6 +51,17 @@ class EditorBuildEventListener : GradleBuildService.EventListener { private var buildStartTimeMs: Long = System.currentTimeMillis() private var lastOutputTimeMs: Long = SystemClock.elapsedRealtime() + /** + * Whether the build now running drew a "Build started" marker. + * + * The outcome callbacks used to decide for themselves, from the task list they are handed -- + * a different list from the one prepareBuild sees. If those two ever disagreed the chart got + * a start with no finish, or a finish with no start, which is the one thing a pair of markers + * exists to avoid. The build that started decides, and its outcome follows. + */ + @VisibleForTesting + internal var annotatedBuild = false + private var enabled = true private var activityReference: WeakReference = WeakReference(null) @@ -88,32 +104,96 @@ 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 the flag set for + // the next build to inherit and draw a finish for a build that never started. + annotatedBuild = false + + val act = checkActivity("prepareBuild") ?: return + + // A project sync runs through the same callbacks with no tasks, so annotating every + // 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, ) } } + /** + * Whether [failure] is the user's own Stop rather than something going wrong. + * + * One definition, because this callback used to ask the same question three times -- once in + * [failureMessage], once in [outcomeKind] and once inline -- which is how the chart and the + * messages beside it came to disagree in the first place. + */ + @VisibleForTesting + internal fun isCancelled(failure: TaskExecutionResult.Failure?): Boolean = failure == TaskExecutionResult.Failure.BUILD_CANCELLED + + /** + * What a failed build is reported as, to the plugins and in the result the editor posts. + * + * [cancelledText] is passed in rather than resolved here so this can be asserted without an + * activity, for the same reason [outcomeKind] is separate: [onBuildFailed] returns early + * without one, so anything decided inside it is unreachable from a test. + */ + @VisibleForTesting + internal fun failureMessage( + failure: TaskExecutionResult.Failure?, + cancelledText: String, + ): String = + when { + isCancelled(failure) -> cancelledText + lastStatusLine.contains("BUILD FAILED") -> lastStatusLine + else -> "Build failed. Check build output for details." + } + + /** + * Which marker a failed build gets: the user's own cancel, or a real failure (ADFA-5542). + * + * [failure] is the server's own classification of the throwable Gradle raised. The listener + * used to answer this from a flag it set when the cancel was requested, which meant deciding + * from the order two main-thread runnables happened to run in -- and a cancel that overtook + * [prepareBuild] was cleared by it, so the build the user stopped was reported back to them as + * an error. + * + * Separated from [onBuildFailed] so the decision can be tested: that method needs a live + * activity before it reaches this point, and returns early without one. + */ + @VisibleForTesting + internal fun outcomeKind(failure: TaskExecutionResult.Failure?): MetricsAnnotationStore.Kind = + if (isCancelled(failure)) { + MetricsAnnotationStore.Kind.BUILD_CANCELLED + } else { + MetricsAnnotationStore.Kind.BUILD_FAILED + } + private fun resetBuildTimers() { buildStartTimeMs = System.currentTimeMillis() lastOutputTimeMs = SystemClock.elapsedRealtime() @@ -122,6 +202,11 @@ class EditorBuildEventListener : GradleBuildService.EventListener { override fun onBuildSuccessful(tasks: List) { val act = checkActivity("onBuildSuccessful") ?: return + if (annotatedBuild) { + act.recordBuildAnnotation(MetricsAnnotationStore.Kind.BUILD_FINISHED) + } + annotatedBuild = false + pluginBuildService?.notifyBuildFinished() analyzeCurrentFile() @@ -150,24 +235,68 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } override fun onProgressEvent(event: ProgressEvent) { - checkActivity("onProgressEvent") ?: return + val act = checkActivity("onProgressEvent") ?: return if (event is ProjectConfigurationStartEvent || event is TaskStartEvent) { - activity.setStatus(event.descriptor.displayName) + act.setStatus(event.descriptor.displayName) + } + + if (isAnnotated(event)) { + act.recordMetricsAnnotation(event.descriptor.displayName) } } - override fun onBuildFailed(tasks: List) { + /** + * Whether [event] is one the metrics charts annotate (ADFA-5486). + * + * Task starts and stops, and nothing else. Gradle emits these far faster than a chart can show + * them -- dozens a second during configuration -- so the store throttles to one every five + * seconds and keeps the first of each quiet period. + * + * Separated from [onProgressEvent] so the decision can be tested: that method needs a live + * activity before it reaches this point, and returns early without one. + */ + @VisibleForTesting + internal fun isAnnotated(event: ProgressEvent): Boolean = event is TaskStartEvent || event is TaskFinishEvent + + override fun onBuildFailed( + tasks: List, + failure: TaskExecutionResult.Failure?, + ) { val act = checkActivity("onBuildFailed") ?: return + val cancelled = isCancelled(failure) + + if (annotatedBuild) { + // A build the user stopped arrives through this same callback. Marking it as a failure + // would report their own deliberate action back to them in the error colour. + act.recordBuildAnnotation(outcomeKind(failure)) + } + annotatedBuild = false + analyzeCurrentFile() GeneralPreferences.isFirstBuild = false act.editorViewModel.isBuildInProgress = false - act.flashError(R.string.build_status_failed) + // Everything this method says, not only the chart marker. The annotation was fixed first + // and the three reports beside it were not, so a user who pressed Stop still got a red + // "Build failed" bar, a "Build failed" notification and an isSuccess=false result -- their + // own action read back to them as an error in every place but one. + val cancelledText = act.getString(R.string.info_build_cancelled) + if (cancelled) { + act.flashInfo(R.string.info_build_cancelled) + // The status line under the output too. Gradle prints "BUILD FAILED" for a cancelled + // build like any other, and [onOutput] copies that line into the label, so the label + // sat there contradicting the bar that had just said the build was stopped. This runs + // after onOutput, so it has the last word. + act.setStatus(cancelledText) + } else { + act.flashError(R.string.build_status_failed) + } - val message = - if (lastStatusLine.contains("BUILD FAILED")) lastStatusLine else "Build failed. Check build output for details." + val message = failureMessage(failure, cancelledText) + // The plugin API has no way to say "cancelled" -- IdeServices.onBuildFailed takes an error + // string and nothing else -- so the message is the whole of what a plugin can be told. pluginBuildService?.notifyBuildFailed(message) act.notifyBuildResult(BuildResult(isSuccess = false, message = message, launchResult = null)) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt b/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt new file mode 100644 index 0000000000..e5c1796db9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/handlers/MetricsCrashAttachment.kt @@ -0,0 +1,160 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.handlers + +import android.content.Context +import android.os.SystemClock +import com.itsaky.androidide.utils.MetricsCsv +import com.itsaky.androidide.utils.MetricsCsvFile +import com.itsaky.androidide.utils.MetricsSnapshotAssembler +import com.itsaky.androidide.utils.MetricsSource +import io.sentry.Attachment +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Attaches the carousel's metrics to every report the IDE sends (ADFA-5526). + * + * A crash arrives with a stack and no idea what the machine was doing. The minutes of memory, + * network, temperature and power leading up to it are what turn "it died" into a diagnosis -- and + * for an out-of-memory kill they are most of the answer. + * + * Registered as a Sentry [EventProcessor] beside [GlitchTipDiagnosticsContext], not on the uncaught + * exception handler, so it also covers the non-fatal `Sentry.captureException` calls the IDE makes + * deliberately. + * + * ADFA-5494 will keep this history across process death; this does not need it. A crash is the one + * loss cause with a hookable moment, which is exactly why it can be served on its own. The kill that + * 5494 exists for produces no report at all -- nothing runs on a SIGKILL -- so it was never this + * ticket's case. + */ +class MetricsCrashAttachment( + private val context: Context, + private val nowMillis: () -> Long = SystemClock::elapsedRealtime, + private val writeFile: (MetricsCsv.Snapshot) -> File? = { snapshot -> + MetricsCsvFile.writeForReport(context, snapshot) + }, +) : EventProcessor { + /** + * The last file written, and when. Reused rather than rewritten for a moment afterwards. + * + * Not synchronised: two events racing here write two files and one of them wins the field, + * which costs a write and loses nothing. A lock would be the more expensive mistake, since this + * runs on the thread of whatever is being reported. + */ + @Volatile + private var recent: Recent? = null + + private class Recent( + val atMillis: Long, + val file: File, + ) + + override fun process( + event: SentryEvent, + hint: Hint, + ): SentryEvent { + // Everything, including Errors. This runs while the process is dying, and an OutOfMemoryError + // raised in here would replace a useful report with no report -- losing the attachment is the + // right way to fail. runCatching is what makes that true: it catches Throwable. + runCatching { attach(hint) } + .onFailure { failure -> log.warn("Could not attach the metrics file to the report", failure) } + return event + } + + private fun attach(hint: Hint) { + // No source before the editor has run: a crash in onboarding, in the project chooser or in + // direct boot has no history to report, and direct boot has no credential-protected cache to + // write it to either. + val metrics = MetricsSource.current ?: return + val file = writeSnapshot(metrics) ?: return + hint.addAttachment(Attachment(file.absolutePath, file.name, MetricsCsvFile.COMPRESSED_MIME_TYPE)) + } + + /** + * The file to attach, writing one if the last is too old to stand in. + * + * This runs on the thread of whatever is being reported, and it is not cheap: a full buffer is + * 3600 rows, which format and gzip in 10-15ms on a desktop JVM and a good deal more on a phone. + * A crash pays that once and it does not matter. But this processor is deliberately registered + * for *every* event, including the non-fatal `Sentry.captureException` calls the IDE makes on + * purpose -- and those arrive in bursts, on whatever thread noticed, the main one included. Paid + * per event that is a visible stutter per event. + * + * So a file written moments ago is handed out again instead. The window is short because + * freshness matters most at exactly the moment this is for: a crash gets at most + * [REUSE_WINDOW_MS] less of its own tail, while a burst of non-fatals collapses to one write. + * Every event still gets an attachment, which distinguishing crashes from non-fatals would not + * manage here -- the IDE reports its own crashes through a plain `captureException`, so + * `SentryEvent.isCrashed` is false for them and there is nothing at this level to tell the two + * apart. + * + * The existence check is not belt and braces: [MetricsCsvFile] prunes its directory to the few + * most recent, so a file handed out here can be deleted by a later write. + */ + private fun writeSnapshot(metrics: MetricsSource.Metrics): File? { + val now = nowMillis() + recent?.let { last -> + if (now - last.atMillis < REUSE_WINDOW_MS && last.file.exists()) { + return last.file + } + } + + val file = + MetricsSnapshotAssembler.withSnapshot( + context = context, + memory = metrics.memoryUsageWatcher, + network = metrics.networkUsageWatcher, + power = metrics.powerUsageWatcher, + annotations = metrics.annotations, + ) { snapshot -> + // Nothing sampled yet is nothing to say. A header-only attachment on every early + // crash would be noise in the reports rather than context. + if (!snapshot.hasRows) null else writeFile(snapshot) + } + if (file != null) { + recent = Recent(now, file) + } + return file + } + + companion object { + private val log = LoggerFactory.getLogger(MetricsCrashAttachment::class.java) + + /** + * How long a written file stands in for the next one. + * + * Short deliberately: the cost this bounds is a burst of non-fatals, which arrive far + * faster than this, and the thing it risks is the tail of a crash, which is the part worth + * having. + */ + const val REUSE_WINDOW_MS = 5_000L + + /** Registers this processor. Call once, from within `SentryAndroid.init`. */ + fun install( + options: SentryOptions, + context: Context, + ) { + options.addEventProcessor(MetricsCrashAttachment(context)) + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index b0c29b2ea3..121356f0cc 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -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 @@ -201,6 +202,45 @@ class GradleBuildService : ) companion object { + @VisibleForTesting + internal fun wrap(listener: EventListener?): EventListener? = + if (listener == null) { + null + } else { + object : EventListener { + override fun prepareBuild(buildInfo: BuildInfo) { + runOnUiThread { listener.prepareBuild(buildInfo) } + } + + override fun onBuildSuccessful(tasks: List) { + runOnUiThread { listener.onBuildSuccessful(tasks) } + } + + override fun onGradleDaemonStarted(pid: Int) { + runOnUiThread { listener.onGradleDaemonStarted(pid) } + } + + override fun onGradleDaemonExited(pid: Int) { + runOnUiThread { listener.onGradleDaemonExited(pid) } + } + + override fun onProgressEvent(event: ProgressEvent) { + runOnUiThread { listener.onProgressEvent(event) } + } + + override fun onBuildFailed( + tasks: List, + failure: TaskExecutionResult.Failure?, + ) { + runOnUiThread { listener.onBuildFailed(tasks, failure) } + } + + 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") @@ -468,11 +508,29 @@ class GradleBuildService : eventListener?.onBuildSuccessful(result.tasks) } + /** + * What the notification in the shade says about a build that did not succeed. + * + * Extracted so it can be asserted: [onBuildFailed] needs a live service before it reaches this + * point, which is the same reason [EditorBuildEventListener] separates its own two decisions. + * Without a test here, deleting the cancelled arm left every test green while a build the user + * stopped went back to saying "Build failed" in the shade. + */ + @VisibleForTesting + internal fun notificationStatusFor(failure: TaskExecutionResult.Failure?): Int = + if (failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { + R.string.info_build_cancelled + } else { + R.string.build_status_failed + } + override fun onBuildFailed(result: BuildResult) { - updateNotification(getString(R.string.build_status_failed), false) + // The notification too, not only what reaches the listener: a build the user stopped left + // "Build failed" in the shade whatever the chart said (ADFA-5542). + updateNotification(getString(notificationStatusFor(result.failure)), false) dispatchBuildResult(result, false) - eventListener?.onBuildFailed(result.tasks) + eventListener?.onBuildFailed(result.tasks, result.failure) } private fun dispatchBuildResult( @@ -788,41 +846,6 @@ class GradleBuildService : return this } - private fun wrap(listener: EventListener?): EventListener? = - if (listener == null) { - null - } else { - object : EventListener { - override fun prepareBuild(buildInfo: BuildInfo) { - runOnUiThread { listener.prepareBuild(buildInfo) } - } - - override fun onBuildSuccessful(tasks: List) { - runOnUiThread { listener.onBuildSuccessful(tasks) } - } - - override fun onGradleDaemonStarted(pid: Int) { - runOnUiThread { listener.onGradleDaemonStarted(pid) } - } - - override fun onGradleDaemonExited(pid: Int) { - runOnUiThread { listener.onGradleDaemonExited(pid) } - } - - override fun onProgressEvent(event: ProgressEvent) { - runOnUiThread { listener.onProgressEvent(event) } - } - - override fun onBuildFailed(tasks: List) { - runOnUiThread { listener.onBuildFailed(tasks) } - } - - override fun onOutput(line: String?) { - runOnUiThread { listener.onOutput(line) } - } - } - } - private fun startServerOutputReader(input: InputStream): Job { outputReaderJob?.let { job -> if (job.isActive) { @@ -875,13 +898,14 @@ class GradleBuildService : /** * Called when the Gradle daemon has been identified by the tooling server. * - * Defaulted, because a daemon is only of interest to a listener that plots it and every - * other implementer would otherwise gain two empty methods. + * Deliberately not defaulted. An interface default here let the wrapper satisfy the + * interface without forwarding, so the daemon callbacks were silently swallowed; + * GradleBuildServiceListenerWrapperTest asserts no callback on this interface has one. * * @param pid The process id of the Gradle daemon. * @see IToolingApiClient.onGradleDaemonStarted */ - fun onGradleDaemonStarted(pid: Int) = Unit + fun onGradleDaemonStarted(pid: Int) /** * Called when the Gradle daemon has exited. @@ -889,7 +913,7 @@ class GradleBuildService : * @param pid The process id of the daemon that exited. * @see IToolingApiClient.onGradleDaemonExited */ - fun onGradleDaemonExited(pid: Int) = Unit + fun onGradleDaemonExited(pid: Int) /** * Called when a progress event is received from the Tooling API server. @@ -901,10 +925,21 @@ class GradleBuildService : /** * Called when a build fails. * + * A build the user cancelled arrives here too, and [failure] is what tells the two apart. + * It comes from the server, which classifies the throwable Gradle raised -- the only place + * the answer is known rather than inferred (ADFA-5542). + * * @param tasks The tasks that were run. + * @param failure Why the build failed. Never null from this server, which classifies every + * failure before reporting it; nullable because the wire type allows a server that does + * not. A null is treated as an ordinary failure, which is the safe reading -- reporting + * a real failure as a cancel would hide it. * @see IToolingApiClient.onBuildFailed */ - fun onBuildFailed(tasks: List) + fun onBuildFailed( + tasks: List, + failure: TaskExecutionResult.Failure?, + ) /** * Called when the output line is received. diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 9570333eb2..bc078478d4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -67,6 +67,8 @@ import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.DiagnosticsFormatter import com.itsaky.androidide.utils.IntentUtils.shareFile import com.itsaky.androidide.utils.Symbols.forFile +import com.itsaky.androidide.utils.clearLongPressHelp +import com.itsaky.androidide.utils.displayTooltipOnLongPress import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess @@ -251,7 +253,7 @@ class EditorBottomSheet } } } - binding.shareOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SHARE_EXTERNAL)) + binding.shareOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_SHARE_EXTERNAL) binding.clearOutputAction.setOnClickListener { val fragment = @@ -262,7 +264,7 @@ class EditorBottomSheet } (fragment as ShareableOutputFragment).clearOutput() } - binding.clearOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_CLEAR)) + binding.clearOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_CLEAR) binding.copyDiagnosticsFab.setOnClickListener { copyDiagnosticsToClipboard() @@ -278,7 +280,7 @@ class EditorBottomSheet viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) fragment.beginSearch() } - binding.searchOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SEARCH)) + binding.searchOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_SEARCH) binding.filterOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) @@ -289,7 +291,7 @@ class EditorBottomSheet viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) fragment.toggleFilterBar() } - binding.filterOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_FILTER)) + binding.filterOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_FILTER) updateWordWrapButtonState(EditorPreferences.outputWordWrap) binding.wordWrapOutputAction.setOnClickListener { @@ -297,7 +299,7 @@ class EditorBottomSheet EditorPreferences.outputWordWrap = newState updateWordWrapButtonState(newState) } - binding.wordWrapOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_WORD_WRAP)) + binding.wordWrapOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_WORD_WRAP) binding.viewOptionsOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) @@ -305,7 +307,7 @@ class EditorBottomSheet fragment.showViewOptions(it) } } - binding.viewOptionsOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_VIEW_OPTIONS)) + binding.viewOptionsOutputAction.displayTooltipOnLongPress(context, TooltipTag.OUTPUT_VIEW_OPTIONS) binding.headerContainer.setOnClickListener { viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) @@ -332,17 +334,17 @@ class EditorBottomSheet binding.tabs.clearOnTabSelectedListeners() binding.shareOutputAction.setOnClickListener(null) - binding.shareOutputAction.setOnLongClickListener(null) + binding.shareOutputAction.clearLongPressHelp() binding.clearOutputAction.setOnClickListener(null) - binding.clearOutputAction.setOnLongClickListener(null) + binding.clearOutputAction.clearLongPressHelp() binding.searchOutputAction.setOnClickListener(null) - binding.searchOutputAction.setOnLongClickListener(null) + binding.searchOutputAction.clearLongPressHelp() binding.filterOutputAction.setOnClickListener(null) - binding.filterOutputAction.setOnLongClickListener(null) + binding.filterOutputAction.clearLongPressHelp() binding.wordWrapOutputAction.setOnClickListener(null) - binding.wordWrapOutputAction.setOnLongClickListener(null) + binding.wordWrapOutputAction.clearLongPressHelp() binding.viewOptionsOutputAction.setOnClickListener(null) - binding.viewOptionsOutputAction.setOnLongClickListener(null) + binding.viewOptionsOutputAction.clearLongPressHelp() binding.copyDiagnosticsFab.setOnClickListener(null) binding.headerContainer.setOnClickListener(null) removeOnLayoutChangeListener(fabLayoutChangeListener) @@ -387,18 +389,6 @@ class EditorBottomSheet } } - private fun generateTooltipListener(tooltipTag: String): OnLongClickListener = - OnLongClickListener { view: View -> - TooltipManager.showIdeCategoryTooltip( - context = context, - anchorView = view, - tag = tooltipTag, - ) - - // A long-click listener must return true to indicate it has consumed the event. - true - } - fun setCurrentTab( @BottomSheetViewModel.TabDef tabIndex: Int, ) { diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt new file mode 100644 index 0000000000..a7fe80d1b4 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -0,0 +1,233 @@ +/* + * 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.annotation.UiThread +import androidx.collection.IntObjectMap +import androidx.collection.MutableIntIntMap +import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.IAxisValueFormatter +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.ShiftedLongArray +import kotlin.math.max +import kotlin.math.roundToLong + +/** + * Renders [MemoryUsageWatcher] samples into a [SafeLineChart]. + * + * The chart view is attached and detached independently of the data: [MemoryUsageWatcher] owns the + * per-process [ProcessMemoryInfo.usageHistory] ring buffers, so this renderer holds no sample state + * of its own and can rebuild a complete chart from [usagesProvider] at any time. That is what makes + * the chart safe to host in a recycling container (ADFA-5487's metrics carousel): a chart view that + * is created long after watching began still shows the full history, and one that is recycled away + * loses nothing. + * + * All methods must be called on the UI thread. MPAndroidChart is not thread-safe; see [SafeLineChart]. + * + * @param usagesProvider Supplies the currently watched processes, newest state each call. + * @param lineColorFor Supplies the plot line color for a process. + */ +class MemoryUsageChartRenderer( + private val usagesProvider: () -> Array, + private val lineColorFor: (ProcessMemoryInfo) -> Int, + annotations: MetricsAnnotationStore? = null, + sampleIntervalMillis: () -> Long = { MemoryUsageWatcher.DEFAULT_UPDATE_INTERVAL }, +) : MetricsChartRenderer( + sampleIntervalMillis = sampleIntervalMillis, + annotations = annotations, + ) { + /** + * Maps a watched pid to its dataset index in the attached chart's [LineData]. Empty whenever no + * chart is attached. + */ + private val pidToDatasetIdx = MutableIntIntMap(initialCapacity = 3) + + @UiThread + override fun detach() { + super.detach() + pidToDatasetIdx.clear() + } + + override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY + + /** + * Rebuilds the chart's datasets from scratch for the currently watched processes, rendering each + * process's complete [ProcessMemoryInfo.usageHistory]. Call when the set of watched processes + * changes; [onUsagesChanged] calls it on its own when it detects such a change. + */ + @UiThread + override fun rebuild() { + val chart = this.chart ?: return + val processes = usagesProvider() + + pidToDatasetIdx.clear() + + val datasets = + Array(processes.size) { index -> + val proc = processes[index] + pidToDatasetIdx[proc.pid] = index + + LineDataSet( + List(proc.usageHistory.size) { entryIdx -> + Entry(entryIdx.toFloat(), proc.usageHistory.megabytesAt(entryIdx)) + }, + proc.pname, + ).apply { + // The right axis is the one configure() leaves enabled and the one this + // renderer ranges and formats. MPAndroidChart defaults a dataset to LEFT, so + // without this the lines were scaled by an axis nobody had configured while + // the labels beside them came from another. + axisDependency = YAxis.AxisDependency.RIGHT + color = lineColorFor(proc) + setDrawIcons(false) + setDrawCircles(false) + setDrawCircleHole(false) + setDrawValues(false) + isHighlightEnabled = false + label = labelFor(chart.context, proc.pname, entries.lastOrNull()?.y ?: 0f) + } + } + + setData(chart, datasets) { applyAxisRange(it, processes) } + } + + /** + * Scales the value axis to the samples on screen (ADFA-5486). + * + * Left to itself MPAndroidChart ranges over every entry in the data, which is the whole + * retained buffer -- ten thousand samples, hours of it -- while sixty are visible. One early + * Gradle daemon peak then flattened every later reading into the bottom of the plot and nothing + * ever brought the ceiling back down. The network chart was fixed first; this is the sibling. + */ + private fun applyAxisRange( + chart: SafeLineChart, + processes: Array, + ) = applyAxisRangeFor(chart) { visit -> processes.forEach(visit) } + + /** + * Sets the axis from whatever [forEachProcess] offers, so a caller that already holds the + * samples does not have to ask the watcher for another copy of them. + */ + private fun applyAxisRangeFor( + chart: SafeLineChart, + forEachProcess: ((ProcessMemoryInfo) -> Unit) -> Unit, + ) { + var peak = 0f + forEachProcess { proc -> + for (index in visibleSampleRange(chart, proc.usageHistory.size)) { + peak = max(peak, proc.usageHistory.megabytesAt(index)) + } + } + + chart.axisRight.axisMinimum = 0f + // A little headroom so the tallest line is not drawn on the frame, and a floor so an idle + // chart does not collapse onto a zero-height axis before the first samples land. + chart.axisRight.axisMaximum = max(peak * AXIS_HEADROOM, MIN_AXIS_MEGABYTES) + } + + /** + * Renders a fresh set of samples into the attached chart, mutating the existing entries in place. + * + * Falls back to [rebuild] when [memoryUsage] no longer matches the datasets the chart was built + * with -- a process started or stopped being watched, or the chart was attached before this pid + * existed. The in-place path is the common one: it mutates the existing entries rather than + * rebuilding the datasets, which is what matters because this runs once a second for the + * lifetime of the editor. It is not allocation-free -- each series reformats its legend label + * every tick -- so do not add work here on the assumption that it is. + */ + @UiThread + fun onUsagesChanged(memoryUsage: IntObjectMap) { + val chart = this.chart ?: return + + if (memoryUsage.size != pidToDatasetIdx.size) { + rebuild() + return + } + + var dataChanged = false + memoryUsage.forEachValue { proc -> + val datasetIdx = pidToDatasetIdx.getOrDefault(proc.pid, -1) + val dataset = chart.data?.getDataSetByIndex(datasetIdx) as LineDataSet? + if (dataset == null) { + // The chart's datasets no longer describe the watched processes. Rebuild rather than + // dropping this process's samples on the floor, as the previous code did. + rebuild() + return + } + + for (index in dataset.entries.indices) { + dataset.entries[index].y = proc.usageHistory.megabytesAt(index) + } + + dataset.label = labelFor(chart.context, proc.pname, dataset.entries.lastOrNull()?.y ?: 0f) + dataset.notifyDataSetChanged() + dataChanged = true + } + + 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. + redraw(chart) { ranged -> + applyAxisRangeFor(ranged) { visit -> + memoryUsage.forEachValue { visit(it) } + } + } + } + } + + override fun configure(chart: SafeLineChart) { + super.configure(chart) + chart.axisRight.valueFormatter = + object : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = "%dMB".format(value.roundToLong()) + } + } + + private companion object { + /** Keeps the tallest line off the top frame of the plot. */ + const val AXIS_HEADROOM = 1.1f + + /** Floor for the axis, so an idle chart has a readable scale rather than a flat zero. */ + const val MIN_AXIS_MEGABYTES = 64f + } + + private fun labelFor( + context: Context, + pname: String, + megabytes: Float, + ): String = context.getString(R.string.metrics_legend_entry, pname, "%.2fMB".format(megabytes)) +} + +internal const val BYTES_PER_MEGABYTE = 1024.0 * 1024.0 + +/** + * The sample at [index] in megabytes. [MemoryUsageWatcher] stores bytes. + */ +private fun ShiftedLongArray.megabytesAt(index: Int): Float = (this[index] / BYTES_PER_MEGABYTE).toFloat() diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt new file mode 100644 index 0000000000..0ea5ba14fc --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt @@ -0,0 +1,129 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +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. + */ +interface MetricsPage { + @get:StringRes val title: Int + + @get:StringRes val contentDescription: Int + + 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 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 + * away from a chart and back therefore loses nothing. + */ +class MetricsCarouselAdapter( + private val pages: List, +) : RecyclerView.Adapter() { + /** + * @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 + + /** + * 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 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, + ) { + 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. + 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 new file mode 100644 index 0000000000..1458025d00 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -0,0 +1,835 @@ +/* + * 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.app.Activity +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 +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.isVisible +import androidx.core.widget.ImageViewCompat +import androidx.viewpager2.widget.ViewPager2 +import com.itsaky.androidide.R +import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.floating.window.OverlayDialogs +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.IntentUtils +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.MetricsSnapshotAssembler +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import com.itsaky.androidide.utils.clearLongPressHelp +import com.itsaky.androidide.utils.displayTooltipOnLongPress +import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory + +/** + * Drives one metrics carousel: its pages, its renderers, and the title that names the current page. + * + * Split out of the editor activity so the carousel can be hosted somewhere else -- specifically a + * floating window, once ADFA-5486's undocking lands. The host supplies a binding to bind to and the + * watchers to read from; everything else about running a carousel lives here. + * + * Only one controller may be live at a time. [MemoryUsageWatcher] and [NetworkUsageWatcher] each + * hold a single listener, so a second carousel would silently take the updates from the first -- + * which is why undocking has to move the carousel out of the editor rather than copy it there. + * + * @param lineColorFor Supplies the plot colour for a watched process. Passed in because the process + * names it keys on belong to the editor activity. + */ +class MetricsCarouselController( + private val memoryUsageWatcher: MemoryUsageWatcher, + private val networkUsageWatcher: NetworkUsageWatcher, + private val powerUsageWatcher: PowerUsageWatcher, + lineColorFor: (MemoryUsageWatcher.ProcessMemoryInfo) -> Int, + private val annotations: MetricsAnnotationStore? = null, +) { + private val memoryRenderer = + MemoryUsageChartRenderer( + usagesProvider = { memoryUsageWatcher.getMemoryUsages() }, + lineColorFor = lineColorFor, + annotations = annotations, + sampleIntervalMillis = { memoryUsageWatcher.updateInterval }, + ) + + private val networkRenderer = + NetworkUsageChartRenderer( + usageProvider = { networkUsageWatcher.getUsage() }, + annotations = annotations, + sampleInterval = { networkUsageWatcher.updateInterval }, + ) + + 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. + 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) + } + + private val networkListener = + NetworkUsageWatcher.NetworkUsageListener { usage -> + networkRenderer.onUsageChanged(usage) + } + + /** + * Runs the snapshot write. Main-dispatched so its result lands back on the UI thread, with the + * disk work pushed to [Dispatchers.IO] inside; a SupervisorJob so one failed export does not + * stop the next. + */ + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private var binding: LayoutMemUsageBinding? = null + private var pageCallback: ViewPager2.OnPageChangeCallback? = null + + /** + * The page the user is on, kept across bind and unbind. + * + * The pager itself cannot hold it: docking and undocking inflate a fresh layout and a fresh + * ViewPager2, which starts at zero. Without this, undocking while reading the network chart + * put the floating window on the memory chart. + */ + private var currentPage = 0 + + /** + * Whether a PNG snapshot is already being written. + * + * One at a time. The camera button is not debounced and each tap launched its own coroutine, + * so two quick taps raced over the same scratch directory -- and, within the same second, over + * the same filename, since the name is the chart label and a whole-second timestamp. Touched + * only on the main thread, which is where both the tap and the coroutine's continuations run. + * + * A rapid second tap of the same button is refused silently: it is the double tap this exists + * to swallow, and a message for it would be noise on the gesture a user did not mean to make. + */ + private var snapshotInFlight = false + + /** + * Whether a CSV export is already being written, tracked apart from [snapshotInFlight]. + * + * The two write different files into different directories and cannot race each other, so one + * flag for both only meant that starting a ten-thousand-row export refused the camera button + * for as long as it ran -- and refused it silently, which reads as a dead control. + */ + private var csvExportInFlight = false + + /** + * The pager of the bound carousel, or `null` when nothing is bound. Exposed so a host can apply + * layout that is its own concern, such as the editor's status-bar inset. + */ + val pager: ViewPager2? + get() = binding?.metricsPager + + /** + * Binds the carousel to [binding] and starts feeding it samples. + */ + @UiThread + fun bind(binding: LayoutMemUsageBinding) { + // A carousel can be re-bound without an intervening unbind -- docking, undocking and an + // activity recreation all route through here. Releasing first keeps one page callback and + // one set of listeners alive rather than accumulating them on views that are already gone. + if (this.binding != null) { + unbind() + } + + this.binding = binding + + binding.metricsPager.adapter = MetricsCarouselAdapter(pages) + + // The arrows carry their colour from app:tint, which only AppCompat applies -- and only + // when AppCompat's factory is on the inflater. The floating window inflates from a plain + // window context, so there it produced an ordinary ImageButton, app:tint was ignored, and + // the vector's own android:tint="#000000" took over: black arrows on a near-black strip. + // Setting the tint here works whichever inflater built the view. + tintArrows(binding) + + // Before the page callback is registered, so restoring does not fire it. Docking and + // undocking rebind the carousel, and a rebind used to drop the user back on the first + // page: undocking while reading the network chart showed them the memory chart instead. + binding.metricsPager.setCurrentItem(currentPage, false) + + val showTitleFor = { position: Int -> + pages.getOrNull(position)?.let { page -> + binding.metricsTitle.setText(page.title) + } + } + + pageCallback = + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + 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) } + + // onPageSelected does not fire for the page the carousel opens on. + showTitleFor(binding.metricsPager.currentItem) + + // A tap on the x axis opens the sampling-rate chooser (ADFA-5486). The axis is drawn by the + // chart, not a view of its own, so the strip of the pager it occupies is the target. + // Paging is by the arrows only. A swipe in the plot competes with panning a zoomed chart + // and with the editor's drawer gesture, and losing that race intermittently made the + // carousel feel broken; with touch paging off, a horizontal drag is unambiguously a pan. + binding.metricsPager.isUserInputEnabled = false + + memoryRenderer.onXAxisTap = { showSamplingRateDialog() } + networkRenderer.onXAxisTap = { showSamplingRateDialog() } + 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. + binding.metricsSnapshot.setOnClickListener { exportSnapshot() } + binding.metricsExport.setOnClickListener { exportCsv() } + + // Arrows are the dependable way to move between pages: a swipe has to share the gesture + // with panning a zoomed chart and with the editor's drawer, and loses often enough to be + // annoying. + binding.metricsPrevious.setOnClickListener { step(-1) } + binding.metricsNext.setOnClickListener { step(1) } + // After the click listeners, which set isClickable themselves. + ViewCompat.setAccessibilityDelegate(binding.metricsPrevious, arrowAccessibilityDelegate) + ViewCompat.setAccessibilityDelegate(binding.metricsNext, arrowAccessibilityDelegate) + updateArrows(binding.metricsPager.currentItem) + + wireHelp(binding) + + memoryUsageWatcher.listener = memoryListener + networkUsageWatcher.listener = networkListener + powerUsageWatcher.listener = powerListener + } + + /** + * Gives every control in the strip its long-press help (ADFA-5510). + * + * Here rather than at each host, because this runs for the docked strip and for the floating + * window alike -- the window's own chrome already carries the `window-*` tags, and the carousel + * inside it is this same controller. + * + * The charts are absent from this list on purpose: MPAndroidChart swallows the touch events a + * view-level long press would need, so each renderer answers through the chart's gesture + * listener instead. + */ + @UiThread + private fun wireHelp(binding: LayoutMemUsageBinding) { + val context = binding.root.context + helpTargets(binding).forEach { (view, tag) -> + view.displayTooltipOnLongPress(context, tag) + } + } + + /** + * Every control that answers a long press, and the tag it answers with. + * + * One list drives the wiring, the unwiring and the test, because three hand-maintained copies + * is how a control added later gets help on binding and keeps a stale listener after unbinding. + * + * The charts are absent on purpose: MPAndroidChart swallows the touch events a view-level long + * press needs, so each renderer answers through the chart's own gesture listener instead. + */ + @VisibleForTesting + internal fun helpTargets(binding: LayoutMemUsageBinding): List> = + listOf( + // The strip itself, for the gaps its children do not cover. + binding.root to TooltipTag.CAROUSEL_PANEL, + binding.metricsTitle to TooltipTag.CAROUSEL_TITLE, + binding.metricsPrevious to TooltipTag.CAROUSEL_PREVIOUS, + binding.metricsNext to TooltipTag.CAROUSEL_NEXT, + binding.metricsSnapshot to TooltipTag.CAROUSEL_SNAPSHOT, + binding.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. + binding.metricsUndockedMessage to TooltipTag.CAROUSEL_UNDOCKED, + ) + + /** + * Unbinds only if [binding] is still the bound one. + * + * The undock and redock paths are two independent collectors of the same DockingManager + * emission with nothing ordering them, so the editor can rebind this controller to its own + * views before the floating window's onDestroyView runs. An unconditional unbind there stripped + * the editor's freshly bound carousel -- adapter null, listeners cleared, renderers detached -- + * leaving a dead strip until the next onResume. Every sibling teardown here is identity-guarded + * for the same reason. + */ + @UiThread + fun unbindIfBoundTo(binding: LayoutMemUsageBinding) { + if (this.binding === binding) { + unbind() + } + } + + /** + * Stops feeding the carousel and releases the bound views. Sampling is unaffected -- the + * watchers keep their history, so re-binding shows it in full. + */ + @UiThread + fun unbind() { + if (memoryUsageWatcher.listener === memoryListener) { + memoryUsageWatcher.listener = null + } + 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?.metricsExport?.setOnClickListener(null) + binding?.let { bound -> + helpTargets(bound) + // All but the undocked message: that view becomes visible *because* the carousel + // unbound, so clearing its listener here left the one control the user can still + // reach with no help at all. + .filterNot { (view, _) -> view === bound.metricsUndockedMessage } + .map { (view, _) -> view } + .forEach(View::clearLongPressHelp) + } + binding?.metricsPrevious?.setOnClickListener(null) + binding?.metricsNext?.setOnClickListener(null) + binding?.metricsPrevious?.let { ViewCompat.setAccessibilityDelegate(it, null) } + binding?.metricsNext?.let { ViewCompat.setAccessibilityDelegate(it, null) } + pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } + pageCallback = null + + binding?.metricsPager?.adapter = null + memoryRenderer.detach() + networkRenderer.detach() + powerRenderer.detach() + binding = null + } + + /** + * Moves the carousel by [delta] pages, stopping at either end. + */ + @UiThread + private fun step(delta: Int) { + val pager = binding?.metricsPager ?: return + val target = (pager.currentItem + delta).coerceIn(0, pages.lastIndex) + if (target != pager.currentItem) { + pager.setCurrentItem(target, true) + } + } + + /** + * Colours both arrows from the theme, rather than trusting the layout's `app:tint`. + * + * Falls back to the title's own colour if the attribute does not resolve: a window context + * carrying a different theme is exactly the case this is here for, and an unresolved colour + * attribute comes back as 0 -- transparent -- rather than as an error. + */ + @UiThread + private fun tintArrows(binding: LayoutMemUsageBinding) { + val fallback = binding.metricsTitle.currentTextColor + val value = TypedValue() + val color = + if (binding.root.context.theme + .resolveAttribute(R.attr.colorOnSurface, value, true) + ) { + value.data + } else { + fallback + } + ImageViewCompat.setImageTintList(binding.metricsPrevious, ColorStateList.valueOf(color)) + ImageViewCompat.setImageTintList(binding.metricsNext, ColorStateList.valueOf(color)) + } + + /** + * Dims the arrow that has nowhere to go, so the ends of the carousel are visible. + */ + @UiThread + private fun updateArrows(position: Int) { + val binding = this.binding ?: return + setPagingAvailable(binding.metricsPrevious, available = position > 0) + setPagingAvailable(binding.metricsNext, available = position < pages.lastIndex) + } + + /** + * Marks an arrow as leading somewhere, or not. + * + * Deliberately not `isEnabled`. A disabled View still consumes a touch and then drops it + * without calling any listener, so a long press on the arrow at either end of the carousel + * showed no tooltip -- and that is the arrow whose greying-out a user is likeliest to ask + * about. [isClickable] is the narrower statement and the true one: the arrow does not answer a + * tap, but it does answer a long press. [step] clamps anyway, so a tap on a dimmed arrow was + * already a no-op. + * + * Alpha alone would have lost the state for anyone who cannot see it, since a screen reader + * reads a node's flags rather than its opacity. [arrowAccessibilityDelegate] puts it back. + */ + @UiThread + private fun setPagingAvailable( + arrow: View, + available: Boolean, + ) { + arrow.alpha = if (available) 1f else DIMMED_ARROW_ALPHA + arrow.isClickable = available + } + + /** + * 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). + */ + @UiThread + fun showSamplingRateDialog() { + val context = binding?.root?.context ?: return + val rates = MetricsSamplingRates.ratesFor(IDEBuildConfigProvider.getInstance().deviceArch) + val current = memoryUsageWatcher.updateInterval + + val labels = + rates + .map { rate -> + val label = context.getString(string.metrics_sampling_rate_entry, formatInterval(rate.intervalMillis)) + if (rate.isAvailable) label else context.getString(string.metrics_sampling_rate_unavailable, label) + }.toTypedArray() + + val checked = rates.indexOfFirst { it.intervalMillis == current } + + // A choice adapter that knows which rows are selectable, rather than reaching into the + // list's laid-out children afterwards: getChildAt only sees rows that already exist, and a + // recycled row comes back enabled, so an unavailable rate could look selectable and then + // silently do nothing. + val adapter = + object : ArrayAdapter( + context, + android.R.layout.simple_list_item_single_choice, + android.R.id.text1, + labels, + ) { + override fun areAllItemsEnabled(): Boolean = false + + override fun isEnabled(position: Int): Boolean = rates.getOrNull(position)?.isAvailable ?: false + + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup, + ): View = + super.getView(position, convertView, parent).apply { + isEnabled = isEnabled(position) + alpha = if (isEnabled) 1f else UNAVAILABLE_RATE_ALPHA + } + } + + val dialog = + DialogUtils + .newMaterialDialogBuilder(context) + .setTitle(string.metrics_sampling_rate_title) + .setSingleChoiceItems(adapter, checked) { dismissable, which -> + val rate = rates[which] + if (rate.isAvailable) { + setSamplingInterval(rate.intervalMillis) + dismissable.dismiss() + } + // An unavailable rate stays listed and does nothing; the message below says why. + } + // No setMessage: an AlertDialog shows either a message or a list, never both, and + // the message silently wins. The unavailable entries carry the explanation instead. + .setNegativeButton(string.cancel) { dismissable, _ -> dismissable.dismiss() } + // A dialog has no free surface to long-press, so help is a button here rather than a + // gesture. It does not dismiss: the point is to read it and then choose a rate. + .setNeutralButton(string.help, null) + .create() + + // Not builder.show(): while the carousel is floating, `context` is the overlay window's + // context, which carries no activity token -- adding an ordinary application window + // against it throws BadTokenException. OverlayDialogs raises the dialog to the overlay + // window type first, which also puts it above the floating windows instead of behind them. + OverlayDialogs.show(dialog) + + // After show: an AlertDialog has no buttons to reach before then. + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { helpAnchor -> + // No haptic: this is a plain tap, and the default buzz is the platform's long-press + // feedback, which would mis-signal what the user just did. + showIdeCategoryTooltipIfPresent(context, helpAnchor, TooltipTag.CAROUSEL_RATE, playHapticFeedback = false) + } + } + + /** + * Applies a new sampling interval to every watcher. Their histories are discarded, because a + * buffer holding samples taken at two rates would misdate the older ones. + */ + @UiThread + private fun setSamplingInterval(intervalMillis: Long) { + // Clamped to what this device supports, which is decided here rather than in the watchers: + // the arch comes from IDEBuildConfigProvider, which a plain JVM test cannot resolve, so the + // watchers keep only an absolute floor to stop delay() spinning. This is the policy. + val supported = + MetricsSamplingRates.coerceToSupportedRange( + intervalMillis, + IDEBuildConfigProvider.getInstance().deviceArch, + ) + // Whether anything actually changes, because the clear below must not run when nothing + // does: each watcher's setter returns early on an unchanged value, so re-picking the rate + // already in effect -- which the dialog allows, and which a user opening it to read the + // options does -- cleared every build marker off a chart whose samples were untouched. + val changed = memoryUsageWatcher.updateInterval != supported + memoryUsageWatcher.updateInterval = supported + networkUsageWatcher.updateInterval = supported + powerUsageWatcher.updateInterval = supported + if (changed) { + // The annotations go with the samples they annotate. Left behind, task markers stood + // over a flat zero line with nothing to mark -- and this is the only route by which the + // store's throttle window is ever reset. + annotations?.clear() + } + refresh() + } + + private fun formatInterval(intervalMillis: Long): String = + if (intervalMillis < 1_000L) { + "%.1fs".format(intervalMillis / 1000.0) + } else { + "%ds".format(intervalMillis / 1_000L) + } + + /** + * Writes the visible chart to an image and offers it to another app (ADFA-5486). + * + * The bitmap has to be taken on the UI thread -- it is a copy of what the chart drew -- but + * encoding and writing the PNG must not be. That is a directory listing, a delete and a file + * write behind a full-chart encode, all of which used to run inside the click listener. + * + * @return whether a snapshot could be started. The write itself completes later. + */ + @UiThread + fun exportSnapshot(): Boolean { + val binding = this.binding ?: return false + if (snapshotInFlight) { + log.debug("Ignoring a snapshot request while one is already being written") + return false + } + val context = binding.root.context + val position = binding.metricsPager.currentItem + val page = pages.getOrNull(position) ?: return false + + val renderer = page.renderer + + val label = context.getString(page.title) + val bitmap = renderer.snapshot() + if (bitmap == null) { + // The application context, not the host: a toast's window is added against whatever + // context built it, and a floating window's context fixes a window type a toast + // cannot use. + Toast.makeText(context.applicationContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + return false + } + + // The write takes the application context because it outlives the click. The share does not: + // it ends in startActivity, which throws from a context with no task of its own unless it is + // given FLAG_ACTIVITY_NEW_TASK, so it keeps the context the carousel is hosted in. + val appContext = context.applicationContext + snapshotInFlight = true + scope.launch { + // The recycle wraps the whole body, not just the IO block. getChartBitmap hands back a + // fresh full-size ARGB_8888 copy of the plot on every tap -- the largest thing this + // class allocates -- and it is taken before the launch. Recycling inside + // withContext(Dispatchers.IO) meant a cancellation at that suspension point, which + // close() causes on undock and on activity destroy, skipped the finally entirely and + // left it to the collector. + try { + // Everything here is guarded: the scope has no exception handler, so anything + // escaping reaches the global crash reporter and is filed as a crash. + // MetricsSnapshot.write converts only IOException, and shareFile ends in + // startActivity, which throws ActivityNotFoundException on a device with nothing + // able to receive an image. + runCatching { + val file = + withContext(Dispatchers.IO) { + MetricsSnapshot.write(appContext, bitmap) + } + // Read through the property, not the local captured above: the export is no longer + // instantaneous, and the carousel can be unbound or rebound while the file is + // written, which would leave the share pointed at a dead host. + val host = this@MetricsCarouselController.binding?.root?.context + if (file == null || host == null) { + Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + return@runCatching + } + // A floating window's context has no task, so startActivity needs NEW_TASK + // there. Docked, the host is the activity and the flag would change its task + // affinity. + val extraFlags = + if (host.findActivityOrNull() == null) Intent.FLAG_ACTIVITY_NEW_TASK else 0 + IntentUtils.shareFile(host, file, MetricsSnapshot.MIME_TYPE, extraFlags) + }.onFailure { failure -> + if (failure is CancellationException) { + // Cleared before rethrowing: a cancelled export is finished either way, and + // leaving the flag set would refuse every later one for the life of the + // carousel. + snapshotInFlight = false + throw failure + } + log.error("Could not share the chart snapshot", failure) + Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + } + } finally { + bitmap.recycle() + } + snapshotInFlight = false + } + 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(context) + 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, for the export. + * + * The assembly itself is [MetricsSnapshotAssembler]: the same file is wanted by things that + * have no carousel bound at all (ADFA-5534, ADFA-5526), so it cannot live here. + */ + @UiThread + @VisibleForTesting + internal fun snapshot(context: Context): MetricsCsv.Snapshot = + MetricsSnapshotAssembler.assemble( + context = context, + memory = memoryUsageWatcher, + network = networkUsageWatcher, + power = powerUsageWatcher, + annotations = annotations, + ) + + /** + * Releases the controller for good. Distinct from [unbind], which runs on every dock, undock + * and recreation; this is the terminal teardown and cancels any snapshot still being written. + */ + @UiThread + fun close() { + unbind() + scope.cancel() + } + + /** + * Redraws every chart from the full history, for a host coming back to the foreground with + * samples gathered while it was away. + */ + @UiThread + fun refresh() { + memoryRenderer.rebuild() + networkRenderer.rebuild() + powerRenderer.rebuild() + } + + /** + * Rebuilds the memory chart for a changed set of watched processes. + */ + @UiThread + fun onWatchedProcessesChanged() { + memoryRenderer.rebuild() + } + + private companion object { + private val log = LoggerFactory.getLogger(MetricsCarouselController::class.java) + + /** The nearest [Activity] up the context chain, or `null` for a window context. */ + private tailrec fun Context.findActivityOrNull(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivityOrNull() + else -> null + } + + const val DIMMED_ARROW_ALPHA = 0.35f + + /** + * Reports an arrow that leads nowhere as disabled, and as offering no tap. + * + * The views stay touch-enabled so they can still answer a long press with their tooltip + * (see [setPagingAvailable]); without this, TalkBack would offer "double-tap to activate" + * on an arrow that does nothing, and give no hint that the carousel has an end. Reads + * [View.isClickable] rather than holding its own copy, so there is one source of truth. + */ + val arrowAccessibilityDelegate = + object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.isEnabled = host.isClickable + info.isClickable = host.isClickable + } + } + + /** Dims a rate this device cannot offer, so the list shows what the hardware costs. */ + const val UNAVAILABLE_RATE_ALPHA = 0.4f + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt new file mode 100644 index 0000000000..f5a6ddc746 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -0,0 +1,241 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import com.itsaky.androidide.R +import org.slf4j.LoggerFactory +import kotlin.math.hypot + +/** + * Host for the editor's metrics carousel, which claims horizontal gestures that begin inside it. + * + * A left-to-right swipe elsewhere in the editor opens the navigation drawer -- documented + * behaviour, shown in the editor's own onboarding text. Asking every ancestor not to intercept, for + * the rest of the gesture, keeps horizontal drags that start in this strip for the chart to pan + * with, and leaves the drawer gesture untouched everywhere else. + * + * This covers ancestors that intercept through the view hierarchy. The editor also runs an + * activity-level [android.view.GestureDetector] from `dispatchTouchEvent`, which never calls + * `onInterceptTouchEvent` and so cannot be stopped this way; `BaseEditorActivity` excludes this + * view's bounds there instead, the same way it already excludes the bottom-sheet tab strip. + * + * The vertical reveal drag is unaffected: `SwipeRevealLayout` only captures a vertical drag whose + * touch-down landed in its configured drag handle (the editor app bar), never in this strip. + */ +class MetricsCarouselLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : ConstraintLayout(context, attrs, defStyleAttr) { + /** + * Invoked on a two-finger tap anywhere in the carousel, which undocks it into a floating + * window (ADFA-5486). + */ + var onTwoFingerTap: (() -> Unit)? = null + + /** 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, 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 + * 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, + R.id.metrics_title, + R.id.metrics_previous, + R.id.metrics_next, + R.id.metrics_snapshot, + R.id.metrics_export, + ) + 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 + } + + private var twoFingerDownAt = 0L + + /** + * Where each of the two fingers landed. Both are tracked, not just the first: a pinch that + * keeps one finger still and spreads the other travels no distance at index 0, so watching + * only that finger let a zoom be read as a tap and undock the chart. + */ + private val twoFingerDownX = FloatArray(TWO_FINGERS) + private val twoFingerDownY = FloatArray(TWO_FINGERS) + + /** + * The pointers being tracked, by id rather than by index. + * + * A pointer's index is its slot in the current event and shifts when another pointer + * lifts; its id is stable for the life of that finger. Keyed by index, the travel check + * could compare one finger's current position against the other's starting point. + */ + private val twoFingerIds = IntArray(TWO_FINGERS) { MotionEvent.INVALID_POINTER_ID } + private var twoFingerTapCandidate = false + + /** + * The gesture is watched here rather than in [onInterceptTouchEvent] because ViewPager2's + * RecyclerView calls `requestDisallowInterceptTouchEvent` on its parents as soon as a second + * pointer lands, and a ViewGroup only calls `onInterceptTouchEvent` while that flag is + * clear. Watching from there saw the two fingers arrive and never saw them leave. + * `dispatchTouchEvent` is delivered first and is unaffected by the flag. + */ + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + trackTwoFingerTap(ev) + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + onTouchDown?.invoke() + } + return super.dispatchTouchEvent(ev) + } + + /** + * Recognises a two-finger tap: a second finger lands, neither travels far, and one lifts + * again quickly. Movement disqualifies it so a pinch is never mistaken for a tap, which + * matters because pinch-to-zoom shares this view. + */ + private fun trackTwoFingerTap(ev: MotionEvent) { + if (log.isDebugEnabled) { + log.debug( + "carousel touch action={} pointers={} candidate={}", + ev.actionMasked, + ev.pointerCount, + twoFingerTapCandidate, + ) + } + when (ev.actionMasked) { + // Start every gesture clean; a truncated one must not leave a candidate behind. + MotionEvent.ACTION_DOWN -> { + twoFingerTapCandidate = false + } + + MotionEvent.ACTION_POINTER_DOWN -> { + if (ev.pointerCount == TWO_FINGERS) { + twoFingerTapCandidate = true + twoFingerDownAt = ev.eventTime + for (pointer in 0 until TWO_FINGERS) { + twoFingerIds[pointer] = ev.getPointerId(pointer) + twoFingerDownX[pointer] = ev.getX(pointer) + twoFingerDownY[pointer] = ev.getY(pointer) + } + } else { + // A third finger is not this gesture. + twoFingerTapCandidate = false + } + } + + MotionEvent.ACTION_MOVE -> { + if (twoFingerTapCandidate) { + // Either finger travelling means this is a pinch, not a tap. Each is found + // by its id: a finger that has lifted is simply absent, rather than + // silently standing in for the other one. + for (pointer in 0 until TWO_FINGERS) { + val index = ev.findPointerIndex(twoFingerIds[pointer]) + if (index < 0) { + continue + } + val travel = + hypot( + ev.getX(index) - twoFingerDownX[pointer], + ev.getY(index) - twoFingerDownY[pointer], + ) + if (travel > touchSlop) { + twoFingerTapCandidate = false + break + } + } + } + } + + MotionEvent.ACTION_POINTER_UP -> { + val heldFor = ev.eventTime - twoFingerDownAt + if (log.isDebugEnabled) { + log.debug( + "carousel two-finger up: candidate={} heldFor={}ms limit={}ms", + twoFingerTapCandidate, + heldFor, + tapTimeout, + ) + } + // Cleared either way: a candidate that has outlasted the tap timeout is over, + // and leaving it set let a later part of the same gesture be measured against + // starting points that no longer mean anything. + val recognised = twoFingerTapCandidate && heldFor <= tapTimeout + twoFingerTapCandidate = false + if (recognised) { + log.debug("carousel two-finger tap recognised") + onTwoFingerTap?.invoke() + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + twoFingerTapCandidate = false + } + } + } + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + + // A person's two-finger tap is far slower than the single-finger tap timeout: the two + // fingers land and lift out of step. Anything shorter than a long press counts. + private val tapTimeout = ViewConfiguration.getLongPressTimeout().toLong() + + private companion object { + private val log = LoggerFactory.getLogger(MetricsCarouselLayout::class.java) + + const val TWO_FINGERS = 2 + } + } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt new file mode 100644 index 0000000000..6b7bc0c739 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -0,0 +1,1030 @@ +/* + * 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.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.TypedValue +import android.view.MotionEvent +import android.view.View +import androidx.annotation.CallSuper +import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting +import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.components.Legend +import com.github.mikephil.charting.components.LimitLine +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.IAxisValueFormatter +import com.github.mikephil.charting.listener.ChartTouchListener +import com.github.mikephil.charting.listener.OnChartGestureListener +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis +import com.itsaky.androidide.utils.resolveAttr +import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +/** + * Shared behaviour for the charts on the editor's metrics carousel. + * + * A renderer holds no sample state -- the watchers own the history -- so a chart view is attached + * when its carousel page binds and detached when the page is recycled, and [rebuild] can redraw the + * whole series from scratch at any time. That is what makes a chart safe as a recycled page. + * + * Subclasses supply the data and whatever axis configuration is specific to them; everything the + * charts have in common lives here, so a change to how metrics charts look or behave is made once. + * + * All methods must be called on the UI thread. MPAndroidChart is not thread-safe; see + * [SafeLineChart]. + */ +abstract class MetricsChartRenderer( + // A provider, not a value: the sampling rate is user-settable, and a captured interval leaves + // the axis labelling ages with the old spacing -- reading -54s where the sample is really 295 + // seconds old. + private val sampleIntervalMillis: () -> Long, + private val annotations: MetricsAnnotationStore? = null, + private val nowMillis: () -> Long = SystemClock::elapsedRealtime, +) { + /** + * Invoked when the chart's x axis is tapped, which opens the sampling-rate chooser + * (ADFA-5486). Set by the host; the axis band is worked out here because only the chart knows + * where it drew it. + */ + var onXAxisTap: (() -> Unit)? = null + + /** + * The help tag for this page's plot, shown on a long press (ADFA-5510). + * + * Routed through the chart's own gesture listener rather than [android.view.View.setOnLongClickListener]: + * MPAndroidChart's `BarLineChartBase.onTouchEvent` hands the event to its touch listener and + * never calls `super`, so the framework's long-press detection never runs and a view listener + * would be installed, look wired, and never fire. + */ + protected abstract val helpTag: String + + /** + * The help tag for a long press at [y], or `null` if this page has none. + * + * Separated from showing the tooltip so it can be tested: TooltipManager reads the docs + * database from device storage in its static initialiser and cannot be loaded off-device. + */ + @VisibleForTesting + internal fun helpTagAt(y: Float): String? { + // The axis band answers for the sampling rate, the plot for the metric itself, matching + // where a tap goes. + return if (isOnAxisBand(y)) TooltipTag.CAROUSEL_AXIS_TIME else helpTag + } + + /** + * Whether [y] landed on the x axis band rather than in the plot. + * + * One predicate, because the tap that opens the sampling-rate chooser and the long press that + * explains it have to agree on where that band is: written twice, they can drift apart and the + * tooltip then describes a control the tap no longer reaches. + * + * Bounded below, not just above. Everything under the plot used to count, and the legend lives + * there too -- MPAndroidChart aligns it to the bottom by default, under the axis labels. So + * tapping the legend, which is the one thing in a chart a reader expects to be tappable, opened + * the sampling-rate chooser; picking a rate there clears every buffer, and the user loses the + * history they were looking at for an action they did not ask for. + * + * The band stops at the legend's top edge, and is never narrower than one axis label, so a + * legend that measures larger than expected cannot squeeze the rate chooser out of reach. + */ + private fun isOnAxisBand(y: Float): Boolean { + val chart = this.chart ?: return false + val top = chart.viewPortHandler.contentBottom() + val legend = chart.legend + // What the chart reserves for the legend at the bottom, in pixels. mNeededHeight already + // includes yOffset: the last thing Legend.calculateDimensions does, on both of its + // orientation branches, is `mNeededHeight += mYOffset` (3.1.0.21, offsets 879-887, reached + // from the horizontal branch by `366: goto 877`). Adding the offset again reserved it twice + // and took a strip the height of yOffset off the bottom of the tap target. + val reservedForLegend = if (legend.isEnabled) legend.mNeededHeight else 0f + val bottom = maxOf(chart.height - reservedForLegend, top + chart.xAxis.textSize) + return y >= top && y < bottom + } + + /** + * Whether the user has pinched this chart. + * + * Recorded from the scale gesture rather than read back from the chart. Showing a window of + * [VISIBLE_SAMPLES] out of a buffer of thousands *is* a zoom as far as the chart is concerned -- + * scaleX sits around 166 at rest -- so testing scaleX for "has the user zoomed" is always true, + * which silently disabled the auto-follow window and handed every horizontal drag to the chart. + */ + private var userHasZoomed = false + + /** + * The font scale [applyTextScale] last wrote to the attached chart, or NaN if none. + * + * [redraw] runs once per sampling tick per attached page, and re-applying the scale there + * rewrites nine chart properties and re-measures four text sizes to catch a change that + * happens at most a handful of times in a session. + * + * [detach] resets it, but nothing depends on that: [attach] ends in `rebuild`, which reaches + * `setData`, which applies the scale unconditionally. The reset keeps a detached renderer from + * holding a claim about a chart it no longer has, and is not what makes a rebind re-apply. + */ + private var appliedTextScale = Float.NaN + + /** + * The gesture listener installed on the attached chart, kept so [detach] can reach its + * pending hold. Nothing else can: it lives on the chart, and a rebind installs a new one. + */ + private var axisTapListener: XAxisTapListener? = null + + /** + * How the chart's hold shows its help. + * + * A seam, not a setting: `TooltipManager` reads the docs database from device storage in its + * static initialiser and cannot be loaded off-device, so without this the whole deferred-help + * path -- when it fires, when it is given up -- could not be tested at all. + */ + @VisibleForTesting + internal var showHelp: (Context, SafeLineChart, String) -> Unit = + { context, anchor, tag -> showIdeCategoryTooltipIfPresent(context, anchor, tag) } + + /** + * The 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. + */ + @UiThread + fun attach(chart: SafeLineChart) { + // A rebind can attach the replacement before the view it replaced is recycled, so the + // outgoing chart is let go of here rather than waiting for a [detachIfAttached] that, by + // then, no longer names it. + // + // The whole teardown, not just the listener. [detach] also clears [userHasZoomed], and + // releasing only the listener leaked it onto the replacement: a user who had panned once + // got a chart whose follow-window was disabled for good, because showNewestWindow returns + // early on the flag and every later redraw takes the same early return. That is the + // oldest-samples symptom this ticket was filed for -- reachable only after a pan, which is + // why the resume paths reproduce it and a fresh chart never does. Subclasses clear their own + // per-chart state through the same override. + // + // Unconditionally, including when the same chart is handed back. Skipping the teardown + // there let [configure] install a second gesture listener while the first stayed queued on + // the main thread with a hold nothing could reach, and added a second layout listener that + // one removeOnLayoutChangeListener cannot undo. + // + // The one thing that must survive it is the user's own viewport. detach() clears + // userHasZoomed, which is what turns the auto-follow window back on, so a rebind of an + // already-bound holder would snap a chart the user had panned back to the newest samples. + val sameChart = this.chart === chart + val hadZoomed = userHasZoomed + detach() + if (sameChart) { + userHasZoomed = hadZoomed + } + this.chart = chart + configure(chart) + chart.addOnLayoutChangeListener(newestWindowOnLayout) + rebuild() + } + + /** + * Detaches the current chart. Sample history is unaffected; a later [attach] renders it in full. + */ + @UiThread + @CallSuper + open fun detach() { + userHasZoomed = false + appliedTextScale = Float.NaN + // Memoised per chart, so it has to go with the chart. Left set, a rebind onto a fresh + // SafeLineChart asking for the same inset takes reserveTopSpace's early return and never + // calls setExtraTopOffset on it -- and nothing else does, unlike appliedTextScale, which + // setData re-applies. The battery readout then covers the right axis's topmost label again, + // which is the whole reason the inset exists. + reservedTopPixels = Float.NaN + // A hold counting down survives the chart it was started on: the timer is on the main + // thread's queue. Left running it shows the outgoing page's help over whatever replaced + // it, and the replacement's listener -- a new object with its own null pendingHelp -- + // could never have cancelled it. + axisTapListener?.cancelPendingHelp() + axisTapListener = null + // The chart holds the listener, and the listener is an inner class holding this renderer, + // so a detached chart left with it keeps the whole renderer alive -- and answers a later + // press through a listener whose own chart reference is now null. + chart?.onChartGestureListener = null + chart?.onSecondPointerDown = null + chart?.removeOnLayoutChangeListener(newestWindowOnLayout) + chart = null + } + + /** + * Detaches [chart] only if it is the currently attached one. + * + * A recycling container needs this: RecyclerView can bind a replacement view before recycling + * the one it replaced, and an unconditional detach would then drop the new chart. + */ + @UiThread + fun detachIfAttached(chart: SafeLineChart) { + if (this.chart === chart) { + detach() + } + } + + /** + * Rebuilds the chart's series from the full current history. + */ + @UiThread + abstract fun rebuild() + + /** + * Returns the chart to its unzoomed state. + */ + @UiThread + fun resetZoom() { + userHasZoomed = false + chart?.fitScreen() + chart?.let { showNewestWindow(it) } + } + + /** + * An image of the chart as it currently looks, or `null` when nothing is attached + * (ADFA-5486's snapshot export). + */ + @UiThread + fun snapshot(): Bitmap? = chart?.chartBitmap + + /** + * Applies the configuration every metrics chart shares. Subclasses override to add their own -- + * a value formatter, axis range -- and must call through. + */ + @CallSuper + protected open fun configure(chart: SafeLineChart) { + chart.apply { + val colorAccent = context.resolveAttr(R.attr.colorAccent) + + description.isEnabled = false + xAxis.axisLineColor = colorAccent + axisRight.axisLineColor = colorAccent + + // Zoom the time axis only. Zooming the value axis on a memory or throughput chart just + // makes the numbers lie about their own scale; time is the axis worth magnifying. + setScaleXEnabled(true) + setScaleYEnabled(false) + setPinchZoom(false) + // Panning is what makes zoom usable: without it you magnify and are then stranded. + // MetricsCarouselLayout decides per gesture whether a horizontal drag pans the chart or + // pages the carousel. + isDragEnabled = true + setDoubleTapToZoomEnabled(false) + + setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) + setDrawGridBackground(true) + + // Below the plot, which is also where a tap opens the sampling-rate chooser + // (ADFA-5486). The two have to agree: they disagreed once, and the gesture was + // unreachable at the labels it is named for. + xAxis.position = XAxis.XAxisPosition.BOTTOM + + // The right axis carries the labels. The left is unused 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) + + // A dot, not the 15dp square each renderer used to ask for per dataset (ADFA-5553): + // the squares crowded the labels beside them and the axis below. + // + // On the legend, never on a dataset. LegendRenderer resolves each entry as + // `isNaN(entry.formSize) ? legend.formSize : entry.formSize`, and takes the legend's + // form only for an entry left at DEFAULT -- so a dataset that sets either one wins + // silently. The size itself is set in [applyTextScale], which has to re-apply it. + legend.form = Legend.LegendForm.CIRCLE + // Kept at the 1f the renderers used to ask for, down from the 3f a Legend defaults to. + // NaN is a dataset entry's "defer to the legend" marker and is not a state the legend's + // own field can be in, so this is a real change rather than a guard against one -- inert + // while the form is a circle, and load-bearing only if anyone chooses LINE. + legend.formLineWidth = 1f + + onChartGestureListener = XAxisTapListener(this).also { axisTapListener = it } + // A two-finger tap is the carousel's undock gesture, and it starts as a press like any + // other. Without this the stand-in tap fired for it and opened the sampling-rate + // chooser -- so one gesture both undocked the strip and cleared every buffer. + onSecondPointerDown = { axisTapListener?.abandonGesture() } + + xAxis.valueFormatter = + ElapsedTimeFormatter(sampleIntervalMillis, context.getString(R.string.metrics_axis_now)) + // One label per 15 samples keeps the window readable without crowding. + xAxis.granularity = X_LABEL_GRANULARITY_SAMPLES + xAxis.isGranularityEnabled = true + } + } + + /** + * Scrolls the viewport to the newest samples, showing [VISIBLE_SAMPLES] of them. + * + * The watchers retain thousands of samples (ADFA-5486), far more than is legible at once in a + * 200dp strip and more than is cheap to draw -- MPAndroidChart clips drawing to the visible x + * range, so a window keeps the cost independent of how much is retained. + */ + private fun showNewestWindow(chart: SafeLineChart) { + // Once the user has zoomed in, the view is theirs. Re-centring on every redraw would drag + // them back to the newest samples once a second, which makes zooming useless. + if (userHasZoomed) { + return + } + + // xMax is the newest sample's index. entryCount would be the total across every series -- + // 7200 for the network chart's two -- which would scroll the window off the end of the data. + val newestIndex = chart.data?.xMax ?: return + if (newestIndex < VISIBLE_SAMPLES) { + return + } + + // Before the first layout there is no plot area to place a window in, and applying one + // anyway is worse than waiting: the scale is clamped against an empty content rect, and the + // layout that follows resets the chart's transform. [newestWindowOnLayout] re-applies it as + // soon as there is something to apply it to (ADFA-5515). + if (!chart.viewPortHandler.hasChartDimens()) { + return + } + + chart.setVisibleXRangeMaximum(VISIBLE_SAMPLES.toFloat()) + // Not moveViewToX: its scroll is deferred to a later frame and would be converted through + // a different transform from the scale just set here (ADFA-5515). + chart.moveViewToXNow(newestIndex - VISIBLE_SAMPLES.toFloat() + 1f) + } + + /** + * Re-applies the newest window whenever the chart is laid out. + * + * A layout that changes the chart's size resets its transform, which drops the window and shows + * the whole buffer from its oldest end. Nothing put it back until the next sample landed a + * redraw, so every rebind -- and the carousel is rebound on every resume -- opened on an empty + * plot for a second or more (ADFA-5515). + */ + private val newestWindowOnLayout = + View.OnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + val chart = view as? SafeLineChart ?: return@OnLayoutChangeListener + if (chart === this.chart) { + showNewestWindow(chart) + } + } + + /** + * The sample indices currently on screen, for a series of [sampleCount] samples. + * + * The buffer holds thousands of samples and the window shows sixty of them, so anything derived + * from "all the data" -- an axis range, a peak -- describes a chart the user is not looking at. + * + * While the chart is following the newest samples this is [VISIBLE_SAMPLES] at the end of the + * buffer by definition; only once the user has pinched or panned is the chart itself asked. + */ + @VisibleForTesting + internal fun visibleSampleRange( + chart: SafeLineChart, + sampleCount: Int, + ): IntRange { + if (sampleCount <= 0) { + return IntRange.EMPTY + } + + // Until the user drives the viewport themselves, the window is exactly what + // showNewestWindow put there, and saying so is both cheaper and more reliable than asking + // the chart -- which reports the whole data range until it has been laid out and drawn. + if (!userHasZoomed) { + return (sampleCount - VISIBLE_SAMPLES).coerceAtLeast(0)..(sampleCount - 1) + } + + val from = floor(chart.lowestVisibleX).toInt().coerceIn(0, sampleCount - 1) + val to = ceil(chart.highestVisibleX).toInt().coerceIn(from, sampleCount - 1) + return from..to + } + + /** + * Turns a tap in the x-axis band into [onXAxisTap]. + * + * The axis is drawn by the chart rather than being a view of its own, so there is nothing to + * attach a click listener to. `contentBottom` is the bottom of the plotting area and the axis + * is drawn below it (see [configure]), so a tap lower than that landed on the axis. + * + * This used to test `contentTop`, which put the only way to reach the sampling-rate chooser in + * an empty band at the *opposite* end of the chart from the labels it is named for. The strip + * under the plot had been left alone for the carousel swipe; paging is by the arrows now, so it + * is free. + */ + private inner class XAxisTapListener( + private val chart: SafeLineChart, + ) : OnChartGestureListener { + // An explicit handler, not View.postDelayed, which parks work on an unattached view's + // HandlerActionQueue until it attaches. The chart that receives a long press is attached, + // so that would happen to work -- but only by accident, and it puts the hold out of reach + // of a test. The same handler [performOnHold] uses, for the same reason. + private val handler = Handler(Looper.getMainLooper()) + + /** The deferred half of a long press, waiting out the rest of the hold. */ + private var pendingHelp: Runnable? = null + + /** + * The stand-in tap waiting for the next turn of the looper. + * + * Held for the same reason [performOnHold] holds its click: posted rather than run inline, + * it outlives the dispatch that queued it, so a [detach] landing in between would otherwise + * still open the sampling-rate chooser for a chart the renderer no longer has -- and that + * chooser clears every sample buffer. + */ + private var pendingTap: Runnable? = null + + /** Whether this gesture already showed help, so its lift must not also count as a tap. */ + private var helpShown = false + + /** Whether the press that became a long press had started on the axis band. */ + private var pendingTapOnAxis = false + + override fun onChartSingleTapped(me: MotionEvent?) { + val y = me?.y ?: return + if (isOnAxisBand(y)) { + onXAxisTap?.invoke() + } + } + + override fun onChartGestureStart( + me: MotionEvent?, + lastPerformedGesture: ChartTouchListener.ChartGesture?, + ) = Unit + + override fun onChartGestureEnd( + me: MotionEvent?, + lastPerformedGesture: ChartTouchListener.ChartGesture?, + ) { + cancelPendingHelp() + // Lifted before the hold completed: the detector ate the tap, so stand in for it. + // + // Only for a gesture that was still a long press when it ended. A press that became a + // pan or a pinch is not a tap by any reading, and standing in for one there opened the + // sampling-rate chooser from a drag -- which clears every sample buffer, the exact + // history loss [isOnAxisBand] was narrowed to prevent. [onChartTranslate] and + // [onChartScale] give up the stand-in as the gesture escalates; this is the check for + // an escalation neither of them reports. + // A cancel is not a lift. ChartTouchListener.endAction runs for ACTION_CANCEL as well + // as ACTION_UP -- case 3 and case 1 of the same tableswitch, both reaching it with the + // original event -- and it reports mLastGesture untouched, because startAction never + // resets it. So a press an ancestor steals mid-gesture (the reveal layout, the bottom + // sheet, the pager) arrived here looking exactly like a finger lifted early, and stood + // in for a tap the user never completed. The chooser it opens clears every buffer. + val lifted = me?.actionMasked != MotionEvent.ACTION_CANCEL + if (lifted && + !helpShown && + pendingTapOnAxis && + lastPerformedGesture == ChartTouchListener.ChartGesture.LONG_PRESS + ) { + // Posted, not called here. This runs inside the chart's onTouchEvent, and the tap + // opens a dialog; showing one mid-dispatch leaves the chart's touch state and its + // velocity tracker part-way through a gesture. performOnHold posts its click for + // the same reason, and the two paths should not disagree. + val tap = + Runnable { + pendingTap = null + onXAxisTap?.invoke() + } + pendingTap = tap + handler.post(tap) + } + helpShown = false + pendingTapOnAxis = false + } + + /** + * Drops a hold that has not fired and the tap it was standing in for. + * + * For the end of a gesture, for a gesture that turns into something else, and for + * [detach], which is the one caller outside the touch stream. + */ + fun cancelPendingHelp() { + pendingHelp?.let(handler::removeCallbacks) + pendingHelp = null + pendingTap?.let(handler::removeCallbacks) + pendingTap = null + } + + override fun onChartLongPressed(me: MotionEvent?) { + val event = me ?: return + val y = event.y + val tag = helpTagAt(y) ?: return + + // This arrives at the platform's own timeout -- 400ms by default, a brisk tap -- and + // help at that speed is what ADFA-5554 is about. Wait out the rest of the hold and + // show it only if the finger is still down; [onChartGestureEnd] cancels otherwise. + cancelPendingHelp() + val onAxisBand = isOnAxisBand(y) + // From the event's own downTime, not by subtracting the platform timeout from the hold. + // GestureDetector does not report a long press exactly getLongPressTimeout() after the + // finger landed: below Android Q it adds TAP_TIMEOUT, and it caches LONGPRESS_TIMEOUT + // in a static read once at class-load, so a user who lengthens the accessibility + // touch-and-hold delay moves the buttons' hold and not this one. minSdk here is 28. + val elapsed = SystemClock.uptimeMillis() - event.downTime + val remaining = (longPressHelpTimeoutMillis() - elapsed).coerceAtLeast(0L) + pendingHelp = + Runnable { + pendingHelp = null + helpShown = true + // Haptic feedback left at its default, unlike every view-based help site, + // which passes false. Those rely on View.performLongClick buzzing for them; + // BarLineChartBase.onTouchEvent never calls super, so the framework's long + // press -- and its feedback -- never runs here and this is the only thing + // that provides it. + showHelp(chart.context, chart, tag) + }.also { handler.postDelayed(it, remaining) } + + // GestureDetector has already decided this gesture is a long press, so it will not + // report the tap that would have opened the sampling-rate chooser. Remember whether + // this one was headed there, so a finger lifted before the hold completes still gets + // the tap it asked for rather than nothing at all. + pendingTapOnAxis = onAxisBand + } + + override fun onChartDoubleTapped(me: MotionEvent?) = Unit + + override fun onChartFling( + me1: MotionEvent?, + me2: MotionEvent?, + velocityX: Float, + velocityY: Float, + ) = Unit + + override fun onChartScale( + me: MotionEvent?, + scaleX: Float, + scaleY: Float, + ) { + userHasZoomed = true + abandonGesture() + } + + override fun onChartTranslate( + me: MotionEvent?, + dX: Float, + dY: Float, + ) { + // A pan is the user driving the viewport just as much as a pinch is. Left unrecorded, + // showNewestWindow dragged them back to the newest samples on the next tick -- once a + // second -- so panning a zoomed chart appeared not to work at all. + userHasZoomed = true + abandonGesture() + } + + /** + * Gives up the deferred help and the stand-in tap, because this gesture has become + * something neither is meant for. + * + * A drag or a pinch can begin from a press the detector already called a long press, and + * the finger is then still down: the hold would go on to open a tooltip over a chart the + * user is in the middle of panning, and the lift would open the sampling-rate chooser. + */ + fun abandonGesture() { + cancelPendingHelp() + pendingTapOnAxis = false + } + } + + /** + * Labels the x axis by age rather than by sample index, which is meaningless to a reader and + * would run to 3599 at the current retention. + */ + private class ElapsedTimeFormatter( + private val sampleIntervalMillis: () -> Long, + private val nowLabel: String, + ) : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String { + val newestIndex = (axis?.mAxisMaximum ?: value) + val secondsAgo = ((newestIndex - value) * sampleIntervalMillis() / 1000f).roundToLong() + return if (secondsAgo <= 0L) nowLabel else "-%ds".format(secondsAgo) + } + } + + /** + * Installs [datasets] on [chart] and applies the theme colours, then redraws. + */ + 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) + legend.textColor = textColor + // MPAndroidChart defaults every component's text to Color.BLACK. The y axis and legend + // were given a themed colour and the x axis never was, so its labels have always been + // drawn black on a near-black surface -- which is the "x axis has no labels" of + // ADFA-5486. They were there the whole time, just invisible. + xAxis.textColor = textColor + + data.setValueTextColor(textColor) + applyTextScale(this) + styleValueAxes(this, textColor) + setBackgroundColor(bgColor) + setGridBackgroundColor(bgColor) + } + // 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() + } + + /** + * Sizes every piece of text the chart draws, following the system font scale up to a ceiling. + * + * MPAndroidChart sizes its text in dp, so nothing it draws responded to the font scale at all: + * a user who asked for larger text got it everywhere in the IDE except inside these plots, + * where the text is already the smallest on the screen (ADFA-5527). + * + * Followed only to [MAX_TEXT_SCALE], because a plot is dense by nature and the strip is a + * fixed [R.dimen.editor_mem_usage_view_height]. At the full 2.0 the axis labels collide with + * each other and the eight staggered annotation rows overlap, so honouring the scale + * literally would make the chart less readable rather than more. A ceiling gives most of the + * benefit and keeps the plot legible at the extreme. + */ + @UiThread + private fun applyTextScale(chart: SafeLineChart) { + val scale = textScaleFor(chart.context) + appliedTextScale = scale + chart.legend.textSize = BASE_TEXT_SIZE_DP * scale + // Scaled with its label: a fixed dot beside text at 1.5 reads as though it were shrinking. + // See [configure] for why the size is the legend's business and not a dataset's. + chart.legend.formSize = BASE_LEGEND_FORM_DP * scale + // The gaps go with them. Left fixed they close up as the text grows -- the same argument + // as the dot, applied to the space around it. + // + // Which means the legend's furniture is no longer a fixed budget. Three entries' dots and + // gaps came to 3*(15+5) + 2*6 = 72dp before this ticket, at every scale; they now come to + // 51dp times the scale. That is narrower up to 1.41 and wider above it -- 76.5dp at the 1.5 + // ceiling. The smaller dot buys width at the sizes most people run and gives 4.5dp of it + // back at the extreme. Whether that clips on the narrowest screen we support has not been + // measured; if it does, the answer is a ceiling on the scale used here, not a smaller dot. + chart.legend.formToTextSpace = BASE_LEGEND_FORM_TO_TEXT_DP * scale + chart.legend.xEntrySpace = BASE_LEGEND_ENTRY_SPACE_DP * scale + // The gap above the legend, scaled for the same reason as the gaps beside the dot. It has + // no bearing on where [isOnAxisBand] puts the band: the legend folds yOffset into + // mNeededHeight itself, so the band already accounts for it at whatever size it is. + chart.legend.yOffset = BASE_LEGEND_Y_OFFSET_DP * scale + chart.xAxis.textSize = BASE_TEXT_SIZE_DP * scale + chart.axisLeft.textSize = BASE_TEXT_SIZE_DP * scale + chart.axisRight.textSize = BASE_TEXT_SIZE_DP * scale + chart.data?.setValueTextSize(BASE_VALUE_TEXT_SIZE_DP * scale) + + // Bigger text needs fewer labels. Growing the text alone left the count untouched, so the + // memory page's nine value labels went from 29px apart to 6px -- crowded enough that the + // change made the axis worse rather than better. The count is a hint: granularity still + // has the last word, which is what keeps the temperature axis on whole degrees. + val labels = (BASE_LABEL_COUNT / scale).roundToInt().coerceAtLeast(MIN_LABEL_COUNT) + chart.axisLeft.setLabelCount(labels, false) + chart.axisRight.setLabelCount(labels, false) + } + + /** + * Applies the font scale only if it has moved since the last time it was applied. + * + * For [redraw], which runs per sample. [setData] applies unconditionally: it installs fresh + * [LineData], and the value text size is a property of the data rather than of the chart. + */ + @UiThread + private fun applyTextScaleIfChanged(chart: SafeLineChart) { + if (textScaleFor(chart.context) != appliedTextScale) { + applyTextScale(chart) + } + } + + /** + * Colours the value axes' labels. Called from [setData], not [configure], because the styling + * here is re-applied on every redraw and would otherwise overwrite whatever a subclass had set + * 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). + * + * Annotations are stored by wall-clock time, not sample position, because the ring buffer + * shifts under them. Age converts to an x position here: the newest sample sits at the buffer's + * last index, and every [sampleIntervalMillis] before that is one index to the left. Anything + * older than the buffer holds falls outside the axis and is not drawn. + * + * 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 + val newestIndex = chart.data?.xMax ?: return + + chart.xAxis.removeAllLimitLines() + + val interval = sampleIntervalMillis() + // Back as far as the oldest sample on screen, and no further. Spanning the whole buffer + // meant building a LimitLine and a DashPathEffect for every annotation the store holds on + // every redraw, almost all of them clipped off screen; spanning a fixed sixty-one samples + // from now was wrong in the other direction, because a panned viewport shows older + // samples than that and their markers were dropped before their x was worked out. + val visible = visibleSampleRange(chart, newestIndex.toInt() + 1) + val oldestVisibleIndex = if (visible.isEmpty()) newestIndex else visible.first.toFloat() + val spanMillis = ((newestIndex - oldestVisibleIndex).toLong() + 1L) * interval + val now = nowMillis() + // 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 + val x = newestIndex - samplesAgo + if (x < 0f) { + return@forEach + } + + chart.xAxis.addLimitLine( + LimitLine(x, labelFor(chart, annotation)).apply { + val markerColor = markerColors.getValue(annotation.kind) + lineWidth = ANNOTATION_LINE_WIDTH + lineColor = markerColor + 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 = annotationRowHeightFor(chart.context) * slotFor(annotation.sequence) + }, + ) + } + } + + /** + * 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. + */ + 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, + 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) + // Re-read the font scale here too, not only in [setData]. EditorActivityKt declares + // fontScale in configChanges, so the activity is never recreated for one -- and this is + // the only path a running chart takes per sample. Left out, a live scale change moved the + // annotation rows, which [applyAnnotations] re-reads below, while none of the text or the + // legend dot it spaces them for ever grew. Only when it has actually moved, though: this + // runs on every tick of every attached page and the answer changes a handful of times a + // session. + applyTextScaleIfChanged(chart) + chart.apply { + data.notifyDataChanged() + notifyDataSetChanged() + } + // Re-applied on every redraw, not just when data is set: the visible x range is held as a + // scale factor, so a layout change (a rotation, say) leaves the window pointing at a + // different part of the history. Landscape showed samples from half an hour ago. + applyAnnotations(chart) + showNewestWindow(chart) + chart.invalidate() + } + + @VisibleForTesting + internal companion object { + /** + * Samples shown at once. Thousands are retained; a minute is what fits legibly in the strip. + */ + const val VISIBLE_SAMPLES = 60 + + const val X_LABEL_GRANULARITY_SAMPLES = 15f + + 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, at a font scale of 1. The label text is [BASE_TEXT_SIZE_DP], so this + * leaves a little air between rows; it is scaled with the text by [annotationRowHeightFor], + * or the rows would overlap exactly when the labels grew (ADFA-5527). + */ + const val ANNOTATION_LABEL_ROW_HEIGHT_DP = 12f + + /** MPAndroidChart's own default for axis and legend text, which this matches at scale 1. */ + const val BASE_TEXT_SIZE_DP = 10f + + /** MPAndroidChart's own default for value labels. */ + const val BASE_VALUE_TEXT_SIZE_DP = 9f + + /** + * The legend's dot, in dp, at a font scale of 1. + * + * MPAndroidChart's own default, and about the cap height of [BASE_TEXT_SIZE_DP] text, so the + * marker reads as part of its label rather than as a block beside it. + * + * The dot and its label do not in fact share a ceiling, whatever [applyTextScale] reads + * like: `ComponentBase.setTextSize` clamps to 6..24dp on the way in and `Legend.setFormSize` + * does not. It makes no difference while [BASE_TEXT_SIZE_DP] times [MAX_TEXT_SCALE] stays + * under 24 -- 15dp today -- and above that the text would stop growing while the dot kept + * going, until the marker was larger than the label it is meant to sit inside. Raising + * [BASE_TEXT_SIZE_DP] past 16 means clamping this too. + */ + const val BASE_LEGEND_FORM_DP = 8f + + /** The gap between a legend dot and its label, in dp, at a font scale of 1. */ + const val BASE_LEGEND_FORM_TO_TEXT_DP = 5f + + /** The gap between one legend entry and the next, in dp, at a font scale of 1. */ + const val BASE_LEGEND_ENTRY_SPACE_DP = 6f + + /** The gap the legend keeps above itself, in dp, at a font scale of 1. */ + const val BASE_LEGEND_Y_OFFSET_DP = 3f + + /** + * The most the chart will grow its text by, whatever the system font scale. + * + * 1.5 rather than the platform's maximum of 2.0: see [applyTextScale]. Eight annotation + * rows at 1.5 still fit the plot, where at 2.0 they do not. + */ + const val MAX_TEXT_SCALE = 1.5f + + /** Value-axis labels at a font scale of 1, which is MPAndroidChart's own default. */ + const val BASE_LABEL_COUNT = 6 + + /** Never fewer than this, or the axis stops conveying a scale at all. */ + const val MIN_LABEL_COUNT = 3 + + /** + * The font scale the charts follow: the system's, held to [MAX_TEXT_SCALE]. + * + * Both bounds are deliberate. The ceiling is the fixed-height strip's trade-off + * (ADFA-5634); the floor is legibility -- this text is already the smallest on the screen, + * so following a reduction below 1.0 makes it unreadable rather than merely small. Pinned + * by `a font scale below one does not shrink the chart further`. + */ + @JvmStatic + fun textScaleFor(context: Context): Float = + context.resources.configuration.fontScale + .coerceIn(1f, MAX_TEXT_SCALE) + + /** One annotation row, scaled with the label text it has to leave room for. */ + @JvmStatic + fun annotationRowHeightFor(context: Context): Float = ANNOTATION_LABEL_ROW_HEIGHT_DP * textScaleFor(context) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt new file mode 100644 index 0000000000..63113b946d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -0,0 +1,296 @@ +/* + * 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.Color +import androidx.annotation.UiThread +import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.IAxisValueFormatter +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.NetworkUsageWatcher.NetworkUsage +import kotlin.math.ceil +import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Renders [NetworkUsageWatcher] samples into a [SafeLineChart] on a logarithmic scale (ADFA-5489). + * + * Traffic spans orders of magnitude -- a few hundred bytes of chatter next to a multi-megabyte + * Gradle download -- so a linear axis flattens everything but the largest burst into the baseline. + * MPAndroidChart has no logarithmic axis, so the plotted value is [log10] of the byte count and + * [BytesAxisFormatter] turns the axis labels back into byte units. + * + * Zero is the common sample, not an edge case: an idle IDE transfers nothing, and `log10(0)` is + * negative infinity. Values are therefore `log10(bytes + 1)`, which puts a zero sample at exactly + * `0.0` and keeps the line continuous. + * + * Like [MemoryUsageChartRenderer] this holds no sample state -- [NetworkUsageWatcher] owns the + * history -- so a chart can be attached, detached and recycled by the metrics carousel without + * losing anything. + * + * All methods must be called on the UI thread; MPAndroidChart is not thread-safe (see + * [SafeLineChart]). + * + * @param usageProvider Supplies the current sample history. + */ +class NetworkUsageChartRenderer( + private val usageProvider: () -> NetworkUsage, + annotations: MetricsAnnotationStore? = null, + private val sampleInterval: () -> Long = { NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL }, +) : MetricsChartRenderer( + sampleIntervalMillis = sampleInterval, + annotations = annotations, + ) { + override val helpTag: String = TooltipTag.CAROUSEL_CHART_NETWORK + + /** + * Rebuilds both series from the full sample history. + */ + @UiThread + override fun rebuild() { + val chart = this.chart ?: return + val usage = usageProvider() + + val datasets = + arrayOf( + dataset( + chart.context, + usage.received, + chart.context.getString(R.string.metrics_network_received), + RECEIVED_COLOR, + ), + dataset( + chart.context, + usage.transmitted, + chart.context.getString(R.string.metrics_network_transmitted), + TRANSMITTED_COLOR, + ), + ) + + setData(chart, datasets) { applyAxisRange(it, usage) } + } + + /** + * Updates both series in place from a fresh sample, rebuilding if the chart's shape no longer + * matches. Allocates nothing on the common path, which runs once a second. + */ + @UiThread + fun onUsageChanged(usage: NetworkUsage) { + val chart = this.chart ?: return + val data = chart.data + + if (data == null || data.dataSetCount != SERIES_COUNT) { + rebuild() + return + } + + val received = data.getDataSetByIndex(RECEIVED_INDEX) as LineDataSet? + val transmitted = data.getDataSetByIndex(TRANSMITTED_INDEX) as LineDataSet? + if (received == null || transmitted == null || + received.entryCount != usage.received.size || + transmitted.entryCount != usage.transmitted.size + ) { + rebuild() + return + } + + update(chart.context, received, usage.received, chart.context.getString(R.string.metrics_network_received)) + update( + chart.context, + transmitted, + usage.transmitted, + chart.context.getString(R.string.metrics_network_transmitted), + ) + + redraw(chart) { applyAxisRange(it, usage) } + } + + private fun dataset( + context: Context, + samples: LongArray, + label: String, + lineColor: Int, + ): LineDataSet = + LineDataSet( + List(samples.size) { index -> Entry(index.toFloat(), samples[index].toLogBytes()) }, + label, + ).apply { + // The labelled axis is the right one, and applyAxisRange pins its range. Without this the + // series is scaled against the (disabled, auto-ranged) left axis instead, so the line is + // drawn at a position the labels do not describe -- an idle chart plots its zero line + // halfway up a plot whose baseline is labelled 0 B. + axisDependency = YAxis.AxisDependency.RIGHT + color = lineColor + setDrawIcons(false) + setDrawCircles(false) + setDrawCircleHole(false) + setDrawValues(false) + isHighlightEnabled = false + this.label = labelFor(context, label, samples.lastOrNull() ?: 0L) + } + + private fun update( + context: Context, + dataset: LineDataSet, + samples: LongArray, + label: String, + ) { + for (index in samples.indices) { + dataset.entries[index].y = samples[index].toLogBytes() + } + dataset.label = labelFor(context, label, samples.lastOrNull() ?: 0L) + dataset.notifyDataSetChanged() + } + + /** + * The legend entry for a series, as a rate. + * + * The stored samples are bytes per sampling interval, and the legend says "/s", so the delta + * has to be divided by that interval. It was not, which was harmless only while the interval + * was fixed at one second: once ADFA-5486 let the user choose, picking "Every 5s" overstated + * throughput fivefold, with the axis agreeing. + */ + private fun labelFor( + context: Context, + label: String, + bytes: Long, + ): String = + context.getString( + R.string.metrics_legend_entry, + label, + "%s/s".format(formatBytes(bytesPerSecond(bytes), decimals = 1)), + ) + + /** A per-interval byte count as a per-second rate. */ + private fun bytesPerSecond(bytes: Long): Double = bytes.toDouble() * MILLIS_PER_SECOND / sampleInterval().coerceAtLeast(1L) + + /** + * Pins the axis to whole decades, from zero up to at least [MIN_AXIS_DECADES]. + * + * Two things depend on this. Zero has to sit on the baseline: when every sample is zero -- an + * idle IDE -- the data range is degenerate, and left to itself the chart pads around it and + * floats the flat line up the middle of the plot. And the maximum has to be a whole number, so + * the gridlines (granularity 1) land on exact powers of ten and can be labelled as whole units. + */ + private fun applyAxisRange( + chart: SafeLineChart, + usage: NetworkUsage, + ) { + // The peak of what is on screen, not of the whole buffer. Scaled to the buffer, one early + // burst raised the ceiling for the rest of the session and never let it back down -- + // flattening everything after it, which is the opposite of what the log axis is for. + val samples = minOf(usage.received.size, usage.transmitted.size) + val visible = visibleSampleRange(chart, samples) + var peak = 0L + for (index in visible) { + peak = max(peak, max(usage.received[index], usage.transmitted[index])) + } + + chart.axisRight.axisMinimum = 0f + chart.axisRight.axisMaximum = ceil(peak.toLogBytes()).coerceAtLeast(MIN_AXIS_DECADES) + } + + override fun configure(chart: SafeLineChart) { + super.configure(chart) + chart.axisRight.apply { + valueFormatter = BytesAxisFormatter + // One label per decade, so the gridlines read as 1 kB / 1 MB rather than arbitrary + // fractions of a logarithm. The range itself is set per sample by applyAxisRange. + granularity = 1f + isGranularityEnabled = true + } + } + + /** + * Labels a logarithmic axis value in byte units. + * + * Gridlines land on integer values (granularity 1), so each is a power of ten and is labelled as + * one: 10B, 100B, 1.0kB. The exact inverse of [toLogBytes] would be `10^value - 1`, which labels + * those same lines 9B, 99B, 999B -- correct to the byte but unreadable as a scale. The one byte + * is not worth the confusion; the legend carries the exact current figure. + * + * Zero is the exception and is labelled exactly: `log10(0 + 1)` is 0, so the baseline really is + * no traffic, not one byte. + */ + private object BytesAxisFormatter : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = + if (value < 0.5f) { + formatBytes(0.0, decimals = 0) + } else { + // Gridlines are whole decades, so the mantissa is exact and needs no decimal place. + formatBytes(10.0.pow(value.toDouble()), decimals = 0) + } + } + + private companion object { + /** + * The axis always spans at least this many decades (0 B to 1 kB), so an idle chart keeps a + * sensible scale instead of collapsing onto a single value. + */ + const val MIN_AXIS_DECADES = 3f + + const val MILLIS_PER_SECOND = 1_000.0 + + const val SERIES_COUNT = 2 + const val RECEIVED_INDEX = 0 + const val TRANSMITTED_INDEX = 1 + + val RECEIVED_COLOR = Color.CYAN + val TRANSMITTED_COLOR = Color.MAGENTA + } +} + +/** + * The plotted value for a byte count: `log10(bytes + 1)`. + * + * The `+ 1` is what makes zero plottable -- it maps to `0.0` rather than negative infinity -- and + * zero is the usual sample for an idle IDE. + */ +private fun Long.toLogBytes(): Float = log10(this.coerceAtLeast(0L).toDouble() + 1.0).toFloat() + +/** + * Formats a byte count for an axis label or legend, to at most one decimal place. + * + * Units are decimal (1 kB = 1000 B), not binary. On a log10 axis the gridlines are powers of ten, + * and dividing those by 1024 would label them 9.8KB, 977KB, 954MB -- the decades stop looking like + * decades. Decimal units are also the convention for network throughput. + */ +private fun formatBytes( + bytes: Double, + decimals: Int, +): String { + val clamped = bytes.coerceAtLeast(0.0) + return when { + clamped < 1_000 -> "%d B".format(clamped.roundToLong()) + clamped < 1_000_000 -> "%.${decimals}f kB".format(clamped / 1_000) + clamped < 1_000_000_000 -> "%.${decimals}f MB".format(clamped / 1_000_000) + else -> "%.${decimals}f GB".format(clamped / 1_000_000_000) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt new file mode 100644 index 0000000000..bdd73cf251 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/PowerUsageChartRenderer.kt @@ -0,0 +1,447 @@ +/* + * 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.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.idetooltips.TooltipTag +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, + ) { + override val helpTag: String = TooltipTag.CAROUSEL_CHART_POWER + + @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( + context = context, + values = usage.temperatureMilliCelsius, + label = context.getString(R.string.metrics_power_temperature), + lineColor = TEMPERATURE_COLOR, + axis = YAxis.AxisDependency.LEFT, + transform = ::milliCelsiusToCelsius, + ), + series( + context = context, + 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( + context = context, + dataset = temperature, + values = usage.temperatureMilliCelsius, + label = context.getString(R.string.metrics_power_temperature), + axis = YAxis.AxisDependency.LEFT, + transform = ::milliCelsiusToCelsius, + ) + update( + context = context, + 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( + context: Context, + 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(context, 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( + context: Context, + 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) + isHighlightEnabled = false + this.label = labelFor(context, label, values.lastOrNull(), axis) + } + + private fun labelFor( + context: Context, + label: String, + latest: Long?, + axis: YAxis.AxisDependency, + ): String { + val value = latest ?: PowerUsageWatcher.UNAVAILABLE + val reading = + when { + value == PowerUsageWatcher.UNAVAILABLE -> context.getString(R.string.metrics_value_unavailable) + axis == YAxis.AxisDependency.LEFT -> "%.1f\u00b0".format(milliCelsiusToCelsius(value)) + else -> formatPower(value) + } + return context.getString(R.string.metrics_legend_entry, label, reading) + } + + /** + * 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 the integer format would print 29, 30, 30, 31, 31. + chart.axisLeft.granularity = 1f + chart.axisLeft.isGranularityEnabled = true + + chart.axisLeft.valueFormatter = + object : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = "%d\u00b0".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..f116dc2945 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,11 @@ package com.itsaky.androidide.ui import android.content.Context import android.graphics.Canvas +import android.graphics.Paint import android.util.AttributeSet +import android.view.MotionEvent import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.YAxis import org.slf4j.LoggerFactory /** @@ -36,7 +39,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) @@ -53,6 +55,121 @@ 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, + ) + + /** + * Called when a second finger lands, which ends whatever one-finger gesture was in progress. + * + * MPAndroidChart's gesture listener cannot report this. `ChartTouchListener` assigns its + * `mLastGesture` only from a drag, a zoom, a long press, a tap or a fling -- never from + * ACTION_POINTER_DOWN -- so a second finger that lands and lifts without moving leaves the + * gesture still labelled LONG_PRESS, and the listener cannot tell that from a finger simply + * being lifted (ADFA-5554). + */ + var onSecondPointerDown: (() -> Unit)? = null + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) { + onSecondPointerDown?.invoke() + } + return super.onTouchEvent(event) + } + + /** + * Draws the spans immediately after the grid background, which is an opaque fill of the plot: a + * span painted before [onDraw] delegates upwards is covered by it and never reaches the screen. + * 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) + } + } + + /** + * Scrolls the plot so that [xValue] is its leftmost value, now rather than on a later frame. + * + * What [moveViewToX] does, minus the deferral. That one queues the scroll as a viewport job + * which MPAndroidChart hands to `View.post`, and by the time it runs the transform it converts + * its x value through is no longer the one the caller set it up against -- a layout in between + * resets the transform to identity, and the clamp afterwards restores the scale around a + * translation computed for a different one. The viewport then lands neither where it was nor + * where it was asked to go (ADFA-5515). + * + * On a chart that is not attached to a window the job is worse than late: `View.post` drops it + * in the view's run queue, which is only flushed on attach, so it never runs at all. + */ + fun moveViewToXNow(xValue: Float) { + // The left axis, as moveViewToX itself uses; only the x component is read back. + val transformer = getTransformer(YAxis.AxisDependency.LEFT) ?: return + val target = floatArrayOf(xValue, 0f) + transformer.pointValuesToPixel(target) + viewPortHandler.centerViewPort(target, this) + } + override fun onDraw(canvas: Canvas) { try { super.onDraw(canvas) diff --git a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt index c3888d672c..86a0ee2503 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt @@ -26,388 +26,372 @@ import android.view.ViewGroup import androidx.annotation.CallSuper import androidx.annotation.FloatRange import androidx.annotation.IdRes +import androidx.core.content.withStyledAttributes import androidx.customview.widget.ViewDragHelper import com.google.android.material.shape.MaterialShapeDrawable import com.itsaky.androidide.R import kotlin.math.max import kotlin.math.min -import androidx.core.content.withStyledAttributes /** * A layout which can be dragged vertically to reveal a hidden content. * * @author Akash Yadav */ -open class SwipeRevealLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0, -) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { - - /** - * Interface for listening to drag events. - */ - interface OnDragListener { - - /** - * Called when the drag state changes. - */ - fun onDragStateChanged(swipeRevealLayout: SwipeRevealLayout, state: Int) - - /** - * Called when the drag progress changes. - */ - fun onDragProgress(swipeRevealLayout: SwipeRevealLayout, progress: Float) - } - - private val leftDragHelper: ViewDragHelper - private val rightDragHelper: ViewDragHelper - - private var leftDragProgress = 0f - private var rightDragProgress = 0f - private var isVerticalDragEnabled = true - - private var isDownInDragHandle = false - /** - * Whether the most recent touch-down landed within the configured drag handle. The vertical - * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting - * in the middle of the overlapping content (e.g. the editor) are not stolen. - */ - private val dragHandleLocation = IntArray(2) - - init { - leftDragHelper = ViewDragHelper.create(this, 1f, LeftDragCallback()) - rightDragHelper = ViewDragHelper.create(this, 1f, RightDragCallback()) - } - - private val dragHelperCallback = object : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return isVerticalDragEnabled && isDownInDragHandle && child === overlappingContent - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - draggingViewTop = top - onDragProgress(min(1f, top.toFloat() / dragHeightMax.toFloat())) - } - - override fun getViewVerticalDragRange(child: View): Int { - return if (isVerticalDragEnabled) dragHeightMax else 0 - } - - override fun getOrderedChildIndex(index: Int): Int { - return OVERLAPPING_CONTENT_INDEX - } - - override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { - return min(max(top, paddingTop), dragHeightMax) - } - - override fun onViewDragStateChanged(state: Int) { - if (state == draggingState) { - return - } - - if (isDragging && state == ViewDragHelper.STATE_IDLE) { - isOpen = draggingViewTop >= dragHeightMax - } - - onDragStateChanged(state) - } - - override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { - if (draggingViewTop == 0) { - isOpen = false - return - } - - if (draggingViewTop >= dragHeightMax) { - isOpen = true - return - } - - // whether the view should settle to open or close - val settleDestY = if (yvel > AUTO_OPEN_VELOCITY_LIM || draggingViewTop > dragHeightMax / 2) { - dragHeightMax - } else { - paddingTop - } - - if (dragHelper.settleCapturedViewAt(0, settleDestY)) { - this@SwipeRevealLayout.postInvalidateOnAnimation() - } - } - } - - private val hiddenContent: View - get() = getChildAt(HIDDEN_CONTENT_INDEX)!! - - private val overlappingContent: View - get() = getChildAt(OVERLAPPING_CONTENT_INDEX)!! - - private var draggingState = -1 - private var draggingViewTop = 0 - private val dragHeightMax - get() = hiddenContent.height - - private lateinit var dragHelper: ViewDragHelper - - /** - * Whether the view is currently in 'dragging' state. - */ - val isDragging: Boolean - get() = draggingState == ViewDragHelper.STATE_DRAGGING || - draggingState == ViewDragHelper.STATE_SETTLING - - /** - * The ID of the view which will be dragged to reveal the content. - */ - @IdRes - var dragHandleViewId = 0 - - /** - * The current drag progress. - */ - @FloatRange(from = 0.0, to = 1.0) - var dragProgress = 0.0f - private set - - /** - * Listener for drag events. - */ - var dragListener: OnDragListener? = null - - /** - * Whether the view is open. - */ - var isOpen = false - protected set - - companion object { - - private const val HIDDEN_CONTENT_INDEX = 0 - private const val OVERLAPPING_CONTENT_INDEX = 1 - - @Suppress("UNUSED") - const val STATE_IDLE = ViewDragHelper.STATE_IDLE - - @Suppress("UNUSED") - const val STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING - - @Suppress("UNUSED") - const val STATE_SETTLING = ViewDragHelper.STATE_SETTLING - - const val AUTO_OPEN_VELOCITY_LIM = 800.0 - } - - init { - if (attrs != null) { - context.withStyledAttributes( - attrs, R.styleable.SwipeRevealLayout, - defStyleAttr, defStyleRes - ) { - dragHandleViewId = getResourceId( - R.styleable.SwipeRevealLayout_dragHandle, - dragHandleViewId - ) - } - } - } - - override fun onFinishInflate() { - super.onFinishInflate() - this.dragHelper = ViewDragHelper.create(this, dragHelperCallback) - this.isOpen = false - - check(childCount == 2) { - "SwipeRevealLayout must have exactly two children; the hidden content and the overlapping content" - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - measureChildren(widthMeasureSpec, heightMeasureSpec) - - val maxWidth = MeasureSpec.getSize(widthMeasureSpec) - val maxHeight = MeasureSpec.getSize(heightMeasureSpec) - - setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 0), - resolveSizeAndState(maxHeight, heightMeasureSpec, 0)) - } - - override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { - hiddenContent.layout(0, paddingTop, r, paddingTop + hiddenContent.measuredHeight) - - val olapTop = paddingTop + (hiddenContent.height * dragProgress).toInt() - // Ensure overlappingContent extends to bottom to fill available space - overlappingContent.layout(0, olapTop, r, b) - } - - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - val action = ev.actionMasked - if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { - leftDragHelper.cancel() - rightDragHelper.cancel() - dragHelper.cancel() - return false - } - if (action == MotionEvent.ACTION_DOWN) { - isDownInDragHandle = isTouchInDragHandle(ev) - } - val isLeft = leftDragHelper.shouldInterceptTouchEvent(ev) - val isRight = rightDragHelper.shouldInterceptTouchEvent(ev) - val isVertical = dragHelper.shouldInterceptTouchEvent(ev) - return isLeft || isRight || isVertical - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - leftDragHelper.processTouchEvent(event) - rightDragHelper.processTouchEvent(event) - dragHelper.processTouchEvent(event) - return true - } - - /** - * Returns whether the given touch event falls within the bounds of the configured drag handle - * (see [dragHandleViewId] / the `app:dragHandle` attribute). When no drag handle is configured, - * the whole overlapping content acts as the handle (legacy behavior). - */ - private fun isTouchInDragHandle(ev: MotionEvent): Boolean { - if (dragHandleViewId == 0) { - return true - } - - val handle = findViewById(dragHandleViewId) - if (handle == null || handle.visibility != VISIBLE) { - return false - } - - handle.getLocationOnScreen(dragHandleLocation) - val left = dragHandleLocation[0] - val top = dragHandleLocation[1] - val right = left + handle.width - val bottom = top + handle.height - val x = ev.rawX - val y = ev.rawY - return x >= left && x <= right && y >= top && y <= bottom - } - - override fun computeScroll() { - if (leftDragHelper.continueSettling(true) or rightDragHelper.continueSettling(true) or dragHelper.continueSettling(true)) { - postInvalidateOnAnimation() - } - } - - /** - * Internal callback. Invoked when the drag state changes. - */ - @CallSuper - protected open fun onDragStateChanged(state: Int) { - draggingState = state - dragListener?.onDragStateChanged(this, state) - } - - /** - * Internal callback. Invoked when the drag progress changes. - */ - @CallSuper - protected open fun onDragProgress(progress: Float) { - if (dragProgress == progress) { - return - } - - dragProgress = progress - applyDragProgress(progress) - dragListener?.onDragProgress(this, progress) - } - - /** - * Applies the drag progress to the content. - */ - protected open fun applyDragProgress(progress: Float) { - val min = 0.97f - val max = 1f - val scale = min + (max - min) * (1 - progress) - overlappingContent.scaleX = scale - overlappingContent.scaleY = scale - (overlappingContent.background as? MaterialShapeDrawable?)?.interpolation = progress - } - - /** - * Toggles the state of the view. - */ - fun toggleState(isOpen: Boolean) { - if (isOpen) { - open() - } else { - close() - } - } - - /** - * Opens the view. - */ - fun open() { - if (isOpen) { - return - } - smoothSlideTo(1f) - } - - /** - * Closes the view. - */ - fun close() { - if (!isOpen) { - return - } - - smoothSlideTo(0f) - } - - fun setVerticalDragEnabled(enabled: Boolean) { - this.isVerticalDragEnabled = enabled - if (!enabled && isOpen) { - close() - } - } - - private fun smoothSlideTo(offset: Float) { - val y = paddingTop + offset * dragHeightMax - if (dragHelper.smoothSlideViewTo(overlappingContent, overlappingContent.left, y.toInt())) { - postInvalidateOnAnimation() - } - } - - private inner class LeftDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return child.id == R.id.drawer_sidebar // Your left drawer ID - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - leftDragProgress = left.toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, leftDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return max(0, min(left, width - child.width)) - } - } - - private inner class RightDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return true//child.id == R.id.right_drawer_sidebar - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - rightDragProgress = (width - left).toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, rightDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return max(width - child.width, min(left, width)) - } - } -} \ No newline at end of file +open class SwipeRevealLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, + ) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { + /** + * Interface for listening to drag events. + */ + interface OnDragListener { + /** + * Called when the drag state changes. + */ + fun onDragStateChanged( + swipeRevealLayout: SwipeRevealLayout, + state: Int, + ) + + /** + * Called when the drag progress changes. + */ + fun onDragProgress( + swipeRevealLayout: SwipeRevealLayout, + progress: Float, + ) + } + + private var isVerticalDragEnabled = true + + /** + * Whether the most recent touch-down landed within the configured drag handle. The vertical + * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting + * in the middle of the overlapping content (e.g. the editor) are not stolen. + */ + private var isDownInDragHandle = false + + /** Scratch for [View.getLocationOnScreen] while testing a touch against the handle's bounds. */ + private val dragHandleLocation = IntArray(2) + + private val dragHelperCallback = + object : ViewDragHelper.Callback() { + override fun tryCaptureView( + child: View, + pointerId: Int, + ): Boolean = isVerticalDragEnabled && isDownInDragHandle && child === overlappingContent + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int, + ) { + draggingViewTop = top + onDragProgress(min(1f, top.toFloat() / dragHeightMax.toFloat())) + } + + override fun getViewVerticalDragRange(child: View): Int = if (isVerticalDragEnabled) dragHeightMax else 0 + + override fun getOrderedChildIndex(index: Int): Int = OVERLAPPING_CONTENT_INDEX + + override fun clampViewPositionVertical( + child: View, + top: Int, + dy: Int, + ): Int = min(max(top, paddingTop), dragHeightMax) + + override fun onViewDragStateChanged(state: Int) { + if (state == draggingState) { + return + } + + if (isDragging && state == ViewDragHelper.STATE_IDLE) { + isOpen = draggingViewTop >= dragHeightMax + } + + onDragStateChanged(state) + } + + override fun onViewReleased( + releasedChild: View, + xvel: Float, + yvel: Float, + ) { + if (draggingViewTop == 0) { + isOpen = false + return + } + + if (draggingViewTop >= dragHeightMax) { + isOpen = true + return + } + + // whether the view should settle to open or close + val settleDestY = + if (yvel > AUTO_OPEN_VELOCITY_LIM || draggingViewTop > dragHeightMax / 2) { + dragHeightMax + } else { + paddingTop + } + + if (dragHelper.settleCapturedViewAt(0, settleDestY)) { + this@SwipeRevealLayout.postInvalidateOnAnimation() + } + } + } + + private val hiddenContent: View + get() = getChildAt(HIDDEN_CONTENT_INDEX)!! + + private val overlappingContent: View + get() = getChildAt(OVERLAPPING_CONTENT_INDEX)!! + + private var draggingState = -1 + private var draggingViewTop = 0 + private val dragHeightMax + get() = hiddenContent.height + + private lateinit var dragHelper: ViewDragHelper + + /** + * Whether the view is currently in 'dragging' state. + */ + val isDragging: Boolean + get() = + draggingState == ViewDragHelper.STATE_DRAGGING || + draggingState == ViewDragHelper.STATE_SETTLING + + /** + * The ID of the view which will be dragged to reveal the content. + */ + @IdRes + var dragHandleViewId = 0 + + /** + * The current drag progress. + */ + @FloatRange(from = 0.0, to = 1.0) + var dragProgress = 0.0f + private set + + /** + * Listener for drag events. + */ + var dragListener: OnDragListener? = null + + /** + * Whether the view is open. + */ + var isOpen = false + protected set + + companion object { + private const val HIDDEN_CONTENT_INDEX = 0 + private const val OVERLAPPING_CONTENT_INDEX = 1 + + @Suppress("UNUSED") + const val STATE_IDLE = ViewDragHelper.STATE_IDLE + + @Suppress("UNUSED") + const val STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING + + @Suppress("UNUSED") + const val STATE_SETTLING = ViewDragHelper.STATE_SETTLING + + const val AUTO_OPEN_VELOCITY_LIM = 800.0 + } + + init { + if (attrs != null) { + context.withStyledAttributes( + attrs, + R.styleable.SwipeRevealLayout, + defStyleAttr, + defStyleRes, + ) { + dragHandleViewId = + getResourceId( + R.styleable.SwipeRevealLayout_dragHandle, + dragHandleViewId, + ) + } + } + } + + override fun onFinishInflate() { + super.onFinishInflate() + this.dragHelper = ViewDragHelper.create(this, dragHelperCallback) + this.isOpen = false + + check(childCount == 2) { + "SwipeRevealLayout must have exactly two children; the hidden content and the overlapping content" + } + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + measureChildren(widthMeasureSpec, heightMeasureSpec) + + val maxWidth = MeasureSpec.getSize(widthMeasureSpec) + val maxHeight = MeasureSpec.getSize(heightMeasureSpec) + + setMeasuredDimension( + resolveSizeAndState(maxWidth, widthMeasureSpec, 0), + resolveSizeAndState(maxHeight, heightMeasureSpec, 0), + ) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + hiddenContent.layout(0, paddingTop, r, paddingTop + hiddenContent.measuredHeight) + + val olapTop = paddingTop + (hiddenContent.height * dragProgress).toInt() + // Ensure overlappingContent extends to bottom to fill available space + overlappingContent.layout(0, olapTop, r, b) + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + val action = ev.actionMasked + if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + dragHelper.cancel() + return false + } + if (action == MotionEvent.ACTION_DOWN) { + isDownInDragHandle = isTouchInDragHandle(ev) + } + return dragHelper.shouldInterceptTouchEvent(ev) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + dragHelper.processTouchEvent(event) + return true + } + + /** + * Returns whether the given touch event falls within the bounds of the configured drag handle + * (see [dragHandleViewId] / the `app:dragHandle` attribute). When no drag handle is configured, + * the whole overlapping content acts as the handle (legacy behavior). + */ + private fun isTouchInDragHandle(ev: MotionEvent): Boolean { + if (dragHandleViewId == 0) { + return true + } + + val handle = findViewById(dragHandleViewId) + if (handle == null || handle.visibility != VISIBLE) { + return false + } + + handle.getLocationOnScreen(dragHandleLocation) + val left = dragHandleLocation[0] + val top = dragHandleLocation[1] + val right = left + handle.width + val bottom = top + handle.height + val x = ev.rawX + val y = ev.rawY + return x >= left && x <= right && y >= top && y <= bottom + } + + override fun computeScroll() { + if (dragHelper.continueSettling(true)) { + postInvalidateOnAnimation() + } + } + + /** + * Internal callback. Invoked when the drag state changes. + */ + @CallSuper + protected open fun onDragStateChanged(state: Int) { + draggingState = state + dragListener?.onDragStateChanged(this, state) + } + + /** + * Internal callback. Invoked when the drag progress changes. + */ + @CallSuper + protected open fun onDragProgress(progress: Float) { + if (dragProgress == progress) { + return + } + + dragProgress = progress + applyDragProgress(progress) + dragListener?.onDragProgress(this, progress) + } + + /** + * Applies the drag progress to the content. + */ + protected open fun applyDragProgress(progress: Float) { + val min = 0.97f + val max = 1f + val scale = min + (max - min) * (1 - progress) + overlappingContent.scaleX = scale + overlappingContent.scaleY = scale + (overlappingContent.background as? MaterialShapeDrawable?)?.interpolation = progress + } + + /** + * Toggles the state of the view. + */ + fun toggleState(isOpen: Boolean) { + if (isOpen) { + open() + } else { + close() + } + } + + /** + * Opens the view. + */ + fun open() { + if (isOpen) { + return + } + smoothSlideTo(1f) + } + + /** + * Closes the view. + */ + fun close() { + if (!isOpen) { + return + } + + smoothSlideTo(0f) + } + + fun setVerticalDragEnabled(enabled: Boolean) { + this.isVerticalDragEnabled = enabled + if (!enabled && isOpen) { + close() + } + } + + private fun smoothSlideTo(offset: Float) { + val y = paddingTop + offset * dragHeightMax + if (dragHelper.smoothSlideViewTo(overlappingContent, overlappingContent.left, y.toInt())) { + postInvalidateOnAnimation() + } + } + } diff --git a/app/src/main/java/com/itsaky/androidide/utils/BuildCancellation.kt b/app/src/main/java/com/itsaky/androidide/utils/BuildCancellation.kt new file mode 100644 index 0000000000..af0b83cb2c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/BuildCancellation.kt @@ -0,0 +1,63 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory + +private val log = LoggerFactory.getLogger("BuildCancellation") + +/** + * Asks [service] to stop the running build, and says so when it will not. + * + * A Stop the server turns down -- there is no build to cancel, or Gradle refused the request -- + * reached a log line at one call site and nothing at all at the other, so the button appeared to + * do nothing and the build carried on. ADFA-5542 removed `EventListener.onBuildCancelRequested`, + * which was the only request-time signal, because it guessed the outcome rather than reporting + * one; nothing replaced the half of it that mattered. This is that half, in one place, because + * two call sites written separately are how they came to disagree. + * + * Success is deliberately silent: the build stopping is its own feedback, and + * [com.itsaky.androidide.handlers.EditorBuildEventListener.onBuildFailed] reports the outcome the + * server actually reached. + */ +fun requestBuildCancellation(service: BuildService) { + log.info("Sending build cancellation request...") + service.cancelCurrentBuild().whenComplete { result, error -> + if (error != null) { + log.error("Failed to send build cancellation request", error) + flashError(R.string.msg_build_cancel_failed) + return@whenComplete + } + + if (!result.wasEnqueued) { + // failureReason is nullable on the wire, so this reads it rather than asserting it. + // A refusal with no reason still has to reach the user. + log.warn( + "Unable to enqueue cancellation request reason={} reason.message={}", + result.failureReason, + result.failureReason?.message, + ) + flashError(R.string.msg_build_cancel_failed) + return@whenComplete + } + + log.info("Build cancellation request was successfully enqueued...") + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt new file mode 100644 index 0000000000..872c1e2b63 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/DevicePowerSource.kt @@ -0,0 +1,259 @@ +/* + * 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.BroadcastReceiver +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 org.slf4j.LoggerFactory +import kotlin.math.abs + +/** + * Reads temperature and power from the battery, which is all a normally-installed app can see + * (ADFA-5499). + * + * `ACTION_BATTERY_CHANGED` is a broadcast, and this registers one receiver for it and keeps the + * last Intent. Re-fetching the sticky Intent per sample with `registerReceiver(null, ...)` is a + * synchronous binder round trip to the system server, and at the fastest offered rate that was ten + * of them a second, for the life of the process, to re-read values that move on the order of + * seconds. Registering costs one call and the broadcast then pushes every change (ADFA-5172 is the + * repo's precedent: eliminate the operation rather than make it cheaper). + * + * Not read here, deliberately: the per-zone CPU, GPU and skin temperatures from + * `HardwarePropertiesManager`. Those need `android.permission.DEVICE_POWER`, which is signature + * 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, + AutoCloseable { + private val batteryManager = context.getSystemService() + private val powerManager = context.getSystemService() + + @Volatile + private var lastBattery: Intent? = null + + private val batteryReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + lastBattery = intent + } + } + + init { + // The registration returns the sticky Intent, so the first sample has a value without + // waiting for a change. Registered on the main looper: the receiver only stores a + // reference, and the field it stores into is volatile for the sampling thread. + lastBattery = context.registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } + + /** Stops listening. The source is unusable afterwards; [read] would go on reporting the last Intent. */ + override fun close() { + runCatching { context.unregisterReceiver(batteryReceiver) } + .onFailure { log.warn("Could not unregister the battery receiver", it) } + } + + override fun read(): PowerReading { + val battery = lastBattery + + 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. + // + // Corrected, not discarded. Rejecting that band identified the misreport and then threw the + // sample away, so on a device with such a kernel every sample was UNAVAILABLE and the power + // series read "n/a" for the life of the session -- while temperature, which has no such + // filter, plotted normally. Measured on a Galaxy Note 20 Ultra: CURRENT_NOW 318 at 3807mV + // gives 1,210 microwatts, which is 1.21W of a phone with an IDE open reported as 1.2mW. + // + // A single sample still cannot distinguish a milliamp kernel from a genuinely tiny draw, so + // this remains a judgement rather than a detector. It is the same judgement the floor + // already made, now acted on instead of used to drop the reading: below 10mW a non-zero + // draw is far likelier to be a unit mismatch than a real measurement, because the only + // device drawing single-digit milliwatts is one in deep doze -- and a dozing device is not + // running the build this chart exists to measure. + val magnitude = abs(microWatts) + return when { + magnitude == 0L -> { + microWatts + } + + magnitude in MIN_PLAUSIBLE_MICROWATTS..MAX_PLAUSIBLE_MICROWATTS -> { + microWatts + } + + // Recomputed from the scaled current rather than by scaling the product: the product + // has already been through an integer division, so multiplying it back up would round + // to the nearest milliwatt. + magnitude < MIN_PLAUSIBLE_MICROWATTS -> { + microAmps.toLong() * MICROAMPS_PER_MILLIAMP * milliVolts.toLong() / NANOWATTS_PER_MICROWATT + } + + // Above the ceiling is the mismatch the other way, and scaling up would only widen it. + else -> { + PowerUsageWatcher.UNAVAILABLE + } + } + } + + /** + * 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 { + private val log = LoggerFactory.getLogger(DevicePowerSource::class.java) + + /** Microamps times millivolts gives nanowatts; this scales the product to microwatts. */ + const val NANOWATTS_PER_MICROWATT = 1_000L + + /** + * 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 + + /** What a milliamp-reporting kernel's reading must be multiplied by to become microamps. */ + const val MICROAMPS_PER_MILLIAMP = 1_000L + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt index 0bcf662ba4..ce619dffc3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt @@ -71,12 +71,14 @@ object IntentUtils { } @JvmStatic + @JvmOverloads fun shareFile( context: Context, file: File, mimeType: String, + extraFlags: Int = 0, ) { - startIntent(context = context, file = file, mimeType = mimeType) + startIntent(context = context, file = file, mimeType = mimeType, extraFlags = extraFlags) } @JvmStatic @@ -86,6 +88,9 @@ object IntentUtils { file: File, mimeType: String = MIME_ANY, intentAction: String = Intent.ACTION_SEND, + // For a context with no task of its own -- a floating window's -- where startActivity + // needs FLAG_ACTIVITY_NEW_TASK. Zero leaves an activity-hosted share exactly as it was. + extraFlags: Int = 0, ) { val uri = context.fileProviderUriFor(file) val intent = @@ -96,9 +101,13 @@ object IntentUtils { .intent .setAction(intentAction) .setDataAndType(uri, mimeType) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or extraFlags) - context.startActivity(Intent.createChooser(intent, null)) + // extraFlags on the chooser as well as on the intent it wraps. createChooser copies only + // the URI-grant flags outwards, and the chooser is what startActivity launches -- so a + // FLAG_ACTIVITY_NEW_TASK passed for a window context never reached the intent that needed + // it, and the share threw from a context with no task of its own. + context.startActivity(Intent.createChooser(intent, null).addFlags(extraFlags)) } /** diff --git a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt index bc067b051c..56c6386aff 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -17,20 +17,18 @@ package com.itsaky.androidide.utils -import android.app.ActivityManager -import android.os.Debug import android.os.Debug.MemoryInfo import androidx.annotation.VisibleForTesting import androidx.collection.IntObjectMap import androidx.collection.MutableIntObjectMap -import androidx.core.content.getSystemService -import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.tasks.cancelIfActive -import com.termux.shared.reflection.ReflectionUtils +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi 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 @@ -40,270 +38,568 @@ import org.slf4j.LoggerFactory import java.io.File import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext /** * Handles memory usage information of the IDE. * * @property updateInterval The interval at which to update the memory usage. + * @property coroutineDispatcher Where sampling runs. Injectable so tests can drive it with virtual + * time rather than waiting on a real clock. + * @property mainDispatcher Where listeners are notified. * @author Akash Yadav */ -class MemoryUsageWatcher( - private val updateInterval: Long = DEFAULT_UPDATE_INTERVAL, -) { +class MemoryUsageWatcher @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) - private val coroutineDispatcher = newSingleThreadContext("MemoryUsageWatcher") - private val coroutineScope = CoroutineScope(coroutineDispatcher) - private val memoryUsage = ConcurrentHashMap() - private val watching = AtomicBoolean(false) - - /** - * Whether the memory usage watcher is watching processes for their memory usage. - */ - val isWatching: Boolean - get() = watching.get() - - /** - * The listener to be notified when the memory usage of a process changes. - */ - var listener: MemoryUsageListener? = null - - companion object { - private val android_os_Debug_getMemoryInfo by lazy { - checkNotNull( - ReflectionUtils.getDeclaredMethod( - Debug::class.java, - "getMemoryInfo", - Int::class.javaPrimitiveType, - MemoryInfo::class.java, - ), - ) { - "Unable to find getMemoryInfo method in android.os.Debug class" + constructor( + updateInterval: Long = DEFAULT_UPDATE_INTERVAL, + private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("MemoryUsageWatcher"), + private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, + private val nowMillis: () -> Long = System::currentTimeMillis, + // Injectable for the same reason the other watchers' readers are: it is the one part of a + // sample that needs a device. A factory rather than a reader, because which read is correct + // depends on the process -- see [ProcessMemoryReaders] (ADFA-5574). + private val readerFor: (Int) -> ProcessMemoryReader = ProcessMemoryReaders::chooseReader, + ) { + /** + * Milliseconds between samples. Changing it clears the history: the chart reads a sample's + * age from its position, which assumes every sample is the same age apart, and a buffer + * holding samples taken at two rates would silently misdate all the older ones (ADFA-5486). + * + * Volatile: written on the UI thread and read on the watcher's own sampling thread. + * Without it the reader can go on seeing a stale value indefinitely. + */ + @Volatile + var updateInterval: Long = MetricsSamplingRates.coerceToSafeRange(updateInterval) + set(value) { + val safe = MetricsSamplingRates.coerceToSafeRange(value) + if (field == safe) { + return + } + field = safe + clearHistory() } - } - const val MAX_USAGE_ENTRIES = 30 - const val DEFAULT_UPDATE_INTERVAL = 1000L - private val log = LoggerFactory.getLogger(MemoryUsageWatcher::class.java) - } + private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher) + + /** The running sampling loop, so [stopWatching] can actually stop it. */ + private var samplingJob: Job? = null + + /** + * Which sampling loop is the current one. + * + * `samplingJob` is assigned only after `launch` returns, so a stop landing in that gap + * cancels whatever the field held rather than the loop just started, and a later start can + * overwrite the field with a job nothing then cancels -- leaving two loops appending to the + * same buffers, at twice the sample rate, out of step with the row timestamps. Cancelling + * more carefully cannot fix that; the assignments themselves can land out of order. So each + * loop carries the generation it was started for and stops as soon as it is not the current + * one, whichever assignment won. + */ + private val samplingGeneration = AtomicInteger(0) + private val memoryUsage = ConcurrentHashMap() + + /** + * When each sample was taken, in the same order and at the same indices as the values. + * + * Recorded rather than reconstructed. The chart infers a sample's age from its position, + * which is close enough for placing a marker on a plot, but the exported metrics file states + * a time per row (ADFA-5531) and inference would be wrong three ways: the newest sample was + * taken up to an interval before the export, the loop delays *after* doing its work so the + * true period drifts past the nominal one, and sampling can stop and restart without the + * buffer being cleared. + * + * A zero means no sample was ever recorded at that index, which is what tells a blank cell + * apart from a measured zero. + */ + private val sampleTimes = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + + /** + * Guards the per-process ring buffers, matching [NetworkUsageWatcher] and + * [PowerUsageWatcher]. The sampler appends to them; [clearHistory] wipes them from whatever + * thread changed the sampling rate. + */ + private val historyLock = Any() + private val watching = AtomicBoolean(false) + + /** + * Set by [close] and never cleared. Without it a start after a terminal teardown would flip + * [isWatching] to true and launch into a cancelled scope, leaving the watcher reporting that + * it is sampling when no loop exists. + */ + private val closed = AtomicBoolean(false) - /** - * Start watching processes for their memory usage. - */ - fun startWatching() { - if (isWatching) { - log.warn("Processes are already being watched for memory usage") - return + /** + * Whether the memory usage watcher is watching processes for their memory usage. + */ + val isWatching: Boolean + get() = watching.get() + + /** + * The listener to be notified when the memory usage of a process changes. + * + * Volatile: written on the UI thread and read on the watcher's own sampling thread. + * Without it the reader can go on seeing a stale value indefinitely. + */ + @Volatile + var listener: MemoryUsageListener? = null + + companion object { + /** + * Samples retained per series. + * + * An hour at [DEFAULT_UPDATE_INTERVAL], and the chart shows sixty of them at a time + * (ADFA-5486). It was 10,000, which is nearly three hours nobody was looking at -- and + * eleven buffers of that is 859KB held for the life of the process, doubled by the + * pre-allocated snapshot destinations [MetricsScratch] adds so a crash handler never has + * to allocate. At 3,600 the two together cost less than the one did (ADFA-5526). + * + * A count of samples, not a duration: at the fastest offered rate of 100ms it is six + * minutes rather than an hour. + */ + const val MAX_USAGE_ENTRIES = 3600 + const val DEFAULT_UPDATE_INTERVAL = 1000L + private val log = LoggerFactory.getLogger(MemoryUsageWatcher::class.java) } - watching.set(true) + /** + * Start watching processes for their memory usage. + */ + fun startWatching() { + if (closed.get()) { + log.warn("Memory usage watcher is closed and cannot be restarted") + return + } - coroutineScope.launch(context = SupervisorJob() + coroutineDispatcher) { - while (isWatching) { - readUsages() + if (!watching.compareAndSet(false, true)) { + log.warn("Processes are already being watched for memory usage") + return + } - // don't bother to update if no listeners are set - listener?.also { listener -> - val usages = MutableIntObjectMap(memoryUsage.size) - for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { - usages[pid] = usage - } - withContext(Dispatchers.Main.immediate) { - listener.onMemoryUsageChanged(usages) + val generation = samplingGeneration.incrementAndGet() + samplingJob = + coroutineScope.launch { + while (isWatching && samplingGeneration.get() == generation) { + // A throw here used to end the coroutine while `watching` stayed true, so + // every later startWatching() was refused as "already watching" and + // sampling stopped for good. A sample is worth losing; the loop is not. + runCatching { + readUsages() + + // don't bother to update if no listeners are set + listener?.also { listener -> + // Snapshots, not the live objects. Handing the renderer the live + // ProcessMemoryInfo hands it the live ring buffer: it reads all + // 3600 slots on the main thread while the sampler is mid-append, + // so it can see the advanced shift against the not-yet-written + // value and plot every point one slot out of place. That is the + // failure getMemoryUsages() snapshots to prevent (ADFA-5531); the + // listener path was bypassing it. + val usages = MutableIntObjectMap(memoryUsage.size) + synchronized(historyLock) { + for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { + usages[pid] = usage.snapshot() + } + } + withContext(mainDispatcher) { + listener.onMemoryUsageChanged(usages) + } + } + }.onFailure { failure -> + if (failure is CancellationException) { + throw failure + } + log.error("Memory usage sampling failed; continuing", failure) + } + + delay(updateInterval) } } + } - delay(1000) + @VisibleForTesting + internal fun readUsages() { + if (memoryUsage.isEmpty()) { + return } - } - } - @VisibleForTesting - internal fun readUsages() { - val activityManager = BaseApplication.baseInstance.getSystemService() - if (activityManager == null) { - log.error("ActivityManager is null") - return - } + // Read every process first, append nothing yet. The reading is the slow part and must + // not hold the lock; the append is the part a reader can see, and all of it -- the time + // and every process's value -- has to land in one critical section. A reader that + // caught the time appended but not the values got a file whose every row sat on its + // neighbour's timestamp, which is the one thing a row of this file is for (ADFA-5531). + val at = nowMillis() + val pids = memoryUsage.keys.toIntArray() + val sampled = ArrayList>(pids.size) + pids.forEach { pid -> + val proc = + memoryUsage[pid] ?: run { + log.warn("Process {} is not being watched, but readUsages() was called for the process", pid) + return@forEach + } - val pids = memoryUsage.keys.toIntArray() - pids.forEach { pid -> + // values are in kB, convert to bytes + sampled += proc to readKb(proc) * 1024L + } - // ActivityManager.getProcessMemoryInfo is rate-limited - // but it internally uses Debug.getMemoryInfo to get the memory info - // we use it directly using reflection to bypass the rate limit - val proc = - memoryUsage[pid] ?: run { - log.warn("Process {} is not being watched, but readUsages() was called for the process", pid) - return@forEach + synchronized(historyLock) { + // The entry goes in at the start of the array and the shift amount goes up by one, + // which makes it the last element and the oldest the first -- so + // _history[_history.size - 1] is always the newest. The shift is the array's start + // index, wrapping back to 0 once it passes the end. + sampleTimes[0] = at + sampleTimes.shift(1) + + val readings = sampled.associate { (proc, usageBytes) -> proc.pid to usageBytes } + // Every watched process advances, not only the ones read above. Alignment between + // these buffers is by append count, so a process registered after the pid set was + // snapshotted -- the Gradle daemon appears when a build does -- would otherwise + // miss the append sampleTimes just took and stay one slot out of step with the row + // timestamps for the rest of the session. It gets a zero for the sample it was not + // present for, which watchedSinceMillis already tells the exporter to blank. + memoryUsage.values.forEach { proc -> + proc._history[0] = readings[proc.pid] ?: 0L + proc._history.shift(1) } + } + } - // A dead process still has an entry here until whoever is watching it says otherwise, and - // Debug.getMemoryInfo leaves memInfo untouched for one -- so sampling it again would - // repeat the last reading forever and draw a flat line for a process that no longer - // exists. The Gradle daemon made this reachable: unlike the IDE and the tooling server it - // comes and goes, and it is the largest of the three (ADFA-5514). Plot a zero instead, - // which is both true and visibly the end of that process. - val usageBytes = - if (!isProcessAlive(pid)) { - 0L - } else { - ReflectionUtils.invokeMethod(android_os_Debug_getMemoryInfo, null, pid, proc.memInfo) - - // From https://developer.android.com/tools/dumpsys#meminfo - // "PSS is a good measure for the actual RAM weight of a process and for comparison against - // the RAM use of other processes and the total available RAM." - // values are in kB, convert to bytes - proc.memInfo.totalPss * 1024L - } - // [proc], not a second lookup: unwatchProcess runs on the main thread and can drop the - // entry between the two, and the Gradle daemon is unwatched from a build event - // (ADFA-5514), so the window is real rather than theoretical. - proc.apply { - // we insert the usage entry at the start of the array, then increment the shift amount by 1 - // this makes the newly inserted usage entry the last element in the array - // and the oldest usage entry the first element in the array - - // this means that _history[_history.size - 1] will be the newest usage entry - - // the "shift" amount basically indicates what is the start index of the array - // for example, if shift is 1, then _history[0] will actually return _history[1] (index shifted by 1 to the right) - // when the shift amount exceeds the size of the array, it will be reset to 0 (wrapped around) - - _history[0] = usageBytes - _history.shift(1) + /** + * This process's footprint in kB, falling back to the reflective read if the cheap one + * fails. + * + * The fallback latches on the process, so a rollup that cannot be read -- the process gone, + * a permission this build does not have -- costs one failed attempt rather than one every + * second for the rest of the session. + */ + private fun readKb(proc: ProcessMemoryInfo): Int { + // A dead process keeps its entry here until whoever watches it says otherwise, and the + // Gradle daemon is the one that makes that reachable: unlike the IDE and the tooling + // server it comes and goes (ADFA-5514). Its smaps_rollup disappears with it, so without + // this the read below reports UNAVAILABLE, latches the process onto the reflective + // reader, and then repeats its last value for the rest of the session -- because + // Debug.getMemoryInfo leaves memInfo untouched for a pid that no longer exists. The + // chart would draw a flat line for a process that has gone. Zero is both true and + // visibly the end of it. + if (!isProcessAlive(proc.pid)) { + return 0 + } + + val kb = proc.reader.totalKb(proc.pid, proc.memInfo) + if (kb != ProcessMemoryReaders.UNAVAILABLE) { + return kb + } + if (proc.reader !== DebugMemoryInfoReader) { + ProcessMemoryReaders.logFallback(proc.pid) + proc.reader = DebugMemoryInfoReader + return proc.reader.totalKb(proc.pid, proc.memInfo) } + return 0 } - } - /** - * Whether [pid] still names a live process. - * - * `/proc` rather than `ProcessHandle`, which Android only gained recently, or a signal probe, - * which needs a permission this does not have. - */ - @VisibleForTesting - internal var isProcessAlive: (Int) -> Boolean = { pid -> File("/proc/$pid").exists() } - - /** - * Watches the memory usage of the given process. - * - * @param pid The process ID. - * @param pname The process name. - * @param unique Whether to unwatch the process with the same process name. - */ - fun watchProcess( - pid: Int, - pname: String, - unique: Boolean = true, - ) { - if (memoryUsage.containsKey(pid)) { - log.warn("Process {} is already being watched", pid) - return + /** + * Whether [pid] still names a live process. + * + * `/proc` rather than `ProcessHandle`, which Android only gained recently, or a signal + * probe, which needs a permission this does not have. + */ + @VisibleForTesting + internal var isProcessAlive: (Int) -> Boolean = { pid -> File("/proc/$pid").exists() } + + /** + * Watches the memory usage of the given process. + * + * @param pid The process ID. + * @param pname The process name. + * @param unique Whether to unwatch the process with the same process name. + */ + fun watchProcess( + pid: Int, + pname: String, + unique: Boolean = true, + ) = synchronized(historyLock) { + // The same lock the sampler appends under. readUsages() snapshots the pid set, spends + // 13-31ms per process reading /proc, then appends to sampleTimes and to every process + // in that snapshot. A registration landing in that window missed the append that + // sampleTimes received, so the new buffer stayed one slot out of step with the row + // timestamps for the rest of the session -- the misalignment ADFA-5531's + // single-critical-section design exists to prevent. watchProcess also runs off the main + // thread (the tooling server's own, and a CompletableFuture completion), so this is not + // a UI-thread-only path that could rely on ordering. + if (memoryUsage.containsKey(pid)) { + log.warn("Process {} is already being watched", pid) + return@synchronized + } + + if (unique) { + // unwatch the process with the given process name + removeByName(pname) + } + + memoryUsage[pid] = + ProcessMemoryInfo( + pid, + pname, + MutableShiftedLongArray(MAX_USAGE_ENTRIES), + // A process can start being watched long after the others -- the Gradle daemon + // appears when a build does -- and its buffer is zero-filled back to the start + // of the session. Without this, the exported file could not tell those zeros + // from a process that really was using no memory (ADFA-5531). + watchedSinceMillis = nowMillis(), + ).also { it.reader = readerFor(pid) } } - if (unique) { - // unwatch the process with the given process name - unwatchProcess(pname) + /** + * Discards every recorded sample, keeping the watched processes. + */ + fun clearHistory() { + // Held while clearing because clear() is two writes -- fill the array, reset the shift -- + // and the sampler's append is another two. Interleaved, they leave the buffer's shift + // pointing into data that is no longer there, and the chart plots a scrambled history. + // The rate dialog changes the interval from the UI thread while the sampler is running, + // so this is reachable, not theoretical. + synchronized(historyLock) { + memoryUsage.values.forEach { it._history.clear() } + sampleTimes.clear() + } } - memoryUsage[pid] = - ProcessMemoryInfo( - pid, - pname, - MutableShiftedLongArray(MAX_USAGE_ENTRIES), - ) - } + /** + * Every retained sample, with the times the samples were taken at. + * + * One lock around the whole read, and it has to be: asking for the times and the values + * separately let the sampler append between the two calls, which shifts every value one + * index against its timestamp and puts each row of the exported file on its neighbour's + * time (ADFA-5531). There is no accessor for the times alone, deliberately. + * + * A zero time at an index means nothing was ever sampled there -- the buffers are + * fixed-length and start, and are cleared, full of them. Copies, for the same reason the + * values have always been copied. + */ + fun history(): MemoryHistory = + synchronized(historyLock) { + MemoryHistory( + times = sampleTimes.toLongArray(), + processes = + memoryUsage.values.map { proc -> + ProcessHistory( + pid = proc.pid, + pname = proc.pname, + usage = proc._history.toLongArray(), + watchedSinceMillis = proc.watchedSinceMillis, + ) + }, + ) + } - /** - * Returns the memory usage of all the registered processes. - */ - fun getMemoryUsages(): Array = memoryUsage.values.toTypedArray() - - /** - * Returns the memory usage of the given process (in bytes). - */ - fun getMemoryUsage(processId: Int): ProcessMemoryInfo? = memoryUsage[processId] - - /** - * Removes the given process from the watch list. - */ - fun unwatchProcess(processId: Int) { - memoryUsage.remove(processId) - } + /** + * [history], into destinations the caller owns (ADFA-5526). + * + * Processes beyond the destinations given are dropped rather than allocated for -- the caller + * sized itself for [MetricsCsv.MEMORY_COLUMNS], which is every process the chart can plot. + */ + fun copyHistoryInto( + timesDest: LongArray, + destinations: List, + ): MemoryHistory = + synchronized(historyLock) { + MemoryHistory( + times = sampleTimes.copyInto(timesDest), + processes = + memoryUsage.values.take(destinations.size).mapIndexed { index, proc -> + ProcessHistory( + pid = proc.pid, + pname = proc.pname, + usage = proc._history.copyInto(destinations[index]), + watchedSinceMillis = proc.watchedSinceMillis, + ) + }, + ) + } - /** - * Removes the process with the given process name from the watch list. - */ - fun unwatchProcess(procName: String) { - memoryUsage.values.forEach { - if (it.pname == procName) { - memoryUsage.remove(it.pid) + /** + * Returns the memory usage of all the registered processes. + */ + fun getMemoryUsages(): Array = + synchronized(historyLock) { + // Snapshots, not the live objects. The sampler's append is two writes and clear() + // is another two, and a reader holding nothing could see an advanced shift against + // an old value -- plotting a point one slot out of place, which is exactly the + // scrambled history the lock's own doc says it prevents. NetworkUsageWatcher and + // PowerUsageWatcher already hand out copies for this reason. + // One walk, no indexing. Reading size and then values.elementAt(index) could throw + // IndexOutOfBoundsException on the main thread if a process was unwatched between + // the two -- watchProcess(unique = true) removes one, and it runs from the tooling + // server's own thread. elementAt on a values view is also O(n). + memoryUsage.values.map { it.snapshot() }.toTypedArray() } - } - } - /** - * Unwatches all the registered processes. - */ - fun unwatchAll() { - memoryUsage.clear() - } + /** + * Returns the memory usage of the given process (in bytes). + */ + fun getMemoryUsage(processId: Int): ProcessMemoryInfo? = memoryUsage[processId] + + /** + * Removes the given process from the watch list. + */ + fun unwatchProcess(processId: Int) = + synchronized(historyLock) { + memoryUsage.remove(processId) + Unit + } + + /** + * Removes the process with the given process name from the watch list. + */ + fun unwatchProcess(procName: String) = synchronized(historyLock) { removeByName(procName) } - /** - * Stop watching processes for their memory usage. - */ - fun stopWatching(unwatchAll: Boolean = true) { - if (unwatchAll) { - unwatchAll() + /** Removal without taking [historyLock], for callers that already hold it. */ + private fun removeByName(procName: String) { + memoryUsage.values.forEach { + if (it.pname == procName) { + memoryUsage.remove(it.pid) + } + } } - watching.set(false) - coroutineScope.cancelIfActive("Cancellation requested") - } - /** - * Registers a listener to be notified when the memory usage of a process changes. - */ - fun interface MemoryUsageListener { /** - * Called when the memory usage of a process changes. - * - * @param memoryUsage The memory usage of all the registered processes. + * Unwatches all the registered processes. */ - fun onMemoryUsageChanged(memoryUsage: IntObjectMap) - } + fun unwatchAll() = + synchronized(historyLock) { + memoryUsage.clear() + } - /** - * Represents the memory usage of a process. - * - * @property pid The process ID. - * @property memInfo The latest [MemoryInfo] object. Stored here to ensure that we only allocate - * a single [MemoryInfo] object for a process. - * @property usageHistory The memory usage history of the process. - */ - data class ProcessMemoryInfo( - val pid: Int, - val pname: String, - internal val _history: MutableShiftedLongArray, - ) { - internal val memInfo: MemoryInfo = MemoryInfo() + /** + * Stop watching processes for their memory usage. + */ + fun stopWatching(unwatchAll: Boolean = true) { + if (unwatchAll) { + unwatchAll() + } + watching.set(false) + // Cancelled rather than left to notice the flag: the loop spends almost all its time in + // delay(updateInterval), up to a minute at the slowest rate, so a stop followed by a + // start inside that window would leave the old loop running alongside the new one. + samplingGeneration.incrementAndGet() + samplingJob?.cancel() + samplingJob = null + } - val usageHistory: ShiftedLongArray - get() = _history + /** + * Stops sampling and releases the sampling thread. The watcher cannot be started again. + * + * Separate from [stopWatching] because a watcher is stopped and restarted across the + * editor's lifecycle; only a terminal teardown should give up the thread, and + * `newSingleThreadContext` holds one until it is closed. + */ + fun close() { + closed.set(true) + stopWatching() + listener = null + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() + } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ProcessMemoryInfo) return false + /** + * One process's retained samples, detached from the watcher. + * + * Deliberately not [ProcessMemoryInfo], which carries a MemoryInfo and a ring buffer of its + * own and is what [getMemoryUsages] allocates. + * + * @property usage The samples, oldest first, in bytes. + * @property watchedSinceMillis When this process started being watched. Its buffer reaches + * back to the start of the session however late in it the process appeared, and this is what + * tells those zeros from a measurement. + */ + class ProcessHistory( + val pid: Int, + val pname: String, + val usage: LongArray, + val watchedSinceMillis: Long, + ) - if (pid != other.pid) return false - if (!_history.contentEquals(other._history)) return false + /** + * Every watched process's samples and the times they were taken at, read together. + * + * @property times When each sample was taken, oldest first, as milliseconds since the epoch, + * parallel to every entry in [processes]. + */ + class MemoryHistory( + val times: LongArray, + val processes: List, + ) - return true + /** + * Registers a listener to be notified when the memory usage of a process changes. + */ + fun interface MemoryUsageListener { + /** + * Called when the memory usage of a process changes. + * + * @param memoryUsage The memory usage of all the registered processes. + */ + fun onMemoryUsageChanged(memoryUsage: IntObjectMap) } - override fun hashCode(): Int { - var result = pid - result = 31 * result + _history.contentHashCode() - return result + /** + * Represents the memory usage of a process. + * + * @property pid The process ID. + * @property memInfo The latest [MemoryInfo] object. Stored here to ensure that we only allocate + * a single [MemoryInfo] object for a process. + * @property usageHistory The memory usage history of the process. + */ + data class ProcessMemoryInfo( + val pid: Int, + val pname: String, + internal val _history: MutableShiftedLongArray, + /** When this process started being watched, as milliseconds since the epoch. */ + val watchedSinceMillis: Long, + ) { + internal val memInfo: MemoryInfo = MemoryInfo() + + /** + * How this process's footprint is read, chosen once when it starts being watched. + * + * Per process rather than per sample: the choice needs a file-existence check, and a + * read that fails at runtime latches here so it is not retried every second. + */ + internal var reader: ProcessMemoryReader = DebugMemoryInfoReader + + val usageHistory: ShiftedLongArray + get() = _history + + /** + * A copy of this process's history, safe to read while the sampler keeps appending. + * + * The copy gets a fresh [MemoryInfo] and the default [reader]: neither is part of what + * a reader of a snapshot looks at, which is the history and the process's identity. An + * earlier version of this comment claimed the MemoryInfo was shared with the original; + * it never was, because it is a property initialiser. + */ + internal fun snapshot(): ProcessMemoryInfo = + // Every field, including watchedSinceMillis. Dropping it let it default to 0, which + // reads as "watched since the epoch" -- so the guard that blanks a process's + // zero-filled past never fired, and the Gradle daemon's buffer exported as + // measured zeros from before it existed (ADFA-5531). + ProcessMemoryInfo(pid, pname, _history.copy(), watchedSinceMillis) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ProcessMemoryInfo) return false + + if (pid != other.pid) return false + if (!_history.contentEquals(other._history)) return false + + return true + } + + override fun hashCode(): Int { + var result = pid + result = 31 * result + _history.contentHashCode() + return result + } } } -} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt new file mode 100644 index 0000000000..0aba44c264 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -0,0 +1,231 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.os.SystemClock +import androidx.annotation.StringRes +import com.itsaky.androidide.resources.R.string + +/** + * Records significant events for the metrics charts to annotate (ADFA-5486). + * + * Significant means Gradle task starts and stops, and a build's own start and outcome + * (ADFA-5509). A real build emits far too many task events to draw -- dozens a second during + * configuration -- so those are throttled to at most one every [THROTTLE_INTERVAL_MS]. The first + * event in a quiet period is the one kept, since the interesting moment is when work *began*, not + * an arbitrary one from the middle of a burst. Build outcomes are never throttled and are the last + * thing evicted; see [Kind.isThrottled] and [record]. + * + * Annotations are stored by wall-clock time rather than by sample position, because the charts hold + * a ring buffer whose contents shift under them; a stored index would drift. The renderer converts + * a timestamp to an x position from its age, and anything older than the buffer falls off. + */ +class MetricsAnnotationStore( + private val nowMillis: () -> Long = SystemClock::elapsedRealtime, +) { + private val annotations = ArrayDeque() + + /** + * When the last annotation was recorded, or `null` if none has been. Nullable rather than a + * sentinel: `now - Long.MIN_VALUE` overflows to a negative gap, which reads as "inside the + * throttle window" and silently swallows every annotation for the life of the store. + */ + private var lastRecordedAt: Long? = null + + /** Hands each annotation its [Annotation.sequence]. */ + private var nextSequence: Long = 0L + + /** + * What kind of event an annotation marks, which decides both how it is drawn and whether the + * throttle applies to it (ADFA-5509). + */ + enum class Kind( + /** + * The label for this kind, or `null` for [TASK], whose label is the Gradle task's own name. + * + * A resource id rather than resolved text: the store lives in a ViewModel that outlives an + * activity, so a label resolved at record time would keep the old language after the system + * locale changes. Holding the id also removes the only reason a caller had to know which + * string went with which kind. + */ + @StringRes val labelRes: Int?, + ) { + /** A Gradle task starting or finishing. Throttled: Gradle emits dozens a second. */ + TASK(labelRes = null), + + /** A build beginning. */ + BUILD_STARTED(string.metrics_annotation_build_started), + + /** A build completing successfully. */ + BUILD_FINISHED(string.metrics_annotation_build_finished), + + /** A build failing. */ + BUILD_FAILED(string.metrics_annotation_build_failed), + + /** + * A build stopped by the user. Not a failure: the platform reports a cancel through the + * same failure callback, and painting a deliberate stop in the error colour misreports it. + */ + BUILD_CANCELLED(string.metrics_annotation_build_cancelled), + ; + + /** + * Whether the throttle may drop this kind. + * + * Only task events. A build outcome dropped because a task marker happened to land two + * seconds earlier would be the one annotation on the chart worth having. + */ + val isThrottled: Boolean + get() = this == TASK + } + + /** + * An annotated moment. + * + * @property atMillis When it happened, on the same clock as [nowMillis]. + * @property label What to show against it. + */ + data class Annotation( + val atMillis: Long, + val label: String, + /** + * 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, + /** Decides the marker's colour, and whether the throttle could have dropped it. */ + val kind: Kind = Kind.TASK, + ) + + /** + * Records a build outcome. Its label comes from [Kind.labelRes], so the caller names the + * outcome and nothing else. + */ + @Synchronized + fun recordBuild(kind: Kind): Boolean = record(label = "", kind = kind) + + /** + * Records [label] unless another annotation was recorded within [THROTTLE_INTERVAL_MS]. + * + * The throttle only applies to [Kind.TASK]; a build outcome is always kept. See + * [Kind.isThrottled]. + * + * @return whether it was recorded. + */ + @Synchronized + fun record( + label: String, + kind: Kind = Kind.TASK, + ): Boolean { + val now = nowMillis() + val since = lastRecordedAt + if (kind.isThrottled && since != null && now - since < THROTTLE_INTERVAL_MS) { + return false + } + + // Set even for an unthrottled kind, so the next task marker waits its interval instead of + // landing a few pixels from a build marker and colliding with it. + lastRecordedAt = now + annotations.addLast(Annotation(now, label, nextSequence++, kind)) + evictToCapacity() + return true + } + + /** + * Drops the oldest annotations until the store is back within [MAX_ANNOTATIONS]. + * + * Task markers go first, whatever their age. Plain oldest-first eviction dropped a build's + * "Build started" while the build was still running -- 256 markers at one per + * [THROTTLE_INTERVAL_MS] is about twenty minutes, which a clean build on a phone can exceed -- + * leaving an unpaired outcome on the chart and no way to see how long the build took. Task + * markers are the padding here; the build's own moments are the point. + */ + private fun evictToCapacity() { + while (annotations.size > MAX_ANNOTATIONS) { + val oldestTask = annotations.indexOfFirst { it.kind.isThrottled } + if (oldestTask >= 0) { + annotations.removeAt(oldestTask) + } else { + // Nothing but build outcomes left, so the oldest of those has to go. + annotations.removeFirst() + } + } + } + + /** + * The annotations recorded within [withinMillis] of now, oldest first. + */ + @Synchronized + fun recentAnnotations(withinMillis: Long): List { + val cutoff = nowMillis() - withinMillis + return annotations.filter { it.atMillis >= cutoff } + } + + /** + * Every annotation the store holds, oldest first. + * + * The exported metrics file carries the whole retained history rather than a window of it, so + * it cannot go through [recentAnnotations] -- there is no "within" that means "all of it" + * without the cutoff arithmetic overflowing (ADFA-5531). + */ + @Synchronized + fun allAnnotations(): List = annotations.toList() + + @Synchronized + fun clear() { + annotations.clear() + lastRecordedAt = null + nextSequence = 0L + } + + companion object { + /** + * Gradle emits task events far faster than a chart can show them; one every five seconds is + * what the ticket asks for. + */ + const val THROTTLE_INTERVAL_MS = 5_000L + + /** + * Enough to cover the whole visible window at the slowest sampling rate. + * + * Derived rather than picked. The renderer asks for the annotations within + * `(VISIBLE_SAMPLES + 1) * interval`, which at [MetricsSamplingRates.MAX_INTERVAL_MS] is + * just over an hour, and the throttle admits one task marker every + * [THROTTLE_INTERVAL_MS] -- so a busy hour can fill the window with more markers than a + * flat 256 could hold, and eviction then dropped markers that still had samples on + * screen beside them. The bound still exists: a session cannot grow this without limit, + * it just no longer cuts into what is being drawn. + */ + val MAX_ANNOTATIONS = + (VISIBLE_WINDOW_SAMPLES * MetricsSamplingRates.MAX_INTERVAL_MS / THROTTLE_INTERVAL_MS).toInt() + + /** + * How many samples a chart shows at once, plus the one the renderer allows for. + * + * Held here rather than read from MetricsChartRenderer.VISIBLE_SAMPLES: this class is in + * `utils` and the renderer is in `ui`, so reaching for it would be an upward dependency. + * If the renderer's window changes, this follows. + */ + private const val VISIBLE_WINDOW_SAMPLES = 61L + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt new file mode 100644 index 0000000000..b2454dfeed --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsv.kt @@ -0,0 +1,369 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.abs + +/** + * The canonical metrics file: everything the carousel has sampled, as CSV (ADFA-5531). + * + * One definition, because the file has several producers and consumers -- the export button here, + * ADFA-5494's restore across process death, and the copies ADFA-5526 and ADFA-5534 attach to crash + * reports and to feedback. Those differ from this only in compressing it. + * + * A row is a sampling tick, and its columns come from three watchers that each keep their own ring + * buffer and their own coroutine. They are started together and share one interval, and every one + * of them is cleared when that interval changes. The row's stated time is the memory watcher's, + * recorded when it sampled, and nothing here reconstructs a time from an index. + * + * The other series are paired to that row by array index rather than by time, which is not the same + * thing. Each watcher runs its own loop and does its own per-tick work, so their ticks drift apart, + * and because the buffers are filled oldest-first the drift accumulates backwards: the further back + * a row is, the further its network and power values can sit from its stated time. Every column is + * a real reading with a real time behind it, so nothing in the file is invented -- but a consumer + * must not read one row as three simultaneous measurements. Merging the series on time instead is + * the subject of its own change; this comment says what is true until then. + * + * Formatting only, with no Android types, so the whole format can be tested without a device. + */ +object MetricsCsv { + /** Media type for the written file, for the sharing intent. */ + const val MIME_TYPE = "text/csv" + + /** + * A sample time of zero means no sample was taken at that index: the ring buffers are + * fixed-length and start, and are cleared, full of zeros. + */ + const val NO_SAMPLE = 0L + + /** + * A row's time, ISO 8601 with the offset the device was on. + * + * Deliberately not the filename's format, which is built from what a filesystem allows and what + * sorts lexicographically. This one has to round-trip exactly for ADFA-5494 and be read by a + * person triaging a report from another timezone, which is what the offset is for. + * + * Spelled out rather than [DateTimeFormatter.ISO_OFFSET_DATE_TIME], which drops trailing zeros + * from the fraction and so writes a column of varying width -- ".38" for one row and ".123" for + * the next. Both parse, but a fixed three digits matches the millisecond the value is recorded + * at and the three the filename carries. + */ + private val TIMESTAMP_FORMAT: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.ROOT) + + /** + * The names the memory watcher is given for the three processes the IDE plots. + * + * Here rather than beside the watcher because this file is the one that cannot move: a column + * name is the file's published contract, read back by ADFA-5494 and by whoever opens the copy + * ADFA-5526 and ADFA-5534 attach to a report. Everything else looks these up. + * + * They were literals in two places -- these and `BaseEditorActivity.PROC_*` -- joined by + * nothing but string equality. Renaming a process there would have gone on writing the old + * header here and quietly emptied the column, because a name that matches nothing in the + * snapshot is written as an absent value rather than as an error. + */ + const val PROC_IDE = "IDE" + + /** @see PROC_IDE */ + const val PROC_GRADLE_TOOLING = "Gradle Tooling" + + /** @see PROC_IDE */ + const val PROC_GRADLE_DAEMON = "Gradle Daemon" + + /** + * The memory series, in column order. + * + * Fixed rather than taken from whatever is being watched at export time. The set changes during + * a session -- the Gradle daemon appears when a build starts and goes when it exits (ADFA-5514) + * -- and a header that depended on it would describe a different file each time. A process that + * is not being watched leaves its column empty. + */ + val MEMORY_COLUMNS = listOf(PROC_IDE, PROC_GRADLE_TOOLING, PROC_GRADLE_DAEMON) + + @JvmStatic + val HEADER: List = + listOf("timestamp") + + MEMORY_COLUMNS.map { "${it.lowercase().replace(' ', '_')}_pss_bytes" } + + listOf( + "net_rx_bytes", + "net_tx_bytes", + "battery_temp_millicelsius", + "power_microwatts", + "thermal_status", + "annotation", + "annotation_kind", + ) + + /** + * One series of samples and the times they were recorded at. + * + * @property times When each value was sampled, oldest first and parallel to [values]. A + * [NO_SAMPLE] entry marks an index nothing was ever recorded at, which is what tells an empty + * cell apart from a measured zero. + * @property values The samples themselves. + * @property absent The in-band value this series uses for "the device did not provide a + * reading", or `null` if it has none. Written as an empty cell, the same as an unsampled index. + * A watcher that stores a sentinel -- `PowerUsageWatcher.UNAVAILABLE` is [Long.MIN_VALUE] -- + * would otherwise put `-9223372036854775808` in a numeric column, and every consumer that + * averages or plots that column gets an answer that is not merely wrong but spectacular. The + * sentinel is named by the caller rather than known here, because this file deliberately has no + * Android types in it. + * @property since When this series started being recorded. Samples timed before it belong to + * the buffer's zero-filled past rather than to this series -- the Gradle daemon's buffer reaches + * back to the start of the session however late in it the daemon appeared. + */ + class Series( + private val times: LongArray, + private val values: LongArray, + private val since: Long = 0L, + private val absent: Long? = null, + ) { + /** The value at index [i], or `null` if this series has nothing to say there. */ + fun at(i: Int): Long? { + if (i < 0 || i >= times.size || i >= values.size) { + return null + } + val time = times[i] + if (time == NO_SAMPLE || time < since) { + return null + } + val value = values[i] + return if (value == absent) null else value + } + + companion object { + val EMPTY = Series(LongArray(0), LongArray(0)) + } + } + + /** + * @property atMillis When the event happened. + * @property label Its text, already resolved. + * @property kind The sort of event, as the annotation store names it. + */ + data class Marker( + val atMillis: Long, + val label: String, + val kind: String, + ) + + /** + * Everything one export writes. + * + * @property rowTimes The memory watcher's sample times, oldest first. They are the rows, because + * memory is the one series always being recorded. + * @property sampleIntervalMillis How often the watchers sample. Required rather than defaulted, + * because it decides which annotations are near enough to a row to be written on it and a + * default would pick that bound for a caller who never considered it. + */ + class Snapshot( + val rowTimes: LongArray, + val sampleIntervalMillis: Long, + val memory: Map, + val networkReceived: Series = Series.EMPTY, + val networkTransmitted: Series = Series.EMPTY, + val temperature: Series = Series.EMPTY, + val power: Series = Series.EMPTY, + val thermal: Series = Series.EMPTY, + val annotations: List = emptyList(), + ) { + /** + * Whether this snapshot has any sample to report. + * + * [write] always produces a file, header included, because the export button was asked for + * one whatever the state of the buffers. Sending one is a different question: ADFA-5534 + * attaches the file to feedback only when there is something in it, rather than posting an + * empty attachment, and ADFA-5526 will want the same of a crash report. This is how a caller + * asks. + */ + val hasRows: Boolean get() = rowTimes.any { it != NO_SAMPLE } + } + + /** + * Writes [snapshot] to [out], timestamps in [zone]. + * + * The header is always written, even when nothing has been sampled. A file that exists and + * reports no rows is easier for a consumer to handle than one that may or may not be there, and + * the empty case is not exotic: changing the sampling rate clears every buffer. + */ + fun write( + snapshot: Snapshot, + zone: ZoneId, + out: Appendable, + ) { + out.append(HEADER.joinToString(",", transform = ::quote)).append('\n') + + val markerRows = markerRows(snapshot) + val row = StringBuilder() + snapshot.rowTimes.forEachIndexed { i, at -> + if (at == NO_SAMPLE) { + return@forEachIndexed + } + + row.setLength(0) + row.append(quote(formatTime(at, zone))) + MEMORY_COLUMNS.forEach { name -> row.append(',').append(number(snapshot.memory[name]?.at(i))) } + row.append(',').append(number(snapshot.networkReceived.at(i))) + row.append(',').append(number(snapshot.networkTransmitted.at(i))) + row.append(',').append(number(snapshot.temperature.at(i))) + row.append(',').append(number(snapshot.power.at(i))) + row.append(',').append(number(snapshot.thermal.at(i))) + val marker = markerRows[i] + row.append(',').append(marker?.let { quote(it.label) } ?: "") + row.append(',').append(marker?.let { quote(it.kind) } ?: "") + out.append(row).append('\n') + } + } + + /** + * An annotation's time, moved onto the clock the samples are stamped with. + * + * [MetricsAnnotationStore] records on [android.os.SystemClock.elapsedRealtime], which is + * monotonic and immune to the wall clock being set, and is what the chart wants: it only ever + * asks how long ago something happened. A file has to say *when*, so the samples carry epoch + * milliseconds, and the two cannot be compared without this. + * + * Mixing them is not a small error. A monotonic time is a few hours since boot and an epoch time + * is decades, so every row looks about equally far from the marker and the nearest-row search + * lands on whichever row has the smallest number -- the oldest one in the buffer, every time. + * + * @param monotonicAtMillis The time the store recorded. + * @param nowEpochMillis Now, on the samples' clock. + * @param nowMonotonicMillis Now, on the store's clock. Read as close together as possible. + */ + fun epochFor( + monotonicAtMillis: Long, + nowEpochMillis: Long, + nowMonotonicMillis: Long, + ): Long = monotonicAtMillis + (nowEpochMillis - nowMonotonicMillis) + + /** [atMillis] as ISO 8601 in [zone]. */ + fun formatTime( + atMillis: Long, + zone: ZoneId, + ): String = TIMESTAMP_FORMAT.format(Instant.ofEpochMilli(atMillis).atZone(zone)) + + /** + * The row each marker belongs on, resolved once for the whole file. + * + * A marker goes on the row whose sample is nearest it in time. An annotation is recorded when + * something happened, not when a sample was taken, so requiring an exact match would drop + * almost all of them; and doing this per row rather than once would walk every marker against + * every row, which at ten thousand of each is not a cost worth paying for a button. + * + * A marker further than one sampling interval from its nearest row is dropped rather than + * pulled onto it. Sampling at a fixed interval leaves every marker that happened while the + * buffer was filling within half an interval of some sample, so a greater distance means the + * marker falls outside the sampled window -- an annotation older than the buffer reaches, which + * is the ordinary case in a long session. Without the cap every one of those lands on row 0, + * where [MutableMap.putIfAbsent] keeps the first and drops the rest: the file would carry one + * arbitrary ancient marker on its oldest row and lose the others silently. The chart has both + * guards already -- it asks the store only for the annotations in the visible span, and drops + * any whose x falls before the first sample. + * + * Where two markers land on one row the earlier wins, and the later is dropped rather than + * silently overwriting it -- the file has one annotation column per row by definition. + */ + private fun markerRows(snapshot: Snapshot): Map { + if (snapshot.annotations.isEmpty()) { + return emptyMap() + } + + // Two parallel arrays rather than a list of IndexedValue: this used to build a 3600-element + // boxed list per call, on the crashing thread. + val sampledTimes = LongArray(snapshot.rowTimes.size) + val sampledRows = IntArray(snapshot.rowTimes.size) + var sampledCount = 0 + snapshot.rowTimes.forEachIndexed { row, time -> + if (time != NO_SAMPLE) { + sampledTimes[sampledCount] = time + sampledRows[sampledCount] = row + sampledCount++ + } + } + if (sampledCount == 0) { + return emptyMap() + } + + val rows = mutableMapOf() + snapshot.annotations.sortedBy { it.atMillis }.forEach { marker -> + // A binary search, not a scan. rowTimes is ascending among sampled entries, and the + // scan this replaced was the O(markers x rows) walk the KDoc above claims to avoid -- + // ~2.6M compares at a full buffer and MAX_ANNOTATIONS, on the thread that just threw. + val nearest = nearestSampleTo(marker.atMillis, sampledTimes, sampledCount) + if (abs(sampledTimes[nearest] - marker.atMillis) > snapshot.sampleIntervalMillis) { + return@forEach + } + rows.putIfAbsent(sampledRows[nearest], marker) + } + return rows + } + + /** The index in [times]`[0, count)` whose value is closest to [target]. */ + private fun nearestSampleTo( + target: Long, + times: LongArray, + count: Int, + ): Int { + var low = 0 + var high = count - 1 + while (low < high) { + val mid = (low + high) / 2 + if (times[mid] < target) low = mid + 1 else high = mid + } + // binarySearch lands on the first entry at or after the target; the one before it can be + // closer, and is when the target falls between two samples. + val previous = (low - 1).coerceAtLeast(0) + return if (abs(times[previous] - target) <= abs(times[low] - target)) previous else low + } + + private fun number(value: Long?): String = value?.toString() ?: "" + + /** + * The characters that make a spreadsheet read a cell as a formula rather than as text. + * + * Tab and carriage return are here because a leading one of either is stripped on import, which + * exposes whatever follows it: a cell of "\t=cmd" is a formula too. + */ + private val FORMULA_LEAD = charArrayOf('=', '+', '-', '@', '\t', '\r') + + /** + * A CSV string cell. + * + * Quoted per the format's rule (b), with any quote inside it doubled -- a task name is text the + * IDE was given, and nothing guarantees it has no quotes in it. + * + * A cell beginning with one of [FORMULA_LEAD] additionally gets a leading apostrophe. Quoting + * alone does not stop a spreadsheet evaluating the cell on import, and the annotation columns + * carry Gradle task names taken from the user's own build script -- into a file ADFA-5526 and + * ADFA-5534 attach to crash reports and to feedback, which a support engineer then opens. The + * apostrophe is part of the cell as written, so a reader parsing this file back has to strip it. + * + * The numeric columns do not come through here. They are written by [number], where a negative + * value has to stay a number rather than become text with a quote in front of it. + */ + private fun quote(value: String): String { + val guarded = if (value.isNotEmpty() && value[0] in FORMULA_LEAD) "'" + value else value + return "\"" + guarded.replace("\"", "\"\"") + "\"" + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt new file mode 100644 index 0000000000..b73c686894 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsCsvFile.kt @@ -0,0 +1,124 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.content.Context +import androidx.annotation.VisibleForTesting +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException +import java.time.ZoneId +import java.util.zip.GZIPOutputStream + +/** + * Writes a [MetricsCsv.Snapshot] to a file the IDE can share (ADFA-5531). + * + * The same scratch-directory arrangement as [MetricsSnapshot], and for the same reason: exports go + * under the cache so the platform can reclaim them, and the sharing intent grants the recipient a + * read on the file before that matters. + */ +object MetricsCsvFile { + private val log = LoggerFactory.getLogger(MetricsCsvFile::class.java) + + private const val DIRECTORY = "metrics-exports" + + /** + * Where a file written for a report goes, rather than for the user. + * + * Separate from the exports because they prune independently: a feedback send must not evict an + * export the user is about to hand to another app (ADFA-5534). + */ + private const val REPORT_DIRECTORY = "metrics-reports" + + /** Gzip, not zip: one file, so an archive container adds a name and nothing else. */ + private const val COMPRESSED_EXTENSION = "csv.gz" + + /** Media type for a compressed file. */ + const val COMPRESSED_MIME_TYPE = "application/gzip" + + /** + * How many exports to keep. + * + * A share hands the recipient a URI and returns long before the recipient reads it, so the + * previous file cannot be deleted on the next export. Fewer than the images are kept: a full + * buffer is around a megabyte of text, against a few hundred kilobytes for a PNG. + */ + @VisibleForTesting + internal const val KEEP_RECENT = 3 + + /** + * Writes [snapshot] and returns the file, or `null` if it could not be written. + */ + fun write( + context: Context, + snapshot: MetricsCsv.Snapshot, + nowMillis: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + ): File? = write(context, snapshot, DIRECTORY, compress = false, nowMillis, zone) + + /** + * Writes [snapshot] gzipped, for attaching to a report, or `null` if it could not be written. + * + * Compressed because it travels: a full buffer is around a megabyte of text and it is highly + * compressible -- the timestamps advance by a constant and the magnitudes barely move -- so this + * is a large saving on an email attachment for no loss (ADFA-5534, and ADFA-5526 to come). + */ + fun writeForReport( + context: Context, + snapshot: MetricsCsv.Snapshot, + nowMillis: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + ): File? = write(context, snapshot, REPORT_DIRECTORY, compress = true, nowMillis, zone) + + private fun write( + context: Context, + snapshot: MetricsCsv.Snapshot, + directoryName: String, + compress: Boolean, + nowMillis: Long, + zone: ZoneId, + ): File? { + val directory = File(context.cacheDir, directoryName) + return try { + if (!directory.exists() && !directory.mkdirs()) { + log.error("Could not create the metrics export directory at {}", directory) + return null + } + + val extension = if (compress) COMPRESSED_EXTENSION else "csv" + val file = File(directory, MetricsFileName.forTime(nowMillis, extension, zone)) + // Streamed, not built into a string: a full buffer is ten thousand rows, and holding the + // whole file in memory to write it is a megabyte of char array nobody needs. Compressed + // on the way out for the same reason -- the uncompressed file never has to exist. + // The raw stream is opened into its own `use`: GZIPOutputStream writes the gzip header in + // its constructor and can throw, and wrapping only the outer sink leaked the descriptor it + // had already been handed -- once per reported crash on a device whose cache is full. + file.outputStream().use { raw -> + val sink = if (compress) GZIPOutputStream(raw) else raw + sink.bufferedWriter().use { writer -> + MetricsCsv.write(snapshot, zone, writer) + } + } + MetricsSnapshot.pruneTo(directory, KEEP_RECENT, file) + file + } catch (io: IOException) { + log.error("Could not write the metrics export", io) + null + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsFileName.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsFileName.kt new file mode 100644 index 0000000000..97a13b3419 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsFileName.kt @@ -0,0 +1,50 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * What every exported metrics file is called (ADFA-5531). + * + * One rule for the CSV and the chart image alike, differing only in extension, so a pair exported + * together sorts together and a consumer can tell when a file was written without opening it. + * + * Underscores and no offset, which is what makes it a filename rather than a timestamp: it has to + * survive every filesystem the IDE can write to and sort lexicographically in a directory listing. + * The times *inside* the file are ISO 8601 -- see [MetricsCsv]. + */ +object MetricsFileName { + /** `YYYY_MM_DD_HH_MM_SS_SSS`, as the format section of ADFA-5531 specifies it. */ + private val PATTERN: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ss_SSS", Locale.ROOT) + + /** + * The name for a file written at [atMillis], with [extension] and no leading dot. + * + * Local time, because this is the name a person reads in a share sheet or a file manager. + */ + fun forTime( + atMillis: Long, + extension: String, + zone: ZoneId = ZoneId.systemDefault(), + ): String = "${PATTERN.format(Instant.ofEpochMilli(atMillis).atZone(zone))}.$extension" +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt new file mode 100644 index 0000000000..e50dc13fab --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt @@ -0,0 +1,113 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.itsaky.androidide.app.configuration.CpuArch +import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider + +/** + * The sampling rates the metrics charts offer, and which of them a given device may use + * (ADFA-5486). + * + * Sampling costs a `Debug.getMemoryInfo` call per watched process plus two `TrafficStats` reads, + * every interval. At the fastest rate that is ten times a second, which on weak hardware is enough + * to distort the very thing the chart is measuring. 32-bit devices are therefore held to a slower + * floor than 64-bit ones. + * + * Rates a device cannot use are still listed, marked unavailable, rather than hidden -- a chooser + * that silently omits them leaves the user wondering whether the IDE simply cannot sample faster. + * [Rate.isAvailable] is what a chooser should grey out; [minimumIntervalMillis] is the floor it + * enforces. + */ +object MetricsSamplingRates { + /** Floor for a 64-bit device: ten samples a second. */ + const val MIN_INTERVAL_64_BIT_MS = 100L + + /** Floor for a 32-bit device: two samples a second. */ + const val MIN_INTERVAL_32_BIT_MS = 500L + + /** The slowest rate offered, from the ticket's 0.1s-to-60s range. */ + const val MAX_INTERVAL_MS = 60_000L + + /** + * Every rate the chooser offers, fastest first. + */ + val OFFERED_INTERVALS_MS = + longArrayOf(100L, 200L, 500L, 1_000L, 2_000L, 5_000L, 10_000L, 30_000L, 60_000L) + + /** + * A rate as a chooser should present it. + * + * @property intervalMillis The sampling interval. + * @property isAvailable Whether this device may select it. + */ + data class Rate( + val intervalMillis: Long, + val isAvailable: Boolean, + ) + + /** + * The fastest interval [arch] may sample at. + */ + fun minimumIntervalMillis(arch: CpuArch): Long = if (arch.is64Bit) MIN_INTERVAL_64_BIT_MS else MIN_INTERVAL_32_BIT_MS + + /** + * The fastest interval this device may sample at. + * + * Keyed on the device's architecture rather than the build flavour: a 32-bit build of the IDE + * running on a 64-bit phone is still running on hardware that can afford the faster rate. + */ + fun minimumIntervalMillis(): Long = minimumIntervalMillis(IDEBuildConfigProvider.getInstance().deviceArch) + + /** + * Every offered rate, each marked with whether [arch] may select it. + */ + fun ratesFor(arch: CpuArch): List { + val minimum = minimumIntervalMillis(arch) + return OFFERED_INTERVALS_MS.map { interval -> Rate(interval, isAvailable = interval >= minimum) } + } + + /** + * Clamps [intervalMillis] into the range [arch] may use. + */ + fun coerceToSupportedRange( + intervalMillis: Long, + arch: CpuArch, + ): Long = intervalMillis.coerceIn(minimumIntervalMillis(arch), MAX_INTERVAL_MS) + + /** + * Clamps [intervalMillis] into the range *any* device may run at. + * + * The watchers guard themselves with this rather than with [coerceToSupportedRange], which + * needs to know the architecture and so cannot be called from a plain unit test. It is a safety + * net, not the policy: what the user may pick is still decided by [ratesFor]. Its job is to + * keep a non-positive interval out of `delay()`, which does not suspend for one -- the sampling + * loop would then spin, pinning a core for as long as the editor is open. + */ + fun coerceToSafeRange(intervalMillis: Long): Long = intervalMillis.coerceIn(MIN_INTERVAL_64_BIT_MS, MAX_INTERVAL_MS) +} + +/** + * Whether this architecture is 64-bit. + */ +val CpuArch.is64Bit: Boolean + get() = + when (this) { + CpuArch.AARCH64, CpuArch.X86_64 -> true + CpuArch.ARM, CpuArch.X86 -> false + } diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt new file mode 100644 index 0000000000..089aade934 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt @@ -0,0 +1,128 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import androidx.annotation.VisibleForTesting +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Destinations for one metrics snapshot, allocated once so that taking one needs no memory. + * + * A crash handler is a poor place to ask for memory: the crash being reported may be the heap + * running out, and a handler that throws replaces a useful report with a useless one. Snapshotting + * the watchers otherwise takes eleven fresh arrays -- around 300KB at the retained length -- so the + * arrays are taken at startup instead, when failing to get them is survivable and obvious. + * + * This removes the largest single allocation on that path, not all of it: writing the file still + * takes a Deflater and its buffer, an 8KB writer buffer and a String per cell. So it improves the + * odds of getting a report out under memory pressure rather than guaranteeing one, and under a + * genuine OutOfMemoryError the write can still fail and the attachment still be dropped. Removing + * the rest means streaming the CSV without per-cell Strings, which is a bigger change than this. + * + * Held for the life of the process, which is the trade: this is memory reserved against a crash that + * may never come, in a process that is already a fat target for the low-memory killer. It is paid + * for by [MemoryUsageWatcher.MAX_USAGE_ENTRIES] coming down at the same time -- the live buffers plus + * these cost less than the live buffers alone did before (ADFA-5526). + * + * Not thread-confined but single-use at a time: [claim] hands it to one caller and [release] gives it + * back. A caller that cannot claim it allocates for itself rather than waiting or sharing, because + * two writers into one array is a scrambled file and a crash must not block on an export. + */ +class MetricsScratch( + @VisibleForTesting internal val entries: Int, + memorySeries: Int, +) { + private val inUse = AtomicBoolean(false) + + val memoryTimes = LongArray(entries) + val memoryValues: List = List(memorySeries) { LongArray(entries) } + val networkTimes = LongArray(entries) + val networkReceived = LongArray(entries) + val networkTransmitted = LongArray(entries) + val powerTimes = LongArray(entries) + val temperature = LongArray(entries) + val power = LongArray(entries) + val thermal = LongArray(entries) + + /** Takes this scratch, or returns false if something else already has it. */ + fun claim(): Boolean = inUse.compareAndSet(false, true) + + fun release() { + inUse.set(false) + } + + companion object { + /** + * The process-wide scratch, or `null` before [install] or if it could not be allocated. + * + * A crash arrives on whatever thread threw, from anywhere in the process, so this cannot + * live on an activity-scoped ViewModel the way the watchers do. + */ + @Volatile + var instance: MetricsScratch? = null + private set + + /** + * Allocates the process-wide scratch. Call once, from application startup. + * + * Failure is not fatal and not worth retrying: the crash path simply allocates for itself, + * which is what it did before this existed. + */ + fun install( + entries: Int = sharedRetention(), + memorySeries: Int = MetricsCsv.MEMORY_COLUMNS.size, + ) { + if (instance != null) { + return + } + instance = runCatching { MetricsScratch(entries, memorySeries) }.getOrNull() + } + + /** + * The one retention all three watchers keep, or a throw naming the ones that disagree. + * + * One buffer size is handed to all three, and `ShiftedLongArray.copyInto` require()s an + * *exact* match -- so `maxOf` of the three was no protection at all: it picks a size two of + * them would reject the moment they stopped agreeing. That throw lands inside + * `MetricsCrashAttachment`'s runCatching, where it is swallowed, and every crash report and + * feedback send silently loses its metrics -- the failure this class exists to prevent. + * + * Failing here instead makes divergence a loud startup failure with the numbers in the + * message, not a quiet hole in diagnostics nobody notices until they need one. The + * alternative, sizing a destination per watcher, is the right answer if these ever + * legitimately differ; today they are one number and this says so. + */ + @VisibleForTesting + internal fun sharedRetention( + memory: Int = MemoryUsageWatcher.MAX_USAGE_ENTRIES, + network: Int = NetworkUsageWatcher.MAX_USAGE_ENTRIES, + power: Int = PowerUsageWatcher.MAX_USAGE_ENTRIES, + ): Int { + require(memory == network && network == power) { + "The watchers must retain the same number of samples to share one scratch buffer, " + + "but memory=$memory, network=$network, power=$power" + } + return memory + } + + @VisibleForTesting + internal fun resetForTesting() { + instance = null + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt new file mode 100644 index 0000000000..113b9c89fe --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt @@ -0,0 +1,120 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.content.Context +import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +/** + * Writes a metrics chart image to a file the IDE can share (ADFA-5486). + * + * Snapshots go to a directory under the cache, so the platform can reclaim them and they never + * accumulate; the sharing intent gives the receiving app a grant on the file before that matters. + */ +object MetricsSnapshot { + private val log = LoggerFactory.getLogger(MetricsSnapshot::class.java) + + private const val DIRECTORY = "metrics-snapshots" + private const val QUALITY = 100 + + /** + * How many snapshots to keep. + * + * Enough that a share still has its file when the recipient gets round to reading it, few + * enough that a long session cannot fill the cache. These are a few hundred kilobytes each. + */ + @VisibleForTesting + internal const val KEEP_RECENT = 5 + + /** Media type for the written file, for the sharing intent. */ + const val MIME_TYPE = "image/png" + + /** + * Writes [bitmap] as a PNG, named by [MetricsFileName] like every other exported metrics file. + * + * The name used to lead with the chart's title. ADFA-5531 made one naming rule for the image and + * the CSV so that a pair exported together sorts together, and a title in front of the timestamp + * would have sorted them apart. + * + * A few recent snapshots are kept rather than only the newest. This is a scratch directory for + * handing an image to another app, not a gallery, so it stays bounded -- but a share hands the + * recipient a FileProvider URI and the chooser returns long before the recipient opens it. + * Deleting the previous file on the next export therefore pulled an image out from under an + * app that had not read it yet. [KEEP_RECENT] is the slack that buys. + * + * @return the file, or `null` if it could not be written. + */ + fun write( + context: Context, + bitmap: Bitmap, + nowMillis: Long = System.currentTimeMillis(), + ): File? { + val directory = File(context.cacheDir, DIRECTORY) + return try { + if (!directory.exists() && !directory.mkdirs()) { + log.error("Could not create the snapshot directory at {}", directory) + return null + } + + val file = File(directory, MetricsFileName.forTime(nowMillis, "png")) + file.outputStream().use { output -> + if (!bitmap.compress(Bitmap.CompressFormat.PNG, QUALITY, output)) { + log.error("Could not encode the chart snapshot") + return null + } + } + pruneTo(directory, KEEP_RECENT, file) + file + } catch (io: IOException) { + log.error("Could not write the chart snapshot", io) + null + } + } + + /** + * Trims [directory] to the [limit] most recent snapshots, always keeping [newest]. + * + * Oldest first, by last-modified. The file just written is protected explicitly rather than + * trusted to sort newest: two exports in the same second share a timestamp, and the filename + * carries only whole seconds. + */ + internal fun pruneTo( + directory: File, + limit: Int, + newest: File, + ) { + // [newest] is excluded from the candidates rather than skipped among them. Skipping it after + // choosing "the oldest n" left one file too many whenever it sorted into that set, and the + // directory then crept one over the limit per collision. Two writes inside one filesystem + // timestamp are enough to sort it there. + val candidates = directory.listFiles()?.filter { it != newest }?.sortedBy { it.lastModified() } ?: return + val excess = candidates.size - (limit - 1) + if (excess <= 0) { + return + } + candidates.take(excess).forEach { file -> + if (!file.delete()) { + log.warn("Could not delete the stale chart snapshot at {}", file) + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt new file mode 100644 index 0000000000..1ddc498b13 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshotAssembler.kt @@ -0,0 +1,176 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.content.Context +import android.os.SystemClock +import androidx.annotation.AnyThread + +/** + * Reads the watchers' buffers into a [MetricsCsv.Snapshot]. + * + * Deliberately free of the carousel. This began on MetricsCarouselController, which bailed when the + * carousel was unbound -- fine for a button on the carousel, wrong for everything else that wants + * the file: the feedback FAB can be tapped with the strip closed (ADFA-5534), and a crash report is + * assembled with no UI at all (ADFA-5526). + * + * Every read here takes the watcher's own history lock, so this is safe from any thread -- which it + * has to be, because a crash arrives on whatever thread threw (ADFA-5526). Formatting and writing + * are a different matter and must stay off the main thread. + */ +object MetricsSnapshotAssembler { + /** + * The watchers' current buffers, as the export format's view of them. + * + * Rows come from the memory watcher: it is the only one always recording, the network watcher + * stops for good on a device whose counters are unsupported, and a power source can be missing. + * The other series are read at the same index -- the watchers share an interval, are started + * together and are cleared together -- and each carries its own sample times, so a series that + * was not recording leaves empty cells rather than zeros. + * + * @param context resolves an annotation's label, which a build outcome carries as a string id + * so its marker follows the system language. + */ + @AnyThread + fun assemble( + context: Context, + memory: MemoryUsageWatcher, + network: NetworkUsageWatcher, + power: PowerUsageWatcher, + annotations: MetricsAnnotationStore?, + ): MetricsCsv.Snapshot = assemble(context, memory, network, power, annotations, scratch = null) + + /** + * Assembles a snapshot, hands it to [block], and only then gives the scratch back. + * + * The snapshot points *into* the scratch, so the scratch cannot be released when this returns -- + * it has to outlive whatever reads the snapshot, which is a file write. Scoping it to a block is + * how that is made hard to get wrong. + * + * Falls back to allocating when the scratch is already taken. A crash must not wait on an export, + * and two writers into one array is a scrambled file. + */ + @AnyThread + fun withSnapshot( + context: Context, + memory: MemoryUsageWatcher, + network: NetworkUsageWatcher, + power: PowerUsageWatcher, + annotations: MetricsAnnotationStore?, + block: (MetricsCsv.Snapshot) -> T, + ): T { + val scratch = MetricsScratch.instance?.takeIf { it.claim() } + return try { + block(assemble(context, memory, network, power, annotations, scratch)) + } finally { + scratch?.release() + } + } + + private fun assemble( + context: Context, + memory: MemoryUsageWatcher, + network: NetworkUsageWatcher, + power: PowerUsageWatcher, + annotations: MetricsAnnotationStore?, + scratch: MetricsScratch?, + ): MetricsCsv.Snapshot { + // One call per watcher, not one per array. Each hands back its times and its values from a + // single critical section, which is what keeps a row of the file a single moment: asking + // separately let a sample land between the two calls, and every value came out one row off + // its own timestamp (ADFA-5531). + val memoryHistory = + if (scratch == null) { + memory.history() + } else { + memory.copyHistoryInto(scratch.memoryTimes, scratch.memoryValues) + } + val networkUsage = + if (scratch == null) { + network.getUsage() + } else { + network.copyUsageInto(scratch.networkReceived, scratch.networkTransmitted, scratch.networkTimes) + } + val powerUsage = + if (scratch == null) { + power.getUsage() + } else { + power.copyUsageInto(scratch.temperature, scratch.power, scratch.thermal, scratch.powerTimes) + } + + return MetricsCsv.Snapshot( + rowTimes = memoryHistory.times, + // The memory watcher's, because its times are the rows. + sampleIntervalMillis = memory.updateInterval, + memory = + memoryHistory.processes.associate { process -> + process.pname to + MetricsCsv.Series( + times = memoryHistory.times, + values = process.usage, + since = process.watchedSinceMillis, + ) + }, + networkReceived = MetricsCsv.Series(networkUsage.sampleTimes, networkUsage.received), + networkTransmitted = MetricsCsv.Series(networkUsage.sampleTimes, networkUsage.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( + powerUsage.sampleTimes, + powerUsage.temperatureMilliCelsius, + absent = PowerUsageWatcher.UNAVAILABLE, + ), + power = + MetricsCsv.Series( + powerUsage.sampleTimes, + powerUsage.powerMicroWatts, + absent = PowerUsageWatcher.UNAVAILABLE, + ), + thermal = + MetricsCsv.Series( + powerUsage.sampleTimes, + powerUsage.thermalStatus, + absent = PowerUsageWatcher.THERMAL_UNKNOWN.toLong(), + ), + annotations = markers(context, annotations), + ) + } + + /** + * The annotations, with their times moved onto the clock the samples carry. + * + * The store records on the monotonic clock and the samples on the wall clock, and the two are + * read here as close together as they can be so the offset between them is the right one. + */ + private fun markers( + context: Context, + annotations: MetricsAnnotationStore?, + ): List { + val store = annotations ?: return emptyList() + val nowEpoch = System.currentTimeMillis() + val nowMonotonic = SystemClock.elapsedRealtime() + return store.allAnnotations().map { annotation -> + MetricsCsv.Marker( + atMillis = MetricsCsv.epochFor(annotation.atMillis, nowEpoch, nowMonotonic), + label = annotation.kind.labelRes?.let(context::getString) ?: annotation.label, + kind = annotation.kind.name, + ) + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSource.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSource.kt new file mode 100644 index 0000000000..d84e9e19f3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSource.kt @@ -0,0 +1,59 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +/** + * Where a process-wide caller finds the live metrics watchers (ADFA-5526). + * + * The watchers belong to an activity-scoped ViewModel, which is right for the carousel and no use to + * a crash handler: a crash arrives on any thread, from anywhere, with no activity in hand. This is + * the one indirection that lets the handler reach them. + * + * Deliberately thin, and deliberately nullable. There is no source before the editor has run -- a + * crash during onboarding, in the project chooser, or in direct boot has no history to report -- and + * a caller that cannot find one attaches nothing rather than inventing something. + */ +object MetricsSource { + /** What a crash handler needs to build a snapshot. */ + interface Metrics { + val memoryUsageWatcher: MemoryUsageWatcher + val networkUsageWatcher: NetworkUsageWatcher + val powerUsageWatcher: PowerUsageWatcher + val annotations: MetricsAnnotationStore + } + + @Volatile + var current: Metrics? = null + private set + + fun register(metrics: Metrics) { + current = metrics + } + + /** + * Clears [current] if [metrics] is still the registered one. + * + * Conditional because an activity recreation can register the replacement before the outgoing + * one is cleared, and an unconditional clear would then drop the live source. + */ + fun unregister(metrics: Metrics) { + if (current === metrics) { + current = null + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt b/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt index 7c2bdb59a5..6add3e6902 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt @@ -23,37 +23,65 @@ package com.itsaky.androidide.utils * @author Akash Yadav */ class MutableShiftedLongArray( - array: LongArray, - shift: Int = 0 + array: LongArray, + shift: Int = 0, ) : ShiftedLongArray(array, shift) { + /** + * @param capacity The capacity of the array. + * @param shift The shift amount. + * @param init A function to initialize the values of the array. + */ + constructor(capacity: Int, shift: Int = 0, init: (Int) -> Long = { 0 }) : this( + LongArray(capacity, init), + shift, + ) - /** - * @param capacity The capacity of the array. - * @param shift The shift amount. - * @param init A function to initialize the values of the array. - */ - constructor(capacity: Int, shift: Int = 0, init: (Int) -> Long = { 0 }) : this( - LongArray(capacity, init), - shift) + operator fun set( + index: Int, + value: Long, + ) { + checkIdx(index) + array[getShiftedIndex(index)] = value + } - operator fun set(index: Int, value: Long) { - checkIdx(index) - array[getShiftedIndex(index)] = value - } + /** + * Sets the given value at the specified absolute (un-shifted) index. + */ + fun setAbsolute( + index: Int, + value: Long, + ) { + array[index] = value + } - /** - * Sets the given value at the specified absolute (un-shifted) index. - */ - fun setAbsolute(index: Int, value: Long) { - array[index] = value - } + /** + * An independent copy, in the same logical order. + * + * For handing a reader a stable view while the sampler keeps appending to this one. The copy + * carries no shift, so index 0 is the oldest entry in it. + */ + fun copy(): MutableShiftedLongArray = MutableShiftedLongArray(LongArray(size) { this[it] }) - /** - * Shifts the array by the specified amount. The shift amount is added to the current shift. - * - * @param shift The shift amount. - */ - fun shift(shift: Int) { - this.shift = (this.shift + shift) % size - } -} \ No newline at end of file + /** + * 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). + */ + @JvmOverloads + fun clear(fillWith: Long = 0L) { + array.fill(fillWith) + shift = 0 + } + + /** + * Shifts the array by the specified amount. The shift amount is added to the current shift. + * + * @param shift The shift amount. + */ + fun shift(shift: Int) { + this.shift = (this.shift + shift) % size + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt new file mode 100644 index 0000000000..bf597315a1 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -0,0 +1,414 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.net.TrafficStats +import android.os.Process +import androidx.annotation.VisibleForTesting +import com.itsaky.androidide.tasks.cancelIfActive +import kotlinx.coroutines.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 java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext + +/** + * Samples this app's network traffic (ADFA-5489). + * + * Accounting is UID-level, not per socket: [TrafficStats.getUidRxBytes] and + * [TrafficStats.getUidTxBytes] cover every process sharing the app's UID, which is what makes + * Gradle's downloads show up here -- the Gradle Tooling and daemon processes share it. No socket + * tagging is involved, so there is deliberately no per-feature breakdown. + * + * The platform counters are cumulative since boot, so what is recorded is the *delta* between + * consecutive samples: bytes transferred during that interval. A sampler that reported the raw + * counters would draw a monotonically rising line that says nothing about current activity. + * + * @param updateInterval Milliseconds between samples. + * @param uid The UID to account for. Defaults to this process's own; injectable for tests. + * @param readRxBytes Reads the cumulative received byte count. Injectable for tests. + * @param readTxBytes Reads the cumulative transmitted byte count. Injectable for tests. + */ +class NetworkUsageWatcher + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + constructor( + updateInterval: Long = DEFAULT_UPDATE_INTERVAL, + private val uid: Int = Process.myUid(), + private val readRxBytes: (Int) -> Long = TrafficStats::getUidRxBytes, + private val readTxBytes: (Int) -> Long = TrafficStats::getUidTxBytes, + // Injectable so a test can drive the sampling loop on a virtual clock. Waiting on the wall + // clock instead is what hung the test executor the first time this was attempted. + private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("NetworkUsageWatcher"), + // Null means "the real main dispatcher", resolved where it is used rather than here: + // touching Dispatchers.Main at construction throws in a plain JVM test, and most of these + // tests never start the sampling loop at all. + private val mainDispatcher: CoroutineContext? = null, + private val nowMillis: () -> Long = System::currentTimeMillis, + ) { + private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher) + private val watching = AtomicBoolean(false) + + /** + * Set by [close] and never cleared. Without it a start after a terminal teardown would flip + * [isWatching] to true and launch into a cancelled scope, leaving the watcher reporting that + * it is sampling when no loop exists. + */ + private val closed = AtomicBoolean(false) + + /** The running sampling loop, so [stopWatching] can actually stop it. */ + private var samplingJob: Job? = null + + /** + * Which sampling loop is the current one. + * + * `samplingJob` is assigned only after `launch` returns, so a stop landing in that gap + * cancels whatever the field held rather than the loop just started, and a later start can + * overwrite the field with a job nothing then cancels -- leaving two loops appending to the + * same buffers, at twice the sample rate, out of step with the row timestamps. Cancelling + * more carefully cannot fix that; the assignments themselves can land out of order. So each + * loop carries the generation it was started for and stops as soon as it is not the current + * one, whichever assignment won. + */ + private val samplingGeneration = AtomicInteger(0) + + /** + * Milliseconds between samples. Changing it clears the history, for the reason given on + * [MemoryUsageWatcher.updateInterval]. + * + * 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() + } + + /** Guards the two ring buffers: the sampler writes them, the UI thread snapshots them. */ + private val historyLock = Any() + + /** + * When each sample was taken, in the same order and at the same indices as the values. + * + * Recorded rather than reconstructed. The chart infers a sample's age from its position, + * which is close enough for placing a marker on a plot, but the exported metrics file states + * a time per row (ADFA-5531) and inference would be wrong three ways: the newest sample was + * taken up to an interval before the export, the loop delays *after* doing its work so the + * true period drifts past the nominal one, and sampling can stop and restart without the + * buffer being cleared. + * + * A zero means no sample was ever recorded at that index, which is what tells a blank cell + * apart from a measured zero. + */ + private val sampleTimes = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + + private val received = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + private val transmitted = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + + /** + * The previous cumulative readings, or `null` before the first sample. The first sample + * establishes a baseline and contributes no delta -- the alternative would be a spike equal to + * everything the app had transferred since boot. + */ + private var lastRx: Long? = null + private var lastTx: Long? = null + + /** + * Whether the platform reports traffic for this UID at all. Cleared permanently if a read comes + * back [TrafficStats.UNSUPPORTED], which some devices and emulators do. + */ + @Volatile + var isSupported: Boolean = true + private set + + val isWatching: Boolean + get() = watching.get() + + /** + * Notified on the main thread after each sample. + */ + @Volatile + var listener: NetworkUsageListener? = null + + /** + * A snapshot of the sampled history, oldest first. Safe to call from any thread at any time; + * before the first sample every entry is zero. + * + * The arrays are copies. Handing out the live ring buffers would let the caller read them while + * the sampler thread is midway through appending, and the chart renderer reads all 30 entries. + */ + fun getUsage(): NetworkUsage = copyUsageInto(LongArray(received.size), LongArray(transmitted.size), LongArray(sampleTimes.size)) + + /** + * [getUsage], into destinations the caller owns (ADFA-5526). + * + * The times come back with the values because they are read in the same critical section: + * asking separately let a sample land between the calls and shifted every value one index + * against its timestamp (ADFA-5531). + */ + fun copyUsageInto( + receivedDest: LongArray, + transmittedDest: LongArray, + timesDest: LongArray, + ): NetworkUsage = + synchronized(historyLock) { + NetworkUsage( + received.copyInto(receivedDest), + transmitted.copyInto(transmittedDest), + sampleTimes.copyInto(timesDest), + ) + } + + /** + * Discards every recorded sample and drops the cumulative baseline, so the next sample + * re-establishes it rather than reporting everything since the last one as one huge delta. + */ + fun clearHistory() { + synchronized(historyLock) { + received.clear() + transmitted.clear() + sampleTimes.clear() + lastRx = null + lastTx = null + } + } + + fun startWatching() { + if (closed.get()) { + log.warn("Network usage watcher is closed and cannot be restarted") + return + } + + if (!watching.compareAndSet(false, true)) { + log.warn("Network usage is already being watched") + return + } + + val generation = samplingGeneration.incrementAndGet() + samplingJob = + coroutineScope.launch { + while (isWatching && samplingGeneration.get() == generation) { + // A throw here used to end the coroutine while `watching` stayed true, so every + // later startWatching() was refused as "already watching" and sampling stopped + // for good. A sample is worth losing; the loop is not. + runCatching { + sampleOnce() + + listener?.also { listener -> + val usage = getUsage() + withContext(mainDispatcher ?: Dispatchers.Main.immediate) { + listener.onNetworkUsageChanged(usage) + } + } + }.onFailure { failure -> + if (failure is CancellationException) { + throw failure + } + log.error("Network usage sampling failed; continuing", failure) + } + + // A device whose counters are unsupported has nothing further to give, and + // the loop was otherwise repainting three charts a second with data known + // to be permanently zero. Clearing the flag too, so isWatching does not + // claim a sampler that has stopped. + if (!isSupported) { + watching.set(false) + break + } + + delay(updateInterval) + } + } + } + + /** + * Stops sampling. The watcher can be started again; [close] is what makes it unusable. + * + * The job is cancelled rather than left to notice the flag: it spends almost all its time in + * `delay(updateInterval)`, which is up to a minute at the slowest rate, so a stop followed by a + * start inside that window would leave the old loop running alongside the new one, both + * recording samples and notifying the chart. + */ + fun stopWatching() { + watching.set(false) + // Drop the cumulative baseline as well. Left set, the first sample after a resume + // reports everything transferred while the watcher was stopped as a single interval -- + // background a Gradle download for three minutes and the chart reads hundreds of MB/s. + // The next sample re-establishes it, which is what the null baseline means. + synchronized(historyLock) { + lastRx = null + lastTx = null + } + samplingGeneration.incrementAndGet() + samplingJob?.cancel() + samplingJob = null + } + + /** + * Stops sampling and releases the sampling thread. The watcher cannot be started again. + * + * Separate from [stopWatching] because a watcher is stopped and restarted across the editor's + * lifecycle; only a terminal teardown should give up the thread, and `newSingleThreadContext` + * holds one until it is closed. + */ + fun close() { + closed.set(true) + stopWatching() + listener = null + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() + } + + /** + * Takes one sample. The sampling loop calls this once per [updateInterval]; tests call it + * directly so the delta accounting can be exercised without threads or waiting. + */ + @VisibleForTesting + internal fun sampleOnce() { + if (!isSupported) { + return + } + + val rx = readRxBytes(uid) + val tx = readTxBytes(uid) + + if (rx == UNSUPPORTED || tx == UNSUPPORTED) { + // Not transient: the platform either accounts for this UID or it does not. + isSupported = false + log.info("Network usage is unavailable on this device; the traffic chart will read zero") + return + } + + // One block, not two. Between them clearHistory() could null the baselines -- it runs + // when the sampling rate changes, precisely so that no delta straddles the change -- + // and the second block then put the pre-reset values straight back, so the next + // sample counted traffic from before the change. + synchronized(historyLock) { + sampleTimes[0] = nowMillis() + sampleTimes.shift(1) + record(received, previous = lastRx, current = rx) + record(transmitted, previous = lastTx, current = tx) + lastRx = rx + lastTx = tx + } + } + + /** + * Appends the delta between [previous] and [current] to [history]. + * + * A negative delta means the counter went backwards, which happens when it is reset -- the + * device rebooted, or the platform re-based its accounting. Treated as a fresh baseline (zero + * for this interval) rather than plotted as negative traffic. + */ + private fun record( + history: MutableShiftedLongArray, + previous: Long?, + current: Long, + ) { + val delta = + when { + previous == null -> 0L + current < previous -> 0L + else -> current - previous + } + + // Newest entry goes in at index 0 and the shift makes it the last element, so + // history[size - 1] is always the newest. Same convention as MemoryUsageWatcher. + history[0] = delta + history.shift(1) + } + + /** + * Bytes transferred per sampling interval, oldest first. + * + * @property received Bytes received during each interval. + * @property transmitted Bytes transmitted during each interval. + * @property sampleTimes When each sample was taken, oldest first, as milliseconds since the + * epoch, parallel to the values. Read in the same critical section as them, because reading + * the two separately let the sampler append between the calls and shifted every value one + * index against its timestamp (ADFA-5531). A zero means nothing was ever sampled at that + * index -- the buffers are fixed-length and start, and are cleared, full of them. + * + * Required, with no empty default. A caller that omitted it produced a history whose every + * sample read as never-taken, which the chart cannot see -- it asks only how long ago a + * sample was -- but which silently emptied both of this watcher's columns in the CSV. + */ + data class NetworkUsage( + val received: LongArray, + val transmitted: LongArray, + val sampleTimes: LongArray, + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is NetworkUsage && + received.contentEquals(other.received) && + transmitted.contentEquals(other.transmitted) && + sampleTimes.contentEquals(other.sampleTimes) + ) + + override fun hashCode(): Int { + var result = received.contentHashCode() + result = 31 * result + transmitted.contentHashCode() + result = 31 * result + sampleTimes.contentHashCode() + return result + } + } + + fun interface NetworkUsageListener { + fun onNetworkUsageChanged(usage: NetworkUsage) + } + + companion object { + /** + * Samples retained per series. + * + * An hour at [DEFAULT_UPDATE_INTERVAL], and the chart shows sixty of them at a time + * (ADFA-5486). It was 10,000, which is nearly three hours nobody was looking at -- and + * eleven buffers of that is 859KB held for the life of the process, doubled by the + * pre-allocated snapshot destinations [MetricsScratch] adds so a crash handler never has + * to allocate. At 3,600 the two together cost less than the one did (ADFA-5526). + * + * A count of samples, not a duration: at the fastest offered rate of 100ms it is six + * minutes rather than an hour. + */ + const val MAX_USAGE_ENTRIES = 3600 + const val DEFAULT_UPDATE_INTERVAL = 1000L + + /** [TrafficStats.UNSUPPORTED] widened to [Long], which is what the getters return. */ + private const val UNSUPPORTED = TrafficStats.UNSUPPORTED.toLong() + + private val log = LoggerFactory.getLogger(NetworkUsageWatcher::class.java) + } + } 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..df1f9758af --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/PowerUsageWatcher.kt @@ -0,0 +1,386 @@ +/* + * 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 java.util.concurrent.atomic.AtomicInteger +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 nowMillis: () -> Long = System::currentTimeMillis, + ) { + 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 + + /** + * Which sampling loop is the current one. + * + * `samplingJob` is assigned only after `launch` returns, so a stop landing in that gap + * cancels whatever the field held rather than the loop just started, and a later start can + * overwrite the field with a job nothing then cancels -- leaving two loops appending to the + * same buffers, at twice the sample rate, out of step with the row timestamps. Cancelling + * more carefully cannot fix that; the assignments themselves can land out of order. So each + * loop carries the generation it was started for and stops as soon as it is not the current + * one, whichever assignment won. + */ + private val samplingGeneration = AtomicInteger(0) + + /** Guards the ring buffers: the sampler writes them, the UI thread snapshots them. */ + private val historyLock = Any() + + /** + * When each sample was taken, in the same order and at the same indices as the values. + * + * Recorded rather than reconstructed. The chart infers a sample's age from its position, + * which is close enough for placing a marker on a plot, but the exported metrics file states + * a time per row (ADFA-5531) and inference would be wrong three ways: the newest sample was + * taken up to an interval before the export, the loop delays *after* doing its work so the + * true period drifts past the nominal one, and sampling can stop and restart without the + * buffer being cleared. + * + * A zero means no sample was ever recorded at that index, which is what tells a blank cell + * apart from a measured zero. Zero is safe as the sentinel here, unlike the value series + * below: no real sample was taken at the epoch. + */ + private val sampleTimes = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + + // Filled with UNAVAILABLE, not zero. A slot that has never been sampled is an absence, and + // zero is a reading: a zero-filled prefix plotted a flat 0 C and 0 W line and presented it + // as measurement, which then forced applyAxisRanges to special-case `!= 0L` -- discarding + // a genuine freezing-battery sample along with the fake ones (ADFA-5499). + 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. + * + * Filled with [THERMAL_UNKNOWN], not [UNAVAILABLE]: this series has its own sentinel, and + * every consumer and [MetricsSnapshotAssembler]'s `absent` already use it. Filled with + * UNAVAILABLE instead, `Long.MIN_VALUE.toInt()` is 0 -- THERMAL_STATUS_NONE, "measured and + * not throttled" -- and the CSV would not recognise it as absent, writing the raw + * MIN_VALUE into the column. + */ + private val thermal = MutableShiftedLongArray(MAX_USAGE_ENTRIES) { THERMAL_UNKNOWN.toLong() } + + /** + * Milliseconds between samples. Changing it clears the history, for the reason given on + * [MemoryUsageWatcher.updateInterval]. + * + * Volatile: written on the UI thread and read on the watcher's own sampling thread. + * Without it the reader can go on seeing a stale value indefinitely. + */ + @Volatile + var updateInterval: Long = MetricsSamplingRates.coerceToSafeRange(updateInterval) + set(value) { + val safe = MetricsSamplingRates.coerceToSafeRange(value) + 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 = + copyUsageInto( + LongArray(temperature.size), + LongArray(power.size), + LongArray(thermal.size), + LongArray(sampleTimes.size), + ) + + /** + * [getUsage], into destinations the caller owns (ADFA-5526). + * + * The times come back with the values because they are read in the same critical section: + * asking separately let a sample land between the calls and shifted every value one index + * against its timestamp (ADFA-5531). + */ + fun copyUsageInto( + temperatureDest: LongArray, + powerDest: LongArray, + thermalDest: LongArray, + timesDest: LongArray, + ): PowerUsage = + synchronized(historyLock) { + PowerUsage( + temperature.copyInto(temperatureDest), + power.copyInto(powerDest), + thermal.copyInto(thermalDest), + sampleTimes.copyInto(timesDest), + ) + } + + fun clearHistory() { + synchronized(historyLock) { + sampleTimes.clear() + temperature.clear(UNAVAILABLE) + power.clear(UNAVAILABLE) + thermal.clear(THERMAL_UNKNOWN.toLong()) + } + } + + 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 + } + + val generation = samplingGeneration.incrementAndGet() + samplingJob = + coroutineScope.launch { + while (isWatching && samplingGeneration.get() == generation) { + 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) + samplingGeneration.incrementAndGet() + 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 + // The source too, if it holds anything. DevicePowerSource registers a battery receiver + // against the application context, so a source left open outlives this watcher and the + // editor that created it -- one more receiver per editor session, for the life of the + // process. PowerSource stays a fun interface so a test can still pass a lambda. + (source as? AutoCloseable)?.let { closeable -> + runCatching { closeable.close() } + .onFailure { log.warn("Could not close the power source", it) } + } + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() + } + + /** + * 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(sampleTimes, nowMillis()) + 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. + * @property sampleTimes When each sample was taken, oldest first, as milliseconds since the + * epoch, parallel to the values. Read in the same critical section as them, because reading + * the two separately let the sampler append between the calls and shifted every value one + * index against its timestamp (ADFA-5531). A zero means nothing was ever sampled at that + * index -- the buffers are fixed-length and start, and are cleared, full of them. + * + * Required, with no empty default. A caller that omitted it produced a history whose every + * sample read as never-taken, which the chart cannot see -- it asks only how long ago a + * sample was -- but which silently emptied every one of this watcher's columns in the CSV. + */ + data class PowerUsage( + val temperatureMilliCelsius: LongArray, + val powerMicroWatts: LongArray, + val thermalStatus: LongArray, + val sampleTimes: LongArray, + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is PowerUsage && + temperatureMilliCelsius.contentEquals(other.temperatureMilliCelsius) && + powerMicroWatts.contentEquals(other.powerMicroWatts) && + thermalStatus.contentEquals(other.thermalStatus) && + sampleTimes.contentEquals(other.sampleTimes) + ) + + override fun hashCode(): Int { + var result = temperatureMilliCelsius.contentHashCode() + result = 31 * result + powerMicroWatts.contentHashCode() + result = 31 * result + thermalStatus.contentHashCode() + result = 31 * result + sampleTimes.contentHashCode() + return result + } + } + + fun interface PowerUsageListener { + fun onPowerUsageChanged(usage: PowerUsage) + } + + companion object { + /** Samples retained per series, matching the other watchers (ADFA-5526). */ + const val MAX_USAGE_ENTRIES = 3600 + const val DEFAULT_UPDATE_INTERVAL = 1000L + + /** A reading the device does not provide. */ + 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/ProcessMemoryReader.kt b/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt new file mode 100644 index 0000000000..867699dfb7 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ProcessMemoryReader.kt @@ -0,0 +1,237 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.os.Debug +import android.os.Debug.MemoryInfo +import android.os.Process +import androidx.annotation.VisibleForTesting +import com.itsaky.androidide.BuildConfig +import com.termux.shared.reflection.ReflectionUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Reads one watched process's total memory footprint, in kB (ADFA-5574). + * + * A seam with two implementations, because the processes the metrics carousel plots are not alike + * and reading them all the same way costs the same as reading the most expensive one, three times. + */ +fun interface ProcessMemoryReader { + /** + * This process's footprint in kB, or [ProcessMemoryReaders.UNAVAILABLE] if it could not be + * read. + * + * @param scratch A reusable [MemoryInfo]. Readers that do not need one ignore it; it is a + * parameter rather than an allocation because this runs on every sample. + */ + fun totalKb( + pid: Int, + scratch: MemoryInfo, + ): Int +} + +/** + * Picks the cheapest reader that is still correct for a given process. + * + * The IDE is a Zygote fork and has GPU memory; the tooling server and the Gradle daemon are plain + * OpenJDK processes exec'd from the app's Termux prefix and have none. Measured on a Pixel 6 Pro: + * for the JVMs `smaps_rollup` and `Debug.getMemoryInfo` agree to 0.009% (66,018 against 66,012 kB) + * while the rollup costs 13.4ms against 31.4ms; for the IDE the rollup reads ~124MB low, because + * `dumpsys meminfo` accounts EGL mtrack 89MB and GL mtrack 36MB through the memtrack HAL rather + * than through `/proc/pid/smaps`, where a rollup cannot see them. That is 23% of the IDE's total, + * so the IDE keeps the expensive read. + */ +object ProcessMemoryReaders { + /** Returned when a process's footprint could not be read at all. */ + const val UNAVAILABLE = -1 + + private val log = LoggerFactory.getLogger(ProcessMemoryReaders::class.java) + + /** + * Whether this kernel offers a rollup at all. + * + * Checked once. `smaps_rollup` arrived in Linux 4.14, so Android 10 in practice, and minSdk + * here is 28 -- a device below that gets the reflective read for everything, which is what it + * had before. + */ + @VisibleForTesting + internal val isRollupSupported: Boolean by lazy { + File("/proc/self/smaps_rollup").exists() + } + + /** + * The reader for [pid], decided once when a process starts being watched. + * + * `pid == Process.myPid()` is the whole test, and it costs nothing: the only Zygote-forked + * process the carousel plots is the app itself. Nothing has to inspect `/proc` to find out. + */ + fun chooseReader(pid: Int): ProcessMemoryReader = + chooseReader(pid, Process.myPid(), isRollupSupported).also { chosen -> + if (BuildConfig.DEBUG && chosen === SmapsRollupReader) { + // Off the caller's thread. The only caller is watchProcess, and for the Gradle + // daemon it reaches there from a main-dispatched build callback -- so this + // sequential scan of a JVM's maps file (93,120 lines for the IDE's own) ran on the + // UI thread, in exactly the build a developer is watching. A StrictMode + // DiskReadViolation, and visible jank, for a debug-only warning. + diagnosticsScope.launch { warnIfProcessHasGraphicsMemory(pid) } + } + } + + /** Debug-only diagnostics, off whatever thread started watching a process. */ + private val diagnosticsScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** + * Complains if a process given the cheap read turns out to be an Android runtime process. + * + * The rule rests on an assumption about the three processes plotted today: only the app's own + * is Zygote-forked, and only a Zygote fork has graphics memory a rollup cannot see. Add a + * fourth watched process that is one, and its line would quietly read about a quarter low -- + * the failure this whole ticket is about, arriving silently. One maps scan when a process starts + * being watched, in debug builds only, turns that into something someone notices. + */ + private fun warnIfProcessHasGraphicsMemory(pid: Int) { + val isRuntimeProcess = + runCatching { + File("/proc/$pid/maps").useLines { lines -> + lines.any { it.contains("libandroid_runtime.so") } + } + }.getOrDefault(false) + if (isRuntimeProcess) { + log.error( + "pid {} maps libandroid_runtime.so, so it may hold graphics memory that " + + "smaps_rollup cannot see. Its memory line will read low. See ADFA-5574.", + pid, + ) + } + } + + @VisibleForTesting + internal fun chooseReader( + pid: Int, + ownPid: Int, + rollupSupported: Boolean, + ): ProcessMemoryReader = + if (pid == ownPid || !rollupSupported) { + DebugMemoryInfoReader + } else { + SmapsRollupReader + } + + internal fun logFallback(pid: Int) { + log.warn("smaps_rollup unreadable for pid {}; falling back to Debug.getMemoryInfo", pid) + } +} + +/** + * `Debug.getMemoryInfo`, reached reflectively. + * + * The only source that includes graphics memory, which is why the app's own process uses it. + * Reflective because `ActivityManager.getProcessMemoryInfo` is rate-limited and internally calls + * this, so going straight to it sidesteps the limit. + */ +object DebugMemoryInfoReader : ProcessMemoryReader { + private val getMemoryInfo: java.lang.reflect.Method by lazy { + checkNotNull( + ReflectionUtils.getDeclaredMethod( + Debug::class.java, + "getMemoryInfo", + Int::class.javaPrimitiveType, + MemoryInfo::class.java, + ), + ) { + "Unable to find getMemoryInfo method in android.os.Debug class" + } + } + + override fun totalKb( + pid: Int, + scratch: MemoryInfo, + ): Int { + ReflectionUtils.invokeMethod(getMemoryInfo, null, pid, scratch) + + // From https://developer.android.com/tools/dumpsys#meminfo + // "PSS is a good measure for the actual RAM weight of a process and for comparison + // against the RAM use of other processes and the total available RAM." + return scratch.totalPss + } +} + +/** + * The kernel's own PSS total, from `/proc/pid/smaps_rollup`. + * + * The `Pss:` field alone, not `Pss` plus `SwapPss`. Measured against `Debug.getMemoryInfo` on a + * JVM process, `Pss` alone was 6kB *higher* out of 66MB, so adding swap would move it further + * away rather than closer. + * + * Cheaper than walking `/proc/pid/smaps` because the kernel does the summation and hands back one + * short file rather than one stanza per mapping -- 22 lines against 93,120 for the IDE. The kernel + * still walks every mapping to compute it, which is why this is 2.3x cheaper and not 40x. + */ +object SmapsRollupReader : ProcessMemoryReader { + override fun totalKb( + pid: Int, + scratch: MemoryInfo, + ): Int = + runCatching { + File("/proc/$pid/smaps_rollup").useLines { lines -> pssKbFrom(lines) } + }.getOrDefault(ProcessMemoryReaders.UNAVAILABLE) + + /** + * Picks the rollup's `Pss` out of [lines] and reads its value. + * + * Separate from [totalKb] so both halves can be tested: choosing the right line matters as much + * as parsing it, and a test that does its own line-picking would pin only the parse. + */ + @VisibleForTesting + internal fun pssKbFrom(lines: Sequence): Int = + lines + .firstOrNull { it.startsWith(PSS_PREFIX) } + ?.let(::firstIntOrUnavailable) + ?: ProcessMemoryReaders.UNAVAILABLE + + /** + * The first run of digits in a line, without allocating. + * + * `Pss: 425176 kB`. Hand-scanned rather than split, because this runs on every + * sample for every watched process. + */ + private fun firstIntOrUnavailable(line: String): Int { + var value = 0 + var seen = false + for (c in line) { + if (c in '0'..'9') { + value = value * 10 + (c - '0') + seen = true + } else if (seen) { + break + } + } + return if (seen) value else ProcessMemoryReaders.UNAVAILABLE + } + + /** + * Deliberately with the colon. The rollup also carries `Pss_Anon`, `Pss_File`, `Pss_Shmem` and + * `Pss_Dirty`, and a prefix of `Pss` alone would match whichever came first. + */ + private const val PSS_PREFIX = "Pss:" +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt b/app/src/main/java/com/itsaky/androidide/utils/ShiftedLongArray.kt index 4e2c524293..36cf8ba013 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,123 @@ 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 = copyInto(LongArray(size)) + +/** + * Copies this ring buffer into [dest] in logical order, oldest first, and returns it. + * + * For a caller that owns its destination already. A crash handler must not allocate -- the crash it + * is reporting may be the heap running out -- so ADFA-5526 pre-allocates one set of destinations at + * startup and fills them here instead of taking eleven fresh arrays per snapshot. + * + * @throws IllegalArgumentException when [dest] is not exactly this buffer's length. A short + * destination would silently truncate the history and a long one would leave a stale tail behind + * it, and both read as data. + */ +internal fun ShiftedLongArray.copyInto(dest: LongArray): LongArray { + require(dest.size == size) { "Destination is ${dest.size} long, buffer is $size" } + for (i in 0 until size) { + dest[i] = this[i] + } + return dest +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 027670f55a..1d56122c16 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -15,6 +15,7 @@ import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.GradleBuildParams import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -91,7 +92,17 @@ class BuildViewModel( }.await() if (result == null || !result.isSuccessful) { - throw RuntimeException("Task execution failed: ${result.failure}") + // A build the user stopped is not a failure, and it does not arrive as a + // CancellationException -- the catch below only recognises the coroutine kind. + // It comes back as a result carrying BUILD_CANCELLED, so without this the + // user's own Stop finished as BuildState.Error("Task execution failed: + // BUILD_CANCELLED"), with the enum name shown to them. + if (result?.failure == TaskExecutionResult.Failure.BUILD_CANCELLED) { + log.info("Build was cancelled by the user.") + reporter.finish(BuildState.Idle) + return@launch + } + throw RuntimeException("Task execution failed: ${result?.failure}") } if (isPluginProject) { diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt new file mode 100644 index 0000000000..3a511114d5 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt @@ -0,0 +1,72 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.viewmodel + +import 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.MetricsSource +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher + +/** + * Owns the sample history behind the editor's metrics carousel. + * + * The watchers used to be fields on the editor activity, and survived rotation only because + * `EditorActivityKt` happens to declare `orientation` in its `configChanges`. Drop that flag, or add + * a screen that does not declare it, and an hour of history would vanish silently. Holding them here + * makes survival a property of the ViewModel lifecycle instead of a manifest coincidence + * (ADFA-5486). + * + * This survives configuration changes and activity recreation. It does not survive the process being + * killed -- see ADFA-5494. + */ +class MetricsViewModel( + application: Application, +) : AndroidViewModel(application), + MetricsSource.Metrics { + override val memoryUsageWatcher = MemoryUsageWatcher() + + override val networkUsageWatcher = NetworkUsageWatcher() + + /** + * Temperature and power (ADFA-5499). Needs a Context for the battery broadcast, which is why + * this is an AndroidViewModel. + */ + override val powerUsageWatcher = PowerUsageWatcher(source = DevicePowerSource(application)) + + /** Significant events for the charts to annotate (ADFA-5486). */ + override val annotations = MetricsAnnotationStore() + + init { + // So a crash handler can reach the history (ADFA-5526). It has no activity to ask. + MetricsSource.register(this) + } + + override fun onCleared() { + super.onCleared() + MetricsSource.unregister(this) + // close(), not stopWatching(): this is the terminal teardown, and each watcher holds a + // dedicated sampling thread that newSingleThreadContext keeps alive until it is closed. + memoryUsageWatcher.close() + networkUsageWatcher.close() + powerUsageWatcher.close() + } +} diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000000..a31428756f --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_spreadsheet.xml b/app/src/main/res/drawable/ic_spreadsheet.xml new file mode 100644 index 0000000000..db16b3dd48 --- /dev/null +++ b/app/src/main/res/drawable/ic_spreadsheet.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/item_metrics_chart.xml b/app/src/main/res/layout/item_metrics_chart.xml new file mode 100644 index 0000000000..643645bd1f --- /dev/null +++ b/app/src/main/res/layout/item_metrics_chart.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index 92888099bc..aee15c1e87 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -1,33 +1,141 @@ - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e39716e1cf..70c48d7d9b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,33 +1,32 @@ - + - 200dp - 28dp + 248dp + 16dp + 4dp + 40dp + 10dp + 48dp + 12dp + + 28dp + 28dp - 28dp - 8dp - 24dp - 32sp - 16sp - 12sp + 28dp + 8dp + 24dp + 32sp + 16sp + 12sp - - 44dp - 64dp - 6dp - \ No newline at end of file + + 44dp + 64dp + 6dp + diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt new file mode 100644 index 0000000000..8bb19fa47f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt @@ -0,0 +1,59 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities.editor + +import android.graphics.Color +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MutableShiftedLongArray +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * That an unnamed process costs a line colour rather than the editor. + * + * This fallback has been established twice and removed twice. It is reached from the once-a-second + * sample listener and from RecyclerView's bind pass, so throwing here takes the editor down from a + * timer callback or mid-layout -- for the sake of a colour. + */ +@RunWith(RobolectricTestRunner::class) +class MemUsageLineColorTest { + private fun process(name: String) = + MemoryUsageWatcher.ProcessMemoryInfo( + pid = 1234, + pname = name, + _history = MutableShiftedLongArray(4), + watchedSinceMillis = 0L, + ) + + @Test + fun `the three watched processes keep their colours`() { + assertThat(BaseEditorActivity.getMemUsageLineColorFor(process("IDE"))).isEqualTo(Color.BLUE) + assertThat(BaseEditorActivity.getMemUsageLineColorFor(process("Gradle Tooling"))).isEqualTo(Color.RED) + assertThat(BaseEditorActivity.getMemUsageLineColorFor(process("Gradle Daemon"))).isEqualTo(Color.GREEN) + } + + @Test + fun `a process nobody gave a colour gets one anyway`() { + // Not a throw. The names are only ever supplied by watchProcess call sites today, so this + // is a guard rather than a live path -- but the cost of being wrong is a crash from a + // timer callback, and the cost of the guard is one grey line. + assertThat(BaseEditorActivity.getMemUsageLineColorFor(process("Kotlin Daemon"))).isEqualTo(Color.GRAY) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt new file mode 100644 index 0000000000..7a4671d0eb --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/BuildCompletedMetricTest.kt @@ -0,0 +1,78 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.analytics + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.analytics.gradle.BuildCompletedMetric +import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.result.BuildResult +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What build telemetry says about a build that did not succeed (ADFA-5542). + * + * A build the user stopped and a build that broke both report `success=false`. Without the reason + * beside it the two are indistinguishable, so every build-success rate counts deliberate cancels + * as failures -- and the cancel is the one the IDE deliberately makes easy to press. + */ +@RunWith(RobolectricTestRunner::class) +class BuildCompletedMetricTest { + private fun metric( + isSuccess: Boolean, + failure: TaskExecutionResult.Failure?, + ) = BuildCompletedMetric( + buildId = BuildId.Unknown, + buildType = "assemble", + isSuccess = isSuccess, + buildResult = + BuildResult( + buildId = BuildId.Unknown, + tasks = listOf(":app:assembleDebug"), + durationMs = 1_234L, + failure = failure, + ), + ) + + @Test + fun `a cancelled build carries the reason that says so`() { + val bundle = metric(isSuccess = false, failure = TaskExecutionResult.Failure.BUILD_CANCELLED).asBundle() + + assertThat(bundle.getString("failure_reason")).isEqualTo("BUILD_CANCELLED") + // isSuccess keeps its plain meaning: the build did not succeed. The reason is what lets a + // consumer separate the user's own Stop from a broken build. + assertThat(bundle.getBoolean("success")).isFalse() + } + + @Test + fun `a build that really failed carries its own reason`() { + val bundle = metric(isSuccess = false, failure = TaskExecutionResult.Failure.BUILD_FAILED).asBundle() + + assertThat(bundle.getString("failure_reason")).isEqualTo("BUILD_FAILED") + } + + @Test + fun `a successful build carries no reason at all`() { + val bundle = metric(isSuccess = true, failure = null).asBundle() + + assertThat(bundle.containsKey("failure_reason")).isFalse() + assertThat(bundle.getBoolean("success")).isTrue() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt new file mode 100644 index 0000000000..186cfa6873 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt @@ -0,0 +1,169 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.handlers + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.result.BuildInfo +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import com.itsaky.androidide.tooling.events.ProgressEvent +import com.itsaky.androidide.tooling.events.internal.DefaultOperationDescriptor +import com.itsaky.androidide.tooling.events.internal.DefaultProgressEvent +import com.itsaky.androidide.tooling.events.task.TaskFailureResult +import com.itsaky.androidide.tooling.events.task.TaskFinishEvent +import com.itsaky.androidide.tooling.events.task.TaskOperationDescriptor +import com.itsaky.androidide.tooling.events.task.TaskStartEvent +import com.itsaky.androidide.tooling.model.PluginIdentifier +import com.itsaky.androidide.utils.MetricsAnnotationStore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What the metrics charts annotate, and which build each annotation belongs to. + * + * Two decisions, both asserted against the function that makes them rather than through the + * callback that acts on it -- those need a live activity before they get this far. Which progress + * events are marked at all (ADFA-5486), and whether a build that failed was really the user + * stopping it (ADFA-5542). The second is now a reading of what the server said rather than a + * conclusion drawn on this side, so what is worth pinning is which answers are *not* a cancel. + */ +@RunWith(RobolectricTestRunner::class) +class EditorBuildEventListenerAnnotationTest { + private val listener = EditorBuildEventListener() + + private fun taskDescriptor() = + TaskOperationDescriptor( + dependencies = emptySet(), + originPlugin = PluginIdentifier("org.gradle"), + taskPath = ":app:compileKotlin", + name = "compileKotlin", + displayName = "Task :app:compileKotlin", + ) + + private fun taskStart(): ProgressEvent = + TaskStartEvent( + displayName = "Task :app:compileKotlin", + eventTime = 0L, + descriptor = taskDescriptor(), + ) + + private fun taskFinish(): ProgressEvent = + TaskFinishEvent( + displayName = "Task :app:compileKotlin", + eventTime = 0L, + descriptor = taskDescriptor(), + result = TaskFailureResult(startTime = 0L, endTime = 1L), + ) + + private fun plainEvent(): ProgressEvent = + DefaultProgressEvent( + displayName = "Configure project :app", + eventTime = 0L, + descriptor = DefaultOperationDescriptor(name = "configure", displayName = "Configure"), + ) + + @Test + fun `the server saying a build was cancelled is what marks it cancelled`() { + // The listener used to answer this from a flag it set when the cancel was asked for, which + // meant deciding from the order two main-thread runnables happened to run in -- and a + // cancel that overtook prepareBuild was cleared by it. Nothing here depends on order any + // more: the server classifies the throwable Gradle raised and this reads the answer. + assertThat(listener.outcomeKind(TaskExecutionResult.Failure.BUILD_CANCELLED)) + .isEqualTo(MetricsAnnotationStore.Kind.BUILD_CANCELLED) + } + + @Test + fun `every other reason the server gives is a failure`() { + // The substance of the mapping, and the half worth pinning: a connection that dropped or a + // Gradle version that is not supported is not the user stopping anything, and reporting it + // as one would tell them their own action broke a build they never touched. + val notCancels = + TaskExecutionResult.Failure.entries.filterNot { it == TaskExecutionResult.Failure.BUILD_CANCELLED } + notCancels.forEach { failure -> + assertWithMessage(failure.name) + .that(listener.outcomeKind(failure)) + .isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) + } + } + + @Test + fun `a failure the server did not classify is a failure`() { + // Null reaches here from any path that reports a build result without a reason. Treating + // an absent answer as a cancel would put the user's name on something they did not do. + assertThat(listener.outcomeKind(null)).isEqualTo(MetricsAnnotationStore.Kind.BUILD_FAILED) + } + + @Test + fun `the message for a build the user stopped is the cancelled text`() { + // One of the four places a cancel used to be reported as an error. The others are pinned + // separately -- the notification by GradleBuildServiceNotificationStatusTest, the chart + // marker by the outcomeKind cases above. The bar itself (flashInfo rather than flashError) + // and the cancelled-sync branch in ProjectHandlerActivity are not pinned: both need a live + // activity, which is what onBuildFailed returns early without. + // + // This test was previously named for all four and asserted only this one, so deleting the + // flashInfo branch or the notification branch left it green. + assertThat(listener.failureMessage(TaskExecutionResult.Failure.BUILD_CANCELLED, CANCELLED_TEXT)) + .isEqualTo(CANCELLED_TEXT) + } + + @Test + fun `a build that really failed still says so`() { + assertThat(listener.failureMessage(TaskExecutionResult.Failure.BUILD_FAILED, CANCELLED_TEXT)) + .isNotEqualTo(CANCELLED_TEXT) + assertThat(listener.failureMessage(null, CANCELLED_TEXT)).isNotEqualTo(CANCELLED_TEXT) + } + + @Test + fun `preparing a build clears a stale pairing, even with no activity attached`() { + listener.annotatedBuild = true + + // No activity is attached here, so prepareBuild returns early -- which is the point. The + // flag means "a start marker was drawn for the build now running", and this listener + // outlives any one activity, so a build whose outcome arrived without one would otherwise + // leave it set for the next build to inherit and draw a finish for a build that never + // started. + listener.prepareBuild(BuildInfo(BuildId.Unknown, listOf(":app:assembleDebug"))) + + assertThat(listener.annotatedBuild).isFalse() + } + + @Test + fun `a task starting is annotated`() { + assertThat(listener.isAnnotated(taskStart())).isTrue() + } + + @Test + fun `a task finishing is annotated`() { + assertThat(listener.isAnnotated(taskFinish())).isTrue() + } + + @Test + fun `an unrelated progress event is not annotated`() { + // Gradle emits far more than task events. Annotating everything would bury the markers + // that matter under configuration noise. + assertThat(listener.isAnnotated(plainEvent())).isFalse() + } + + private companion object { + /** Stands in for the string the activity would resolve, which a test has no activity for. */ + const val CANCELLED_TEXT = "Build was cancelled by the user." + } +} diff --git a/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentEnvelopeTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentEnvelopeTest.kt new file mode 100644 index 0000000000..f5b3b05960 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentEnvelopeTest.kt @@ -0,0 +1,177 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.handlers + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsScratch +import com.itsaky.androidide.utils.MetricsSource +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import io.sentry.Hint +import io.sentry.ITransportFactory +import io.sentry.Sentry +import io.sentry.SentryEnvelope +import io.sentry.SentryItemType +import io.sentry.SentryOptions +import io.sentry.transport.ITransport +import io.sentry.transport.RateLimiter +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.zip.GZIPInputStream + +/** + * The last hop: that the attachment this handler puts on a [Hint] reaches the envelope Sentry sends. + * + * Everything up to the Hint is covered by [MetricsCrashAttachmentTest]. This runs the real SDK with + * a transport that keeps what it is handed, because the hop itself is the SDK's to make and asserting + * on our own call proves nothing about it. + * + * Why not on a device: a crash there does reach this processor -- verified, it writes its file -- but + * the IDE's own uncaught handler calls exitProcess straight after capturing, so no event envelope + * survives to disk to be read back. That is worth its own ticket and is not this hop. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCrashAttachmentEnvelopeTest { + private val context = ApplicationProvider.getApplicationContext() + + private val sent = mutableListOf() + + private val watchers = mutableListOf() + + @After + fun tearDown() { + Sentry.close() + watchers.forEach { it.stopWatching() } + watchers.clear() + MetricsSource.current?.let(MetricsSource::unregister) + MetricsScratch.resetForTesting() + sent.clear() + } + + private inner class RecordingTransport : ITransport { + override fun send( + envelope: SentryEnvelope, + hint: Hint, + ) { + sent += envelope + } + + override fun flush(timeoutMillis: Long) = Unit + + override fun getRateLimiter(): RateLimiter? = null + + override fun close(isRestarting: Boolean) = Unit + + override fun close() = Unit + } + + private fun sampledMetrics(): MetricsSource.Metrics { + val memory = + MemoryUsageWatcher().also { watcher -> + watchers += watcher + watcher.watchProcess(android.os.Process.myPid(), "IDE") + watcher.readUsages() + } + return object : MetricsSource.Metrics { + override val memoryUsageWatcher = memory + override val networkUsageWatcher = NetworkUsageWatcher(uid = 0) + override val powerUsageWatcher = + PowerUsageWatcher( + source = { + PowerUsageWatcher.PowerReading( + temperatureMilliCelsius = 29_700L, + powerMicroWatts = -3_400_000L, + thermalStatus = 0, + battery = PowerUsageWatcher.BatteryState.UNKNOWN, + ) + }, + ) + override val annotations = MetricsAnnotationStore() + } + } + + private fun startSentry() { + Sentry.init { options: SentryOptions -> + // A DSN that cannot resolve, and a transport that never touches the network anyway. + options.dsn = "https://0123456789abcdef0123456789abcdef@sentry.invalid/1" + options.isEnableUncaughtExceptionHandler = false + options.setTransportFactory { _, _ -> RecordingTransport() } + MetricsCrashAttachment.install(options, context) + } + } + + private fun attachmentsOf(envelope: SentryEnvelope) = envelope.items.filter { it.header.type == SentryItemType.Attachment } + + @Test + fun `the metrics file arrives in the envelope Sentry sends`() { + MetricsSource.register(sampledMetrics()) + startSentry() + + Sentry.captureException(RuntimeException("boom")) + + assertThat(sent).isNotEmpty() + val attachments = sent.flatMap(::attachmentsOf) + val metrics = + attachments.single { + it.header.fileName + .orEmpty() + .endsWith(".csv.gz") + } + assertThat(metrics.header.contentType).isEqualTo("application/gzip") + // The bytes have to survive the trip, not just the filename: an envelope carrying a name and + // no readable payload would look like context and be none. + val csv = GZIPInputStream(metrics.data.inputStream()).bufferedReader().use { it.readText() } + assertThat(csv.lineSequence().first()).startsWith("\"timestamp\"") + assertThat(csv.lineSequence().count()).isAtLeast(2) + } + + @Test + fun `an envelope from a session with no samples carries no metrics attachment`() { + MetricsSource.register( + object : MetricsSource.Metrics { + override val memoryUsageWatcher = MemoryUsageWatcher().also(watchers::add) + override val networkUsageWatcher = NetworkUsageWatcher(uid = 0) + override val powerUsageWatcher = + PowerUsageWatcher( + source = { + PowerUsageWatcher.PowerReading(0L, 0L, 0, PowerUsageWatcher.BatteryState.UNKNOWN) + }, + ) + override val annotations = MetricsAnnotationStore() + }, + ) + startSentry() + + Sentry.captureException(RuntimeException("boom")) + + assertThat(sent).isNotEmpty() + assertThat( + sent.flatMap(::attachmentsOf).filter { + it.header.fileName + .orEmpty() + .endsWith(".csv.gz") + }, + ).isEmpty() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt new file mode 100644 index 0000000000..f80fa709d4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/handlers/MetricsCrashAttachmentTest.kt @@ -0,0 +1,217 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.handlers + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsCsvFile +import com.itsaky.androidide.utils.MetricsScratch +import com.itsaky.androidide.utils.MetricsSource +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import io.sentry.Hint +import io.sentry.SentryEvent +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.zip.GZIPInputStream + +/** + * What a report carries about the machine that produced it (ADFA-5526). + * + * The failure modes matter more than the happy path here: this runs while the process is dying, so + * anything it throws costs the whole report rather than just the attachment. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCrashAttachmentTest { + private val context = ApplicationProvider.getApplicationContext() + + private val watchers = mutableListOf() + + @After + fun tearDown() { + watchers.forEach { it.stopWatching() } + watchers.clear() + MetricsSource.current?.let(MetricsSource::unregister) + MetricsScratch.resetForTesting() + } + + private class FakeMetrics( + override val memoryUsageWatcher: MemoryUsageWatcher, + override val networkUsageWatcher: NetworkUsageWatcher = NetworkUsageWatcher(uid = 0), + override val powerUsageWatcher: PowerUsageWatcher = + PowerUsageWatcher( + source = { + PowerUsageWatcher.PowerReading( + temperatureMilliCelsius = 29_700L, + powerMicroWatts = -3_400_000L, + thermalStatus = 0, + battery = PowerUsageWatcher.BatteryState.UNKNOWN, + ) + }, + ), + override val annotations: MetricsAnnotationStore = MetricsAnnotationStore(), + ) : MetricsSource.Metrics + + private fun sampledWatcher(): MemoryUsageWatcher = + MemoryUsageWatcher().also { watcher -> + watchers += watcher + watcher.watchProcess(android.os.Process.myPid(), "IDE") + watcher.readUsages() + } + + private fun process(): Hint { + val hint = Hint() + MetricsCrashAttachment(context).process(SentryEvent(), hint) + return hint + } + + @Test + fun `a report from a session with history carries it, gzipped`() { + MetricsSource.register(FakeMetrics(sampledWatcher())) + + val attachments = process().attachments + + assertThat(attachments).hasSize(1) + val attachment = attachments.single() + assertThat(attachment.contentType).isEqualTo(MetricsCsvFile.COMPRESSED_MIME_TYPE) + assertThat(attachment.filename).endsWith(".csv.gz") + // A named file that does not unzip is worse than none: it looks like context and is not. + val unzipped = GZIPInputStream(File(attachment.pathname!!).inputStream()).bufferedReader().use { it.readText() } + assertThat(unzipped.lineSequence().first()).startsWith("\"timestamp\"") + assertThat(unzipped.lineSequence().count()).isAtLeast(2) + } + + @Test + fun `a crash before the editor ran attaches nothing`() { + // Onboarding, the project chooser, direct boot: no watchers exist, and direct boot has no + // credential-protected cache to write to either. + assertThat(MetricsSource.current).isNull() + + assertThat(process().attachments).isEmpty() + } + + @Test + fun `a session that has sampled nothing attaches nothing`() { + // A header-only file on every early crash would be noise in the reports, not context. + MetricsSource.register(FakeMetrics(MemoryUsageWatcher().also(watchers::add))) + + assertThat(process().attachments).isEmpty() + } + + @Test + fun `a burst of reports pays for one file, not one each`() { + MetricsSource.register(FakeMetrics(sampledWatcher())) + var writes = 0 + var clock = 1_000L + val processor = + MetricsCrashAttachment( + context = context, + nowMillis = { clock }, + writeFile = { snapshot -> + writes++ + MetricsCsvFile.writeForReport(context, snapshot) + }, + ) + + // This is registered for every event, not only crashes, and the IDE captures non-fatals + // deliberately -- in bursts, on whatever thread noticed, the main one included. A full + // buffer formats and gzips in 10-15ms on a desktop JVM and more on a phone, so paid per + // event that is a visible stutter per event. + val filenames = + (1..5).map { + clock += 100L + val hint = Hint() + processor.process(SentryEvent(), hint) + hint.attachments.single().filename + } + + assertThat(writes).isEqualTo(1) + assertThat(filenames.toSet()).hasSize(1) + } + + @Test + fun `a report after the window gets a file of its own`() { + MetricsSource.register(FakeMetrics(sampledWatcher())) + var writes = 0 + var clock = 1_000L + val processor = + MetricsCrashAttachment( + context = context, + nowMillis = { clock }, + writeFile = { snapshot -> + writes++ + MetricsCsvFile.writeForReport(context, snapshot) + }, + ) + + processor.process(SentryEvent(), Hint()) + // Freshness is what matters at a crash, so the reuse has to expire rather than latch. + clock += MetricsCrashAttachment.REUSE_WINDOW_MS + processor.process(SentryEvent(), Hint()) + + assertThat(writes).isEqualTo(2) + } + + @Test + fun `a reused file that has been pruned away is written again`() { + MetricsSource.register(FakeMetrics(sampledWatcher())) + var writes = 0 + val clock = 1_000L + val processor = + MetricsCrashAttachment( + context = context, + nowMillis = { clock }, + writeFile = { snapshot -> + writes++ + MetricsCsvFile.writeForReport(context, snapshot) + }, + ) + + val first = Hint() + processor.process(SentryEvent(), first) + // MetricsCsvFile keeps only the few most recent, so a file handed out here can be deleted + // by a later write. An attachment naming a file that is gone is worse than none. + File(first.attachments.single().pathname!!).delete() + + val second = Hint() + processor.process(SentryEvent(), second) + + assertThat(writes).isEqualTo(2) + assertThat(File(second.attachments.single().pathname!!).exists()).isTrue() + } + + @Test + fun `the event is returned unchanged even when the attachment fails`() { + // The whole point of the guard: a report with no metrics beats no report. A watcher whose + // buffers are a different length than the scratch makes copyInto throw, which is the + // closest stand-in for the crash-time failures this has to survive. + MetricsScratch.install(entries = MemoryUsageWatcher.MAX_USAGE_ENTRIES + 1, memorySeries = 3) + MetricsSource.register(FakeMetrics(sampledWatcher())) + + val event = SentryEvent() + val returned = MetricsCrashAttachment(context).process(event, Hint()) + + assertThat(returned).isSameInstanceAs(event) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt new file mode 100644 index 0000000000..5eac7a65cb --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceListenerWrapperTest.kt @@ -0,0 +1,103 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.services.builder + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.services.builder.GradleBuildService.EventListener +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.lang.reflect.Proxy + +/** + * Pins that the build service's listener wrapper forwards every callback it is given. + * + * It did not. A now-removed `onBuildCancelRequested` was declared with a `= Unit` default so that + * only listeners that cared had to implement it; the wrapper then inherited that no-op rather than + * passing the call on, so the cancel never reached the real listener and a build the user had + * stopped went on being annotated as a failure -- which is what BUILD_CANCELLED exists to prevent. + * The feature was unreachable, and the store-level test for it passed the whole time, because it + * called the store directly and nothing exercised the path to it. + * + * That callback is gone: the server classifies the throwable Gradle raised and says so on the + * BuildResult, so nothing on this side has to be told separately (ADFA-5542). The invariant it + * left behind outlives it and still guards every remaining member. + */ +@RunWith(RobolectricTestRunner::class) +class GradleBuildServiceListenerWrapperTest { + /** Records which interface method it was handed, so no call has to be spelled out here. */ + private class Recorder { + val calls = mutableListOf() + + var lastArgs: List = emptyList() + + val listener: EventListener = + Proxy.newProxyInstance( + EventListener::class.java.classLoader, + arrayOf(EventListener::class.java), + ) { _, method, args -> + calls += method.name + lastArgs = args.orEmpty().toList() + null + } as EventListener + } + + @Test + fun `no callback on the interface has a default implementation`() { + // This is the invariant that would have caught the bug, and it is not the one you reach + // for first: asserting that the wrapper "overrides every declared method" looks right and + // cannot fail, because Kotlin emits a bridge method on the implementing class for an + // inherited default, so reflection sees an override that is really a no-op. + // + // A default is what let the wrapper inherit silence instead of being asked to forward. With + // none, the compiler demands an implementation from every implementor -- the wrapper + // included -- and this class of omission stops being possible. + val defaults = + EventListener::class.java.declaredClasses + .firstOrNull { it.simpleName == "DefaultImpls" } + ?.declaredMethods + .orEmpty() + .map { it.name } + + assertThat(defaults).isEmpty() + } + + @Test + fun `a failure reaches the listener with the server's reason for it`() { + val recorder = Recorder() + val wrapped = GradleBuildService.wrap(recorder.listener)!! + + wrapped.onBuildFailed(listOf(":app:assembleDebug"), TaskExecutionResult.Failure.BUILD_CANCELLED) + + assertThat(recorder.calls).containsExactly("onBuildFailed") + + // The reason is the whole of what tells a cancel from a failure (ADFA-5542), and it is the + // server's answer rather than one this side worked out. A wrapper that forwarded the call + // and dropped the argument would be the earlier defect one layer in, with no signature to + // complain about it. + assertThat(recorder.lastArgs) + .containsExactly(listOf(":app:assembleDebug"), TaskExecutionResult.Failure.BUILD_CANCELLED) + .inOrder() + } + + @Test + fun `wrapping nothing yields nothing`() { + assertThat(GradleBuildService.wrap(null)).isNull() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceNotificationStatusTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceNotificationStatusTest.kt new file mode 100644 index 0000000000..4327102618 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceNotificationStatusTest.kt @@ -0,0 +1,62 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.services.builder + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.R +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What the shade says about a build that did not succeed (ADFA-5542). + * + * The notification is one of the four places a cancelled build used to be reported as a failure, + * and the only one of them with no test: the chart marker and the message both had one, so the + * notification could have been reverted without a single test noticing. + */ +@RunWith(RobolectricTestRunner::class) +class GradleBuildServiceNotificationStatusTest { + private val service = GradleBuildService() + + @Test + fun `a build the user stopped says so in the shade`() { + assertThat(service.notificationStatusFor(TaskExecutionResult.Failure.BUILD_CANCELLED)) + .isEqualTo(R.string.info_build_cancelled) + } + + @Test + fun `every other failure says the build failed`() { + TaskExecutionResult.Failure.entries + .filter { it != TaskExecutionResult.Failure.BUILD_CANCELLED } + .forEach { failure -> + assertWithMessage(failure.name) + .that(service.notificationStatusFor(failure)) + .isEqualTo(R.string.build_status_failed) + } + } + + @Test + fun `a failure the server did not classify says the build failed`() { + // Reporting an unclassified failure as a cancel would put the user's name on something + // they did not do. + assertThat(service.notificationStatusFor(null)).isEqualTo(R.string.build_status_failed) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/ChartGestureHarness.kt b/app/src/test/java/com/itsaky/androidide/ui/ChartGestureHarness.kt new file mode 100644 index 0000000000..5790369e71 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/ChartGestureHarness.kt @@ -0,0 +1,171 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewConfiguration +import com.github.mikephil.charting.listener.ChartTouchListener +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +/** Samples a harnessed chart is given; more than a window's worth, so a pan has somewhere to go. */ +const val HARNESS_SAMPLES = 200 + +/** + * A laid-out chart with a renderer attached, and the gestures to drive it. + * + * The two classes that test the chart's hold -- when help fires, and what happens to a hold when + * the gesture or the chart goes away -- had grown byte-identical copies of all of this, 74 lines + * each, down to the comments. One of the copies had a `panBy` nothing called. + * + * The gesture helpers all go through `chart.onChartGestureListener` rather than dispatching real + * touches, because that is the seam the renderer actually listens on: MPAndroidChart's own + * detector is what decides a press is a long press, and standing that up would be testing the + * library rather than the renderer. + */ +class ChartGestureHarness( + private val context: Context, +) { + /** Times [MetricsChartRenderer.onXAxisTap] fired -- the sampling-rate chooser opening. */ + var taps = 0 + private set + + /** Times the renderer asked for help to be shown. */ + var helps = 0 + private set + + lateinit var renderer: NetworkUsageChartRenderer + private set + + fun laidOutChart(): SafeLineChart { + val chart = SafeLineChart(context) + // Any concrete renderer will do -- the hold is the base class's, and every page wires it + // the same way. + renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(HARNESS_SAMPLES) { 1_000L }, + LongArray(HARNESS_SAMPLES) { 500L }, + LongArray(HARNESS_SAMPLES), + ) + }, + ) + renderer.attach(chart) + renderer.onXAxisTap = { taps++ } + renderer.showHelp = { _, _, _ -> helps++ } + + chart.layOutAndDraw() + return chart + } + + /** + * An event whose finger landed [sincePressMillis] ago. + * + * The down time is what the chart measures its remaining hold from, so it has to be real here. + * Defaults to the platform's long-press timeout, which is when a detector on a current device + * reports one. + */ + fun eventAt( + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ): MotionEvent { + val now = SystemClock.uptimeMillis() + return MotionEvent.obtain(now - sincePressMillis, now, MotionEvent.ACTION_MOVE, 10f, y, 0) + } + + /** The platform's own long press, which is where the chart's hold started counting from. */ + fun longPressAt( + chart: SafeLineChart, + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ) { + val event = eventAt(y, sincePressMillis) + chart.onChartGestureListener.onChartLongPressed(event) + event.recycle() + } + + fun panBy( + chart: SafeLineChart, + dx: Float, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartTranslate(event, dx, 0f) + event.recycle() + } + + fun scaleBy( + chart: SafeLineChart, + factor: Float, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartScale(event, factor, factor) + event.recycle() + } + + fun endGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) { + val event = eventAt(0f) + chart.onChartGestureListener.onChartGestureEnd(event, gesture) + event.recycle() + } + + /** + * The end of a gesture an ancestor took away. + * + * ChartTouchListener.endAction is reached from ACTION_CANCEL as well as ACTION_UP, with the + * original event and with mLastGesture untouched, so this is what the listener actually sees + * when the reveal layout, the bottom sheet or the pager claims the stream mid-press. + */ + fun cancelGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) { + val now = SystemClock.uptimeMillis() + val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 10f, 0f, 0) + chart.onChartGestureListener.onChartGestureEnd(event, gesture) + event.recycle() + } + + /** A y on the axis band, where a tap opens the sampling-rate chooser. */ + fun onAxisBand(chart: SafeLineChart) = chart.viewPortHandler.contentBottom() + 1f + + /** A y inside the plot, where a hold means help for the page rather than for the axis. */ + fun insidePlot(chart: SafeLineChart) = (chart.viewPortHandler.contentTop() + chart.viewPortHandler.contentBottom()) / 2f +} + +/** Runs the main looper forward by [millis] of virtual time. */ +fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) + +/** + * Runs what is already due on the main looper without advancing the clock. + * + * The stand-in tap and the stand-in click are posted rather than run inside the touch dispatch, so + * nothing has been tapped until the looper turns. + */ +fun drain() = shadowOf(Looper.getMainLooper()).idle() + +/** The rest of the hold, after a long press reported at the platform's own timeout. */ +fun remainderOfHold() = longPressHelpTimeoutMillis() - ViewConfiguration.getLongPressTimeout() + 50L diff --git a/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt b/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt new file mode 100644 index 0000000000..3c76eb0eee --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/ChartLayout.kt @@ -0,0 +1,49 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View + +/** The plot size every metrics chart test lays out at; roughly the carousel strip on a phone. */ +const val CHART_WIDTH = 720 + +const val CHART_HEIGHT = 400 + +/** + * Lays this chart out and draws it once, which is what every assertion about its viewport needs. + * + * Without the layout the plot area has no extent, so every coordinate lands on its edge, a hit test + * cannot tell inside from outside, and the renderer has nothing to place its viewport in. The draw + * is what renders the axes and annotations that assertions about them read back. + * + * Four test classes had grown their own copy of this, with the comment explaining it in three of + * them and the draw missing from one. + */ +fun SafeLineChart.layOutAndDraw( + width: Int = CHART_WIDTH, + height: Int = CHART_HEIGHT, +) { + measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + layout(0, 0, width, height) + draw(Canvas(Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888))) +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt new file mode 100644 index 0000000000..30e722393c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/LongPressHelpTimingTest.kt @@ -0,0 +1,406 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.os.Looper +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.widget.Button +import android.widget.HorizontalScrollView +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.clearLongPressHelp +import com.itsaky.androidide.utils.displayTooltipOnLongPress +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis +import com.itsaky.androidide.utils.performOnHold +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit + +/** + * How long a press has to last before help replaces the control (ADFA-5554). + * + * The platform fires a long press at 400ms, which is a brisk tap, so the carousel's buttons were + * answering with a tooltip instead of doing their job. The interesting case is neither the long + * press nor the short one -- it is the press in between. At 500ms the framework has already + * decided the gesture is a long press and cancelled the click, so a fix that merely defers the + * tooltip leaves that press doing nothing whatsoever: no help, and no button either. That is the + * first test here, and it is why the timing is this code's rather than the framework's. + * + * The hold's payload is a lambda rather than a real tooltip because `TooltipManager` reads the + * docs database from device storage in its static initialiser and cannot be loaded off-device -- + * the same reason the renderer separates deciding a help tag from showing one. + */ +@RunWith(RobolectricTestRunner::class) +class LongPressHelpTimingTest { + private val context = ApplicationProvider.getApplicationContext() + + private var holds = 0 + + private var clicks = 0 + + /** + * A control with a real size, which the move cases need: whether a touch is still on the view + * is measured against the view's bounds, so an unmeasured one collapses every position onto + * the same answer. + */ + private fun target(): Button = + Button(context).apply { + layout(0, 0, WIDTH, HEIGHT) + setOnClickListener { clicks++ } + performOnHold { holds++ } + } + + /** + * The same control inside a container that delays its children's pressed state. + * + * A `HorizontalScrollView` because that is the real case: the bottom sheet's output-action + * buttons, which this ticket wired for help, sit in one. Not any container -- `ViewGroup` + * defaults to true but `FrameLayout` and `LinearLayout` both override it to false, so the + * choice here has to be a container that actually scrolls. + */ + private fun targetInScrollingContainer(): Button { + val button = target() + HorizontalScrollView(context).addView(button) + button.layout(0, 0, WIDTH, HEIGHT) + return button + } + + private fun send( + view: View, + action: Int, + x: Float = CENTRE_X, + y: Float = CENTRE_Y, + ) { + val event = MotionEvent.obtain(0L, 0L, action, x, y, 0) + view.dispatchTouchEvent(event) + event.recycle() + } + + /** Runs the main looper forward by [millis] of virtual time. */ + private fun elapse(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(millis, TimeUnit.MILLISECONDS) + + /** + * Runs whatever is already due on the main looper without advancing the clock. + * + * The click is posted rather than performed inside the touch dispatch, as the framework does + * it, so nothing has clicked until the looper turns. + */ + private fun drain() = shadowOf(Looper.getMainLooper()).idle() + + @Test + fun `a control with no scrolling ancestor lights up the moment the finger lands`() { + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + + assertThat(view.isPressed).isTrue() + } + + @Test + fun `a control inside a scrolling container waits out the tap timeout first`() { + val view = targetInScrollingContainer() + + send(view, MotionEvent.ACTION_DOWN) + + // View.onTouchEvent does not light a control up straight away when it can be scrolled: + // it waits a tap timeout, so a flick that happens to start on a button scrolls without + // flashing it. Taking the touch over means taking that over too, and this listener did + // not -- every drag off one of these controls blinked it first. + assertThat(view.isPressed).isFalse() + } + + @Test + fun `a flick off a control in a scrolling container never lights it up`() { + val view = targetInScrollingContainer() + + send(view, MotionEvent.ACTION_DOWN) + send(view, MotionEvent.ACTION_MOVE, x = WIDTH * 4f, y = HEIGHT * 4f) + elapse(ViewConfiguration.getTapTimeout().toLong()) + + // The pressed state is on the queue when the finger leaves, so dropping the hold is not + // enough: the flash arrives after the gesture that cancelled it. + assertThat(view.isPressed).isFalse() + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a press past the platform timeout but short of the hold still clicks`() { + // The regression the obvious fix introduces, and the reason this class exists. The + // framework's long press is 400ms and the hold is 800ms; everything between the two would + // otherwise be dead. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(ViewConfiguration.getLongPressTimeout() + 100L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(clicks).isEqualTo(1) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a quick tap clicks`() { + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(clicks).isEqualTo(1) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a press held past the hold shows help and does not click`() { + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(longPressHelpTimeoutMillis() + 50L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(holds).isEqualTo(1) + assertThat(clicks).isEqualTo(0) + } + + @Test + fun `the click is posted, not run inside the touch that ended it`() { + // View.onTouchEvent posts its click so the pressed state is drawn before the action runs, + // and these actions open dialogs and re-page the carousel from inside the dispatch of the + // event that triggered them. Taking the touch over means taking that over too. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_UP) + + assertThat(clicks).isEqualTo(0) + drain() + assertThat(clicks).isEqualTo(1) + } + + @Test + fun `a press that rolls but stays on the control still clicks`() { + // The framework gives up on a press when the finger leaves the view grown by the slop -- + // not when it has travelled slop from where it went down. Measured from the down point + // instead, an ordinary thumb tap on a large target rolls far enough to cancel its own + // click without ever leaving the control, and every one of these targets is large: the + // carousel strip is the full width of the editor. + val view = target() + val slop = ViewConfiguration.get(context).scaledTouchSlop + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_MOVE, x = CENTRE_X + slop + 10f) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(clicks).isEqualTo(1) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a press that leaves the control does neither`() { + val view = target() + val slop = ViewConfiguration.get(context).scaledTouchSlop + + send(view, MotionEvent.ACTION_DOWN) + elapse(100L) + send(view, MotionEvent.ACTION_MOVE, x = WIDTH + slop + 10f) + elapse(longPressHelpTimeoutMillis()) + send(view, MotionEvent.ACTION_UP) + drain() + + // The framework treats a drag out of a view as neither, so taking the touch over means + // saying so rather than inventing a third behaviour. + assertThat(clicks).isEqualTo(0) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a cancelled gesture does neither`() { + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(100L) + send(view, MotionEvent.ACTION_CANCEL) + elapse(longPressHelpTimeoutMillis()) + + assertThat(clicks).isEqualTo(0) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a lengthened touch-and-hold delay is doubled, not ignored`() { + // Asserting isAtLeast against the live platform value pins nothing: maxOf(x * 2, 800) is + // at least 800 and at least x for every x by construction, so the whole rule could be + // deleted and such a test would still pass. Named values, and each of the two terms + // decides one of them. + // + // The delay is exposed as an accessibility setting, and someone who lengthened it meant + // to -- so the hold has to grow with it rather than staying at the floor. + assertThat(longPressHelpTimeoutMillis(platformTimeoutMillis = 1_000L)).isEqualTo(2_000L) + } + + @Test + fun `a shortened touch-and-hold delay still gets the floor`() { + // Doubling alone would put help back inside a brisk tap, which is the defect. + assertThat(longPressHelpTimeoutMillis(platformTimeoutMillis = 100L)).isEqualTo(800L) + } + + @Test + fun `the platform default lands on the floor`() { + // 400ms doubled is exactly the floor, so the two terms agree at the value almost every + // device reports -- which is why neither can be tested at it. + assertThat(longPressHelpTimeoutMillis(platformTimeoutMillis = 400L)).isEqualTo(800L) + } + + @Test + fun `clearing the help stops the timing`() { + val view = target() + view.clearLongPressHelp() + + send(view, MotionEvent.ACTION_DOWN) + elapse(longPressHelpTimeoutMillis() + 50L) + send(view, MotionEvent.ACTION_UP) + + // Left installed, the listener would go on timing holds -- and swallowing every touch -- + // for help the view no longer offers. + assertThat(holds).isEqualTo(0) + assertThat(view.isLongClickable).isFalse() + } + + @Test + fun `clearing the help cancels a hold already counting down`() { + // The teardown runs while a finger is down -- the carousel unbinds, the strip is replaced, + // the sheet is torn down. The timer is on the main thread's queue rather than on the view, + // so clearing the listeners does not reach it: held in a closure it was unreachable + // altogether, and the tooltip appeared over a control that had just been unwired. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(100L) + view.clearLongPressHelp() + elapse(longPressHelpTimeoutMillis()) + + assertThat(holds).isEqualTo(0) + } + + @Test + fun `re-wiring with a blank tag takes the previous tag's help away`() { + // A blank tag says this view offers no help, which has to replace whatever was wired here + // before. Returning early instead left the previous listeners in place, still timing holds + // and still swallowing every touch. + val view = target() + view.displayTooltipOnLongPress(context, tooltipTag = "") + + send(view, MotionEvent.ACTION_DOWN) + elapse(longPressHelpTimeoutMillis() + 50L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(holds).isEqualTo(0) + assertThat(view.isLongClickable).isFalse() + } + + @Test + fun `a control that does not answer taps is not clicked`() { + // View.onTouchEvent performs a click only for a clickable view, and taking the touch over + // means taking that test over too. The carousel dims the arrow at either end by clearing + // isClickable rather than isEnabled -- deliberately, so it still answers a hold -- so + // without this a tap on the dimmed arrow played the click sound and announced a click for + // a control the screen reader is being told is unavailable. + val view = target() + view.isClickable = false + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(clicks).isEqualTo(0) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `a control that does not answer taps still answers a hold`() { + // The other half, and the reason isClickable was chosen over isEnabled in the first place. + val view = target() + view.isClickable = false + + send(view, MotionEvent.ACTION_DOWN) + elapse(longPressHelpTimeoutMillis() + 50L) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(holds).isEqualTo(1) + assertThat(clicks).isEqualTo(0) + } + + @Test + fun `a second finger gives up the press`() { + // The carousel undocks on a two-finger tap anywhere in the strip, and one of those fingers + // lands on a control. Counting it as a press meant the gesture both undocked the strip and + // paged it, or held long enough to open that button's help over a strip on its way out. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_POINTER_DOWN) + elapse(longPressHelpTimeoutMillis()) + send(view, MotionEvent.ACTION_UP) + drain() + + assertThat(clicks).isEqualTo(0) + assertThat(holds).isEqualTo(0) + } + + @Test + fun `clearing the help takes back a click that has not run yet`() { + // The click is posted, so there is a turn of the looper between the finger lifting and the + // action running. A teardown landing in it -- the sheet detaching, the carousel unbinding + // -- would otherwise still click a control it has just unwired. + val view = target() + + send(view, MotionEvent.ACTION_DOWN) + elapse(50L) + send(view, MotionEvent.ACTION_UP) + view.clearLongPressHelp() + drain() + + assertThat(clicks).isEqualTo(0) + } + + private companion object { + /** Big enough that a roll of one touch slop is still well inside it. */ + const val WIDTH = 400 + + const val HEIGHT = 200 + + const val CENTRE_X = WIDTH / 2f + + const val CENTRE_Y = HEIGHT / 2f + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt new file mode 100644 index 0000000000..a68214fdc7 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -0,0 +1,261 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.view.View +import androidx.collection.MutableIntObjectMap +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.LineDataSet +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.MutableShiftedLongArray +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the properties ADFA-5487's metrics carousel relies on: the renderer holds no sample state, so + * a chart attached at any time shows the complete history, and a change to the watched process set + * is picked up rather than dropped. + */ +@RunWith(RobolectricTestRunner::class) +class MemoryUsageChartRendererTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun chart() = SafeLineChart(context) + + private fun renderer(processes: () -> Array) = + MemoryUsageChartRenderer( + usagesProvider = processes, + lineColorFor = { Color.BLUE }, + ) + + /** A chart showing one process with the given byte history, laid out and drawn once. */ + private fun laidOutChart(history: LongArray): SafeLineChart { + val chart = chart() + val process = + ProcessMemoryInfo( + PID_IDE, + "IDE", + MutableShiftedLongArray(LongArray(history.size) { history[it] }), + watchedSinceMillis = 0L, + ) + renderer { arrayOf(process) }.attach(chart) + chart.layOutAndDraw() + return chart + } + + /** A process whose history ramps from [firstMegabytes] by 1MB per sample. */ + private fun proc( + pid: Int, + pname: String, + firstMegabytes: Long, + ) = ProcessMemoryInfo( + pid, + pname, + MutableShiftedLongArray(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { (firstMegabytes + it) * BYTES_PER_MB }, + watchedSinceMillis = 0L, + ) + + private fun datasetFor( + chart: SafeLineChart, + index: Int, + ) = chart.data.getDataSetByIndex(index) as LineDataSet + + @Test + fun `every memory line is scaled by the axis that labels it`() { + val chart = laidOutChart(LongArray(SAMPLE_COUNT) { 100L * BYTES_PER_MB }) + + // configure() disables axisLeft and this renderer ranges and formats only axisRight, but + // MPAndroidChart defaults a dataset to LEFT -- so the lines were scaled by an axis nobody + // had configured while the labels beside them came from another. + val datasets = (0 until chart.data.dataSetCount).map { chart.data.getDataSetByIndex(it) } + assertThat(datasets).isNotEmpty() + for (dataset in datasets) { + assertThat(dataset.axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + } + } + + @Test + fun `attach renders the complete existing history, not a flat line`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + + renderer { processes }.attach(chart) + + val dataset = datasetFor(chart, 0) + assertThat(dataset.entryCount).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + // The old resetMemUsageChart() seeded every entry with 0f and waited a tick for real values; + // a carousel page attached mid-session would have shown that flat line. + assertThat(dataset.entries.map { it.y }).doesNotContain(0f) + assertThat(dataset.entries.first().y).isEqualTo(100f) + assertThat(dataset.entries.last().y).isEqualTo((100 + MemoryUsageWatcher.MAX_USAGE_ENTRIES - 1).toFloat()) + assertThat(dataset.label).isEqualTo("IDE %.2fMB".format(dataset.entries.last().y)) + } + + @Test + fun `onUsagesChanged updates entries in place without replacing the datasets`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + val renderer = renderer { processes } + renderer.attach(chart) + + val datasetBefore = datasetFor(chart, 0) + val entryBefore = datasetBefore.entries.first() + + val updated = proc(pid = 1, pname = "IDE", firstMegabytes = 200) + renderer.onUsagesChanged(MutableIntObjectMap().apply { put(1, updated) }) + + // Same dataset and same Entry objects, new values: this path runs once a second for the + // lifetime of the editor, so it must not allocate. + assertThat(datasetFor(chart, 0)).isSameInstanceAs(datasetBefore) + assertThat(datasetBefore.entries.first()).isSameInstanceAs(entryBefore) + assertThat(entryBefore.y).isEqualTo(200f) + } + + @Test + fun `onUsagesChanged rebuilds when a process starts being watched`() { + var processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + val renderer = renderer { processes } + renderer.attach(chart) + + assertThat(chart.data.dataSetCount).isEqualTo(1) + + // Gradle Tooling starts up. The old code looked the new pid up in a map that only reset() + // populated, logged "No dataset found for process", and dropped its samples. + val gradle = proc(pid = 2, pname = "Gradle Tooling", firstMegabytes = 300) + processes = arrayOf(processes[0], gradle) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { + put(1, processes[0]) + put(2, gradle) + }, + ) + + assertThat(chart.data.dataSetCount).isEqualTo(2) + assertThat(datasetFor(chart, 1).label).startsWith("Gradle Tooling ") + assertThat(datasetFor(chart, 1).entries.first().y).isEqualTo(300f) + } + + @Test + fun `onUsagesChanged after detach is a no-op`() { + var processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val renderer = renderer { processes } + val detached = chart() + renderer.attach(detached) + val before = datasetFor(detached, 0).entries.map { it.y } + + renderer.detach() + + // Different samples, so a renderer that kept writing would visibly change the chart. + processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 900)) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { put(1, processes[0]) }, + ) + + // A recycled carousel page must not keep the renderer writing into a dead view. Asserting + // only that the call does not throw pinned nothing: it would not have thrown anyway. + assertThat(datasetFor(detached, 0).entries.map { it.y }).isEqualTo(before) + } + + @Test + fun `a swapped process rebuilds rather than plotting its samples on another line`() { + // The count stays the same and a pid changes -- what a tooling-server pid correction does. + // The suite covered only the count-changed path, and correctness here rested on + // getDataSetByIndex(-1) happening to return null. + val first = proc(pid = 1, pname = "IDE", firstMegabytes = 100) + var processes = arrayOf(first) + val renderer = renderer { processes } + val chart = chart() + renderer.attach(chart) + + val replacement = proc(pid = 2, pname = "Gradle Tooling", firstMegabytes = 700) + processes = arrayOf(replacement) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { put(2, replacement) }, + ) + + assertThat(chart.data.dataSetCount).isEqualTo(1) + assertThat(datasetFor(chart, 0).label).startsWith("Gradle Tooling ") + assertThat(datasetFor(chart, 0).entries.first().y).isEqualTo(700f) + } + + @Test + fun `attach after detach renders the history into the new chart`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val renderer = renderer { processes } + renderer.attach(chart()) + renderer.detach() + + val rebound = chart() + renderer.attach(rebound) + + assertThat(datasetFor(rebound, 0).entryCount).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(datasetFor(rebound, 0).entries.first().y).isEqualTo(100f) + } + + @Test + fun `the axis is scaled to what is on screen, not to the whole buffer`() { + // An early 1.5 GB daemon peak, then a long quiet stretch around 200 MB. + val history = LongArray(SAMPLE_COUNT) { 200L * BYTES_PER_MB } + history[0] = 1_500L * BYTES_PER_MB + val chart = laidOutChart(history) + + // Ranged over the whole buffer the axis reaches 1650 MB and presses every later reading + // into the bottom eighth of the plot for the hours the buffer takes to turn over. + assertThat(chart.axisRight.axisMaximum).isLessThan(400f) + } + + @Test + fun `a peak still on screen does raise the axis`() { + // Guards the test above: it must not pass by ignoring peaks altogether. + val history = LongArray(SAMPLE_COUNT) { 200L * BYTES_PER_MB } + history[SAMPLE_COUNT - 1] = 1_500L * BYTES_PER_MB + val chart = laidOutChart(history) + + assertThat(chart.axisRight.axisMaximum).isAtLeast(1_500f) + } + + @Test + fun `an idle chart still has a readable scale`() { + val chart = laidOutChart(LongArray(SAMPLE_COUNT)) + + // Zero everywhere would otherwise collapse the axis to no height at all. + assertThat(chart.axisRight.axisMaximum).isGreaterThan(0f) + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + } + + private companion object { + /** + * The production constant, not a copy of it. With its own literal the test verified its + * own arithmetic: change the renderer to decimal megabytes and every assertion still + * passed because both sides had stopped agreeing. + */ + val BYTES_PER_MB = BYTES_PER_MEGABYTE.toLong() + const val PID_IDE = 1 + + /** Longer than the visible window, so the start of the history scrolls off screen. */ + const val SAMPLE_COUNT = 200 + } +} 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..a174c5ace3 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationRenderingTest.kt @@ -0,0 +1,254 @@ +/* + * 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.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.idetooltips.TooltipTag +import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.resolveAttr +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * 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 { + // Themed: the marker colours come from theme attributes, and against a bare application + // context every one of them resolves to 0, so a colour test would pass by comparing nothing. + private val context: Context = + ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), + com.itsaky.androidide.R.style.Theme_AndroidIDE, + ) + + /** A minimal renderer, so the placement is tested without a particular page's data. */ + private class TestRenderer( + private val sampleCount: Int, + annotations: MetricsAnnotationStore, + now: () -> Long, + ) : MetricsChartRenderer( + sampleIntervalMillis = { SAMPLE_INTERVAL_MS }, + annotations = annotations, + nowMillis = now, + ) { + override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY + + override fun rebuild() { + val chart = this.chart ?: return + val entries = List(sampleCount) { Entry(it.toFloat(), 0f) } + setData(chart, arrayOf(LineDataSet(entries, "test"))) + } + } + + private class Fixture { + var now = 0L + val store = MetricsAnnotationStore(nowMillis = { now }) + + /** Records [count] task markers, spaced far enough apart to clear the store's throttle. */ + fun recordBurst(count: Int) { + repeat(count) { index -> record("task $index") } + } + + /** + * Records one annotation and advances past the throttle window. + * + * Every caller wanted both halves and had to remember the second one; forgetting it made + * the store drop the next annotation, and the test then asserted against a chart with one + * fewer marker than it had asked for. + */ + fun record( + label: String, + kind: MetricsAnnotationStore.Kind = MetricsAnnotationStore.Kind.TASK, + ) { + store.record(label, kind) + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + } + + /** Records a build outcome, whose label comes from its kind, and advances the clock. */ + fun recordBuild(kind: MetricsAnnotationStore.Kind) { + store.recordBuild(kind) + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + } + } + + 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) + } + + @Test + fun `a failed build is drawn in a different colour from a task marker`() { + val fixture = Fixture() + fixture.record("some task") + fixture.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + + val (_, chart) = render(fixture) + + val lines = chart.xAxis.limitLines + assertThat(lines).hasSize(2) + // Which colour, not merely a different one: asserting inequality alone passes just as + // happily with the two attributes swapped, telling the user a failed build succeeded. + assertThat(lines[0].lineColor).isEqualTo(context.resolveAttr(R.attr.colorOnSurface)) + assertThat(lines[1].lineColor).isEqualTo(context.resolveAttr(R.attr.colorError)) + // The label sits on the line, so colouring only the line would leave it unreadable. + assertThat(lines[1].textColor).isEqualTo(lines[1].lineColor) + } + + @Test + fun `a build starting and finishing share one colour, distinct from a failure`() { + val fixture = Fixture() + fixture.record("Build started", MetricsAnnotationStore.Kind.BUILD_STARTED) + fixture.record("Build finished", MetricsAnnotationStore.Kind.BUILD_FINISHED) + fixture.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + + val (_, chart) = render(fixture) + + val lines = chart.xAxis.limitLines + assertThat(lines).hasSize(3) + // Started and finished are both outcomes worth seeing; only failure is bad news. + assertThat(lines[0].lineColor).isEqualTo(context.resolveAttr(R.attr.colorSuccess)) + assertThat(lines[1].lineColor).isEqualTo(context.resolveAttr(R.attr.colorSuccess)) + assertThat(lines[2].lineColor).isEqualTo(context.resolveAttr(R.attr.colorError)) + } + + @Test + fun `a cancelled build is not drawn as a failure`() { + val fixture = Fixture() + fixture.recordBuild(MetricsAnnotationStore.Kind.BUILD_CANCELLED) + + val (_, chart) = render(fixture) + + // The user stopped the build themselves; reporting that back in the error colour reads as + // something having gone wrong. + val line = chart.xAxis.limitLines.single() + assertThat(line.lineColor).isNotEqualTo(context.resolveAttr(R.attr.colorError)) + assertThat(line.lineColor).isEqualTo(context.resolveAttr(R.attr.colorOnSurface)) + } + + @Test + fun `a build marker takes its label from its kind, not from the recorded text`() { + val fixture = Fixture() + fixture.recordBuild(MetricsAnnotationStore.Kind.BUILD_FAILED) + + val (_, chart) = render(fixture) + + // Resolved at draw time, so the marker follows the system language even though the store + // outlives the activity that recorded it. + assertThat( + chart.xAxis.limitLines + .single() + .label, + ).isEqualTo(context.getString(string.metrics_annotation_build_failed)) + } + + private companion object { + 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/MetricsAnnotationSpanTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt new file mode 100644 index 0000000000..ab36922fa2 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt @@ -0,0 +1,118 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.MotionEvent +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * How far back the renderer asks the annotation store to look (ADFA-5486). + * + * It asked for a fixed sixty-one samples' worth of time from now, which is right only while the + * viewport is following the newest samples. Once panning began to stick, a viewport showing older + * samples had its markers dropped before their x was worked out -- invisible in the one view that + * was looking at them. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsAnnotationSpanTest { + private val context = ApplicationProvider.getApplicationContext() + + private var now = 1_000_000L + + private val store = MetricsAnnotationStore(nowMillis = { now }) + + private fun chartWithAnnotations(): Pair { + val chart = SafeLineChart(context) + val renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) + }, + annotations = store, + sampleInterval = { INTERVAL_MS }, + ) + renderer.attach(chart) + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + draw(chart) + return renderer to chart + } + + private fun draw(chart: SafeLineChart) { + chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) + } + + @Test + fun `a marker outside the newest window is drawn once the viewport is panned to it`() { + // One annotation, then enough elapsed time to push it far outside the newest 61 samples. + store.record("an old task") + now += INTERVAL_MS * 200L + + val (renderer, chart) = chartWithAnnotations() + val whileFollowing = chart.xAxis.limitLines.size + + // Pan back to where that marker lives, and record that the user drove the viewport. + chart.setVisibleXRangeMaximum(VISIBLE_WINDOW.toFloat()) + chart.moveViewToXNow(0f) + draw(chart) + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, 10f, 0) + chart.onChartGestureListener.onChartTranslate(event, -50f, 0f) + event.recycle() + + renderer.rebuild() + + assertThat(whileFollowing).isEqualTo(0) + assertThat(chart.xAxis.limitLines.size).isEqualTo(1) + } + + @Test + fun `following the newest samples still asks for only the visible window`() { + // The other half: the span must not quietly become the whole buffer, which would build a + // LimitLine and a DashPathEffect per stored annotation on every redraw. + store.record("a recent task") + + val (_, chart) = chartWithAnnotations() + + assertThat(chart.xAxis.limitLines.size).isEqualTo(1) + } + + private companion object { + const val WIDTH = 720 + const val HEIGHT = 400 + const val SAMPLES = 400 + const val VISIBLE_WINDOW = 60 + const val INTERVAL_MS = 1_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt new file mode 100644 index 0000000000..148507adc4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselAdapterTest.kt @@ -0,0 +1,161 @@ +/* + * 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.idetooltips.TooltipTag +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 }) { + override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY + + 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/MetricsCarouselHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt new file mode 100644 index 0000000000..44c35a3d3c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselHelpTest.kt @@ -0,0 +1,238 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins that every control in the metrics carousel answers a long press (ADFA-5510). + * + * The assertion is that a listener is installed, not that a tooltip appears: TooltipManager reads + * the docs database from device storage in its static initialiser and cannot be loaded off-device. + * Whether a tag has copy behind it is the database's business, not this code's. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCarouselHelpTest { + private val context: Context = + ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), + R.style.Theme_AndroidIDE, + ) + + /** + * Every controller this test builds, so [tearDown] can release them. + * + * Each one installs itself as the listener on three watchers; a controller left bound holds + * its views and goes on being fed for the rest of the JVM's life, and the tests then run + * against a growing pile of live carousels. + */ + private val controllers = mutableListOf() + + @After + fun tearDown() { + controllers.forEach { it.unbind() } + controllers.clear() + } + + private fun boundStrip(): LayoutMemUsageBinding { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + controller().bind(binding) + return binding + } + + private fun controller() = newController().also(controllers::add) + + private fun newController() = + MetricsCarouselController( + memoryUsageWatcher = MemoryUsageWatcher(), + networkUsageWatcher = NetworkUsageWatcher(uid = TEST_UID), + powerUsageWatcher = + PowerUsageWatcher( + source = { + PowerUsageWatcher.PowerReading( + temperatureMilliCelsius = 30_000L, + powerMicroWatts = 1_000_000L, + thermalStatus = 0, + battery = PowerUsageWatcher.BatteryState.UNKNOWN, + ) + }, + ), + lineColorFor = { android.graphics.Color.BLUE }, + annotations = MetricsAnnotationStore(), + ) + + @Test + fun `every control in the strip answers a long press`() { + val binding = boundStrip() + + val controls: List> = + listOf( + "panel" to binding.root, + "title" to binding.metricsTitle, + "previous" to binding.metricsPrevious, + "next" to binding.metricsNext, + "snapshot" to binding.metricsSnapshot, + "battery" to binding.metricsBattery, + "undocked message" to binding.metricsUndockedMessage, + ) + + val unwired = controls.filterNot { (_, view) -> view.isLongClickable }.map { it.first } + assertThat(unwired).isEmpty() + } + + @Test + fun `the arrow at the end of the carousel is dimmed but still answers a long press`() { + val binding = boundStrip() + + // On the first page there is nowhere to go back to. Disabling that arrow would leave it + // consuming the long press and dropping it, so the one arrow whose greyed-out state a + // user is likeliest to ask about was the one with no answer. + assertThat(binding.metricsPager.currentItem).isEqualTo(0) + assertThat(binding.metricsPrevious.alpha).isLessThan(1f) + assertThat(binding.metricsPrevious.isEnabled).isTrue() + assertThat(binding.metricsPrevious.isLongClickable).isTrue() + // It answers no tap, though: that is the narrower and the true statement. + assertThat(binding.metricsPrevious.isClickable).isFalse() + + // ...and the other end is at full strength, so the dimming means something. + assertThat(binding.metricsNext.alpha).isEqualTo(1f) + assertThat(binding.metricsNext.isClickable).isTrue() + } + + @Test + fun `a dimmed arrow still reads as disabled to a screen reader`() { + val binding = boundStrip() + + // Alpha is invisible to accessibility services, so dropping isEnabled would have taken + // the state away from exactly the users who cannot see the dimming. + val previous = nodeInfoFor(binding.metricsPrevious) + assertThat(previous.isEnabled).isFalse() + assertThat(previous.isClickable).isFalse() + + val next = nodeInfoFor(binding.metricsNext) + assertThat(next.isEnabled).isTrue() + assertThat(next.isClickable).isTrue() + } + + /** What a screen reader would be handed for [view]. */ + private fun nodeInfoFor(view: View): AccessibilityNodeInfoCompat { + val info = view.createAccessibilityNodeInfo() + assertThat(info).isNotNull() + return AccessibilityNodeInfoCompat.wrap(info!!) + } + + @Test + fun `an unbound strip has no help wired`() { + // Guards the test above: if inflation alone made these long-clickable, it would pass + // against a controller that wires nothing. + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + + assertThat(binding.metricsPrevious.isLongClickable).isFalse() + assertThat(binding.metricsSnapshot.isLongClickable).isFalse() + } + + @Test + fun `unbinding releases the help listeners`() { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + val controller = controller() + controller.bind(binding) + controller.unbind() + + assertThat(binding.metricsPrevious.isLongClickable).isFalse() + assertThat(binding.metricsSnapshot.isLongClickable).isFalse() + } + + @Test + fun `each control is wired to its own tag`() { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + val targets = controller().helpTargets(binding) + + // Asserting the constants against their own literals, as this test used to, would pass + // just as happily with two controls' tags swapped. + val byTag = targets.associate { (view, tag) -> tag to view } + assertThat(byTag[TooltipTag.CAROUSEL_PREVIOUS]).isSameInstanceAs(binding.metricsPrevious) + assertThat(byTag[TooltipTag.CAROUSEL_NEXT]).isSameInstanceAs(binding.metricsNext) + assertThat(byTag[TooltipTag.CAROUSEL_SNAPSHOT]).isSameInstanceAs(binding.metricsSnapshot) + assertThat(byTag[TooltipTag.CAROUSEL_BATTERY]).isSameInstanceAs(binding.metricsBattery) + assertThat(byTag[TooltipTag.CAROUSEL_TITLE]).isSameInstanceAs(binding.metricsTitle) + assertThat(byTag[TooltipTag.CAROUSEL_UNDOCKED]).isSameInstanceAs(binding.metricsUndockedMessage) + // Every tag distinct, so no two controls can answer with the same one. + assertThat(targets.map { it.second }.toSet()).hasSize(targets.size) + } + + @Test + fun `unbinding keeps the undocked message answering`() { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + val controller = controller() + controller.bind(binding) + controller.unbind() + + // That view becomes visible *because* the carousel unbound, so clearing its listener left + // the one control a user can still reach with no help at all. + assertThat(binding.metricsUndockedMessage.isLongClickable).isTrue() + } + + @Test + fun `a long press below the plot asks about the sampling rate, not the metric`() { + val chart = SafeLineChart(context) + val renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) + }, + ) + renderer.attach(chart) + chart.layOutAndDraw() + + val handler = chart.viewPortHandler + // Guards the two assertions below: on an unlaid-out chart both points land on one edge. + assertThat(handler.contentBottom()).isLessThan(CHART_HEIGHT.toFloat()) + + // Below the plot is the time axis, which is what the sampling rate belongs to. + assertThat(renderer.helpTagAt(handler.contentBottom() + 1f)).isEqualTo(TooltipTag.CAROUSEL_AXIS_TIME) + // Inside the plot, the metric itself answers. + assertThat(renderer.helpTagAt((handler.contentTop() + handler.contentBottom()) / 2f)) + .isEqualTo(TooltipTag.CAROUSEL_CHART_NETWORK) + } + + private companion object { + const val TEST_UID = 10_123 + const val SAMPLES = 60 + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt new file mode 100644 index 0000000000..6a9847be89 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt @@ -0,0 +1,293 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.os.SystemClock +import android.view.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 +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the two-finger tap that undocks the metrics carousel (ADFA-5486). + * + * The gesture cannot be injected on an unrooted device -- `adb input` has no multi-touch and + * `sendevent` needs root -- so the recogniser is exercised here with the same MotionEvents it would + * receive, including the pinch it must not mistake for a tap. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCarouselLayoutTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun layout() = MetricsCarouselLayout(context) + + /** + * The real layout, inflated against the app's theme. + * + * The theme is not optional: the strip's controls resolve Material attributes, and a bare + * application context fails to inflate them. + */ + private fun inflatedStrip(): LayoutMemUsageBinding { + val themed = ContextThemeWrapper(context, R.style.Theme_AndroidIDE) + return LayoutMemUsageBinding.inflate(LayoutInflater.from(themed)) + } + + private var downTime = 0L + + private fun event( + action: Int, + vararg points: Pair, + eventTime: Long = downTime, + ): MotionEvent { + val properties = + Array(points.size) { index -> + MotionEvent.PointerProperties().apply { + id = index + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = + Array(points.size) { index -> + MotionEvent.PointerCoords().apply { + x = points[index].first + y = points[index].second + pressure = 1f + size = 1f + } + } + return MotionEvent.obtain( + downTime, + eventTime, + action, + points.size, + properties, + coords, + 0, + 0, + 1f, + 1f, + 0, + 0, + 0, + 0, + ) + } + + private fun pointerDown(index: Int): Int = MotionEvent.ACTION_POINTER_DOWN or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + + private fun pointerUp(index: Int): Int = MotionEvent.ACTION_POINTER_UP or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + + /** + * Drives one gesture through the layout the way the framework does. + * + * Via dispatchTouchEvent, not onInterceptTouchEvent: these tests passed against a recogniser + * that never fired on a device, because ViewPager2 stops the parent's onInterceptTouchEvent + * being called the moment a second pointer lands. Calling the method under test directly proved + * the logic and not the wiring. + */ + private fun MetricsCarouselLayout.dispatch(vararg events: MotionEvent) { + events.forEach { event -> + dispatchTouchEvent(event) + event.recycle() + } + } + + /** 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 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. + // + // 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 the carousel back`() { + val binding = inflatedStrip() + + binding.root.setUndocked(true) + binding.root.setUndocked(false) + + assertThat(binding.metricsPager.isVisible).isTrue() + assertThat(binding.metricsTitle.isVisible).isTrue() + assertThat(binding.metricsPrevious.isVisible).isTrue() + assertThat(binding.metricsNext.isVisible).isTrue() + assertThat(binding.metricsSnapshot.isVisible).isTrue() + assertThat(binding.metricsUndockedMessage.isVisible).isFalse() + } + + @Test + fun `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 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(pointerUp(1), 500f to 450f, 900f to 450f, eventTime = downTime + 40L), + event(MotionEvent.ACTION_UP, 500f to 450f, eventTime = downTime + 50L), + ) + + assertThat(taps).isEqualTo(1) + } + + @Test + fun `a single-finger tap does not fire it`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(MotionEvent.ACTION_UP, 500f to 450f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a pinch is not a tap`() { + // The carousel is also meant to pinch-to-zoom, so movement has to disqualify the tap. + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + val travel = ViewConfiguration.get(context).scaledTouchSlop * 4f + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(MotionEvent.ACTION_MOVE, 500f - travel to 450f, 900f + travel to 450f, eventTime = downTime + 20L), + event(pointerUp(1), 500f - travel to 450f, 900f + travel to 450f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a pinch anchored on the first finger is not a tap`() { + // The awkward case: hold one finger still and spread the other. Watching only pointer 0 + // sees no travel at all, so the zoom was recognised as a tap and undocked the chart. + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + val travel = ViewConfiguration.get(context).scaledTouchSlop * 4f + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(MotionEvent.ACTION_MOVE, 500f to 450f, 900f + travel to 450f, eventTime = downTime + 20L), + event(pointerUp(1), 500f to 450f, 900f + travel to 450f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a pinch anchored on the second finger is not a tap either`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + val travel = ViewConfiguration.get(context).scaledTouchSlop * 4f + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(MotionEvent.ACTION_MOVE, 500f - travel to 450f, 900f to 450f, eventTime = downTime + 20L), + event(pointerUp(1), 500f - travel to 450f, 900f to 450f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a long two-finger hold is not a tap`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + val tooLong = ViewConfiguration.getTapTimeout().toLong() * 5 + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(pointerUp(1), 500f to 450f, 900f to 450f, eventTime = downTime + tooLong), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `three fingers are not a two-finger tap`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(pointerDown(2), 500f to 450f, 900f to 450f, 700f to 600f), + event(pointerUp(2), 500f to 450f, 900f to 450f, 700f to 600f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt new file mode 100644 index 0000000000..79d6e02646 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt @@ -0,0 +1,208 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.widget.ImageViewCompat +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What has to survive the carousel moving between the editor and its floating window. + * + * Both cases here were reported from a device and neither had a test. Undocking inflates a fresh + * layout from a plain window context and rebinds the same controller into it, which is a different + * enough environment from the editor that things correct in one are wrong in the other. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCarouselRebindTest { + private val context: Context = + ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.Theme_AndroidIDE) + + private val controllers = mutableListOf() + + @After + fun tearDown() { + controllers.forEach { it.unbind() } + controllers.clear() + } + + private fun controller() = + MetricsCarouselController( + memoryUsageWatcher = MemoryUsageWatcher(), + networkUsageWatcher = NetworkUsageWatcher(uid = TEST_UID), + 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) + + private fun strip() = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + + /** The pager needs a size before a chart page can produce a bitmap to export. */ + private fun laidOut(binding: LayoutMemUsageBinding) { + val width = View.MeasureSpec.makeMeasureSpec(720, View.MeasureSpec.EXACTLY) + val height = View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY) + binding.root.measure(width, height) + binding.root.layout(0, 0, 720, 400) + } + + @Test + fun `the page survives a rebind`() { + val controller = controller() + val docked = strip() + controller.bind(docked) + docked.metricsPager.setCurrentItem(1, false) + + // Undocking rebinds the same controller into a freshly inflated layout, whose ViewPager2 + // starts at zero. Undocking while reading the network chart put the floating window on + // the memory chart. + val floating = strip() + controller.bind(floating) + + assertThat(floating.metricsPager.currentItem).isEqualTo(1) + } + + @Test + fun `the page title follows the restored page, not the first one`() { + val controller = controller() + val docked = strip() + controller.bind(docked) + docked.metricsPager.setCurrentItem(1, false) + val title = docked.metricsTitle.text.toString() + + val floating = strip() + controller.bind(floating) + + // A restored page with the first page's title would be worse than not restoring at all. + assertThat(floating.metricsTitle.text.toString()).isEqualTo(title) + } + + @Test + fun `a second snapshot is refused while the first is still being written`() { + val controller = controller() + val binding = strip() + controller.bind(binding) + laidOut(binding) + + // The camera button is not debounced, and each tap used to launch its own coroutine over + // the same scratch directory -- and, within the same second, the same filename, since the + // name is the chart label plus a whole-second timestamp. The first export could then hand + // another app a URI whose file the second had already replaced. + assertThat(controller.exportSnapshot()).isTrue() + assertThat(controller.exportSnapshot()).isFalse() + } + + @Test + fun `a running CSV export does not refuse the camera button`() { + val controller = controller() + val binding = strip() + controller.bind(binding) + laidOut(binding) + + // The two write different files into different directories and cannot race each other. One + // flag for both meant that starting an export of ten thousand rows made the camera button + // dead for as long as it ran, and dead silently -- the tap returned false and said nothing. + assertThat(controller.exportCsv()).isTrue() + assertThat(controller.exportSnapshot()).isTrue() + } + + @Test + fun `a second CSV export is refused while the first is still being written`() { + val controller = controller() + val binding = strip() + controller.bind(binding) + laidOut(binding) + + assertThat(controller.exportCsv()).isTrue() + assertThat(controller.exportCsv()).isFalse() + } + + @Test + fun `both arrows are tinted, whatever inflated them`() { + val binding = strip() + controller().bind(binding) + + // app:tint is applied by AppCompat, and only when its factory is on the inflater. The + // floating window inflates from a plain window context, so there the arrows came out as + // ordinary ImageButtons and the vector's own android:tint="#000000" won -- black arrows + // on a near-black strip, reported from a device as "the arrows are not visible". + val previous = ImageViewCompat.getImageTintList(binding.metricsPrevious) + val next = ImageViewCompat.getImageTintList(binding.metricsNext) + + assertThat(previous).isNotNull() + assertThat(next).isNotNull() + assertThat(previous!!.defaultColor).isNotEqualTo(BLACK) + assertThat(next!!.defaultColor).isNotEqualTo(BLACK) + assertThat(previous.defaultColor).isEqualTo(next.defaultColor) + } + + @Test + fun `dimming an end arrow changes its alpha, not its tint`() { + val binding = strip() + controller().bind(binding) + + // The two are orthogonal, which is why asserting the arrows share a tint does not + // contradict their looking different at the ends of the carousel: the tint is the colour + // the glyph is drawn in, and the dimming is alpha over the top of it. A later change that + // dimmed through a state-aware ColorStateList instead would break that, and this says so. + assertThat(binding.metricsPager.currentItem).isEqualTo(0) + assertThat(binding.metricsPrevious.alpha).isLessThan(binding.metricsNext.alpha) + + val previous = ImageViewCompat.getImageTintList(binding.metricsPrevious)!! + val next = ImageViewCompat.getImageTintList(binding.metricsNext)!! + assertThat(previous.defaultColor).isEqualTo(next.defaultColor) + // One colour, no per-state variation: nothing here depends on the enabled state. + assertThat(previous.isStateful).isFalse() + } + + @Test + fun `the arrow tint is the colour the title uses`() { + val binding = strip() + controller().bind(binding) + + // The arrows sit either side of the title and should read as the same control surface. + val tint = ImageViewCompat.getImageTintList(binding.metricsPrevious)!!.defaultColor + assertThat(tint).isEqualTo(binding.metricsTitle.currentTextColor) + } + + private companion object { + const val TEST_UID = 10_123 + const val BLACK = 0xFF000000.toInt() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt new file mode 100644 index 0000000000..672c5430f9 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -0,0 +1,235 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.MotionEvent +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins where the sampling-rate chooser is reached from (ADFA-5486). + * + * The x axis is drawn by the chart rather than being a view of its own, so the tap is recognised by + * comparing coordinates against the plot area. That test and the axis's position have to agree: + * they disagreed once -- the axis at the bottom, the tap band at the top -- which left the only way + * to change the sampling rate in an empty strip at the far end of the chart from the labels the + * gesture is named for. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartAxisTapTest { + private val context = ApplicationProvider.getApplicationContext() + + private var taps = 0 + + /** Set by [laidOutChart], for the tests that need to ask the renderer something. */ + private lateinit var attachedRenderer: NetworkUsageChartRenderer + + private fun laidOutChart(): SafeLineChart { + val chart = SafeLineChart(context) + // Any concrete renderer will do -- the tap band is decided by the base class, and every + // page positions its x axis the same way. + val renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) + }, + ) + renderer.attach(chart) + renderer.onXAxisTap = { taps++ } + attachedRenderer = renderer + + chart.layOutAndDraw() + return chart + } + + private fun tapAt( + chart: SafeLineChart, + y: Float, + ) { + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_UP, 10f, y, 0) + chart.onChartGestureListener.onChartSingleTapped(event) + event.recycle() + } + + @Test + fun `a panned viewport is what the renderer reads, not the newest window`() { + val chart = laidOutChart() + drawOnce(chart) + + // Zoom first: an unzoomed chart shows everything, so there is nothing a pan could move. + chart.setVisibleXRangeMaximum(VISIBLE_WINDOW.toFloat()) + chart.moveViewToXNow(0f) + drawOnce(chart) + assertThat(chart.lowestVisibleX).isLessThan(10f) + assertThat(chart.highestVisibleX).isLessThan(SAMPLES / 2f) + + // Until the user drives the viewport, the renderer says what showNewestWindow put there + // rather than asking the chart -- so it reports the newest samples even though the chart + // is showing the oldest. + assertThat(attachedRenderer.visibleSampleRange(chart, SAMPLES).last).isEqualTo(SAMPLES - 1) + + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, 10f, 0) + chart.onChartGestureListener.onChartTranslate(event, -50f, 0f) + event.recycle() + + // A pan is the user driving the viewport just as much as a pinch. Only a pinch used to + // count, so a pan left the renderer ranging and annotating against the wrong samples -- + // and showNewestWindow scrolled the chart back on the next tick. + assertThat(attachedRenderer.visibleSampleRange(chart, SAMPLES).last) + .isLessThan(SAMPLES - 1) + } + + @Test + fun `re-attaching the same chart keeps the viewport the user drove`() { + val chart = laidOutChart() + drawOnce(chart) + chart.setVisibleXRangeMaximum(VISIBLE_WINDOW.toFloat()) + chart.moveViewToXNow(0f) + drawOnce(chart) + + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, 10f, 0) + chart.onChartGestureListener.onChartTranslate(event, -50f, 0f) + event.recycle() + assertThat(attachedRenderer.visibleSampleRange(chart, SAMPLES).last).isLessThan(SAMPLES - 1) + + // A rebind of an already-bound holder. The teardown it runs is what stops a second gesture + // listener being installed, so it has to happen -- but it also cleared the flag that says + // the user has driven the viewport, and the next tick then scrolled the chart back to the + // newest samples underneath them. + attachedRenderer.attach(chart) + drawOnce(chart) + + assertThat(attachedRenderer.visibleSampleRange(chart, SAMPLES).last).isLessThan(SAMPLES - 1) + } + + /** MPAndroidChart runs its viewport jobs during a draw, so a pan is not real until one. */ + private fun drawOnce(chart: SafeLineChart) { + chart.draw(Canvas(Bitmap.createBitmap(CHART_WIDTH, CHART_HEIGHT, Bitmap.Config.ARGB_8888))) + } + + @Test + fun `the plot area has room for a tap to fall inside or outside it`() { + val chart = laidOutChart() + + // Guards the other tests: on an unlaid-out chart they would all tap the same edge. + assertThat(chart.viewPortHandler.contentBottom()).isGreaterThan(chart.viewPortHandler.contentTop()) + assertThat(chart.viewPortHandler.contentBottom()).isLessThan(CHART_HEIGHT.toFloat()) + } + + @Test + fun `a tap below the plot, where the axis is drawn, opens the chooser`() { + val chart = laidOutChart() + + tapAt(chart, chart.viewPortHandler.contentBottom() + 1f) + + assertThat(taps).isEqualTo(1) + } + + @Test + fun `a tap on the legend does not open the chooser`() { + val chart = laidOutChart() + + // MPAndroidChart aligns the legend to the bottom by default, below the axis labels, so + // "everything under the plot" included it -- and the legend is the one part of a chart a + // reader expects to be tappable. Opening the rate chooser there is bad enough; picking a + // rate in it clears every buffer, so a mis-tap costs the history being looked at. + // + // Robolectric measures no real text, so the legend here is a few pixels rather than the + // ~10dp row a device draws. That is enough: the assertion is about which side of the + // boundary the legend's own rows fall on, and the bottom row is the legend's. + tapAt(chart, CHART_HEIGHT - 1f) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `the axis labels still open the chooser, with the legend excluded`() { + val chart = laidOutChart() + + // The other half of the bound: narrowing the band must not put the rate chooser out of + // reach. One axis label's height below the plot always stays in it. + tapAt(chart, chart.viewPortHandler.contentBottom() + chart.xAxis.textSize / 2f) + + assertThat(taps).isEqualTo(1) + } + + @Test + fun `the gap the legend keeps above itself still opens the chooser`() { + val chart = laidOutChart() + val legend = chart.legend + + // Guards the assertion below: with no legend, or no gap, there is no strip to test. + assertThat(legend.isEnabled).isTrue() + assertThat(legend.mNeededHeight).isGreaterThan(0f) + assertThat(legend.yOffset).isGreaterThan(0f) + + // Legend.calculateDimensions ends with `mNeededHeight += mYOffset`, so the offset is + // already inside the measured height. Reserving `mNeededHeight + yOffset` counted it twice + // and handed the legend a strip yOffset tall that nothing draws in -- taken off the bottom + // of the one target that opens the sampling-rate chooser. + tapAt(chart, CHART_HEIGHT - legend.mNeededHeight - legend.yOffset / 2f) + + assertThat(taps).isEqualTo(1) + } + + @Test + fun `a tap above the plot does not open the chooser`() { + val chart = laidOutChart() + + // Nothing is drawn up there. Answering taps here is what made the gesture unreachable. + tapAt(chart, chart.viewPortHandler.contentTop() - 1f) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a tap inside the plot does not open the chooser`() { + val chart = laidOutChart() + + val handler = chart.viewPortHandler + tapAt(chart, (handler.contentTop() + handler.contentBottom()) / 2f) + + assertThat(taps).isEqualTo(0) + } + + private companion object { + /** + * Longer than the chart's visible window. + * + * It was exactly the window, and showNewestWindow returns early when the newest index is + * below it -- so the pan test could not tell the fix from the bug, because nothing was + * scrolling the viewport either way. + */ + const val SAMPLES = 200 + + /** The renderer's own visible window, which is what it scrolls to the newest samples. */ + const val VISIBLE_WINDOW = 60 + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt new file mode 100644 index 0000000000..9723b88c74 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartGestureTeardownTest.kt @@ -0,0 +1,167 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.view.ViewConfiguration +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.listener.ChartTouchListener +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What happens to a hold in progress when the gesture or the chart under it goes away (ADFA-5554). + * + * A hold is a timer on the main thread's queue, not state on the view, so it outlives whatever + * started it: a second finger landing, the page being rebound, the renderer letting the chart go. + * Each of those has to reach the timer, and none of them can once the listener holding it has been + * replaced. + * + * Split from [MetricsChartHoldHelpTest] only because these three are about teardown rather than + * timing. An earlier version of this comment blamed a Robolectric interaction: the suite was + * killing the test JVM as cases were added, and splitting appeared to help. It was heap -- + * Robolectric builds a sandbox per distinct `@Config` and `:app` had outgrown the 1g in the root + * build file. The split is kept because it reads better, not because it fixes anything. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartGestureTeardownTest { + private val harness = ChartGestureHarness(ApplicationProvider.getApplicationContext()) + + private val taps get() = harness.taps + + private val helps get() = harness.helps + + private val renderer get() = harness.renderer + + private fun laidOutChart() = harness.laidOutChart() + + private fun longPressAt( + chart: SafeLineChart, + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ) = harness.longPressAt(chart, y, sincePressMillis) + + private fun panBy( + chart: SafeLineChart, + dx: Float, + ) = harness.panBy(chart, dx) + + private fun endGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) = harness.endGesture(chart, gesture) + + private fun cancelGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) = harness.cancelGesture(chart, gesture) + + private fun onAxisBand(chart: SafeLineChart) = harness.onAxisBand(chart) + + private fun insidePlot(chart: SafeLineChart) = harness.insidePlot(chart) + + @Test + fun `a gesture an ancestor cancels does not stand in for a tap`() { + val chart = laidOutChart() + + longPressAt(chart, onAxisBand(chart)) + cancelGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + drain() + + // A cancel is not a lift. The chooser this would open clears every sample buffer, so a + // press the sheet or the pager steals mid-gesture must not be read as a finger lifting + // early -- which is exactly what it looked like, because endAction reports the same + // LONG_PRESS for both. + assertThat(taps).isEqualTo(0) + } + + @Test + fun `detaching takes back a stand-in tap that has been posted`() { + val chart = laidOutChart() + + longPressAt(chart, onAxisBand(chart)) + endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + // The tap is on the looper now, not yet run. Letting the chart go in that window used to + // leave it there: it opened the chooser, and cleared every buffer, for a chart this + // renderer no longer had. + renderer.detach() + drain() + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a second finger gives up the gesture, even without a move`() { + val chart = laidOutChart() + + // This tests what the renderer does when the second pointer is reported, by calling the + // callback directly. It does NOT test that the callback fires for the gesture that matters, + // and it cannot: a ViewGroup rewrites ACTION_POINTER_DOWN to ACTION_MOVE for the child + // already holding the first pointer, so on the realistic undock -- one finger on an arrow, + // one on the strip -- SafeLineChart.onTouchEvent never sees a pointer-down at all. Driving + // this from MetricsCarouselLayout.dispatchTouchEvent, which does see it, is its own change. + // + // The carousel undocks on a two-finger tap, and that starts as a press like any other. + // MPAndroidChart cannot report it -- ACTION_POINTER_DOWN never touches its mLastGesture -- + // so the gesture still ends labelled LONG_PRESS and the stand-in tap fired, opening the + // sampling-rate chooser. Picking a rate there clears every buffer, so one gesture both + // undocked the strip and threw away the history it was showing. + longPressAt(chart, chart.viewPortHandler.contentBottom() + 1f) + chart.onSecondPointerDown?.invoke() + endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + elapse(remainderOfHold()) + drain() + + assertThat(taps).isEqualTo(0) + assertThat(helps).isEqualTo(0) + } + + @Test + fun `re-attaching the same chart leaves no second listener behind`() { + val chart = laidOutChart() + + // attach() used to skip the teardown when handed the chart it already had, so configure() + // installed a second gesture listener while the first stayed queued with a hold nothing + // could reach. A rebind of a bound holder does exactly that. + longPressAt(chart, insidePlot(chart)) + renderer.attach(chart) + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + @Test + fun `detaching cancels a hold already counting down`() { + val chart = laidOutChart() + + // The timer is on the main thread's queue, not on the chart, so unbinding the page does + // not reach it. Worse, the rebind installs a fresh listener whose own pending hold is + // null -- so nobody could have cancelled the old one, and it fired the outgoing page's + // help over whatever replaced it. + longPressAt(chart, insidePlot(chart)) + renderer.detach() + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + private companion object { + const val SAMPLES = 200 + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt new file mode 100644 index 0000000000..99851457d5 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartHoldHelpTest.kt @@ -0,0 +1,184 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.view.ViewConfiguration +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.listener.ChartTouchListener +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.longPressHelpTimeoutMillis +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * When the chart answers a hold with help, and when it gives that help up (ADFA-5554). + * + * The platform reports its long press at 400ms, which is a brisk tap, so the chart waits out the + * rest of the hold before showing anything. Two things have to be true of that wait: it happens, + * and it is abandoned when the gesture turns into something a hold is not -- a pan, a pinch, or a + * page being unbound underneath it. + * + * The help itself is a seam rather than a real tooltip. `TooltipManager` reads the docs database + * from device storage in its static initialiser and cannot be loaded off-device, which is the same + * reason the renderer separates deciding a help tag from showing one. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartHoldHelpTest { + private val harness = ChartGestureHarness(ApplicationProvider.getApplicationContext()) + + private val taps get() = harness.taps + + private val helps get() = harness.helps + + private val renderer get() = harness.renderer + + private fun laidOutChart() = harness.laidOutChart() + + private fun longPressAt( + chart: SafeLineChart, + y: Float, + sincePressMillis: Long = ViewConfiguration.getLongPressTimeout().toLong(), + ) = harness.longPressAt(chart, y, sincePressMillis) + + private fun panBy( + chart: SafeLineChart, + dx: Float, + ) = harness.panBy(chart, dx) + + private fun scaleBy( + chart: SafeLineChart, + factor: Float, + ) = harness.scaleBy(chart, factor) + + private fun endGesture( + chart: SafeLineChart, + gesture: ChartTouchListener.ChartGesture, + ) = harness.endGesture(chart, gesture) + + private fun insidePlot(chart: SafeLineChart) = harness.insidePlot(chart) + + @Test + fun `a press held past the hold shows help`() { + val chart = laidOutChart() + + longPressAt(chart, insidePlot(chart)) + elapse(remainderOfHold()) + + // The deferral is the point of ADFA-5554: the platform reports its long press at 400ms, + // which is a brisk tap, and help at that speed is what the ticket is about. + assertThat(helps).isEqualTo(1) + } + + @Test + fun `a press lifted before the hold completes shows no help`() { + val chart = laidOutChart() + + longPressAt(chart, insidePlot(chart)) + endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + @Test + fun `a press on the axis lifted before the hold still opens the chooser`() { + val chart = laidOutChart() + + // The detector has already called this a long press, so it will not report the tap. The + // stand-in is what keeps a brisk press on the axis doing what it always did. + longPressAt(chart, chart.viewPortHandler.contentBottom() + 1f) + endGesture(chart, ChartTouchListener.ChartGesture.LONG_PRESS) + + // Nothing has been tapped inside the dispatch itself: the tap opens a dialog, and doing + // that mid-gesture leaves the chart's touch state part-way through one. + assertThat(taps).isEqualTo(0) + drain() + + assertThat(taps).isEqualTo(1) + assertThat(helps).isEqualTo(0) + } + + @Test + fun `a press on the axis that becomes a pan does not open the chooser`() { + val chart = laidOutChart() + + // A drag begins from a press the detector has already called a long press, so the + // stand-in fired for it: panning the chart opened the sampling-rate chooser, and picking + // a rate there clears every buffer -- the history loss the band's lower bound exists to + // prevent, reached by another route. + longPressAt(chart, chart.viewPortHandler.contentBottom() + 1f) + panBy(chart, -50f) + endGesture(chart, ChartTouchListener.ChartGesture.DRAG) + drain() + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a press that becomes a pan shows no help either`() { + val chart = laidOutChart() + + // The finger is still down and still dragging when the hold would come due, so the + // tooltip opened over a chart the user was in the middle of panning. + longPressAt(chart, insidePlot(chart)) + panBy(chart, -50f) + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + @Test + fun `a press that becomes a pinch shows no help`() { + val chart = laidOutChart() + + longPressAt(chart, insidePlot(chart)) + scaleBy(chart, 1.2f) + elapse(remainderOfHold()) + + assertThat(helps).isEqualTo(0) + } + + @Test + fun `the hold is measured from the finger landing, not from when the press was reported`() { + val chart = laidOutChart() + + // GestureDetector does not report a long press exactly getLongPressTimeout() after the + // finger lands: below Q it adds TAP_TIMEOUT, and it caches the timeout in a static read at + // class-load, so a lengthened accessibility touch-and-hold delay moves the buttons' hold + // and not the detector's. Subtracting the platform timeout from the total assumed + // otherwise, and stretched the chart's hold by however far the detector was late. + longPressAt(chart, insidePlot(chart), sincePressMillis = LATE_REPORT_MILLIS) + elapse(longPressHelpTimeoutMillis() - LATE_REPORT_MILLIS + 50L) + + assertThat(helps).isEqualTo(1) + } + + private companion object { + /** Longer than the chart's visible window, matching the axis-tap tests' fixture. */ + const val SAMPLES = 200 + + /** + * A long press reported well after the finger landed. + * + * Comfortably past the platform timeout, so the two ways of computing the remaining hold + * give different answers and the test can tell them apart. + */ + const val LATE_REPORT_MILLIS = 700L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt new file mode 100644 index 0000000000..2594759b6f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLargeTextTest.kt @@ -0,0 +1,163 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.appcompat.view.ContextThemeWrapper +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The chart at the size the carousel actually gives it, with the text a low-vision user runs + * (ADFA-5602). + * + * Every other chart test lays out at [CHART_HEIGHT], 400px, which is far taller than the strip: + * `editor_mem_usage_view_height` is 248dp and the plot is only the part of it left over after the + * title row, the legend and the arrows. At 400px there is room for the axis text to grow and + * nothing collapses, which is why the whole suite passed while the chart on the device drew + * nothing at all at 2x. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartLargeTextTest { + private val context = ApplicationProvider.getApplicationContext() + + private val themed: Context = + ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.Theme_AndroidIDE) + + private fun laidOutChart(height: Int): SafeLineChart { + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L + it }, + LongArray(SAMPLES) { 500L + it }, + LongArray(SAMPLES), + ) + }, + ).attach(chart) + chart.layOutAndDraw(CHART_WIDTH, height) + return chart + } + + /** The x labels the axis would draw, as the reader sees them. */ + private fun xLabels(chart: SafeLineChart): List { + val axis = chart.xAxis + val formatter = axis.valueFormatter ?: return emptyList() + return axis.mEntries.map { formatter.getFormattedValue(it, axis).orEmpty() } + } + + @Test + fun `the plot keeps a usable area in the strip the carousel gives it`() { + val chart = laidOutChart(STRIP_PLOT_HEIGHT) + + val handler = chart.viewPortHandler + assertWithMessage("content width").that(handler.contentWidth()).isGreaterThan(0f) + assertWithMessage("content height").that(handler.contentHeight()).isGreaterThan(0f) + } + + @Test + @Config(fontScale = 2.0f) + fun `the plot keeps a usable area at 2x font scale`() { + // The strip's height is fixed, so everything the axes and the legend reserve comes out of + // the plot. At 2x that reservation grew past what was there. + val chart = laidOutChart(STRIP_PLOT_HEIGHT) + + val handler = chart.viewPortHandler + assertWithMessage("content width").that(handler.contentWidth()).isGreaterThan(0f) + assertWithMessage("content height").that(handler.contentHeight()).isGreaterThan(0f) + } + + @Test + @Config(fontScale = 2.0f) + fun `the time axis still says how long ago, not 'now' for every label`() { + // The reported symptom. ElapsedTimeFormatter answers "now" whenever a label's value equals + // the axis maximum, so an axis whose range has collapsed labels every tick "now" -- and the + // same collapse is why nothing is drawn. + val chart = laidOutChart(STRIP_PLOT_HEIGHT) + + val labels = xLabels(chart) + assertThat(labels).isNotEmpty() + assertWithMessage("labels were $labels").that(labels.any { it != "now" }).isTrue() + } + + @Test + fun `a series with no readings at all does not collapse the time axis`() { + // What the device showed when this was reported: the legend read "Power - n/a", the plot was + // empty, and every x label read "now". A power source that stops answering gives the chart a + // series of pure sentinels, which is not the same as no chart at all -- the axis still has to + // say how long ago each sample was. + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage(LongArray(0), LongArray(0), LongArray(0)) + }, + ).attach(chart) + chart.layOutAndDraw(CHART_WIDTH, STRIP_PLOT_HEIGHT) + + val labels = xLabels(chart) + assertWithMessage("labels were $labels, xRange=${chart.xAxis.mAxisMinimum}..${chart.xAxis.mAxisMaximum}") + .that(labels.all { it == "now" } && labels.isNotEmpty()) + .isFalse() + } + + private companion object { + const val SAMPLES = 200 + + /** + * What the plot gets inside the 248dp strip once the title row, legend and arrows have + * taken theirs. Robolectric's density is 1.0, so dp and px are the same here. + */ + const val STRIP_PLOT_HEIGHT = 150 + } + + @Test + @Config(fontScale = 2.0f, qualifiers = "xhdpi") + fun `the undocked message fits the strip at 2x text`() { + // The strip is a fixed editor_mem_usage_view_height and the message fills it with no room to + // scroll, so the only thing keeping it readable at 2x is that it still fits. Measured at the + // real height rather than the 400px the other tests use. + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(themed)) + val strip = binding.root as MetricsCarouselLayout + strip.setUndocked(true) + + val height = themed.resources.getDimensionPixelSize(R.dimen.editor_mem_usage_view_height) + strip.measure( + View.MeasureSpec.makeMeasureSpec(CHART_WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + strip.layout(0, 0, CHART_WIDTH, height) + + val message = strip.findViewById(R.id.metrics_undocked_message) + val needed = message.layout.height + message.paddingTop + message.paddingBottom + + assertWithMessage("undocked message needs %spx of the %spx it has", needed, message.height) + .that(needed) + .isAtMost(message.height) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt new file mode 100644 index 0000000000..bce619c6ba --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartLegendFormTest.kt @@ -0,0 +1,191 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.components.Legend +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MutableShiftedLongArray +import com.itsaky.androidide.utils.NetworkUsageWatcher +import com.itsaky.androidide.utils.PowerUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The legend marker is a small dot, and it stays one (ADFA-5553). + * + * The squares were 15dp and set per dataset, which crowded the label beside them and the axis + * below. The size now comes from the legend, and that only works while no dataset overrides it: + * `LegendRenderer` takes the dataset's `formSize` whenever it is not NaN and falls back to the + * legend's only otherwise, so one renderer setting it again would silently take the setting back + * without anything failing. That deference is what these tests pin -- asserting `legend.form` alone + * would restate a setter and pass against the code this ticket exists to change. + * + * The size test runs at xhdpi on purpose. MPAndroidChart stores half of these properties in dp and + * half in pixels, and at Robolectric's default density of 1.0 nothing tells the two apart. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartLegendFormTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun memoryChart(): SafeLineChart { + val chart = SafeLineChart(context) + MemoryUsageChartRenderer( + usagesProvider = { + arrayOf( + MemoryUsageWatcher.ProcessMemoryInfo(1, "IDE", MutableShiftedLongArray(SAMPLES), watchedSinceMillis = 0L), + MemoryUsageWatcher.ProcessMemoryInfo( + 2, + "Gradle Tooling", + MutableShiftedLongArray(SAMPLES), + watchedSinceMillis = 0L, + ), + ) + }, + lineColorFor = { android.graphics.Color.BLUE }, + ).attach(chart) + return chart + } + + private fun networkChart(): SafeLineChart { + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1L }, + LongArray(SAMPLES) { 1L }, + LongArray(SAMPLES), + ) + }, + ).attach(chart) + return chart + } + + private fun powerChart(): SafeLineChart { + val chart = SafeLineChart(context) + PowerUsageChartRenderer( + usageProvider = { + PowerUsageWatcher.PowerUsage( + LongArray(SAMPLES) { 30_000L }, + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 0L }, + LongArray(SAMPLES), + ) + }, + batteryProvider = { PowerUsageWatcher.BatteryState.UNKNOWN }, + ).attach(chart) + return chart + } + + private fun charts() = listOf("memory" to memoryChart(), "network" to networkChart(), "power" to powerChart()) + + @Test + fun `every page's legend marker is a dot`() { + charts().forEach { (name, chart) -> + assertWithMessage(name).that(chart.legend.form).isEqualTo(Legend.LegendForm.CIRCLE) + } + } + + // Deliberately no size assertion at the default scale: MPAndroidChart's own Legend constructor + // sets formSize to 8f, which is the value this renderer asks for, so such an assertion passes + // with the production line deleted. The scale test below is what pins the size, because 12f is + // a number only this code produces. + + @Test + fun `no dataset overrides the legend, which is the only reason the legend's value applies`() { + charts().forEach { (name, chart) -> + chart.layOutAndDraw() + + val entries = chart.legend.entries + assertThat(name to entries.isNotEmpty()).isEqualTo(name to true) + entries.forEach { entry -> + assertThat(name to entry.form).isEqualTo(name to Legend.LegendForm.DEFAULT) + assertThat(name to entry.formSize.isNaN()).isEqualTo(name to true) + } + } + } + + @Test + @Config(fontScale = 2.0f, qualifiers = "xhdpi") + fun `the dot, its gaps and its label all grow together, to the same ceiling`() { + // A fixed marker beside text at the ceiling reads as though it were shrinking, and so do + // the gaps around it. Spelled out rather than derived from BASE_LEGEND_FORM_DP * + // MAX_TEXT_SCALE: computing the expectation from the same two constants the code + // multiplies can only show that a multiplication happened. Each figure below is its dp + // constant at the chart's 1.5 ceiling -- the chart's, not the platform's 2.0 -- so raising + // either constant has to come and change this line. + // + // Half of these properties are stored in pixels and half in dp, which is MPAndroidChart's + // doing and not ours: Legend keeps formSize, formToTextSpace and xEntrySpace as the dp it + // was given and converts them when it draws, while ComponentBase.setTextSize and + // setYOffset convert on the way in. At Robolectric's default density of 1.0 the two are + // indistinguishable and a pixel getter compared against a dp constant passes anyway, so + // this runs at xhdpi where they differ by 2x. + assertThat(context.resources.displayMetrics.density).isWithin(TOLERANCE).of(2f) + + charts().forEach { (name, chart) -> + val legend = chart.legend + assertWithMessage("$name formSize").that(legend.formSize).isWithin(TOLERANCE).of(12f) + assertWithMessage("$name formToTextSpace").that(legend.formToTextSpace).isWithin(TOLERANCE).of(7.5f) + assertWithMessage("$name xEntrySpace").that(legend.xEntrySpace).isWithin(TOLERANCE).of(9f) + + // 15dp and 4.5dp, in pixels. Both are looks only. An earlier version of this comment + // claimed the offset also moved the sampling-rate tap band, which it cannot: the + // legend adds yOffset into mNeededHeight itself, and the band is measured from that. + assertWithMessage("$name textSize").that(legend.textSize).isWithin(TOLERANCE).of(30f) + assertWithMessage("$name yOffset").that(legend.yOffset).isWithin(TOLERANCE).of(9f) + } + } + + @Test + fun `a font scale changed mid-session still reaches the chart through a redraw`() { + // The per-tick path applies the scale only when it has moved, so this is the case that + // guards the saving: EditorActivityKt handles fontScale itself, so no activity is + // recreated and a redraw is the only thing a running chart does. + val usage = + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1L }, + LongArray(SAMPLES) { 1L }, + LongArray(SAMPLES), + ) + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage }) + renderer.attach(chart) + assertThat(chart.legend.formSize).isWithin(TOLERANCE).of(8f) + + context.resources.configuration.fontScale = 2.0f + // The same sample, so the series keep their shape and this takes the in-place redraw + // rather than falling back to a rebuild, which would apply the scale by another route + // and prove nothing. + renderer.onUsageChanged(usage) + + assertThat(chart.legend.formSize).isWithin(TOLERANCE).of(12f) + } + + private companion object { + const val SAMPLES = 60 + + /** The sizes are computed in floats and read back, so they land near-exactly. */ + const val TOLERANCE = 0.01f + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt new file mode 100644 index 0000000000..a8443a7894 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartNewestWindowTest.kt @@ -0,0 +1,147 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Where the chart's viewport ends up when a page is bound before it has been laid out. + * + * That is the order the carousel always binds in -- `onBindViewHolder` attaches the renderer while + * RecyclerView is still laying the page out -- and the editor rebinds the whole carousel on every + * resume. Returning from the APK install prompt therefore left the chart parked in the zeroed head + * of the buffer, reading -9999s and drawing nothing, until the next sample landed a redraw a second + * or more later: an empty plot at the moment the user has just run a build and is looking straight + * at it (ADFA-5515). + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartNewestWindowTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun renderer() = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) + }, + ) + + /** + * The window the chart is meant to settle on: the newest [MetricsChartRenderer.VISIBLE_SAMPLES] + * of them, ending on the newest sample. + * + * The window is asked to start one sample further right than this and is clamped back, since it + * cannot extend past the end of the data -- so the newest sample sits exactly on the right edge. + */ + private fun assertShowsNewestSamples(chart: SafeLineChart) { + val newest = (SAMPLES - 1).toFloat() + assertThat(chart.highestVisibleX).isWithin(TOLERANCE).of(newest) + assertThat(chart.lowestVisibleX) + .isWithin(TOLERANCE) + .of(newest - MetricsChartRenderer.VISIBLE_SAMPLES) + } + + @Test + fun `a page bound before it is laid out still opens on the newest samples`() { + val chart = SafeLineChart(context) + + // The carousel's order: attach first, lay out second. + renderer().attach(chart) + chart.layOutAndDraw() + + assertShowsNewestSamples(chart) + } + + @Test + fun `a page bound after it is laid out opens on the newest samples`() { + val chart = SafeLineChart(context) + chart.layOutAndDraw() + + renderer().attach(chart) + + assertShowsNewestSamples(chart) + } + + @Test + fun `a resize puts the window back without waiting for a sample`() { + val chart = SafeLineChart(context) + renderer().attach(chart) + chart.layOutAndDraw() + + // A size change resets the chart's transform, dropping the window. Only a redraw used to + // restore it, which is why a rotation showed samples from half an hour ago until the next + // tick. + chart.layOutAndDraw(width = CHART_WIDTH, height = CHART_HEIGHT + 40) + + assertShowsNewestSamples(chart) + } + + @Test + fun `a detached renderer stops following the chart it left`() { + val chart = SafeLineChart(context) + val renderer = renderer() + renderer.attach(chart) + chart.layOutAndDraw() + renderer.detach() + + // A resize drops the window, and nothing should put it back: the page is on its way to + // another renderer, and a stale listener would fight whichever one binds next. + chart.layOutAndDraw(width = CHART_WIDTH, height = CHART_HEIGHT + 40) + + assertThat(chart.lowestVisibleX).isWithin(TOLERANCE).of(0f) + } + + @Test + fun `a rebind onto a new page forgets a pan on the old one`() { + val renderer = renderer() + val first = SafeLineChart(context) + renderer.attach(first) + first.layOutAndDraw() + + // The user pans. From here the viewport on *this* chart is theirs, not the renderer's. + checkNotNull(first.onChartGestureListener).onChartTranslate(null, -20f, 0f) + + // A resume rebinds the carousel, which attaches the replacement page before the outgoing + // one is recycled -- so the detach naming the old chart arrives afterwards and finds a + // different one bound. The pan belonged to the page the user left; the fresh page must + // still open on the newest samples. + val second = SafeLineChart(context) + renderer.attach(second) + renderer.detachIfAttached(first) + second.layOutAndDraw() + + assertShowsNewestSamples(second) + } + + private companion object { + /** Longer than the visible window, so there is a wrong end of the buffer to park in. */ + const val SAMPLES = 200 + + /** The viewport is computed in pixels and read back as a value, so it lands near-exactly. */ + const val TOLERANCE = 0.01f + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt new file mode 100644 index 0000000000..b131aee423 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartTextScaleTest.kt @@ -0,0 +1,206 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.view.ContextThemeWrapper +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The chart text-scale policy of ADFA-5527: follow the system font scale, up to a ceiling. + * + * MPAndroidChart sizes its text in dp, so before this the charts ignored the font scale entirely + * -- a user who asked for larger text got it everywhere in the IDE except inside these plots. The + * scale is followed only to [MetricsChartRenderer.MAX_TEXT_SCALE], because the strip is a fixed + * height and at the platform's full 2.0 the axis labels collide and the eight staggered annotation + * rows overlap. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartTextScaleTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private fun chart(): SafeLineChart { + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) + }, + ).attach(chart) + return chart + } + + private val base get() = MetricsChartRenderer.BASE_TEXT_SIZE_DP + + @Test + fun `at the default scale the text is the size it always was`() { + val chart = chart() + + // Matching MPAndroidChart's own default, so nothing moves for a user who has not changed + // the setting. + assertThat(chart.xAxis.textSize).isWithin(TOLERANCE).of(base) + assertThat(chart.legend.textSize).isWithin(TOLERANCE).of(base) + assertThat(chart.axisRight.textSize).isWithin(TOLERANCE).of(base) + } + + @Test + @Config(fontScale = 1.3f) + fun `a modest font scale is followed exactly`() { + val chart = chart() + + assertThat(chart.xAxis.textSize).isWithin(TOLERANCE).of(base * 1.3f) + assertThat(chart.legend.textSize).isWithin(TOLERANCE).of(base * 1.3f) + } + + @Test + @Config(fontScale = 2.0f) + fun `the largest font scale is held to the ceiling`() { + val chart = chart() + + // Not base * 2: eight annotation rows at that size do not fit the plot, and the axis + // labels collide with each other. + assertThat(chart.xAxis.textSize) + .isWithin(TOLERANCE) + .of(base * MetricsChartRenderer.MAX_TEXT_SCALE) + } + + @Test + @Config(fontScale = 0.85f) + fun `a font scale below one does not shrink the chart further`() { + val chart = chart() + + // The chart's text is already the smallest on the screen; following a reduction would + // make the labels unreadable rather than merely small. + assertThat(chart.xAxis.textSize).isWithin(TOLERANCE).of(base) + } + + @Test + @Config(fontScale = 2.0f) + fun `the annotation rows a chart actually draws grow with the labels`() { + // Asserted on the drawn marker, not on the helper: an earlier version of this test called + // annotationRowHeightFor directly, so it passed even with the renderer still using the + // unscaled constant at the call site. + var now = 1_000_000L + val store = MetricsAnnotationStore(nowMillis = { now }) + store.record("first") + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("second") + + val chart = SafeLineChart(context) + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) + }, + annotations = store, + sampleInterval = { 1_000L }, + ).attach(chart) + + // Rows sized for scale-1 text would overlap exactly when the text grew, which is what the + // staggering exists to prevent. Two consecutive markers sit one row apart. + val offsets = + chart.xAxis.limitLines + .map { it.yOffset } + .sorted() + assertThat(offsets).hasSize(2) + val expected = + MetricsChartRenderer.ANNOTATION_LABEL_ROW_HEIGHT_DP * MetricsChartRenderer.MAX_TEXT_SCALE + assertThat(offsets[1] - offsets[0]).isWithin(TOLERANCE).of(expected) + } + + @Test + @Config(fontScale = 2.0f) + fun `eight annotation rows still fit the plot at the ceiling`() { + // The reason the ceiling is 1.5. The strip is a fixed height, and this is the constraint + // that sets the limit -- if it ever fails, the ceiling is too high or the strip too short. + // + // Measured, not guessed. This used to compare against a hand-picked 150dp with a comment + // admitting it was conservative, which pinned the ceiling against a number no layout change + // could ever move. The strip is laid out at the ceiling font scale and the pager reports + // what the title row -- itself grown by that scale -- left it. + val rows = MetricsChartRenderer.ANNOTATION_LABEL_SLOTS + val used = rows * MetricsChartRenderer.annotationRowHeightFor(context) + + assertThat(used).isLessThan(plotHeightDp()) + } + + /** + * The plot area a chart page actually gets, in dp, with the system font scale at its largest. + * + * Measured the whole way down, with nothing allowed for by hand: the strip's height is the + * dimen the layout uses, the pager's share of it comes from a real measure and layout of the + * real strip, and the plot's share of *that* is the content rect a real chart page reports + * after a real renderer has put its legend and axis on it. So shortening the strip fails this, + * and so does anything above or inside the plot growing with the font scale. + */ + private fun plotHeightDp(): Float { + val themed = ContextThemeWrapper(context, R.style.Theme_AndroidIDE) + val strip = LayoutMemUsageBinding.inflate(LayoutInflater.from(themed)) + val metrics = context.resources.displayMetrics + val stripHeightPx = context.resources.getDimensionPixelSize(R.dimen.editor_mem_usage_view_height) + val widthPx = (STRIP_WIDTH_DP * metrics.density).toInt() + + strip.root.measure( + View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(stripHeightPx, View.MeasureSpec.EXACTLY), + ) + strip.root.layout(0, 0, widthPx, stripHeightPx) + + val page = + LayoutInflater + .from(themed) + .inflate(R.layout.item_metrics_chart, strip.metricsPager, false) as SafeLineChart + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + LongArray(SAMPLES), + ) + }, + ).attach(page) + page.layOutAndDraw(width = strip.metricsPager.width, height = strip.metricsPager.height) + + return page.viewPortHandler.contentHeight() / metrics.density + } + + private companion object { + const val SAMPLES = 60 + const val TOLERANCE = 0.01f + + /** A narrow phone, so the title row wraps here if it is ever going to. */ + const val STRIP_WIDTH_DP = 360f + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt new file mode 100644 index 0000000000..b104b450be --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -0,0 +1,330 @@ +/* + * 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.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.math.log10 + +/** + * Pins the two axis decisions ADFA-5489 was scoped around: values are log10, and zero is floored + * via `log10(bytes + 1)` so an idle IDE plots a continuous line at 0 instead of negative infinity. + */ +@RunWith(RobolectricTestRunner::class) +class NetworkUsageChartRendererTest { + private companion object { + /** Longer than the visible window, so the start of the history scrolls off screen. */ + const val SAMPLE_COUNT = 200 + } + + private val context = ApplicationProvider.getApplicationContext() + + // No sample times: these tests are about what the chart draws, and the chart asks only how long + // ago a sample was. Stated rather than defaulted, because the same emptiness in production + // silently blanks the CSV's network columns. + private fun usage( + received: LongArray, + transmitted: LongArray = received, + ) = NetworkUsageWatcher.NetworkUsage(received, transmitted, LongArray(received.size)) + + private fun rendererFor(usage: NetworkUsageWatcher.NetworkUsage): Pair { + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage }) + renderer.attach(chart) + return renderer to chart + } + + private fun dataset( + chart: SafeLineChart, + index: Int, + ) = chart.data.getDataSetByIndex(index) as LineDataSet + + @Test + fun `plots log10 of the byte count`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 9L, 99L, 999L))) + + val ys = dataset(chart, 0).entries.map { it.y } + + // log10(n + 1): 0 -> 0, 9 -> 1, 99 -> 2, 999 -> 3. Exact decades, so the floor is visible. + assertThat(ys).containsExactly(0f, 1f, 2f, 3f).inOrder() + } + + @Test + fun `zero bytes plots at zero rather than negative infinity`() { + val (_, chart) = rendererFor(usage(LongArray(30) { 0L })) + + val ys = dataset(chart, 0).entries.map { it.y } + + assertThat(ys.none { it.isInfinite() || it.isNaN() }).isTrue() + assertThat(ys.toSet()).containsExactly(0f) + } + + @Test + fun `a megabyte burst stays on scale with surrounding chatter`() { + val bytes = longArrayOf(0L, 512L, 2L * 1024 * 1024, 256L) + val (_, chart) = rendererFor(usage(bytes)) + + val ys = dataset(chart, 0).entries.map { it.y } + + // The point of the log axis: a 2MB burst is ~6.3 while 512B is ~2.7, so the small values + // stay legible instead of being flattened onto the baseline. + assertThat(ys[2]).isWithin(0.01f).of(log10(2.0 * 1024 * 1024 + 1).toFloat()) + assertThat(ys[1]).isGreaterThan(2f) + assertThat(ys[2] - ys[1]).isLessThan(4f) + } + + private fun laidOut(chart: SafeLineChart) = chart.layOutAndDraw() + + @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. + val samples = LongArray(SAMPLE_COUNT) { 500L } + samples[0] = 1_000_000_000L + + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage(samples) }) + renderer.attach(chart) + laidOut(chart) + // A second pass, now that the chart has a viewport to report. + renderer.rebuild() + + // Scaled to the burst the axis would reach 9 decades and flatten the 500 B chatter onto the + // baseline for the rest of the session -- the opposite of what the log axis is for. + assertThat(chart.axisRight.axisMaximum).isLessThan(4f) + } + + @Test + fun `a burst still on screen does raise the axis`() { + // Guards the test above: it must not pass by ignoring bursts altogether. + val samples = LongArray(SAMPLE_COUNT) { 500L } + samples[SAMPLE_COUNT - 1] = 1_000_000_000L + + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage(samples) }) + renderer.attach(chart) + laidOut(chart) + renderer.rebuild() + + assertThat(chart.axisRight.axisMaximum).isAtLeast(9f) + } + + @Test + fun `received and transmitted are separate series`() { + val (_, chart) = + rendererFor( + usage( + received = longArrayOf(0L, 999L), + transmitted = longArrayOf(0L, 9L), + ), + ) + + assertThat(chart.data.dataSetCount).isEqualTo(2) + assertThat(dataset(chart, 0).entries.last().y).isEqualTo(3f) + assertThat(dataset(chart, 1).entries.last().y).isEqualTo(1f) + } + + @Test + fun `the legend reports a rate, so a slower sampling rate does not overstate it`() { + val chart = SafeLineChart(context) + // 10 kB in a five-second interval is 2 kB/s, not 10 kB/s. + val renderer = + NetworkUsageChartRenderer( + usageProvider = { usage(longArrayOf(0L, 10_000L)) }, + sampleInterval = { 5_000L }, + ) + renderer.attach(chart) + + // Undivided, choosing "Every 5s" in the rate chooser overstated throughput fivefold. + assertThat(dataset(chart, 0).label).endsWith("2.0 kB/s") + } + + @Test + fun `the legend reports the latest sample in byte units`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 2_000L))) + + // Rendered from the raw byte count, not from the logarithm, and in decimal units so that + // the log10 axis labels come out as clean decades. + assertThat(dataset(chart, 0).label).endsWith("2.0 kB/s") + } + + @Test + fun `onUsageChanged updates entries in place without replacing the datasets`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 9L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + val datasetBefore = dataset(chart, 0) + val entryBefore = datasetBefore.entries.last() + + current = usage(longArrayOf(0L, 999L)) + renderer.onUsageChanged(current) + + assertThat(dataset(chart, 0)).isSameInstanceAs(datasetBefore) + assertThat(datasetBefore.entries.last()).isSameInstanceAs(entryBefore) + assertThat(entryBefore.y).isEqualTo(3f) + } + + @Test + fun `onUsageChanged rebuilds when the sample count changes`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 9L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + assertThat(dataset(chart, 0).entryCount).isEqualTo(2) + + current = usage(longArrayOf(0L, 9L, 99L)) + renderer.onUsageChanged(current) + + assertThat(dataset(chart, 0).entryCount).isEqualTo(3) + } + + @Test + fun `attach after detach renders the history into the new chart`() { + val current = usage(longArrayOf(0L, 99L)) + val (renderer, _) = rendererFor(current) + renderer.detach() + + val rebound = SafeLineChart(context) + renderer.attach(rebound) + + assertThat(dataset(rebound, 0).entries.last().y).isEqualTo(2f) + } + + @Test + fun `axis labels are whole units with no decimal place`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 10_000_000L))) + val formatter = chart.axisRight.valueFormatter + + // Gridlines sit on whole decades, so the mantissa is exact. + assertThat(formatter.getFormattedValue(0f, chart.axisRight)).isEqualTo("0 B") + assertThat(formatter.getFormattedValue(1f, chart.axisRight)).isEqualTo("10 B") + assertThat(formatter.getFormattedValue(3f, chart.axisRight)).isEqualTo("1 kB") + assertThat(formatter.getFormattedValue(4f, chart.axisRight)).isEqualTo("10 kB") + assertThat(formatter.getFormattedValue(6f, chart.axisRight)).isEqualTo("1 MB") + } + + @Test + fun `the series are scaled against the labelled axis`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 100L))) + + // The right axis is the one carrying the labels and the pinned range. A dataset left on the + // default LEFT dependency is drawn against the auto-ranged left axis, so the line lands + // somewhere the labels do not describe -- which is invisible to an assertion on the axis + // alone, and was only caught on a device. + assertThat(dataset(chart, 0).axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + assertThat(dataset(chart, 1).axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + } + + @Test + fun `an idle chart keeps zero on the baseline`() { + // Every sample zero. Left to itself the chart pads around a degenerate range and floats the + // flat line up the middle of the plot instead of resting it on the axis minimum. + val (_, chart) = rendererFor(usage(LongArray(30) { 0L })) + + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + assertThat(chart.axisRight.axisMaximum).isEqualTo(3f) + } + + @Test + fun `the axis grows to whole decades around the peak`() { + // 2 MB peak -> log10 is ~6.3, so the axis tops out at the 10 MB decade. + val (_, chart) = rendererFor(usage(longArrayOf(0L, 2_000_000L))) + + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + assertThat(chart.axisRight.axisMaximum).isEqualTo(7f) + } + + @Test + fun `the axis follows the peak across both series`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 100L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + assertThat(chart.axisRight.axisMaximum).isEqualTo(3f) + + // A burst on the transmitted series alone must still lift the axis. + current = usage(received = longArrayOf(0L, 100L), transmitted = longArrayOf(0L, 500_000L)) + renderer.onUsageChanged(current) + + assertThat(chart.axisRight.axisMaximum).isEqualTo(6f) + } + + @Test + fun `onUsageChanged after detach is a no-op`() { + val current = usage(longArrayOf(0L, 99L)) + val (renderer, _) = rendererFor(current) + renderer.detach() + + // A recycled carousel page must not keep the renderer writing into a dead view. + renderer.onUsageChanged(current) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt new file mode 100644 index 0000000000..1771a8ebf0 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/PowerUsageChartRendererTest.kt @@ -0,0 +1,465 @@ +/* + * 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() + + // No sample times, for the reason NetworkUsageChartRendererTest gives: a chart test says so + // rather than letting a default say it. + private fun usage( + temperature: LongArray, + power: LongArray = LongArray(temperature.size), + thermal: LongArray = LongArray(temperature.size), + ) = PowerUsageWatcher.PowerUsage(temperature, power, thermal, LongArray(temperature.size)) + + 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.layOutAndDraw() + + @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 `the battery readout gets room again on a replacement chart`() { + val (renderer, first) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) + laidOut(first) + renderer.reserveTopSpace(READOUT_HEIGHT_PX) + laidOut(first) + + // Undocking recycles the strip, so the same renderer is handed a brand new chart that asks + // for the same inset. The reservation is memoised per chart: carried across the detach, the + // early return meant the replacement never got setExtraTopOffset at all -- and nothing else + // applies it, unlike the text scale, which setData re-applies on every rebuild. + val second = SafeLineChart(context) + renderer.attach(second) + laidOut(second) + val unreserved = second.viewPortHandler.contentTop() + + renderer.reserveTopSpace(READOUT_HEIGHT_PX) + laidOut(second) + assertThat(second.viewPortHandler.contentTop()).isGreaterThan(unreserved) + } + + @Test + fun `only one axis rules the plot`() { + val (_, chart) = rendererFor(usage(temperature = LongArray(SAMPLES) { 30_000L })) + 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 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..396bbc2147 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/DevicePowerEnvelopeTest.kt @@ -0,0 +1,94 @@ +/* + * 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 corrected, not dropped`() { + // 5W at 4V is 1.25A; a milliamp kernel reports 1250 where microamps would say 1_250_000, + // so the product comes out a thousand times small. This used to answer UNAVAILABLE, which + // identified the misreport and then discarded the sample. + assertThat(source.microWattsOrUnavailable(microAmps = 1_250, milliVolts = 4_000)) + .isEqualTo(5_000_000L) + } + + @Test + fun `the Galaxy Note 20 Ultra's own reading becomes a number rather than n slash a`() { + // Measured on the device: CURRENT_NOW 318 at 3807mV, with the editor open after a build. + // Taken at face value that is 1,210 microwatts -- 1.2mW for a phone running an IDE -- and + // being below the floor it was dropped, so the Power series read "n/a" on every sample for + // the life of the session while temperature plotted normally. + val microWatts = source.microWattsOrUnavailable(microAmps = 318, milliVolts = 3_807) + + assertThat(microWatts).isNotEqualTo(PowerUsageWatcher.UNAVAILABLE) + assertThat(microWatts).isEqualTo(1_210_626L) + } + + @Test + fun `a discharging misreport keeps its sign through the correction`() { + // The same device discharging: CURRENT_NOW -496 at 3731mV, i.e. 1.85W leaving the battery. + assertThat(source.microWattsOrUnavailable(microAmps = -496, milliVolts = 3_731)) + .isEqualTo(-1_850_576L) + } + + @Test + 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/IntentUtilsShareTest.kt b/app/src/test/java/com/itsaky/androidide/utils/IntentUtilsShareTest.kt new file mode 100644 index 0000000000..7df352cfa5 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/IntentUtilsShareTest.kt @@ -0,0 +1,76 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.app.Application +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.io.File + +/** + * Which flags reach the intent that is actually started (ADFA-5486). + * + * The metrics carousel shares a chart image, and while it is floating it does so from a window + * context with no task of its own -- where startActivity needs FLAG_ACTIVITY_NEW_TASK. The flag + * was added to the send intent, but `Intent.createChooser` copies only the URI-grant flags + * outwards and the chooser is what gets started, so the flag never reached the intent that needed + * it and the share threw. + * + * The mirror case -- that a share from an activity is left alone, with no NEW_TASK added -- is not + * covered here. Robolectric routes Activity.startActivity down to ContextImpl, which applies the + * "outside of an Activity context" check regardless, so the assertion would fail for reasons that + * have nothing to do with this code. + */ +@RunWith(RobolectricTestRunner::class) +class IntentUtilsShareTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun file(): File = + File(context.cacheDir, "chart.png").apply { + parentFile?.mkdirs() + writeBytes(byteArrayOf(1, 2, 3)) + } + + private fun lastStarted(): Intent? = shadowOf(context as Application).nextStartedActivity + + @Test + fun `the started chooser carries the extra flags it was given`() { + IntentUtils.shareFile(context, file(), "image/png", Intent.FLAG_ACTIVITY_NEW_TASK) + + val started = lastStarted() + assertThat(started).isNotNull() + assertThat(started!!.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isNotEqualTo(0) + } + + @Test + fun `the wrapped send intent still grants read access to the image`() { + IntentUtils.shareFile(context, file(), "image/png", Intent.FLAG_ACTIVITY_NEW_TASK) + + @Suppress("DEPRECATION") + val inner = lastStarted()!!.getParcelableExtra(Intent.EXTRA_INTENT) + assertThat(inner).isNotNull() + assertThat(inner!!.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION).isNotEqualTo(0) + assertThat(inner.type).isEqualTo("image/png") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherIntervalTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherIntervalTest.kt new file mode 100644 index 0000000000..32828d706c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherIntervalTest.kt @@ -0,0 +1,87 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Pins that the sampling loop honours [MemoryUsageWatcher]'s configured interval. + * + * The loop used to `delay(1000)` regardless of the constructor argument, so the interval was fixed + * at one second whatever a caller asked for -- the "sample time is fixed" of ADFA-5486, in the code + * rather than only in the UI. + * + * Sampling runs on an injected test dispatcher, so these advance virtual time and never wait on a + * real clock. No process is watched, so a sample does no work and only the interval governs the + * rate. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MemoryUsageWatcherIntervalTest { + @Test + fun `the sampling rate follows the configured interval`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(1_000L) + watcher.stopWatching() + + // One second of virtual time at 100ms. The hardcoded one-second delay this replaced + // would have produced one sample regardless of the interval asked for. + assertThat(samples).isAtLeast(9) + assertThat(samples).isAtMost(11) + } + + @Test + fun `a longer interval samples proportionally less often`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + + val watcher = + MemoryUsageWatcher( + updateInterval = 500L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(1_000L) + watcher.stopWatching() + + // Five times the interval, so a fifth of the samples. With the interval ignored this + // was indistinguishable from the 100ms case. + assertThat(samples).isAtLeast(1) + assertThat(samples).isAtMost(3) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherReaderFallbackTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherReaderFallbackTest.kt new file mode 100644 index 0000000000..e4a602640a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherReaderFallbackTest.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 com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What the sampler does when the cheap read fails (ADFA-5574). + * + * A rollup can be unreadable for reasons that are not the kernel's capability -- the process exited + * between being listed and being read, most likely. The sampler must not lose the series over it, + * and must not pay for the failure once a second for the rest of the session. + */ +@RunWith(RobolectricTestRunner::class) +class MemoryUsageWatcherReaderFallbackTest { + private var clock = 1_700_000_000_000L + + @Test + fun `a read that fails latches the process onto the reflective one`() { + var cheapAttempts = 0 + val alwaysUnavailable = + ProcessMemoryReader { _, _ -> + cheapAttempts++ + ProcessMemoryReaders.UNAVAILABLE + } + val watcher = + MemoryUsageWatcher( + nowMillis = { + clock += TICK_MILLIS + clock + }, + readerFor = { alwaysUnavailable }, + ).apply { + // An invented pid: without this the liveness guard (ADFA-5514) short-circuits before + // the reader is consulted, and the fallback this case exists for never runs. + isProcessAlive = { true } + } + watcher.watchProcess(PID, "IDE") + + watcher.readUsages() + watcher.readUsages() + + val proc = checkNotNull(watcher.getMemoryUsage(PID)) + assertThat(proc.reader).isSameInstanceAs(DebugMemoryInfoReader) + + // Once, not once per sample. The latch is the point: without it the sampler would try the + // unreadable file every second and take the failure path every time. + assertThat(cheapAttempts).isEqualTo(1) + } + + private companion object { + const val PID = 4242 + + const val TICK_MILLIS = 1_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt new file mode 100644 index 0000000000..2323cd79a1 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherSampleAlignmentTest.kt @@ -0,0 +1,147 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * That a row of the exported metrics file is one moment (ADFA-5531). + * + * The exported file states a time per row, and every value on that row has to be the one recorded + * at it. The sampler runs on its own thread and the export reads from the UI thread, so the only + * thing making that true is where the sampler's appends happen and how many calls the reader makes. + */ +@RunWith(RobolectricTestRunner::class) +class MemoryUsageWatcherSampleAlignmentTest { + private var clock = 1_700_000_000_000L + + private fun watcher(readPssKb: (Int, android.os.Debug.MemoryInfo) -> Int = { _, _ -> PSS_KB }) = + MemoryUsageWatcher( + nowMillis = { + clock += TICK_MILLIS + clock + }, + // The seam is a factory now (ADFA-5574): which read is correct depends on the process. + // These cases are about when values are appended, not how they are obtained, so every + // process gets the same stub. + readerFor = { ProcessMemoryReader { pid, scratch -> readPssKb(pid, scratch) } }, + ).apply { + // These pids are invented, so /proc has nothing for them and the liveness guard added + // with the daemon plot (ADFA-5514) would read every one of them as gone and sample a + // zero. These cases are about when values are appended, not about liveness. + isProcessAlive = { true } + } + + @Test + fun `a read taken during a sample sees times and values that agree`() { + lateinit var watcher: MemoryUsageWatcher + var midSample: MemoryUsageWatcher.MemoryHistory? = null + + // Read from inside the sample, which is the interleaving the sampling thread and the UI + // thread can produce for real. Appending the time and the values in separate critical + // sections left this window: the reader caught a buffer with one more timestamp in it than + // values, so every value in the exported file sat on the row below its own timestamp. + watcher = + watcher { _, _ -> + if (midSample == null) { + midSample = watcher.history() + } + PSS_KB + } + watcher.watchProcess(PID, "IDE") + + watcher.readUsages() + watcher.readUsages() + + val history = checkNotNull(midSample) + val stamped = history.times.count { it != MetricsCsv.NO_SAMPLE } + val measured = + history.processes + .single() + .usage + .count { it != 0L } + assertThat(measured).isEqualTo(stamped) + } + + @Test + fun `a completed sample stamps every process with the same time`() { + val watcher = watcher() + watcher.watchProcess(PID, "IDE") + watcher.watchProcess(OTHER_PID, "Gradle Daemon") + + watcher.readUsages() + + // The two processes are read one after the other, but they belong to one row, so they share + // its time -- and each has exactly one value against it. + val history = watcher.history() + assertThat(history.times.count { it != MetricsCsv.NO_SAMPLE }).isEqualTo(1) + history.processes.forEach { process -> + assertThat(process.usage.count { it != 0L }).isEqualTo(1) + } + } + + @Test + fun `the times come back with the values, not from a call of their own`() { + val watcher = watcher() + watcher.watchProcess(PID, "IDE") + watcher.readUsages() + + // The guard on the fix above: one accessor, so a caller cannot reintroduce the window by + // asking for the halves separately. There is deliberately no times-only accessor. + val history = watcher.history() + assertThat(history.times).hasLength(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(history.processes.single().usage).hasLength(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(history.times.last()).isNotEqualTo(MetricsCsv.NO_SAMPLE) + assertThat( + history.processes + .single() + .usage + .last(), + ).isEqualTo(PSS_KB * 1024L) + } + + @Test + fun `a copied process still says when it started being watched`() { + val watcher = watcher() + watcher.watchProcess(PID, "Gradle Daemon") + watcher.readUsages() + + // getMemoryUsages hands out copies, and the copy used to drop watchedSinceMillis -- which + // defaults to 0, i.e. "watched since the epoch". The export's guard for a process's + // zero-filled past then never fired, so the daemon's buffer from before the daemon existed + // came out as measured zeros rather than empty cells (ADFA-5531). + val copied = watcher.getMemoryUsages().single() + assertThat(copied.watchedSinceMillis).isNotEqualTo(0L) + assertThat(copied.watchedSinceMillis).isEqualTo(watcher.getMemoryUsage(PID)!!.watchedSinceMillis) + } + + private companion object { + const val PID = 4242 + + const val OTHER_PID = 4243 + + /** Any non-zero reading; the test counts measured samples rather than reading values. */ + const val PSS_KB = 512 + + /** Enough that no two sample times collide. */ + const val TICK_MILLIS = 1_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt new file mode 100644 index 0000000000..26821a9256 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt @@ -0,0 +1,253 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Pins the annotation throttle of ADFA-5486: significant events are Gradle task starts and stops, + * and there are far too many of them to draw, so at most one every five seconds is kept. + */ +class MetricsAnnotationStoreTest { + private var now = 1_000L + private val store = MetricsAnnotationStore(nowMillis = { now }) + + @Test + fun `the first event is always recorded`() { + assertThat(store.record(":app:compileKotlin")).isTrue() + assertThat(store.recentAnnotations(60_000L)).hasSize(1) + } + + @Test + fun `events inside the throttle window are dropped`() { + store.record("first") + now += 1_000L + assertThat(store.record("second")).isFalse() + now += 3_000L + assertThat(store.record("third")).isFalse() + + // A real build emits dozens of these a second; only the first survives. + val labels = store.recentAnnotations(60_000L).map { it.label } + assertThat(labels).containsExactly("first") + } + + @Test + fun `an event after the window is recorded`() { + store.record("first") + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + + assertThat(store.record("second")).isTrue() + assertThat(store.recentAnnotations(60_000L).map { it.label }) + .containsExactly("first", "second") + .inOrder() + } + + @Test + fun `the first event of a quiet period is the one kept`() { + // The interesting moment is when work began, not one from the middle of a burst. + store.record("burst start") + repeat(20) { + now += 100L + store.record("noise") + } + + assertThat(store.recentAnnotations(60_000L).map { it.label }).containsExactly("burst start") + } + + @Test + fun `only annotations within the requested age are returned`() { + store.record("old") + now += 30_000L + store.record("recent") + + assertThat(store.recentAnnotations(10_000L).map { it.label }).containsExactly("recent") + assertThat(store.recentAnnotations(60_000L).map { it.label }).containsExactly("old", "recent").inOrder() + } + + @Test + fun `the store is bounded`() { + repeat(MetricsAnnotationStore.MAX_ANNOTATIONS * 2) { + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("task $it") + } + + val all = store.recentAnnotations(Long.MAX_VALUE / 2) + assertThat(all).hasSize(MetricsAnnotationStore.MAX_ANNOTATIONS) + // The oldest are the ones dropped. + assertThat(all.last().label).endsWith( + (MetricsAnnotationStore.MAX_ANNOTATIONS * 2 - 1).toString(), + ) + } + + @Test + fun `clearing forgets the throttle as well as the annotations`() { + store.record("first") + store.clear() + + assertThat(store.recentAnnotations(60_000L)).isEmpty() + // Without resetting the throttle, the next event would be swallowed for five seconds. + assertThat(store.record("second")).isTrue() + } + + @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) + } + + @Test + fun `a build outcome is kept even inside the throttle window`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("some task") + // Well inside the window that drops a task marker. + now += 1_000L + store.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + + // Dropped, this would be the one annotation on the chart worth having. + assertThat(store.recentAnnotations(60_000L).map { it.label }) + .containsExactly("some task", "Build failed") + .inOrder() + } + + @Test + fun `a task marker inside the window is still dropped`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("first task") + now += 1_000L + store.record("second task") + + // Guards the test above: the bypass must be for build outcomes only. + assertThat(store.recentAnnotations(60_000L).map { it.label }).containsExactly("first task") + } + + @Test + fun `a build outcome restarts the throttle window`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("Build started", MetricsAnnotationStore.Kind.BUILD_STARTED) + now += 1_000L + store.record("a task right behind it") + + // Otherwise the first task marker lands a few pixels from the build marker and collides. + assertThat(store.recentAnnotations(60_000L).map { it.label }).containsExactly("Build started") + } + + @Test + fun `the kind survives to the reader`() { + val store = MetricsAnnotationStore(nowMillis = { now }) + + store.record("Build started", MetricsAnnotationStore.Kind.BUILD_STARTED) + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("Build failed", MetricsAnnotationStore.Kind.BUILD_FAILED) + + // The renderer colours by kind, so it has to arrive intact. + assertThat(store.recentAnnotations(60_000L).map { it.kind }) + .containsExactly( + MetricsAnnotationStore.Kind.BUILD_STARTED, + MetricsAnnotationStore.Kind.BUILD_FAILED, + ).inOrder() + } + + @Test + fun `a build outcome inside the throttle window is still recorded`() { + // Asserting isThrottled against its own definition, as this test used to, would pass just + // as happily with record() ignoring the flag altogether. + store.record("a task") + now += 1_000L + + MetricsAnnotationStore.Kind.entries + .filterNot { it == MetricsAnnotationStore.Kind.TASK } + .forEach { kind -> + assertThat(store.recordBuild(kind)).isTrue() + now += 1_000L + } + + // One task marker, then every build outcome, none of them dropped. + assertThat(store.recentAnnotations(60_000L).map { it.kind }) + .containsExactlyElementsIn( + listOf(MetricsAnnotationStore.Kind.TASK) + + MetricsAnnotationStore.Kind.entries.filterNot { it == MetricsAnnotationStore.Kind.TASK }, + ).inOrder() + } + + @Test + fun `a full store evicts task markers before build outcomes`() { + store.recordBuild(MetricsAnnotationStore.Kind.BUILD_STARTED) + // Enough task markers to overflow the store several times over. A build long enough to do + // that -- about twenty minutes at one marker every five seconds -- used to lose its own + // "Build started", leaving an unpaired outcome and no way to see how long it took. + repeat(MetricsAnnotationStore.MAX_ANNOTATIONS * 2) { + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("task $it") + } + store.recordBuild(MetricsAnnotationStore.Kind.BUILD_FINISHED) + + val kinds = store.recentAnnotations(Long.MAX_VALUE / 2).map { it.kind } + assertThat(kinds.first()).isEqualTo(MetricsAnnotationStore.Kind.BUILD_STARTED) + assertThat(kinds.last()).isEqualTo(MetricsAnnotationStore.Kind.BUILD_FINISHED) + assertThat(kinds).hasSize(MetricsAnnotationStore.MAX_ANNOTATIONS) + } + + @Test + fun `a store holding nothing but build outcomes still respects its bound`() { + // The fallback branch: with no task marker left to sacrifice, the oldest outcome goes. + repeat(MetricsAnnotationStore.MAX_ANNOTATIONS + 5) { + now += 1_000L + store.recordBuild(MetricsAnnotationStore.Kind.BUILD_FINISHED) + } + + assertThat(store.recentAnnotations(Long.MAX_VALUE / 2)) + .hasSize(MetricsAnnotationStore.MAX_ANNOTATIONS) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt new file mode 100644 index 0000000000..8c420e95e6 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvFileTest.kt @@ -0,0 +1,184 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.time.ZoneId +import java.util.zip.GZIPInputStream +import kotlin.random.Random + +/** + * The two files the metrics format is written to: the user's export, and the compressed copy that + * travels with a report (ADFA-5534). + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCsvFileTest { + private val context = ApplicationProvider.getApplicationContext() + + /** + * Fixed, not the machine's own. + * + * The names below are a rendering of [AT] in a particular zone, so leaving the zone to the + * default made them pass here and fail wherever CI happens to be. + */ + private val zone: ZoneId = ZoneId.of("America/Los_Angeles") + + private fun snapshot(rows: Int): MetricsCsv.Snapshot { + val times = LongArray(rows) { AT + it * 1_000L } + return MetricsCsv.Snapshot( + rowTimes = times, + sampleIntervalMillis = INTERVAL_MS, + memory = mapOf("IDE" to MetricsCsv.Series(times, LongArray(rows) { 600_000_000L + it })), + ) + } + + /** + * A session whose columns move the way a device's do, rather than climbing by one per row. + * + * Seeded, so the sizes above are the same on every run and in CI. + */ + private fun noisySnapshot(rows: Int): MetricsCsv.Snapshot { + val random = Random(20260907L) + val times = LongArray(rows) { AT + it * 1_000L + random.nextInt(80) } + + fun series(next: () -> Long) = MetricsCsv.Series(times, LongArray(rows) { next() }) + var ide = 600_000_000L + var daemon = 780_000_000L + var celsius = 32_000L + return MetricsCsv.Snapshot( + rowTimes = times, + sampleIntervalMillis = INTERVAL_MS, + memory = + mapOf( + "IDE" to series { (ide + random.nextInt(-6_000_000, 6_000_000)).also { ide = it } }, + "Gradle Daemon" to series { (daemon + random.nextInt(-40_000_000, 40_000_000)).also { daemon = it } }, + ), + // Bursty: mostly idle, occasionally a download. + networkReceived = series { if (random.nextInt(6) == 0) random.nextLong(2_000_000) else random.nextLong(4_000) }, + networkTransmitted = series { if (random.nextInt(8) == 0) random.nextLong(300_000) else random.nextLong(1_500) }, + temperature = series { (celsius + random.nextInt(-300, 300)).also { celsius = it } }, + power = series { 1_200_000L + random.nextLong(3_500_000) }, + thermal = series { if (random.nextInt(10) == 0) random.nextLong(4) else 0L }, + ) + } + + @Test + fun `an export is plain csv the user can open`() { + val file = MetricsCsvFile.write(context, snapshot(3), AT, zone)!! + + assertThat(file.name).isEqualTo("2026_09_06_22_33_40_123.csv") + assertThat(file.readText().lineSequence().first()).startsWith("\"timestamp\"") + } + + @Test + fun `a report copy is gzipped, and unzips to the same csv`() { + val plain = MetricsCsvFile.write(context, snapshot(50), AT, zone)!!.readText() + val compressed = MetricsCsvFile.writeForReport(context, snapshot(50), AT, zone)!! + + assertThat(compressed.name).isEqualTo("2026_09_06_22_33_40_123.csv.gz") + val unzipped = GZIPInputStream(compressed.inputStream()).bufferedReader().use { it.readText() } + assertThat(unzipped).isEqualTo(plain) + } + + @Test + fun `compressing is worth doing on a session that is not a straight line`() { + // Against noise, not against [snapshot]'s ramp. A file whose every column advances by a + // constant compresses about fifty-fold, so a bound met by that says nothing about a real + // session -- and this test exists to notice if the extra step ever stops earning its place. + // Every column here moves the way its metric does on a device: memory in steps of megabytes, + // network in bursts, temperature and power drifting, thermal status flipping. + // + // This session is deliberately noisier than a real one -- uniformly random power draw and + // network bursts, where a device gives smooth drifts -- so what it achieves is a floor, not + // an estimate: 40896 -> 14722 bytes, 2.8x, against 4.6x measured on a real 86-row + // attachment on a Pixel 6 Pro. Halving is the bound, which compression bypassed fails and + // a shift in gzip's tuning does not. + val plain = MetricsCsvFile.write(context, noisySnapshot(500), AT, zone)!!.length() + val compressed = MetricsCsvFile.writeForReport(context, noisySnapshot(500), AT, zone)!!.length() + + assertThat(compressed).isLessThan(plain / 2) + } + + @Test + fun `a report copy does not evict the user's exports`() { + // They prune independently. A feedback send must not delete an export the user is part-way + // through handing to another app. + val export = MetricsCsvFile.write(context, snapshot(2), AT, zone)!! + repeat(MetricsCsvFile.KEEP_RECENT + 3) { i -> + MetricsCsvFile.writeForReport(context, snapshot(2), AT + i + 1L, zone) + } + + assertThat(export.exists()).isTrue() + assertThat(export.parentFile).isNotEqualTo( + MetricsCsvFile.writeForReport(context, snapshot(2), AT + 99L, zone)!!.parentFile, + ) + } + + @Test + fun `both directories stay bounded`() { + repeat(20) { i -> MetricsCsvFile.write(context, snapshot(2), AT + i.toLong(), zone) } + repeat(20) { i -> MetricsCsvFile.writeForReport(context, snapshot(2), AT + i.toLong(), zone) } + + val exports = MetricsCsvFile.write(context, snapshot(2), AT + 500L, zone)!!.parentFile!! + val reports = MetricsCsvFile.writeForReport(context, snapshot(2), AT + 500L, zone)!!.parentFile!! + assertThat(exports.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) + assertThat(reports.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) + } + + @Test + fun `the limit holds when the file just written is not the newest on disk`() { + // Pruning used to pick "the oldest n" across every file and then skip the one just written, + // which deleted one too few whenever that one sorted into the set -- and the directory crept + // one over the limit each time. Two writes inside a single filesystem timestamp are enough + // to sort it there. + // + // Dating the existing files into the future is what puts the new one at the front of the + // sort deterministically. Tying them all to one *past* value does not: the file written last + // still carries a real mtime, so it sorts last, is never in the set, and the skip never + // fires -- which is how the first version of this test passed against the unfixed code. + val future = System.currentTimeMillis() + 1_000_000L + repeat(MetricsCsvFile.KEEP_RECENT + 3) { i -> + MetricsCsvFile.write(context, snapshot(1), AT + i, zone)!!.setLastModified(future) + } + + val directory = MetricsCsvFile.write(context, snapshot(1), AT + 900L, zone)!!.parentFile!! + + assertThat(directory.listFiles()!!.size).isAtMost(MetricsCsvFile.KEEP_RECENT) + } + + @Test + fun `files land under the cache, which the platform may reclaim`() { + val file: File = MetricsCsvFile.writeForReport(context, snapshot(2), AT, zone)!! + + assertThat(file.absolutePath).startsWith(context.cacheDir.absolutePath) + } + + private companion object { + /** 2026-09-06T22:33:40.123 local. */ + const val AT = 1_788_759_220_123L + + /** The gap between the rows these fixtures build. */ + const val INTERVAL_MS = 1_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt new file mode 100644 index 0000000000..5880a8488f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsCsvTest.kt @@ -0,0 +1,426 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.time.ZoneId + +/** + * The canonical metrics file format (ADFA-5531). + * + * Pinned closely because it is not this ticket's file alone: ADFA-5494 reads it back to restore the + * chart history, and ADFA-5526 and ADFA-5534 attach copies to crash reports and to feedback. A + * column that quietly changes shape breaks a consumer that is not in front of you. + */ +@RunWith(JUnit4::class) +class MetricsCsvTest { + private val zone: ZoneId = ZoneId.of("America/Los_Angeles") + + private fun render(snapshot: MetricsCsv.Snapshot): List = + StringBuilder() + .also { MetricsCsv.write(snapshot, zone, it) } + .toString() + .trimEnd('\n') + .split('\n') + + private fun snapshot( + rowTimes: LongArray = longArrayOf(T0, T0 + 1_000L), + sampleIntervalMillis: Long = INTERVAL_MS, + memory: Map = emptyMap(), + networkReceived: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + networkTransmitted: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + temperature: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + power: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + thermal: MetricsCsv.Series = MetricsCsv.Series.EMPTY, + annotations: List = emptyList(), + ) = MetricsCsv.Snapshot( + rowTimes = rowTimes, + sampleIntervalMillis = sampleIntervalMillis, + memory = memory, + networkReceived = networkReceived, + networkTransmitted = networkTransmitted, + temperature = temperature, + power = power, + thermal = thermal, + annotations = annotations, + ) + + private fun series( + times: LongArray, + values: LongArray, + since: Long = 0L, + ) = MetricsCsv.Series(times, values, since) + + @Test + fun `an export with nothing sampled is a header and no rows`() { + // The empty case is not exotic: changing the sampling rate clears every buffer, so the very + // next export has nothing to say. It still produces a file. + val lines = render(snapshot(rowTimes = LongArray(4))) + + assertThat(lines).hasSize(1) + assertThat(lines.single()).isEqualTo(EXPECTED_HEADER) + } + + @Test + fun `a row's time is the one recorded for that sample`() { + val lines = render(snapshot(rowTimes = longArrayOf(T0))) + + // Read back, not reconstructed from an index and an interval: 2026-09-06T22:33:40.123 in + // Los Angeles, with the offset that says which 22:33 it was. + assertThat(lines[1]).startsWith("\"2026-09-06T22:33:40.123-07:00\"") + } + + @Test + fun `the fraction is always three digits, even when it ends in zero`() { + // ISO_OFFSET_DATE_TIME drops trailing zeros and would write ".38" here, giving a column of + // varying width. Both parse; only one lines up with the milliseconds the filename carries. + val lines = render(snapshot(rowTimes = longArrayOf(T0 - 43L))) + + assertThat(lines[1]).startsWith("\"2026-09-06T22:33:40.080-07:00\"") + } + + @Test + fun `an index nothing was sampled at is not a row`() { + // The buffers are fixed-length and start full of zeros, so most of a young session's buffer + // has never been written. Those are absent rows, not rows of zeros. + val lines = render(snapshot(rowTimes = longArrayOf(0L, 0L, T0, 0L, T0 + 1_000L))) + + assertThat(lines).hasSize(3) + assertThat(lines[1]).contains("22:33:40.123") + assertThat(lines[2]).contains("22:33:41.123") + } + + @Test + fun `every row has as many cells as the header`() { + val times = longArrayOf(T0, T0 + 1_000L) + val lines = + render( + snapshot( + rowTimes = times, + memory = mapOf("IDE" to series(times, longArrayOf(1L, 2L))), + networkReceived = series(times, longArrayOf(3L, 4L)), + annotations = listOf(MetricsCsv.Marker(T0, "assemble", "TASK")), + ), + ) + + lines.forEach { line -> + assertThat(cellsIn(line)).hasSize(MetricsCsv.HEADER.size) + } + } + + @Test + fun `a process that was not being watched yet leaves the cell empty, not zero`() { + val times = longArrayOf(T0, T0 + 1_000L) + val lines = + render( + snapshot( + rowTimes = times, + // The Gradle daemon appears when a build starts, and its buffer is zero-filled + // back to the beginning of the session (ADFA-5514). Reporting those zeros as + // measurements would say the daemon was running and using nothing. + memory = mapOf("Gradle Daemon" to series(times, longArrayOf(0L, 900L), since = T0 + 1_000L)), + ), + ) + + val daemon = MetricsCsv.HEADER.indexOf("gradle_daemon_pss_bytes") + assertThat(cellsIn(lines[1])[daemon]).isEmpty() + assertThat(cellsIn(lines[2])[daemon]).isEqualTo("900") + } + + @Test + fun `a series that never recorded leaves its columns empty`() { + val times = longArrayOf(T0, T0 + 1_000L) + val lines = + render( + snapshot( + rowTimes = times, + memory = mapOf("IDE" to series(times, longArrayOf(5L, 6L))), + // A device whose traffic counters are unsupported never records a sample, and + // zero bytes transferred is a different statement from no measurement. + networkReceived = MetricsCsv.Series.EMPTY, + ), + ) + + val rx = MetricsCsv.HEADER.indexOf("net_rx_bytes") + assertThat(cellsIn(lines[1])[rx]).isEmpty() + } + + @Test + fun `strings are quoted, numbers are not, and a quote inside one is doubled`() { + val times = longArrayOf(T0) + val lines = + render( + snapshot( + rowTimes = times, + memory = mapOf("IDE" to series(times, longArrayOf(7L))), + annotations = listOf(MetricsCsv.Marker(T0, ":app:say \"hi\"", "TASK")), + ), + ) + + val cells = cellsIn(lines[1]) + assertThat(cells[MetricsCsv.HEADER.indexOf("ide_pss_bytes")]).isEqualTo("7") + assertThat(cells[MetricsCsv.HEADER.indexOf("annotation")]).isEqualTo("\":app:say \"\"hi\"\"\"") + assertThat(cells[MetricsCsv.HEADER.indexOf("annotation_kind")]).isEqualTo("\"TASK\"") + } + + @Test + fun `a cell a spreadsheet would run as a formula is prefixed`() { + // The annotation columns carry Gradle task names read from the user's own build script, and + // this file is attached to crash reports (ADFA-5526) and feedback (ADFA-5534) that someone + // opens. Quoting alone does not stop the evaluation; the apostrophe does. + val column = MetricsCsv.HEADER.indexOf("annotation") + listOf("=1+1", "+1", "-1", "@SUM(A1)", "\tlater", "\rlater").forEach { label -> + val lines = + render( + snapshot( + rowTimes = longArrayOf(T0), + annotations = listOf(MetricsCsv.Marker(T0, label, "TASK")), + ), + ) + + assertThat(cellsIn(lines[1])[column]).isEqualTo("\"'" + label + "\"") + } + } + + @Test + fun `an ordinary label is not prefixed`() { + // The guard has to be narrow: prefixing every cell would put an apostrophe in front of every + // task name a reader sees. + val lines = + render( + snapshot( + rowTimes = longArrayOf(T0), + annotations = listOf(MetricsCsv.Marker(T0, ":app:assembleV8Debug", "TASK")), + ), + ) + + assertThat(cellsIn(lines[1])[MetricsCsv.HEADER.indexOf("annotation")]) + .isEqualTo("\":app:assembleV8Debug\"") + } + + @Test + fun `an annotation further than one interval from every row is dropped`() { + // An annotation older than the buffer reaches is the ordinary case in a long session: the + // store keeps its own history and the ring buffer has already rolled past it. + val lines = + render( + snapshot( + rowTimes = longArrayOf(T0, T0 + INTERVAL_MS), + annotations = listOf(MetricsCsv.Marker(T0 - 60_000L, "Build started", "BUILD_STARTED")), + ), + ) + + val column = MetricsCsv.HEADER.indexOf("annotation") + assertThat(lines.drop(1).map { cellsIn(it)[column] }).containsExactly("", "") + } + + @Test + fun `a stale annotation does not take the first row from a real one`() { + // Both markers' nearest row is the first one. Sorted by time the stale one comes first, so + // without the cap it took the row and putIfAbsent then dropped the marker that actually + // belongs there -- the file gained an ancient annotation on its oldest row and lost a real + // one, with nothing to say either had happened. + val lines = + render( + snapshot( + rowTimes = longArrayOf(T0, T0 + INTERVAL_MS), + annotations = + listOf( + MetricsCsv.Marker(T0 - 60 * 60 * 1000L, "stale", "TASK"), + MetricsCsv.Marker(T0 + 10L, "real", "TASK"), + ), + ), + ) + + assertThat(cellsIn(lines[1])[MetricsCsv.HEADER.indexOf("annotation")]).isEqualTo("\"real\"") + } + + @Test + fun `an annotation lands on the sample nearest in time, not only an exact match`() { + val times = longArrayOf(T0, T0 + 1_000L, T0 + 2_000L) + val lines = + render( + snapshot( + rowTimes = times, + // Recorded when the build started, which is between two samples. Requiring an + // exact match would drop nearly every marker in the file. + annotations = listOf(MetricsCsv.Marker(T0 + 1_600L, "Build started", "BUILD_STARTED")), + ), + ) + + val column = MetricsCsv.HEADER.indexOf("annotation") + assertThat(cellsIn(lines[1])[column]).isEmpty() + assertThat(cellsIn(lines[2])[column]).isEmpty() + assertThat(cellsIn(lines[3])[column]).isEqualTo("\"Build started\"") + } + + @Test + fun `two annotations falling on one sample keep the earlier one`() { + val times = longArrayOf(T0) + val lines = + render( + snapshot( + rowTimes = times, + annotations = + listOf( + MetricsCsv.Marker(T0 + 40L, "second", "TASK"), + MetricsCsv.Marker(T0 + 10L, "first", "TASK"), + ), + ), + ) + + // One annotation column per row by definition, so the loser is dropped rather than + // overwriting the winner or being appended into the same cell. + assertThat(cellsIn(lines[1])[MetricsCsv.HEADER.indexOf("annotation")]).isEqualTo("\"first\"") + } + + @Test + fun `an annotation recorded on the monotonic clock lands on the right row`() { + // The store stamps annotations with elapsedRealtime and the samples carry epoch millis. + // Recorded three seconds ago, on a device up for two hours. + val upFor = 2 * 60 * 60 * 1000L + val recordedAt = upFor - 3_000L + val times = longArrayOf(T0, T0 + 1_000L, T0 + 2_000L, T0 + 3_000L) + val onEpoch = MetricsCsv.epochFor(recordedAt, nowEpochMillis = T0 + 3_000L, nowMonotonicMillis = upFor) + + val lines = + render( + snapshot( + rowTimes = times, + annotations = listOf(MetricsCsv.Marker(onEpoch, "Build started", "BUILD_STARTED")), + ), + ) + + val column = MetricsCsv.HEADER.indexOf("annotation") + assertThat(cellsIn(lines[1])[column]).isEqualTo("\"Build started\"") + } + + @Test + fun `an unconverted monotonic time reaches no row at all`() { + // What the mix-up looks like on a device: a monotonic time is a few hours and an epoch time + // is decades, so every row is about equally far away. Before the distance cap the + // nearest-row search picked whichever number was smallest -- the oldest sample, whenever + // the event really happened -- and wrote the marker there. Now it is further from every row + // than a sampling interval, so it is dropped, and the file loses it rather than lying about + // when it happened. Either way [MetricsCsv.epochFor] is what makes it land correctly. + val times = longArrayOf(T0, T0 + 1_000L, T0 + 2_000L) + val lines = + render( + snapshot( + rowTimes = times, + annotations = listOf(MetricsCsv.Marker(2 * 60 * 60 * 1000L, "Build started", "BUILD_STARTED")), + ), + ) + + val column = MetricsCsv.HEADER.indexOf("annotation") + assertThat(lines.drop(1).map { cellsIn(it)[column] }).containsExactly("", "", "") + } + + @Test + fun `hasRows tells a caller whether the file is worth sending`() { + // The writer always produces a file, header included, because the export button asked for + // one. Attaching it to feedback is a different question (ADFA-5534): an empty attachment on + // a report from a freshly started IDE is worse than no attachment. + assertThat(snapshot(rowTimes = LongArray(8)).hasRows).isFalse() + assertThat(snapshot(rowTimes = longArrayOf(0L, 0L, T0, 0L)).hasRows).isTrue() + } + + @Test + fun `the memory columns are the three the chart can plot`() { + // Fixed, not derived from what is being watched: the set changes mid-session, and a header + // that followed it would describe a different file each time. + assertThat(MetricsCsv.MEMORY_COLUMNS).containsExactly("IDE", "Gradle Tooling", "Gradle Daemon").inOrder() + assertThat(MetricsCsv.HEADER).containsAtLeast("ide_pss_bytes", "gradle_tooling_pss_bytes", "gradle_daemon_pss_bytes") + } + + /** Splits a row on commas that are not inside a quoted cell. */ + private fun cellsIn(line: String): List { + val cells = mutableListOf() + val cell = StringBuilder() + var quoted = false + line.forEach { c -> + when { + c == '"' -> { + quoted = !quoted + cell.append(c) + } + + c == ',' && !quoted -> { + cells += cell.toString() + cell.setLength(0) + } + + else -> { + cell.append(c) + } + } + } + cells += cell.toString() + return cells + } + + @Test + fun `a reading the device does not provide is an empty cell, not a sentinel`() { + val times = longArrayOf(T0, T0 + 1_000L) + val lines = + render( + snapshot( + rowTimes = times, + temperature = + MetricsCsv.Series( + times, + longArrayOf(Long.MIN_VALUE, 31_500L), + absent = Long.MIN_VALUE, + ), + ), + ) + + // PowerUsageWatcher stores Long.MIN_VALUE for a reading the platform will not give. Written + // straight out, a numeric column gets -9223372036854775808, and anything that averages or + // plots it -- ADFA-5494 reads this format back -- gets an answer that is not merely wrong + // but spectacular. Empty is what the format already means by "nothing to say here". + assertThat(cellsIn(lines[1])[TEMPERATURE_COLUMN]).isEmpty() + assertThat(cellsIn(lines[2])[TEMPERATURE_COLUMN]).isEqualTo("31500") + } + + private companion object { + /** Index of `battery_temp_millicelsius`, from the header contract above. */ + val TEMPERATURE_COLUMN = EXPECTED_HEADER.split(",").indexOf("\"battery_temp_millicelsius\"") + + /** + * The header line, spelled out rather than derived from [MetricsCsv.HEADER]. + * + * This is the file's contract, and a test that builds its expectation from the same constant + * the code builds the file from asserts only that the code is self-consistent -- a renamed + * column or a change in how cells are quoted would rename it here too and stay green. + * ADFA-5494 reads this format back, and ADFA-5526 and ADFA-5534 ship it inside reports, so a + * schema change should have to come and edit this line on purpose. + */ + const val EXPECTED_HEADER = + "\"timestamp\",\"ide_pss_bytes\",\"gradle_tooling_pss_bytes\",\"gradle_daemon_pss_bytes\",\"net_rx_bytes\",\"net_tx_bytes\",\"battery_temp_millicelsius\",\"power_microwatts\",\"thermal_status\",\"annotation\",\"annotation_kind\"" + + /** 2026-09-06T22:33:40.123 in America/Los_Angeles, which is UTC-7 at that date. */ + const val T0 = 1_788_759_220_123L + + /** The gap between the default rows, and so the distance a marker may sit from one. */ + const val INTERVAL_MS = 1_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsFileNameTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsFileNameTest.kt new file mode 100644 index 0000000000..fd98e4ffbf --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsFileNameTest.kt @@ -0,0 +1,65 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.time.ZoneId + +/** The one name every exported metrics file gets (ADFA-5531's rule (c)). */ +@RunWith(JUnit4::class) +class MetricsFileNameTest { + private val zone: ZoneId = ZoneId.of("America/Los_Angeles") + + @Test + fun `the name is the local time to the millisecond, and the extension`() { + val name = MetricsFileName.forTime(T0, "csv", zone) + + assertThat(name).isEqualTo("2026_09_06_22_33_40_123.csv") + } + + @Test + fun `the image and the data exported at one moment differ only in extension`() { + // Which is the point of rule (c): a pair exported together sorts together, and neither + // leads with a chart title that would sort them apart. + val csv = MetricsFileName.forTime(T0, "csv", zone) + val png = MetricsFileName.forTime(T0, "png", zone) + + assertThat(csv.removeSuffix(".csv")).isEqualTo(png.removeSuffix(".png")) + } + + @Test + fun `names sort in the order the files were written`() { + val earlier = MetricsFileName.forTime(T0, "csv", zone) + val later = MetricsFileName.forTime(T0 + 1L, "csv", zone) + val muchLater = MetricsFileName.forTime(T0 + 86_400_000L, "csv", zone) + + // A directory listing is sorted lexicographically, so the format has to be too -- which is + // why it is fixed-width and big-endian rather than anything friendlier to read. + assertThat(listOf(muchLater, later, earlier).sorted()) + .containsExactly(earlier, later, muchLater) + .inOrder() + } + + private companion object { + /** 2026-09-06T22:33:40.123 in America/Los_Angeles. */ + const val T0 = 1_788_759_220_123L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt new file mode 100644 index 0000000000..1b22612c83 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt @@ -0,0 +1,109 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.configuration.CpuArch +import org.junit.Test + +/** + * Pins the sampling-rate policy of ADFA-5486: 0.1s is the floor on 64-bit hardware, 0.5s on 32-bit, + * and a rate a device cannot use is still offered, marked unavailable, so the user can see what the + * hardware is costing them. + */ +class MetricsSamplingRatesTest { + @Test + fun `64-bit devices may sample ten times a second`() { + assertThat(MetricsSamplingRates.minimumIntervalMillis(CpuArch.AARCH64)).isEqualTo(100L) + assertThat(MetricsSamplingRates.minimumIntervalMillis(CpuArch.X86_64)).isEqualTo(100L) + } + + @Test + fun `32-bit devices are held to twice a second`() { + assertThat(MetricsSamplingRates.minimumIntervalMillis(CpuArch.ARM)).isEqualTo(500L) + assertThat(MetricsSamplingRates.minimumIntervalMillis(CpuArch.X86)).isEqualTo(500L) + } + + @Test + fun `every rate is offered to both, with the fast ones unavailable on 32-bit`() { + val on64 = MetricsSamplingRates.ratesFor(CpuArch.AARCH64) + val on32 = MetricsSamplingRates.ratesFor(CpuArch.ARM) + + // The same list either way: a rate the device cannot use is shown and greyed, not hidden, + // so the user knows what they are missing rather than assuming the IDE cannot go faster. + assertThat(on32.map { it.intervalMillis }).isEqualTo(on64.map { it.intervalMillis }) + + assertThat(on64.filter { !it.isAvailable }).isEmpty() + assertThat(on32.filter { !it.isAvailable }.map { it.intervalMillis }) + .containsExactly(100L, 200L) + .inOrder() + } + + @Test + fun `the offered range spans the ticket's 0_1 to 60 seconds`() { + val intervals = MetricsSamplingRates.OFFERED_INTERVALS_MS.toList() + + assertThat(intervals.first()).isEqualTo(100L) + assertThat(intervals.last()).isEqualTo(MetricsSamplingRates.MAX_INTERVAL_MS) + assertThat(intervals).isInOrder() + } + + @Test + fun `an out-of-range interval is clamped to what the device supports`() { + // Faster than the hardware allows. + assertThat(MetricsSamplingRates.coerceToSupportedRange(50L, CpuArch.ARM)).isEqualTo(500L) + assertThat(MetricsSamplingRates.coerceToSupportedRange(50L, CpuArch.AARCH64)).isEqualTo(100L) + + // Slower than the slowest offered. + assertThat(MetricsSamplingRates.coerceToSupportedRange(120_000L, CpuArch.AARCH64)) + .isEqualTo(60_000L) + + // Already in range. + assertThat(MetricsSamplingRates.coerceToSupportedRange(2_000L, CpuArch.ARM)).isEqualTo(2_000L) + } + + @Test + fun `architectures are classified by word size`() { + assertThat(CpuArch.AARCH64.is64Bit).isTrue() + assertThat(CpuArch.X86_64.is64Bit).isTrue() + assertThat(CpuArch.ARM.is64Bit).isFalse() + assertThat(CpuArch.X86.is64Bit).isFalse() + } + + @Test + fun `the safe range keeps a non-positive interval out of delay`() { + // delay() does not suspend for a non-positive value, so the sampling loop would spin and + // pin a core for as long as the editor is open. + assertThat(MetricsSamplingRates.coerceToSafeRange(0L)).isGreaterThan(0L) + assertThat(MetricsSamplingRates.coerceToSafeRange(-1_000L)).isGreaterThan(0L) + assertThat(MetricsSamplingRates.coerceToSafeRange(Long.MIN_VALUE)).isGreaterThan(0L) + } + + @Test + fun `the safe range caps an absurdly long interval`() { + assertThat(MetricsSamplingRates.coerceToSafeRange(Long.MAX_VALUE)) + .isEqualTo(MetricsSamplingRates.MAX_INTERVAL_MS) + } + + @Test + fun `the safe range leaves a supported interval alone`() { + assertThat(MetricsSamplingRates.coerceToSafeRange(1_000L)).isEqualTo(1_000L) + assertThat(MetricsSamplingRates.coerceToSafeRange(MetricsSamplingRates.MIN_INTERVAL_64_BIT_MS)) + .isEqualTo(MetricsSamplingRates.MIN_INTERVAL_64_BIT_MS) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt new file mode 100644 index 0000000000..c6d631860d --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsScratchTest.kt @@ -0,0 +1,112 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * The destinations a crash handler snapshots into, so that it never has to allocate (ADFA-5526). + */ +@RunWith(JUnit4::class) +class MetricsScratchTest { + @After + fun tearDown() = MetricsScratch.resetForTesting() + + @Test + fun `one claim at a time`() { + val scratch = MetricsScratch(entries = 4, memorySeries = 2) + + assertThat(scratch.claim()).isTrue() + // Two writers into one array is a scrambled file, so the second caller is refused and + // allocates for itself rather than waiting -- a crash must not block on an export. + assertThat(scratch.claim()).isFalse() + + scratch.release() + assertThat(scratch.claim()).isTrue() + } + + @Test + fun `every destination is the retained length`() { + val scratch = MetricsScratch(entries = 7, memorySeries = 3) + + // copyInto requires an exact-length destination, so a mismatch here is a crash-time failure. + val all = + listOf( + scratch.memoryTimes, + scratch.networkTimes, + scratch.networkReceived, + scratch.networkTransmitted, + scratch.powerTimes, + scratch.temperature, + scratch.power, + scratch.thermal, + ) + scratch.memoryValues + all.forEach { assertThat(it.size).isEqualTo(7) } + assertThat(scratch.memoryValues).hasSize(3) + } + + @Test + fun `installing is idempotent, so a second call keeps the first arrays`() { + MetricsScratch.install(entries = 4, memorySeries = 1) + val first = MetricsScratch.instance + + MetricsScratch.install(entries = 99, memorySeries = 1) + + // Replacing it would hand a second set of destinations to whoever already held the first. + assertThat(MetricsScratch.instance).isSameInstanceAs(first) + assertThat(MetricsScratch.instance!!.entries).isEqualTo(4) + } + + @Test + fun `there is no scratch until it is installed`() { + assertThat(MetricsScratch.instance).isNull() + } + + @Test + fun `the default size matches the retained history`() { + MetricsScratch.install() + + // If these drift apart, copyInto throws at crash time -- exactly when nothing may throw. + assertThat(MetricsScratch.instance!!.entries).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(MetricsScratch.instance!!.memoryValues).hasSize(MetricsCsv.MEMORY_COLUMNS.size) + } + + @Test + fun `the shared retention is the one all three watchers keep`() { + assertThat(MetricsScratch.sharedRetention(memory = 3600, network = 3600, power = 3600)).isEqualTo(3600) + } + + @Test + fun `retentions that disagree fail loudly, naming them`() { + // maxOf was no guard: one size is handed to all three and copyInto require()s an exact match, + // so the two smaller watchers would throw inside MetricsCrashAttachment's runCatching -- and + // every crash report would quietly lose its metrics. + val thrown = + assertThrows(IllegalArgumentException::class.java) { + MetricsScratch.sharedRetention(memory = 3600, network = 1800, power = 3600) + } + + assertThat(thrown).hasMessageThat().contains("memory=3600") + assertThat(thrown).hasMessageThat().contains("network=1800") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt new file mode 100644 index 0000000000..613d699915 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt @@ -0,0 +1,105 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.content.Context +import android.graphics.Bitmap +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * Pins ADFA-5486's snapshot export: a chart becomes a PNG in the cache, with a few recent ones kept. + * + * The name used to lead with the chart's title. ADFA-5531 gave the image and the CSV one naming rule + * so a pair exported together sorts together, which is what the naming tests here now pin. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsSnapshotTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun bitmap() = Bitmap.createBitmap(64, 32, Bitmap.Config.ARGB_8888) + + @Test + fun `writes a png into the cache`() { + val file = MetricsSnapshot.write(context, bitmap()) + + assertThat(file).isNotNull() + assertThat(file!!.exists()).isTrue() + assertThat(file.extension).isEqualTo("png") + assertThat(file.length()).isGreaterThan(0L) + // Under the cache, so the platform can reclaim it. + assertThat(file.absolutePath).startsWith(context.cacheDir.absolutePath) + } + + @Test + fun `the name is the shared metrics naming rule`() { + val file = MetricsSnapshot.write(context, bitmap(), AT) + + // The same name the CSV exported at that moment would get, differing only in extension -- + // no chart title in front of it to sort the pair apart (ADFA-5531). + assertThat(file!!.name).isEqualTo(MetricsFileName.forTime(AT, "png")) + } + + @Test + fun `a shared snapshot survives the next few exports`() { + val shared = MetricsSnapshot.write(context, bitmap(), AT)!! + + // A share hands the recipient a FileProvider URI and the chooser returns long before the + // recipient opens it. Deleting the previous file on the next export pulled the image out + // from under an app that had not read it yet. + repeat(3) { index -> MetricsSnapshot.write(context, bitmap(), AT + index + 1L) } + + assertThat(shared.exists()).isTrue() + } + + @Test + fun `the directory stays bounded across many exports`() { + repeat(20) { index -> MetricsSnapshot.write(context, bitmap(), AT + index) } + + // Bounded, not unbounded: this is a scratch directory, not a gallery. + val directory = MetricsSnapshot.write(context, bitmap(), AT + 100L)!!.parentFile!! + assertThat(directory.listFiles()!!.size).isAtMost(MetricsSnapshot.KEEP_RECENT) + } + + @Test + fun `the newest snapshot is the one handed back, and it is on disk`() { + MetricsSnapshot.write(context, bitmap(), AT) + val newest = MetricsSnapshot.write(context, bitmap(), AT + 1L) + + // This used to assert that the previous file was gone. It is not, deliberately: a share + // can still be reading it. What has to hold is that the file returned exists and is in + // the scratch directory, which stays bounded -- see the two tests above. + assertThat(newest).isNotNull() + assertThat(newest!!.exists()).isTrue() + assertThat(newest.parentFile).isEqualTo(File(context.cacheDir, "metrics-snapshots")) + } + + private companion object { + /** + * A fixed export time. + * + * The name carries milliseconds, so two writes in the same millisecond would be one file. + * Real exports are a tap apart; a test loop is not. + */ + const val AT = 1_788_759_220_123L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt new file mode 100644 index 0000000000..963321cd01 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt @@ -0,0 +1,203 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the accounting decisions ADFA-5489 was scoped around: the platform counters are cumulative, + * so what is plotted is the delta between samples, and a counter reset must not plot as negative + * traffic. + * + * These drive [NetworkUsageWatcher.sampleOnce] directly rather than starting the sampling loop, so + * there is no waiting and no dependence on thread timing. + */ +@RunWith(RobolectricTestRunner::class) +class NetworkUsageWatcherTest { + /** Every watcher built here, so the sampling thread each one starts is released. */ + private val created = mutableListOf() + + @After + fun tearDown() { + created.forEach { it.close() } + created.clear() + } + + /** + * A watcher fed a scripted sequence of cumulative readings, advancing one step per sample. + */ + private inner class Fixture( + rx: List, + tx: List = rx, + ) { + private var index = -1 + private val rxReadings = rx + private val txReadings = tx + + val watcher = + NetworkUsageWatcher( + uid = TEST_UID, + readRxBytes = { rxReadings[index.coerceIn(0, rxReadings.lastIndex)] }, + readTxBytes = { txReadings[index.coerceIn(0, txReadings.lastIndex)] }, + ).also { created += it } + + /** Takes [count] samples, walking the scripted readings. */ + fun sample(count: Int) { + repeat(count) { + index++ + watcher.sampleOnce() + } + } + } + + /** The last [count] recorded samples, ignoring the leading zeros of an unfilled buffer. */ + private fun LongArray.recent(count: Int): List = takeLast(count) + + @Test + fun `history is all zeros before the first sample`() { + val fixture = Fixture(listOf(5_000L)) + + val usage = fixture.watcher.getUsage() + + assertThat(usage.received).hasLength(NetworkUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(usage.received.sum()).isEqualTo(0L) + assertThat(usage.transmitted.sum()).isEqualTo(0L) + } + + @Test + fun `plots deltas between samples, not the cumulative counters`() { + // Cumulative since boot: 1000, then +500, then +2500. + val fixture = Fixture(listOf(1_000L, 1_500L, 4_000L)) + + fixture.sample(3) + val usage = fixture.watcher.getUsage() + + // The first sample only establishes a baseline, so it contributes 0 rather than a + // 1000-byte spike for traffic that happened before the chart existed. + assertThat(usage.received.recent(3)).containsExactly(0L, 500L, 2_500L).inOrder() + assertThat(usage.transmitted.recent(3)).containsExactly(0L, 500L, 2_500L).inOrder() + } + + @Test + fun `a counter reset records zero rather than negative traffic`() { + // A reboot or re-based accounting makes the counter go backwards. + val fixture = Fixture(listOf(10_000L, 10_400L, 200L, 700L)) + + fixture.sample(4) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received.recent(4)).containsExactly(0L, 400L, 0L, 500L).inOrder() + assertThat(usage.received.none { it < 0L }).isTrue() + } + + @Test + fun `received and transmitted are accounted separately`() { + val fixture = + Fixture( + rx = listOf(0L, 1_000L), + tx = listOf(0L, 7L), + ) + + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received.recent(2)).containsExactly(0L, 1_000L).inOrder() + assertThat(usage.transmitted.recent(2)).containsExactly(0L, 7L).inOrder() + } + + @Test + fun `the ring buffer keeps only the most recent samples`() { + val capacity = NetworkUsageWatcher.MAX_USAGE_ENTRIES + // Cumulative readings rising by 10 bytes each sample, for one more sample than fits. + val readings = List(capacity + 2) { it * 10L } + val fixture = Fixture(readings) + + fixture.sample(readings.size) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received).hasLength(capacity) + // The baseline zero has been pushed out; every retained sample is a full 10-byte delta. + assertThat(usage.received.toList()).containsNoneIn(listOf(-10L)) + assertThat(usage.received.last()).isEqualTo(10L) + assertThat(usage.received.sum()).isEqualTo(10L * capacity) + } + + @Test + fun `an unsupported counter is detected and nothing is recorded`() { + // TrafficStats.UNSUPPORTED is -1. + val fixture = Fixture(listOf(-1L)) + + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + assertThat(fixture.watcher.isSupported).isFalse() + // In particular, -1 is not plotted as traffic. + assertThat(usage.received.sum()).isEqualTo(0L) + assertThat(usage.transmitted.sum()).isEqualTo(0L) + } + + @Test + fun `getUsage returns a copy, not the live buffer`() { + val fixture = Fixture(listOf(0L, 100L, 300L)) + + fixture.sample(2) + val first = fixture.watcher.getUsage() + val asHandedOut = first.received.copyOf() + fixture.sample(1) + + // The array handed out earlier must not have been mutated by the later sample. + assertThat(first.received).isEqualTo(asHandedOut) + assertThat(fixture.watcher.getUsage().received).isNotEqualTo(asHandedOut) + } + + @Test + fun `stopping drops the cumulative baseline so a resume does not spike`() { + // 1 MB transferred, then the watcher is stopped while a download keeps running. + val fixture = Fixture(listOf(1_000_000L, 1_000_000L, 250_000_000L, 250_500_000L)) + fixture.sample(2) + + fixture.watcher.stopWatching() + + // Resume: the counter has moved by 249 MB while nothing was watching. + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + // Kept, the baseline turns the whole gap into one interval's traffic -- the legend reads + // hundreds of MB/s and the axis is stretched for the next minute. + assertThat(usage.received.recent(2)).containsExactly(0L, 500_000L).inOrder() + } + + @Test + fun `an unsupported counter stops the watcher rather than sampling zeroes forever`() { + val fixture = Fixture(listOf(-1L)) + + fixture.sample(1) + + // Nothing more to read, so nothing more to do: the loop was repainting the charts once a + // second with data known to be permanently unavailable. + assertThat(fixture.watcher.isSupported).isFalse() + } + + private companion object { + const val TEST_UID = 10_123 + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt new file mode 100644 index 0000000000..b990dfa45f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt @@ -0,0 +1,143 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the sampling loop's lifecycle (ADFA-5489). + * + * The loop spends nearly all of its time in `delay()`, so "stopped" cannot mean "will notice a + * flag eventually": between the request and the next tick the watcher is still sampling, and a + * stop followed by a start inside that window used to leave two loops appending to one buffer. + * + * Driven on a virtual clock. Waiting on the wall clock instead is what hung the test executor the + * first time this was attempted. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class NetworkWatcherLifecycleTest { + private fun watcher( + dispatcher: kotlin.coroutines.CoroutineContext, + onSample: () -> Unit = {}, + ): NetworkUsageWatcher { + var counter = 0L + return NetworkUsageWatcher( + updateInterval = INTERVAL_MS, + uid = TEST_UID, + readRxBytes = { + onSample() + counter += 100L + counter + }, + readTxBytes = { counter }, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + } + + @Test + fun `stopping inside the sampling interval actually stops sampling`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + + try { + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 3) + val whileRunning = samples + + watcher.stopWatching() + advanceTimeBy(INTERVAL_MS * 5) + + // Cancelling the job rather than waiting for the loop to observe a flag is what makes + // this exact: nothing is sampled after the stop. + assertThat(whileRunning).isGreaterThan(0) + assertThat(samples).isEqualTo(whileRunning) + assertThat(watcher.isWatching).isFalse() + } finally { + // Closed here rather than after the assertions: see the class KDoc. + watcher.close() + } + } + + @Test + fun `restarting inside the sampling interval does not leave two loops running`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + + try { + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 2) + watcher.stopWatching() + watcher.startWatching() + + val before = samples + advanceTimeBy(INTERVAL_MS * 4) + + // The raw count, not a rate: integer division passed for anything from four to + // seven samples, so a second loop that only partly overlapped went unnoticed. + assertThat(samples - before).isEqualTo(4) + } finally { + watcher.close() + } + } + + @Test + fun `a listener that throws does not kill sampling for the rest of the session`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + + try { + var thrown = 0 + watcher.listener = + NetworkUsageWatcher.NetworkUsageListener { + if (thrown++ == 0) { + throw IllegalStateException("listener blew up") + } + } + + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 4) + + // Uncaught, the exception ends the coroutine while isWatching stays true, so every + // later startWatching() is refused and the charts freeze for good. + assertThat(samples).isGreaterThan(1) + assertThat(watcher.isWatching).isTrue() + } finally { + watcher.close() + } + } + + private companion object { + const val INTERVAL_MS = 1_000L + const val TEST_UID = 10_123 + } +} 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..40871c2b30 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/PowerUsageWatcherTest.kt @@ -0,0 +1,195 @@ +/* + * 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 `every slot reads as absent before the first sample`() { + val fixture = Fixture(listOf(reading())) + + val usage = fixture.watcher.getUsage() + + // Asserted per slot, not as a sum. This test used to check `sum() == 0`, which passed for + // a reason that had nothing to do with absence: 3600 * Long.MIN_VALUE wraps to exactly 0, + // so the assertion held whether the buffers were filled with the sentinel or with zeros -- + // and went on holding when the thermal series was filled with the wrong sentinel entirely. + assertThat(usage.temperatureMilliCelsius).hasLength(PowerUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(usage.temperatureMilliCelsius.toSet()).containsExactly(PowerUsageWatcher.UNAVAILABLE) + assertThat(usage.powerMicroWatts.toSet()).containsExactly(PowerUsageWatcher.UNAVAILABLE) + // Its own sentinel, which is what every consumer and the CSV's `absent` use. + assertThat(usage.thermalStatus.toSet()).containsExactly(PowerUsageWatcher.THERMAL_UNKNOWN.toLong()) + } + + @Test + 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/utils/ProcessMemoryReaderTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ProcessMemoryReaderTest.kt new file mode 100644 index 0000000000..ded0b8b0ce --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ProcessMemoryReaderTest.kt @@ -0,0 +1,122 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Which read is used for which process, and what the cheap one makes of a rollup (ADFA-5574). + * + * The equivalence of the two reads is deliberately not asserted here. `Debug.getMemoryInfo` is not + * meaningfully callable off a device, and the interesting part of the claim is a device fact: for a + * plain JVM the rollup agrees with it to 0.009%, while for the app's own process it reads ~124MB low + * because graphics memory is accounted through memtrack rather than through `/proc/pid/smaps`. That + * is recorded on the ticket from a real measurement. What can be pinned here is the rule that acts + * on it, and the parse. + */ +@RunWith(RobolectricTestRunner::class) +class ProcessMemoryReaderTest { + @Test + fun `the app's own process keeps the expensive read`() { + // It is the only Zygote fork the carousel plots, and the only one with GPU memory. A rollup + // cannot see EGL or GL mtrack, so this process would silently lose about a quarter of its + // footprint. + val reader = ProcessMemoryReaders.chooseReader(pid = OWN_PID, ownPid = OWN_PID, rollupSupported = true) + + assertThat(reader).isSameInstanceAs(DebugMemoryInfoReader) + } + + @Test + fun `every other process gets the rollup`() { + // The tooling server and the Gradle daemon: plain OpenJDK processes with no graphics + // memory, where the rollup is the same number for less than half the cost. + val reader = ProcessMemoryReaders.chooseReader(pid = OTHER_PID, ownPid = OWN_PID, rollupSupported = true) + + assertThat(reader).isSameInstanceAs(SmapsRollupReader) + } + + @Test + fun `a kernel without a rollup falls back for everything`() { + // smaps_rollup arrived in Linux 4.14, so Android 10 in practice, and minSdk here is 28. + // Such a device gets exactly what it had before this change. + val reader = ProcessMemoryReaders.chooseReader(pid = OTHER_PID, ownPid = OWN_PID, rollupSupported = false) + + assertThat(reader).isSameInstanceAs(DebugMemoryInfoReader) + } + + @Test + fun `the rollup's own Pss is read, not one of the fields that start like it`() { + // A rollup carries Pss_Anon, Pss_File, Pss_Shmem and Pss_Dirty as well, and matching on + // "Pss" alone would take whichever came first -- here Pss_Dirty, a different number. + val value = + parse( + """ + 02000000-7ffc009000 ---p 00000000 00:00 0 [rollup] + Rss: 653352 kB + Pss_Dirty: 379731 kB + Pss: 441070 kB + Pss_Anon: 385191 kB + SwapPss: 15 kB + """.trimIndent(), + ) + + assertThat(value).isEqualTo(441070) + } + + @Test + fun `a rollup with no Pss line is unavailable rather than zero`() { + // Zero is a measurement -- a process really using no memory. Unavailable is the absence of + // one, and the caller falls back rather than plotting it. + assertThat(parse("Rss: 653352 kB")).isEqualTo(ProcessMemoryReaders.UNAVAILABLE) + } + + @Test + fun `a Pss line with no number is unavailable`() { + assertThat(parse("Pss: kB")).isEqualTo(ProcessMemoryReaders.UNAVAILABLE) + } + + @Test + fun `a process with no rollup at all is unavailable`() { + // The pid is gone, or the kernel has no rollup. Either way this must not throw: it runs on + // the sampling thread once a second. + val value = SmapsRollupReader.totalKb(NO_SUCH_PID, android.os.Debug.MemoryInfo()) + + assertThat(value).isEqualTo(ProcessMemoryReaders.UNAVAILABLE) + } + + /** + * The reader's own line-picking and parsing, over a fixture. + * + * Through [SmapsRollupReader.pssKbFrom], not by finding the line here first: an earlier version + * of this helper did its own `startsWith("Pss:")` and so pinned only the number extraction -- + * loosening the reader's prefix to "Pss" left every case below green. + */ + private fun parse(rollup: String): Int = SmapsRollupReader.pssKbFrom(rollup.lineSequence()) + + private companion object { + const val OWN_PID = 4242 + + const val OTHER_PID = 4243 + + /** Comfortably above any real pid on a device, so `/proc/` cannot exist. */ + const val NO_SUCH_PID = 999_999 + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/ShiftedLongArrayCopyIntoTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ShiftedLongArrayCopyIntoTest.kt new file mode 100644 index 0000000000..2ba1d776cc --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ShiftedLongArrayCopyIntoTest.kt @@ -0,0 +1,79 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** Copying a ring buffer into a destination the caller owns (ADFA-5526). */ +@RunWith(JUnit4::class) +class ShiftedLongArrayCopyIntoTest { + private fun buffer(): MutableShiftedLongArray { + val buffer = MutableShiftedLongArray(4) + // Appended the way the watchers append: newest in at 0, then shift. + listOf(10L, 20L, 30L).forEach { value -> + buffer[0] = value + buffer.shift(1) + } + return buffer + } + + @Test + fun `it writes the same order toLongArray produces`() { + val buffer = buffer() + + val dest = LongArray(buffer.size) + assertThat(buffer.copyInto(dest).toList()).isEqualTo(buffer.toLongArray().toList()) + } + + @Test + fun `it returns the destination it was given, not a copy`() { + val buffer = buffer() + val dest = LongArray(buffer.size) + + // The whole point: the caller pre-allocated this, so nothing new may be handed back. + assertThat(buffer.copyInto(dest)).isSameInstanceAs(dest) + } + + @Test + fun `a destination of the wrong length is refused`() { + val buffer = buffer() + + // A short destination truncates the history and a long one leaves a stale tail behind it, + // and both read as data. Better to fail where the mistake is than to file a wrong graph. + listOf(LongArray(buffer.size - 1), LongArray(buffer.size + 1)).forEach { wrong -> + val failure = runCatching { buffer.copyInto(wrong) }.exceptionOrNull() + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + } + } + + @Test + fun `a second copy overwrites the first, leaving nothing of it`() { + val dest = LongArray(4) + buffer().copyInto(dest) + + val fresh = MutableShiftedLongArray(4) + fresh.copyInto(dest) + + // The scratch is reused across snapshots, so a stale value surviving into the next one + // would be reported as a measurement. + assertThat(dest.toList()).containsExactly(0L, 0L, 0L, 0L) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt b/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt new file mode 100644 index 0000000000..a87b129de2 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt @@ -0,0 +1,120 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Test + +/** + * Pins that changing the sampling rate discards the history (ADFA-5486). + * + * The chart reads a sample's age from its position, which assumes every sample is the same age + * apart. A buffer holding samples taken at two rates would silently misdate all the older ones, so + * the history goes when the rate does. + */ +class WatcherIntervalChangeTest { + /** Every watcher built here, so the sampling threads they hold are released. */ + private val created = mutableListOf() + + @After + fun tearDown() { + // An @After rather than a close at the end of each test: a watcher holds a dedicated + // sampling thread until close(), and a failed assertion would skip a trailing call. + created.forEach { it.close() } + created.clear() + } + + private fun networkWatcher(readings: List): Pair Unit> { + var index = -1 + val watcher = + NetworkUsageWatcher( + uid = TEST_UID, + readRxBytes = { readings[index.coerceIn(0, readings.lastIndex)] }, + readTxBytes = { readings[index.coerceIn(0, readings.lastIndex)] }, + ).also { created += it } + return watcher to { + index++ + watcher.sampleOnce() + } + } + + @Test + fun `changing the network interval discards the samples`() { + val (watcher, sample) = networkWatcher(listOf(0L, 1_000L, 3_000L)) + repeat(3) { sample() } + assertThat(watcher.getUsage().received.sum()).isGreaterThan(0L) + + watcher.updateInterval = 5_000L + + assertThat(watcher.getUsage().received.sum()).isEqualTo(0L) + assertThat(watcher.getUsage().transmitted.sum()).isEqualTo(0L) + } + + @Test + fun `setting the same network interval keeps the samples`() { + val (watcher, sample) = networkWatcher(listOf(0L, 1_000L)) + repeat(2) { sample() } + val before = watcher.getUsage().received.sum() + + watcher.updateInterval = watcher.updateInterval + + assertThat(watcher.getUsage().received.sum()).isEqualTo(before) + } + + @Test + fun `the cumulative baseline is dropped too`() { + // Otherwise the first sample after the change would report every byte since the last one as + // a single delta -- a spike at exactly the moment the user changed the rate. + val (watcher, sample) = networkWatcher(listOf(0L, 1_000L, 50_000L)) + repeat(2) { sample() } + + watcher.updateInterval = 2_000L + sample() + + assertThat(watcher.getUsage().received.sum()).isEqualTo(0L) + } + + private companion object { + const val TEST_UID = 10_123 + } + + @Test + fun `a watcher refuses a non-positive sampling interval`() { + val watcher = NetworkUsageWatcher(uid = TEST_UID, readRxBytes = { 0L }, readTxBytes = { 0L }) + try { + watcher.updateInterval = -1L + + // Stored raw, this reaches delay(), which does not suspend for it: the loop spins. + assertThat(watcher.updateInterval).isAtLeast(MetricsSamplingRates.MIN_INTERVAL_64_BIT_MS) + } finally { + watcher.close() + } + } + + @Test + fun `a watcher constructed with a non-positive interval is clamped too`() { + // The constructor initialiser bypasses the setter, so it needs its own guard. + val watcher = NetworkUsageWatcher(updateInterval = 0L, uid = TEST_UID, readRxBytes = { 0L }, readTxBytes = { 0L }) + try { + assertThat(watcher.updateInterval).isAtLeast(MetricsSamplingRates.MIN_INTERVAL_64_BIT_MS) + } finally { + watcher.close() + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/WatcherLifecycleTest.kt b/app/src/test/java/com/itsaky/androidide/utils/WatcherLifecycleTest.kt new file mode 100644 index 0000000000..a0e592680b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/WatcherLifecycleTest.kt @@ -0,0 +1,178 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Pins the sampling loop's lifecycle, from three defects found in review of ADFA-5487/5489. + * + * The loop used to be launched with its own `SupervisorJob`, which meant the watcher's scope could + * not cancel it: it ran until it next observed the `watching` flag, and it spends nearly all its + * time asleep in `delay(updateInterval)` -- up to a minute at the slowest rate now that the rate is + * configurable. And an exception anywhere in the body ended the coroutine while the flag stayed + * set, so sampling stopped for good and every later restart was refused. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WatcherLifecycleTest { + @Test + fun `restarting inside the sampling interval does not leave two loops running`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 1_000L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(1_500L) + val afterFirstRun = samples + + // Stop and start again while the loop is asleep mid-interval. The old loop used to wake + // up, see the flag set again, and carry on beside the new one. + watcher.stopWatching(unwatchAll = false) + watcher.startWatching() + advanceTimeBy(3_000L) + + // Three more intervals, one sampler: three more samples, not six. + val duringSecondRun = samples - afterFirstRun + assertThat(duringSecondRun).isAtMost(4) + + watcher.close() + } + + @Test + fun `stopping actually stops sampling`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(500L) + watcher.stopWatching(unwatchAll = false) + val atStop = samples + + advanceTimeBy(2_000L) + + assertThat(samples).isEqualTo(atStop) + assertThat(watcher.isWatching).isFalse() + } + + @Test + fun `a listener that throws does not kill sampling`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var notifications = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = + MemoryUsageWatcher.MemoryUsageListener { + notifications++ + throw IllegalStateException("listener blew up") + } + + watcher.startWatching() + advanceTimeBy(1_000L) + + // The loop used to die on the first throw, leaving isWatching true so nothing could + // restart it. It should keep sampling instead. + assertThat(notifications).isAtLeast(5) + assertThat(watcher.isWatching).isTrue() + + // runTest drains the scheduler when the test ends, which an unstopped loop never lets + // it do. + watcher.close() + } + + @Test + fun `a watcher can be restarted after a listener throws`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var notifications = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = + MemoryUsageWatcher.MemoryUsageListener { + notifications++ + throw IllegalStateException("listener blew up") + } + + watcher.startWatching() + advanceTimeBy(300L) + watcher.stopWatching(unwatchAll = false) + + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { notifications++ } + watcher.startWatching() + val beforeRestart = notifications + advanceTimeBy(500L) + + assertThat(watcher.isWatching).isTrue() + assertThat(notifications).isGreaterThan(beforeRestart) + + watcher.close() + } + + @Test + fun `close stops sampling and refuses to restart`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(300L) + watcher.close() + val atClose = samples + + // The scope is cancelled, so a restart launches nothing. + watcher.startWatching() + advanceTimeBy(1_000L) + + assertThat(samples).isEqualTo(atClose) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt new file mode 100644 index 0000000000..e6b0b9c6ff --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt @@ -0,0 +1,99 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.viewmodel + +import 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 +import org.robolectric.RobolectricTestRunner + +/** + * The terminal teardown of the metrics watchers (ADFA-5486). + * + * The watchers each own a dedicated sampling thread that `newSingleThreadContext` keeps alive + * until it is closed, so this is the one place that has to close rather than merely stop them. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsViewModelTest { + /** onCleared is protected, so it is reached the way the framework reaches it. */ + private fun cleared() = store.clear() + + private val store = ViewModelStore() + + private fun viewModel(): MetricsViewModel { + // 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 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() + + // close(), not stopWatching(): a closed watcher gives up its sampling thread and refuses + // to restart, which is what makes this the terminal teardown rather than a pause. + assertThat(model.memoryUsageWatcher.isWatching).isFalse() + assertThat(model.networkUsageWatcher.isWatching).isFalse() + 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 + fun `the watchers and the annotation store are the same instances across reads`() { + val model = viewModel() + + // The history lives here precisely so it survives an activity being recreated; handing + // back a new watcher per read would quietly defeat that. + assertThat(model.memoryUsageWatcher).isSameInstanceAs(model.memoryUsageWatcher) + assertThat(model.networkUsageWatcher).isSameInstanceAs(model.networkUsageWatcher) + assertThat(model.powerUsageWatcher).isSameInstanceAs(model.powerUsageWatcher) + assertThat(model.annotations).isSameInstanceAs(model.annotations) + } +} diff --git a/common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt b/common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt index 7992626eda..e0e954cb0f 100644 --- a/common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt +++ b/common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt @@ -16,105 +16,121 @@ import kotlinx.coroutines.launch * Uses normalized ratios instead of absolute coordinates to keep the FAB correctly * positioned across layout size changes (e.g. resizing, multi-window, DeX). */ -class FeedbackButtonManager( - private val activity: AppCompatActivity, - private val feedbackFab: FloatingActionButton?, - private val getLogContent: (() -> String?)? = null, -) { - private val repository = FabPositionRepository(activity.applicationContext) - private val calculator = FabPositionCalculator() - - // This function is called in the onCreate method of the activity that contains the FAB - fun setupDraggableFab() { - val fab = feedbackFab ?: return - loadFabPosition() - setupLayoutChangeListener(fab) - setupTouchAndClickListeners(fab) - } - - // Called in onResume for returning activities to reload FAB position - fun loadFabPosition() { - val fab = feedbackFab ?: return - activity.lifecycleScope.launch { - val (xRatio, yRatio) = repository.readPositionRatios() - if (xRatio == -1f || yRatio == -1f) return@launch - - fab.post { applySavedPosition(fab, xRatio, yRatio) } - } - } - - private fun applySavedPosition(fab: FloatingActionButton, xRatio: Float, yRatio: Float) { - val parentView = fab.parent as? ViewGroup ?: return - val safeBounds = calculator.getSafeDraggingBounds(parentView, fab) - val availableWidth = (safeBounds.right - safeBounds.left).toFloat() - val availableHeight = (safeBounds.bottom - safeBounds.top).toFloat() - - val x = calculator.fromRatio(xRatio, safeBounds.left, availableWidth) - val y = calculator.fromRatio(yRatio, safeBounds.top, availableHeight) - val (validX, validY) = calculator.validateAndCorrectPosition(x, y, parentView, fab) - - fab.x = validX - fab.y = validY - - if (validX != x || validY != y) { - saveFabPosition(fab, validX, validY) - } - } - - private fun setupLayoutChangeListener(fab: FloatingActionButton) { - fab.post { - val parentView = fab.parent as? ViewGroup ?: return@post - - parentView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> - val newWidth = right - left - val newHeight = bottom - top - val oldWidth = oldRight - oldLeft - val oldHeight = oldBottom - oldTop - if (newWidth != oldWidth || newHeight != oldHeight) { - loadFabPosition() - } - } - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun setupTouchAndClickListeners(fab: FloatingActionButton) { - val touchListener = DraggableTouchListener( - context = activity, - calculator = calculator, - onSavePosition = { x, y -> saveFabPosition(fab, x, y) }, - onShowTooltip = { showTooltip(fab) } - ) - - fab.setOnTouchListener(touchListener) - fab.setOnClickListener { performFeedbackAction() } - } - - private fun saveFabPosition(fab: FloatingActionButton, x: Float, y: Float) { - val parentView = fab.parent as? ViewGroup ?: return - // Get safe dragging bounds that account for system UI - val safeBounds = calculator.getSafeDraggingBounds(parentView, fab) - val availableWidth = (safeBounds.right - safeBounds.left).toFloat() - val availableHeight = (safeBounds.bottom - safeBounds.top).toFloat() - - val xRatio = calculator.toRatio(x, safeBounds.left, availableWidth) - val yRatio = calculator.toRatio(y, safeBounds.top, availableHeight) - - repository.savePositionRatios(xRatio, yRatio) - } - - private fun showTooltip(fab: FloatingActionButton) { - TooltipManager.showIdeCategoryTooltip( - context = activity, - anchorView = fab, - tag = TooltipTag.FEEDBACK, - ) - } - - private fun performFeedbackAction() { - FeedbackManager.showFeedbackDialog( - activity = activity, - logContent = getLogContent?.invoke() - ) - } -} +class FeedbackButtonManager + // Java callers construct this positionally (TermuxActivity), and Kotlin's default arguments are + // invisible from Java, so the shorter forms have to be generated. + @JvmOverloads + constructor( + private val activity: AppCompatActivity, + private val feedbackFab: FloatingActionButton?, + private val getLogContent: (() -> String?)? = null, + /** The metrics file to attach, or null for none. Suspending: it writes a file (ADFA-5534). */ + private val getMetricsAttachment: (suspend () -> java.io.File?)? = null, + ) { + private val repository = FabPositionRepository(activity.applicationContext) + private val calculator = FabPositionCalculator() + + // This function is called in the onCreate method of the activity that contains the FAB + fun setupDraggableFab() { + val fab = feedbackFab ?: return + loadFabPosition() + setupLayoutChangeListener(fab) + setupTouchAndClickListeners(fab) + } + + // Called in onResume for returning activities to reload FAB position + fun loadFabPosition() { + val fab = feedbackFab ?: return + activity.lifecycleScope.launch { + val (xRatio, yRatio) = repository.readPositionRatios() + if (xRatio == -1f || yRatio == -1f) return@launch + + fab.post { applySavedPosition(fab, xRatio, yRatio) } + } + } + + private fun applySavedPosition( + fab: FloatingActionButton, + xRatio: Float, + yRatio: Float, + ) { + val parentView = fab.parent as? ViewGroup ?: return + val safeBounds = calculator.getSafeDraggingBounds(parentView, fab) + val availableWidth = (safeBounds.right - safeBounds.left).toFloat() + val availableHeight = (safeBounds.bottom - safeBounds.top).toFloat() + + val x = calculator.fromRatio(xRatio, safeBounds.left, availableWidth) + val y = calculator.fromRatio(yRatio, safeBounds.top, availableHeight) + val (validX, validY) = calculator.validateAndCorrectPosition(x, y, parentView, fab) + + fab.x = validX + fab.y = validY + + if (validX != x || validY != y) { + saveFabPosition(fab, validX, validY) + } + } + + private fun setupLayoutChangeListener(fab: FloatingActionButton) { + fab.post { + val parentView = fab.parent as? ViewGroup ?: return@post + + parentView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + val newWidth = right - left + val newHeight = bottom - top + val oldWidth = oldRight - oldLeft + val oldHeight = oldBottom - oldTop + if (newWidth != oldWidth || newHeight != oldHeight) { + loadFabPosition() + } + } + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupTouchAndClickListeners(fab: FloatingActionButton) { + val touchListener = + DraggableTouchListener( + context = activity, + calculator = calculator, + onSavePosition = { x, y -> saveFabPosition(fab, x, y) }, + onShowTooltip = { showTooltip(fab) }, + ) + + fab.setOnTouchListener(touchListener) + fab.setOnClickListener { performFeedbackAction() } + } + + private fun saveFabPosition( + fab: FloatingActionButton, + x: Float, + y: Float, + ) { + val parentView = fab.parent as? ViewGroup ?: return + // Get safe dragging bounds that account for system UI + val safeBounds = calculator.getSafeDraggingBounds(parentView, fab) + val availableWidth = (safeBounds.right - safeBounds.left).toFloat() + val availableHeight = (safeBounds.bottom - safeBounds.top).toFloat() + + val xRatio = calculator.toRatio(x, safeBounds.left, availableWidth) + val yRatio = calculator.toRatio(y, safeBounds.top, availableHeight) + + repository.savePositionRatios(xRatio, yRatio) + } + + private fun showTooltip(fab: FloatingActionButton) { + TooltipManager.showIdeCategoryTooltip( + context = activity, + anchorView = fab, + tag = TooltipTag.FEEDBACK, + ) + } + + private fun performFeedbackAction() { + FeedbackManager.showFeedbackDialog( + activity = activity, + logContent = getLogContent?.invoke(), + metricsAttachment = getMetricsAttachment, + ) + } + } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt index 19e208e1c8..969d164336 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt @@ -137,10 +137,14 @@ class FeedbackEmailHandler( emailRecipient: String, subject: String, body: String, + metricsUri: Uri? = null, ): Intent { val attachmentUris = mutableListOf() screenshotUri?.let { attachmentUris.add(it) } logContentUri?.let { attachmentUris.add(it) } + // The performance history from the session being complained about (ADFA-5534). Absent when + // nothing has been sampled yet, which is the one case worth sending nothing for. + metricsUri?.let { attachmentUris.add(it) } return getIntentBasedOnAttachments( emailRecipient = emailRecipient, diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt index b3b749e596..cd492ab4d5 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt @@ -14,6 +14,7 @@ import android.view.PixelCopy import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.createBitmap import androidx.core.net.toUri @@ -21,6 +22,7 @@ import androidx.core.text.HtmlCompat import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.eventbus.events.editor.ReportCaughtExceptionEvent import com.itsaky.androidide.resources.R +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -48,6 +50,7 @@ object FeedbackManager { fun showFeedbackDialog( activity: AppCompatActivity, logContent: String?, + metricsAttachment: (suspend () -> File?)? = null, ) { val builder = DialogUtils.newMaterialDialogBuilder(activity) @@ -61,7 +64,7 @@ object FeedbackManager { ).setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } .setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() - sendFeedbackWithAttachments(activity, logContent) + sendFeedbackWithAttachments(activity, logContent, metricsAttachment) }.show() } @@ -299,15 +302,48 @@ object FeedbackManager { else -> "Unknown Screen" } + /** + * The URI for the metrics attachment, or `null` if there is nothing to attach or it failed. + * + * Suspending, unlike the log: the caller reads the sample buffers on the main thread and writes + * a compressed file off it, and neither belongs in a click listener. Guarded, because feedback + * about a broken IDE has to send even when this part does not work. + * + * Both steps are inside the guard. [toUri] used to be chained outside it, so a FileProvider not + * told about the attachment's directory threw past the guard and killed the whole send -- + * precisely what the guard is for. + * + * [CancellationException] is rethrown rather than swallowed. `runCatching` catches `Throwable`, + * and [provider] really does suspend, so a destroyed activity had its cancellation eaten here + * and the caller ran on to `startActivity()` on a dead activity. + * + * Separated from [sendFeedbackWithAttachments] so it can be tested: that one needs a live + * activity and its lifecycle scope, and this is the part with the failure modes. + */ + @VisibleForTesting + internal suspend fun metricsAttachmentUri( + provider: (suspend () -> File?)?, + toUri: (File) -> Uri, + ): Uri? = + runCatching { provider?.invoke()?.let(toUri) } + .onFailure { error -> + if (error is CancellationException) { + throw error + } + logger.error("Could not attach the metrics file", error) + }.getOrNull() + private fun sendFeedbackWithAttachments( activity: AppCompatActivity, logContent: String?, + metricsAttachment: (suspend () -> File?)? = null, ) { activity.lifecycleScope.launch { val handler = FeedbackEmailHandler(activity) val screenshotUri = handler.captureAndPrepareScreenshotUri(activity) val logContentUri = handler.getLogUri(activity, logContent) + val metricsUri = metricsAttachmentUri(metricsAttachment, activity::fileProviderUriFor) val feedbackRecipient = activity.getString(R.string.feedback_email) val feedbackSubject = @@ -340,6 +376,7 @@ object FeedbackManager { feedbackRecipient, feedbackSubject, feedbackBody, + metricsUri, ) runCatching { diff --git a/common/src/test/java/com/itsaky/androidide/utils/FeedbackMetricsAttachmentTest.kt b/common/src/test/java/com/itsaky/androidide/utils/FeedbackMetricsAttachmentTest.kt new file mode 100644 index 0000000000..3b67c459fb --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/FeedbackMetricsAttachmentTest.kt @@ -0,0 +1,91 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File +import java.io.IOException + +/** + * What the metrics attachment is allowed to cost the feedback send (ADFA-5534). + * + * Feedback about a broken IDE has to reach us even when the part that describes the breakage does + * not work. The two ways that was not true: a throw from the URI step, which sat outside the guard, + * and a cancellation, which the guard caught and hid. + */ +class FeedbackMetricsAttachmentTest { + private val uri = mockk() + + private val file = File("metrics.csv.gz") + + @Test + fun `an attachment that writes becomes a uri`() = + runTest { + val result = FeedbackManager.metricsAttachmentUri({ file }) { uri } + + assertThat(result).isSameInstanceAs(uri) + } + + @Test + fun `nothing to attach is not a failure`() = + runTest { + assertThat(FeedbackManager.metricsAttachmentUri(null) { uri }).isNull() + assertThat(FeedbackManager.metricsAttachmentUri({ null }) { uri }).isNull() + } + + @Test + fun `a write that fails costs the attachment, not the send`() = + runTest { + val result = + FeedbackManager.metricsAttachmentUri({ throw IOException("no space") }) { uri } + + assertThat(result).isNull() + } + + @Test + fun `a uri that cannot be granted costs the attachment, not the send`() = + runTest { + // FileProvider throws IllegalArgumentException for a path outside its configured roots, + // and the metrics reports live in a directory this feature added. Chained outside the + // guard, as it was, this threw past it and took the whole feedback send with it. + val result = + FeedbackManager.metricsAttachmentUri({ file }) { + throw IllegalArgumentException("Failed to find configured root") + } + + assertThat(result).isNull() + } + + @Test + fun `a cancelled send is not carried on with`() = + runTest { + // runCatching catches Throwable, so this used to be swallowed and the caller ran on to + // startActivity() on an activity that had already been destroyed. + try { + FeedbackManager.metricsAttachmentUri({ throw CancellationException("destroyed") }) { uri } + throw AssertionError("expected the cancellation to propagate") + } catch (expected: CancellationException) { + assertThat(expected).hasMessageThat().isEqualTo("destroyed") + } + } +} diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt index 92d5221b68..6e2e4de7b3 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt @@ -252,7 +252,15 @@ object TooltipManager { ) } - private fun canShowPopup(context: Context, view: View): Boolean { + /** + * Whether a popup anchored to [view] can actually be shown right now. + * + * Internal so [com.itsaky.androidide.utils.showTooltipIfPresent] can ask before it plays the + * long-press haptic. Asking after is too late: the buzz is the user's signal that help arrived, + * and a hold that completes 800ms after its window has gone fired it for a tooltip that never + * appeared. + */ + internal fun canShowPopup(context: Context, view: View): Boolean { tailrec fun Context.findActivity(): Activity? { return when (this) { is Activity -> this diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 7c4e23f0e3..99bcd85213 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -235,6 +235,15 @@ object TooltipTag { const val WINDOW_DOCK = "window-dock" const val WINDOW_UNDOCK = "window-undock" + /** + * The floating window's close control. + * + * Distinct from [WINDOW_UNDOCK], which names the opposite action and belongs to the editor + * controls that open something in a window. The chrome's close button borrowed that tag, so a + * long press on it answered "Opens the file in a separate window". + */ + const val WINDOW_CLOSE = "window-close" + // Delete project const val DELETE_PROJECT = "project.delete" const val DELETE_PROJECT_SELECT = "project.delete.select" @@ -314,4 +323,20 @@ object TooltipTag { const val GIT_DIALOG_ABORT_MERGE = "git.dialog.abortmerge" const val GIT_PUSH = "git.action.push" const val GIT_PULL = "git.action.pull" + + // Editor metrics carousel (ADFA-5510). Unprefixed like every other tag here: the lookup is by + // tag AND category, and the category column already carries "ide". + const val CAROUSEL_PANEL = "carousel.panel" + const val CAROUSEL_TITLE = "carousel.title" + const val CAROUSEL_PREVIOUS = "carousel.previous" + const val CAROUSEL_NEXT = "carousel.next" + const val CAROUSEL_SNAPSHOT = "carousel.snapshot" + const val CAROUSEL_EXPORT = "carousel.export" + const val CAROUSEL_CHART_MEMORY = "carousel.chart.memory" + const val CAROUSEL_CHART_NETWORK = "carousel.chart.network" + const val CAROUSEL_CHART_POWER = "carousel.chart.power" + const val CAROUSEL_BATTERY = "carousel.battery" + const val CAROUSEL_AXIS_TIME = "carousel.axis.time" + const val CAROUSEL_RATE = "carousel.rate" + const val CAROUSEL_UNDOCKED = "carousel.undocked" } diff --git a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt index 02213fd571..304270eac2 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/utils/ViewUtils.kt @@ -1,8 +1,14 @@ package com.itsaky.androidide.utils import android.content.Context +import android.os.Handler +import android.os.Looper import android.view.HapticFeedbackConstants +import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import com.itsaky.androidide.idetooltips.R import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager @@ -23,12 +29,16 @@ fun showTooltipIfPresent( tag: String, playHapticFeedback: Boolean = true, ) { - if (tag.isNotBlank()) { - if (playHapticFeedback) { - anchor.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - } - TooltipManager.showTooltip(context, anchor, category, tag) + if (tag.isBlank() || !TooltipManager.canShowPopup(context, anchor)) { + // Asked before the haptic, not after. The buzz is what tells the user help has arrived, and + // showTooltip declines silently for a detached anchor -- so a hold completing after its + // window has gone used to buzz for a tooltip that never appeared. + return + } + if (playHapticFeedback) { + anchor.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) } + TooltipManager.showTooltip(context, anchor, category, tag) } /** Shows [tag]'s IDE-category tooltip anchored to [anchor]. See [showTooltipIfPresent]. */ @@ -40,17 +50,313 @@ fun showIdeCategoryTooltipIfPresent( ) = showTooltipIfPresent(context, anchor, TooltipCategory.CATEGORY_IDE, tag, playHapticFeedback) /** - * Installs a long-click listener on this view that consumes the click and shows [tooltipTag]'s - * tooltip (under [tooltipCategory]) anchored to this view, or does nothing if [tooltipTag] is - * blank. See [showTooltipIfPresent] - no manual haptic feedback here for the same reason. + * How long a press has to be held before help appears, in milliseconds. + * + * Twice the platform's own long-press timeout, floored at 800ms. The platform default is 400ms, + * which is a brisk tap, so help was appearing instead of the control activating (ADFA-5554). + * + * Never *shorter* than the platform's value: that setting is exposed as an accessibility + * "touch and hold delay", and someone who has lengthened it did so deliberately. + * + * [platformTimeoutMillis] is a parameter only so a test can name one. Asserted against the live + * value, both the doubling and the floor are implied by the expression itself and a test of them + * pins nothing. + */ +fun longPressHelpTimeoutMillis(platformTimeoutMillis: Long = ViewConfiguration.getLongPressTimeout().toLong()): Long = + maxOf(platformTimeoutMillis * PLATFORM_TIMEOUT_MULTIPLE, MIN_HOLD_MILLIS) + +/** The shortest hold that will ever be asked for, whatever the platform's own timeout. */ +private const val MIN_HOLD_MILLIS = 800L + +/** How much longer than the platform's long press a hold is, above the floor. */ +private const val PLATFORM_TIMEOUT_MULTIPLE = 2L + +/** + * Shows [tooltipTag]'s tooltip (under [tooltipCategory]) when this view is held for + * [holdMillis], and lets a shorter press through as an ordinary click. + * + * The timing is this function's rather than the framework's, and that is the whole point. + * `setOnLongClickListener` fires at [ViewConfiguration.getLongPressTimeout] -- 400ms by default -- + * and returning `true` from it sets `mHasPerformedLongPress`, which cancels the click. So simply + * deferring the tooltip would leave a 500ms press doing nothing at all: no help, and no button + * press either. Instead the touch is taken over outright, and the click is performed here only + * when no tooltip was shown. + * + * The long-click listener stays installed for accessibility. Touch never reaches + * [View.onTouchEvent], so the framework cannot fire it from a finger; TalkBack's own long-press + * calls [View.performLongClick] directly, and that path shows help immediately, as it should -- + * it is already a deliberate gesture. + * + * On a [android.view.ViewGroup] this only sees touches its children did not take, which is what + * makes it safe to install on a container for the gaps between its controls. */ fun View.displayTooltipOnLongPress( context: Context, tooltipTag: String, tooltipCategory: String = TooltipCategory.CATEGORY_IDE, + holdMillis: Long = longPressHelpTimeoutMillis(), ) { - this.setOnLongClickListener { + if (tooltipTag.isBlank()) { + // Not a no-op. This call replaces whatever help was wired here before, and a blank tag + // says there is none now; returning early would leave the previous tag's listeners + // answering holds -- and swallowing every touch -- for help this view no longer offers. + clearLongPressHelp() + return + } + + setOnLongClickListener { showTooltipIfPresent(context, this, tooltipCategory, tooltipTag, playHapticFeedback = false) true } + + // Haptic feedback on, unlike the long-click path above: nothing else buzzes here, because the + // framework's own long press never runs for this view. + performOnHold(holdMillis) { showTooltipIfPresent(context, this, tooltipCategory, tooltipTag) } +} + +/** + * Runs [onHold] when this view is held for [holdMillis], and lets a shorter press through as an + * ordinary click. + * + * Separated from [displayTooltipOnLongPress] so the timing can be tested: `TooltipManager` reads + * the docs database from device storage in its static initialiser and cannot be loaded off-device, + * so a test that showed a real tooltip could not run at all. + */ +fun View.performOnHold( + holdMillis: Long = longPressHelpTimeoutMillis(), + onHold: () -> Unit, +) { + // The hold only. [displayTooltipOnLongPress] installs its long-click listener first and this + // second, so clearing that here would take away what the caller had just wired. + clearOnHold() + val listener = HoldTouchListener(this, holdMillis, onHold) + // Tagged so [clearLongPressHelp] can tell that the listener it is about to remove is this one. + setTag(R.id.tooltip_hold_listener, listener) + setOnTouchListener(listener) +} + +/** + * Stops this view answering a hold or a long press with help, and cancels one already timing. + * + * `setOnLongClickListener(null)` alone is not enough: [View.setOnLongClickListener] sets + * `isLongClickable` when it installs a listener but does not unset it when the listener is + * removed, so the view goes on consuming long presses -- and showing the system's own + * "performLongClick" feedback -- for help it no longer offers. The hold half is [clearOnHold]. + */ +fun View.clearLongPressHelp() { + setOnLongClickListener(null) + isLongClickable = false + clearOnHold() +} + +/** + * Stops this view timing a hold, and cancels one already counting down. + * + * The half of [clearLongPressHelp] that undoes [performOnHold], separately callable because a + * caller that installed only a hold should be able to undo only a hold. + * + * The touch listener is removed only when [performOnHold] is the one that installed it, which the + * tag says. Most views [clearLongPressHelp] is called on are wired through the framework's long + * click and never had one, and a blanket `setOnTouchListener(null)` there would silently take away + * an unrelated listener the next contributor adds. (An earlier version of this line counted them -- + * "five of the six" -- which stopped being true in the same PR that wrote it, when the bottom + * sheet's buttons were converted.) + */ +fun View.clearOnHold() { + val hold = getTag(R.id.tooltip_hold_listener) as? HoldTouchListener ?: return + // A hold already counting down outlives its listener: the timer is on the main thread's + // queue, not on the view. Left running it fires against a control that has just been unwired + // -- or a carousel page that has just been replaced (ADFA-5554). + hold.cancel() + setTag(R.id.tooltip_hold_listener, null) + setOnTouchListener(null) +} + +/** + * Times a hold on [view] and stands in for the framework's own press handling while it does. + * + * A class rather than a lambda so the pending hold can be cancelled from outside the touch stream; + * captured in a closure it was unreachable, and a teardown could only stop the *next* hold. + */ +private class HoldTouchListener( + private val view: View, + private val holdMillis: Long, + private val onHold: () -> Unit, +) : View.OnTouchListener { + // An explicit handler, not View.postDelayed: a view not attached to a window parks posted work + // in its HandlerActionQueue and only runs it on attach, so the hold would never time out. + private val handler = Handler(Looper.getMainLooper()) + + // Deliberately without View.CheckForLongPress's window-attach test. The framework refuses to + // fire a long press for a view whose window has gone, and reproducing that here was tried and + // backed out: a Robolectric view is never window-attached, so the guard turned every timing + // test into a no-op, and attaching one needs the activity harness that takes this JVM down. + // The exposure it covers is already covered where it matters -- TooltipManager re-checks + // isAttachedToWindow before showing, and [clearLongPressHelp] is called from every teardown + // this module has. A caller of [performOnHold] doing something else with the callback would + // not be covered, and there is no such caller today. + + private val slop = ViewConfiguration.get(view.context).scaledTouchSlop + + private var held = false + + private var holding = false + + /** + * The pressed state waiting out [ViewConfiguration.getTapTimeout], or `null` when there is none. + * + * `View.onTouchEvent` does not light a control up the instant a finger lands on it when the + * control sits in a scrolling container: it waits a tap timeout first, so that a flick which + * happens to start on a button scrolls without flashing it. Taking the touch over means taking + * that over too. The bottom sheet's output-action buttons, which ADFA-5554 wired for help, sit + * in a HorizontalScrollView, so this is a real case here and not a hypothetical one. + */ + private var pendingPress: Runnable? = null + + /** The click waiting for the next turn of the looper, so a teardown can still take it back. */ + private var pendingClick: Runnable? = null + + private val fire = + Runnable { + held = true + releasePress() + onHold() + } + + fun cancel() { + holding = false + handler.removeCallbacks(fire) + releasePress() + // The click too. It is posted rather than run inline, so a teardown landing between the + // finger lifting and the looper's next turn would otherwise still click a control it has + // just unwired -- the same defect the hold timer has, one method along. + pendingClick?.let(handler::removeCallbacks) + pendingClick = null + } + + /** Drops a press that has not been drawn yet, and any that has. */ + private fun releasePress() { + pendingPress?.let(handler::removeCallbacks) + pendingPress = null + view.isPressed = false + } + + /** + * Whether any ancestor delays the pressed state of its children, which is what + * `View.isInScrollingContainer` asks. That method is not in the public SDK; the question it + * answers is, one `ViewGroup` at a time. + */ + private fun isInScrollingContainer(): Boolean { + var parent = view.parent + while (parent is ViewGroup) { + if (parent.shouldDelayChildPressedState()) { + return true + } + parent = parent.parent + } + return false + } + + /** + * Whether a touch at ([x], [y]) is still on the view, by the framework's rule. + * + * `View.onTouchEvent` gives up on a press when `!pointInView(x, y, mTouchSlop)` -- when the + * finger leaves the view's bounds grown by the slop, not when it has travelled slop from + * where it went down. Measured from the down point instead, an ordinary thumb tap on a large + * target rolls far enough to cancel its own click without ever leaving the control, and the + * carousel strip is the full width of the editor. + */ + private fun isInside( + x: Float, + y: Float, + ): Boolean = x >= -slop && y >= -slop && x < view.width + slop && y < view.height + slop + + override fun onTouch( + v: View, + event: MotionEvent, + ): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + held = false + holding = true + // The framework starts the ripple from the touch point. Without this every ripple + // on these controls begins at the centre of the drawable instead. + val x = event.x + val y = event.y + val press = + Runnable { + pendingPress = null + v.isPressed = true + v.drawableHotspotChanged(x, y) + } + if (isInScrollingContainer()) { + pendingPress = press + handler.postDelayed(press, ViewConfiguration.getTapTimeout().toLong()) + } else { + press.run() + } + handler.postDelayed(fire, holdMillis) + } + + MotionEvent.ACTION_POINTER_DOWN -> { + // A second finger means this is no longer the single-finger press this listener + // times. The carousel undocks on a two-finger tap anywhere in the strip, and + // without this the finger that started on a button also clicked it, or held long + // enough to open that button's help over a strip that was undocking. + holding = false + handler.removeCallbacks(fire) + releasePress() + } + + MotionEvent.ACTION_MOVE -> { + if (holding && !isInside(event.x, event.y)) { + // Left the control: neither a click nor help, which is how the framework + // treats a drag out of a view. Taking the touch over means saying so. + holding = false + handler.removeCallbacks(fire) + releasePress() + } + } + + MotionEvent.ACTION_UP -> { + handler.removeCallbacks(fire) + releasePress() + // The click belongs to a press that stayed put, did not become a hold, and landed + // on something that answers taps. + // + // That last test is stricter than the framework's, deliberately. View.onTouchEvent + // reads `clickable` once at the top, as CLICKABLE || LONG_CLICKABLE || + // CONTEXT_CLICKABLE, and never re-tests isClickable before performing the click -- + // so a long-clickable view still clicks there. The carousel dims the arrow at + // either end by clearing isClickable rather than isEnabled, precisely so it keeps + // answering a hold, and matching the framework here would have it answer taps too: + // playing the click sound and announcing a click for a control a screen reader is + // being told is unavailable. + if (holding && !held && v.isClickable) { + // Posted rather than called here, as View.onTouchEvent does, so the pressed + // state is drawn before the action runs -- these open dialogs and re-page the + // carousel from inside the dispatch of the event that triggered them. + // + // Through this handler and not View.post, which parks work on an unattached + // view's HandlerActionQueue and returns true having run nothing. Same trap as + // the hold timer, one method along. + val click = + Runnable { + pendingClick = null + v.performClick() + } + pendingClick = click + handler.post(click) + } + holding = false + } + + MotionEvent.ACTION_CANCEL -> { + holding = false + handler.removeCallbacks(fire) + releasePress() + } + } + return true + } } diff --git a/idetooltips/src/main/res/values/ids.xml b/idetooltips/src/main/res/values/ids.xml new file mode 100644 index 0000000000..53b7a6f9d8 --- /dev/null +++ b/idetooltips/src/main/res/values/ids.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/resources/src/main/java/com/itsaky/androidide/resources/TooltipMaterialCheckBox.java b/resources/src/main/java/com/itsaky/androidide/resources/TooltipMaterialCheckBox.java index 49fb9eb6f2..e291a9fc8e 100644 --- a/resources/src/main/java/com/itsaky/androidide/resources/TooltipMaterialCheckBox.java +++ b/resources/src/main/java/com/itsaky/androidide/resources/TooltipMaterialCheckBox.java @@ -9,34 +9,38 @@ import com.google.android.material.checkbox.MaterialCheckBox; /** - * A MaterialCheckBox that implements ITooltipView to provide a unified - * long-press listener for tooltips. + * A MaterialCheckBox that implements ITooltipView to provide a unified long-press listener for tooltips. */ public class TooltipMaterialCheckBox extends MaterialCheckBox implements ITooltipView { - public TooltipMaterialCheckBox(@NonNull Context context) { - super(context); - } - - public TooltipMaterialCheckBox(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public TooltipMaterialCheckBox(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public void setTooltipLongPressListener(OnTooltipLongPressListener listener) { - if (listener == null) { - setOnLongClickListener(null); - return; - } - // Bridge our interface listener to the standard Android OnLongClickListener - setOnLongClickListener(v -> { - listener.onLongPress(); - // Return true to consume the event, preventing other actions - return true; - }); - } -} \ No newline at end of file + public TooltipMaterialCheckBox(@NonNull Context context) { + super(context); + } + + public TooltipMaterialCheckBox(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public TooltipMaterialCheckBox(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void setTooltipLongPressListener(OnTooltipLongPressListener listener) { + if (listener == null) { + setOnLongClickListener(null); + // setOnLongClickListener sets isLongClickable when it installs a listener but does not + // unset it when the listener is removed, so without this the checkbox goes on + // consuming long presses -- and showing the platform's long-press feedback -- for a + // tooltip it no longer offers. + setLongClickable(false); + return; + } + // Bridge our interface listener to the standard Android OnLongClickListener + setOnLongClickListener(v -> { + listener.onLongPress(); + // Return true to consume the event, preventing other actions + return true; + }); + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index bab84b6648..bf47ef4a41 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1167,6 +1167,7 @@ No APK found in output listing file. APK file specified does not exist: %1$s Build was cancelled by the user. + Could not stop the build. Quick Run failed. Building… Installing plugin… @@ -1680,4 +1681,36 @@ Dock to editor Close + Memory usage chart + Memory usage + Network traffic chart + Network traffic + Metrics are in a floating window.\nSingle-tap to bring them back. + Metrics + Sampling rate + Every %1$s + %1$s (needs a 64-bit device) + Temperature and power + + Build started + Build finished + Build failed + Build cancelled + Temperature and power chart + Battery temp + Power + Previous metric + Next metric + Save chart image + Couldn\'t save the chart image. + Save metrics data + Couldn\'t save the metrics data. + Received + Sent + now + n/a + + %1$s %2$s + + diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt index dd5e0b8c26..229946218a 100644 --- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt +++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.tooling.impl import com.itsaky.androidide.tooling.api.IToolingApiClient import com.itsaky.androidide.tooling.api.IToolingApiServer +import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.ClientGradleBuildConfig import com.itsaky.androidide.tooling.api.messages.GradleDistributionParams import com.itsaky.androidide.tooling.api.messages.GradleDistributionType @@ -143,14 +144,9 @@ internal class ToolingApiServerImpl : IToolingApiServer { return@runBuild doInitialize(params, start) } catch (err: Throwable) { log.error("Failed to initialize project", err) - notifyBuildFailure( - BuildResult( - tasks = emptyList(), - buildId = params.buildId, - durationMs = System.currentTimeMillis() - start, - ), + return@runBuild InitializeResult.Failure( + notifyBuildFailure(params.buildId, emptyList(), start, err), ) - return@runBuild InitializeResult.Failure(getTaskFailureType(err)) } } } @@ -218,7 +214,17 @@ internal class ToolingApiServerImpl : IToolingApiServer { clientConfig = clientConfig, ) - RootModelBuilder.build(params, modelBuilderParams) + try { + RootModelBuilder.build(params, modelBuilderParams) + } finally { + // The sync path never cleared this on any outcome -- only shutdown() and an actual + // Stop did. So after every sync a token for a finished build sat here: the next + // Stop cancelled that dead source and answered wasEnqueued = true with no build + // running, and the check at the top of this method cancelled it again on the next + // initialize. The sibling of the same omission in executeTasks. + buildCancellationToken = null + } + notifyBuildSuccess( BuildResult( tasks = emptyList(), @@ -305,28 +311,30 @@ internal class ToolingApiServerImpl : IToolingApiServer { try { builder.run() - this.buildCancellationToken = null - notifyBuildSuccess( - result = - BuildResult( - tasks = message.tasks, - buildId = message.buildId, - durationMs = System.currentTimeMillis() - start, - ), - ) - return@runBuild TaskExecutionResult.SUCCESS } catch (error: Throwable) { log.error("Failed to run tasks: {}", message.tasks, error) - notifyBuildFailure( - result = - BuildResult( - tasks = message.tasks, - buildId = message.buildId, - durationMs = System.currentTimeMillis() - start, - ), + return@runBuild TaskExecutionResult( + false, + notifyBuildFailure(message.buildId, message.tasks, start, error), ) - return@runBuild TaskExecutionResult(false, getTaskFailureType(error)) + } finally { + // On both paths. Only the success path cleared it, so every failed build left a + // token behind for a source that was already finished. The next Stop then + // cancelled that dead source and answered wasEnqueued = true while the live build + // ran on, and [initialize] -- which cancels first whenever one is set -- paid for + // a build that had ended long before. + this.buildCancellationToken = null } + + notifyBuildSuccess( + result = + BuildResult( + tasks = message.tasks, + buildId = message.buildId, + durationMs = System.currentTimeMillis() - start, + ), + ) + return@runBuild TaskExecutionResult.SUCCESS } } @@ -368,8 +376,31 @@ internal class ToolingApiServerImpl : IToolingApiServer { ) } - private fun notifyBuildFailure(result: BuildResult) { - client?.onBuildFailed(result) + /** + * Tells the client a build failed, and answers with why. + * + * Both in one call on purpose. The classification and the notification used to be written + * separately at each failure site, which is how the notified [BuildResult] came to carry + * everything except the answer while the caller of the request got it (ADFA-5542). A site + * cannot now report a failure without saying which, or say one thing to the client and another + * to its caller. + */ + private fun notifyBuildFailure( + buildId: BuildId, + tasks: List, + startedAtMillis: Long, + error: Throwable, + ): Failure { + val failure = getTaskFailureType(error) + client?.onBuildFailed( + BuildResult( + buildId = buildId, + tasks = tasks, + durationMs = System.currentTimeMillis() - startedAtMillis, + failure = failure, + ), + ) + return failure } private fun notifyBuildSuccess(result: BuildResult) { diff --git a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt index d0b127de24..9f1c4501ba 100644 --- a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt +++ b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImplTest.kt @@ -1,8 +1,11 @@ package com.itsaky.androidide.tooling.impl import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.tooling.api.IToolingApiClient import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams +import com.itsaky.androidide.tooling.api.messages.result.BuildCancellationRequestResult +import com.itsaky.androidide.tooling.api.messages.result.BuildResult import com.itsaky.androidide.tooling.api.messages.result.InitializeResult import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.tooling.api.messages.result.isSuccessful @@ -11,8 +14,10 @@ import com.itsaky.androidide.tooling.impl.sync.RootModelBuilder import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.slot import io.mockk.spyk import io.mockk.verify +import org.gradle.tooling.BuildCancelledException import org.gradle.tooling.GradleConnector import org.gradle.tooling.ProjectConnection import org.junit.Test @@ -30,9 +35,11 @@ class ToolingApiServerImplTest { directory: String = "/does/not/exist", forceSync: Boolean = false, ) = InitializeProjectParams( + // Required since ADFA-2784 added it, and never supplied here: this file has not compiled + // on stage since, and no workflow runs :subprojects: tests, so nothing said so. + buildId = BuildId.Unknown, directory = directory, needsGradleSync = forceSync, - buildId = BuildId.Unknown, ) private data class MockServer( @@ -86,6 +93,80 @@ class ToolingApiServerImplTest { assertThat((result as InitializeResult.Failure).failure).isEqualTo(TaskExecutionResult.Failure.UNKNOWN) } + @Test + fun `GIVEN a build the user stopped WHEN it fails THEN the client is told it was a cancel`() { + mockkObject(RootModelBuilder) + every { + // Gradle raises this, and only this, for a build that was cancelled. + RootModelBuilder.build(any(), any()) + } throws BuildCancelledException("stopped by the user") + + val (server) = mockkToolingServer() + + every { + server.validateProjectDirectory(any()) + } returns null + + val client = mockk(relaxed = true) + server.connect(client) + + val result = server.initialize(testInitParams()).get(5, TimeUnit.SECONDS) + assertThat((result as InitializeResult.Failure).failure) + .isEqualTo(TaskExecutionResult.Failure.BUILD_CANCELLED) + + // The same verdict has to reach the client, not only the caller of initialize. It did not, + // and the editor was left reconstructing "was that a cancel?" from the order its own + // callbacks happened to arrive in -- which it got wrong, annotating a build the user had + // stopped as a failure (ADFA-5542). + // + // This drives the sync path. The task-run path is the one the ticket is really about, and + // standing it up needs a live ProjectConnection; instead of testing the two separately, + // notifyBuildFailure now classifies and notifies in one call and hands the answer back, so + // neither site can report a failure without saying which, or tell the client one thing and + // its caller another. There is one place left to get this wrong and this covers it. + val reported = slot() + verify { client.onBuildFailed(capture(reported)) } + assertThat(reported.captured.failure).isEqualTo(TaskExecutionResult.Failure.BUILD_CANCELLED) + } + + @Test + fun `GIVEN a sync that finished WHEN a Stop arrives THEN there is no build to cancel`() { + mockkObject(RootModelBuilder) + every { RootModelBuilder.build(any(), any()) } returns File("/does/not/exist/cache") + + val (server) = mockkToolingServer() + every { server.validateProjectDirectory(any()) } returns null + server.connect(mockk(relaxed = true)) + + server.initialize(testInitParams()).get(5, TimeUnit.SECONDS) + + // The token for the sync's own build was never cleared on any outcome, so it outlived the + // build it belonged to. A Stop pressed afterwards cancelled that dead source and answered + // "enqueued" -- telling the user a build was being stopped when none was running, and, once + // a real build had started, leaving it running while claiming otherwise. + val result = server.cancelCurrentBuild().get(5, TimeUnit.SECONDS) + + assertThat(result.wasEnqueued).isFalse() + assertThat(result.failureReason).isEqualTo(BuildCancellationRequestResult.Reason.NO_RUNNING_BUILD) + } + + @Test + fun `GIVEN a sync that failed WHEN a Stop arrives THEN there is no build to cancel`() { + mockkObject(RootModelBuilder) + every { RootModelBuilder.build(any(), any()) } throws RuntimeException("intentional failure") + + val (server) = mockkToolingServer() + every { server.validateProjectDirectory(any()) } returns null + server.connect(mockk(relaxed = true)) + + server.initialize(testInitParams()).get(5, TimeUnit.SECONDS) + + // The failing path leaked it the same way the succeeding one did. + val result = server.cancelCurrentBuild().get(5, TimeUnit.SECONDS) + + assertThat(result.wasEnqueued).isFalse() + } + @Test fun `GIVEN force sync not requested WHEN sync files are unreadable THEN sync anyway`() { val initParams = testInitParams(forceSync = false) diff --git a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt index 46fb9babba..baf6359b9d 100644 --- a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt +++ b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/BuildResult.kt @@ -28,4 +28,20 @@ data class BuildResult( val buildId: BuildId, val tasks: List, val durationMs: Long, + /** + * Why the build failed. + * + * `null` on a successful build, which this type also carries. On a failed one this server + * always fills it in -- `notifyBuildFailure` classifies the throwable and returns a + * non-null [TaskExecutionResult.Failure], and both catch paths go through it -- so a client + * seeing `null` here alongside a failure is talking to a server that does not classify, not + * to this one. Nullable on the wire for exactly that case. + * + * The server is the only party that can answer this: Gradle raises a + * `BuildCancelledException` for a build the user stopped, and the same throwable that decides + * the [TaskExecutionResult] decides this. Without it a client had to reconstruct "was that a + * cancel?" from the order its own callbacks happened to arrive in, and got it wrong + * (ADFA-5542). + */ + val failure: TaskExecutionResult.Failure? = null, )