Skip to content

ADFA-5489: Add a UID-level network traffic page to the metrics carousel - #1787

Open
davidschachterADFA wants to merge 5 commits into
feature/ADFA-5487-metrics-carouselfrom
feature/ADFA-5489-network-traffic-page
Open

ADFA-5489: Add a UID-level network traffic page to the metrics carousel#1787
davidschachterADFA wants to merge 5 commits into
feature/ADFA-5487-metrics-carouselfrom
feature/ADFA-5489-network-traffic-page

Conversation

@davidschachterADFA

@davidschachterADFA davidschachterADFA commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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 stage automatically once it lands.

# Commit
1 feat: add a UID-level network traffic page to the metrics carousel
2 fix: label the network axis in whole units and rest zero on the baseline

Accounting

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 what is plotted is the delta between samples. Three cases the raw counters get wrong, each guarded:

  • The first sample only establishes a baseline, or the chart opens with a spike equal to everything transferred since boot.
  • A counter that goes backwards (reboot, re-based accounting) records 0 rather than 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 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 + 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.

Two label decisions that fell out of looking at it on a device:

  • Units are decimal (1 kB = 1000 B). The first cut labelled gridlines 9B / 99B / 999B / 9.8KB, because powers of ten divided by 1024 stop looking like decades. Decimal gives 0 B / 10 B / 100 B / 1 kB, and is the convention for throughput anyway.
  • Labels show 10^value, not the exact inverse 10^value - 1, which would read 9 B / 99 B / 999 B. One byte is not worth the confusion; the legend carries the exact current figure. Zero is labelled exactly, since log10(0 + 1) really is 0.

Worth a reviewer's attention

Commit 2 fixes a rendering bug that unit tests asserting axisMinimum/axisMaximum passed straight through: LineDataSet defaults to axisDependency = LEFT while 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 read 0 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:

  • Both series track real traffic — ~10 kB/s peaks and byte-level chatter legible on one scale — with idle periods resting on the 0 B baseline and the axis reading 0 B / 10 B / 100 B / 1 kB / 10 kB.
  • Swiping to the memory page and back returns the full history, so the page is recycling-safe like the memory one.
  • Font scale 1.0 and 2.0 on a cold start (EditorActivityKt declares fontScale in configChanges, so a warm relaunch reports stale geometry), landscape, nothing clipped.
  • 16 new tests (7 watcher, 13 renderer); full app suite green.

Follow-up: ADFA-5486 (#1785) builds on this.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz

davidschachterADFA and others added 2 commits September 4, 2026 16:21
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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Daniel-ADFA

Copy link
Copy Markdown
Contributor

LGTM but I think we need an indicator ( maybe dot indicator?) that it's a carousel

@davidschachterADFA

Copy link
Copy Markdown
Collaborator Author

@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.

davidschachterADFA and others added 2 commits September 5, 2026 22:20
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants