diff --git a/controller/app/src/main/java/org/appdevforall/k2go/install/presentation/InstallService.java b/controller/app/src/main/java/org/appdevforall/k2go/install/presentation/InstallService.java index 4d8b98f9b..a96f9f350 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/install/presentation/InstallService.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/install/presentation/InstallService.java @@ -29,9 +29,6 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; -import android.net.ConnectivityManager; -import android.net.Network; -import android.net.NetworkCapabilities; import android.net.wifi.WifiManager; import android.os.Build; import android.os.Handler; @@ -265,24 +262,12 @@ private void onValidatedNetworkReturned() { // still drives it (a user decision, not a radio event); the rootfs-download resume below is // unrelated to maps. if (!InstallProgressRepository.get().current().isSoftFailed()) return; - if (!hasValidatedInternet()) return; + if (!org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier.hasValidatedInternet(this)) return; Log.i(TAG, "ADFA-4895: a validated network returned while the download was held — resuming"); log("[Download] validated network returned — resuming the held download"); doResume(); } - /** ADFA-4895: true only when the active default network both offers internet and has been validated. */ - private boolean hasValidatedInternet() { - ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); - if (cm == null) return false; - Network active = cm.getActiveNetwork(); - if (active == null) return false; - NetworkCapabilities caps = cm.getNetworkCapabilities(active); - return caps != null - && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); - } - @Override public int onStartCommand(Intent intent, int flags, int startId) { String action = intent != null ? intent.getAction() : null; 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 index 9d20ee7a6..b2582263f 100644 --- 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 @@ -12,10 +12,9 @@ /** * 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 single reader of ConnectivityManager (ADR-395 / K2GO-404): {@link #classify} for cost, + * {@link #hasInternet} / {@link #hasValidatedInternet} for the checks that used to live in + * DashboardRebuild and InstallService. One source for "what is the network". * *

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 @@ -40,4 +39,25 @@ public static NetworkClass classify(@NonNull Context ctx) { boolean notMetered = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED); return NetworkClass.from(hasInternet, notMetered); } + + /** The single "is there internet" reader (K2GO-404: folds DashboardRebuild.hasInternet). A null + * ConnectivityManager reads as no internet (was "unknown -> true"); that edge is never hit. */ + public static boolean hasInternet(@NonNull Context ctx) { + return classify(ctx) != NetworkClass.NONE; + } + + /** Like {@link #hasInternet} but also requires NET_CAPABILITY_VALIDATED: a captive-portal + * association has INTERNET but not VALIDATED, and resuming a download onto it soft-fails + * (K2GO-404: folds InstallService.hasValidatedInternet). */ + public static boolean hasValidatedInternet(@NonNull Context ctx) { + ConnectivityManager cm = + (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) return false; + Network net = cm.getActiveNetwork(); + if (net == null) return false; + NetworkCapabilities caps = cm.getNetworkCapabilities(net); + return caps != null + && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); + } } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardCardStatus.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardCardStatus.java index 3ca049352..9f1131c5c 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardCardStatus.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardCardStatus.java @@ -33,7 +33,7 @@ public interface Listener { void onState(DashboardCardState state); } */ public static void fetch(Context context, Listener l) { final Context ctx = context.getApplicationContext(); - final boolean online = DashboardRebuild.hasInternet(ctx); + final boolean online = org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier.hasInternet(ctx); // Cached-first (ADFA-5026) so the UI isn't blank while the live check runs; the cache holds // only the boolean, so no versions -> no arrow from a cached state. @@ -55,7 +55,7 @@ public static void fetch(Context context, Listener l) { // Online but the check failed (box stopped): fall back to the cached state, or Checking // when nothing is cached. Re-read connectivity in case the network just dropped. l.onState(DashboardCardState.resolve( - DashboardRebuild.hasInternet(ctx), false, false, null, null, + org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier.hasInternet(ctx), false, false, null, null, UpdateStatusCache.has(ctx), UpdateStatusCache.updateAvailable(ctx))); } }); 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 e5ae0449b..e0c469db4 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 @@ -52,7 +52,7 @@ public static void confirmAndStart(@NonNull Fragment host, @NonNull View anchor, Snackbars.make(anchor, org.appdevforall.k2go.util.BusyMessage.resFor(ctx)).show(); return; } - if (!hasInternet(ctx)) { + if (!org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier.hasInternet(ctx)) { Snackbars.make(anchor, R.string.k2go_dash_needs_internet).show(); return; } @@ -149,16 +149,4 @@ public static boolean blockedByUpdate(@NonNull View anchor) { } return false; } - - /** True when the device reports an internet-capable active network. Unknown -> true (let the - * preflight decide), matching the previous inline check in ModuleHubFragment. */ - public static boolean hasInternet(@NonNull Context ctx) { - android.net.ConnectivityManager cm = (android.net.ConnectivityManager) - ctx.getSystemService(Context.CONNECTIVITY_SERVICE); - if (cm == null) return true; - android.net.Network n = cm.getActiveNetwork(); - if (n == null) return false; - android.net.NetworkCapabilities caps = cm.getNetworkCapabilities(n); - return caps != null && caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET); - } } 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 b301fbb12..756655c29 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 @@ -343,24 +343,28 @@ public void startWizardInstall() { // The trade-off, stated: if the service never starts at all, the marker is left set with no // install behind it, and the next launch enters recovery. That is a state with a dialog and a // way out (ADFA-5119) rather than a silent dead end, which is the right side to fail on. - org.appdevforall.k2go.InstallGuard.begin(this); - Intent i = new Intent(this, InstallService.class); - i.setAction(InstallService.ACTION_START); - i.putExtra(InstallService.EXTRA_TIER, getSelectedTier().name()); - i.putExtra(InstallService.EXTRA_ARCH, SystemStateEvaluator.termuxArch(this)); - // ADFA-5023: reinstall wipes the existing rootfs first. Stopping a LIVE server before the wipe is - // done by the SERVICE (InstallService.runPipeline) — NOT here — so this navigation stays instant: - // one tap goes straight to the boot gate instead of the wizard sitting there during stopEnvironment. - i.putExtra(InstallService.EXTRA_REINSTALL, reinstallMode); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(i); - else startService(i); - // ADFA-5023: plain startActivity so a FRESH LibraryActivity is created and reads EXTRA_INSTALLING - // in onCreate → the boot gate. (An earlier CLEAR_TOP reused the existing Library sitting on the - // Settings tab, which doesn't re-read the extra via onNewIntent, and dumped the user back on - // Settings.) Backing out mid-install is prevented by LibraryActivity.onBackPressed, not by - // clearing the stack. - startActivity(new Intent(this, LibraryActivity.class).putExtra(LibraryActivity.EXTRA_INSTALLING, true)); - finish(); + // K2GO-404: gate the rootfs (aria2) download on metered cost. Wrap the whole commit so a + // decline plants no InstallGuard marker (a marker with no install behind it forces recovery). + org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart(this, () -> { + org.appdevforall.k2go.InstallGuard.begin(this); + Intent i = new Intent(this, InstallService.class); + i.setAction(InstallService.ACTION_START); + i.putExtra(InstallService.EXTRA_TIER, getSelectedTier().name()); + i.putExtra(InstallService.EXTRA_ARCH, SystemStateEvaluator.termuxArch(this)); + // ADFA-5023: reinstall wipes the existing rootfs first. Stopping a LIVE server before the wipe is + // done by the SERVICE (InstallService.runPipeline) — NOT here — so this navigation stays instant: + // one tap goes straight to the boot gate instead of the wizard sitting there during stopEnvironment. + i.putExtra(InstallService.EXTRA_REINSTALL, reinstallMode); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(i); + else startService(i); + // ADFA-5023: plain startActivity so a FRESH LibraryActivity is created and reads EXTRA_INSTALLING + // in onCreate → the boot gate. (An earlier CLEAR_TOP reused the existing Library sitting on the + // Settings tab, which doesn't re-read the extra via onNewIntent, and dumped the user back on + // Settings.) Backing out mid-install is prevented by LibraryActivity.onBackPressed, not by + // clearing the stack. + startActivity(new Intent(this, LibraryActivity.class).putExtra(LibraryActivity.EXTRA_INSTALLING, true)); + finish(); + }, () -> installStarting = false); } /** ADFA-4853: the wizard content step — the Get More hub in pre-install mode (tier-gated). */ diff --git a/controller/app/src/main/java/org/appdevforall/k2go/update/presentation/UpdateController.java b/controller/app/src/main/java/org/appdevforall/k2go/update/presentation/UpdateController.java index 360bdffc4..06c1b7d85 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/update/presentation/UpdateController.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/update/presentation/UpdateController.java @@ -219,10 +219,13 @@ private void startDownload(String downloadUrl) { android.app.DownloadManager manager = (android.app.DownloadManager) activity.getSystemService(Context.DOWNLOAD_SERVICE); if (manager != null) { - downloadCompletionHandled = false; - updateDownloadId = manager.enqueue(request); - getUpdateViewModel().track(updateDownloadId); - showUpdateProgressDialog(); + // K2GO-404: gate the OTA (internet) download on metered cost; on decline it does not enqueue. + org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart(activity, () -> { + downloadCompletionHandled = false; + updateDownloadId = manager.enqueue(request); + getUpdateViewModel().track(updateDownloadId); + showUpdateProgressDialog(); + }); } } diff --git a/controller/docs/ADR-395-network-cost-consent.md b/controller/docs/ADR-395-network-cost-consent.md index 1ec8a63ea..e5ee480b3 100644 --- a/controller/docs/ADR-395-network-cost-consent.md +++ b/controller/docs/ADR-395-network-cost-consent.md @@ -212,11 +212,26 @@ except the last two (pending a device pass). Every REST-heavy egress the user ca 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`. +K2GO-404 (first PR) extends the gate to the non-REST egress: +- Rootfs install (aria2, rootfs image + proot-distro base): gated at the UI commit + `SetupLibraryActivity.startWizardInstall`, wrapping the whole commit (marker + service + + navigation) so a decline plants no InstallGuard marker. Auto-retry / resume stay + headless via `InstallService.onValidatedNetworkReturned`, not re-prompted. +- OTA APK (`UpdateController.startDownload`): prompt at the enqueue (an Activity is + present, so a prompt beats a silent `setAllowedOverMetered(false)`). +- Portal APK / PDF (`PortalActivity`): NOT gated -- the WebView `DownloadListener` only + serves LOCAL box files (internal host; external downloads are ignored), so they are + never metered internet egress. Gating them would false-alarm or block a local download. +- `hasInternet` fold: `AndroidNetworkClassifier.hasInternet` and `hasValidatedInternet` + (the latter preserves `NET_CAPABILITY_VALIDATED`) are now the single reader; + `DashboardRebuild.hasInternet` and `InstallService.hasValidatedInternet` are removed and + their callers routed. One behavior delta: a null ConnectivityManager now reads as no + internet (was "unknown -> true"), an edge effectively never hit; failing closed is safe. + +Remaining (K2GO-404 second PR): the wizard/system-install maps path (`mapsWizardConfirm`) +and its headless hold -- it needs an orchestrator "waiting for network" state because +`SetupProgressActivity` treats a `MapsProvisioner.drain` refusal as terminal +(`mapsStartFailed`), so a plain cost-hold there reads as a hard failure, not a deferral. (l10n done pending human review.) ## 6. Device evidence appendix (dark surfaces flattened)