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 4d855dbea7..0287967d4d 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 @@ -59,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 @@ -66,7 +67,6 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.viewpager2.widget.ViewPager2 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 @@ -119,10 +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.MemoryUsageChartRenderer -import com.itsaky.androidide.ui.MetricsCarouselAdapter -import com.itsaky.androidide.ui.MetricsPage -import com.itsaky.androidide.ui.NetworkUsageChartRenderer +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 @@ -132,7 +129,6 @@ 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.NetworkUsageWatcher import com.itsaky.androidide.utils.StringsInjectionException import com.itsaky.androidide.utils.StringsXmlInjector import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation @@ -154,6 +150,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 @@ -189,22 +186,29 @@ abstract class BaseEditorActivity : protected var editorBottomSheet: BottomSheetBehavior? = null private var drawerToggle: ActionBarDrawerToggle? = null private var bottomSheetCallback: BottomSheetBehavior.BottomSheetCallback? = null - protected val memoryUsageWatcher = MemoryUsageWatcher() - private var metricsPageCallback: ViewPager2.OnPageChangeCallback? = null - private val memUsageChartRenderer = - MemoryUsageChartRenderer( - usagesProvider = memoryUsageWatcher::getMemoryUsages, + 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 metricsCarousel by lazy { + MetricsCarouselController( + memoryUsageWatcher = memoryUsageWatcher, + networkUsageWatcher = networkUsageWatcher, lineColorFor = Companion::getMemUsageLineColorFor, + annotations = metricsViewModel.annotations, ) + } - private val networkUsageWatcher = NetworkUsageWatcher() - private val networkUsageChartRenderer = - NetworkUsageChartRenderer(usageProvider = networkUsageWatcher::getUsage) - - private val networkUsageListener = - NetworkUsageWatcher.NetworkUsageListener { usage -> - networkUsageChartRenderer.onUsageChanged(usage) - } + /** Records a significant event for the charts to annotate (ADFA-5486). */ + fun recordMetricsAnnotation(label: String) { + metricsViewModel.annotations.record(label) + } private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null @@ -325,11 +329,6 @@ abstract class BaseEditorActivity : } } - private val memoryUsageListener = - MemoryUsageWatcher.MemoryUsageListener { memoryUsage -> - memUsageChartRenderer.onUsagesChanged(memoryUsage) - } - private val shizukuBinderReceivedListener = Shizuku.OnBinderReceivedListener { invalidateOptionsMenu() @@ -453,13 +452,16 @@ abstract class BaseEditorActivity : /** * The plot colour for a watched process. * - * On the companion rather than the activity: a bound reference to an activity method is - * handed to the renderer, which the carousel adapter holds, so any path that misses the - * adapter teardown would keep the whole editor reachable. Nothing here needs an activity. + * 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. The renderer now reaches this 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. + * 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 fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = @@ -548,21 +550,22 @@ abstract class BaseEditorActivity : fullscreenManager?.destroy() fullscreenManager = null - metricsPageCallback?.let { callback -> - _binding?.memUsageView?.metricsPager?.unregisterOnPageChangeCallback(callback) + // 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. + if (!isMetricsCarouselUndocked() || isDestroying) { + metricsCarousel.unbind() + } + if (isDestroying) { + metricsCarousel.close() } - metricsPageCallback = null - _binding?.memUsageView?.metricsPager?.adapter = null - memUsageChartRenderer.detach() - networkUsageChartRenderer.detach() _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 - // close(), not stopWatching(): this is the terminal teardown, and the watcher holds a - // dedicated sampling thread that newSingleThreadContext keeps alive until it is closed. - networkUsageWatcher.close() + networkUsageWatcher.listener = null editorActivityScope.cancelIfActive("Activity is being destroyed") unbindDebuggerService() @@ -999,45 +1002,63 @@ abstract class BaseEditorActivity : content.editorAppBarLayout.updatePadding(top = topInset) } - // translationY, not a margin: this runs on every frame of the reveal drag, and a - // margin change calls requestLayout, which now re-measures a ViewPager2, its - // RecyclerView and every attached page rather than the single chart view it used to. - // The visual result is identical for a pure vertical offset. - memUsageView.metricsPager.translationY = insetsTop * progress + metricsCarousel.pager?.updateLayoutParams { + topMargin = (insetsTop * progress).roundToInt() + } } } private fun setupMetricsCarousel() { - val pages = - listOf( - // The memory chart is the default page (ADFA-5487); network traffic is the second - // (ADFA-5489), replacing the brand-mark placeholder that ADFA-5487 shipped. - MetricsPage.MemoryChart(title = string.metrics_title_memory), - MetricsPage.NetworkChart(title = string.metrics_title_network), - ) + 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()) + } - binding.memUsageView.metricsPager.adapter = - MetricsCarouselAdapter(pages, memUsageChartRenderer, networkUsageChartRenderer) + /** + * 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 - val showTitleFor = { position: Int -> - pages.getOrNull(position)?.let { page -> - binding.memUsageView.metricsTitle.setText(page.title) - } - } + /** Whether the carousel is currently floating rather than docked here. */ + protected open fun isMetricsCarouselUndocked(): Boolean = false - metricsPageCallback = - object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - showTitleFor(position) - } - }.also { binding.memUsageView.metricsPager.registerOnPageChangeCallback(it) } + /** 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) - // onPageSelected does not fire for the page the carousel opens on. - showTitleFor(binding.memUsageView.metricsPager.currentItem) + if (undocked) { + metricsCarousel.unbind() + } else { + metricsCarousel.bind(view) + metricsCarousel.refresh() + } } private fun watchMemory() { - memoryUsageWatcher.listener = memoryUsageListener memoryUsageWatcher.watchProcess(Process.myPid(), PROC_IDE) resetMemUsageChart() } @@ -1047,15 +1068,21 @@ abstract class BaseEditorActivity : * watching a process. */ protected fun resetMemUsageChart() { - memUsageChartRenderer.rebuild() + metricsCarousel.onWatchedProcessesChanged() } override fun onPause() { super.onPause() - memoryUsageWatcher.listener = null - memoryUsageWatcher.stopWatching(false) - networkUsageWatcher.listener = null - networkUsageWatcher.stopWatching() + // 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() @@ -1072,18 +1099,21 @@ abstract class BaseEditorActivity : log.warn("Unable to move debugger overlay to display {}", displayId, err) } - // Not for an instance onCreate already abandoned: the deep-link path calls finish() and - // returns, yet the platform still runs onStart and onResume. The memory watcher is immune - // by design -- it early-returns on an empty process set -- but the network sampler would - // poll TrafficStats and hop to the main thread once a second for an activity with no - // chart to render into. - if (didCompleteLiveOnCreate) { - memoryUsageWatcher.listener = memoryUsageListener + if (!isMetricsCarouselUndocked()) { + _binding?.let { metricsCarousel.bind(it.memUsageView) } + } + if (!memoryUsageWatcher.isWatching) { memoryUsageWatcher.startWatching() - networkUsageWatcher.listener = networkUsageListener + } + if (!networkUsageWatcher.isWatching) { networkUsageWatcher.startWatching() } + if (!isMetricsCarouselUndocked()) { + // Draw whatever was sampled while away, rather than waiting for the next tick. + metricsCarousel.refresh() + } + apkInstallationViewModel.reloadStatus(this) try { 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 255a18bc08..382fba636c 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 @@ -903,6 +904,24 @@ 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) + + /** 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/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..e35c79b40b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.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.editor.floating + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.floating.model.DockableContent +import com.itsaky.androidide.floating.window.FloatingWindowHost +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 + + override fun onCreateView( + context: Context, + host: FloatingWindowHost, + ): View { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + + // 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, + ) + + // 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() { + controller.unbind() + } + + 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 ba7a9975b1..03671a8e12 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 @@ -28,6 +29,7 @@ import com.itsaky.androidide.services.builder.GradleBuildService import com.itsaky.androidide.tooling.api.messages.result.BuildInfo 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.flashError import com.itsaky.androidide.utils.flashSuccess @@ -140,13 +142,30 @@ 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) } } + /** + * 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) { val act = checkActivity("onBuildFailed") ?: return diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt index 8d348944a7..7e9d48bac5 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -21,6 +21,7 @@ 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 @@ -28,8 +29,9 @@ import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.itsaky.androidide.R 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 com.itsaky.androidide.utils.resolveAttr +import kotlin.math.max import kotlin.math.roundToLong /** @@ -50,53 +52,31 @@ import kotlin.math.roundToLong class MemoryUsageChartRenderer( private val usagesProvider: () -> Array, private val lineColorFor: (ProcessMemoryInfo) -> Int, -) { - private var chart: SafeLineChart? = null - + 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) - /** - * Attaches [chart], applies the static chart configuration, and renders the full current - * history. Replaces any previously attached chart. - */ - @UiThread - fun attach(chart: SafeLineChart) { - this.chart = chart - configure(chart) - rebuild() - } - - /** - * Detaches the current chart. Sample history is unaffected; a later [attach] renders it in full. - */ @UiThread - fun detach() { - chart = null + override fun detach() { + super.detach() pidToDatasetIdx.clear() } - /** - * Detaches [chart] only if it is the currently attached one. Use from a recycling container, - * where the replacement view can be bound before the view it replaces is recycled. - */ - @UiThread - fun detachIfAttached(chart: SafeLineChart) { - if (this.chart === chart) { - detach() - } - } - /** * 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 - fun rebuild() { + override fun rebuild() { val chart = this.chart ?: return val processes = usagesProvider() @@ -113,6 +93,11 @@ class MemoryUsageChartRenderer( }, 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) @@ -125,21 +110,42 @@ class MemoryUsageChartRenderer( } } - val bgColor = chart.context.resolveAttr(R.attr.colorSurfaceDim) - val textColor = chart.context.resolveAttr(R.attr.colorOnSurface) + applyAxisRange(chart, processes) + setData(chart, datasets) + } - chart.apply { - data = LineData(*datasets) - axisRight.textColor = textColor - axisLeft.textColor = textColor - legend.textColor = textColor + /** + * 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) } - data.setValueTextColor(textColor) - setBackgroundColor(bgColor) - setGridBackgroundColor(bgColor) - notifyDataSetChanged() - invalidate() + /** + * 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) } /** @@ -182,40 +188,32 @@ class MemoryUsageChartRenderer( } if (dataChanged) { - chart.apply { - data.notifyDataChanged() - notifyDataSetChanged() - invalidate() + // From the samples already in hand: usagesProvider() copies every history, so calling + // it again here would snapshot the whole buffer a second time per tick. + applyAxisRangeFor(chart) { visit -> + memoryUsage.forEachValue { visit(it) } } + redraw(chart) } } - /** - * Applies the configuration that does not depend on the samples. Idempotent. - */ - private fun configure(chart: SafeLineChart) { - chart.apply { - val colorAccent = context.resolveAttr(R.attr.colorAccent) - - isDragEnabled = false - description.isEnabled = false - xAxis.axisLineColor = colorAccent - axisRight.axisLineColor = colorAccent - - setPinchZoom(false) - setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) - setDrawGridBackground(true) - setScaleEnabled(true) - - axisLeft.isEnabled = false - axisRight.valueFormatter = - object : IAxisValueFormatter { - override fun getFormattedValue( - value: Float, - axis: AxisBase?, - ): String = "%dMB".format(value.roundToLong()) - } - } + 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( 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..822e29916e --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -0,0 +1,521 @@ +/* + * 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.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.annotation.UiThread +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.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.MetricsSamplingRates +import com.itsaky.androidide.utils.MetricsSnapshot +import com.itsaky.androidide.utils.NetworkUsageWatcher +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, + 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 pages = + listOf( + // The memory chart is the default page (ADFA-5487); network traffic is the second + // (ADFA-5489), replacing the brand-mark placeholder that ADFA-5487 shipped. + MetricsPage.MemoryChart(title = string.metrics_title_memory), + MetricsPage.NetworkChart(title = string.metrics_title_network), + ) + + 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 an export is already running. + * + * 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. + */ + private var exportInFlight = 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, memoryRenderer, networkRenderer) + + // 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) + // A page left zoomed would keep claiming horizontal drags when swiped back to. + memoryRenderer.resetZoom() + networkRenderer.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() } + + // 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() } + + // 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) } + updateArrows(binding.metricsPager.currentItem) + + memoryUsageWatcher.listener = memoryListener + networkUsageWatcher.listener = networkListener + } + + /** + * 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 + } + + memoryRenderer.onXAxisTap = null + networkRenderer.onXAxisTap = null + binding?.metricsSnapshot?.setOnClickListener(null) + binding?.metricsPrevious?.setOnClickListener(null) + binding?.metricsNext?.setOnClickListener(null) + pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } + pageCallback = null + + binding?.metricsPager?.adapter = null + memoryRenderer.detach() + networkRenderer.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 + binding.metricsPrevious.isEnabled = position > 0 + binding.metricsNext.isEnabled = position < pages.lastIndex + binding.metricsPrevious.alpha = if (position > 0) 1f else DISABLED_ARROW_ALPHA + binding.metricsNext.alpha = if (position < pages.lastIndex) 1f else DISABLED_ARROW_ALPHA + } + + /** + * 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() } + .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) + } + + /** + * 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, + ) + memoryUsageWatcher.updateInterval = supported + networkUsageWatcher.updateInterval = supported + // The annotations go with the samples they annotate. Left behind, task markers stood over + // a flat zero line with nothing to mark -- and this is the only route by which the store's + // throttle window is ever reset. + 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 (exportInFlight) { + 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 = + when (page) { + is MetricsPage.MemoryChart -> memoryRenderer + is MetricsPage.NetworkChart -> networkRenderer + } + + 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 + exportInFlight = true + scope.launch { + // Everything here is guarded: the scope has no exception handler, so anything escaping + // reaches the global crash reporter and is filed as a crash. MetricsSnapshot.write + // 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) { + // Recycled as soon as it has been encoded: getChartBitmap hands back a + // fresh full-size ARGB_8888 copy of the plot on every tap, which is + // megabytes that would otherwise sit around until the collector noticed. + try { + MetricsSnapshot.write(appContext, bitmap, label) + } finally { + bitmap.recycle() + } + } + // 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. + exportInFlight = false + throw failure + } + log.error("Could not share the chart snapshot", failure) + Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + } + exportInFlight = false + } + return true + } + + /** + * 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() + } + + /** + * 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 DISABLED_ARROW_ALPHA = 0.35f + + /** 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 index 79dd92c872..c55989037d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -20,16 +20,21 @@ 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. * - * The carousel pages with a horizontal swipe, but a left-to-right swipe elsewhere in the editor - * opens the navigation drawer -- documented behaviour, shown in the editor's own onboarding text. - * Without this, the carousel could only page forwards. Asking every ancestor not to intercept, for - * the rest of the gesture, hands horizontal drags that start in this strip to [ViewPager2] and - * leaves the drawer gesture untouched everywhere else. + * 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 @@ -46,11 +51,168 @@ class MetricsCarouselLayout attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : ConstraintLayout(context, attrs, defStyleAttr) { - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + /** + * 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 + + /** + * Shows either the carousel or the "it is in a floating window" message, never a mix. + * + * The whole strip switches, not just the pager. The arrows and the snapshot button are + * chrome for a chart that is not here: left behind they sit over the message, and the + * camera is inert anyway because undocking unbinds the controller that listens to it. + * Keeping the set here rather than at the call site is what stops a control added later + * from being forgotten again. + */ + fun setUndocked(undocked: Boolean) { + val carouselIds = + intArrayOf( + R.id.metrics_pager, + R.id.metrics_title, + R.id.metrics_previous, + R.id.metrics_next, + R.id.metrics_snapshot, + ) + carouselIds.forEach { id -> + findViewById(id)?.isVisible = !undocked + } + 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) { - // Cleared by the framework on the next ACTION_DOWN, so this lasts exactly one gesture. - parent?.requestDisallowInterceptTouchEvent(true) + 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, + ) } - return super.onInterceptTouchEvent(ev) + 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..1d2cbdf79a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -0,0 +1,424 @@ +/* + * 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.os.SystemClock +import android.view.MotionEvent +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.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.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.resolveAttr +import kotlin.math.ceil +import kotlin.math.floor +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 + + /** + * 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 attached chart, or `null` when no carousel page is bound to this renderer. + */ + protected var chart: SafeLineChart? = null + private set + + /** + * Attaches [chart], applies configuration, and renders the full current history. + */ + @UiThread + fun attach(chart: SafeLineChart) { + this.chart = chart + configure(chart) + rebuild() + } + + /** + * Detaches the current chart. Sample history is unaffected; a later [attach] renders it in full. + */ + @UiThread + @CallSuper + open fun detach() { + userHasZoomed = false + 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 + + onChartGestureListener = XAxisTapListener(this) + + xAxis.valueFormatter = ElapsedTimeFormatter(sampleIntervalMillis) + // 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 + } + + chart.setVisibleXRangeMaximum(VISIBLE_SAMPLES.toFloat()) + chart.moveViewToX(newestIndex - VISIBLE_SAMPLES.toFloat() + 1f) + } + + /** + * 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 { + override fun onChartSingleTapped(me: MotionEvent?) { + val y = me?.y ?: return + if (y >= chart.viewPortHandler.contentBottom()) { + onXAxisTap?.invoke() + } + } + + override fun onChartGestureStart( + me: MotionEvent?, + lastPerformedGesture: ChartTouchListener.ChartGesture?, + ) = Unit + + override fun onChartGestureEnd( + me: MotionEvent?, + lastPerformedGesture: ChartTouchListener.ChartGesture?, + ) = Unit + + override fun onChartLongPressed(me: MotionEvent?) = Unit + + 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 + } + + 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 + } + } + + /** + * 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, + ) : 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) "now" else "-%ds".format(secondsAgo) + } + } + + /** + * Installs [datasets] on [chart] and applies the theme colours, then redraws. + */ + protected fun setData( + chart: SafeLineChart, + datasets: Array, + ) { + val bgColor = chart.context.resolveAttr(R.attr.colorSurfaceDim) + val textColor = chart.context.resolveAttr(R.attr.colorOnSurface) + + chart.apply { + data = LineData(*datasets) + axisRight.textColor = textColor + axisLeft.textColor = textColor + legend.textColor = textColor + // MPAndroidChart defaults every component's text to Color.BLACK. The y axis and legend + // were given a themed colour and the x axis never was, so its labels have always been + // 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) + setBackgroundColor(bgColor) + setGridBackgroundColor(bgColor) + notifyDataSetChanged() + } + applyAnnotations(chart) + showNewestWindow(chart) + chart.invalidate() + } + + /** + * 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. + */ + 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() + val markerColor = chart.context.resolveAttr(R.attr.colorOnSurface) + + 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, annotation.label).apply { + lineWidth = ANNOTATION_LINE_WIDTH + lineColor = markerColor + textColor = markerColor + enableDashedLine(ANNOTATION_DASH_LENGTH, ANNOTATION_DASH_LENGTH, 0f) + labelPosition = LimitLine.LimitLabelPosition.RIGHT_BOTTOM + }, + ) + } + } + + /** + * Redraws after the attached series have been mutated in place. + */ + protected fun redraw(chart: SafeLineChart) { + 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() + } + + private 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 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt index 2dae31b2f2..989139e676 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -22,13 +22,13 @@ 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.LineData import com.github.mikephil.charting.data.LineDataSet import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.itsaky.androidide.R +import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.NetworkUsageWatcher.NetworkUsage -import com.itsaky.androidide.utils.resolveAttr +import java.util.Locale import kotlin.math.ceil import kotlin.math.log10 import kotlin.math.max @@ -58,63 +58,31 @@ import kotlin.math.roundToLong */ class NetworkUsageChartRenderer( private val usageProvider: () -> NetworkUsage, -) { - private var chart: SafeLineChart? = null - - @UiThread - fun attach(chart: SafeLineChart) { - this.chart = chart - configure(chart) - rebuild() - } - - @UiThread - fun detach() { - chart = null - } - - /** - * Detaches [chart] only if it is the currently attached one. See - * [MemoryUsageChartRenderer.detachIfAttached]. - */ - @UiThread - fun detachIfAttached(chart: SafeLineChart) { - if (this.chart === chart) { - detach() - } - } - + annotations: MetricsAnnotationStore? = null, + private val sampleInterval: () -> Long = { NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL }, +) : MetricsChartRenderer( + sampleIntervalMillis = sampleInterval, + annotations = annotations, + ) { /** * Rebuilds both series from the full sample history. */ @UiThread - fun rebuild() { + override fun rebuild() { val chart = this.chart ?: return val usage = usageProvider() - val textColor = chart.context.resolveAttr(R.attr.colorOnSurface) - val bgColor = chart.context.resolveAttr(R.attr.colorSurfaceDim) - val datasets = arrayOf( dataset(usage.received, chart.context.getString(R.string.metrics_network_received), RECEIVED_COLOR), dataset(usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted), TRANSMITTED_COLOR), ) + setData(chart, datasets) + // After, not before: setData is what scrolls the window to the newest samples, and the + // range is derived from what that window ends up showing. applyAxisRange(chart, usage) - - chart.apply { - data = LineData(*datasets) - axisRight.textColor = textColor - axisLeft.textColor = textColor - legend.textColor = textColor - - data.setValueTextColor(textColor) - setBackgroundColor(bgColor) - setGridBackgroundColor(bgColor) - notifyDataSetChanged() - invalidate() - } + chart.invalidate() } /** @@ -144,13 +112,9 @@ class NetworkUsageChartRenderer( update(received, usage.received, chart.context.getString(R.string.metrics_network_received)) update(transmitted, usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted)) + redraw(chart) applyAxisRange(chart, usage) - - chart.apply { - data.notifyDataChanged() - notifyDataSetChanged() - invalidate() - } + chart.invalidate() } private fun dataset( @@ -190,10 +154,21 @@ class NetworkUsageChartRenderer( 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( label: String, bytes: Long, - ): String = "%s - %s/s".format(label, formatBytes(bytes.toDouble(), decimals = 1)) + ): String = "%s - %s/s".format(label, 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]. @@ -207,31 +182,28 @@ class NetworkUsageChartRenderer( chart: SafeLineChart, usage: NetworkUsage, ) { - val peak = max(usage.received.maxOrNull() ?: 0L, usage.transmitted.maxOrNull() ?: 0L) + // 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) } - private fun configure(chart: SafeLineChart) { - chart.apply { - val colorAccent = context.resolveAttr(R.attr.colorAccent) - - isDragEnabled = false - description.isEnabled = false - xAxis.axisLineColor = colorAccent - axisRight.axisLineColor = colorAccent - - setPinchZoom(false) - setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) - setDrawGridBackground(true) - setScaleEnabled(true) - - axisLeft.isEnabled = false - axisRight.valueFormatter = BytesAxisFormatter + 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. - axisRight.granularity = 1f - axisRight.isGranularityEnabled = true + granularity = 1f + isGranularityEnabled = true } } @@ -266,6 +238,8 @@ class NetworkUsageChartRenderer( */ 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 @@ -296,9 +270,9 @@ private fun formatBytes( ): 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) + clamped < 1_000 -> "%d B".format(Locale.US, clamped.roundToLong()) + clamped < 1_000_000 -> "%.${decimals}f kB".format(Locale.US, clamped / 1_000) + clamped < 1_000_000_000 -> "%.${decimals}f MB".format(Locale.US, clamped / 1_000_000) + else -> "%.${decimals}f GB".format(Locale.US, clamped / 1_000_000_000) } } 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 0e531ae964..229a103e27 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -26,10 +26,13 @@ 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 @@ -38,248 +41,370 @@ import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +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, + ) { + /** + * 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 + private val memoryUsage = ConcurrentHashMap() + + /** + * 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) + + /** + * 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 { + 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" + } + } - /** - * Start watching processes for their memory usage. - */ - fun startWatching() { - if (isWatching) { - log.warn("Processes are already being watched for memory usage") - return + /** + * Samples retained per series: nearly three hours at [DEFAULT_UPDATE_INTERVAL] (ADFA-5486). + * About 80KB of longs per series, so the cost is in drawing rather than holding -- + * see MetricsChartRenderer, which shows a window of this rather than all of it. + */ + const val MAX_USAGE_ENTRIES = 10000 + 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) + samplingJob = + coroutineScope.launch { + while (isWatching) { + // 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 -> + val usages = MutableIntObjectMap(memoryUsage.size) + for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { + usages[pid] = usage + } + 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) + private fun readUsages() { + if (memoryUsage.isEmpty()) { + // Nothing to sample. Returning before the service lookup keeps an idle watcher off + // BaseApplication, which a unit test does not have. + return } - } - } - private fun readUsages() { - val activityManager = BaseApplication.baseInstance.getSystemService() - if (activityManager == null) { - log.error("ActivityManager is null") - return - } + val activityManager = BaseApplication.baseInstance.getSystemService() + if (activityManager == null) { + log.error("ActivityManager is null") + return + } - val pids = memoryUsage.keys.toIntArray() - pids.forEach { pid -> + val pids = memoryUsage.keys.toIntArray() + pids.forEach { pid -> - // 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 - } + // 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 + } - ReflectionUtils.invokeMethod(android_os_Debug_getMemoryInfo, null, pid, proc.memInfo) + 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." - val usage = proc.memInfo.totalPss + // 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." + val usage = proc.memInfo.totalPss - // values are in kB, convert to bytes - val usageBytes = usage * 1024L - memoryUsage[pid]!!.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 + // values are in kB, convert to bytes + val usageBytes = usage * 1024L + memoryUsage[pid]!!.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 + // 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) + // 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) + synchronized(historyLock) { + _history[0] = usageBytes + _history.shift(1) + } + } } } - } - /** - * 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 + /** + * 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 + } + + if (unique) { + // unwatch the process with the given process name + unwatchProcess(pname) + } + + memoryUsage[pid] = + ProcessMemoryInfo( + pid, + pname, + MutableShiftedLongArray(MAX_USAGE_ENTRIES), + ) } - 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() } + } } - memoryUsage[pid] = - ProcessMemoryInfo( - pid, - pname, - MutableShiftedLongArray(MAX_USAGE_ENTRIES), - ) - } + /** + * 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. + Array(memoryUsage.size) { index -> memoryUsage.values.elementAt(index).snapshot() } + } - /** - * 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) - } + /** + * 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) + } - /** - * 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) + /** + * 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) + } } } - } - /** - * Unwatches all the registered processes. - */ - fun unwatchAll() { - memoryUsage.clear() - } + /** + * Unwatches all the registered processes. + */ + fun unwatchAll() { + memoryUsage.clear() + } - /** - * Stop watching processes for their memory usage. - */ - fun stopWatching(unwatchAll: Boolean = true) { - if (unwatchAll) { - unwatchAll() + /** + * 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. + samplingJob?.cancel() + samplingJob = null } - 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. + * Stops sampling and releases the sampling thread. The watcher cannot be started again. * - * @param memoryUsage The memory usage of all the registered processes. + * 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 onMemoryUsageChanged(memoryUsage: IntObjectMap) - } - - /** - * 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() - - val usageHistory: ShiftedLongArray - get() = _history - - 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 + fun close() { + closed.set(true) + stopWatching() + listener = null + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() + } - 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, + ) { + internal val memInfo: MemoryInfo = MemoryInfo() + + val usageHistory: ShiftedLongArray + get() = _history + + /** + * A copy of this process's history, safe to read while the sampler keeps appending. + * + * The MemoryInfo instance is shared deliberately: it is the sampler's scratch buffer + * for the next reading and no reader looks at it. + */ + internal fun snapshot(): ProcessMemoryInfo = ProcessMemoryInfo(pid, pname, _history.copy()) + + 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..c684ed0c81 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -0,0 +1,123 @@ +/* + * 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 + +/** + * Records significant events for the metrics charts to annotate (ADFA-5486). + * + * Significant means Gradle task starts and stops. A real build emits far too many of those to draw + * -- dozens a second during configuration -- so they 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. + * + * 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 + + /** + * 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, + ) + + /** + * Records [label] unless another annotation was recorded within [THROTTLE_INTERVAL_MS]. + * + * @return whether it was recorded. + */ + @Synchronized + fun record(label: String): Boolean { + val now = nowMillis() + val since = lastRecordedAt + if (since != null && now - since < THROTTLE_INTERVAL_MS) { + return false + } + + lastRecordedAt = now + annotations.addLast(Annotation(now, label)) + while (annotations.size > MAX_ANNOTATIONS) { + annotations.removeFirst() + } + return true + } + + /** + * 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 } + } + + @Synchronized + fun clear() { + annotations.clear() + lastRecordedAt = null + } + + 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/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/MetricsSnapshot.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt new file mode 100644 index 0000000000..8f1f07d0b5 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt @@ -0,0 +1,130 @@ +/* + * 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 +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 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 + private const val TIMESTAMP_PATTERN = "yyyyMMdd-HHmmss" + + /** Media type for the written file, for the sharing intent. */ + const val MIME_TYPE = "image/png" + + /** + * Writes [bitmap] as a PNG named after [label] and the current time. + * + * 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, + label: String, + ): 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, "${fileNameFor(label)}.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. + */ + private fun pruneTo( + directory: File, + limit: Int, + newest: File, + ) { + val files = directory.listFiles()?.sortedBy { it.lastModified() } ?: return + if (files.size <= limit) { + return + } + files.take(files.size - limit).forEach { file -> + if (file != newest && !file.delete()) { + log.warn("Could not delete the stale chart snapshot at {}", file) + } + } + } + + /** + * A filename from [label] and the current time, with anything that is not safe in a filename + * replaced. Chart titles are translated, so they can contain spaces and non-ASCII. + */ + private fun fileNameFor(label: String): String { + val stamp = SimpleDateFormat(TIMESTAMP_PATTERN, Locale.US).format(Date()) + val safeLabel = + label + .lowercase(Locale.US) + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .ifEmpty { "metrics" } + return "$safeLabel-$stamp" + } +} 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..3d3417646c 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,60 @@ 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 + /** + * Resets every element to zero and returns the shift to its starting position, so the array reads + * as though nothing had ever been recorded. + */ + fun clear() { + array.fill(0L) + 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 index 9499f527d9..10370b00fe 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -57,23 +57,49 @@ import kotlin.coroutines.CoroutineContext class NetworkUsageWatcher @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) constructor( - private val updateInterval: Long = DEFAULT_UPDATE_INTERVAL, + 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"), - private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, + // 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, ) { - // A parent job, so cancelling the scope in close() actually reaches the sampler. Without one - // the launch below had to supply its own, and nothing the scope did could stop it. 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 + /** + * 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() @@ -117,9 +143,25 @@ class NetworkUsageWatcher NetworkUsage(received.snapshot(), transmitted.snapshot()) } + /** + * 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() + lastRx = null + lastTx = null + } + } + fun startWatching() { - // compareAndSet, not a read then a write: two callers racing here would each start a - // sampler, and both would append to the same buffers. + 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 @@ -128,16 +170,15 @@ class NetworkUsageWatcher samplingJob = coroutineScope.launch { while (isWatching) { - // The loop must outlive a bad sample. Without this an exception -- a - // misbehaving listener is enough -- ends the coroutine while `watching` stays - // true, so every later startWatching() is refused as "already watching" and - // sampling is dead for the rest of the session. + // 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) { + withContext(mainDispatcher ?: Dispatchers.Main.immediate) { listener.onNetworkUsageChanged(usage) } } @@ -163,7 +204,12 @@ class NetworkUsageWatcher } /** - * Stops sampling. The watcher can be started again; the history is kept. + * 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) @@ -175,22 +221,19 @@ class NetworkUsageWatcher lastRx = null lastTx = null } - // Cancel the job, not the scope. The loop spends nearly all its time in delay(), so waiting - // for it to notice the flag leaves it sampling for up to a full interval after the editor - // asked it to stop -- long enough for a stop/start to run two samplers at once. Cancelling - // the scope instead would end the watcher for good, and this is a pause, not a teardown. samplingJob?.cancel() samplingJob = null } /** - * Stops sampling and releases the sampling thread. Terminal: the watcher cannot be restarted. + * Stops sampling and releases the sampling thread. The watcher cannot be started again. * - * Separate from [stopWatching] because the editor stops and restarts the watcher across its - * lifecycle, and only the final teardown should give up the thread that - * [newSingleThreadContext] keeps alive. + * 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") @@ -217,12 +260,13 @@ class NetworkUsageWatcher 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) { record(received, previous = lastRx, current = rx) record(transmitted, previous = lastTx, current = tx) - } - - synchronized(historyLock) { lastRx = rx lastTx = tx } @@ -279,7 +323,13 @@ class NetworkUsageWatcher } companion object { - const val MAX_USAGE_ENTRIES = 30 + /** + * Samples retained per series (ADFA-5486). The span this covers depends on the interval: + * under three hours at one second, about seventeen minutes at the 0.1s minimum. 80KB of + * longs per series, so the cost is in drawing rather than holding -- see + * MetricsChartRenderer, which shows a window of this rather than all of it. + */ + const val MAX_USAGE_ENTRIES = 10000 const val DEFAULT_UPDATE_INTERVAL = 1000L /** [TrafficStats.UNSUPPORTED] widened to [Long], which is what the getters return. */ 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..bcf61bd48d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt @@ -0,0 +1,52 @@ +/* + * 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 androidx.lifecycle.ViewModel +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.NetworkUsageWatcher + +/** + * 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 : ViewModel() { + val memoryUsageWatcher = MemoryUsageWatcher() + + val networkUsageWatcher = NetworkUsageWatcher() + + /** Significant events for the charts to annotate (ADFA-5486). */ + val annotations = MetricsAnnotationStore() + + override fun onCleared() { + super.onCleared() + // 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() + } +} 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/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index e78b5f9dc9..5190312432 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -24,6 +24,39 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index f1785efd39..8ac6c22353 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -9,6 +9,10 @@ 248dp 16dp 4dp + 40dp + 10dp + 48dp + 12dp 28dp 28dp 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..d82bb0b9aa --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt @@ -0,0 +1,58 @@ +/* + * 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), + ) + + @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/handlers/EditorBuildEventListenerAnnotationTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt new file mode 100644 index 0000000000..8eb6db3252 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt @@ -0,0 +1,90 @@ +/* + * 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.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 org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Which Gradle progress events the metrics charts annotate (ADFA-5486). + * + * Task starts and stops, and nothing else. Asserted against the predicate rather than through + * `onProgressEvent`, which needs a live activity before it gets this far. + */ +@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 `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() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt index 5d1c8bab2a..e6feae8618 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -17,9 +17,13 @@ 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 @@ -46,6 +50,30 @@ class MemoryUsageChartRendererTest { lineColorFor = { Color.BLUE }, ) + /** + * A chart showing one process with the given byte history, laid out and drawn once. + * + * The draw matters: MPAndroidChart queues the scroll to the newest samples as a job that only + * runs during a draw pass, so without one the chart reports the oldest samples as visible. + */ + private fun laidOutChart(history: LongArray): SafeLineChart { + val chart = chart() + val process = + ProcessMemoryInfo( + PID_IDE, + "IDE", + MutableShiftedLongArray(LongArray(history.size) { history[it] }), + ) + renderer { arrayOf(process) }.attach(chart) + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) + return chart + } + /** A process whose history ramps from [firstMegabytes] by 1MB per sample. */ private fun proc( pid: Int, @@ -62,6 +90,20 @@ class MemoryUsageChartRendererTest { 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)) @@ -181,6 +223,37 @@ class MemoryUsageChartRendererTest { 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 @@ -188,5 +261,11 @@ class MemoryUsageChartRendererTest { * passed because both sides had stopped agreeing. */ val BYTES_PER_MB = BYTES_PER_MEGABYTE.toLong() + const val WIDTH = 720 + const val HEIGHT = 400 + 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/MetricsAnnotationSpanTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt new file mode 100644 index 0000000000..16c953db28 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt @@ -0,0 +1,117 @@ +/* + * 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 }, + ) + }, + 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.moveViewToX(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/MetricsCarouselLayoutTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt new file mode 100644 index 0000000000..9697966d47 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt @@ -0,0 +1,265 @@ +/* + * 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.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() + } + } + + @Test + fun `undocking hides every carousel control, not just the chart`() { + val binding = inflatedStrip() + + binding.root.setUndocked(true) + + // The arrows and the camera are chrome for a chart that is not here. Left visible they sit + // over the message, and the camera is inert anyway because undocking unbinds its listener. + assertThat(binding.metricsPager.isVisible).isFalse() + assertThat(binding.metricsTitle.isVisible).isFalse() + assertThat(binding.metricsPrevious.isVisible).isFalse() + assertThat(binding.metricsNext.isVisible).isFalse() + assertThat(binding.metricsSnapshot.isVisible).isFalse() + assertThat(binding.metricsUndockedMessage.isVisible).isTrue() + } + + @Test + fun `re-docking brings every control 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 `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..70f8ee588a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt @@ -0,0 +1,152 @@ +/* + * 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 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), + 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 `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 `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..9944eec6ad --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -0,0 +1,172 @@ +/* + * 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 }, + ) + }, + ) + renderer.attach(chart) + renderer.onXAxisTap = { taps++ } + attachedRenderer = renderer + + // Without a layout pass the plot area has no extent, so every coordinate is on its edge. + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + 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.moveViewToX(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) + } + + /** 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(WIDTH, 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(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 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 { + const val WIDTH = 720 + const val HEIGHT = 400 + + /** + * 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/NetworkUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt index d24b027cd8..85cd37298c 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -18,6 +18,9 @@ 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 @@ -34,6 +37,14 @@ import kotlin.math.log10 */ @RunWith(RobolectricTestRunner::class) class NetworkUsageChartRendererTest { + private companion object { + const val WIDTH = 720 + const val HEIGHT = 400 + + /** Longer than the visible window, so the start of the history scrolls off screen. */ + const val SAMPLE_COUNT = 200 + } + private val context = ApplicationProvider.getApplicationContext() private fun usage( @@ -87,6 +98,55 @@ class NetworkUsageChartRendererTest { assertThat(ys[2] - ys[1]).isLessThan(4f) } + /** + * Lays the chart out and draws it once. + * + * The draw is not decoration: MPAndroidChart queues the scroll to the newest samples as a job + * that only runs during a draw pass, so without one the chart still reports the *oldest* + * samples as visible and every assertion here would read the wrong window. + */ + private fun laidOut(chart: SafeLineChart) { + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) + } + + @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) = @@ -102,6 +162,21 @@ class NetworkUsageChartRendererTest { 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))) 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/MetricsAnnotationStoreTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt new file mode 100644 index 0000000000..03dc8b4b46 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt @@ -0,0 +1,107 @@ +/* + * 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() + } +} 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/MetricsSnapshotTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt new file mode 100644 index 0000000000..53ca31b780 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt @@ -0,0 +1,108 @@ +/* + * 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, named after the chart, with + * only the newest one kept. + */ +@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(), "Memory usage") + + 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 `names the file after the chart`() { + val file = MetricsSnapshot.write(context, bitmap(), "Network traffic") + + assertThat(file!!.name).startsWith("network-traffic-") + } + + @Test + fun `a title with punctuation or non-ascii still makes a usable filename`() { + // Chart titles are translated, so they are not guaranteed to be filename-safe. + val file = MetricsSnapshot.write(context, bitmap(), "Mémoire / usage (MB)") + + assertThat(file).isNotNull() + assertThat(file!!.name).matches("[a-z0-9-]+\\.png") + } + + @Test + fun `a title with nothing usable still produces a file`() { + val file = MetricsSnapshot.write(context, bitmap(), "***") + + assertThat(file).isNotNull() + assertThat(file!!.name).startsWith("metrics-") + } + + @Test + fun `a shared snapshot survives the next few exports`() { + val shared = MetricsSnapshot.write(context, bitmap(), "Memory usage")!! + + // 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(), "Chart $index") } + + assertThat(shared.exists()).isTrue() + } + + @Test + fun `the directory stays bounded across many exports`() { + repeat(20) { index -> MetricsSnapshot.write(context, bitmap(), "Chart $index") } + + // Bounded, not unbounded: this is a scratch directory, not a gallery. + val directory = MetricsSnapshot.write(context, bitmap(), "Last")!!.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(), "Memory usage") + val newest = MetricsSnapshot.write(context, bitmap(), "Network traffic") + + // 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")) + } +} 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..a2eb18665d --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt @@ -0,0 +1,75 @@ +/* + * 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 androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +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 { + val provider = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory()) + return provider[MetricsViewModel::class.java] + } + + @Test + fun `clearing the view model closes both watchers for good`() { + val model = viewModel() + model.memoryUsageWatcher.startWatching() + model.networkUsageWatcher.startWatching() + 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() + + model.memoryUsageWatcher.startWatching() + model.networkUsageWatcher.startWatching() + assertThat(model.memoryUsageWatcher.isWatching).isFalse() + assertThat(model.networkUsageWatcher.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.annotations).isSameInstanceAs(model.annotations) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 1b9946fa0c..bdba956b6b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1677,6 +1677,15 @@ Memory usage Network traffic chart Network traffic + Metrics are in a floating window.\nTap to bring them back. + Metrics + Sampling rate + Every %1$s + %1$s (needs a 64-bit device) + Previous metric + Next metric + Save chart image + Couldn\'t save the chart image. Received Sent