Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a5b1730
K2GO-395 feat(networkpolicy): metered-data cost-consent gate + proact…
luisguzman-adfa Sep 7, 2026
5fdbad7
K2GO-395 docs(networkpolicy): pinpoint remaining seams + recipes in A…
luisguzman-adfa Sep 7, 2026
bc9ab6b
K2GO-395 l10n(networkpolicy): translate consent strings to 33 locales
luisguzman-adfa Sep 7, 2026
0362ede
K2GO-395 docs(networkpolicy): record code-review second-pass design q…
luisguzman-adfa Sep 7, 2026
452f11b
Merge branch 'main' of github.com:appdevforall/iiab-android into feat…
luisguzman-adfa Sep 16, 2026
1a90c50
Merge branch 'main' of github.com:appdevforall/iiab-android into feat…
luisguzman-adfa Sep 16, 2026
f288b75
K2GO-395 feat(networkpolicy): enforce cost gate in ContentAdmission; …
luisguzman-adfa Sep 16, 2026
af71b30
K2GO-395 feat(networkpolicy): bank-before-gate prompt at the Books co…
luisguzman-adfa Sep 16, 2026
7f6bd7e
K2GO-395 feat(networkpolicy): gate FQR maps region download on metere…
luisguzman-adfa Sep 16, 2026
fd60c54
K2GO-395 style(l10n): soften metered wording (seem to be / data plan)
luisguzman-adfa Sep 16, 2026
67aa7a2
K2GO-395 feat(networkpolicy): prompt at the Kolibri commit point
luisguzman-adfa Sep 16, 2026
9914457
K2GO-395 refactor(util): extract WebPath.pathOf as the single URL-to-…
luisguzman-adfa Sep 16, 2026
d7a150c
K2GO-395 feat(networkpolicy): gate the native Kolibri import via WebV…
luisguzman-adfa Sep 16, 2026
2cb7362
K2GO-395 fix(networkpolicy): decline Kolibri import with a clean abor…
luisguzman-adfa Sep 17, 2026
1e605e0
K2GO-395 refactor(l10n): fold networkpolicy strings into strings.xml
luisguzman-adfa Sep 17, 2026
5bc3c79
K2GO-395 feat(networkpolicy): gate the dashboard live update on meter…
luisguzman-adfa Sep 17, 2026
e52f804
K2GO-395 feat(networkpolicy): gate the Get More base-maps download on…
luisguzman-adfa Sep 17, 2026
67510da
K2GO-395 fix(networkpolicy): name the real deferral reason in the cos…
luisguzman-adfa Sep 17, 2026
48d85dc
K2GO-395 docs(networkpolicy): record dashboard + base-maps seams; def…
luisguzman-adfa Sep 17, 2026
7f0d9e3
K2GO-395 fix(networkpolicy): guard dashboard startRest against host d…
luisguzman-adfa Sep 17, 2026
File filter

Filter by extension

Filter by extension

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,20 @@ private void startLive(List<Channel> 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<Channel> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.appdevforall.k2go.networkpolicy.data;

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

import androidx.annotation.NonNull;

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

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

private AndroidNetworkClassifier() {}

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

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

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

private static final SessionMeteredConsentStore INSTANCE = new SessionMeteredConsentStore();

public static SessionMeteredConsentStore get() {
return INSTANCE;
}

private SessionMeteredConsentStore() {}

private volatile boolean granted = false;

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

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

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

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

boolean isGranted();

void grant();

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

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

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

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

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

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

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

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

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

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

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

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

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

private NetworkTransition() {}

/**
* True when the default network just crossed INTO a metered state from a
* non-metered one -- the moment to warn the user that further activity spends
* data. A metered-to-metered change, or any change back to unmetered, never
* warns.
*/
public static boolean shouldWarn(NetworkClass previous, NetworkClass next) {
return next == NetworkClass.METERED && previous != NetworkClass.METERED;
}
}
Loading