Skip to content

Commit f288b75

Browse files
K2GO-395 feat(networkpolicy): enforce cost gate in ContentAdmission; 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.
1 parent 1a90c50 commit f288b75

7 files changed

Lines changed: 118 additions & 42 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package org.appdevforall.k2go.networkpolicy.data;
2+
3+
import android.content.Context;
4+
5+
import androidx.annotation.NonNull;
6+
7+
import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicy;
8+
import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicyDecision;
9+
10+
/**
11+
* The one headless "may a heavy transfer start now, cost-wise?" decision (ADR-395).
12+
*
13+
* <p>It is the single source of the classify + consent + policy glue, used by both
14+
* the UI gate ({@code NetworkPolicyGate}, which needs the full decision to choose
15+
* dialog vs snackbar) and the content-stream admission ({@code ContentAdmission},
16+
* which needs only the boolean). Data-layer, so the system-side admission can call
17+
* it without depending on presentation.
18+
*/
19+
public final class NetworkCostAdmission {
20+
21+
private NetworkCostAdmission() {}
22+
23+
private static final NetworkPolicy POLICY = new NetworkPolicy();
24+
25+
/** The full decision for the active default network and the current session consent. */
26+
@NonNull
27+
public static NetworkPolicyDecision decideNow(@NonNull Context ctx) {
28+
return POLICY.decideHeavyStart(
29+
AndroidNetworkClassifier.classify(ctx),
30+
SessionMeteredConsentStore.get().isGranted());
31+
}
32+
33+
/**
34+
* True when a heavy transfer may start now on cost grounds (unmetered, or the
35+
* user consented this session). False means HOLD: leave the order banked, a
36+
* later pass takes it once the network is free or consent is given -- the same
37+
* "deferred is not a failure" contract the other admission checks use.
38+
*/
39+
public static boolean allowsHeavyStartNow(@NonNull Context ctx) {
40+
return decideNow(ctx) == NetworkPolicyDecision.ALLOW;
41+
}
42+
}

‎controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/presentation/MeteredNetworkObserver.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@ public final class MeteredNetworkObserver {
4242
private MeteredNetworkObserver() {}
4343

4444
private static NetworkClass last = null;
45+
private static boolean started = false;
4546

4647
/** Idempotent; call once from Application.onCreate on the main thread. */
4748
public static void start(@NonNull Application app) {
49+
if (started) return; // guard: a second call must not add a second observeForever
50+
started = true;
4851
ensureChannel(app);
4952
last = AndroidNetworkClassifier.classify(app);
5053
// observeForever keeps NetworkStateLiveData active for the process lifetime,

‎controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/presentation/NetworkPolicyGate.java‎

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,35 @@
66
import androidx.annotation.NonNull;
77

88
import org.appdevforall.k2go.R;
9-
import org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier;
9+
import org.appdevforall.k2go.networkpolicy.data.NetworkCostAdmission;
1010
import org.appdevforall.k2go.networkpolicy.data.SessionMeteredConsentStore;
11-
import org.appdevforall.k2go.networkpolicy.domain.MeteredConsentStore;
12-
import org.appdevforall.k2go.networkpolicy.domain.NetworkClass;
13-
import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicy;
14-
import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicyDecision;
1511
import org.appdevforall.k2go.ui.dialog.BrandDialog;
1612
import org.appdevforall.k2go.util.Snackbars;
1713

1814
/**
19-
* The single consult point before any heavy download starts (ADR-395). A caller
20-
* wraps its existing start call:
15+
* The user-facing PROMPT for a costed start (ADR-395). A commit point (a Download
16+
* button) wraps its start so the user is asked before spending metered data:
2117
*
2218
* <pre>NetworkPolicyGate.guardHeavyStart(activity, () -&gt; a.startZimDownload());</pre>
2319
*
2420
* <p>Stateless, like {@code OpReturnNavigator}: it owns no "is metered" flag. It
25-
* reads the live class off {@link AndroidNetworkClassifier} and the session
26-
* consent off {@link SessionMeteredConsentStore}, applies the pure
27-
* {@link NetworkPolicy}, and either proceeds, asks, or blocks.
21+
* reads the decision from {@link NetworkCostAdmission} (the one classify + consent
22+
* + policy source) and either proceeds, asks, or reports offline.
2823
*
29-
* <p>It gates the START only. It does NOT control a transfer already in flight --
30-
* the content bytes are pulled by the in-proot server over the device default
31-
* network, which Android gives the app no handle to throttle (ADR-395). Callers
32-
* put this at the user's commit point (the Download button), never on the
33-
* background drain that re-hands an already-authorized wishlist.
24+
* <p>This gate is only the PROMPT. The actual hold is enforced headless in
25+
* {@code ContentAdmission} (via {@link NetworkCostAdmission}), which every content
26+
* drain already consults -- so a banked order never starts on metered data without
27+
* consent even if it was never routed through a commit point (the wizard-bank path,
28+
* a background re-drain). Banking must therefore happen BEFORE this gate: an order
29+
* declined or offline here stays queued and drains once the network is free or
30+
* consent is given, rather than being lost (ADR-395 sec.10).
3431
*/
3532
public final class NetworkPolicyGate {
3633

3734
private NetworkPolicyGate() {}
3835

39-
private static final NetworkPolicy POLICY = new NetworkPolicy();
40-
4136
public static void guardHeavyStart(@NonNull Activity activity, @NonNull Runnable onProceed) {
42-
MeteredConsentStore consent = SessionMeteredConsentStore.get();
43-
NetworkClass net = AndroidNetworkClassifier.classify(activity);
44-
NetworkPolicyDecision decision = POLICY.decideHeavyStart(net, consent.isGranted());
45-
switch (decision) {
37+
switch (NetworkCostAdmission.decideNow(activity)) {
4638
case ALLOW:
4739
onProceed.run();
4840
return;
@@ -51,7 +43,7 @@ public static void guardHeavyStart(@NonNull Activity activity, @NonNull Runnable
5143
.setTitle(R.string.k2go_netpolicy_metered_title)
5244
.setMessage(R.string.k2go_netpolicy_metered_msg)
5345
.setPositive(R.string.k2go_netpolicy_continue, () -> {
54-
consent.grant();
46+
SessionMeteredConsentStore.get().grant();
5547
onProceed.run();
5648
})
5749
.setNegative(R.string.k2go_netpolicy_not_now, null)

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupLibraryActivity.java‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,15 @@ public void openZimConfirm() {
304304
public void startZimDownload() {
305305
ZimWishlist.add(this, selection().zimCart());
306306
selection().zimCart().clear(); // handed over; keeping it would re-offer the same picks
307-
ZimProvisioner.drain(this); // starts now if the line is free, banks it if not
308-
startActivity(new Intent(this, SetupProgressActivity.class));
307+
// K2GO-395 (ADR-395 sec.10): the order is banked above, unconditionally, so it is never lost.
308+
// The gate wraps only the drain + navigation: it prompts on a metered network and, on consent
309+
// (or Wi-Fi), drains and opens progress; declined or offline the order stays queued and a later
310+
// pass drains it. The drain is also held headless by ContentAdmission, so a banked order never
311+
// starts on metered data without consent even via the wizard-bank path or a background re-drain.
312+
org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart(this, () -> {
313+
ZimProvisioner.drain(this); // starts now if the line is free, banks it if not
314+
startActivity(new Intent(this, SetupProgressActivity.class));
315+
});
309316
}
310317

311318
/** ADFA-4853: the wizard's "Continue" — install the system now; content (Books/ZIM) is banked

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/ZimConfirmFragment.java‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,10 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
106106
if (!(getActivity() instanceof SetupLibraryActivity)) return;
107107
SetupLibraryActivity a = (SetupLibraryActivity) getActivity();
108108
if (banks) a.zimWizardConfirm(); // no box yet: bank it
109-
// ADFA-5074 / ADFA-5333: blocked while a dashboard update runs.
110-
// K2GO-395 (ADR-395): gate a metered start behind explicit consent (cost awareness).
111-
// The gate sits at the user commit point, not on ZimProvisioner.drain (that re-hands an
112-
// already-authorized wishlist every ~2 s and would re-prompt).
113-
else if (!DashboardRebuild.blockedByUpdate(v)) {
114-
org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate
115-
.guardHeavyStart(a, a::startZimDownload);
116-
}
109+
// ADFA-5074 / ADFA-5333: blocked while a dashboard update runs. The K2GO-395 metered-cost
110+
// gate lives inside startZimDownload (bank first, then gate the drain), so the order is
111+
// never lost on decline/offline; see ADR-395 sec.10.
112+
else if (!DashboardRebuild.blockedByUpdate(v)) a.startZimDownload();
117113
});
118114

119115
return root;

‎controller/app/src/main/java/org/appdevforall/k2go/system/data/ContentAdmission.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import android.util.Log;
1616

1717
import org.appdevforall.k2go.install.presentation.ModuleQueueRepository;
18+
import org.appdevforall.k2go.networkpolicy.data.NetworkCostAdmission;
1819
import org.appdevforall.k2go.redesign.DashboardRebuildService;
1920
import org.appdevforall.k2go.redesign.MapsProvisioner;
2021
import org.appdevforall.k2go.system.domain.ContentType;
@@ -73,6 +74,15 @@ public static boolean canStart(Context ctx, ContentType stream) {
7374
Log.d(TAG, stream.key() + " drain deferred: a content stream still has work to do");
7475
return false;
7576
}
77+
// ADR-395: cost gate. A heavy content download must not start on a metered network without
78+
// the user's consent. This is the single headless hold that covers every drain path -- the
79+
// commit point, the wizard-bank path, and every background re-drain -- so a banked order
80+
// waits for Wi-Fi or consent rather than silently spending mobile data. Prompting happens at
81+
// the UI commit point (NetworkPolicyGate); here we only defer.
82+
if (!NetworkCostAdmission.allowsHeavyStartNow(ctx)) {
83+
Log.d(TAG, stream.key() + " drain deferred: metered network without consent");
84+
return false;
85+
}
7686
return true;
7787
}
7888
}

‎controller/docs/ADR-395-network-cost-consent.md‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,22 @@ Landed as a compiling, tested starting point for the implementer:
163163
- Domain, pure JVM, unit-tested: `NetworkClass`, `NetworkPolicy`,
164164
`NetworkTransition`, `NetworkPolicyDecision`, `MeteredConsentStore`
165165
(`NetworkPolicyTest`, `NetworkClassTest`, `NetworkTransitionTest` -- green).
166-
- Data: `AndroidNetworkClassifier`, `SessionMeteredConsentStore`.
167-
- Presentation: `NetworkPolicyGate` (+ `BrandDialog` consent), `MeteredNetworkObserver`.
168-
- Wiring: observer started in `IIABApplication`; gate wired at the ZIM commit
169-
point (`ZimConfirmFragment`).
166+
- Data: `AndroidNetworkClassifier`, `SessionMeteredConsentStore`,
167+
`NetworkCostAdmission` (the one classify + consent + policy decision source).
168+
- Presentation: `NetworkPolicyGate` (the UI prompt, + `BrandDialog`),
169+
`MeteredNetworkObserver`.
170+
- Enforcement (headless): `ContentAdmission.canStart` defers when
171+
`NetworkCostAdmission.allowsHeavyStartNow` is false, so the ZIM, Books and
172+
Kolibri drains all HOLD a banked order on metered-without-consent (sec.10).
173+
- Wiring: observer started in `IIABApplication`; the ZIM commit point
174+
(`SetupLibraryActivity.startZimDownload`) banks first, then gates the drain, so
175+
a declined/offline order is queued, not lost.
170176
- Strings translated to all 33 locales (machine-generated, pending human review)
171177
in `values*/strings_networkpolicy.xml`; `strings_untranslated.xml` is clear.
172178

173-
Remaining to finish the contract: the other four seams and the two-way fold of the
174-
existing `hasInternet` readers into the classifier. (l10n is done pending review.)
179+
Remaining to finish the contract: the Books/Kolibri commit-point prompts (their
180+
drains are already held), the Maps seam (re-locate post K2GO-394), the
181+
DownloadManager seam, and the two-`hasInternet` fold. (l10n done pending review.)
175182

176183
## 6. Device evidence appendix (dark surfaces flattened)
177184

@@ -277,7 +284,26 @@ is complete:
277284
does not hold the transfer. This is the real depth of the feature and should
278285
be designed before wiring the remaining seams, not after.
279286

280-
Decision needed (consult, do not default): keep the commit-point gate as a first
281-
layer and add drain-level consent enforcement, or move enforcement entirely into
282-
the provisioner/session start. The reference ZIM seam is left as-is pending this
283-
decision so the trade-off is visible, not silently patched.
287+
### Resolution (implemented)
288+
289+
Enforcement moved to the single choke every content drain already consults:
290+
`ContentAdmission.canStart` (system/data) -- "the one answer to may a REST content
291+
stream start now". It now also defers when `NetworkCostAdmission.allowsHeavyStartNow`
292+
is false (metered without consent), so ZIM, Books and Kolibri drains all HOLD a
293+
banked order rather than spend mobile data -- the commit point, the wizard-bank
294+
path and every background re-drain, covered in one place, with no per-provisioner
295+
edit and no new persistent state (a held order is simply left banked, the existing
296+
"deferred is not a failure" contract).
297+
298+
`NetworkCostAdmission` (networkpolicy/data) is the single source of the classify +
299+
consent + policy decision, used both by that headless admission and by the UI gate.
300+
301+
The prompt stays at the UI commit point (`NetworkPolicyGate`), but banking now
302+
happens BEFORE the gate (`SetupLibraryActivity.startZimDownload` banks, then gates
303+
the drain), so a declined or offline order is queued, not lost. This removes the
304+
commit-point special case rather than adding one.
305+
306+
Still open as follow-ups: the Books/Kolibri commit-point prompts (their drains are
307+
already held by the ContentAdmission change; only the interactive ask is missing),
308+
the Maps seam (K2GO-394 reworked Maps; re-locate it), DownloadManager (OTA/portal),
309+
and the two-`hasInternet` fold.

0 commit comments

Comments
 (0)