Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public void onCreate() {
// with NO foreground Activity. The tick stands down while an Activity is foregrounded (the Activity
// poll + bridge drive then); it only actuates OFF-UI when backgrounded.
org.appdevforall.k2go.env.ServerLifecycleReconciler.get().startBackgroundTick(this);
// K2GO-395 (ADR-395): one process-scoped watcher of the default-network cost class.
// Proactive alert on crossing into metered + clears the session metered-consent on leaving
// metered. Reuses the existing NetworkStateLiveData callback (one source of the change fact).
org.appdevforall.k2go.networkpolicy.presentation.MeteredNetworkObserver.start(this);
// We inject Conscrypt as the app's primary security provider
try {
Security.insertProviderAt(Conscrypt.newProvider(), 1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.appdevforall.k2go.networkpolicy.data;

import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;

import androidx.annotation.NonNull;

import org.appdevforall.k2go.networkpolicy.domain.NetworkClass;

/**
* Reads the cost class off the ACTIVE DEFAULT network.
*
* <p>This is the single reader of ConnectivityManager for cost decisions
* (ADR-395). The two existing internet checks -- DashboardRebuild.hasInternet and
* InstallService.hasValidatedInternet -- should route through here as a follow-up
* so there is one source of the "what is the network" fact, not three.
*
* <p>The rule is by NET_CAPABILITY_NOT_METERED, never by transport: on real
* hardware the cellular IMS PDN reports NOT_METERED while the internet APN does
* not (see ADR-395 device evidence). The pure mapping lives in
* {@link NetworkClass#from(boolean, boolean)}; this class only extracts the two
* facts from Android.
*/
public final class AndroidNetworkClassifier {

private AndroidNetworkClassifier() {}

@NonNull
public static NetworkClass classify(@NonNull Context ctx) {
ConnectivityManager cm =
(ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cm == null) return NetworkClass.NONE;
Network net = cm.getActiveNetwork();
if (net == null) return NetworkClass.NONE;
NetworkCapabilities caps = cm.getNetworkCapabilities(net);
if (caps == null) return NetworkClass.NONE;
boolean hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
boolean notMetered = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
return NetworkClass.from(hasInternet, notMetered);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.appdevforall.k2go.networkpolicy.data;

import org.appdevforall.k2go.networkpolicy.domain.MeteredConsentStore;

/**
* In-memory, process-lifetime consent (ADR-395). Not persisted on purpose: the
* grant must not outlive the session, so cost awareness returns on the next
* launch. The metered-network observer clears it the moment the network returns
* to non-metered, so the grant never outlives the metered episode either.
*/
public final class SessionMeteredConsentStore implements MeteredConsentStore {

private static final SessionMeteredConsentStore INSTANCE = new SessionMeteredConsentStore();

public static SessionMeteredConsentStore get() {
return INSTANCE;
}

private SessionMeteredConsentStore() {}

private volatile boolean granted = false;

@Override
public boolean isGranted() {
return granted;
}

@Override
public void grant() {
granted = true;
}

@Override
public void clear() {
granted = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.appdevforall.k2go.networkpolicy.domain;

/**
* Holds the one ephemeral fact the gate needs: did the user consent to spend
* metered data for this session?
*
* <p>Ephemeral by design (ADR-395): a persisted "always allow" would defeat the
* cost-awareness goal, and a persisted grant that nobody clears is the
* stuck-marker anti-pattern this project avoids. Lifecycle: the consent dialog
* calls {@link #grant()}; the metered-network observer calls {@link #clear()}
* when the default network returns to unmetered; process death clears it because
* the only implementation keeps it in memory.
*/
public interface MeteredConsentStore {

boolean isGranted();

void grant();

void clear();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.appdevforall.k2go.networkpolicy.domain;

/**
* Cost class of the ACTIVE DEFAULT network, from the app point of view.
*
* <p>The split is by metered state, not by transport. On real hardware a carrier
* runs several cellular data networks at once: the IMS signaling network reports
* NOT_METERED, while the general-internet APN does not. Keying on
* TRANSPORT_CELLULAR would therefore misjudge cost. The one reliable signal is
* the active default network NET_CAPABILITY_NOT_METERED. See
* ADR-395 (device evidence appendix) for the measured values.
*/
public enum NetworkClass {

/** Has internet and is not metered (home Wi-Fi, unmetered ethernet). Free to use. */
UNMETERED,

/** Has internet but is metered (cellular internet APN, a metered Wi-Fi hotspot). Costs data. */
METERED,

/** No internet-capable default network. Nothing can be downloaded. */
NONE;

/**
* Pure mapping from the two facts the data layer reads off the active default
* network. Kept here so the rule is unit-tested without Android.
*
* @param hasInternet the default network has NET_CAPABILITY_INTERNET
* @param notMetered the default network has NET_CAPABILITY_NOT_METERED
*/
public static NetworkClass from(boolean hasInternet, boolean notMetered) {
if (!hasInternet) return NONE;
return notMetered ? UNMETERED : METERED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.appdevforall.k2go.networkpolicy.domain;

/**
* The single rule for "may a heavy transfer start now?". Pure, no Android.
*
* <p>Defensive by design (see ADR-395): the gate acts at the START of a new
* transfer. It does not micro-manage a transfer already in flight -- once bytes
* move on a link the app does not own (the in-proot server pulls content over
* the device default network), Android gives no fine control. So the contract
* is simple: do not START anything costly without consent.
*/
public final class NetworkPolicy {

/**
* @param net cost class of the active default network
* @param consented the user granted "spend metered data" for this session
*/
public NetworkPolicyDecision decideHeavyStart(NetworkClass net, boolean consented) {
switch (net) {
case UNMETERED:
return NetworkPolicyDecision.ALLOW;
case METERED:
return consented ? NetworkPolicyDecision.ALLOW : NetworkPolicyDecision.NEEDS_CONSENT;
case NONE:
default:
return NetworkPolicyDecision.BLOCKED_NO_NETWORK;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.appdevforall.k2go.networkpolicy.domain;

/** What a caller must do before starting a heavy (costly) transfer. */
public enum NetworkPolicyDecision {

/** Proceed now. The network is free, or the user already consented to spend data. */
ALLOW,

/** Ask the user to consent to spending metered data; proceed only on a yes. */
NEEDS_CONSENT,

/** No usable network. Do not start; tell the user they are offline. */
BLOCKED_NO_NETWORK
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.appdevforall.k2go.networkpolicy.domain;

/** Pure rule for the proactive alert: warn when the default network becomes metered. */
public final class NetworkTransition {

private NetworkTransition() {}

/**
* True when the default network just crossed INTO a metered state from a
* non-metered one -- the moment to warn the user that further activity spends
* data. A metered-to-metered change, or any change back to unmetered, never
* warns.
*/
public static boolean shouldWarn(NetworkClass previous, NetworkClass next) {
return next == NetworkClass.METERED && previous != NetworkClass.METERED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package org.appdevforall.k2go.networkpolicy.presentation;

import android.app.Application;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build;

import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;

import org.appdevforall.k2go.R;
import org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier;
import org.appdevforall.k2go.networkpolicy.data.SessionMeteredConsentStore;
import org.appdevforall.k2go.networkpolicy.domain.NetworkClass;
import org.appdevforall.k2go.networkpolicy.domain.NetworkTransition;
import org.appdevforall.k2go.sync.transport.NetworkStateLiveData;

/**
* Process-wide watcher of the default-network cost class. Started once from
* IIABApplication, mirroring {@code ServerLifecycleReconciler} (one process-scoped
* owner). It REUSES the single existing default-network callback
* ({@link NetworkStateLiveData}) instead of registering a second one -- one
* source for the "network changed" fact (ADR-395).
*
* <p>Two jobs:
* <ul>
* <li>Proactive alert: when the network crosses into metered, post a
* notification so the user knows further activity spends data -- even with
* no download pending.</li>
* <li>Consent lifecycle: clear the session metered-consent the moment the
* network leaves metered, so the next metered episode asks again (no stuck
* grant -- ADR-395).</li>
* </ul>
*/
public final class MeteredNetworkObserver {

private static final String CHANNEL_ID = "network_cost";
private static final int NOTIF_ID = 0x4E50; // stable id: re-alert replaces, never stacks

private MeteredNetworkObserver() {}

private static NetworkClass last = null;

/** Idempotent; call once from Application.onCreate on the main thread. */
public static void start(@NonNull Application app) {
ensureChannel(app);
last = AndroidNetworkClassifier.classify(app);
// observeForever keeps NetworkStateLiveData active for the process lifetime,
// which is exactly the scope we want; no separate registration.
NetworkStateLiveData.get(app).observeForever(token -> onNetworkChanged(app));
}

private static void onNetworkChanged(@NonNull Application app) {
NetworkClass previous = last;
NetworkClass next = AndroidNetworkClassifier.classify(app);
if (next == previous) return;
last = next;
if (next != NetworkClass.METERED) {
SessionMeteredConsentStore.get().clear();
}
if (NetworkTransition.shouldWarn(previous, next)) {
notifyMetered(app);
}
}

private static void notifyMetered(@NonNull Context ctx) {
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setContentTitle(ctx.getString(R.string.k2go_netpolicy_switched_title))
.setContentText(ctx.getString(R.string.k2go_netpolicy_switched_msg))
.setStyle(new NotificationCompat.BigTextStyle()
.bigText(ctx.getString(R.string.k2go_netpolicy_switched_msg)))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
try {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
} catch (SecurityException ignored) {
// POST_NOTIFICATIONS not granted (Android 13+): the start-gate still protects cost.
}
}

private static void ensureChannel(@NonNull Context ctx) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID,
ctx.getString(R.string.k2go_netpolicy_channel),
NotificationManager.IMPORTANCE_DEFAULT);
NotificationManager m = ctx.getSystemService(NotificationManager.class);
if (m != null) m.createNotificationChannel(ch);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.appdevforall.k2go.networkpolicy.presentation;

import android.app.Activity;
import android.view.View;

import androidx.annotation.NonNull;

import org.appdevforall.k2go.R;
import org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier;
import org.appdevforall.k2go.networkpolicy.data.SessionMeteredConsentStore;
import org.appdevforall.k2go.networkpolicy.domain.MeteredConsentStore;
import org.appdevforall.k2go.networkpolicy.domain.NetworkClass;
import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicy;
import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicyDecision;
import org.appdevforall.k2go.ui.dialog.BrandDialog;
import org.appdevforall.k2go.util.Snackbars;

/**
* The single consult point before any heavy download starts (ADR-395). A caller
* wraps its existing start call:
*
* <pre>NetworkPolicyGate.guardHeavyStart(activity, () -&gt; a.startZimDownload());</pre>
*
* <p>Stateless, like {@code OpReturnNavigator}: it owns no "is metered" flag. It
* reads the live class off {@link AndroidNetworkClassifier} and the session
* consent off {@link SessionMeteredConsentStore}, applies the pure
* {@link NetworkPolicy}, and either proceeds, asks, or blocks.
*
* <p>It gates the START only. It does NOT control a transfer already in flight --
* the content bytes are pulled by the in-proot server over the device default
* network, which Android gives the app no handle to throttle (ADR-395). Callers
* put this at the user's commit point (the Download button), never on the
* background drain that re-hands an already-authorized wishlist.
*/
public final class NetworkPolicyGate {

private NetworkPolicyGate() {}

private static final NetworkPolicy POLICY = new NetworkPolicy();

public static void guardHeavyStart(@NonNull Activity activity, @NonNull Runnable onProceed) {
MeteredConsentStore consent = SessionMeteredConsentStore.get();
NetworkClass net = AndroidNetworkClassifier.classify(activity);
NetworkPolicyDecision decision = POLICY.decideHeavyStart(net, consent.isGranted());
switch (decision) {
case ALLOW:
onProceed.run();
return;
case NEEDS_CONSENT:
new BrandDialog(activity)
.setTitle(R.string.k2go_netpolicy_metered_title)
.setMessage(R.string.k2go_netpolicy_metered_msg)
.setPositive(R.string.k2go_netpolicy_continue, () -> {
consent.grant();
onProceed.run();
})
.setNegative(R.string.k2go_netpolicy_not_now, null)
.show();
return;
case BLOCKED_NO_NETWORK:
default:
View root = activity.findViewById(android.R.id.content);
if (root != null) {
Snackbars.make(root, R.string.k2go_netpolicy_offline).show();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,14 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
if (!(getActivity() instanceof SetupLibraryActivity)) return;
SetupLibraryActivity a = (SetupLibraryActivity) getActivity();
if (banks) a.zimWizardConfirm(); // no box yet: bank it
else if (!DashboardRebuild.blockedByUpdate(v)) a.startZimDownload(); // ADFA-5074 / ADFA-5333
// ADFA-5074 / ADFA-5333: blocked while a dashboard update runs.
// K2GO-395 (ADR-395): gate a metered start behind explicit consent (cost awareness).
// The gate sits at the user commit point, not on ZimProvisioner.drain (that re-hands an
// already-authorized wishlist every ~2 s and would re-prompt).
else if (!DashboardRebuild.blockedByUpdate(v)) {
org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate
.guardHeavyStart(a, a::startZimDownload);
}
});

return root;
Expand Down
Loading
Loading