diff --git a/CLAUDE.md b/CLAUDE.md index 9681286a3..70793fe59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,16 +113,19 @@ two layered migrations from colliding, follow these rules: - **Shared contracts land first.** If two features need a common domain type or port, define and merge that small interface on its own first, then both features build against it. Don't duplicate it on two branches. -- **Per-feature resource files** to avoid `strings.xml` collisions: a feature may - add its own `res/values/strings_.xml` (Android merges all `` - files) instead of everyone editing the one shared `strings.xml`. Append, never - reorder existing keys. **This is a temporary device for parallel work, not the - end state.** A `strings_.xml` is folded back into `strings.xml` (with all - 33 locale values) once the feature lands and the collision risk is gone; the - final tree should carry no loose `strings_.xml`. The one exception is - `strings_untranslated.xml`, the deliberate WIP tracker for strings awaiting - translation (see the l10n conventions). This holds until the policy changes to - keep per-feature string files permanently. +- **One place for strings; no per-feature string files.** All UI strings end in + `strings.xml` (with all locale values). Do NOT create `res/values/strings_.xml` + files. A scattered per-feature file is more dangerous than a single tracker: to + find a string you must first know which feature owns it, and the last feature + added is exactly the one you do not know to look in -- so strings get lost. The + ONLY external string file is `strings_untranslated.xml`: the single, always-known + WIP tracker for strings that are user-facing but not yet translated (see the l10n + conventions). Park a WIP string there while you work, then migrate it into + `strings.xml` (all locale values) before the PR merges. A PR closes fully + integrated, so `strings_untranslated.xml` should be near-empty when the next PR + opens -- never a growing pile. Edits to `strings.xml` stay additive and append-only + (never reorder existing keys) to keep parallel-branch collisions trivial to + resolve; discoverability wins over collision-avoidance. - **Wire dependencies by hand, per feature.** Each feature has its own `…ViewModelFactory` / small factory. There is no shared DI graph for everyone to edit (introducing Hilt/Dagger is a separate ADR), so composition roots don't diff --git a/controller/app/src/main/java/org/appdevforall/k2go/IIABApplication.java b/controller/app/src/main/java/org/appdevforall/k2go/IIABApplication.java index 3dc3786eb..e067e3dbf 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/IIABApplication.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/IIABApplication.java @@ -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); diff --git a/controller/app/src/main/java/org/appdevforall/k2go/PortalActivity.java b/controller/app/src/main/java/org/appdevforall/k2go/PortalActivity.java index 65b05994d..fb0fbcc5e 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/PortalActivity.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/PortalActivity.java @@ -72,6 +72,7 @@ public class PortalActivity extends AppCompatActivity { private GestureWebView webView; private org.appdevforall.k2go.redesign.FqrController fqr; // ADFA-4879: FQR maps (only on /maps/) private org.appdevforall.k2go.redesign.KiwixManageController kiwixMgr; // ADFA-5004: ZIM delete (only on /kiwix/) + private org.appdevforall.k2go.redesign.KolibriGuardController kolibriGuard; // K2GO-395: metered gate for native Kolibri import (only on /kolibri/) private static final long AUTO_HIDE_MS = 4000L; // ADFA-4887: nav-bar auto-hide after inactivity private boolean fullscreenOn = false; // ADFA-4887: Home button toggles fullscreen private Handler hideHandler; // ADFA-4887: nav-bar auto-hide (cleared in onDestroy) @@ -224,6 +225,7 @@ public boolean shouldOverrideUrlLoading(WebView view, android.webkit.WebResource // Internal server link stays in the WebView (and travels through the proxy). if (NavigationPolicy.isInternalHost(host)) { if (fqr != null) fqr.prepareForUrl(url); // ADFA-4879: add the FQR bridge only on /maps/ + if (kolibriGuard != null) kolibriGuard.prepareForUrl(url); // K2GO-395: Kolibri gate only on /kolibri/ return false; } @@ -259,6 +261,7 @@ public void onPageFinished(WebView view, String url) { if (fqr != null) fqr.onPageFinished(url); // ADFA-5004: arm/disarm in-app ZIM manager depending on whether this is /kiwix/. if (kiwixMgr != null) kiwixMgr.onPageFinished(url); + if (kolibriGuard != null) kolibriGuard.onPageFinished(url); // K2GO-395: arm the Kolibri import gate } @Override @@ -371,6 +374,10 @@ public boolean onConsoleMessage(android.webkit.ConsoleMessage consoleMessage) { // (gated in KiwixManageController#onPageFinished). kiwixMgr = new org.appdevforall.k2go.redesign.KiwixManageController(this, webView); + // K2GO-395 (ADR-395): metered-cost gate for the NATIVE Kolibri import, active only on /kolibri/. + kolibriGuard = new org.appdevforall.k2go.redesign.KolibriGuardController(this, webView); + kolibriGuard.prepareForUrl(finalTargetUrl); + // ADFA-5043: Books (Calibre-Web) / Courses (Kolibri) auto-login as box admin — fetch a session // cookie, inject it into the WebView CookieManager, THEN load, so the card opens already // authenticated. Degrades gracefully: if the service isn't installed/ready, just load without it. @@ -533,6 +540,7 @@ protected void onDestroy() { // The durable server job (if any) keeps running and shows up on the next /maps/ reload. if (fqr != null) fqr.detach(); if (kiwixMgr != null) kiwixMgr.detach(); // ADFA-5004 + if (kolibriGuard != null) kolibriGuard.detach(); // K2GO-395 if (hideHandler != null && hideRunnable != null) hideHandler.removeCallbacks(hideRunnable); // ADFA-4887 super.onDestroy(); } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/kolibri/presentation/KolibriConfirmFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/kolibri/presentation/KolibriConfirmFragment.java index 873c59d65..9d40f4212 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/kolibri/presentation/KolibriConfirmFragment.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/kolibri/presentation/KolibriConfirmFragment.java @@ -383,7 +383,20 @@ private void startLive(List chosen) { refuse(R.string.k2go_kolibri_nothing_to_add); return; } + // K2GO-395 (ADR-395): prompt on a metered network before committing. Both outcomes proceed to + // commit -- the order is banked either way, so a declined order is not lost; the actual HOLD is + // enforced in ContentAdmission (the drain waits for consent or Wi-Fi and the order shows as + // Queued on the index, exactly as Kolibri already defers a busy-line order). The prompt's only + // job is to grant consent (on Continue) so the drain may start now. + org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart( + requireActivity(), () -> commitLive(toDownload), () -> commitLive(toDownload)); + } + private void commitLive(List toDownload) { + // K2GO-395: commitLive is deferred behind the metered gate dialog, so it can run after the + // fragment detaches (rotation / navigation). Bail if we are no longer attached, mirroring + // finishStart, before touching requireContext()/requireActivity() below. + if (!isAdded() || getActivity() == null) return; // The order is read off the view model here, on the main thread, and written // on the IO pool: SharedPreferences plus a foreground service start is small // but it is still disk at the moment of a tap. diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/AndroidNetworkClassifier.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/AndroidNetworkClassifier.java new file mode 100644 index 000000000..9d20ee7a6 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/AndroidNetworkClassifier.java @@ -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. + * + *

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. + * + *

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); + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/NetworkCostAdmission.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/NetworkCostAdmission.java new file mode 100644 index 000000000..a04ecc9a4 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/NetworkCostAdmission.java @@ -0,0 +1,42 @@ +package org.appdevforall.k2go.networkpolicy.data; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicy; +import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicyDecision; + +/** + * The one headless "may a heavy transfer start now, cost-wise?" decision (ADR-395). + * + *

It is the single source of the classify + consent + policy glue, used by both + * the UI gate ({@code NetworkPolicyGate}, which needs the full decision to choose + * dialog vs snackbar) and the content-stream admission ({@code ContentAdmission}, + * which needs only the boolean). Data-layer, so the system-side admission can call + * it without depending on presentation. + */ +public final class NetworkCostAdmission { + + private NetworkCostAdmission() {} + + private static final NetworkPolicy POLICY = new NetworkPolicy(); + + /** The full decision for the active default network and the current session consent. */ + @NonNull + public static NetworkPolicyDecision decideNow(@NonNull Context ctx) { + return POLICY.decideHeavyStart( + AndroidNetworkClassifier.classify(ctx), + SessionMeteredConsentStore.get().isGranted()); + } + + /** + * True when a heavy transfer may start now on cost grounds (unmetered, or the + * user consented this session). False means HOLD: leave the order banked, a + * later pass takes it once the network is free or consent is given -- the same + * "deferred is not a failure" contract the other admission checks use. + */ + public static boolean allowsHeavyStartNow(@NonNull Context ctx) { + return decideNow(ctx) == NetworkPolicyDecision.ALLOW; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/SessionMeteredConsentStore.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/SessionMeteredConsentStore.java new file mode 100644 index 000000000..a2efb4f99 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/data/SessionMeteredConsentStore.java @@ -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; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/MeteredConsentStore.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/MeteredConsentStore.java new file mode 100644 index 000000000..b500f4613 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/MeteredConsentStore.java @@ -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? + * + *

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(); +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkClass.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkClass.java new file mode 100644 index 000000000..0b8b2668a --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkClass.java @@ -0,0 +1,35 @@ +package org.appdevforall.k2go.networkpolicy.domain; + +/** + * Cost class of the ACTIVE DEFAULT network, from the app point of view. + * + *

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; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicy.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicy.java new file mode 100644 index 000000000..35bfa1c6f --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicy.java @@ -0,0 +1,29 @@ +package org.appdevforall.k2go.networkpolicy.domain; + +/** + * The single rule for "may a heavy transfer start now?". Pure, no Android. + * + *

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; + } + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicyDecision.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicyDecision.java new file mode 100644 index 000000000..a42b8c0e3 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicyDecision.java @@ -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 +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkTransition.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkTransition.java new file mode 100644 index 000000000..19b094989 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/domain/NetworkTransition.java @@ -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; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/presentation/MeteredNetworkObserver.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/presentation/MeteredNetworkObserver.java new file mode 100644 index 000000000..fa760f12b --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/presentation/MeteredNetworkObserver.java @@ -0,0 +1,99 @@ +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). + * + *

Two jobs: + *

    + *
  • Proactive alert: when the network crosses into metered, post a + * notification so the user knows further activity spends data -- even with + * no download pending.
  • + *
  • 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).
  • + *
+ */ +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; + private static boolean started = false; + + /** Idempotent; call once from Application.onCreate on the main thread. */ + public static void start(@NonNull Application app) { + if (started) return; // guard: a second call must not add a second observeForever + started = true; + 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); + } + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/presentation/NetworkPolicyGate.java b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/presentation/NetworkPolicyGate.java new file mode 100644 index 000000000..a8a468dde --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/networkpolicy/presentation/NetworkPolicyGate.java @@ -0,0 +1,72 @@ +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.NetworkCostAdmission; +import org.appdevforall.k2go.networkpolicy.data.SessionMeteredConsentStore; +import org.appdevforall.k2go.ui.dialog.BrandDialog; +import org.appdevforall.k2go.util.Snackbars; + +/** + * The user-facing PROMPT for a costed start (ADR-395). A commit point (a Download + * button) wraps its start so the user is asked before spending metered data: + * + *
NetworkPolicyGate.guardHeavyStart(activity, () -> a.startZimDownload());
+ * + *

Stateless, like {@code OpReturnNavigator}: it owns no "is metered" flag. It + * reads the decision from {@link NetworkCostAdmission} (the one classify + consent + * + policy source) and either proceeds, asks, or reports offline. + * + *

For the banked content streams (ZIM/Books/Kolibri) the actual HOLD is enforced + * headless in {@code ContentAdmission}; declining here just leaves the order queued, + * so the two-arg form takes no decline action. A direct, non-banked start (FQR maps, + * which is a user-driven Operation, not a banked ContentType, and so is NOT covered + * by ContentAdmission) uses the three-arg form to undo its own UI on decline -- e.g. + * clear the drawn map region -- since there is no queue to fall back on. + */ +public final class NetworkPolicyGate { + + private NetworkPolicyGate() {} + + /** Two-arg form: decline/offline is a no-op (the order stays banked and drains later). */ + public static void guardHeavyStart(@NonNull Activity activity, @NonNull Runnable onProceed) { + guardHeavyStart(activity, onProceed, () -> {}); + } + + /** + * Three-arg form: {@code onDeclined} runs when the user declines the metered + * prompt (or dismisses it) or when there is no network -- for callers with no + * queue, so they can undo the UI they were about to commit. + */ + public static void guardHeavyStart(@NonNull Activity activity, @NonNull Runnable onProceed, + @NonNull Runnable onDeclined) { + switch (NetworkCostAdmission.decideNow(activity)) { + 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, () -> { + SessionMeteredConsentStore.get().grant(); + onProceed.run(); + }) + .setNegative(R.string.k2go_netpolicy_not_now, onDeclined::run) + .setOnCancel(onDeclined::run) + .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(); + } + onDeclined.run(); + } + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardRebuild.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardRebuild.java index 1b3870349..e5ae0449b 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardRebuild.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardRebuild.java @@ -93,8 +93,19 @@ private static void start(@NonNull Fragment host, @NonNull View anchor, boolean if (!host.isAdded()) return; // ADFA-5339: the site refresh only applies to the LIVE REST path; the proot bridge rebuild // (< 1.2.0) has no site step, so the checkbox is simply not carried there. - if (op.isLive()) startRest(host, anchor, updateSite); - else startProot(host); + if (op.isLive()) { + // K2GO-395 (ADR-395): the LIVE path is a REST-heavy transfer -- the box git-fetches + // and blue-green rebuilds over the device's default network. Prompt before spending + // metered data. Like FQR (a user-driven Operation, not a banked ContentType), it is + // gated at the UI commit, not through ContentAdmission -- which already defers TO a + // dashboard update, so routing it there would be circular. The only POST path is + // startRest -> DashboardRebuildService ACTION_START (ATTACH re-owns without POSTing), + // so this one gate covers it. No queue to fall back on: a decline just does not start. + org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart( + host.requireActivity(), () -> startRest(host, anchor, updateSite)); + } else { + startProot(host); + } }); }); } @@ -116,8 +127,14 @@ private static void startProot(@NonNull Fragment host) { * reports done/error, with no time cap. A visible dashboard card refreshes on the service's * completion broadcast; nothing pins this screen. */ private static void startRest(@NonNull Fragment host, @NonNull View anchor, boolean updateSite) { + // K2GO-395: this now runs deferred behind the metered consent dialog, so the host fragment may + // have detached (config change / navigation) before the user taps Continue. Bail before + // requireContext() would throw -- consistent with the isAdded() guard already used for the + // snackbar below and with KolibriConfirmFragment.commitLive. The update is not banked, so a + // dropped start on this rare window is re-triggerable from the card, not a lost queue item. + if (!host.isAdded()) return; DashboardRebuildService.start(host.requireContext().getApplicationContext(), updateSite); - if (host.isAdded()) Snackbars.make(anchor, R.string.k2go_dash_update_started).show(); + Snackbars.make(anchor, R.string.k2go_dash_update_started).show(); } /** ADFA-5333: reverse gate for LIVE content downloads (ZIM/Books/Kolibri). Those run on the server diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/FqrController.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/FqrController.java index 192d9b98b..48dd98a6a 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/FqrController.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/FqrController.java @@ -60,6 +60,7 @@ import com.google.android.material.progressindicator.LinearProgressIndicator; import org.appdevforall.k2go.R; +import org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate; import org.appdevforall.k2go.ui.dialog.BrandDialog; import org.appdevforall.k2go.util.M3Text; import org.json.JSONArray; @@ -172,12 +173,7 @@ public void onPageFinished(String url) { /** True when the URL's path is the box's maps page. Pure string parsing (no android.net.Uri) so * it is unit-testable and dependency-free. */ static boolean isMapsPage(String url) { - if (url == null) return false; - String u = url; - int hash = u.indexOf('#'); if (hash >= 0) u = u.substring(0, hash); - int q = u.indexOf('?'); if (q >= 0) u = u.substring(0, q); - int scheme = u.indexOf("://"); - if (scheme >= 0) { int slash = u.indexOf('/', scheme + 3); u = slash >= 0 ? u.substring(slash) : "/"; } + String u = org.appdevforall.k2go.util.WebPath.pathOf(url); // K2GO-395: one URL->path source return u.equals("/maps/") || u.equals("/maps"); } @@ -348,7 +344,12 @@ private void showConsent(String name, String box, long transfer, long archive, l dialog = new BrandDialog(themed) .setTitle(R.string.k2go_fqr_consent_title) .setContentView(body) - .setPositive(R.string.k2go_fqr_download, () -> startDownload(name)) + // K2GO-395 (ADR-395): after the storage consent, gate the actual REST start on metered + // cost. FQR is a user-driven Operation, not a banked ContentType, so ContentAdmission + // does not cover it -- the gate goes here. Declined/offline resets the map selection + // (there is no queue to fall back on), matching the negative/cancel below. + .setPositive(R.string.k2go_fqr_download, () -> NetworkPolicyGate.guardHeavyStart( + activity, () -> startDownload(name), this::resetMapSelection)) .setNegative(R.string.k2go_fqr_not_now, () -> resetMapSelection()) .setOnCancel(() -> resetMapSelection()) .setCancelable(true) diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/KiwixManageController.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/KiwixManageController.java index bb20304cf..3489aeba6 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/KiwixManageController.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/KiwixManageController.java @@ -115,12 +115,7 @@ public void detach() { /** True when the URL's path is under the box's kiwix reader. Pure string parsing (no * android.net.Uri) so it is unit-testable and dependency-free. */ static boolean isKiwixPage(String url) { - if (url == null) return false; - String u = url; - int hash = u.indexOf('#'); if (hash >= 0) u = u.substring(0, hash); - int q = u.indexOf('?'); if (q >= 0) u = u.substring(0, q); - int scheme = u.indexOf("://"); - if (scheme >= 0) { int slash = u.indexOf('/', scheme + 3); u = slash >= 0 ? u.substring(slash) : "/"; } + String u = org.appdevforall.k2go.util.WebPath.pathOf(url); // K2GO-395: one URL->path source return u.equals("/kiwix") || u.startsWith("/kiwix/"); } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/KolibriGuardController.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/KolibriGuardController.java new file mode 100644 index 000000000..11ed6f1c8 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/KolibriGuardController.java @@ -0,0 +1,143 @@ +/* + * ============================================================================ + * Name : KolibriGuardController.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-395 (ADR-395). Metered-cost gate for the NATIVE Kolibri app. + * + * Kolibri runs in the rootfs and is shown in the PortalActivity WebView at the box + * /kolibri/ page. It has its OWN content-import (download) manager, which our Get More + * flow and ContentAdmission do NOT cover: tapping "Import" in Kolibri's own UI starts a + * server-side download from Kolibri Studio over the device's metered link, with no + * consent prompt. Device recon (K2GO-395): the import is enqueued by + * POST /api/tasks/tasks/ (referer /kolibri/en/device/) + * and cancelled by POST /api/tasks/tasks//cancel/. The task list polls + * GET /api/tasks/tasks/?queue=content. + * + * Lifecycle mirrors FqrController / KiwixManageController: PortalActivity forwards + * prepareForUrl(url) (add the JS bridge only on /kolibri/), onPageFinished(url) (inject + * the hook on the kolibri page), and onDestroy -> detach(). + * + * How it gates: the injected hook wraps XMLHttpRequest (axios, which Kolibri uses) and + * fetch. A POST to /api/tasks/tasks/ that carries a REMOTE import task (heuristic on the + * body: contains "remote"/"channelupdate", not a local "disk" import) is PAUSED; the hook + * calls the native bridge K2GoKolibri.gateImport(reqId, body), which runs + * NetworkPolicyGate.guardHeavyStart on the UI thread and resolves back via + * evaluateJavascript(window.__k2goKolibriResolve) -- proceed on consent (or unmetered / + * already-consented, which resolves instantly), abort on decline. A task POST that is NOT + * classified as a remote import is logged (console.warn -> logcat) so a Kolibri task-API + * change is visible rather than a silent miss. + * + * The task-API shape is Kolibri internal and may change across versions -- the hook is + * intentionally narrow and fails OPEN (any error lets the request through) so a Kolibri + * change never bricks import; the console.warn above is the regression signal. gateImport + * also resolves if the prompt cannot be shown, so a parked request is never left hanging. + * ============================================================================ + */ +package org.appdevforall.k2go.redesign; + +import android.app.Activity; +import android.webkit.JavascriptInterface; +import android.webkit.WebView; + +import org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate; +import org.appdevforall.k2go.util.WebPath; + +public final class KolibriGuardController { + + private final Activity activity; + private final WebView webView; + + public KolibriGuardController(Activity activity, WebView webView) { + this.activity = activity; + this.webView = webView; + } + + /** Add the JS bridge only for the /kolibri/ page and remove it elsewhere (defence in depth: the + * box is trusted, but keep the interface off every other page). Takes effect on the next load. */ + public void prepareForUrl(String url) { + if (isKolibriPage(url)) webView.addJavascriptInterface(this, "K2GoKolibri"); + else webView.removeJavascriptInterface("K2GoKolibri"); + } + + /** Inject the fetch/XHR hook once the kolibri page has loaded. Idempotent in-page. */ + public void onPageFinished(String url) { + if (isKolibriPage(url)) webView.evaluateJavascript(HOOK_JS, null); + } + + /** Host is going away: drop the bridge. The hook lives in the page's JS context and dies with it. */ + public void detach() { + webView.removeJavascriptInterface("K2GoKolibri"); + } + + /** + * Called from the page hook (binder thread) when a Kolibri remote-import POST is parked. Shows the + * consent prompt on the UI thread and resolves the parked request back in JS: proceed on consent + * (or unmetered / already-consented), abort on decline. If the prompt cannot be shown (activity + * finishing), resolves proceed so the request is never left parked -- consistent with fail-open. + */ + @JavascriptInterface + public void gateImport(String reqId, String body) { + if (reqId == null) return; + final String id = reqId; + activity.runOnUiThread(() -> { + try { + NetworkPolicyGate.guardHeavyStart(activity, () -> resolve(id, true), () -> resolve(id, false)); + } catch (RuntimeException e) { + resolve(id, true); + } + }); + } + + private void resolve(String reqId, boolean granted) { + // Only the id we minted in JS (alphanumeric + '_'); never interpolate arbitrary text. + if (reqId == null || !reqId.matches("[A-Za-z0-9_]+")) return; + webView.evaluateJavascript( + "window.__k2goKolibriResolve&&window.__k2goKolibriResolve('" + reqId + "'," + granted + ");", + null); + } + + /** True when the URL path is a box Kolibri page. */ + static boolean isKolibriPage(String url) { + String p = WebPath.pathOf(url); + return p.equals("/kolibri") || p.startsWith("/kolibri/"); + } + + // The page hook. Wraps XHR (axios) + fetch; parks a remote-import task POST behind the native gate, + // warns on any other task POST (regression signal), and fails OPEN on any error. + private static final String HOOK_JS = + "(function(){" + + "if(window.__k2goKolibriHooked)return;window.__k2goKolibriHooked=true;" + + "window.__k2goKolibriPending={};" + + "window.__k2goKolibriResolve=function(id,g){var p=window.__k2goKolibriPending[id];if(!p)return;" + + "delete window.__k2goKolibriPending[id];try{p(g);}catch(e){}};" + + "function isTaskPost(m,u){try{" + + "if(!m||(''+m).toUpperCase()!=='POST')return false;if(!u)return false;" + + "var s=(''+u).split('?')[0].split('#')[0];if(s.charAt(s.length-1)==='/')s=s.substring(0,s.length-1);" + + "return s.endsWith('/api/tasks/tasks');" + + "}catch(e){return false;}}" + + "function isRemoteImport(b){var t=(typeof b==='string')?b:'';return /remote|channelupdate/i.test(t)&&!/disk/i.test(t);}" + + "function park(body,proceed,cancel){" + + "var id='k'+Date.now()+'_'+Math.random().toString(36).slice(2);" + + "window.__k2goKolibriPending[id]=function(g){if(g)proceed();else cancel();};" + + "try{K2GoKolibri.gateImport(id,(typeof body==='string')?body:'');}catch(e){proceed();}}" + + "function classify(m,u,b,proceed,cancel){" + + "if(!isTaskPost(m,u))return false;" + + "if(isRemoteImport(b)){park(b,proceed,cancel);return true;}" + + "try{console.warn('K2Go-Kolibri: ungated task POST',(typeof b==='string')?b.slice(0,160):'');}catch(e){}" + + "return false;}" + + "try{var XO=XMLHttpRequest.prototype.open,XS=XMLHttpRequest.prototype.send;" + + "XMLHttpRequest.prototype.open=function(m,u){this.__k2m=m;this.__k2u=u;return XO.apply(this,arguments);};" + + "XMLHttpRequest.prototype.send=function(b){var self=this,a=arguments;" + + "if(classify(self.__k2m,self.__k2u,b,function(){XS.apply(self,a);}," + + "function(){try{self.abort();}catch(e){}}))return;" + + "return XS.apply(self,arguments);};}catch(e){}" + + "try{var OF=window.fetch;if(OF){window.fetch=function(i,n){" + + "var m=(n&&n.method)||(i&&i.method)||'GET';var u=(typeof i==='string')?i:((i&&i.url)||'');var b=n&&n.body;" + + "var handled=false,pr,rj;var p=new Promise(function(res,rej){pr=res;rj=rej;});" + + "handled=classify(m,u,(typeof b==='string')?b:'',function(){OF(i,n).then(pr,rj);}," + + "function(){rj(new DOMException('Canceled by network policy','AbortError'));});" + + "return handled?p:OF(i,n);};}}catch(e){}" + + "console.log('K2Go-Kolibri gate armed');" + + "})();"; +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupLibraryActivity.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupLibraryActivity.java index 25300cc72..b301fbb12 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupLibraryActivity.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupLibraryActivity.java @@ -304,8 +304,15 @@ public void openZimConfirm() { public void startZimDownload() { ZimWishlist.add(this, selection().zimCart()); selection().zimCart().clear(); // handed over; keeping it would re-offer the same picks - ZimProvisioner.drain(this); // starts now if the line is free, banks it if not - startActivity(new Intent(this, SetupProgressActivity.class)); + // K2GO-395 (ADR-395 sec.10): the order is banked above, unconditionally, so it is never lost. + // The gate wraps only the drain + navigation: it prompts on a metered network and, on consent + // (or Wi-Fi), drains and opens progress; declined or offline the order stays queued and a later + // pass drains it. The drain is also held headless by ContentAdmission, so a banked order never + // starts on metered data without consent even via the wizard-bank path or a background re-drain. + org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart(this, () -> { + ZimProvisioner.drain(this); // starts now if the line is free, banks it if not + startActivity(new Intent(this, SetupProgressActivity.class)); + }); } /** ADFA-4853: the wizard's "Continue" — install the system now; content (Books/ZIM) is banked @@ -517,20 +524,25 @@ public void startBooksDownload() { v != null && v.length > 2 ? v[2] : ""); } selection().booksCart().clear(); - // ADFA-5074: through the wishlist, like ZIM and Courses. Books was the last door still - // calling its service directly, and that had a real consequence beyond symmetry: the - // service registers its session asynchronously in onStartCommand, so for a moment nothing - // was pending and nothing was in session. The index reads exactly that pair to decide the - // run is over — nothingToStart() plus an empty orchestrateStep — and could declare a - // just-started download complete and count down to the Library. Writing the wishlist first - // makes hasPending true synchronously, before the index is even launched, so that window - // does not exist. It also makes Books queue behind a busy line instead of overwriting. - BooksProvisioner.drain(this); - // ADFA-4988: go to the progress screen instead of returning to Get More and downloading - // invisibly. ADFA-5074: to the index, not the books detail. The hint that used to open the - // detail "when books is the only stream" made the landing depend on state the user cannot - // see, and the index is what ends the run. - startActivity(new Intent(this, SetupProgressActivity.class)); + // K2GO-395 (ADR-395 sec.10): the order is banked above; gate only the drain + navigation, so + // a declined or offline order stays queued (ContentAdmission also holds it on metered-without- + // consent). Same shape as startZimDownload. + org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart(this, () -> { + // ADFA-5074: through the wishlist, like ZIM and Courses. Books was the last door still + // calling its service directly, and that had a real consequence beyond symmetry: the + // service registers its session asynchronously in onStartCommand, so for a moment nothing + // was pending and nothing was in session. The index reads exactly that pair to decide the + // run is over — nothingToStart() plus an empty orchestrateStep — and could declare a + // just-started download complete and count down to the Library. Writing the wishlist first + // makes hasPending true synchronously, before the index is even launched, so that window + // does not exist. It also makes Books queue behind a busy line instead of overwriting. + BooksProvisioner.drain(this); + // ADFA-4988: go to the progress screen instead of returning to Get More and downloading + // invisibly. ADFA-5074: to the index, not the books detail. The hint that used to open the + // detail "when books is the only stream" made the landing depend on state the user cannot + // see, and the index is what ends the run. + startActivity(new Intent(this, SetupProgressActivity.class)); + }); } /** ADFA-4850: Books landing -> the download manager screen (per-book checklist + retry). */ @@ -567,12 +579,23 @@ public void openMapsConfirm(String[] names, String[] opts, long[] mb, String[] l public void openMapsIndex(String[] levels, long totalMb) { // ADFA-5228: maps is a proot (STOPPED) install — confirm before entering the index that runs it. InstallConfirm.gate(this, org.appdevforall.k2go.system.domain.Operation.appInstall("maps"), () -> { - String base = levels != null && levels.length > 0 && levels[0] != null ? levels[0] : "11"; - String sat = levels != null && levels.length > 1 && levels[1] != null ? levels[1] : "none"; - String ter = levels != null && levels.length > 2 && levels[2] != null ? levels[2] : "0-none"; - boolean search = levels != null && levels.length > 3 && levels[3] != null; - MapsWishlist.save(this, base, sat, ter, search, totalMb); - startActivity(new Intent(this, SetupProgressActivity.class)); + // K2GO-395 (ADR-395): maps pre-downloads its base layers over REST (dash-node, + // InstallService.downloadMapsBasemapsThenRun) before the runrole, so this is a costed heavy + // start -- prompt before spending metered data. Gated at the UI commit (like FQR), not at + // MapsProvisioner.drain: the maps drain is a serialized proot stage where a refusal is + // TERMINAL (SetupProgressActivity marks mapsStartFailed and retires the stage, by design, to + // avoid an unexplained spinner), so a "cost-hold = retry later" does not fit there without an + // orchestrator "waiting for network" state. The wizard/system-install maps path + // (mapsWizardConfirm) + that headless integration ride with the install/rootfs PR. Decline or + // offline just does not enter the index; nothing is banked. + org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart(this, () -> { + String base = levels != null && levels.length > 0 && levels[0] != null ? levels[0] : "11"; + String sat = levels != null && levels.length > 1 && levels[1] != null ? levels[1] : "none"; + String ter = levels != null && levels.length > 2 && levels[2] != null ? levels[2] : "0-none"; + boolean search = levels != null && levels.length > 3 && levels[3] != null; + MapsWishlist.save(this, base, sat, ter, search, totalMb); + startActivity(new Intent(this, SetupProgressActivity.class)); + }); }); } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/ZimConfirmFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ZimConfirmFragment.java index b26c80eb8..d055e452e 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/ZimConfirmFragment.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ZimConfirmFragment.java @@ -106,7 +106,10 @@ 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. The K2GO-395 metered-cost + // gate lives inside startZimDownload (bank first, then gate the drain), so the order is + // never lost on decline/offline; see ADR-395 sec.10. + else if (!DashboardRebuild.blockedByUpdate(v)) a.startZimDownload(); }); return root; diff --git a/controller/app/src/main/java/org/appdevforall/k2go/system/data/ContentAdmission.java b/controller/app/src/main/java/org/appdevforall/k2go/system/data/ContentAdmission.java index da134b130..fb0912200 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/system/data/ContentAdmission.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/system/data/ContentAdmission.java @@ -15,6 +15,8 @@ import android.util.Log; import org.appdevforall.k2go.install.presentation.ModuleQueueRepository; +import org.appdevforall.k2go.networkpolicy.data.NetworkCostAdmission; +import org.appdevforall.k2go.networkpolicy.domain.NetworkPolicyDecision; import org.appdevforall.k2go.redesign.DashboardRebuildService; import org.appdevforall.k2go.redesign.MapsProvisioner; import org.appdevforall.k2go.system.domain.ContentType; @@ -73,6 +75,19 @@ public static boolean canStart(Context ctx, ContentType stream) { Log.d(TAG, stream.key() + " drain deferred: a content stream still has work to do"); return false; } + // ADR-395: cost gate. A heavy content download must not start on a metered network without + // the user's consent. This is the single headless hold that covers every drain path -- the + // commit point, the wizard-bank path, and every background re-drain -- so a banked order + // waits for Wi-Fi or consent rather than silently spending mobile data. Prompting happens at + // the UI commit point (NetworkPolicyGate); here we only defer. Two distinct hold reasons -- + // no usable network vs metered-without-consent -- so the log names the real one. + NetworkPolicyDecision cost = NetworkCostAdmission.decideNow(ctx); + if (cost != NetworkPolicyDecision.ALLOW) { + Log.d(TAG, stream.key() + " drain deferred: " + + (cost == NetworkPolicyDecision.BLOCKED_NO_NETWORK + ? "no usable network" : "metered network without consent")); + return false; + } return true; } } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/util/WebPath.java b/controller/app/src/main/java/org/appdevforall/k2go/util/WebPath.java new file mode 100644 index 000000000..d283934a1 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/util/WebPath.java @@ -0,0 +1,26 @@ +package org.appdevforall.k2go.util; + +/** + * The single source for turning a URL into its path by pure string parsing -- no + * {@code android.net.Uri}, so it is dependency-free and unit-testable. + * + *

Strips the {@code #fragment}, the {@code ?query} and the {@code scheme://host}, + * returning the path. Returns {@code "/"} when a scheme is present but there is no + * path, and {@code ""} for a null URL. The in-WebView controllers (FQR maps, Kiwix + * manage, Kolibri guard -- K2GO-395) all use this to decide "is this the box's + * /maps/ , /kiwix/ or /kolibri/ page", instead of each carrying its own copy. + */ +public final class WebPath { + + private WebPath() {} + + public static String pathOf(String url) { + if (url == null) return ""; + String u = url; + int hash = u.indexOf('#'); if (hash >= 0) u = u.substring(0, hash); + int q = u.indexOf('?'); if (q >= 0) u = u.substring(0, q); + int scheme = u.indexOf("://"); + if (scheme >= 0) { int slash = u.indexOf('/', scheme + 3); u = slash >= 0 ? u.substring(slash) : "/"; } + return u; + } +} diff --git a/controller/app/src/main/res/values-ar/strings.xml b/controller/app/src/main/res/values-ar/strings.xml index f171a2a8e..953d030e7 100644 --- a/controller/app/src/main/res/values-ar/strings.xml +++ b/controller/app/src/main/res/values-ar/strings.xml @@ -1330,4 +1330,13 @@ احتوى K2Go نشاطًا غير معتاد تعامل K2Go مع عملية كانت تستخدم مساحة تخزين كبيرة وأبقى نظامك يعمل. انقر لإرسال تقرير. جارٍ تنزيل بيانات الخريطة + + يبدو أنك تستخدم بيانات الجوال + يستخدم هذا التنزيل بيانات الجوال. قد يترتب عليه تكلفة أو يستنفد باقة بياناتك. هل تريد المتابعة؟ + المتابعة عبر البيانات + ليس الآن + لا توجد شبكة. اتصل بشبكة Wi-Fi للتنزيل. + يبدو أنك انتقلت إلى بيانات الجوال + تستخدم التنزيلات الجديدة الآن بيانات الجوال. قد يترتب على ذلك تكلفة أو يستنفد باقة بياناتك. + تنبيهات تكلفة البيانات diff --git a/controller/app/src/main/res/values-az/strings.xml b/controller/app/src/main/res/values-az/strings.xml index 58c4086ba..392dc14ee 100644 --- a/controller/app/src/main/res/values-az/strings.xml +++ b/controller/app/src/main/res/values-az/strings.xml @@ -1350,4 +1350,13 @@ K2Go qeyri-adi fəaliyyəti cilovladı K2Go çox yaddaş istifadə edən prosesi idarə etdi və sisteminizi işlək saxladı. Hesabat göndərmək üçün toxunun. Xəritə məlumatları yüklənir + + Görünür, mobil internetdən istifadə edirsiniz + Bu yükləmə mobil internetdən istifadə edir. Bu, xərc yarada və ya data paketinizi bitirə bilər. Davam etmək istəyirsiniz? + Mobil internetlə davam et + İndi yox + Şəbəkə yoxdur. Yükləmək üçün Wi-Fi-a qoşulun. + Deyəsən, mobil internetə keçdiniz + Yeni yükləmələr indi mobil internetdən istifadə edir. Bu, xərc yarada və ya data paketinizi bitirə bilər. + Data xərci bildirişləri diff --git a/controller/app/src/main/res/values-bg/strings.xml b/controller/app/src/main/res/values-bg/strings.xml index ab2a81d53..1b48dcec9 100644 --- a/controller/app/src/main/res/values-bg/strings.xml +++ b/controller/app/src/main/res/values-bg/strings.xml @@ -1337,4 +1337,13 @@ K2Go ограничи необичайна активност K2Go се справи с процес, който използваше твърде много хранилище, и запази системата ви работеща. Докоснете, за да изпратите отчет. Изтегляне на данни за картата + + Изглежда използвате мобилни данни + Това изтегляне използва мобилни данни. Може да има такса или да изчерпи плана ви за данни. Искате ли да продължите? + Продължи с мобилни данни + Не сега + Няма мрежа. Свържете се с Wi-Fi, за да изтеглите. + Изглежда превключихте към мобилни данни + Новите изтегляния вече използват мобилни данни. Това може да има такса или да изчерпи плана ви за данни. + Известия за разход на данни diff --git a/controller/app/src/main/res/values-bn/strings.xml b/controller/app/src/main/res/values-bn/strings.xml index 79a8ffa40..05f199c82 100644 --- a/controller/app/src/main/res/values-bn/strings.xml +++ b/controller/app/src/main/res/values-bn/strings.xml @@ -1343,4 +1343,13 @@ K2Go অস্বাভাবিক কার্যকলাপ নিয়ন্ত্রণ করেছে K2Go অত্যধিক স্টোরেজ ব্যবহার করা একটি প্রক্রিয়া সামলেছে এবং আপনার সিস্টেম চালু রেখেছে। রিপোর্ট পাঠাতে ট্যাপ করুন। মানচিত্রের ডেটা ডাউনলোড হচ্ছে + + মনে হচ্ছে আপনি মোবাইল ডেটা ব্যবহার করছেন + এই ডাউনলোডটি মোবাইল ডেটা ব্যবহার করে। এতে খরচ হতে পারে বা আপনার ডেটা প্ল্যান শেষ হতে পারে। আপনি কি চালিয়ে যেতে চান? + ডেটা দিয়ে চালিয়ে যান + এখন নয় + কোনো নেটওয়ার্ক নেই। ডাউনলোড করতে Wi-Fi এ সংযোগ করুন। + মনে হচ্ছে আপনি মোবাইল ডেটাতে স্যুইচ করেছেন + নতুন ডাউনলোডগুলি এখন মোবাইল ডেটা ব্যবহার করে। এতে খরচ হতে পারে বা আপনার ডেটা প্ল্যান শেষ হতে পারে। + ডেটা খরচের সতর্কতা diff --git a/controller/app/src/main/res/values-cs/strings.xml b/controller/app/src/main/res/values-cs/strings.xml index e5dbc903e..730e67b0c 100644 --- a/controller/app/src/main/res/values-cs/strings.xml +++ b/controller/app/src/main/res/values-cs/strings.xml @@ -1337,4 +1337,13 @@ K2Go zvládl neobvyklou aktivitu K2Go zpracoval proces, který využíval příliš mnoho úložiště, a udržel systém v chodu. Klepnutím odešlete hlášení. Stahování mapových dat + + Zdá se, že používáte mobilní data + Toto stahování používá mobilní data. Může být zpoplatněno nebo vyčerpat váš datový limit. Chcete pokračovat? + Pokračovat na mobilních datech + Teď ne + Žádná síť. Připojte se k Wi-Fi pro stažení. + Zdá se, že jste přepnuli na mobilní data + Nová stahování nyní používají mobilní data. Může být zpoplatněno nebo vyčerpat váš datový limit. + Upozornění na náklady za data diff --git a/controller/app/src/main/res/values-de/strings.xml b/controller/app/src/main/res/values-de/strings.xml index d0a5f84cd..9864d0b55 100644 --- a/controller/app/src/main/res/values-de/strings.xml +++ b/controller/app/src/main/res/values-de/strings.xml @@ -1330,4 +1330,13 @@ K2Go hat ungewöhnliche Aktivität eingedämmt K2Go hat einen Prozess bewältigt, der zu viel Speicher belegte, und Ihr System am Laufen gehalten. Tippen, um einen Bericht zu senden. Kartendaten werden heruntergeladen + + Sie scheinen mobile Daten zu nutzen + Dieser Download nutzt mobile Daten. Das kann Kosten verursachen oder Ihr Datenvolumen aufbrauchen. Möchten Sie fortfahren? + Mit mobilen Daten fortfahren + Nicht jetzt + Kein Netzwerk. Verbinden Sie sich mit WLAN, um herunterzuladen. + Sie scheinen zu mobilen Daten gewechselt zu haben + Neue Downloads nutzen jetzt mobile Daten. Das kann Kosten verursachen oder Ihr Datenvolumen aufbrauchen. + Warnungen zu Datenkosten diff --git a/controller/app/src/main/res/values-el/strings.xml b/controller/app/src/main/res/values-el/strings.xml index d65f4f652..7da76004e 100644 --- a/controller/app/src/main/res/values-el/strings.xml +++ b/controller/app/src/main/res/values-el/strings.xml @@ -1337,4 +1337,13 @@ Το K2Go περιόρισε ασυνήθιστη δραστηριότητα Το K2Go χειρίστηκε μια διεργασία που χρησιμοποιούσε υπερβολικό χώρο και κράτησε το σύστημά σας σε λειτουργία. Πατήστε για αποστολή αναφοράς. Λήψη δεδομένων χάρτη + + Φαίνεται ότι χρησιμοποιείτε δεδομένα κινητής + Αυτή η λήψη χρησιμοποιεί δεδομένα κινητής. Μπορεί να έχει κόστος ή να εξαντλήσει το πρόγραμμα δεδομένων σας. Θέλετε να συνεχίσετε; + Συνέχεια με δεδομένα + Όχι τώρα + Χωρίς δίκτυο. Συνδεθείτε σε Wi-Fi για λήψη. + Φαίνεται ότι μεταβήκατε σε δεδομένα κινητής + Οι νέες λήψεις χρησιμοποιούν τώρα δεδομένα κινητής. Αυτό μπορεί να έχει κόστος ή να εξαντλήσει το πρόγραμμα δεδομένων σας. + Ειδοποιήσεις κόστους δεδομένων diff --git a/controller/app/src/main/res/values-es/strings.xml b/controller/app/src/main/res/values-es/strings.xml index d2515fc75..c47d40fba 100644 --- a/controller/app/src/main/res/values-es/strings.xml +++ b/controller/app/src/main/res/values-es/strings.xml @@ -1406,4 +1406,13 @@ K2Go contuvo actividad inusual K2Go manejó un proceso que usaba demasiado almacenamiento y mantuvo tu sistema funcionando. Toca para enviar un reporte. Descargando datos del mapa + + Parece que estás usando datos móviles + Esta descarga usa datos móviles. Puede tener costo o agotar tu plan de datos. ¿Deseas continuar? + Continuar con datos + Ahora no + Sin red. Conéctate a Wi-Fi para descargar. + Parece que cambiaste a datos móviles + Las nuevas descargas ahora usan datos móviles. Esto puede tener costo o agotar tu plan de datos. + Alertas de costo de datos diff --git a/controller/app/src/main/res/values-fa/strings.xml b/controller/app/src/main/res/values-fa/strings.xml index c955c953a..67a84e288 100644 --- a/controller/app/src/main/res/values-fa/strings.xml +++ b/controller/app/src/main/res/values-fa/strings.xml @@ -1330,4 +1330,13 @@ K2Go فعالیت غیرعادی را مهار کرد K2Go فرایندی را که فضای زیادی مصرف می‌کرد مدیریت کرد و سیستم شما را فعال نگه داشت. برای ارسال گزارش ضربه بزنید. در حال دانلود داده‌های نقشه + + به نظر می‌رسد از داده تلفن همراه استفاده می‌کنید + این دانلود از داده تلفن همراه استفاده می‌کند. ممکن است هزینه داشته باشد یا بسته داده شما را تمام کند. آیا می‌خواهید ادامه دهید؟ + ادامه با داده تلفن همراه + الان نه + شبکه‌ای وجود ندارد. برای دانلود به Wi-Fi متصل شوید. + به نظر می‌رسد به داده تلفن همراه تغییر داده‌اید + دانلودهای جدید اکنون از داده تلفن همراه استفاده می‌کنند. این ممکن است هزینه داشته باشد یا بسته داده شما را تمام کند. + هشدارهای هزینه داده diff --git a/controller/app/src/main/res/values-fr/strings.xml b/controller/app/src/main/res/values-fr/strings.xml index 764bed170..4a174c258 100644 --- a/controller/app/src/main/res/values-fr/strings.xml +++ b/controller/app/src/main/res/values-fr/strings.xml @@ -1417,4 +1417,13 @@ K2Go a contenu une activité inhabituelle K2Go a géré un processus qui utilisait trop de stockage et a gardé votre système en marche. Appuyez pour envoyer un rapport. Téléchargement des données cartographiques + + Vous semblez utiliser les données mobiles + Ce téléchargement utilise les données mobiles. Il peut coûter cher ou épuiser votre forfait de données. Voulez-vous continuer ? + Continuer avec les données + Pas maintenant + Aucun réseau. Connectez-vous au Wi-Fi pour télécharger. + Vous semblez être passé aux données mobiles + Les nouveaux téléchargements utilisent désormais les données mobiles. Cela peut coûter cher ou épuiser votre forfait de données. + Alertes de coût des données diff --git a/controller/app/src/main/res/values-gu/strings.xml b/controller/app/src/main/res/values-gu/strings.xml index 1303442e0..18f946245 100644 --- a/controller/app/src/main/res/values-gu/strings.xml +++ b/controller/app/src/main/res/values-gu/strings.xml @@ -1343,4 +1343,13 @@ K2Go એ અસામાન્ય પ્રવૃત્તિ કાબૂમાં લીધી K2Go એ વધુ પડતું સ્ટોરેજ વાપરતી પ્રક્રિયા સંભાળી અને તમારી સિસ્ટમ ચાલુ રાખી. રિપોર્ટ મોકલવા ટૅપ કરો. નકશા ડેટા ડાઉનલોડ થઈ રહ્યો છે + + એવું લાગે છે કે તમે મોબાઇલ ડેટા વાપરી રહ્યા છો + આ ડાઉનલોડ મોબાઇલ ડેટા વાપરે છે. તેનાથી ખર્ચ થઈ શકે અથવા તમારો ડેટા પ્લાન ખતમ થઈ શકે. શું તમે ચાલુ રાખવા માંગો છો? + ડેટા પર ચાલુ રાખો + હમણાં નહીં + કોઈ નેટવર્ક નથી. ડાઉનલોડ કરવા માટે Wi-Fi સાથે કનેક્ટ કરો. + એવું લાગે છે કે તમે મોબાઇલ ડેટા પર સ્વિચ કર્યું + નવા ડાઉનલોડ હવે મોબાઇલ ડેટા વાપરે છે. તેનાથી ખર્ચ થઈ શકે અથવા તમારો ડેટા પ્લાન ખતમ થઈ શકે. + ડેટા ખર્ચ ચેતવણીઓ diff --git a/controller/app/src/main/res/values-hi/strings.xml b/controller/app/src/main/res/values-hi/strings.xml index 7beb91b0a..352d3acc8 100644 --- a/controller/app/src/main/res/values-hi/strings.xml +++ b/controller/app/src/main/res/values-hi/strings.xml @@ -1407,4 +1407,13 @@ K2Go ने असामान्य गतिविधि को रोका K2Go ने बहुत अधिक स्टोरेज उपयोग कर रही प्रक्रिया को संभाला और आपका सिस्टम चालू रखा। रिपोर्ट भेजने के लिए टैप करें। मानचित्र डेटा डाउनलोड हो रहा है + + ऐसा लगता है कि आप मोबाइल डेटा का उपयोग कर रहे हैं + यह डाउनलोड मोबाइल डेटा का उपयोग करता है. इससे शुल्क लग सकता है या आपका डेटा प्लान खत्म हो सकता है. क्या आप जारी रखना चाहते हैं? + डेटा पर जारी रखें + अभी नहीं + कोई नेटवर्क नहीं. डाउनलोड करने के लिए Wi-Fi से कनेक्ट करें. + ऐसा लगता है कि आप मोबाइल डेटा पर स्विच हो गए + नए डाउनलोड अब मोबाइल डेटा का उपयोग करते हैं. इससे शुल्क लग सकता है या आपका डेटा प्लान खत्म हो सकता है. + डेटा लागत अलर्ट diff --git a/controller/app/src/main/res/values-hu/strings.xml b/controller/app/src/main/res/values-hu/strings.xml index 4b49263ae..2d9a6248e 100644 --- a/controller/app/src/main/res/values-hu/strings.xml +++ b/controller/app/src/main/res/values-hu/strings.xml @@ -1330,4 +1330,13 @@ A K2Go visszafogott egy szokatlan tevékenységet A K2Go kezelt egy túl sok tárhelyet használó folyamatot, és működésben tartotta a rendszert. Koppintson a jelentés küldéséhez. Térképadatok letöltése + + Úgy tűnik, mobiladatot használ + Ez a letöltés mobiladatot használ. Ez költséggel járhat, vagy elfogyaszthatja az adatkeretét. Folytatja? + Folytatás mobiladaton + Most nem + Nincs hálózat. Csatlakozzon Wi-Fi-hez a letöltéshez. + Úgy tűnik, átváltott mobiladatra + Az új letöltések most mobiladatot használnak. Ez költséggel járhat, vagy elfogyaszthatja az adatkeretét. + Adatköltség-figyelmeztetések diff --git a/controller/app/src/main/res/values-in/strings.xml b/controller/app/src/main/res/values-in/strings.xml index c0e35ee23..c18e29326 100644 --- a/controller/app/src/main/res/values-in/strings.xml +++ b/controller/app/src/main/res/values-in/strings.xml @@ -1337,4 +1337,13 @@ K2Go menahan aktivitas tidak biasa K2Go menangani proses yang menggunakan terlalu banyak penyimpanan dan menjaga sistem Anda tetap berjalan. Ketuk untuk mengirim laporan. Mengunduh data peta + + Anda tampaknya menggunakan data seluler + Unduhan ini menggunakan data seluler. Ini dapat menimbulkan biaya atau menghabiskan paket data Anda. Apakah Anda ingin melanjutkan? + Lanjutkan dengan data + Nanti saja + Tidak ada jaringan. Sambungkan ke Wi-Fi untuk mengunduh. + Anda tampaknya beralih ke data seluler + Unduhan baru kini menggunakan data seluler. Ini dapat menimbulkan biaya atau menghabiskan paket data Anda. + Peringatan biaya data diff --git a/controller/app/src/main/res/values-it/strings.xml b/controller/app/src/main/res/values-it/strings.xml index a4c64a66c..e1f48b5da 100644 --- a/controller/app/src/main/res/values-it/strings.xml +++ b/controller/app/src/main/res/values-it/strings.xml @@ -1330,4 +1330,13 @@ K2Go ha contenuto un\'attività insolita K2Go ha gestito un processo che usava troppo spazio e ha mantenuto il sistema attivo. Tocca per inviare un rapporto. Download dei dati della mappa + + Sembra che tu stia usando i dati mobili + Questo download usa i dati mobili. Può comportare costi o esaurire il tuo piano dati. Vuoi continuare? + Continua con i dati + Non ora + Nessuna rete. Connettiti al Wi-Fi per scaricare. + Sembra che tu sia passato ai dati mobili + I nuovi download ora usano i dati mobili. Questo può comportare costi o esaurire il tuo piano dati. + Avvisi sui costi dei dati diff --git a/controller/app/src/main/res/values-ja/strings.xml b/controller/app/src/main/res/values-ja/strings.xml index 2f1b2f359..1568d6224 100644 --- a/controller/app/src/main/res/values-ja/strings.xml +++ b/controller/app/src/main/res/values-ja/strings.xml @@ -1331,4 +1331,13 @@ K2Go が異常な動作を抑制しました K2Go はストレージを過剰に使用していたプロセスを処理し、システムを稼働させ続けました。タップしてレポートを送信します。 地図データをダウンロード中 + + モバイルデータを使用しているようです + このダウンロードはモバイルデータを使用します。料金が発生したり、データプランを使い切ったりする可能性があります。続行しますか? + モバイルデータで続行 + 今はしない + ネットワークがありません。ダウンロードするには Wi-Fi に接続してください。 + モバイルデータに切り替わったようです + 新しいダウンロードはモバイルデータを使用します。料金が発生したり、データプランを使い切ったりする可能性があります。 + データ料金の通知 diff --git a/controller/app/src/main/res/values-ko/strings.xml b/controller/app/src/main/res/values-ko/strings.xml index 9fb772143..64df2f93b 100644 --- a/controller/app/src/main/res/values-ko/strings.xml +++ b/controller/app/src/main/res/values-ko/strings.xml @@ -1331,4 +1331,13 @@ K2Go가 비정상적인 활동을 억제했습니다 K2Go가 저장 공간을 과도하게 사용하던 프로세스를 처리하고 시스템을 계속 실행했습니다. 탭하여 보고서를 보내세요. 지도 데이터 다운로드 중 + + 모바일 데이터를 사용 중인 것 같습니다 + 이 다운로드는 모바일 데이터를 사용합니다. 요금이 발생하거나 데이터 요금제를 모두 사용할 수 있습니다. 계속하시겠습니까? + 데이터로 계속 + 나중에 + 네트워크가 없습니다. 다운로드하려면 Wi-Fi에 연결하세요. + 모바일 데이터로 전환된 것 같습니다 + 새 다운로드가 이제 모바일 데이터를 사용합니다. 요금이 발생하거나 데이터 요금제를 모두 사용할 수 있습니다. + 데이터 요금 알림 diff --git a/controller/app/src/main/res/values-lt/strings.xml b/controller/app/src/main/res/values-lt/strings.xml index a1f7628f0..84719bca2 100644 --- a/controller/app/src/main/res/values-lt/strings.xml +++ b/controller/app/src/main/res/values-lt/strings.xml @@ -1347,4 +1347,13 @@ K2Go suvaldė neįprastą veiklą K2Go sutvarkė procesą, naudojusį per daug saugyklos, ir išlaikė jūsų sistemą veikiančią. Palieskite, kad išsiųstumėte ataskaitą. Atsisiunčiami žemėlapio duomenys + + Panašu, kad naudojate mobiliuosius duomenis + Šis atsisiuntimas naudoja mobiliuosius duomenis. Tai gali kainuoti arba išnaudoti duomenų planą. Ar norite tęsti? + Tęsti su mobiliaisiais duomenimis + Ne dabar + Nėra tinklo. Prisijunkite prie Wi-Fi, kad atsisiųstumėte. + Panašu, kad perjungėte į mobiliuosius duomenis + Nauji atsisiuntimai dabar naudoja mobiliuosius duomenis. Tai gali kainuoti arba išnaudoti duomenų planą. + Duomenų sąnaudų įspėjimai diff --git a/controller/app/src/main/res/values-nl/strings.xml b/controller/app/src/main/res/values-nl/strings.xml index 14fd93a1f..2d38aba91 100644 --- a/controller/app/src/main/res/values-nl/strings.xml +++ b/controller/app/src/main/res/values-nl/strings.xml @@ -1330,4 +1330,13 @@ K2Go heeft ongebruikelijke activiteit ingeperkt K2Go heeft een proces afgehandeld dat te veel opslag gebruikte en uw systeem draaiende gehouden. Tik om een rapport te sturen. Kaartgegevens downloaden + + Je lijkt mobiele data te gebruiken + Deze download gebruikt mobiele data. Dit kan kosten opleveren of je databundel opmaken. Wil je doorgaan? + Doorgaan met mobiele data + Niet nu + Geen netwerk. Maak verbinding met wifi om te downloaden. + Je lijkt overgeschakeld te zijn naar mobiele data + Nieuwe downloads gebruiken nu mobiele data. Dit kan kosten opleveren of je databundel opmaken. + Waarschuwingen datakosten diff --git a/controller/app/src/main/res/values-no/strings.xml b/controller/app/src/main/res/values-no/strings.xml index c4121d69e..6d0085fdb 100644 --- a/controller/app/src/main/res/values-no/strings.xml +++ b/controller/app/src/main/res/values-no/strings.xml @@ -1340,4 +1340,13 @@ K2Go begrenset uvanlig aktivitet K2Go håndterte en prosess som brukte for mye lagringsplass, og holdt systemet i gang. Trykk for å sende en rapport. Laster ned kartdata + + Det ser ut til at du bruker mobildata + Denne nedlastingen bruker mobildata. Det kan koste penger eller bruke opp datapakken din. Vil du fortsette? + Fortsett med mobildata + Ikke nå + Ingen nettverk. Koble til Wi-Fi for å laste ned. + Det ser ut til at du byttet til mobildata + Nye nedlastinger bruker nå mobildata. Det kan koste penger eller bruke opp datapakken din. + Varsler om datakostnad diff --git a/controller/app/src/main/res/values-pl/strings.xml b/controller/app/src/main/res/values-pl/strings.xml index 276baaf1e..eae4c07ca 100644 --- a/controller/app/src/main/res/values-pl/strings.xml +++ b/controller/app/src/main/res/values-pl/strings.xml @@ -1340,4 +1340,13 @@ K2Go opanował nietypową aktywność K2Go obsłużył proces zużywający zbyt dużo pamięci i utrzymał system w działaniu. Dotknij, aby wysłać raport. Pobieranie danych mapy + + Wygląda na to, że używasz danych komórkowych + To pobieranie używa danych komórkowych. Może wiązać się z kosztami lub wyczerpać pakiet danych. Czy chcesz kontynuować? + Kontynuuj na danych + Nie teraz + Brak sieci. Połącz się z Wi-Fi, aby pobrać. + Wygląda na to, że przełączono na dane komórkowe + Nowe pobierania używają teraz danych komórkowych. Może to wiązać się z kosztami lub wyczerpać pakiet danych. + Alerty o kosztach danych diff --git a/controller/app/src/main/res/values-pt/strings.xml b/controller/app/src/main/res/values-pt/strings.xml index 75414b0f7..3f00a4f25 100644 --- a/controller/app/src/main/res/values-pt/strings.xml +++ b/controller/app/src/main/res/values-pt/strings.xml @@ -1409,4 +1409,13 @@ O K2Go conteve atividade incomum O K2Go tratou um processo que usava armazenamento demais e manteve seu sistema funcionando. Toque para enviar um relatório. A transferir dados do mapa + + Parece que você está usando dados móveis + Este download usa dados móveis. Pode gerar custos ou esgotar seu plano de dados. Deseja continuar? + Continuar com dados + Agora não + Sem rede. Conecte-se ao Wi-Fi para baixar. + Parece que você mudou para dados móveis + Os novos downloads agora usam dados móveis. Isso pode gerar custos ou esgotar seu plano de dados. + Alertas de custo de dados diff --git a/controller/app/src/main/res/values-ro/strings.xml b/controller/app/src/main/res/values-ro/strings.xml index 24b23e779..7a5cdc367 100644 --- a/controller/app/src/main/res/values-ro/strings.xml +++ b/controller/app/src/main/res/values-ro/strings.xml @@ -1330,4 +1330,13 @@ K2Go a limitat o activitate neobișnuită K2Go a gestionat un proces care folosea prea mult spațiu și a menținut sistemul în funcțiune. Atinge pentru a trimite un raport. Se descarcă datele hărții + + Se pare că folosești date mobile + Această descărcare folosește date mobile. Poate genera costuri sau îți poate epuiza planul de date. Vrei să continui? + Continuă pe date mobile + Nu acum + Fără rețea. Conectează-te la Wi-Fi pentru a descărca. + Se pare că ai trecut la date mobile + Descărcările noi folosesc acum date mobile. Acest lucru poate genera costuri sau îți poate epuiza planul de date. + Alerte privind costul datelor diff --git a/controller/app/src/main/res/values-ru-rRU/strings.xml b/controller/app/src/main/res/values-ru-rRU/strings.xml index 2edac914c..dd7844e15 100644 --- a/controller/app/src/main/res/values-ru-rRU/strings.xml +++ b/controller/app/src/main/res/values-ru-rRU/strings.xml @@ -1406,4 +1406,13 @@ K2Go сдержал необычную активность K2Go обработал процесс, использовавший слишком много памяти, и сохранил работу системы. Нажмите, чтобы отправить отчёт. Загрузка данных карты + + Похоже, вы используете мобильные данные + Эта загрузка использует мобильные данные. Это может повлечь расходы или исчерпать пакет данных. Продолжить? + Продолжить на мобильных данных + Не сейчас + Нет сети. Подключитесь к Wi-Fi для загрузки. + Похоже, вы переключились на мобильные данные + Новые загрузки теперь используют мобильные данные. Это может повлечь расходы или исчерпать пакет данных. + Оповещения о расходе данных diff --git a/controller/app/src/main/res/values-sk/strings.xml b/controller/app/src/main/res/values-sk/strings.xml index d7ea4fc76..0ae2135e4 100644 --- a/controller/app/src/main/res/values-sk/strings.xml +++ b/controller/app/src/main/res/values-sk/strings.xml @@ -1337,4 +1337,13 @@ K2Go zvládol neobvyklú aktivitu K2Go spracoval proces, ktorý využíval priveľa úložiska, a udržal systém v chode. Klepnutím odošlite hlásenie. Sťahovanie údajov mapy + + Zdá sa, že používate mobilné dáta + Toto sťahovanie používa mobilné dáta. Môže byť spoplatnené alebo vyčerpať váš dátový limit. Chcete pokračovať? + Pokračovať na mobilných dátach + Teraz nie + Žiadna sieť. Pripojte sa k Wi-Fi na stiahnutie. + Zdá sa, že ste sa prepli na mobilné dáta + Nové sťahovania teraz používajú mobilné dáta. Môže byť spoplatnené alebo vyčerpať váš dátový limit. + Upozornenia na náklady za dáta diff --git a/controller/app/src/main/res/values-sr/strings.xml b/controller/app/src/main/res/values-sr/strings.xml index 3a5f09539..38d91d886 100644 --- a/controller/app/src/main/res/values-sr/strings.xml +++ b/controller/app/src/main/res/values-sr/strings.xml @@ -1343,4 +1343,13 @@ K2Go је обуздао необичну активност K2Go је обрадио процес који је користио превише простора и одржао систем у раду. Додирните да пошаљете извештај. Преузимање података мапе + + Изгледа да користите мобилне податке + Ово преузимање користи мобилне податке. Може да има трошак или да потроши ваш план за податке. Желите ли да наставите? + Настави на мобилним подацима + Не сада + Нема мреже. Повежите се на Wi-Fi да бисте преузели. + Изгледа да сте прешли на мобилне податке + Нова преузимања сада користе мобилне податке. Ово може да има трошак или да потроши ваш план за податке. + Обавештења о трошку података diff --git a/controller/app/src/main/res/values-sw/strings.xml b/controller/app/src/main/res/values-sw/strings.xml index c7041d488..52aa2e5f8 100644 --- a/controller/app/src/main/res/values-sw/strings.xml +++ b/controller/app/src/main/res/values-sw/strings.xml @@ -1350,4 +1350,13 @@ K2Go ilidhibiti shughuli isiyo ya kawaida K2Go ilishughulikia mchakato uliokuwa ukitumia hifadhi nyingi mno na kuweka mfumo wako ukiendelea kufanya kazi. Gusa kutuma ripoti. Inapakua data ya ramani + + Inaonekana unatumia data ya simu + Upakuaji huu unatumia data ya simu. Inaweza kugharimu au kumaliza kifurushi chako cha data. Ungependa kuendelea? + Endelea kwa data + Si sasa + Hakuna mtandao. Unganisha kwa Wi-Fi ili kupakua. + Inaonekana umebadili hadi data ya simu + Vipakuliwa vipya sasa vinatumia data ya simu. Hii inaweza kugharimu au kumaliza kifurushi chako cha data. + Arifa za gharama ya data diff --git a/controller/app/src/main/res/values-ta/strings.xml b/controller/app/src/main/res/values-ta/strings.xml index 49d11ad4d..2fc518bb7 100644 --- a/controller/app/src/main/res/values-ta/strings.xml +++ b/controller/app/src/main/res/values-ta/strings.xml @@ -1350,4 +1350,13 @@ K2Go அசாதாரண செயல்பாட்டைக் கட்டுப்படுத்தியது அதிக சேமிப்பைப் பயன்படுத்திய ஒரு செயல்முறையை K2Go கையாண்டு உங்கள் கணினியை இயங்க வைத்தது. அறிக்கை அனுப்ப தட்டவும். வரைபடத் தரவைப் பதிவிறக்குகிறது + + நீங்கள் மொபைல் டேட்டாவைப் பயன்படுத்துவது போல் தெரிகிறது + இந்தப் பதிவிறக்கம் மொபைல் டேட்டாவைப் பயன்படுத்துகிறது. இதனால் கட்டணம் ஏற்படலாம் அல்லது உங்கள் தரவுத் திட்டம் தீர்ந்துபோகலாம். தொடர விரும்புகிறீர்களா? + டேட்டாவில் தொடரவும் + இப்போது வேண்டாம் + நெட்வொர்க் இல்லை. பதிவிறக்க Wi-Fi உடன் இணைக்கவும். + நீங்கள் மொபைல் டேட்டாவிற்கு மாறியது போல் தெரிகிறது + புதிய பதிவிறக்கங்கள் இப்போது மொபைல் டேட்டாவைப் பயன்படுத்துகின்றன. இதனால் கட்டணம் ஏற்படலாம் அல்லது உங்கள் தரவுத் திட்டம் தீர்ந்துபோகலாம். + டேட்டா செலவு எச்சரிக்கைகள் diff --git a/controller/app/src/main/res/values-tr/strings.xml b/controller/app/src/main/res/values-tr/strings.xml index 1fae40528..6cc3e9c6f 100644 --- a/controller/app/src/main/res/values-tr/strings.xml +++ b/controller/app/src/main/res/values-tr/strings.xml @@ -1330,4 +1330,13 @@ K2Go olağan dışı bir etkinliği kontrol altına aldı K2Go çok fazla depolama kullanan bir işlemi yönetti ve sisteminizi çalışır durumda tuttu. Rapor göndermek için dokunun. Harita verileri indiriliyor + + Mobil veri kullanıyor gibisiniz + Bu indirme mobil veri kullanıyor. Ücretlendirilebilir veya veri paketinizi tüketebilir. Devam etmek istiyor musunuz? + Mobil veriyle devam et + Şimdi değil + Ağ yok. İndirmek için Wi-Fi\'ye bağlanın. + Mobil veriye geçmiş gibisiniz + Yeni indirmeler artık mobil veri kullanıyor. Bu, ücretlendirilebilir veya veri paketinizi tüketebilir. + Veri maliyeti uyarıları diff --git a/controller/app/src/main/res/values-uk/strings.xml b/controller/app/src/main/res/values-uk/strings.xml index 9830fba16..7a1e4fff1 100644 --- a/controller/app/src/main/res/values-uk/strings.xml +++ b/controller/app/src/main/res/values-uk/strings.xml @@ -1330,4 +1330,13 @@ K2Go стримав незвичну активність K2Go опрацював процес, що використовував забагато сховища, і зберіг роботу системи. Натисніть, щоб надіслати звіт. Завантаження даних карти + + Схоже, ви використовуєте мобільні дані + Це завантаження використовує мобільні дані. Це може призвести до витрат або вичерпати пакет даних. Продовжити? + Продовжити на мобільних даних + Не зараз + Немає мережі. Підключіться до Wi-Fi, щоб завантажити. + Схоже, ви перейшли на мобільні дані + Нові завантаження тепер використовують мобільні дані. Це може призвести до витрат або вичерпати пакет даних. + Сповіщення про витрати даних diff --git a/controller/app/src/main/res/values-vi/strings.xml b/controller/app/src/main/res/values-vi/strings.xml index 94cb19c32..46c4027b1 100644 --- a/controller/app/src/main/res/values-vi/strings.xml +++ b/controller/app/src/main/res/values-vi/strings.xml @@ -1330,4 +1330,13 @@ K2Go đã kiểm soát hoạt động bất thường K2Go đã xử lý một tiến trình sử dụng quá nhiều bộ nhớ và giữ cho hệ thống hoạt động. Nhấn để gửi báo cáo. Đang tải dữ liệu bản đồ + + Có vẻ như bạn đang dùng dữ liệu di động + Bản tải xuống này dùng dữ liệu di động. Có thể phát sinh chi phí hoặc dùng hết gói dữ liệu của bạn. Bạn có muốn tiếp tục không? + Tiếp tục bằng dữ liệu + Để sau + Không có mạng. Kết nối Wi-Fi để tải xuống. + Có vẻ như bạn đã chuyển sang dữ liệu di động + Các bản tải xuống mới hiện dùng dữ liệu di động. Điều này có thể phát sinh chi phí hoặc dùng hết gói dữ liệu của bạn. + Cảnh báo chi phí dữ liệu diff --git a/controller/app/src/main/res/values-yo/strings.xml b/controller/app/src/main/res/values-yo/strings.xml index 010bb7ecf..79be5ff31 100644 --- a/controller/app/src/main/res/values-yo/strings.xml +++ b/controller/app/src/main/res/values-yo/strings.xml @@ -1350,4 +1350,13 @@ K2Go ṣàkóso iṣẹ́ àìṣàmúlò K2Go ṣàkóso ìlànà kan tí ó ń lo ibi ìsàvè púpọ̀ jù ó sì jẹ́ kí ètò rẹ máa ṣiṣẹ́. Tẹ̀ láti fi ìròyìn ránṣẹ́. Ń gba data maapu wọlé + + O dabi pe o n lo data alagbeka + Igbasilẹ yii n lo data alagbeka. O le san owo tabi lo package data rẹ tan. Ṣe o fẹ tẹsiwaju? + Tẹsiwaju pẹlu data + Kii ṣe bayii + Ko si nẹtiwọọki. Sopọ si Wi-Fi lati gbaa wọle. + O dabi pe o ti yipada si data alagbeka + Awọn igbasilẹ tuntun n lo data alagbeka bayii. Eyi le san owo tabi lo package data rẹ tan. + Awọn ikilọ iye owo data diff --git a/controller/app/src/main/res/values-zh-rCN/strings.xml b/controller/app/src/main/res/values-zh-rCN/strings.xml index d7ae2d12b..d9a6d7422 100644 --- a/controller/app/src/main/res/values-zh-rCN/strings.xml +++ b/controller/app/src/main/res/values-zh-rCN/strings.xml @@ -1331,4 +1331,13 @@ K2Go 已控制异常活动 K2Go 处理了一个占用过多存储的进程,并保持系统运行。点按以发送报告。 正在下载地图数据 + + 您似乎正在使用移动数据 + 此下载会使用移动数据。可能产生费用或用尽您的套餐流量。是否继续? + 使用数据继续 + 暂不 + 无网络。请连接 Wi-Fi 以下载。 + 您似乎已切换到移动数据 + 新的下载现在会使用移动数据。这可能产生费用或用尽您的套餐流量。 + 数据费用提醒 diff --git a/controller/app/src/main/res/values/strings.xml b/controller/app/src/main/res/values/strings.xml index 29f3a2ef7..c95505b0f 100644 --- a/controller/app/src/main/res/values/strings.xml +++ b/controller/app/src/main/res/values/strings.xml @@ -1514,4 +1514,13 @@ K2Go contained unusual activity K2Go handled a process that was using too much storage and kept your system running. Tap to send a report. Downloading map data + + You seem to be on mobile data + This download uses mobile data. It can add cost or use up your data plan. Do you want to continue? + Continue on data + Not now + No network. Connect to Wi-Fi to download. + You seem to have switched to mobile data + New downloads now use mobile data. This can add cost or use up your data plan. + Data cost alerts diff --git a/controller/app/src/main/res/values/strings_untranslated.xml b/controller/app/src/main/res/values/strings_untranslated.xml index 3fbeeeb20..e924a6196 100644 --- a/controller/app/src/main/res/values/strings_untranslated.xml +++ b/controller/app/src/main/res/values/strings_untranslated.xml @@ -5,12 +5,13 @@ This is the tracking list for strings that are user-facing but NOT yet translated to the 33 locales. They carry translatable="false" HERE (so lint's MissingTranslation is satisfied and the debt is visible in one place) instead of being buried inline in strings.xml. When a string is - translated, migrate it to strings.xml (or its feature file) with all 33 locale values and drop the - entry here. + translated, migrate it to strings.xml with all locale values and drop the entry here. This is the + ONLY external string file. There are no per-feature string files; everything else lives in + strings.xml. --> - - + diff --git a/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkClassTest.java b/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkClassTest.java new file mode 100644 index 000000000..4b058eda6 --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkClassTest.java @@ -0,0 +1,30 @@ +package org.appdevforall.k2go.networkpolicy.domain; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * Pure-JVM tests for the cost-class mapping. The cases mirror the device + * evidence in ADR-395: Wi-Fi "HIKVISION" carried NOT_METERED; the "Bienestar" + * LTE internet APN did not; the cellular IMS PDN carried NOT_METERED but no + * INTERNET, so it never becomes the internet-bearing default that gets classified. + */ +public class NetworkClassTest { + + @Test + public void wifiUnmetered_isUnmetered() { + assertEquals(NetworkClass.UNMETERED, NetworkClass.from(true, true)); + } + + @Test + public void cellularInternetApn_isMetered() { + assertEquals(NetworkClass.METERED, NetworkClass.from(true, false)); + } + + @Test + public void noInternet_isNone_whateverTheMeteredFlag() { + assertEquals(NetworkClass.NONE, NetworkClass.from(false, false)); + assertEquals(NetworkClass.NONE, NetworkClass.from(false, true)); + } +} diff --git a/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicyTest.java b/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicyTest.java new file mode 100644 index 000000000..fe8bb76c4 --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkPolicyTest.java @@ -0,0 +1,33 @@ +package org.appdevforall.k2go.networkpolicy.domain; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** Pure-JVM tests for the heavy-start decision table. No Android, no network. */ +public class NetworkPolicyTest { + + private final NetworkPolicy policy = new NetworkPolicy(); + + @Test + public void unmetered_alwaysAllows() { + assertEquals(NetworkPolicyDecision.ALLOW, policy.decideHeavyStart(NetworkClass.UNMETERED, false)); + assertEquals(NetworkPolicyDecision.ALLOW, policy.decideHeavyStart(NetworkClass.UNMETERED, true)); + } + + @Test + public void metered_withoutConsent_needsConsent() { + assertEquals(NetworkPolicyDecision.NEEDS_CONSENT, policy.decideHeavyStart(NetworkClass.METERED, false)); + } + + @Test + public void metered_withConsent_allows() { + assertEquals(NetworkPolicyDecision.ALLOW, policy.decideHeavyStart(NetworkClass.METERED, true)); + } + + @Test + public void noNetwork_blocks_regardlessOfConsent() { + assertEquals(NetworkPolicyDecision.BLOCKED_NO_NETWORK, policy.decideHeavyStart(NetworkClass.NONE, false)); + assertEquals(NetworkPolicyDecision.BLOCKED_NO_NETWORK, policy.decideHeavyStart(NetworkClass.NONE, true)); + } +} diff --git a/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkTransitionTest.java b/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkTransitionTest.java new file mode 100644 index 000000000..13cbeaaa3 --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/networkpolicy/domain/NetworkTransitionTest.java @@ -0,0 +1,30 @@ +package org.appdevforall.k2go.networkpolicy.domain; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** Pure-JVM tests for the "warn on becoming metered" edge rule. */ +public class NetworkTransitionTest { + + @Test + public void unmeteredToMetered_warns() { + assertTrue(NetworkTransition.shouldWarn(NetworkClass.UNMETERED, NetworkClass.METERED)); + } + + @Test + public void noneToMetered_warns() { + assertTrue(NetworkTransition.shouldWarn(NetworkClass.NONE, NetworkClass.METERED)); + } + + @Test + public void meteredToMetered_doesNotWarn() { + assertFalse(NetworkTransition.shouldWarn(NetworkClass.METERED, NetworkClass.METERED)); + } + + @Test + public void meteredToUnmetered_doesNotWarn() { + assertFalse(NetworkTransition.shouldWarn(NetworkClass.METERED, NetworkClass.UNMETERED)); + } +} diff --git a/controller/docs/ADR-395-network-cost-consent.md b/controller/docs/ADR-395-network-cost-consent.md new file mode 100644 index 000000000..1ec8a63ea --- /dev/null +++ b/controller/docs/ADR-395-network-cost-consent.md @@ -0,0 +1,357 @@ +# ADR-395 -- Network cost consent (metered-data gate) + +- Status: Proposed +- Date: 2026-09-06 +- Ticket: K2GO-395 (Relates to K2GO-4 "Resilient download contract") +- Author: AppDevForAll + +## 1. Context + +K2Go downloads very heavy content: the rootfs image is ~2-4 GB, ZIM files reach +tens to hundreds of GB, maps reach tens of GB. A user on a metered link (mobile +data, or a metered Wi-Fi hotspot) can spend real money or burn a monthly data +plan with a single tap, with no warning today. + +K2GO-4 already makes downloads **resilient** -- they survive a network change +mid-download (Wi-Fi to mobile) by pause/resume/reconnect. Resilience is not the +same fact as **cost consent**. Surviving a switch to mobile data is not the same +as asking permission to spend it. This ADR defines the cost-consent mechanism. +There is no ticket and no code for it today (verified: a repo-wide search for +`isActiveNetworkMetered`, `NOT_METERED`, `setAllowedOverMetered` returns zero +hits). + +### 1.1 The three-process reality (why this is not a socket problem) + +Egress does not come from one place. It comes from three, and only some are +under the app's control: + +| What is downloaded | Which process pulls the bytes | App can throttle the socket? | +|---|---|---| +| Rootfs tarball + proot Debian base (GB) | on-device `aria2` (`libaria2c.so`) | Yes (own process) | +| ZIM / Books / Maps / Kolibri content (GB) | the in-proot server (dash-node); device only POSTs + polls | **No** -- not the app's socket | +| OTA APK, portal APK/PDF | Android system `DownloadManager` | Via its own API | +| Manifests, `.meta4` size, catalog ETags (KB) | app process (`HttpURLConnection`) | Yes | + +The key consequence: for content (the biggest cost driver) the app **cannot** +make the transfer "Wi-Fi only" at the network layer, because the in-proot server +holds the socket, not the app. The only honest lever is to **not authorize the +job to start** (gate before the POST), and to cancel/pause it through the +existing REST cancel if the user asks. + +### 1.2 The primitive is metered, not "cellular vs Wi-Fi" + +The user goal is "do not spend costed data without asking". The correct signal +is therefore **metered vs not-metered**, not transport. Two facts force this: + +- A phone hotspot is Wi-Fi but costs data. "Wi-Fi" does not mean "free". +- On real hardware "cellular" is not one network. See the device evidence + (Section 6): the carrier's IMS PDN reports NOT_METERED while its internet APN + does not. Keying on `TRANSPORT_CELLULAR` would misjudge cost. + +So the rule keys on the **active default network's** `NET_CAPABILITY_NOT_METERED`. + +## 2. Decision + +Add one **cost-consent gate** consulted at the START of every heavy download, +plus one process-wide **metered observer** for the proactive alert. Defensive by +design: do not start anything costly on a metered link without consent; do not +attempt fine control of a transfer already in flight (the app cannot). + +### 2.1 Behavior + +1. **Start gate.** Before a heavy download starts, classify the active default + network. If unmetered -> proceed. If metered and the user has not consented + this session -> ask ("You are on mobile data ... continue?"). If they decline, + do not start. If no network -> tell them they are offline. Once consent is + given, it holds for the session: 1 KB or 5 GB, it does not ask again. +2. **Proactive alert.** If the default network crosses INTO metered while the app + runs -- even with nothing pending -- post a notification so the user knows + further activity spends data. +3. **In flight = best effort only.** A transfer already running on a link the app + does not own is left alone. Resilience (K2GO-4) means it can pause/resume, but + this ADR does not add fine control. Offering "cancel or continue" on such a + transfer is a possible follow-up, not part of this contract. + +### 2.2 One source per fact (anti-duplication ledger) + +| Fact | Existing owner | This design | +|---|---|---| +| "network changed" | `sync/transport/NetworkStateLiveData` (the only default-network callback) | REUSED via `observeForever`; no second registration | +| "what is the network" | `DashboardRebuild.hasInternet`, `InstallService.hasValidatedInternet` (two readers today) | New `AndroidNetworkClassifier` becomes the one reader; fold the two existing ones into it as a follow-up | +| "is a heavy transfer running" | `ContentDownloadSession`, `InstallProgressRepository` | READ if needed; never duplicated | +| "did the user consent to spend data" | none (new fact) | `SessionMeteredConsentStore` (in-memory) | + +### 2.3 Lifecycle of the consent grant + +- **Standing preference** (a future "Wi-Fi only" toggle) would be persisted, + default on. Not built yet; the gate already behaves as if it is on. +- **Session grant** is ephemeral, in memory. Set by the consent dialog. Cleared + by the observer when the network leaves metered, and by process death (the only + store keeps it in memory). A persisted "always allow" is deliberately NOT + offered: it would defeat cost awareness and would be the stuck-marker + anti-pattern (a persisted flag nobody clears). +- **If the process dies mid-metered-download**: on restart the grant is gone; if + still metered and a transfer wants to resume, the gate asks again. No stuck + state. + +## 3. Design (layered feature `networkpolicy`) + +New self-contained feature package `org.appdevforall.k2go.networkpolicy`, wired +by hand (no DI), placed beside the existing `network` (DNS) feature. + +``` +networkpolicy/ + domain/ NetworkClass, NetworkPolicyDecision, NetworkPolicy, + NetworkTransition, MeteredConsentStore (pure JVM, unit-tested) + data/ AndroidNetworkClassifier (the one ConnectivityManager reader), + SessionMeteredConsentStore (in-memory grant) + presentation/ NetworkPolicyGate (stateless start gate + consent dialog), + MeteredNetworkObserver (process-wide alert + grant lifecycle) +``` + +- `NetworkPolicyGate` is stateless, in the style of `OpReturnNavigator`: it owns + no "is metered" flag; it reads the live class and the session grant and returns + a decision. +- `MeteredNetworkObserver` is one process-scoped owner started from + `IIABApplication`, in the style of `ServerLifecycleReconciler`. + +## 4. Seams (where the gate is consulted) + +Each content family has a `*ConfirmFragment` with a Start/Add button whose click +is the user commit point. The gate wraps THAT click, never the background +`*Provisioner.drain` (it runs every ~2 s to re-hand an already-authorized +wishlist and would re-prompt). Every confirm fragment has the same shape as the +reference (`ZimConfirmFragment`), so each remaining seam is a one-line wrap. + +| Family | Commit-point seam (exact) | Wrap | +|---|---|---| +| ZIM | `ZimConfirmFragment.java:105-110` -> `a.startZimDownload()` | LANDED (reference) | +| Books | `BooksConfirmFragment.java:82` -> `a.startBooksDownload()` | `guardHeavyStart(a, a::startBooksDownload)` | +| Kolibri | `KolibriConfirmFragment.java:227` -> `startLive(chosen)` | `guardHeavyStart(requireActivity(), () -> startLive(chosen))` | +| Maps (Get More, post-install) | `SetupLibraryActivity.openMapsIndex` (the `MapsConfirmFragment` live `else` branch, `MapsConfirmFragment.java:103`) | LANDED -- `guardHeavyStart` inside the `InstallConfirm.gate` body. Base layers pre-download over REST before the runrole: K2GO-394 moved the maps bytes onto dash-node (`InstallService.downloadMapsBasemapsThenRun` -> `RestContentClient("basemaps")`), so it IS a costed heavy start. NOT gated at `MapsProvisioner.drain`: that drain is a serialized proot stage where a refusal is TERMINAL (`SetupProgressActivity` retires it as `mapsStartFailed`, by design, to avoid an unexplained spinner), so a cost-hold does not fit there without an orchestrator "waiting for network" state -- that headless integration rides with the install/rootfs PR. | +| Dashboard update/install (LIVE) | `DashboardRebuild.start` -> `startRest` (`DashboardRebuild.java:96`) | LANDED -- `guardHeavyStart` on the LIVE branch only. dash-node >= 1.2.0 git-fetches + blue-green rebuilds over REST across the default network. 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. | +| Wizard/system-install maps + rootfs/modules install | `mapsWizardConfirm`; `InstallService` from the wizard | DEFERRED to the install/rootfs PR ("el install va aparte con el rootfs"). Rootfs is aria2 (not REST); the wizard maps download rides with it. | + +Dashboard and Get-More-Maps are user-driven Operations, not banked `ContentType`s, +so they gate at the UI commit (like FQR), NOT through `ContentAdmission` -- +`ContentAdmission` already defers TO a dashboard update and to maps, so routing +them back through it would be circular. + +DownloadManager seams differ -- no `Service.start`; the app enqueues and the +system transfers. Consult the gate first, then honor the decision (proceed on +consent, or set `setAllowedOverMetered(false)` as the fallback): + +| Path | Enqueue site | +|---|---| +| OTA APK | `UpdateController.java:210-223` | +| Portal APK | `PortalActivity.java:464-477` | +| Portal PDF / other box file | `PortalActivity.java:502-513` | + +### 4.1 Recipe (content seam) + +Replace `X.startYDownload()` at the commit click with +`NetworkPolicyGate.guardHeavyStart(activity, activity::startYDownload)`, where +`activity` is the hosting Activity (the consent dialog needs an Activity context). +A seam with no Activity (a pure background start) cannot show the dialog -- but +those are post-authorization drains, correctly left ungated. + +### 4.2 Recipe (DownloadManager seam) + +Before `dm.enqueue(request)`: classify with `AndroidNetworkClassifier`. If metered +and not consented, either ask via the gate or set +`request.setAllowedOverMetered(false)` so the system holds it for Wi-Fi. If +unmetered or already consented, enqueue as today. + +### 4.3 Native Kolibri import (WebView interception) + +A distinct egress the app does NOT own: Kolibri's OWN web app (served by the box at +`/kolibri/`, shown in the PortalActivity WebView) has its own content-import +manager. Tapping Import there starts a server-side download from Kolibri Studio +over the metered link, bypassing Get More / ContentAdmission entirely. Device recon +(reproduced on the OnePlus over a metered hotspot: a 265 MB import began downloading +with no prompt) identified the trigger: `POST /api/tasks/tasks/` (cancel is +`POST /api/tasks/tasks//cancel/`; the queue polls +`GET /api/tasks/tasks/?queue=content`). + +`KolibriGuardController` gates it, mirroring `FqrController`/`KiwixManageController`: +armed on the `/kolibri/` page, it injects JS that wraps XHR (axios) and fetch, +parks a remote-import task POST, calls the native bridge (`K2GoKolibri.gateImport`) +which runs `NetworkPolicyGate.guardHeavyStart`, and proceeds or aborts on the +result. Caveats (from the code-review second pass): it depends on Kolibri's internal +task API, so it fails OPEN (never bricks import) and logs a console warning on any +task POST it cannot classify (the regression signal); the remote-vs-local decision +is a body heuristic (`remote`/`channelupdate`, not `disk`) pending a task-type-field +match. This is the one seam that reaches into a third-party app's egress, so it is +inherently best-effort. + +## 5. Reference implementation status (this change) + +Landed as a compiling, tested starting point for the implementer: + +- Domain, pure JVM, unit-tested: `NetworkClass`, `NetworkPolicy`, + `NetworkTransition`, `NetworkPolicyDecision`, `MeteredConsentStore` + (`NetworkPolicyTest`, `NetworkClassTest`, `NetworkTransitionTest` -- green). +- Data: `AndroidNetworkClassifier`, `SessionMeteredConsentStore`, + `NetworkCostAdmission` (the one classify + consent + policy decision source). +- Presentation: `NetworkPolicyGate` (the UI prompt, + `BrandDialog`), + `MeteredNetworkObserver`. +- Enforcement (headless): `ContentAdmission.canStart` defers when + `NetworkCostAdmission.allowsHeavyStartNow` is false, so the ZIM, Books and + Kolibri drains all HOLD a banked order on metered-without-consent (sec.10). +- Wiring: observer started in `IIABApplication`; the ZIM and Books commit points + (`SetupLibraryActivity.startZimDownload` / `startBooksDownload`) bank first, then + gate the drain, so a declined/offline order is queued, not lost. +- Strings translated to all 33 locales (machine-generated, pending human review), + folded into `values*/strings.xml` (one string file; no per-feature file); + `strings_untranslated.xml` is clear. + +Done since: the Books and Kolibri commit-point prompts, the FQR maps-region seam +(sec.4.1), the native-Kolibri import gate (sec.4.3), the dashboard LIVE update/install +gate and the Get-More base-Maps gate (sec.4), all device-verified on a metered hotspot +except the last two (pending a device pass). Every REST-heavy egress the user can +trigger on a live box now routes through the gate: ZIM, Books, Kolibri (Get More + +native), FQR regions, dashboard live update, and Get-More base maps. + +Remaining, DEFERRED to the install/rootfs PR ("el install va aparte con el rootfs"): +the wizard/system-install maps path (`mapsWizardConfirm`) and its headless hold with +an orchestrator "waiting for network" state; the rootfs-image install (aria2, not +REST). Separate follow-ups: the DownloadManager seam (OTA / portal, +`setAllowedOverMetered`) and the two-`hasInternet` fold into `AndroidNetworkClassifier`. +(l10n done pending human review.) + +## 6. Device evidence appendix (dark surfaces flattened) + +Measured on Samsung SM-A165M (`RF8Y80CE2DA`), Android 15, SIM "Bienestar" LTE +(25 GB plan), via `adb shell svc wifi disable/enable` + `dumpsys connectivity`. + +| Network | Transport | Has NOT_METERED? | Has INTERNET? | Classifier verdict | +|---|---|---|---|---| +| Wi-Fi "HIKVISION_B7FD" (home router) | WIFI | Yes (Metered hint: false) | Yes | UNMETERED | +| Cellular internet APN (rmnet1, default when Wi-Fi off) | CELLULAR | **No** | Yes | **METERED** | +| Cellular IMS PDN | CELLULAR | Yes | No (IMS only) | never the internet default | +| Phone hotspot "Galaxy A16 4AEC" (Samsung tether), seen by an OPPO CPH2557 client | WIFI | **No** (Metered hint: true) | Yes | **METERED** | + +Conclusions, now empirical, not assumed: + +1. Wi-Fi here is unmetered; the LTE internet APN is metered. The classifier rule + (`NOT_METERED` on the active default) produces the right verdict for both. +2. "Cellular" is not monolithic: the IMS PDN carries NOT_METERED. A + transport-based rule would have called the whole radio unmetered and leaked + the plan. This is the concrete reason the rule keys on metered, not transport. +3. The active default flips correctly on Wi-Fi toggle (Wi-Fi network id when on; + cellular id when off), so `getActiveNetwork()` is the right anchor. +4. A phone hotspot CAN be flagged metered natively: the Samsung A16 tether + advertised the metered bit and the OPPO client's Wi-Fi network dropped + NOT_METERED (Metered hint: true). The classifier read METERED with no special + case -- the "Wi-Fi is not always free" case is caught by the same rule. Caveat: + this is OEM/AP-dependent. A router or hotspot that does not advertise the bit, + or a user who marks the Wi-Fi "unmetered", will read UNMETERED; the manual + override and the proactive alert exist for exactly that residual gap. + +## 7. Test protocol (remaining dark surfaces) + +Run before shipping the full contract: + +1. **Phone-hotspot metered detection (MEASURED -- Section 6, row 4).** Samsung A16 + tether -> OPPO CPH2557 client: the client's Wi-Fi network had Metered hint: + true and no NOT_METERED, so the classifier read METERED with no special case. + The residual case to keep in mind: an AP/router that does not advertise the + metered bit, or a user who marks the Wi-Fi "unmetered", reads UNMETERED -- + covered by the manual override (future toggle) and the proactive alert, not by + auto-detection. Re-run with other AP brands as they appear. +2. **Premise: server content download consumes the SIM.** With the box up and the + device on cellular, start a small ZIM and watch `/proc/net/dev` `rmnet` bytes + climb. Architecturally certain (rmnet is the only uplink; proot has no + independent radio) -- measure once to confirm. +3. **DownloadManager over metered.** Enqueue with `setAllowedOverMetered(false)` + on cellular; confirm it holds until Wi-Fi (and that the gate asks first, so the + hold is a chosen fallback, not a silent stall). +4. **Callback latency.** Time `NetworkStateLiveData` firing after a transport flip + to confirm the proactive alert is prompt. + +## 8. Consequences + +- Positive: one owner for cost policy; reuses the existing change callback; + reduces (does not add) `hasInternet` duplication; empirically grounded rule. +- Cost: four seams still to wire; device verification per the protocol. (The UI + strings are already translated to 33 locales, machine-generated, pending human + review.) +- Deployment detail: the proactive alert posts a notification, so on Android 13+ + it needs the POST_NOTIFICATIONS runtime permission. The observer swallows the + SecurityException when it is not granted, so the start gate (the primary cost + protection) still works with no notification permission. If the app does not + already request POST_NOTIFICATIONS elsewhere, the alert is silent until it does. +- Out of scope: fine control of in-flight transfers; a persisted "always allow"; + P2P (rsync clone -- LAN, no cost). + +## 9. Alternatives rejected + +- **Key on `TRANSPORT_CELLULAR`.** Rejected: the IMS PDN evidence shows cellular + is not uniformly metered, and a metered Wi-Fi hotspot would be missed. +- **A per-transfer byte threshold ("ask only above N MB").** Rejected: a global + threshold across every seam adds complexity for little gain. The model is + binary consent. A small-payload exemption (< ~1 MB) may arrive later, per seam, + not as a global rule. +- **Block at the socket / bind the process to Wi-Fi.** Rejected: cannot cover the + in-proot server's egress, and would fight `WifiNetworkBinder` (LAN sync). + +## 10. Open design questions (from the code-review second pass) + +The reference wiring gates the immediate commit point. The two-pass review found +that this alone is not fully defensive, because the wishlist is a durable queue +drained by an UNGATED background pass. These must be resolved before the feature +is complete: + +1. **The commit point both banks and drains.** `SetupLibraryActivity.startZimDownload()` + calls `ZimWishlist.add(cart)` (the durable queue) and then `ZimProvisioner.drain()`. + Wrapping the whole method means a BLOCKED (offline) or declined start also skips + the banking, so the selection is lost instead of queued. Offline is not a cost + decision -- it should still bank for a later drain. Fix direction: bank + unconditionally; gate only the drain. + +2. **Banked items drain ungated.** `ZimProvisioner.drain` runs every ~2 s from + Home/SetupProgress and starts the real download with no gate (by design, to + avoid re-prompting). So any banked item downloads on whatever network is + active. The wizard-bank path (`zimWizardConfirm`, `banks == true`) banks + without ever passing the gate, so those ZIMs can download on metered data with + no consent. Gating the UI commit point does not cover them. + +3. **Consequence:** to be truly defensive the provisioner drain must be + consent-aware -- HOLD a banked item on metered-without-consent instead of + downloading, and surface the consent prompt when an Activity is next + foreground (the drain itself has no UI). The proactive alert only informs; it + does not hold the transfer. This is the real depth of the feature and should + be designed before wiring the remaining seams, not after. + +### Resolution (implemented) + +Enforcement moved to the single choke every content drain already consults: +`ContentAdmission.canStart` (system/data) -- "the one answer to may a REST content +stream start now". It now also defers when `NetworkCostAdmission.allowsHeavyStartNow` +is false (metered without consent), so ZIM, Books and Kolibri drains all HOLD a +banked order rather than spend mobile data -- the commit point, the wizard-bank +path and every background re-drain, covered in one place, with no per-provisioner +edit and no new persistent state (a held order is simply left banked, the existing +"deferred is not a failure" contract). + +`NetworkCostAdmission` (networkpolicy/data) is the single source of the classify + +consent + policy decision, used both by that headless admission and by the UI gate. + +The prompt stays at the UI commit point (`NetworkPolicyGate`), but banking now +happens BEFORE the gate (`SetupLibraryActivity.startZimDownload` banks, then gates +the drain), so a declined or offline order is queued, not lost. This removes the +commit-point special case rather than adding one. + +The ZIM, Books and Kolibri commit points now bank-then-prompt. The Get-More base-Maps +download (K2GO-394 moved its bytes onto REST) is gated at its UI commit +(`openMapsIndex`), NOT at `MapsProvisioner.drain`: unlike the content drains, the maps +drain is a serialized proot stage whose refusal is TERMINAL (`mapsStartFailed`), so a +cost-hold there would read as a hard failure, not a deferral -- the headless maps hold +needs an orchestrator "waiting for network" state and rides with the install/rootfs PR. +The dashboard LIVE update/install is gated at its UI commit (`startRest`). Both are +user-driven Operations, so they gate at the UI (like FQR), not through `ContentAdmission` +(which defers TO them -- routing back would be circular). + +Still open as follow-ups (see sec.5): the wizard/system-install maps path and its +headless hold (install/rootfs PR), the rootfs-image install (aria2), the DownloadManager +seam (OTA/portal), and the two-`hasInternet` fold.