Repository navigation
K2GO-395 feat(networkpolicy): metered-data cost-consent gate + proactive alert - #558
Merged
Merged
Conversation
…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
…/K2GO-395-network-cost-consent
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 domainwith unit tests; one ConnectivityManager reader (
AndroidNetworkClassifier); onedecision source (
NetworkCostAdmission= classify + consent + policy); a statelessUI prompt (
NetworkPolicyGate, BrandDialog); an ephemeral in-memory sessionconsent store; and an observer started from IIABApplication.
Two enforcement points, one decision source:
ContentAdmission-- covers every banked REST content drain(the commit point, the wizard-bank path, and every background re-drain).
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
ContentAdmissionchoke, soa banked order HOLDS on metered-without-consent instead of spending data. Proposed
for merge.
Verification
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.
Follow-ups (separate install/rootfs PR)
network" state in the install orchestrator).
portal, setAllowedOverMetered).
classifier.