Skip to content

K2GO-395 feat(networkpolicy): metered-data cost-consent gate + proactive alert - #558

Merged
luisguzman-adfa merged 20 commits into
mainfrom
feat/K2GO-395-network-cost-consent
Sep 17, 2026
Merged

luisguzman-adfa merged 20 commits into
mainfrom
feat/K2GO-395-network-cost-consent

Conversation

@luisguzman-adfa

@luisguzman-adfa luisguzman-adfa commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

What

A defensive network cost-consent mechanism: a gate that asks before starting a
heavy download on a metered network, plus a process-wide observer that warns when
the device switches to metered data. It keys on the active default network's
NET_CAPABILITY_NOT_METERED, not on transport.

New feature package networkpolicy (domain/data/presentation): a pure-JVM domain
with unit tests; one ConnectivityManager reader (AndroidNetworkClassifier); one
decision source (NetworkCostAdmission = classify + consent + policy); a stateless
UI prompt (NetworkPolicyGate, BrandDialog); an ephemeral in-memory session
consent store; and an observer started from IIABApplication.

Two enforcement points, one decision source:

  • Headless hold in ContentAdmission -- covers every banked REST content drain
    (the commit point, the wizard-bank path, and every background re-drain).
  • The UI prompt at each user commit point.

Every REST-heavy download a user can trigger on a live box is now gated: ZIM,
Books, Kolibri (Get More and the native Kolibri WebView import), FQR map regions,
the dashboard live update/install, and the Get More base-maps download.

Strings live in strings.xml for all locales (machine-generated, pending human
review). Design and device evidence: ADR-395.

Why

Data is a real cost for target users; the app must not silently spend a metered
plan. Distinct from K2GO-4 (download resilience) -- this is cost consent.

Status

The original open decision (ADR-395 sec.10: banked items draining ungated on
metered) is resolved: enforcement moved to the single ContentAdmission choke, so
a banked order HOLDS on metered-without-consent instead of spending data. Proposed
for merge.

Verification

  • Domain unit tests green (NetworkPolicy / NetworkClass / NetworkTransition).
  • Device (Samsung metered SIM; OnePlus over a metered Samsung hotspot; ADR-395
    sec.6): the classifier reads the cellular APN and a phone hotspot as metered and
    Wi-Fi as unmetered; the proactive alert fires on the switch to data; the consent
    prompt fires at each seam; "Not now" holds the order banked (not lost) and
    "Continue on data" downloads; the native Kolibri decline aborts cleanly with no
    false disconnect.
  • Two code-review passes on the change; findings fixed.

Follow-ups (separate install/rootfs PR)

  • The wizard/system-install maps path and its headless hold (needs a "waiting for
    network" state in the install orchestrator).
  • The rootfs-image install (aria2, not REST) and the DownloadManager seam (OTA /
    portal, setAllowedOverMetered).
  • Fold DashboardRebuild.hasInternet + InstallService.hasValidatedInternet into the
    classifier.
  • Human review of the machine-generated translations.

…ive alert

Add a defensive gate that asks for consent before starting a heavy download on a
metered network, plus a process-wide observer that warns on switching to metered
data and clears the session consent on leaving metered.

The rule keys on the active default network NET_CAPABILITY_NOT_METERED, not on
transport -- grounded in device evidence (ADR-395): a cellular IMS PDN reports
NOT_METERED while the internet APN does not, and a phone hotspot can arrive
metered on the client. Pure-JVM domain with unit tests; data reads the one
ConnectivityManager source; presentation is a stateless gate (BrandDialog) plus
the observer started from IIABApplication.

Reference wiring: the ZIM confirm commit point (ZimConfirmFragment), not the ~2s
provisioner drain. Other seams (Books, Kolibri, rootfs install, DownloadManager)
and the l10n migration are follow-ups per ADR-395. Strings parked in
strings_untranslated.xml pending 33-locale translation.
…DR-395

Add exact file:line commit points for the Books/Kolibri/Maps/install/DownloadManager
seams, a one-line wrap recipe per seam, and the POST_NOTIFICATIONS deployment note,
so the remaining wiring is copy-paste for the next implementer.
Move the 8 network-cost consent strings out of strings_untranslated.xml into
values*/strings_networkpolicy.xml with all 33 locale values (machine-generated,
pending human review), per the l10n policy. strings_untranslated.xml is clear
again. ADR-395 updated: l10n done pending review.
…uestions

The two-pass review found that gating only the UI commit point is not fully
defensive: the wishlist is a durable queue drained by an ungated background pass,
so banked items (incl. the wizard-bank path) download on any network without
consent, and wrapping startZimDownload() also discards the cart when offline.
ADR sec.10 records these open design questions to resolve before wiring the rest.
…/K2GO-395-network-cost-consent

# Conflicts:
#	controller/app/src/main/res/values/strings_untranslated.xml
…bank-before-gate for ZIM

Resolves ADR-395 sec.10. Gating only the UI commit point was not defensive: the
wishlist is a durable queue drained by an ungated background pass, so banked
orders (incl. the wizard-bank path) could download on metered data with no consent.

- New NetworkCostAdmission (networkpolicy/data): the single classify+consent+policy
  decision, used by both the UI gate and the headless admission.
- ContentAdmission.canStart now defers on metered-without-consent, so the ZIM,
  Books and Kolibri drains all HOLD a banked order -- one choke, three streams, no
  per-provisioner edit, no new persistent state (a held order stays banked).
- startZimDownload banks the wishlist BEFORE the gate and gates only the drain, so
  a declined/offline order is queued, not lost; ZimConfirmFragment calls it directly.
- MeteredNetworkObserver.start is now truly idempotent (started guard).

Fixes the two-pass review findings (cart loss on offline/decline; false idempotent
claim). Build + networkpolicy unit tests green.
…mmit point

Same shape as startZimDownload: startBooksDownload banks the wishlist first, then
gates only the drain + navigation, so a declined/offline order is queued not lost.
Its drain was already held by the ContentAdmission cost gate; this adds the
interactive prompt. ADR-395 updated (Kolibri prompt remains -- async flow).
…d cost

FQR (Full-Quality Region) map download is a REST content op but a user-driven
Operation, not a banked ContentType, so ContentAdmission does not cover it. Add a
guardHeavyStart at its commit point (after the storage-consent dialog, wrapping the
actual MapsRegionClient start). New three-arg guardHeavyStart overload runs an
onDeclined callback so FQR resets the drawn map selection on decline/offline (no
queue to fall back on). Device-verified on metered hotspot: dialog shows, Continue
starts the extraction, Not now resets and starts nothing.
Two copy changes across all 33 locales + default, per review: the assertive titles
become hedged ('You seem to be on mobile data', 'You seem to have switched...') so
the app does not claim certainty about a heuristic metered detection; and 'monthly
data' becomes 'your data plan' to avoid assuming a monthly billing period. Machine
translations, pending human review.
Kolibri's start is async (it banks on the IO pool), so the metered prompt is added
at the commit point (main thread) and the original body moves to commitLive. Both
prompt outcomes proceed: the order is banked either way (offline-first, not lost),
and the actual hold is enforced in ContentAdmission -- a declined order shows as
Queued on the index and drains once consent or Wi-Fi arrives, matching how Kolibri
already defers a busy-line order. Completes the three REST content streams.
…path source

isMapsPage, isKiwixPage and (new) isKolibriPage each carried an identical scheme/
query/fragment strip. Extract util/WebPath.pathOf and fold FqrController + Kiwix into
it, so the in-WebView page check has one source, not three copies to drift (code-review
finding). Pure string parsing, behavior unchanged.
…iew interception

Kolibri's own web app (box /kolibri/ in the PortalActivity WebView) imports content
straight from Studio over metered data, bypassing Get More / ContentAdmission -- device
recon reproduced a 265 MB import starting with no prompt. KolibriGuardController (armed
on /kolibri/, mirroring FqrController) injects JS that parks a POST /api/tasks/tasks/
remote-import behind NetworkPolicyGate via the K2GoKolibri bridge, proceeding or
aborting on consent. Second-pass review fixes: commitLive bails if detached; gateImport
resolves if the prompt cannot show (no hung request); the hook warns on any unclassified
task POST (fail-open regression signal). ADR-395 sec.4.3 records the seam and caveats.
…t (no false disconnect)

The declined-import path dispatched a synthetic 'error' event on the XHR after
abort(), which Kolibri's axios layer read as a network drop -> it entered a false
'Disconnected from Kolibri' state and cancelled its own task polls (device-observed).
abort() alone already notifies axios as a CanceledError (a user cancel), so drop the
extra dispatch. Device-verified: Not now now cancels cleanly -- 0 disconnect errors,
0 task POST sent, Kolibri stays connected and keeps the selection.
Move the 8 network-cost-consent strings from the per-locale
strings_networkpolicy.xml files into strings.xml (all 34 locales) and delete
the feature files. One string file per locale; strings_untranslated.xml stays
the only external tracker, for in-flight WIP awaiting translation.

A loose strings_<feature>.xml is harder to find than a single tracker: to
locate a string you must first know which feature owns it, and the last feature
added is the one you do not know to look in. CLAUDE.md is updated to require one
strings.xml and forbid per-feature string files.
…ed cost

The LIVE dashboard update/install (dash-node >= 1.2.0) is a REST-heavy transfer:
the box git-fetches and blue-green rebuilds over the device default network. It
started with no cost prompt. Wrap the LIVE branch (DashboardRebuild.start ->
startRest) in NetworkPolicyGate.guardHeavyStart. The proot bridge (< 1.2.0,
startProot) stops the box and is out of scope. Only startRest POSTs a new
rebuild (DashboardRebuildService ACTION_START); ACTION_ATTACH re-owns a running
one without POSTing, so one UI gate covers it. Not routed through
ContentAdmission -- that already defers TO a dashboard update, so it would be
circular.
… metered cost

K2GO-394 moved the base-map bytes onto REST: openMapsIndex -> the maps runrole
now pre-downloads the selected base layers through dash-node
(InstallService.downloadMapsBasemapsThenRun -> RestContentClient("basemaps"))
before the proot post-process. That heavy transfer started with no cost prompt.
Gate the Get More (post-install) commit point (openMapsIndex) with
NetworkPolicyGate.guardHeavyStart, inside the existing InstallConfirm.gate body.

Not gated at MapsProvisioner.drain: that drain is a serialized proot stage whose
refusal is TERMINAL (SetupProgressActivity retires it as mapsStartFailed, by
design, to avoid an unexplained spinner), so a cost-hold there would read as a
hard failure, not a deferral. The wizard/system-install maps path
(mapsWizardConfirm) and that headless hold ride with the install/rootfs PR.
…t-gate log

allowsHeavyStartNow() is false for BOTH metered-without-consent and no usable
network, but the deferral log hardcoded "metered network without consent", so an
offline deferral was misattributed. Classify once with decideNow() and log the
real reason. From the wary full-branch review.
…er install/rootfs

Update ADR-395: the dashboard LIVE update/install and the Get More base-maps
download are now gated at their UI commits (both REST-heavy, both user-driven
Operations, so UI-gated like FQR, not through ContentAdmission). Note that
K2GO-394 moved the maps bytes onto REST, and why the maps hold is at the commit
point, not the drain (mapsStartFailed is terminal). Record the l10n fold. Mark
the remaining work -- wizard/system-install maps, rootfs (aria2), DownloadManager
(OTA/portal), the two-hasInternet fold -- as deferred to the install/rootfs PR
and follow-ups.
…etach

The metered gate defers startRest behind the consent dialog, so the host
fragment can detach before Continue is tapped; host.requireContext() would then
throw. Bail on !isAdded() first (same guard the snackbar already used and that
KolibriConfirmFragment.commitLive uses). The update is not banked, so a dropped
start on this rare window is re-triggerable from the card. From the second
code-review pass on the new commits.
@luisguzman-adfa
luisguzman-adfa marked this pull request as ready for review September 17, 2026 01:08
@luisguzman-adfa
luisguzman-adfa merged commit a721de9 into main Sep 17, 2026
3 checks passed
@luisguzman-adfa
luisguzman-adfa deleted the feat/K2GO-395-network-cost-consent branch September 17, 2026 03:44
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.

1 participant