Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
cf90aaf
fix: honour MemoryUsageWatcher's configured sampling interval
davidschachterADFA Sep 5, 2026
d7b34a6
refactor: extract MetricsChartRenderer, shared by both carousel charts
davidschachterADFA Sep 5, 2026
e681aa5
feat: retain an hour of samples, in a ViewModel, shown as a moving wi…
davidschachterADFA Sep 5, 2026
6e5e2a2
fix: make the x axis labels visible, and sample while backgrounded
davidschachterADFA Sep 5, 2026
bd4f2b5
feat: raise retention to 10000 samples and add the sampling-rate policy
davidschachterADFA Sep 5, 2026
d551fdc
refactor: extract MetricsCarouselController ahead of undocking
davidschachterADFA Sep 5, 2026
6108434
feat: undock the metrics carousel into a floating window
davidschachterADFA Sep 5, 2026
78ef79b
fix: make the sampling loop stoppable, restartable and crash-proof
davidschachterADFA Sep 5, 2026
ebe6874
feat: annotate the metrics charts with Gradle task events
davidschachterADFA Sep 5, 2026
f632892
feat: export a chart snapshot as a shareable image
davidschachterADFA Sep 5, 2026
1e36e1d
feat: choose the sampling rate by tapping the x axis
davidschachterADFA Sep 5, 2026
f5f2d7d
feat: trigger the chart snapshot from a camera button
davidschachterADFA Sep 5, 2026
61d1131
feat: pinch to zoom the chart, with the carousel swipe kept below the…
davidschachterADFA Sep 5, 2026
825ee45
fix: restore the carousel swipe and the auto-follow window; add pagin…
davidschachterADFA Sep 5, 2026
7511b17
fix: tint the carousel arrows so they are visible on a dark chart
davidschachterADFA Sep 5, 2026
9444417
feat: page the carousel only with the arrows
davidschachterADFA Sep 5, 2026
2a72a8a
Merge branch 'feature/ADFA-5489-network-traffic-page' into feature/AD…
davidschachterADFA Sep 5, 2026
c12bc1e
fix(metrics): address four review findings on the carousel (ADFA-5486)
davidschachterADFA Sep 6, 2026
8e68679
fix(metrics): put the sampling-rate tap on the edge the x axis is dra…
davidschachterADFA Sep 6, 2026
1b107df
Merge branch 'feature/ADFA-5489-network-traffic-page' into feature/AD…
davidschachterADFA Sep 6, 2026
717f509
fix(metrics): close out the remaining review findings on the carousel…
davidschachterADFA Sep 6, 2026
8f794a3
fix(metrics): hide the whole strip when the carousel undocks (ADFA-5486)
davidschachterADFA Sep 6, 2026
aa7c719
fix(metrics): scale the network axis to the visible window (ADFA-5486)
davidschachterADFA Sep 6, 2026
aeac2bb
fix(metrics): guard the snapshot share, and clamp the rate where the …
davidschachterADFA Sep 6, 2026
eb4335b
fix(metrics): make the carousel work in the floating window (ADFA-5486)
davidschachterADFA Sep 6, 2026
8fad3ad
fix(metrics): scale the memory axis to the visible window too (ADFA-5…
davidschachterADFA Sep 6, 2026
837b8a1
Merge branch 'feature/ADFA-5489-network-traffic-page' into feature/AD…
davidschachterADFA Sep 6, 2026
c34b648
fix(metrics): report network traffic as the rate the legend claims (A…
davidschachterADFA Sep 6, 2026
5676677
fix(metrics): snapshot the memory history, window the annotations, cl…
davidschachterADFA Sep 6, 2026
7cdbfa9
ADFA-5486: fix the undocked carousel's invisible arrows and lost page
davidschachterADFA Sep 6, 2026
bbf3ab1
ADFA-5486: one snapshot at a time, and track tap pointers by id
davidschachterADFA Sep 6, 2026
26e298d
ADFA-5486: fix the axis the memory lines use, and make panning stick
davidschachterADFA Sep 7, 2026
2d4453c
ADFA-5486: put the share flags on the chooser, and widen the marker span
davidschachterADFA Sep 7, 2026
7a17f2c
ADFA-5486: ask where the carousel is before binding one, and stop thr…
davidschachterADFA Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,14 @@ import androidx.core.os.BundleCompat
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
Expand Down Expand Up @@ -119,10 +119,7 @@ import com.itsaky.androidide.tasks.cancelIfActive
import com.itsaky.androidide.tasks.mainThreadHandler
import com.itsaky.androidide.ui.CodeEditorView
import com.itsaky.androidide.ui.ContentTranslatingDrawerLayout
import com.itsaky.androidide.ui.MemoryUsageChartRenderer
import com.itsaky.androidide.ui.MetricsCarouselAdapter
import com.itsaky.androidide.ui.MetricsPage
import com.itsaky.androidide.ui.NetworkUsageChartRenderer
import com.itsaky.androidide.ui.MetricsCarouselController
import com.itsaky.androidide.ui.SwipeRevealLayout
import com.itsaky.androidide.uidesigner.UIDesignerActivity
import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow
Expand All @@ -132,7 +129,6 @@ import com.itsaky.androidide.utils.FlashType
import com.itsaky.androidide.utils.InstallationResultHandler.onResult
import com.itsaky.androidide.utils.IntentUtils
import com.itsaky.androidide.utils.MemoryUsageWatcher
import com.itsaky.androidide.utils.NetworkUsageWatcher
import com.itsaky.androidide.utils.StringsInjectionException
import com.itsaky.androidide.utils.StringsXmlInjector
import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation
Expand All @@ -154,6 +150,7 @@ import com.itsaky.androidide.viewmodel.DebuggerViewModel
import com.itsaky.androidide.viewmodel.EditorViewModel
import com.itsaky.androidide.viewmodel.FileManagerViewModel
import com.itsaky.androidide.viewmodel.FileOpResult
import com.itsaky.androidide.viewmodel.MetricsViewModel
import com.itsaky.androidide.viewmodel.RecentProjectsViewModel
import com.itsaky.androidide.viewmodel.WADBConnectionViewModel
import com.itsaky.androidide.xml.resources.ResourceTableRegistry
Expand Down Expand Up @@ -189,22 +186,29 @@ abstract class BaseEditorActivity :
protected var editorBottomSheet: BottomSheetBehavior<out View?>? = null
private var drawerToggle: ActionBarDrawerToggle? = null
private var bottomSheetCallback: BottomSheetBehavior.BottomSheetCallback? = null
protected val memoryUsageWatcher = MemoryUsageWatcher()
private var metricsPageCallback: ViewPager2.OnPageChangeCallback? = null
private val memUsageChartRenderer =
MemoryUsageChartRenderer(
usagesProvider = memoryUsageWatcher::getMemoryUsages,
private val metricsViewModel by viewModels<MetricsViewModel>()

/**
* Sample history lives in [MetricsViewModel] so it survives configuration changes and activity
* recreation rather than depending on this activity's configChanges declaration (ADFA-5486).
*/
protected val memoryUsageWatcher get() = metricsViewModel.memoryUsageWatcher

protected val networkUsageWatcher get() = metricsViewModel.networkUsageWatcher

protected val metricsCarousel by lazy {
MetricsCarouselController(
memoryUsageWatcher = memoryUsageWatcher,
networkUsageWatcher = networkUsageWatcher,
lineColorFor = Companion::getMemUsageLineColorFor,
annotations = metricsViewModel.annotations,
)
}

private val networkUsageWatcher = NetworkUsageWatcher()
private val networkUsageChartRenderer =
NetworkUsageChartRenderer(usageProvider = networkUsageWatcher::getUsage)

private val networkUsageListener =
NetworkUsageWatcher.NetworkUsageListener { usage ->
networkUsageChartRenderer.onUsageChanged(usage)
}
/** Records a significant event for the charts to annotate (ADFA-5486). */
fun recordMetricsAnnotation(label: String) {
metricsViewModel.annotations.record(label)
}

private val fileManagerViewModel by viewModels<FileManagerViewModel>()
private var feedbackButtonManager: FeedbackButtonManager? = null
Expand Down Expand Up @@ -325,11 +329,6 @@ abstract class BaseEditorActivity :
}
}

private val memoryUsageListener =
MemoryUsageWatcher.MemoryUsageListener { memoryUsage ->
memUsageChartRenderer.onUsagesChanged(memoryUsage)
}

private val shizukuBinderReceivedListener =
Shizuku.OnBinderReceivedListener {
invalidateOptionsMenu()
Expand Down Expand Up @@ -453,13 +452,16 @@ abstract class BaseEditorActivity :
/**
* The plot colour for a watched process.
*
* On the companion rather than the activity: a bound reference to an activity method is
* handed to the renderer, which the carousel adapter holds, so any path that misses the
* adapter teardown would keep the whole editor reachable. Nothing here needs an activity.
* Lives on the companion, not on the activity: a bound reference to an activity method is
* handed to [MetricsCarouselController], which is in turn handed to the floating window and
* outlives an activity recreation. A pure function of the process name has no business
* pinning an activity in memory, and this one is exactly that.
*
* An unrecognised name falls back rather than throwing. The renderer now reaches this from
* the once-a-second sample listener and from RecyclerView's bind pass, so a name nobody
* added a colour for would take the editor down from a timer callback or mid-layout.
* An unrecognised name falls back rather than throwing. This is reached from the
* once-a-second sample listener and from RecyclerView's bind pass, so a name nobody added a
* colour for would take the editor down from a timer callback or mid-layout -- a crash for
* the sake of a line colour. 5d00a796a and 4c65554e5 each established that; this branch
* removed it again, so it is written down here rather than rediscovered a fourth time.
*/
@JvmStatic
fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int =
Expand Down Expand Up @@ -548,21 +550,22 @@ abstract class BaseEditorActivity :
fullscreenManager?.destroy()
fullscreenManager = null

metricsPageCallback?.let { callback ->
_binding?.memUsageView?.metricsPager?.unregisterOnPageChangeCallback(callback)
// Same reasoning as onPause: a floating carousel is bound to the window, not to these
// views. On a real teardown the window goes with the editor, so releasing the controller
// then is correct.
if (!isMetricsCarouselUndocked() || isDestroying) {
metricsCarousel.unbind()
}
if (isDestroying) {
metricsCarousel.close()
}
metricsPageCallback = null
_binding?.memUsageView?.metricsPager?.adapter = null
memUsageChartRenderer.detach()
networkUsageChartRenderer.detach()
_binding = null

if (isDestroying) {
memoryUsageWatcher.stopWatching(true)
// Sampling itself is stopped by MetricsViewModel.onCleared; the history has to outlive a
// recreation, so it must not be torn down whenever this activity goes away.
memoryUsageWatcher.listener = null
// close(), not stopWatching(): this is the terminal teardown, and the watcher holds a
// dedicated sampling thread that newSingleThreadContext keeps alive until it is closed.
networkUsageWatcher.close()
networkUsageWatcher.listener = null
editorActivityScope.cancelIfActive("Activity is being destroyed")

unbindDebuggerService()
Expand Down Expand Up @@ -999,45 +1002,63 @@ abstract class BaseEditorActivity :
content.editorAppBarLayout.updatePadding(top = topInset)
}

// translationY, not a margin: this runs on every frame of the reveal drag, and a
// margin change calls requestLayout, which now re-measures a ViewPager2, its
// RecyclerView and every attached page rather than the single chart view it used to.
// The visual result is identical for a pure vertical offset.
memUsageView.metricsPager.translationY = insetsTop * progress
metricsCarousel.pager?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = (insetsTop * progress).roundToInt()
}
}
}

private fun setupMetricsCarousel() {
val pages =
listOf(
// The memory chart is the default page (ADFA-5487); network traffic is the second
// (ADFA-5489), replacing the brand-mark placeholder that ADFA-5487 shipped.
MetricsPage.MemoryChart(title = string.metrics_title_memory),
MetricsPage.NetworkChart(title = string.metrics_title_network),
)
binding.memUsageView.root.onTwoFingerTap = ::onMetricsCarouselUndockRequested
binding.memUsageView.metricsUndockedMessage.setOnClickListener {
onMetricsCarouselRedockRequested()
}

// Ask where the carousel is before binding one here. Only one can be live at a time, and
// the floating one outlives this activity -- so an activity recreated while it is floating
// (a night-mode or locale change, or leaving the editor and coming back) used to bind a
// second carousel into the strip and leave the floating one attached to a destroyed
// activity's views, frozen, with the strip showing no sign that it had gone anywhere.
//
// [setMetricsCarouselUndocked] is the same call the undock request makes, so the strip
// shows the "tap to bring them back" message and tapping it re-docks onto *this*
// activity's controller.
setMetricsCarouselUndocked(isMetricsCarouselUndocked())
}

binding.memUsageView.metricsPager.adapter =
MetricsCarouselAdapter(pages, memUsageChartRenderer, networkUsageChartRenderer)
/**
* A two-finger tap on the carousel asks for it to be floated. Overridden where the floating
* window machinery lives; a no-op here.
*/
protected open fun onMetricsCarouselUndockRequested() = Unit

val showTitleFor = { position: Int ->
pages.getOrNull(position)?.let { page ->
binding.memUsageView.metricsTitle.setText(page.title)
}
}
/** Whether the carousel is currently floating rather than docked here. */
protected open fun isMetricsCarouselUndocked(): Boolean = false

metricsPageCallback =
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
showTitleFor(position)
}
}.also { binding.memUsageView.metricsPager.registerOnPageChangeCallback(it) }
/** A tap on the "tap to bring them back" message asks for the floating carousel to re-dock. */
protected open fun onMetricsCarouselRedockRequested() = Unit

/**
* Swaps the carousel for the message explaining where it has gone, or back again.
*
* Only one carousel can be live at a time, so undocking moves it out of the editor. Without the
* message the reveal would open on an empty strip, and a window dragged off screen would leave
* no way back.
*/
@UiThread
protected fun setMetricsCarouselUndocked(undocked: Boolean) {
val view = _binding?.memUsageView ?: return
view.root.setUndocked(undocked)

// onPageSelected does not fire for the page the carousel opens on.
showTitleFor(binding.memUsageView.metricsPager.currentItem)
if (undocked) {
metricsCarousel.unbind()
} else {
metricsCarousel.bind(view)
metricsCarousel.refresh()
}
}

private fun watchMemory() {
memoryUsageWatcher.listener = memoryUsageListener
memoryUsageWatcher.watchProcess(Process.myPid(), PROC_IDE)
resetMemUsageChart()
}
Expand All @@ -1047,15 +1068,21 @@ abstract class BaseEditorActivity :
* watching a process.
*/
protected fun resetMemUsageChart() {
memUsageChartRenderer.rebuild()
metricsCarousel.onWatchedProcessesChanged()
}

override fun onPause() {
super.onPause()
memoryUsageWatcher.listener = null
memoryUsageWatcher.stopWatching(false)
networkUsageWatcher.listener = null
networkUsageWatcher.stopWatching()
// Sampling continues while backgrounded so the history has no gaps; the x axis assumes
// evenly spaced samples and would otherwise misreport their age (ADFA-5486). Only the
// carousel goes, so nothing updates a chart nobody is looking at.
// Not while it is floating: the controller is then bound to the window's own views, and
// unbinding would clear the watcher listeners and detach the renderers -- leaving the
// overlay showing a chart that never updates again, which is the one state undocking
// exists for. onResume already guards its rebind the same way.
if (!isMetricsCarouselUndocked()) {
metricsCarousel.unbind()
}

this.isDestroying = isFinishing
getFileTreeFragment()?.saveTreeState()
Expand All @@ -1072,18 +1099,21 @@ abstract class BaseEditorActivity :
log.warn("Unable to move debugger overlay to display {}", displayId, err)
}

// Not for an instance onCreate already abandoned: the deep-link path calls finish() and
// returns, yet the platform still runs onStart and onResume. The memory watcher is immune
// by design -- it early-returns on an empty process set -- but the network sampler would
// poll TrafficStats and hop to the main thread once a second for an activity with no
// chart to render into.
if (didCompleteLiveOnCreate) {
memoryUsageWatcher.listener = memoryUsageListener
if (!isMetricsCarouselUndocked()) {
_binding?.let { metricsCarousel.bind(it.memUsageView) }
}
if (!memoryUsageWatcher.isWatching) {
memoryUsageWatcher.startWatching()
networkUsageWatcher.listener = networkUsageListener
}
if (!networkUsageWatcher.isWatching) {
networkUsageWatcher.startWatching()
}

if (!isMetricsCarouselUndocked()) {
// Draw whatever was sampled while away, rather than waiting for the next tick.
metricsCarousel.refresh()
}

apkInstallationViewModel.reloadStatus(this)

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import com.itsaky.androidide.databinding.FileActionPopupWindowBinding
import com.itsaky.androidide.databinding.FileActionPopupWindowItemBinding
import com.itsaky.androidide.deeplink.PendingDeepLinkOpen
import com.itsaky.androidide.di.APPLICATION_SCOPE
import com.itsaky.androidide.editor.floating.MetricsCarouselDockableContent
import com.itsaky.androidide.editor.language.treesitter.JavaLanguage
import com.itsaky.androidide.editor.language.treesitter.JsonLanguage
import com.itsaky.androidide.editor.language.treesitter.KotlinLanguage
Expand Down Expand Up @@ -903,6 +904,24 @@ open class EditorHandlerActivity :
return if (child is CodeEditorView) child else null
}

override fun onMetricsCarouselUndockRequested() {
floatingTabController.floatMetricsCarousel(
controller = metricsCarousel,
title = getString(string.metrics_carousel_window_title),
) { setMetricsCarouselUndocked(true) }
}

override fun onMetricsCarouselRedockRequested() {
floatingTabController.redockMetricsCarousel()
}

override fun isMetricsCarouselUndocked(): Boolean = DockingManager.isFloating(MetricsCarouselDockableContent.ID)

/** The floating carousel has closed or re-docked; put the editor's own carousel back. */
fun onFloatingMetricsCarouselGone() {
setMetricsCarouselUndocked(false)
}

/** Undock the file tab at [fileIndex] into a floating window over other apps. */
fun undockFileTab(fileIndex: Int) {
floatingTabController.undock(fileIndex)
Expand Down
Loading
Loading