ADFA-5489: Add a UID-level network traffic page to the metrics carousel - #1787
Conversation
Second page of the editor's metrics carousel (ADFA-5487) is now a live network traffic chart, replacing the brand-mark placeholder. Accounting is UID-level, as decided on the ticket: TrafficStats.getUidRxBytes / getUidTxBytes cover every process sharing the app's UID, so Gradle's downloads are included without any socket tagging -- the Gradle Tooling and daemon processes share it. There is deliberately no per-feature breakdown; the only two tagged sockets in the tree are the local documentation web server and the JDWP listener, neither of which is interesting here. The platform counters are cumulative since boot, so NetworkUsageWatcher records the delta between consecutive samples. Three cases the raw counters would get wrong: - The first sample only establishes a baseline and contributes 0. Otherwise the chart would open with a spike equal to everything the app had transferred since boot. - A counter that goes backwards (reboot, re-based accounting) records 0 rather than plotting negative traffic. - TrafficStats.UNSUPPORTED (-1), which some devices return, is detected once and latched, so -1 is never plotted as a byte count. getUsage() hands out copies rather than the live ring buffers, guarded by a lock. The renderer reads all 30 entries while the sampler thread appends, and MemoryUsageWatcher's equivalent has that race today. Axis, per the ticket's decisions: - Values are log10(bytes + 1). Traffic spans orders of magnitude -- a few hundred bytes of chatter next to a multi-megabyte download -- and a linear axis flattens all of it but the largest burst onto the baseline. MPAndroidChart has no logarithmic axis. - The + 1 floors zero, which is the common sample rather than an edge case: an idle IDE transfers nothing and log10(0) is negative infinity. A zero sample plots at exactly 0.0 and the line stays continuous. - Units are decimal (1 kB = 1000 B), not binary. This was not in the ticket and is a consequence of the log axis: on-device the first cut labelled the gridlines 9B / 99B / 999B / 9.8KB, because powers of ten divided by 1024 stop looking like decades. Decimal units label them 0B / 10B / 100B / 1.0kB, and are the convention for throughput. - Axis labels show 10^value rather than the exact inverse 10^value - 1, which would read 9B / 99B / 999B. One byte is not worth the confusion, and the legend carries the exact current figure. Zero is labelled exactly, since log10(0 + 1) really is 0. MetricsPage.Image and its layout go with the placeholder, having no remaining user; ADFA-5490 will define its own extension surface. The cogo_brand_mark drawable stays -- six other screens use it. Verified on a Pixel 6 Pro (arm64), v8 debug, over wifi with a real Gradle sync: - Both series track real traffic (peaks ~10kB/s against byte-level chatter, both legible on the one scale), idle periods sit flat on the 0B baseline, and the axis reads 0B / 10B / 100B / 1.0kB / 10.0kB. - Swiping to the memory page and back returns the full 30-sample history, so the page is recycling-safe like the memory one. - Font scale 1.0 and 2.0, measured on a cold start (EditorActivityKt declares fontScale in configChanges, so a warm relaunch reports stale geometry): title 22dp -> 35dp, pager 185dp -> 171dp, panel 248dp throughout, nothing clipped. - Landscape renders correctly, nothing clipped. - 16 new tests (7 watcher, 9 renderer); 80 tests green across app ui/utils/activities/fragments. ADFA-5489 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz
Two axis problems, one cosmetic and one a real rendering bug. Labels now read "10 kB" rather than "10.0kB". Gridlines sit on whole decades (granularity 1), so the mantissa is always exact and the decimal place carried no information. formatBytes takes the precision as an argument: none for axis labels, one place for the legend, where the figure is an arbitrary sample and the decimal does carry information. A space separates value from unit throughout. Zero now rests on the baseline. Two causes, both fixed: - The series were scaled against the wrong axis. LineDataSet defaults to axisDependency LEFT, and the labelled axis here is the right one, so the line was positioned by the disabled, auto-ranged left axis while the labels came from the right. The two only agree while both auto-range over the same data; pinning one made them disagree visibly -- an idle chart drew its zero line halfway up a plot whose baseline was labelled 0 B. - The range was not pinned. With every sample zero the data range is degenerate and the chart pads around it. applyAxisRange now fixes the minimum at 0 and the maximum at whole decades above the peak, with a floor of three decades so an idle chart keeps a sensible scale instead of collapsing onto a single value. Worth noting for review: the unit tests asserting axisMinimum and axisMaximum passed throughout, because the axis really was configured correctly -- the data simply was not drawn against it. Only the device showed it. There is now a test asserting the axis dependency of both series, which is the part that was untested. MemoryUsageChartRenderer has the same LEFT-dependency-with-RIGHT-labels shape and renders correctly, because it pins neither axis and both auto-range over the same data. Left alone. Verified on a Pixel 6 Pro (arm64), v8 debug: - Idle: both series rest exactly on the 0 B baseline, axis reads 0 B / 10 B / 100 B / 1 kB. - Under a Gradle sync: axis grows to 10 kB, peaks and zero-traffic troughs both legible, legend reads "212 B/s". - 49 tests green across app ui/utils, including four new ones covering the axis range, its growth across both series, whole-unit labels, and the axis dependency. ADFA-5489 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
…489-network-traffic-page
|
LGTM but I think we need an indicator ( maybe dot indicator?) that it's a carousel |
|
@Daniel-ADFA thanks for the review. The affordance you're asking for exists, one PR up the stack — you couldn't see it from here. #1787 is based on #1784, so what you reviewed has the chart title and nothing else. #1785 (ADFA-5486) adds a left and a right arrow flanking the title, and they are now the only way to move between pages: swipe paging was removed because it competed with panning a zoomed chart and with the editor's drawer gesture, and lost often enough to feel broken. The arrow at each end dims when there is nowhere further to go, so the ends of the carousel are visible too. Arrows rather than dots was deliberate. Dots report position; arrows report position and are tappable. The panel is a fixed 248dp, so the vertical space dots would occupy is scarce — that is also why the dots the carousel originally shipped with were dropped in #1784 in favour of the title. If you would still like dots in addition to the arrows once you have seen #1785, say so and I will add them. |
…f (ADFA-5489) CodeRabbit raised three Major findings against this watcher. They were fixed, but on #1785 -- a later PR in the stack than the one that ships the bug. This PR is already approved and ahead of that one, so on its own it still carried all three. Moving the fix to where the defect lives. The scope had no parent Job and startWatching() supplied its own SupervisorJob per launch, so nothing the scope did could cancel the sampler. stopWatching() only lowered a flag the loop checks once per interval, and the loop spends nearly all its time in delay() -- up to 60s once ADFA-5486 makes the rate configurable. A stop and start inside that window left two loops appending to one buffer, splitting each delta between them. The scope now has a parent job, the launch is stored, and stopWatching() cancels it. Nothing caught exceptions inside the loop. An exception -- a misbehaving listener is enough -- ended the coroutine while `watching` stayed true, so every later startWatching() was refused as "already watching" and sampling was dead for the rest of the session. The body is wrapped, and CancellationException is rethrown so structured cancellation still works. The dedicated sampling thread was never released. close() is separate from stopWatching() on purpose: the editor stops and restarts the watcher across its lifecycle, and only the terminal teardown should give up the thread that newSingleThreadContext keeps alive. The activity's destroy path calls it. startWatching() now guards with compareAndSet rather than a read followed by a write, so two callers racing cannot each start a sampler. The watcher takes its dispatchers as parameters, matching MemoryUsageWatcher, so NetworkWatcherLifecycleTest can drive the loop on a virtual clock. Waiting on the wall clock is what hung the test executor the first time this was attempted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz
…spinning The previous commit shipped the commit message for this fix without the fix. An interrupted command had reverted the watcher to its pre-fix shape for a negative check and was killed before it restored it, so what got committed was `launch(SupervisorJob() + dispatcher)` and a scope cancel that cannot reach the sampler -- the very defect being fixed. stopWatching() now cancels the stored job, as its own comment already claimed. That mistake did prove the tests: against the unfixed watcher NetworkWatcherLifecycleTest reported two samples per interval where one was expected, which is exactly the two-loop overlap the fix exists to prevent. The tests also gained the cleanup they should have had. Each body now closes its watcher in a finally. Without it a failed assertion skipped close(), left the sampling loop live, and runTest's trailing advanceUntilIdle advanced virtual time forever -- a synchronous spin no test timeout can interrupt, which pinned a core and took the Gradle task to its ten-minute limit with no output. CodeRabbit raised exactly this about the tests on #1785; the lesson had not been carried over here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz
Adds a UID-level network traffic page to the editor's metrics carousel, replacing the brand-mark placeholder that ADFA-5487 shipped as page 2.
Stacked on #1784 (ADFA-5487), which builds the carousel this page slots into. Review or merge that first; this retargets to
stageautomatically once it lands.feat: add a UID-level network traffic page to the metrics carouselfix: label the network axis in whole units and rest zero on the baselineAccounting
UID-level, as decided on the ticket:
TrafficStats.getUidRxBytes/getUidTxBytescover every process sharing the app's UID, so Gradle's downloads are included without any socket tagging — the Gradle Tooling and daemon processes share it. There is deliberately no per-feature breakdown; the only two tagged sockets in the tree are the local documentation web server and the JDWP listener, neither of which is interesting here.The platform counters are cumulative since boot, so what is plotted is the delta between samples. Three cases the raw counters get wrong, each guarded:
TrafficStats.UNSUPPORTED(-1), which some devices return, is detected once and latched, so -1 is never plotted as a byte count.getUsage()hands out copies under a lock — the renderer reads every entry while the sampler appends.MemoryUsageWatcher's equivalent has that race today and still hands out its live buffers.The logarithmic axis
Traffic spans orders of magnitude — a few hundred bytes of chatter beside a multi-megabyte download — and a linear axis flattens all but the largest burst onto the baseline. MPAndroidChart has no logarithmic axis, so the plotted value is
log10(bytes + 1)and the formatter turns labels back into byte units.The
+ 1floors zero, which is the common sample rather than an edge case: an idle IDE transfers nothing andlog10(0)is negative infinity. A zero sample plots at exactly0.0and the line stays continuous.Two label decisions that fell out of looking at it on a device:
9B / 99B / 999B / 9.8KB, because powers of ten divided by 1024 stop looking like decades. Decimal gives0 B / 10 B / 100 B / 1 kB, and is the convention for throughput anyway.10^value, not the exact inverse10^value - 1, which would read9 B / 99 B / 999 B. One byte is not worth the confusion; the legend carries the exact current figure. Zero is labelled exactly, sincelog10(0 + 1)really is 0.Worth a reviewer's attention
Commit 2 fixes a rendering bug that unit tests asserting
axisMinimum/axisMaximumpassed straight through:LineDataSetdefaults toaxisDependency = LEFTwhile the labelled axis here is the right one, so the series were positioned by the disabled, auto-ranged left axis. The axis was configured correctly; the data simply was not drawn against it. An idle chart drew its zero line halfway up a plot whose baseline read0 B. Only the device showed it. There is now a test on the axis dependency of both series.Verification
Pixel 6 Pro (arm64), v8 debug, over wifi with a real Gradle sync:
0 Bbaseline and the axis reading0 B / 10 B / 100 B / 1 kB / 10 kB.EditorActivityKtdeclaresfontScaleinconfigChanges, so a warm relaunch reports stale geometry), landscape, nothing clipped.Follow-up: ADFA-5486 (#1785) builds on this.
🤖 Generated with Claude Code
https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz