diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b21c2d872f..d57f254ba4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -329,6 +329,9 @@ dependencies { implementation(libs.androidx.work) implementation(libs.androidx.work.ktx) implementation(libs.google.material) + // Metrics carousel (ADFA-5487). Already on the classpath transitively; declared so the + // compile-time use in MetricsCarouselAdapter does not depend on another library's graph. + implementation(libs.androidx.viewpager2) implementation(libs.google.flexbox) implementation(libs.libsu.core) 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 ae383bafd9..e807ca6631 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -52,7 +52,6 @@ import androidx.annotation.GravityInt import androidx.annotation.RequiresApi import androidx.annotation.UiThread import androidx.appcompat.app.ActionBarDrawerToggle -import androidx.collection.MutableIntIntMap import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import androidx.core.graphics.Insets @@ -67,11 +66,7 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.github.mikephil.charting.components.AxisBase -import com.github.mikephil.charting.data.Entry -import com.github.mikephil.charting.data.LineData -import com.github.mikephil.charting.data.LineDataSet -import com.github.mikephil.charting.formatter.IAxisValueFormatter +import 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 @@ -124,6 +119,10 @@ 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.SwipeRevealLayout import com.itsaky.androidide.uidesigner.UIDesignerActivity import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow @@ -133,6 +132,7 @@ import com.itsaky.androidide.utils.FlashType import com.itsaky.androidide.utils.InstallationResultHandler.onResult import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.StringsInjectionException import com.itsaky.androidide.utils.StringsXmlInjector import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation @@ -145,7 +145,6 @@ import com.itsaky.androidide.utils.flashMessage import com.itsaky.androidide.utils.getOrStoreInitialPadding import com.itsaky.androidide.utils.isAtLeastR import com.itsaky.androidide.utils.isDeepLinkTargetOfOpenProject -import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.viewmodel.ApkInstallationViewModel import com.itsaky.androidide.viewmodel.AppLogsCoordinator import com.itsaky.androidide.viewmodel.AppLogsViewModel @@ -173,7 +172,6 @@ import rikka.shizuku.Shizuku import java.io.File import kotlin.math.abs import kotlin.math.roundToInt -import kotlin.math.roundToLong /** * Base class for EditorActivity which handles most of the view related things. @@ -192,7 +190,21 @@ abstract class BaseEditorActivity : private var drawerToggle: ActionBarDrawerToggle? = null private var bottomSheetCallback: BottomSheetBehavior.BottomSheetCallback? = null protected val memoryUsageWatcher = MemoryUsageWatcher() - protected val pidToDatasetIdxMap = MutableIntIntMap(initialCapacity = 3) + private var metricsPageCallback: ViewPager2.OnPageChangeCallback? = null + private val memUsageChartRenderer = + MemoryUsageChartRenderer( + usagesProvider = memoryUsageWatcher::getMemoryUsages, + lineColorFor = Companion::getMemUsageLineColorFor, + ) + + private val networkUsageWatcher = NetworkUsageWatcher() + private val networkUsageChartRenderer = + NetworkUsageChartRenderer(usageProvider = networkUsageWatcher::getUsage) + + private val networkUsageListener = + NetworkUsageWatcher.NetworkUsageListener { usage -> + networkUsageChartRenderer.onUsageChanged(usage) + } private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null @@ -315,45 +327,7 @@ abstract class BaseEditorActivity : private val memoryUsageListener = MemoryUsageWatcher.MemoryUsageListener { memoryUsage -> - var dataChanged = false - memoryUsage.forEachValue { proc -> - _binding?.memUsageView?.chart?.apply { - val dataset = - ( - data.getDataSetByIndex( - pidToDatasetIdxMap.getOrDefault( - proc.pid, - -1, - ), - ) as LineDataSet? - ) - ?: run { - log.error( - "No dataset found for process: {}: {}", - proc.pid, - proc.pname, - ) - return@forEachValue - } - - dataset.entries.mapIndexed { index, entry -> - entry.y = - (proc.usageHistory[index] / (1024.0 * 1024.0)).toFloat() - } - - dataset.label = "%s - %.2fMB".format(proc.pname, dataset.entries.last().y) - dataset.notifyDataSetChanged() - dataChanged = true - } - } - - if (dataChanged) { - _binding?.memUsageView?.chart?.apply { - data.notifyDataChanged() - notifyDataSetChanged() - invalidate() - } - } + memUsageChartRenderer.onUsagesChanged(memoryUsage) } private val shizukuBinderReceivedListener = @@ -363,10 +337,6 @@ abstract class BaseEditorActivity : private var isImeVisible = false private var contentCardRealHeight: Int? = null - private val editorSurfaceContainerBackground by lazy { - resolveAttr(R.attr.colorSurfaceDim) - } - private var isDebuggerStarting = false @UiThread set(value) { field = value @@ -483,7 +453,26 @@ abstract class BaseEditorActivity : companion object { const val DEBUGGER_SERVICE_STOP_DELAY_MS: Long = 60 * 1000 + /** + * 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. + * + * 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. + */ @JvmStatic + fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = + when (proc.pname) { + PROC_IDE -> Color.BLUE + PROC_GRADLE_TOOLING -> Color.RED + PROC_GRADLE_DAEMON -> Color.GREEN + else -> Color.GRAY + } + protected val PROC_IDE = "IDE" @JvmStatic @@ -562,11 +551,21 @@ abstract class BaseEditorActivity : fullscreenManager?.destroy() fullscreenManager = null + metricsPageCallback?.let { callback -> + _binding?.memUsageView?.metricsPager?.unregisterOnPageChangeCallback(callback) + } + metricsPageCallback = null + _binding?.memUsageView?.metricsPager?.adapter = null + memUsageChartRenderer.detach() + networkUsageChartRenderer.detach() _binding = null if (isDestroying) { memoryUsageWatcher.stopWatching(true) 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() editorActivityScope.cancelIfActive("Activity is being destroyed") unbindDebuggerService() @@ -903,7 +902,7 @@ abstract class BaseEditorActivity : ) feedbackButtonManager?.setupDraggableFab() - setupMemUsageChart() + setupMetricsCarousel() watchMemory() observeFileOperations() @@ -1004,36 +1003,41 @@ abstract class BaseEditorActivity : content.editorAppBarLayout.updatePadding(top = topInset) } - memUsageView.chart.updateLayoutParams { - topMargin = (insetsTop * progress).roundToInt() - } + // 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 } } - private fun setupMemUsageChart() { - binding.memUsageView.chart.apply { - val colorAccent = resolveAttr(R.attr.colorAccent) + 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), + ) - isDragEnabled = false - description.isEnabled = false - xAxis.axisLineColor = colorAccent - axisRight.axisLineColor = colorAccent + binding.memUsageView.metricsPager.adapter = + MetricsCarouselAdapter(pages, memUsageChartRenderer, networkUsageChartRenderer) - setPinchZoom(false) - setBackgroundColor(editorSurfaceContainerBackground) - setDrawGridBackground(true) - setScaleEnabled(true) + val showTitleFor = { position: Int -> + pages.getOrNull(position)?.let { page -> + binding.memUsageView.metricsTitle.setText(page.title) + } + } - axisLeft.isEnabled = false - axisRight.valueFormatter = - object : - IAxisValueFormatter { - override fun getFormattedValue( - value: Float, - axis: AxisBase?, - ): String = "%dMB".format(value.roundToLong()) + metricsPageCallback = + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + showTitleFor(position) } - } + }.also { binding.memUsageView.metricsPager.registerOnPageChangeCallback(it) } + + // onPageSelected does not fire for the page the carousel opens on. + showTitleFor(binding.memUsageView.metricsPager.currentItem) } private fun watchMemory() { @@ -1042,60 +1046,20 @@ abstract class BaseEditorActivity : resetMemUsageChart() } + /** + * Rebuilds the memory chart for the currently watched processes. Call after starting or stopping + * watching a process. + */ protected fun resetMemUsageChart() { - val processes = memoryUsageWatcher.getMemoryUsages() - val datasets = - Array(processes.size) { index -> - LineDataSet( - List(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { Entry(it.toFloat(), 0f) }, - processes[index].pname, - ) - } - - val bgColor = editorSurfaceContainerBackground - val textColor = resolveAttr(R.attr.colorOnSurface) - - for ((index, proc) in processes.withIndex()) { - val dataset = datasets[index] - dataset.color = getMemUsageLineColorFor(proc) - dataset.setDrawIcons(false) - dataset.setDrawCircles(false) - dataset.setDrawCircleHole(false) - dataset.setDrawValues(false) - dataset.formLineWidth = 1f - dataset.formSize = 15f - dataset.isHighlightEnabled = false - pidToDatasetIdxMap[proc.pid] = index - } - - binding.memUsageView.chart.setBackgroundColor(bgColor) - - binding.memUsageView.chart.apply { - data = LineData(*datasets) - axisRight.textColor = textColor - axisLeft.textColor = textColor - legend.textColor = textColor - - data.setValueTextColor(textColor) - setBackgroundColor(bgColor) - setGridBackgroundColor(bgColor) - notifyDataSetChanged() - invalidate() - } + memUsageChartRenderer.rebuild() } - private fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = - when (proc.pname) { - PROC_IDE -> Color.BLUE - PROC_GRADLE_TOOLING -> Color.RED - PROC_GRADLE_DAEMON -> Color.GREEN - else -> throw IllegalArgumentException("Unknown process: $proc") - } - override fun onPause() { super.onPause() memoryUsageWatcher.listener = null memoryUsageWatcher.stopWatching(false) + networkUsageWatcher.listener = null + networkUsageWatcher.stopWatching() this.isDestroying = isFinishing getFileTreeFragment()?.saveTreeState() @@ -1112,8 +1076,17 @@ abstract class BaseEditorActivity : log.warn("Unable to move debugger overlay to display {}", displayId, err) } - memoryUsageWatcher.listener = memoryUsageListener - memoryUsageWatcher.startWatching() + // 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 + memoryUsageWatcher.startWatching() + networkUsageWatcher.listener = networkUsageListener + networkUsageWatcher.startWatching() + } apkInstallationViewModel.reloadStatus(this) @@ -1889,8 +1862,12 @@ abstract class BaseEditorActivity : // Filter out diagonal flings so only an intentional right swipe opens the drawer. // A horizontal fling that started on the bottom-sheet tab strip is the user - // scrolling tabs, not asking for the drawer. - if (isDrawerOpenFling && !isTouchOnBottomSheetTabs(e1)) { + // scrolling tabs, not asking for the drawer; one that started on the metrics + // carousel is the user paging it backwards. + if (isDrawerOpenFling && + !isTouchOnBottomSheetTabs(e1) && + !isTouchOnMetricsCarousel(e1) + ) { binding.editorDrawerLayout.openDrawer(GravityCompat.START) return true } @@ -1912,9 +1889,50 @@ abstract class BaseEditorActivity : private fun isTouchOnBottomSheetTabs(ev: MotionEvent): Boolean { val tabs = contentOrNull?.bottomSheet?.binding?.tabs ?: return false - val rect = Rect() - if (!tabs.getGlobalVisibleRect(rect)) return false - return rect.contains(ev.rawX.toInt(), ev.rawY.toInt()) + return containsTouch(tabs, ev) + } + + private fun isTouchOnMetricsCarousel(ev: MotionEvent): Boolean { + val binding = _binding ?: return false + + // A left-to-right fling pages the carousel *backwards*, so there is nothing for it to do + // on the first page -- which is the page the carousel opens on. Excluding the strip + // regardless left the documented right-swipe drawer gesture dead over the whole panel + // while doing nothing in its place. + if (binding.memUsageView.metricsPager.currentItem <= 0) { + return false + } + + // The carousel is laid out at the top of the reveal even while the content card covers it, + // and siblings do not clip each other, so getGlobalVisibleRect reports it visible either + // way. Without this check the drawer gesture would be dead over the top of a closed editor. + if (binding.swipeReveal.dragProgress <= 0f) { + return false + } + + // The pager, not the whole strip: the title and its row are not something the carousel + // pages from, and MetricsCarouselLayout has already walled that row off from every + // ancestor, so a fling there would otherwise be swallowed twice over. + return containsTouch(binding.memUsageView.metricsPager, ev) + } + + private fun containsTouch( + view: View, + ev: MotionEvent, + ): Boolean { + if (!view.isShown) return false + + // getLocationOnScreen, not getGlobalVisibleRect: the latter reports window coordinates -- + // ViewRootImpl intersects with the window and never offsets by its position on screen -- + // while rawX/rawY are screen coordinates. In split-screen or freeform the window origin is + // not zero, so the two disagree and the hit test lands somewhere else entirely. + // SwipeRevealLayout.isTouchInDragHandle already uses this idiom. + val location = IntArray(2) + view.getLocationOnScreen(location) + val x = ev.rawX.toInt() + val y = ev.rawY.toInt() + return x >= location[0] && x < location[0] + view.width && + y >= location[1] && y < location[1] + view.height } private fun showTooltip(tag: String) { diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 26ed2966e3..afc6707577 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -716,7 +716,11 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { service.startToolingServer { pid -> memoryUsageWatcher.watchProcess(pid, PROC_GRADLE_TOOLING) - resetMemUsageChart() + // The callback arrives on the tooling server's own thread, and the renderer is + // @UiThread: rebuild() clears and repopulates a non-thread-safe pid map that the + // once-a-second sample listener reads on the main thread, so racing it can plot one + // process's samples on another's line or throw out of the entry loop. + runOnUiThread { resetMemUsageChart() } service.metadata().whenComplete { metadata, err -> if (metadata == null || err != null) { @@ -731,7 +735,8 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { metadata.pid, ) memoryUsageWatcher.watchProcess(metadata.pid, PROC_GRADLE_TOOLING) - resetMemUsageChart() + // A CompletableFuture completion thread, for the same reason as above. + runOnUiThread { resetMemUsageChart() } } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt new file mode 100644 index 0000000000..8d348944a7 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -0,0 +1,232 @@ +/* + * 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 androidx.annotation.UiThread +import androidx.collection.IntObjectMap +import androidx.collection.MutableIntIntMap +import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.IAxisValueFormatter +import com.itsaky.androidide.R +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.ShiftedLongArray +import com.itsaky.androidide.utils.resolveAttr +import kotlin.math.roundToLong + +/** + * Renders [MemoryUsageWatcher] samples into a [SafeLineChart]. + * + * The chart view is attached and detached independently of the data: [MemoryUsageWatcher] owns the + * per-process [ProcessMemoryInfo.usageHistory] ring buffers, so this renderer holds no sample state + * of its own and can rebuild a complete chart from [usagesProvider] at any time. That is what makes + * the chart safe to host in a recycling container (ADFA-5487's metrics carousel): a chart view that + * is created long after watching began still shows the full history, and one that is recycled away + * loses nothing. + * + * All methods must be called on the UI thread. MPAndroidChart is not thread-safe; see [SafeLineChart]. + * + * @param usagesProvider Supplies the currently watched processes, newest state each call. + * @param lineColorFor Supplies the plot line color for a process. + */ +class MemoryUsageChartRenderer( + private val usagesProvider: () -> Array, + private val lineColorFor: (ProcessMemoryInfo) -> Int, +) { + private var chart: SafeLineChart? = null + + /** + * 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 + 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() { + val chart = this.chart ?: return + val processes = usagesProvider() + + pidToDatasetIdx.clear() + + val datasets = + Array(processes.size) { index -> + val proc = processes[index] + pidToDatasetIdx[proc.pid] = index + + LineDataSet( + List(proc.usageHistory.size) { entryIdx -> + Entry(entryIdx.toFloat(), proc.usageHistory.megabytesAt(entryIdx)) + }, + proc.pname, + ).apply { + color = lineColorFor(proc) + setDrawIcons(false) + setDrawCircles(false) + setDrawCircleHole(false) + setDrawValues(false) + formLineWidth = 1f + formSize = 15f + isHighlightEnabled = false + label = labelFor(proc.pname, entries.lastOrNull()?.y ?: 0f) + } + } + + 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 + + data.setValueTextColor(textColor) + setBackgroundColor(bgColor) + setGridBackgroundColor(bgColor) + notifyDataSetChanged() + invalidate() + } + } + + /** + * Renders a fresh set of samples into the attached chart, mutating the existing entries in place. + * + * Falls back to [rebuild] when [memoryUsage] no longer matches the datasets the chart was built + * with -- a process started or stopped being watched, or the chart was attached before this pid + * existed. The in-place path is the common one: it mutates the existing entries rather than + * rebuilding the datasets, which is what matters because this runs once a second for the + * lifetime of the editor. It is not allocation-free -- each series reformats its legend label + * every tick -- so do not add work here on the assumption that it is. + */ + @UiThread + fun onUsagesChanged(memoryUsage: IntObjectMap) { + val chart = this.chart ?: return + + if (memoryUsage.size != pidToDatasetIdx.size) { + rebuild() + return + } + + var dataChanged = false + memoryUsage.forEachValue { proc -> + val datasetIdx = pidToDatasetIdx.getOrDefault(proc.pid, -1) + val dataset = chart.data?.getDataSetByIndex(datasetIdx) as LineDataSet? + if (dataset == null) { + // The chart's datasets no longer describe the watched processes. Rebuild rather than + // dropping this process's samples on the floor, as the previous code did. + rebuild() + return + } + + for (index in dataset.entries.indices) { + dataset.entries[index].y = proc.usageHistory.megabytesAt(index) + } + + dataset.label = labelFor(proc.pname, dataset.entries.lastOrNull()?.y ?: 0f) + dataset.notifyDataSetChanged() + dataChanged = true + } + + if (dataChanged) { + chart.apply { + data.notifyDataChanged() + notifyDataSetChanged() + invalidate() + } + } + } + + /** + * 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()) + } + } + } + + private fun labelFor( + pname: String, + megabytes: Float, + ): String = "%s - %.2fMB".format(pname, megabytes) +} + +internal const val BYTES_PER_MEGABYTE = 1024.0 * 1024.0 + +/** + * The sample at [index] in megabytes. [MemoryUsageWatcher] stores bytes. + */ +private fun ShiftedLongArray.megabytesAt(index: Int): Float = (this[index] / BYTES_PER_MEGABYTE).toFloat() diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt new file mode 100644 index 0000000000..51dec3e2b0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt @@ -0,0 +1,135 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +import com.itsaky.androidide.R + +/** + * A page of the editor's metrics carousel. + * + * @property title Names the page. Shown below the carousel, and the only cue to which page is + * showing, so every page needs one. + */ +sealed interface MetricsPage { + @get:StringRes val title: Int + + /** The live memory-usage chart, rendered by [MemoryUsageChartRenderer]. */ + data class MemoryChart( + @StringRes override val title: Int, + ) : MetricsPage + + /** The live network-traffic chart, rendered by [NetworkUsageChartRenderer]. */ + data class NetworkChart( + @StringRes override val title: Int, + ) : MetricsPage +} + +/** + * Backs the editor's horizontally swipeable carousel of metric displays. + * + * [pages] is a constructor argument rather than a hardcoded list so that new displays -- a network + * traffic chart, or pages contributed by plugins -- can be added without touching this class. + * + * A chart page holds no sample state of its own: its renderer is attached when the page binds and + * detached when it is recycled, and rebuilds the full history from its watcher each time. Moving + * away from a chart and back therefore loses nothing. + */ +class MetricsCarouselAdapter( + private val pages: List, + private val memoryChartRenderer: MemoryUsageChartRenderer, + private val networkChartRenderer: NetworkUsageChartRenderer, +) : RecyclerView.Adapter() { + sealed class PageViewHolder( + view: View, + ) : RecyclerView.ViewHolder(view) { + class MemoryChart( + val chart: SafeLineChart, + ) : PageViewHolder(chart) + + class NetworkChart( + val chart: SafeLineChart, + ) : PageViewHolder(chart) + } + + override fun getItemCount(): Int = pages.size + + override fun getItemViewType(position: Int): Int = + when (pages[position]) { + is MetricsPage.MemoryChart -> VIEW_TYPE_MEMORY_CHART + is MetricsPage.NetworkChart -> VIEW_TYPE_NETWORK_CHART + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): PageViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_MEMORY_CHART -> { + PageViewHolder.MemoryChart( + inflater.inflate(R.layout.item_metrics_memory_chart, parent, false) as SafeLineChart, + ) + } + + VIEW_TYPE_NETWORK_CHART -> { + PageViewHolder.NetworkChart( + inflater.inflate(R.layout.item_metrics_network_chart, parent, false) as SafeLineChart, + ) + } + + else -> { + throw IllegalArgumentException("Unknown metrics page view type: $viewType") + } + } + } + + override fun onBindViewHolder( + holder: PageViewHolder, + position: Int, + ) { + when (pages[position]) { + is MetricsPage.MemoryChart -> { + memoryChartRenderer.attach((holder as PageViewHolder.MemoryChart).chart) + } + + is MetricsPage.NetworkChart -> { + networkChartRenderer.attach((holder as PageViewHolder.NetworkChart).chart) + } + } + } + + override fun onViewRecycled(holder: PageViewHolder) { + // Only if this holder's chart is still the attached one: a rebind can create the replacement + // before RecyclerView recycles the view it replaced, and detaching then would drop the new + // chart instead of the old. + when (holder) { + is PageViewHolder.MemoryChart -> memoryChartRenderer.detachIfAttached(holder.chart) + is PageViewHolder.NetworkChart -> networkChartRenderer.detachIfAttached(holder.chart) + } + } + + private companion object { + const val VIEW_TYPE_MEMORY_CHART = 0 + const val VIEW_TYPE_NETWORK_CHART = 1 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt new file mode 100644 index 0000000000..79dd92c872 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -0,0 +1,56 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.constraintlayout.widget.ConstraintLayout + +/** + * 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. + * + * This covers ancestors that intercept through the view hierarchy. The editor also runs an + * activity-level [android.view.GestureDetector] from `dispatchTouchEvent`, which never calls + * `onInterceptTouchEvent` and so cannot be stopped this way; `BaseEditorActivity` excludes this + * view's bounds there instead, the same way it already excludes the bottom-sheet tab strip. + * + * The vertical reveal drag is unaffected: `SwipeRevealLayout` only captures a vertical drag whose + * touch-down landed in its configured drag handle (the editor app bar), never in this strip. + */ +class MetricsCarouselLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : ConstraintLayout(context, attrs, defStyleAttr) { + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + // Cleared by the framework on the next ACTION_DOWN, so this lasts exactly one gesture. + parent?.requestDisallowInterceptTouchEvent(true) + } + return super.onInterceptTouchEvent(ev) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt new file mode 100644 index 0000000000..2dae31b2f2 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -0,0 +1,304 @@ +/* + * 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.Color +import androidx.annotation.UiThread +import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.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.NetworkUsageWatcher +import com.itsaky.androidide.utils.NetworkUsageWatcher.NetworkUsage +import com.itsaky.androidide.utils.resolveAttr +import kotlin.math.ceil +import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Renders [NetworkUsageWatcher] samples into a [SafeLineChart] on a logarithmic scale (ADFA-5489). + * + * Traffic spans orders of magnitude -- a few hundred bytes of chatter next to a multi-megabyte + * Gradle download -- so a linear axis flattens everything but the largest burst into the baseline. + * MPAndroidChart has no logarithmic axis, so the plotted value is [log10] of the byte count and + * [BytesAxisFormatter] turns the axis labels back into byte units. + * + * Zero is the common sample, not an edge case: an idle IDE transfers nothing, and `log10(0)` is + * negative infinity. Values are therefore `log10(bytes + 1)`, which puts a zero sample at exactly + * `0.0` and keeps the line continuous. + * + * Like [MemoryUsageChartRenderer] this holds no sample state -- [NetworkUsageWatcher] owns the + * history -- so a chart can be attached, detached and recycled by the metrics carousel without + * losing anything. + * + * All methods must be called on the UI thread; MPAndroidChart is not thread-safe (see + * [SafeLineChart]). + * + * @param usageProvider Supplies the current sample history. + */ +class NetworkUsageChartRenderer( + private val usageProvider: () -> NetworkUsage, +) { + 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() + } + } + + /** + * Rebuilds both series from the full sample history. + */ + @UiThread + 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), + ) + + 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() + } + } + + /** + * Updates both series in place from a fresh sample, rebuilding if the chart's shape no longer + * matches. Allocates nothing on the common path, which runs once a second. + */ + @UiThread + fun onUsageChanged(usage: NetworkUsage) { + val chart = this.chart ?: return + val data = chart.data + + if (data == null || data.dataSetCount != SERIES_COUNT) { + rebuild() + return + } + + val received = data.getDataSetByIndex(RECEIVED_INDEX) as LineDataSet? + val transmitted = data.getDataSetByIndex(TRANSMITTED_INDEX) as LineDataSet? + if (received == null || transmitted == null || + received.entryCount != usage.received.size || + transmitted.entryCount != usage.transmitted.size + ) { + rebuild() + return + } + + update(received, usage.received, chart.context.getString(R.string.metrics_network_received)) + update(transmitted, usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted)) + + applyAxisRange(chart, usage) + + chart.apply { + data.notifyDataChanged() + notifyDataSetChanged() + invalidate() + } + } + + private fun dataset( + samples: LongArray, + label: String, + lineColor: Int, + ): LineDataSet = + LineDataSet( + List(samples.size) { index -> Entry(index.toFloat(), samples[index].toLogBytes()) }, + label, + ).apply { + // The labelled axis is the right one, and applyAxisRange pins its range. Without this the + // series is scaled against the (disabled, auto-ranged) left axis instead, so the line is + // drawn at a position the labels do not describe -- an idle chart plots its zero line + // halfway up a plot whose baseline is labelled 0 B. + axisDependency = YAxis.AxisDependency.RIGHT + color = lineColor + setDrawIcons(false) + setDrawCircles(false) + setDrawCircleHole(false) + setDrawValues(false) + formLineWidth = 1f + formSize = 15f + isHighlightEnabled = false + this.label = labelFor(label, samples.lastOrNull() ?: 0L) + } + + private fun update( + dataset: LineDataSet, + samples: LongArray, + label: String, + ) { + for (index in samples.indices) { + dataset.entries[index].y = samples[index].toLogBytes() + } + dataset.label = labelFor(label, samples.lastOrNull() ?: 0L) + dataset.notifyDataSetChanged() + } + + private fun labelFor( + label: String, + bytes: Long, + ): String = "%s - %s/s".format(label, formatBytes(bytes.toDouble(), decimals = 1)) + + /** + * Pins the axis to whole decades, from zero up to at least [MIN_AXIS_DECADES]. + * + * Two things depend on this. Zero has to sit on the baseline: when every sample is zero -- an + * idle IDE -- the data range is degenerate, and left to itself the chart pads around it and + * floats the flat line up the middle of the plot. And the maximum has to be a whole number, so + * the gridlines (granularity 1) land on exact powers of ten and can be labelled as whole units. + */ + private fun applyAxisRange( + chart: SafeLineChart, + usage: NetworkUsage, + ) { + val peak = max(usage.received.maxOrNull() ?: 0L, usage.transmitted.maxOrNull() ?: 0L) + 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 + // 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 + } + } + + /** + * Labels a logarithmic axis value in byte units. + * + * Gridlines land on integer values (granularity 1), so each is a power of ten and is labelled as + * one: 10B, 100B, 1.0kB. The exact inverse of [toLogBytes] would be `10^value - 1`, which labels + * those same lines 9B, 99B, 999B -- correct to the byte but unreadable as a scale. The one byte + * is not worth the confusion; the legend carries the exact current figure. + * + * Zero is the exception and is labelled exactly: `log10(0 + 1)` is 0, so the baseline really is + * no traffic, not one byte. + */ + private object BytesAxisFormatter : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = + if (value < 0.5f) { + formatBytes(0.0, decimals = 0) + } else { + // Gridlines are whole decades, so the mantissa is exact and needs no decimal place. + formatBytes(10.0.pow(value.toDouble()), decimals = 0) + } + } + + private companion object { + /** + * The axis always spans at least this many decades (0 B to 1 kB), so an idle chart keeps a + * sensible scale instead of collapsing onto a single value. + */ + const val MIN_AXIS_DECADES = 3f + + const val SERIES_COUNT = 2 + const val RECEIVED_INDEX = 0 + const val TRANSMITTED_INDEX = 1 + + val RECEIVED_COLOR = Color.CYAN + val TRANSMITTED_COLOR = Color.MAGENTA + } +} + +/** + * The plotted value for a byte count: `log10(bytes + 1)`. + * + * The `+ 1` is what makes zero plottable -- it maps to `0.0` rather than negative infinity -- and + * zero is the usual sample for an idle IDE. + */ +private fun Long.toLogBytes(): Float = log10(this.coerceAtLeast(0L).toDouble() + 1.0).toFloat() + +/** + * Formats a byte count for an axis label or legend, to at most one decimal place. + * + * Units are decimal (1 kB = 1000 B), not binary. On a log10 axis the gridlines are powers of ten, + * and dividing those by 1024 would label them 9.8KB, 977KB, 954MB -- the decades stop looking like + * decades. Decimal units are also the convention for network throughput. + */ +private fun formatBytes( + bytes: Double, + decimals: Int, +): String { + val clamped = bytes.coerceAtLeast(0.0) + return when { + clamped < 1_000 -> "%d B".format(clamped.roundToLong()) + clamped < 1_000_000 -> "%.${decimals}f kB".format(clamped / 1_000) + clamped < 1_000_000_000 -> "%.${decimals}f MB".format(clamped / 1_000_000) + else -> "%.${decimals}f GB".format(clamped / 1_000_000_000) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt index c3888d672c..86a0ee2503 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt @@ -26,388 +26,372 @@ import android.view.ViewGroup import androidx.annotation.CallSuper import androidx.annotation.FloatRange import androidx.annotation.IdRes +import androidx.core.content.withStyledAttributes import androidx.customview.widget.ViewDragHelper import com.google.android.material.shape.MaterialShapeDrawable import com.itsaky.androidide.R import kotlin.math.max import kotlin.math.min -import androidx.core.content.withStyledAttributes /** * A layout which can be dragged vertically to reveal a hidden content. * * @author Akash Yadav */ -open class SwipeRevealLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0, -) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { - - /** - * Interface for listening to drag events. - */ - interface OnDragListener { - - /** - * Called when the drag state changes. - */ - fun onDragStateChanged(swipeRevealLayout: SwipeRevealLayout, state: Int) - - /** - * Called when the drag progress changes. - */ - fun onDragProgress(swipeRevealLayout: SwipeRevealLayout, progress: Float) - } - - private val leftDragHelper: ViewDragHelper - private val rightDragHelper: ViewDragHelper - - private var leftDragProgress = 0f - private var rightDragProgress = 0f - private var isVerticalDragEnabled = true - - private var isDownInDragHandle = false - /** - * Whether the most recent touch-down landed within the configured drag handle. The vertical - * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting - * in the middle of the overlapping content (e.g. the editor) are not stolen. - */ - private val dragHandleLocation = IntArray(2) - - init { - leftDragHelper = ViewDragHelper.create(this, 1f, LeftDragCallback()) - rightDragHelper = ViewDragHelper.create(this, 1f, RightDragCallback()) - } - - private val dragHelperCallback = object : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return isVerticalDragEnabled && isDownInDragHandle && child === overlappingContent - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - draggingViewTop = top - onDragProgress(min(1f, top.toFloat() / dragHeightMax.toFloat())) - } - - override fun getViewVerticalDragRange(child: View): Int { - return if (isVerticalDragEnabled) dragHeightMax else 0 - } - - override fun getOrderedChildIndex(index: Int): Int { - return OVERLAPPING_CONTENT_INDEX - } - - override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { - return min(max(top, paddingTop), dragHeightMax) - } - - override fun onViewDragStateChanged(state: Int) { - if (state == draggingState) { - return - } - - if (isDragging && state == ViewDragHelper.STATE_IDLE) { - isOpen = draggingViewTop >= dragHeightMax - } - - onDragStateChanged(state) - } - - override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { - if (draggingViewTop == 0) { - isOpen = false - return - } - - if (draggingViewTop >= dragHeightMax) { - isOpen = true - return - } - - // whether the view should settle to open or close - val settleDestY = if (yvel > AUTO_OPEN_VELOCITY_LIM || draggingViewTop > dragHeightMax / 2) { - dragHeightMax - } else { - paddingTop - } - - if (dragHelper.settleCapturedViewAt(0, settleDestY)) { - this@SwipeRevealLayout.postInvalidateOnAnimation() - } - } - } - - private val hiddenContent: View - get() = getChildAt(HIDDEN_CONTENT_INDEX)!! - - private val overlappingContent: View - get() = getChildAt(OVERLAPPING_CONTENT_INDEX)!! - - private var draggingState = -1 - private var draggingViewTop = 0 - private val dragHeightMax - get() = hiddenContent.height - - private lateinit var dragHelper: ViewDragHelper - - /** - * Whether the view is currently in 'dragging' state. - */ - val isDragging: Boolean - get() = draggingState == ViewDragHelper.STATE_DRAGGING || - draggingState == ViewDragHelper.STATE_SETTLING - - /** - * The ID of the view which will be dragged to reveal the content. - */ - @IdRes - var dragHandleViewId = 0 - - /** - * The current drag progress. - */ - @FloatRange(from = 0.0, to = 1.0) - var dragProgress = 0.0f - private set - - /** - * Listener for drag events. - */ - var dragListener: OnDragListener? = null - - /** - * Whether the view is open. - */ - var isOpen = false - protected set - - companion object { - - private const val HIDDEN_CONTENT_INDEX = 0 - private const val OVERLAPPING_CONTENT_INDEX = 1 - - @Suppress("UNUSED") - const val STATE_IDLE = ViewDragHelper.STATE_IDLE - - @Suppress("UNUSED") - const val STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING - - @Suppress("UNUSED") - const val STATE_SETTLING = ViewDragHelper.STATE_SETTLING - - const val AUTO_OPEN_VELOCITY_LIM = 800.0 - } - - init { - if (attrs != null) { - context.withStyledAttributes( - attrs, R.styleable.SwipeRevealLayout, - defStyleAttr, defStyleRes - ) { - dragHandleViewId = getResourceId( - R.styleable.SwipeRevealLayout_dragHandle, - dragHandleViewId - ) - } - } - } - - override fun onFinishInflate() { - super.onFinishInflate() - this.dragHelper = ViewDragHelper.create(this, dragHelperCallback) - this.isOpen = false - - check(childCount == 2) { - "SwipeRevealLayout must have exactly two children; the hidden content and the overlapping content" - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - measureChildren(widthMeasureSpec, heightMeasureSpec) - - val maxWidth = MeasureSpec.getSize(widthMeasureSpec) - val maxHeight = MeasureSpec.getSize(heightMeasureSpec) - - setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 0), - resolveSizeAndState(maxHeight, heightMeasureSpec, 0)) - } - - override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { - hiddenContent.layout(0, paddingTop, r, paddingTop + hiddenContent.measuredHeight) - - val olapTop = paddingTop + (hiddenContent.height * dragProgress).toInt() - // Ensure overlappingContent extends to bottom to fill available space - overlappingContent.layout(0, olapTop, r, b) - } - - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - val action = ev.actionMasked - if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { - leftDragHelper.cancel() - rightDragHelper.cancel() - dragHelper.cancel() - return false - } - if (action == MotionEvent.ACTION_DOWN) { - isDownInDragHandle = isTouchInDragHandle(ev) - } - val isLeft = leftDragHelper.shouldInterceptTouchEvent(ev) - val isRight = rightDragHelper.shouldInterceptTouchEvent(ev) - val isVertical = dragHelper.shouldInterceptTouchEvent(ev) - return isLeft || isRight || isVertical - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - leftDragHelper.processTouchEvent(event) - rightDragHelper.processTouchEvent(event) - dragHelper.processTouchEvent(event) - return true - } - - /** - * Returns whether the given touch event falls within the bounds of the configured drag handle - * (see [dragHandleViewId] / the `app:dragHandle` attribute). When no drag handle is configured, - * the whole overlapping content acts as the handle (legacy behavior). - */ - private fun isTouchInDragHandle(ev: MotionEvent): Boolean { - if (dragHandleViewId == 0) { - return true - } - - val handle = findViewById(dragHandleViewId) - if (handle == null || handle.visibility != VISIBLE) { - return false - } - - handle.getLocationOnScreen(dragHandleLocation) - val left = dragHandleLocation[0] - val top = dragHandleLocation[1] - val right = left + handle.width - val bottom = top + handle.height - val x = ev.rawX - val y = ev.rawY - return x >= left && x <= right && y >= top && y <= bottom - } - - override fun computeScroll() { - if (leftDragHelper.continueSettling(true) or rightDragHelper.continueSettling(true) or dragHelper.continueSettling(true)) { - postInvalidateOnAnimation() - } - } - - /** - * Internal callback. Invoked when the drag state changes. - */ - @CallSuper - protected open fun onDragStateChanged(state: Int) { - draggingState = state - dragListener?.onDragStateChanged(this, state) - } - - /** - * Internal callback. Invoked when the drag progress changes. - */ - @CallSuper - protected open fun onDragProgress(progress: Float) { - if (dragProgress == progress) { - return - } - - dragProgress = progress - applyDragProgress(progress) - dragListener?.onDragProgress(this, progress) - } - - /** - * Applies the drag progress to the content. - */ - protected open fun applyDragProgress(progress: Float) { - val min = 0.97f - val max = 1f - val scale = min + (max - min) * (1 - progress) - overlappingContent.scaleX = scale - overlappingContent.scaleY = scale - (overlappingContent.background as? MaterialShapeDrawable?)?.interpolation = progress - } - - /** - * Toggles the state of the view. - */ - fun toggleState(isOpen: Boolean) { - if (isOpen) { - open() - } else { - close() - } - } - - /** - * Opens the view. - */ - fun open() { - if (isOpen) { - return - } - smoothSlideTo(1f) - } - - /** - * Closes the view. - */ - fun close() { - if (!isOpen) { - return - } - - smoothSlideTo(0f) - } - - fun setVerticalDragEnabled(enabled: Boolean) { - this.isVerticalDragEnabled = enabled - if (!enabled && isOpen) { - close() - } - } - - private fun smoothSlideTo(offset: Float) { - val y = paddingTop + offset * dragHeightMax - if (dragHelper.smoothSlideViewTo(overlappingContent, overlappingContent.left, y.toInt())) { - postInvalidateOnAnimation() - } - } - - private inner class LeftDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return child.id == R.id.drawer_sidebar // Your left drawer ID - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - leftDragProgress = left.toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, leftDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return max(0, min(left, width - child.width)) - } - } - - private inner class RightDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return true//child.id == R.id.right_drawer_sidebar - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - rightDragProgress = (width - left).toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, rightDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return max(width - child.width, min(left, width)) - } - } -} \ No newline at end of file +open class SwipeRevealLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, + ) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { + /** + * Interface for listening to drag events. + */ + interface OnDragListener { + /** + * Called when the drag state changes. + */ + fun onDragStateChanged( + swipeRevealLayout: SwipeRevealLayout, + state: Int, + ) + + /** + * Called when the drag progress changes. + */ + fun onDragProgress( + swipeRevealLayout: SwipeRevealLayout, + progress: Float, + ) + } + + private var isVerticalDragEnabled = true + + /** + * Whether the most recent touch-down landed within the configured drag handle. The vertical + * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting + * in the middle of the overlapping content (e.g. the editor) are not stolen. + */ + private var isDownInDragHandle = false + + /** Scratch for [View.getLocationOnScreen] while testing a touch against the handle's bounds. */ + private val dragHandleLocation = IntArray(2) + + private val dragHelperCallback = + object : ViewDragHelper.Callback() { + override fun tryCaptureView( + child: View, + pointerId: Int, + ): Boolean = isVerticalDragEnabled && isDownInDragHandle && child === overlappingContent + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int, + ) { + draggingViewTop = top + onDragProgress(min(1f, top.toFloat() / dragHeightMax.toFloat())) + } + + override fun getViewVerticalDragRange(child: View): Int = if (isVerticalDragEnabled) dragHeightMax else 0 + + override fun getOrderedChildIndex(index: Int): Int = OVERLAPPING_CONTENT_INDEX + + override fun clampViewPositionVertical( + child: View, + top: Int, + dy: Int, + ): Int = min(max(top, paddingTop), dragHeightMax) + + override fun onViewDragStateChanged(state: Int) { + if (state == draggingState) { + return + } + + if (isDragging && state == ViewDragHelper.STATE_IDLE) { + isOpen = draggingViewTop >= dragHeightMax + } + + onDragStateChanged(state) + } + + override fun onViewReleased( + releasedChild: View, + xvel: Float, + yvel: Float, + ) { + if (draggingViewTop == 0) { + isOpen = false + return + } + + if (draggingViewTop >= dragHeightMax) { + isOpen = true + return + } + + // whether the view should settle to open or close + val settleDestY = + if (yvel > AUTO_OPEN_VELOCITY_LIM || draggingViewTop > dragHeightMax / 2) { + dragHeightMax + } else { + paddingTop + } + + if (dragHelper.settleCapturedViewAt(0, settleDestY)) { + this@SwipeRevealLayout.postInvalidateOnAnimation() + } + } + } + + private val hiddenContent: View + get() = getChildAt(HIDDEN_CONTENT_INDEX)!! + + private val overlappingContent: View + get() = getChildAt(OVERLAPPING_CONTENT_INDEX)!! + + private var draggingState = -1 + private var draggingViewTop = 0 + private val dragHeightMax + get() = hiddenContent.height + + private lateinit var dragHelper: ViewDragHelper + + /** + * Whether the view is currently in 'dragging' state. + */ + val isDragging: Boolean + get() = + draggingState == ViewDragHelper.STATE_DRAGGING || + draggingState == ViewDragHelper.STATE_SETTLING + + /** + * The ID of the view which will be dragged to reveal the content. + */ + @IdRes + var dragHandleViewId = 0 + + /** + * The current drag progress. + */ + @FloatRange(from = 0.0, to = 1.0) + var dragProgress = 0.0f + private set + + /** + * Listener for drag events. + */ + var dragListener: OnDragListener? = null + + /** + * Whether the view is open. + */ + var isOpen = false + protected set + + companion object { + private const val HIDDEN_CONTENT_INDEX = 0 + private const val OVERLAPPING_CONTENT_INDEX = 1 + + @Suppress("UNUSED") + const val STATE_IDLE = ViewDragHelper.STATE_IDLE + + @Suppress("UNUSED") + const val STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING + + @Suppress("UNUSED") + const val STATE_SETTLING = ViewDragHelper.STATE_SETTLING + + const val AUTO_OPEN_VELOCITY_LIM = 800.0 + } + + init { + if (attrs != null) { + context.withStyledAttributes( + attrs, + R.styleable.SwipeRevealLayout, + defStyleAttr, + defStyleRes, + ) { + dragHandleViewId = + getResourceId( + R.styleable.SwipeRevealLayout_dragHandle, + dragHandleViewId, + ) + } + } + } + + override fun onFinishInflate() { + super.onFinishInflate() + this.dragHelper = ViewDragHelper.create(this, dragHelperCallback) + this.isOpen = false + + check(childCount == 2) { + "SwipeRevealLayout must have exactly two children; the hidden content and the overlapping content" + } + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + measureChildren(widthMeasureSpec, heightMeasureSpec) + + val maxWidth = MeasureSpec.getSize(widthMeasureSpec) + val maxHeight = MeasureSpec.getSize(heightMeasureSpec) + + setMeasuredDimension( + resolveSizeAndState(maxWidth, widthMeasureSpec, 0), + resolveSizeAndState(maxHeight, heightMeasureSpec, 0), + ) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + hiddenContent.layout(0, paddingTop, r, paddingTop + hiddenContent.measuredHeight) + + val olapTop = paddingTop + (hiddenContent.height * dragProgress).toInt() + // Ensure overlappingContent extends to bottom to fill available space + overlappingContent.layout(0, olapTop, r, b) + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + val action = ev.actionMasked + if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + dragHelper.cancel() + return false + } + if (action == MotionEvent.ACTION_DOWN) { + isDownInDragHandle = isTouchInDragHandle(ev) + } + return dragHelper.shouldInterceptTouchEvent(ev) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + dragHelper.processTouchEvent(event) + return true + } + + /** + * Returns whether the given touch event falls within the bounds of the configured drag handle + * (see [dragHandleViewId] / the `app:dragHandle` attribute). When no drag handle is configured, + * the whole overlapping content acts as the handle (legacy behavior). + */ + private fun isTouchInDragHandle(ev: MotionEvent): Boolean { + if (dragHandleViewId == 0) { + return true + } + + val handle = findViewById(dragHandleViewId) + if (handle == null || handle.visibility != VISIBLE) { + return false + } + + handle.getLocationOnScreen(dragHandleLocation) + val left = dragHandleLocation[0] + val top = dragHandleLocation[1] + val right = left + handle.width + val bottom = top + handle.height + val x = ev.rawX + val y = ev.rawY + return x >= left && x <= right && y >= top && y <= bottom + } + + override fun computeScroll() { + if (dragHelper.continueSettling(true)) { + postInvalidateOnAnimation() + } + } + + /** + * Internal callback. Invoked when the drag state changes. + */ + @CallSuper + protected open fun onDragStateChanged(state: Int) { + draggingState = state + dragListener?.onDragStateChanged(this, state) + } + + /** + * Internal callback. Invoked when the drag progress changes. + */ + @CallSuper + protected open fun onDragProgress(progress: Float) { + if (dragProgress == progress) { + return + } + + dragProgress = progress + applyDragProgress(progress) + dragListener?.onDragProgress(this, progress) + } + + /** + * Applies the drag progress to the content. + */ + protected open fun applyDragProgress(progress: Float) { + val min = 0.97f + val max = 1f + val scale = min + (max - min) * (1 - progress) + overlappingContent.scaleX = scale + overlappingContent.scaleY = scale + (overlappingContent.background as? MaterialShapeDrawable?)?.interpolation = progress + } + + /** + * Toggles the state of the view. + */ + fun toggleState(isOpen: Boolean) { + if (isOpen) { + open() + } else { + close() + } + } + + /** + * Opens the view. + */ + fun open() { + if (isOpen) { + return + } + smoothSlideTo(1f) + } + + /** + * Closes the view. + */ + fun close() { + if (!isOpen) { + return + } + + smoothSlideTo(0f) + } + + fun setVerticalDragEnabled(enabled: Boolean) { + this.isVerticalDragEnabled = enabled + if (!enabled && isOpen) { + close() + } + } + + private fun smoothSlideTo(offset: Float) { + val y = paddingTop + offset * dragHeightMax + if (dragHelper.smoothSlideViewTo(overlappingContent, overlappingContent.left, y.toInt())) { + postInvalidateOnAnimation() + } + } + } diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt new file mode 100644 index 0000000000..9499f527d9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -0,0 +1,295 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.net.TrafficStats +import android.os.Process +import androidx.annotation.VisibleForTesting +import com.itsaky.androidide.tasks.cancelIfActive +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext + +/** + * Samples this app's network traffic (ADFA-5489). + * + * Accounting is UID-level, not per socket: [TrafficStats.getUidRxBytes] and + * [TrafficStats.getUidTxBytes] cover every process sharing the app's UID, which is what makes + * Gradle's downloads show up here -- the Gradle Tooling and daemon processes share it. No socket + * tagging is involved, so there is deliberately no per-feature breakdown. + * + * The platform counters are cumulative since boot, so what is recorded is the *delta* between + * consecutive samples: bytes transferred during that interval. A sampler that reported the raw + * counters would draw a monotonically rising line that says nothing about current activity. + * + * @param updateInterval Milliseconds between samples. + * @param uid The UID to account for. Defaults to this process's own; injectable for tests. + * @param readRxBytes Reads the cumulative received byte count. Injectable for tests. + * @param readTxBytes Reads the cumulative transmitted byte count. Injectable for tests. + */ +class NetworkUsageWatcher + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + constructor( + private val 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, + ) { + // 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) + + /** The running sampling loop, so [stopWatching] can actually stop it. */ + private var samplingJob: Job? = null + + /** Guards the two ring buffers: the sampler writes them, the UI thread snapshots them. */ + private val historyLock = Any() + + private val received = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + private val transmitted = MutableShiftedLongArray(MAX_USAGE_ENTRIES) + + /** + * The previous cumulative readings, or `null` before the first sample. The first sample + * establishes a baseline and contributes no delta -- the alternative would be a spike equal to + * everything the app had transferred since boot. + */ + private var lastRx: Long? = null + private var lastTx: Long? = null + + /** + * Whether the platform reports traffic for this UID at all. Cleared permanently if a read comes + * back [TrafficStats.UNSUPPORTED], which some devices and emulators do. + */ + @Volatile + var isSupported: Boolean = true + private set + + val isWatching: Boolean + get() = watching.get() + + /** + * Notified on the main thread after each sample. + */ + @Volatile + var listener: NetworkUsageListener? = null + + /** + * A snapshot of the sampled history, oldest first. Safe to call from any thread at any time; + * before the first sample every entry is zero. + * + * The arrays are copies. Handing out the live ring buffers would let the caller read them while + * the sampler thread is midway through appending, and the chart renderer reads all 30 entries. + */ + fun getUsage(): NetworkUsage = + synchronized(historyLock) { + NetworkUsage(received.snapshot(), transmitted.snapshot()) + } + + 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 (!watching.compareAndSet(false, true)) { + log.warn("Network usage is already being watched") + return + } + + 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. + runCatching { + sampleOnce() + + listener?.also { listener -> + val usage = getUsage() + withContext(mainDispatcher) { + listener.onNetworkUsageChanged(usage) + } + } + }.onFailure { failure -> + if (failure is CancellationException) { + throw failure + } + log.error("Network usage sampling failed; continuing", failure) + } + + // A device whose counters are unsupported has nothing further to give, and + // the loop was otherwise repainting three charts a second with data known + // to be permanently zero. Clearing the flag too, so isWatching does not + // claim a sampler that has stopped. + if (!isSupported) { + watching.set(false) + break + } + + delay(updateInterval) + } + } + } + + /** + * Stops sampling. The watcher can be started again; the history is kept. + */ + fun stopWatching() { + watching.set(false) + // Drop the cumulative baseline as well. Left set, the first sample after a resume + // reports everything transferred while the watcher was stopped as a single interval -- + // background a Gradle download for three minutes and the chart reads hundreds of MB/s. + // The next sample re-establishes it, which is what the null baseline means. + synchronized(historyLock) { + lastRx = null + lastTx = null + } + // 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. + * + * 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. + */ + fun close() { + stopWatching() + listener = null + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() + } + + /** + * Takes one sample. The sampling loop calls this once per [updateInterval]; tests call it + * directly so the delta accounting can be exercised without threads or waiting. + */ + @VisibleForTesting + internal fun sampleOnce() { + if (!isSupported) { + return + } + + val rx = readRxBytes(uid) + val tx = readTxBytes(uid) + + if (rx == UNSUPPORTED || tx == UNSUPPORTED) { + // Not transient: the platform either accounts for this UID or it does not. + isSupported = false + log.info("Network usage is unavailable on this device; the traffic chart will read zero") + return + } + + synchronized(historyLock) { + record(received, previous = lastRx, current = rx) + record(transmitted, previous = lastTx, current = tx) + } + + synchronized(historyLock) { + lastRx = rx + lastTx = tx + } + } + + /** + * Appends the delta between [previous] and [current] to [history]. + * + * A negative delta means the counter went backwards, which happens when it is reset -- the + * device rebooted, or the platform re-based its accounting. Treated as a fresh baseline (zero + * for this interval) rather than plotted as negative traffic. + */ + private fun record( + history: MutableShiftedLongArray, + previous: Long?, + current: Long, + ) { + val delta = + when { + previous == null -> 0L + current < previous -> 0L + else -> current - previous + } + + // Newest entry goes in at index 0 and the shift makes it the last element, so + // history[size - 1] is always the newest. Same convention as MemoryUsageWatcher. + history[0] = delta + history.shift(1) + } + + /** + * Bytes transferred per sampling interval, oldest first. + * + * @property received Bytes received during each interval. + * @property transmitted Bytes transmitted during each interval. + */ + data class NetworkUsage( + val received: LongArray, + val transmitted: LongArray, + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is NetworkUsage && + received.contentEquals(other.received) && + transmitted.contentEquals(other.transmitted) + ) + + override fun hashCode(): Int = 31 * received.contentHashCode() + transmitted.contentHashCode() + } + + fun interface NetworkUsageListener { + fun onNetworkUsageChanged(usage: NetworkUsage) + } + + companion object { + const val MAX_USAGE_ENTRIES = 30 + const val DEFAULT_UPDATE_INTERVAL = 1000L + + /** [TrafficStats.UNSUPPORTED] widened to [Long], which is what the getters return. */ + private const val UNSUPPORTED = TrafficStats.UNSUPPORTED.toLong() + + private val log = LoggerFactory.getLogger(NetworkUsageWatcher::class.java) + } + } + +/** + * Copies this ring buffer into a plain array in logical order, oldest first. + */ +private fun ShiftedLongArray.snapshot(): LongArray = LongArray(size) { this[it] } diff --git a/app/src/main/res/layout/item_metrics_memory_chart.xml b/app/src/main/res/layout/item_metrics_memory_chart.xml new file mode 100644 index 0000000000..d6eaaa40ab --- /dev/null +++ b/app/src/main/res/layout/item_metrics_memory_chart.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/item_metrics_network_chart.xml b/app/src/main/res/layout/item_metrics_network_chart.xml new file mode 100644 index 0000000000..f011080f06 --- /dev/null +++ b/app/src/main/res/layout/item_metrics_network_chart.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index 92888099bc..e78b5f9dc9 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -1,33 +1,42 @@ - + - + + - - + + - \ No newline at end of file + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e39716e1cf..f1785efd39 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,33 +1,25 @@ - + - 200dp - 28dp + 248dp + 16dp + 4dp + 28dp - 28dp - 8dp - 24dp - 32sp - 16sp - 12sp + 28dp + 8dp + 24dp + 32sp + 16sp + 12sp - - 44dp - 64dp - 6dp - \ No newline at end of file + + 44dp + 64dp + 6dp + diff --git a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt new file mode 100644 index 0000000000..5d1c8bab2a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -0,0 +1,192 @@ +/* + * 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.Color +import androidx.collection.MutableIntObjectMap +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.data.LineDataSet +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.MutableShiftedLongArray +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the properties ADFA-5487's metrics carousel relies on: the renderer holds no sample state, so + * a chart attached at any time shows the complete history, and a change to the watched process set + * is picked up rather than dropped. + */ +@RunWith(RobolectricTestRunner::class) +class MemoryUsageChartRendererTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun chart() = SafeLineChart(context) + + private fun renderer(processes: () -> Array) = + MemoryUsageChartRenderer( + usagesProvider = processes, + lineColorFor = { Color.BLUE }, + ) + + /** A process whose history ramps from [firstMegabytes] by 1MB per sample. */ + private fun proc( + pid: Int, + pname: String, + firstMegabytes: Long, + ) = ProcessMemoryInfo( + pid, + pname, + MutableShiftedLongArray(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { (firstMegabytes + it) * BYTES_PER_MB }, + ) + + private fun datasetFor( + chart: SafeLineChart, + index: Int, + ) = chart.data.getDataSetByIndex(index) as LineDataSet + + @Test + fun `attach renders the complete existing history, not a flat line`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + + renderer { processes }.attach(chart) + + val dataset = datasetFor(chart, 0) + assertThat(dataset.entryCount).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + // The old resetMemUsageChart() seeded every entry with 0f and waited a tick for real values; + // a carousel page attached mid-session would have shown that flat line. + assertThat(dataset.entries.map { it.y }).doesNotContain(0f) + assertThat(dataset.entries.first().y).isEqualTo(100f) + assertThat(dataset.entries.last().y).isEqualTo((100 + MemoryUsageWatcher.MAX_USAGE_ENTRIES - 1).toFloat()) + assertThat(dataset.label).isEqualTo("IDE - %.2fMB".format(dataset.entries.last().y)) + } + + @Test + fun `onUsagesChanged updates entries in place without replacing the datasets`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + val renderer = renderer { processes } + renderer.attach(chart) + + val datasetBefore = datasetFor(chart, 0) + val entryBefore = datasetBefore.entries.first() + + val updated = proc(pid = 1, pname = "IDE", firstMegabytes = 200) + renderer.onUsagesChanged(MutableIntObjectMap().apply { put(1, updated) }) + + // Same dataset and same Entry objects, new values: this path runs once a second for the + // lifetime of the editor, so it must not allocate. + assertThat(datasetFor(chart, 0)).isSameInstanceAs(datasetBefore) + assertThat(datasetBefore.entries.first()).isSameInstanceAs(entryBefore) + assertThat(entryBefore.y).isEqualTo(200f) + } + + @Test + fun `onUsagesChanged rebuilds when a process starts being watched`() { + var processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + val renderer = renderer { processes } + renderer.attach(chart) + + assertThat(chart.data.dataSetCount).isEqualTo(1) + + // Gradle Tooling starts up. The old code looked the new pid up in a map that only reset() + // populated, logged "No dataset found for process", and dropped its samples. + val gradle = proc(pid = 2, pname = "Gradle Tooling", firstMegabytes = 300) + processes = arrayOf(processes[0], gradle) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { + put(1, processes[0]) + put(2, gradle) + }, + ) + + assertThat(chart.data.dataSetCount).isEqualTo(2) + assertThat(datasetFor(chart, 1).label).startsWith("Gradle Tooling - ") + assertThat(datasetFor(chart, 1).entries.first().y).isEqualTo(300f) + } + + @Test + fun `onUsagesChanged after detach is a no-op`() { + var processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val renderer = renderer { processes } + val detached = chart() + renderer.attach(detached) + val before = datasetFor(detached, 0).entries.map { it.y } + + renderer.detach() + + // Different samples, so a renderer that kept writing would visibly change the chart. + processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 900)) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { put(1, processes[0]) }, + ) + + // A recycled carousel page must not keep the renderer writing into a dead view. Asserting + // only that the call does not throw pinned nothing: it would not have thrown anyway. + assertThat(datasetFor(detached, 0).entries.map { it.y }).isEqualTo(before) + } + + @Test + fun `a swapped process rebuilds rather than plotting its samples on another line`() { + // The count stays the same and a pid changes -- what a tooling-server pid correction does. + // The suite covered only the count-changed path, and correctness here rested on + // getDataSetByIndex(-1) happening to return null. + val first = proc(pid = 1, pname = "IDE", firstMegabytes = 100) + var processes = arrayOf(first) + val renderer = renderer { processes } + val chart = chart() + renderer.attach(chart) + + val replacement = proc(pid = 2, pname = "Gradle Tooling", firstMegabytes = 700) + processes = arrayOf(replacement) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { put(2, replacement) }, + ) + + assertThat(chart.data.dataSetCount).isEqualTo(1) + assertThat(datasetFor(chart, 0).label).startsWith("Gradle Tooling - ") + assertThat(datasetFor(chart, 0).entries.first().y).isEqualTo(700f) + } + + @Test + fun `attach after detach renders the history into the new chart`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val renderer = renderer { processes } + renderer.attach(chart()) + renderer.detach() + + val rebound = chart() + renderer.attach(rebound) + + assertThat(datasetFor(rebound, 0).entryCount).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(datasetFor(rebound, 0).entries.first().y).isEqualTo(100f) + } + + private companion object { + /** + * The production constant, not a copy of it. With its own literal the test verified its + * own arithmetic: change the renderer to decimal megabytes and every assertion still + * passed because both sides had stopped agreeing. + */ + val BYTES_PER_MB = BYTES_PER_MEGABYTE.toLong() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt new file mode 100644 index 0000000000..d24b027cd8 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -0,0 +1,228 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.LineDataSet +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.math.log10 + +/** + * Pins the two axis decisions ADFA-5489 was scoped around: values are log10, and zero is floored + * via `log10(bytes + 1)` so an idle IDE plots a continuous line at 0 instead of negative infinity. + */ +@RunWith(RobolectricTestRunner::class) +class NetworkUsageChartRendererTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun usage( + received: LongArray, + transmitted: LongArray = received, + ) = NetworkUsageWatcher.NetworkUsage(received, transmitted) + + private fun rendererFor(usage: NetworkUsageWatcher.NetworkUsage): Pair { + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage }) + renderer.attach(chart) + return renderer to chart + } + + private fun dataset( + chart: SafeLineChart, + index: Int, + ) = chart.data.getDataSetByIndex(index) as LineDataSet + + @Test + fun `plots log10 of the byte count`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 9L, 99L, 999L))) + + val ys = dataset(chart, 0).entries.map { it.y } + + // log10(n + 1): 0 -> 0, 9 -> 1, 99 -> 2, 999 -> 3. Exact decades, so the floor is visible. + assertThat(ys).containsExactly(0f, 1f, 2f, 3f).inOrder() + } + + @Test + fun `zero bytes plots at zero rather than negative infinity`() { + val (_, chart) = rendererFor(usage(LongArray(30) { 0L })) + + val ys = dataset(chart, 0).entries.map { it.y } + + assertThat(ys.none { it.isInfinite() || it.isNaN() }).isTrue() + assertThat(ys.toSet()).containsExactly(0f) + } + + @Test + fun `a megabyte burst stays on scale with surrounding chatter`() { + val bytes = longArrayOf(0L, 512L, 2L * 1024 * 1024, 256L) + val (_, chart) = rendererFor(usage(bytes)) + + val ys = dataset(chart, 0).entries.map { it.y } + + // The point of the log axis: a 2MB burst is ~6.3 while 512B is ~2.7, so the small values + // stay legible instead of being flattened onto the baseline. + assertThat(ys[2]).isWithin(0.01f).of(log10(2.0 * 1024 * 1024 + 1).toFloat()) + assertThat(ys[1]).isGreaterThan(2f) + assertThat(ys[2] - ys[1]).isLessThan(4f) + } + + @Test + fun `received and transmitted are separate series`() { + val (_, chart) = + rendererFor( + usage( + received = longArrayOf(0L, 999L), + transmitted = longArrayOf(0L, 9L), + ), + ) + + assertThat(chart.data.dataSetCount).isEqualTo(2) + assertThat(dataset(chart, 0).entries.last().y).isEqualTo(3f) + assertThat(dataset(chart, 1).entries.last().y).isEqualTo(1f) + } + + @Test + fun `the legend reports the latest sample in byte units`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 2_000L))) + + // Rendered from the raw byte count, not from the logarithm, and in decimal units so that + // the log10 axis labels come out as clean decades. + assertThat(dataset(chart, 0).label).endsWith("2.0 kB/s") + } + + @Test + fun `onUsageChanged updates entries in place without replacing the datasets`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 9L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + val datasetBefore = dataset(chart, 0) + val entryBefore = datasetBefore.entries.last() + + current = usage(longArrayOf(0L, 999L)) + renderer.onUsageChanged(current) + + assertThat(dataset(chart, 0)).isSameInstanceAs(datasetBefore) + assertThat(datasetBefore.entries.last()).isSameInstanceAs(entryBefore) + assertThat(entryBefore.y).isEqualTo(3f) + } + + @Test + fun `onUsageChanged rebuilds when the sample count changes`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 9L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + assertThat(dataset(chart, 0).entryCount).isEqualTo(2) + + current = usage(longArrayOf(0L, 9L, 99L)) + renderer.onUsageChanged(current) + + assertThat(dataset(chart, 0).entryCount).isEqualTo(3) + } + + @Test + fun `attach after detach renders the history into the new chart`() { + val current = usage(longArrayOf(0L, 99L)) + val (renderer, _) = rendererFor(current) + renderer.detach() + + val rebound = SafeLineChart(context) + renderer.attach(rebound) + + assertThat(dataset(rebound, 0).entries.last().y).isEqualTo(2f) + } + + @Test + fun `axis labels are whole units with no decimal place`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 10_000_000L))) + val formatter = chart.axisRight.valueFormatter + + // Gridlines sit on whole decades, so the mantissa is exact. + assertThat(formatter.getFormattedValue(0f, chart.axisRight)).isEqualTo("0 B") + assertThat(formatter.getFormattedValue(1f, chart.axisRight)).isEqualTo("10 B") + assertThat(formatter.getFormattedValue(3f, chart.axisRight)).isEqualTo("1 kB") + assertThat(formatter.getFormattedValue(4f, chart.axisRight)).isEqualTo("10 kB") + assertThat(formatter.getFormattedValue(6f, chart.axisRight)).isEqualTo("1 MB") + } + + @Test + fun `the series are scaled against the labelled axis`() { + val (_, chart) = rendererFor(usage(longArrayOf(0L, 100L))) + + // The right axis is the one carrying the labels and the pinned range. A dataset left on the + // default LEFT dependency is drawn against the auto-ranged left axis, so the line lands + // somewhere the labels do not describe -- which is invisible to an assertion on the axis + // alone, and was only caught on a device. + assertThat(dataset(chart, 0).axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + assertThat(dataset(chart, 1).axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + } + + @Test + fun `an idle chart keeps zero on the baseline`() { + // Every sample zero. Left to itself the chart pads around a degenerate range and floats the + // flat line up the middle of the plot instead of resting it on the axis minimum. + val (_, chart) = rendererFor(usage(LongArray(30) { 0L })) + + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + assertThat(chart.axisRight.axisMaximum).isEqualTo(3f) + } + + @Test + fun `the axis grows to whole decades around the peak`() { + // 2 MB peak -> log10 is ~6.3, so the axis tops out at the 10 MB decade. + val (_, chart) = rendererFor(usage(longArrayOf(0L, 2_000_000L))) + + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + assertThat(chart.axisRight.axisMaximum).isEqualTo(7f) + } + + @Test + fun `the axis follows the peak across both series`() { + val chart = SafeLineChart(context) + var current = usage(longArrayOf(0L, 100L)) + val renderer = NetworkUsageChartRenderer(usageProvider = { current }) + renderer.attach(chart) + + assertThat(chart.axisRight.axisMaximum).isEqualTo(3f) + + // A burst on the transmitted series alone must still lift the axis. + current = usage(received = longArrayOf(0L, 100L), transmitted = longArrayOf(0L, 500_000L)) + renderer.onUsageChanged(current) + + assertThat(chart.axisRight.axisMaximum).isEqualTo(6f) + } + + @Test + fun `onUsageChanged after detach is a no-op`() { + val current = usage(longArrayOf(0L, 99L)) + val (renderer, _) = rendererFor(current) + renderer.detach() + + // A recycled carousel page must not keep the renderer writing into a dead view. + renderer.onUsageChanged(current) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt new file mode 100644 index 0000000000..963321cd01 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/NetworkUsageWatcherTest.kt @@ -0,0 +1,203 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the accounting decisions ADFA-5489 was scoped around: the platform counters are cumulative, + * so what is plotted is the delta between samples, and a counter reset must not plot as negative + * traffic. + * + * These drive [NetworkUsageWatcher.sampleOnce] directly rather than starting the sampling loop, so + * there is no waiting and no dependence on thread timing. + */ +@RunWith(RobolectricTestRunner::class) +class NetworkUsageWatcherTest { + /** Every watcher built here, so the sampling thread each one starts is released. */ + private val created = mutableListOf() + + @After + fun tearDown() { + created.forEach { it.close() } + created.clear() + } + + /** + * A watcher fed a scripted sequence of cumulative readings, advancing one step per sample. + */ + private inner class Fixture( + rx: List, + tx: List = rx, + ) { + private var index = -1 + private val rxReadings = rx + private val txReadings = tx + + val watcher = + NetworkUsageWatcher( + uid = TEST_UID, + readRxBytes = { rxReadings[index.coerceIn(0, rxReadings.lastIndex)] }, + readTxBytes = { txReadings[index.coerceIn(0, txReadings.lastIndex)] }, + ).also { created += it } + + /** Takes [count] samples, walking the scripted readings. */ + fun sample(count: Int) { + repeat(count) { + index++ + watcher.sampleOnce() + } + } + } + + /** The last [count] recorded samples, ignoring the leading zeros of an unfilled buffer. */ + private fun LongArray.recent(count: Int): List = takeLast(count) + + @Test + fun `history is all zeros before the first sample`() { + val fixture = Fixture(listOf(5_000L)) + + val usage = fixture.watcher.getUsage() + + assertThat(usage.received).hasLength(NetworkUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(usage.received.sum()).isEqualTo(0L) + assertThat(usage.transmitted.sum()).isEqualTo(0L) + } + + @Test + fun `plots deltas between samples, not the cumulative counters`() { + // Cumulative since boot: 1000, then +500, then +2500. + val fixture = Fixture(listOf(1_000L, 1_500L, 4_000L)) + + fixture.sample(3) + val usage = fixture.watcher.getUsage() + + // The first sample only establishes a baseline, so it contributes 0 rather than a + // 1000-byte spike for traffic that happened before the chart existed. + assertThat(usage.received.recent(3)).containsExactly(0L, 500L, 2_500L).inOrder() + assertThat(usage.transmitted.recent(3)).containsExactly(0L, 500L, 2_500L).inOrder() + } + + @Test + fun `a counter reset records zero rather than negative traffic`() { + // A reboot or re-based accounting makes the counter go backwards. + val fixture = Fixture(listOf(10_000L, 10_400L, 200L, 700L)) + + fixture.sample(4) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received.recent(4)).containsExactly(0L, 400L, 0L, 500L).inOrder() + assertThat(usage.received.none { it < 0L }).isTrue() + } + + @Test + fun `received and transmitted are accounted separately`() { + val fixture = + Fixture( + rx = listOf(0L, 1_000L), + tx = listOf(0L, 7L), + ) + + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received.recent(2)).containsExactly(0L, 1_000L).inOrder() + assertThat(usage.transmitted.recent(2)).containsExactly(0L, 7L).inOrder() + } + + @Test + fun `the ring buffer keeps only the most recent samples`() { + val capacity = NetworkUsageWatcher.MAX_USAGE_ENTRIES + // Cumulative readings rising by 10 bytes each sample, for one more sample than fits. + val readings = List(capacity + 2) { it * 10L } + val fixture = Fixture(readings) + + fixture.sample(readings.size) + val usage = fixture.watcher.getUsage() + + assertThat(usage.received).hasLength(capacity) + // The baseline zero has been pushed out; every retained sample is a full 10-byte delta. + assertThat(usage.received.toList()).containsNoneIn(listOf(-10L)) + assertThat(usage.received.last()).isEqualTo(10L) + assertThat(usage.received.sum()).isEqualTo(10L * capacity) + } + + @Test + fun `an unsupported counter is detected and nothing is recorded`() { + // TrafficStats.UNSUPPORTED is -1. + val fixture = Fixture(listOf(-1L)) + + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + assertThat(fixture.watcher.isSupported).isFalse() + // In particular, -1 is not plotted as traffic. + assertThat(usage.received.sum()).isEqualTo(0L) + assertThat(usage.transmitted.sum()).isEqualTo(0L) + } + + @Test + fun `getUsage returns a copy, not the live buffer`() { + val fixture = Fixture(listOf(0L, 100L, 300L)) + + fixture.sample(2) + val first = fixture.watcher.getUsage() + val asHandedOut = first.received.copyOf() + fixture.sample(1) + + // The array handed out earlier must not have been mutated by the later sample. + assertThat(first.received).isEqualTo(asHandedOut) + assertThat(fixture.watcher.getUsage().received).isNotEqualTo(asHandedOut) + } + + @Test + fun `stopping drops the cumulative baseline so a resume does not spike`() { + // 1 MB transferred, then the watcher is stopped while a download keeps running. + val fixture = Fixture(listOf(1_000_000L, 1_000_000L, 250_000_000L, 250_500_000L)) + fixture.sample(2) + + fixture.watcher.stopWatching() + + // Resume: the counter has moved by 249 MB while nothing was watching. + fixture.sample(2) + val usage = fixture.watcher.getUsage() + + // Kept, the baseline turns the whole gap into one interval's traffic -- the legend reads + // hundreds of MB/s and the axis is stretched for the next minute. + assertThat(usage.received.recent(2)).containsExactly(0L, 500_000L).inOrder() + } + + @Test + fun `an unsupported counter stops the watcher rather than sampling zeroes forever`() { + val fixture = Fixture(listOf(-1L)) + + fixture.sample(1) + + // Nothing more to read, so nothing more to do: the loop was repainting the charts once a + // second with data known to be permanently unavailable. + assertThat(fixture.watcher.isSupported).isFalse() + } + + private companion object { + const val TEST_UID = 10_123 + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt b/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt new file mode 100644 index 0000000000..b990dfa45f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/NetworkWatcherLifecycleTest.kt @@ -0,0 +1,143 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the sampling loop's lifecycle (ADFA-5489). + * + * The loop spends nearly all of its time in `delay()`, so "stopped" cannot mean "will notice a + * flag eventually": between the request and the next tick the watcher is still sampling, and a + * stop followed by a start inside that window used to leave two loops appending to one buffer. + * + * Driven on a virtual clock. Waiting on the wall clock instead is what hung the test executor the + * first time this was attempted. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class NetworkWatcherLifecycleTest { + private fun watcher( + dispatcher: kotlin.coroutines.CoroutineContext, + onSample: () -> Unit = {}, + ): NetworkUsageWatcher { + var counter = 0L + return NetworkUsageWatcher( + updateInterval = INTERVAL_MS, + uid = TEST_UID, + readRxBytes = { + onSample() + counter += 100L + counter + }, + readTxBytes = { counter }, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + } + + @Test + fun `stopping inside the sampling interval actually stops sampling`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + + try { + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 3) + val whileRunning = samples + + watcher.stopWatching() + advanceTimeBy(INTERVAL_MS * 5) + + // Cancelling the job rather than waiting for the loop to observe a flag is what makes + // this exact: nothing is sampled after the stop. + assertThat(whileRunning).isGreaterThan(0) + assertThat(samples).isEqualTo(whileRunning) + assertThat(watcher.isWatching).isFalse() + } finally { + // Closed here rather than after the assertions: see the class KDoc. + watcher.close() + } + } + + @Test + fun `restarting inside the sampling interval does not leave two loops running`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + + try { + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 2) + watcher.stopWatching() + watcher.startWatching() + + val before = samples + advanceTimeBy(INTERVAL_MS * 4) + + // The raw count, not a rate: integer division passed for anything from four to + // seven samples, so a second loop that only partly overlapped went unnoticed. + assertThat(samples - before).isEqualTo(4) + } finally { + watcher.close() + } + } + + @Test + fun `a listener that throws does not kill sampling for the rest of the session`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = watcher(dispatcher) { samples++ } + + try { + var thrown = 0 + watcher.listener = + NetworkUsageWatcher.NetworkUsageListener { + if (thrown++ == 0) { + throw IllegalStateException("listener blew up") + } + } + + watcher.startWatching() + advanceTimeBy(INTERVAL_MS * 4) + + // Uncaught, the exception ends the coroutine while isWatching stays true, so every + // later startWatching() is refused and the charts freeze for good. + assertThat(samples).isGreaterThan(1) + assertThat(watcher.isWatching).isTrue() + } finally { + watcher.close() + } + } + + private companion object { + const val INTERVAL_MS = 1_000L + const val TEST_UID = 10_123 + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index bab84b6648..e968ccb924 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1680,4 +1680,12 @@ Dock to editor Close + Memory usage chart + Memory usage + Network traffic chart + Network traffic + Received + Sent + +