diff --git a/.github/workflows/publish-catalogs.yml b/.github/workflows/publish-catalogs.yml index a4b44faa7..0727ba70e 100644 --- a/.github/workflows/publish-catalogs.yml +++ b/.github/workflows/publish-catalogs.yml @@ -38,6 +38,7 @@ jobs: python3 tools/build_kolibri_catalog.py || echo "::warning::kolibri generator failed; its upload will be skipped" python3 tools/build_kolibri_catalog.py --tree topics && gzip -9 -n -f src/main/assets/kolibri_tree.jsonl || echo "::warning::kolibri tree generator failed; its upload will be skipped" python3 tools/build_kiwix_catalog.py || echo "::warning::kiwix generator failed; its upload will be skipped" + python3 tools/build_maps_catalog.py || echo "::warning::maps generator failed; its upload will be skipped" - name: Build manifests and upload to Cloudflare R2 env: @@ -86,3 +87,4 @@ jobs: publish kolibri src/main/assets/kolibri_catalog.jsonl "application/x-ndjson" jsonl publish kolibri-tree src/main/assets/kolibri_tree.jsonl.gz "application/gzip" none publish kiwix src/main/assets/kiwix_catalog.csv "text/csv" none + publish maps src/main/assets/maps_catalog.csv "text/csv" none diff --git a/controller/app/build.gradle b/controller/app/build.gradle index 6810ab8a7..f37ec1f95 100644 --- a/controller/app/build.gradle +++ b/controller/app/build.gradle @@ -637,91 +637,33 @@ task refreshModuleSizes { preBuild.dependsOn refreshModuleSizes -// ---- maps layer size auto-capture (offline fallback) -------------------------- -// Fetches the from each map layer's .meta4 on the maps mirror and writes -// src/main/assets/maps_sizes.csv, so the Choose screen's OFFLINE fallback (MapsCatalog) -// is captured at package time instead of hand-maintained — same pattern as rootfs above. -// The file names follow roles/maps/defaults/main.yml; update the three data dates below -// when the role bumps them. Non-fatal: an incomplete fetch keeps the committed CSV. -task refreshMapsSizes { +// ---- maps catalog refresh (release / manual) ---------------------------------- +// Regenerates src/main/assets/maps_catalog.csv (group,level,file,bytes,date) from the +// maps mirror's .meta4 pointers via tools/build_maps_catalog.py -- the ONE builder for +// this catalog (it replaced the old inline refreshMapsSizes fetch; no double build path). +// Like the Kiwix catalog it does NOT run on every build: it runs on a RELEASE and on +// demand (./gradlew refreshMapsCatalog), and the weekly publish-catalogs workflow runs +// the same script to publish the CSV + manifest to Cloudflare. The committed CSV is the +// offline floor -- MapsCatalog reads it for the Choose size AND the download file id. +// Never fails the build: if python3 is missing or the fetch is blocked, the committed +// CSV (last known) is kept. +task refreshMapsCatalog { group = 'build setup' - description = 'Capture map layer sizes (.meta4) into assets/maps_sizes.csv for the offline fallback' - def csv = file('src/main/assets/maps_sizes.csv') + description = 'Regenerate assets/maps_catalog.csv from the maps mirror .meta4 (release / manual)' doLast { - def base = 'https://iiab.switnet.org/maps/2/' - // Data dates from roles/maps/defaults/main.yml (maps_vector_data_date / maps_slow_data_date - // / maps_search_data_date). Keep in sync with the role. - def vectorDate = '2026-07-01' - def slowDate = '2025-12-10' - def searchDate = '2026-04-22' - // "group|level" -> mirror file name (whole-world pmtiles; search is a tarball). - def files = [ - 'base|nat-z8' : "naturalearth-openmaptiles.${slowDate}.z00-z08.pmtiles", - 'base|11' : "openstreetmap-openmaptiles.${vectorDate}.z00-z11.pmtiles", - 'base|14' : "openstreetmap-openmaptiles.${vectorDate}.z00-z14.pmtiles", - 'satellite|7' : "s2maps-sentinel2-2023.${slowDate}.z00-z07.pmtiles", - 'satellite|9' : "s2maps-sentinel2-2023.${slowDate}.z00-z09.pmtiles", - 'satellite|11' : "s2maps-sentinel2-2023.${slowDate}.z00-z11.pmtiles", - 'satellite|13' : "s2maps-sentinel2-2023.${slowDate}.z00-z13.pmtiles", - 'terrain|7' : "terrarium.${slowDate}.z00-z07.pmtiles", - 'terrain|8' : "terrarium.${slowDate}.z00-z08.pmtiles", - 'terrain|9' : "terrarium.${slowDate}.z00-z09.pmtiles", - 'terrain|10' : "terrarium.${slowDate}.z00-z10.pmtiles", - 'search|pop-1k-cities' : "static-search.${searchDate}.pop-1k-cities.tar.gz", - ] - def sizePat = ~/(\d+)<\/size>/ - def fetched = [:] - files.each { k, fname -> - def url = "${base}${fname}.meta4" - try { - def conn = (HttpURLConnection) new URL(url).openConnection() - conn.setRequestProperty('User-Agent', 'iiab-android-build') - conn.connectTimeout = 6000 - conn.readTimeout = 6000 - if (conn.responseCode != 200) { - logger.warn(">> [maps sizes] HTTP ${conn.responseCode} for ${url}") - conn.disconnect(); return - } - def body = conn.inputStream.getText('UTF-8') - conn.disconnect() - def m = sizePat.matcher(body) - if (m.find()) { fetched[k] = m.group(1) } - else { logger.warn(">> [maps sizes] no element in ${url}") } - } catch (Exception e) { - logger.warn(">> [maps sizes] fetch failed for ${url}: ${e.message}") - } - } - if (fetched.size() != files.size()) { - logger.warn(">> [maps sizes] incomplete fetch (${fetched.size()}/${files.size()}); keeping existing ${csv.name} (last known).") - return - } - def existing = [:] - if (csv.exists()) { - csv.eachLine { line -> - line = line.trim() - if (line.isEmpty() || line.startsWith('#')) return - def p = line.split(',') - if (p.length >= 3) existing["${p[0].trim()}|${p[1].trim()}".toString()] = p[2].trim() - } - } - if (existing == fetched) { - println ">> [maps sizes] up to date (${fetched.size()} entries unchanged)." - return - } - def today = new Date().format('yyyy-MM-dd') - def rows = files.keySet().collect { k -> - def gl = k.split('\\|', 2) - "${gl[0]},${gl[1]},${fetched[k]},${today}".toString() + try { + def proc = ['python3', 'tools/build_maps_catalog.py'].execute(null, projectDir) + proc.consumeProcessOutput(System.out, System.err) + proc.waitFor() + if (proc.exitValue() != 0) logger.warn(">> [maps catalog] generator exit ${proc.exitValue()}; kept existing CSV.") + } catch (Exception e) { + logger.warn(">> [maps catalog] skipped (${e.message}); kept existing CSV.") } - if (!csv.parentFile.exists()) csv.parentFile.mkdirs() - csv.text = '# group,level,bytes,fetched_date -- AUTO-GENERATED by the refreshMapsSizes Gradle task; do not edit by hand\n' + - '# Whole-world pmtiles from https://iiab.switnet.org/maps/2 (roles/maps/defaults dates).\n' + - rows.join('\n') + '\n' - println ">> [maps sizes] updated ${rows.size()} entries in ${csv.name} (${today})." } } - -preBuild.dependsOn refreshMapsSizes +tasks.matching { it.name == 'assembleRelease' || it.name == 'bundleRelease' }.configureEach { + dependsOn refreshMapsCatalog +} // ---- Kiwix ZIM catalog refresh (release-only) --------------------------------- // Regenerates src/main/assets/kiwix_catalog.csv from the Kiwix directory listings via diff --git a/controller/app/src/main/assets/maps_catalog.csv b/controller/app/src/main/assets/maps_catalog.csv new file mode 100644 index 000000000..bf0cfc279 --- /dev/null +++ b/controller/app/src/main/assets/maps_catalog.csv @@ -0,0 +1,14 @@ +# group,level,file,bytes,fetched_date -- AUTO-GENERATED by tools/build_maps_catalog.py; do not edit by hand +# Whole-world pmtiles from https://iiab.switnet.org/maps/2 (roles/maps/defaults dates). The app sends 'file' as the download id; the box composes the URL. +base,nat-z8,naturalearth-openmaptiles.2025-12-10.z00-z08.pmtiles,89435511,2026-09-07 +base,11,openstreetmap-openmaptiles.2026-07-01.z00-z11.pmtiles,9010265257,2026-09-07 +base,14,openstreetmap-openmaptiles.2026-07-01.z00-z14.pmtiles,87411164966,2026-09-07 +satellite,7,s2maps-sentinel2-2023.2025-12-10.z00-z07.pmtiles,88772638,2026-09-07 +satellite,9,s2maps-sentinel2-2023.2025-12-10.z00-z09.pmtiles,1247283362,2026-09-07 +satellite,11,s2maps-sentinel2-2023.2025-12-10.z00-z11.pmtiles,22312696177,2026-09-07 +satellite,13,s2maps-sentinel2-2023.2025-12-10.z00-z13.pmtiles,290318099827,2026-09-07 +terrain,7,terrarium.2025-12-10.z00-z07.pmtiles,978219778,2026-09-07 +terrain,8,terrarium.2025-12-10.z00-z08.pmtiles,6427331113,2026-09-07 +terrain,9,terrarium.2025-12-10.z00-z09.pmtiles,28621213143,2026-09-07 +terrain,10,terrarium.2025-12-10.z00-z10.pmtiles,106207946492,2026-09-07 +search,pop-1k-cities,static-search.2026-04-22.pop-1k-cities.tar.gz,16111581,2026-09-07 diff --git a/controller/app/src/main/assets/maps_sizes.csv b/controller/app/src/main/assets/maps_sizes.csv deleted file mode 100644 index f70c554b5..000000000 --- a/controller/app/src/main/assets/maps_sizes.csv +++ /dev/null @@ -1,14 +0,0 @@ -# group,level,bytes,fetched_date -- AUTO-GENERATED by the refreshMapsSizes Gradle task; do not edit by hand -# Whole-world pmtiles from https://iiab.switnet.org/maps/2 (roles/maps/defaults dates). -base,nat-z8,89435511,2026-07-24 -base,11,9010265257,2026-07-24 -base,14,87411164966,2026-07-24 -satellite,7,88772638,2026-07-24 -satellite,9,1247283362,2026-07-24 -satellite,11,22312696177,2026-07-24 -satellite,13,290318099827,2026-07-24 -terrain,7,978219778,2026-07-24 -terrain,8,6427331113,2026-07-24 -terrain,9,28621213143,2026-07-24 -terrain,10,106207946492,2026-07-24 -search,pop-1k-cities,16111581,2026-07-24 diff --git a/controller/app/src/main/java/org/appdevforall/k2go/install/domain/MapsRunroleCommand.java b/controller/app/src/main/java/org/appdevforall/k2go/install/domain/MapsRunroleCommand.java index ad4948852..5efd98387 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/install/domain/MapsRunroleCommand.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/install/domain/MapsRunroleCommand.java @@ -14,6 +14,10 @@ * RUNTIME from the completion marker in iiab_state.yml -- --reinstall over a completed or * base-seeded install, plain runrole to recover a half-done one (a bare --reinstall errors * when the marker was already deleted by a prior failed --reinstall run). + * K2GO-394: base-map pmtiles are downloaded by dash-node (the durable job engine) + * BEFORE this runs, so the role's is_proot download task only asserts the files are + * present and skips via creates:. This command carries no download handshake -- it is + * the plain post-processing runrole. * ============================================================================ */ package org.appdevforall.k2go.install.domain; 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 988292d86..4d8b98f9b 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 @@ -227,6 +227,9 @@ public final class InstallService extends Service { private static String sRetryMapsVector, sRetryMapsSat, sRetryMapsTerrain; private static boolean sRetryMapsSearch; private boolean mapsSearchOn; + // K2GO-394: the base-map download client for the maps module (dash-node "basemaps" job). Held so + // the UI's Pause/Resume/Cancel reach the in-flight download; null when nothing is downloading. + private volatile org.appdevforall.k2go.content.RestContentClient mapsClient; private File iiabRootDir; // filesDir/rootfs private File debianRootfs; // filesDir/rootfs/installed-rootfs/iiab @@ -256,6 +259,11 @@ public void onCreate() { */ private void onValidatedNetworkReturned() { if (finished || cancelled) return; + // K2GO-394: the maps base-map download is NOT driven from here -- dash-node owns its transport + // resilience (its "basemaps" job re-runs aria2 with --continue and reports "Reconnecting n/5"), + // the same self-healing path as ZIMs. The app just polls it. The manual Pause/Resume button + // 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; Log.i(TAG, "ADFA-4895: a validated network returned while the download was held — resuming"); @@ -967,6 +975,9 @@ private void installNextModule() { "echo '" + nextModule + "_enabled: True' >> /etc/iiab/local_vars.yml && " + "cd /opt/iiab/iiab && ./runrole " + roleName; + // K2GO-394: the runrole (post-processing) as a deferred step. For maps it runs only AFTER the + // base maps are downloaded through dash-node (the gate below); other modules run it at once. + final Runnable startRunrole = () -> { // ADFA-4435: Ansible can print its failure to stdout yet still exit 0, so the verdict // considers the output as well as the exit code (pure, unit-tested domain object). final AnsibleRunOutcome outcome = new AnsibleRunOutcome(); @@ -1030,6 +1041,15 @@ public void onError(String error) { revertModuleInLocalVars(nextModule, InstallService.this::finishModuleQueue); } }); + }; + // K2GO-394: for maps, download the base maps through dash-node first (server up, resilient), + // then run the role -- which post-processes and skips the downloads via creates:. Other modules + // run the role directly. + if ("maps".equals(nextModule) && hasMapsConfig) { + downloadMapsBasemapsThenRun(nextModule, startRunrole); + } else { + startRunrole.run(); + } } /** @@ -1208,11 +1228,95 @@ private static long boundedDirSize(File root, int fileCap) { * unexpected falls back to a safe default. Uses sed-delete + echo (append-if-missing). */ // ADFA-4900: the maps runrole command is a pure, unit-tested builder (MapsRunroleCommand). + // K2GO-394: post-processing only -- dash-node downloaded the base maps first, so no handshake. private String mapsInstallCmd() { return org.appdevforall.k2go.install.domain.MapsRunroleCommand.build( mapsVector, mapsSat, mapsTerrain, mapsSearchOn); } + /** + * K2GO-394: download the selected base maps through dash-node BEFORE the maps runrole, then run + * {@code onReady} (the runrole). The download goes through the durable job engine with the server + * up -- the same resilient path as ZIMs (RestContentClient, type "basemaps") -- so a network drop + * recovers ("Reconnecting n/5") instead of wedging an in-proot aria2c. When it finishes, the files + * are at {@code /library/www/maps}, so the role's download tasks skip via {@code creates:} and it + * only post-processes. The app resolves each selected layer to its mirror file name (the download + * id) from the catalog; the box composes the URL (same split as kiwix). + */ + private void downloadMapsBasemapsThenRun(String module, Runnable onReady) { + org.appdevforall.k2go.redesign.MapsCatalog cat = new org.appdevforall.k2go.redesign.MapsCatalog(this); + java.util.List ids = new java.util.ArrayList<>(); + for (org.appdevforall.k2go.maps.domain.MapsBasemapSelection.Layer layer + : org.appdevforall.k2go.maps.domain.MapsBasemapSelection.layersToDelegate(mapsVector, mapsSat, mapsTerrain)) { + String file = cat.fileFor(layer.group, layer.level); + if (file != null) { + ids.add(file); + } else { + // The catalog cannot resolve a SELECTED layer (drift between the bundled catalog and the + // role's file map). It is not delegated -> the runrole downloads it in-proot. Log it so a + // partial miss is visible, not silent. + log("[maps] WARNING: no catalog file for " + layer.group + " " + layer.level + + "; it will download in-proot"); + } + } + // A null body (nothing to delegate, or the impossible JSON build failure) means run the role + // directly and let its creates: fetch whatever is still missing. + org.json.JSONObject body = null; + if (!ids.isEmpty()) { + try { + body = new org.json.JSONObject().put("ids", new org.json.JSONArray(ids)); + } catch (org.json.JSONException e) { + body = null; + } + } + if (body == null) { + log("[maps] no base-map files to pre-download; running the role"); + onReady.run(); + return; + } + log("[maps] downloading " + ids.size() + " base-map file(s) via dash-node: " + + android.text.TextUtils.join(", ", ids)); + final org.appdevforall.k2go.content.RestContentClient client = + new org.appdevforall.k2go.content.RestContentClient("basemaps"); + mapsClient = client; + client.start(body, new org.appdevforall.k2go.content.RestContentClient.Listener() { + @Override public void onProgress(int percent, String speed) { + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().post( + org.appdevforall.k2go.maps.domain.MapsDownloadProgress.active(percent, speed)); + } + @Override public void onReconnecting(int attempt, int total) { + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().post( + org.appdevforall.k2go.maps.domain.MapsDownloadProgress.reconnecting(attempt, total)); + } + @Override public void onPaused(int percent) { + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().post( + org.appdevforall.k2go.maps.domain.MapsDownloadProgress.paused(percent)); + } + @Override public void onIndexing() { /* base maps have no index phase */ } + @Override public void onLog(String line) { log("[maps-dl] " + line); } + @Override public void onDone() { + mapsClient = null; + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().clear(); + if (cancelled) return; + log("[maps] base-map download complete; running the role"); + // Keep the runrole off the poll's main-thread callback (it reads assets / prefs). + org.appdevforall.k2go.util.AppExecutors.get().io().execute(onReady); + } + @Override public void onError(String message) { + mapsClient = null; + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().clear(); + if (cancelled) return; + failedModules.add(module); + log("[Install] FAILED: " + module + " base-map download (" + message + ")"); + // Mirror the runrole onError: a download failure is a connectivity problem that would + // fail the remaining modules too, so stop the batch rather than cascade through them. + moduleQueue.clear(); + org.appdevforall.k2go.util.AppExecutors.get().io().execute( + () -> revertModuleInLocalVars(module, InstallService.this::finishModuleQueue)); + } + }); + } + /** * ADFA-4898: re-run the given proot module(s) after a failed batch — the user-confirmed Retry * (same ACTION_START_MODULES intent the provisioners fire). For maps it re-attaches this @@ -1391,6 +1495,14 @@ private String formatEta(int bucket) { */ private void doPause() { if (finished || cancelled) return; + // K2GO-394: a maps base-map download pauses through dash-node (the "basemaps" job), not the + // rootfs aria2 the checks below govern. The poll reflects the paused state on the download bar. + org.appdevforall.k2go.content.RestContentClient pauseClient = mapsClient; + if (pauseClient != null) { + log("[maps] pause requested"); + pauseClient.pause(); + return; + } if (!InstallProgressRepository.get().current().isRunning()) return; if (InstallProgressRepository.get().current().phase != InstallState.Phase.DOWNLOADING) { Log.i(TAG, "pause ignored: only a download can be paused"); @@ -1579,6 +1691,13 @@ private static int softFailLine(org.appdevforall.k2go.download.domain.Aria2Exit. */ private void doResume() { if (finished || cancelled) return; + // K2GO-394: resume a maps download through dash-node (aria2 --continue picks up the partial). + org.appdevforall.k2go.content.RestContentClient resumeClient = mapsClient; + if (resumeClient != null) { + log("[maps] resume requested"); + resumeClient.resume(); + return; + } if (!InstallProgressRepository.get().current().isHeld()) return; // ADFA-5119 (review): leave the held state FIRST, and it is not cosmetic. Nothing posted a new // state until aria2's first progress line, which is after the metalink fetch and the two @@ -1769,6 +1888,10 @@ private void teardown(boolean clearMarker) { // ADFA-5119: nothing to wait for once this is over — neither the window nor a queued attempt. cancelHeldWindow(); cancelPendingRetry(); + // K2GO-394: cancel an in-flight base-map download and clear its bar. + org.appdevforall.k2go.content.RestContentClient mc = mapsClient; + if (mc != null) { mc.cancel(); mapsClient = null; } + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().clear(); if (clearMarker) { org.appdevforall.k2go.InstallGuard.end(this); } else { diff --git a/controller/app/src/main/java/org/appdevforall/k2go/maps/domain/MapsBasemapSelection.java b/controller/app/src/main/java/org/appdevforall/k2go/maps/domain/MapsBasemapSelection.java new file mode 100644 index 000000000..d12efd206 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/maps/domain/MapsBasemapSelection.java @@ -0,0 +1,51 @@ +/* + * ============================================================================ + * Name : MapsBasemapSelection.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-394. The rule for which base-map layers a wizard selection + * delegates to dash-node. Pure JVM, no Android. + * ============================================================================ + */ +package org.appdevforall.k2go.maps.domain; + +import java.util.ArrayList; +import java.util.List; + +/** + * Which base-map layers a wizard selection sends to dash-node (K2GO-394). The vector layer is always + * downloaded; satellite and terrain are downloaded unless the selection is their OFF value. This is a + * pure rule so the "which layers, skip which" decision has one testable home, not scattered inline in + * the install service. The caller resolves each returned layer to a catalog file id (the download id). + */ +public final class MapsBasemapSelection { + private MapsBasemapSelection() {} + + /** Satellite "off" -- the wizard's no-satellite value (the role drops the symlink for it). */ + public static final String SAT_OFF = "none"; + /** Terrain "off" -- the wizard's no-terrain value (the role's real off key). */ + public static final String TERRAIN_OFF = "0-none"; + + /** A catalog layer to delegate: a group ("base" / "satellite" / "terrain") and a level. */ + public static final class Layer { + public final String group; + public final String level; + + public Layer(String group, String level) { + this.group = group; + this.level = level; + } + } + + /** + * The catalog layers to pre-download through dash-node for this selection, in order, skipping any + * layer that is off. "base" carries the vector level (the catalog group for the vector pmtiles). + */ + public static List layersToDelegate(String vector, String sat, String terrain) { + List out = new ArrayList<>(3); + if (vector != null && !vector.isEmpty()) out.add(new Layer("base", vector)); + if (sat != null && !SAT_OFF.equals(sat)) out.add(new Layer("satellite", sat)); + if (terrain != null && !TERRAIN_OFF.equals(terrain)) out.add(new Layer("terrain", terrain)); + return out; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgress.java b/controller/app/src/main/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgress.java new file mode 100644 index 000000000..ab9444775 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgress.java @@ -0,0 +1,86 @@ +/* + * ============================================================================ + * Name : MapsDownloadProgress.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-394. One base-map download's progress, as the subordinate + * download bar needs it, built from the dash-node REST poll + * (RestContentClient). Pure JVM, no Android, no JSON framework. + * ============================================================================ + */ +package org.appdevforall.k2go.maps.domain; + +/** + * A snapshot of the base-map download for the subordinate bar. The base maps are downloaded through + * dash-node's durable job engine (the same path as ZIMs), so this is built from the REST poll fields + * ({@code RestContentClient.Listener}) -- a phase, a percent, a speed token, and the reconnect counter + * -- not from aria2's raw RPC. The rule for "which phase" lives in one testable place, not in the UI. + */ +public final class MapsDownloadProgress { + + /** The states the download bar distinguishes. RECONNECTING is a network drop the server rides out. */ + public enum Phase { NONE, ACTIVE, PAUSED, RECONNECTING, COMPLETE } + + public final Phase phase; + /** 0..100, or -1 when not known yet (queued / no percent reported). */ + public final int percent; + /** Display token for the rate WITHOUT the per-second suffix (e.g. "3.4 MB"); the UI appends "/s". */ + public final String speed; + /** Reconnect counter for "Reconnecting n of N"; both 0 when not reconnecting. */ + public final int reconnectAttempt; + public final int reconnectTotal; + + private MapsDownloadProgress(Phase phase, int percent, String speed, int attempt, int total) { + this.phase = phase; + this.percent = percent < 0 ? -1 : Math.min(100, percent); + this.speed = speed != null ? speed : ""; + this.reconnectAttempt = Math.max(0, attempt); + this.reconnectTotal = Math.max(0, total); + } + + /** Nothing is downloading right now -- before the job starts, or after it finishes/clears. */ + public static MapsDownloadProgress none() { + return new MapsDownloadProgress(Phase.NONE, -1, "", 0, 0); + } + + /** A live download at {@code percent} moving at {@code speed} (a display token, no "/s"). */ + public static MapsDownloadProgress active(int percent, String speed) { + return new MapsDownloadProgress(Phase.ACTIVE, percent, speed, 0, 0); + } + + /** The user paused the download; the partial is kept and resume continues from it. */ + public static MapsDownloadProgress paused(int percent) { + return new MapsDownloadProgress(Phase.PAUSED, percent, "", 0, 0); + } + + /** The server lost the network and is reconnecting (attempt n of total), keeping the partial. */ + public static MapsDownloadProgress reconnecting(int attempt, int total) { + return new MapsDownloadProgress(Phase.RECONNECTING, -1, "", attempt, total); + } + + /** The download finished. */ + public static MapsDownloadProgress complete() { + return new MapsDownloadProgress(Phase.COMPLETE, 100, "", 0, 0); + } + + public boolean isActive() { + return phase == Phase.ACTIVE; + } + + public boolean isPaused() { + return phase == Phase.PAUSED; + } + + public boolean isReconnecting() { + return phase == Phase.RECONNECTING; + } + + public boolean isComplete() { + return phase == Phase.COMPLETE; + } + + /** Whether there is a live download to show a bar for (active, paused, or reconnecting). */ + public boolean isRunning() { + return phase == Phase.ACTIVE || phase == Phase.PAUSED || phase == Phase.RECONNECTING; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/maps/presentation/MapsDownloadRepository.java b/controller/app/src/main/java/org/appdevforall/k2go/maps/presentation/MapsDownloadRepository.java new file mode 100644 index 000000000..2fe764dc9 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/maps/presentation/MapsDownloadRepository.java @@ -0,0 +1,61 @@ +/* + * ============================================================================ + * Name : MapsDownloadRepository.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-394. The one place the maps download progress lives, so the + * install-progress UI observes it instead of polling aria2 itself. + * ============================================================================ + */ +package org.appdevforall.k2go.maps.presentation; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.appdevforall.k2go.maps.domain.MapsDownloadProgress; + +/** + * The subordinate download bar's single source (K2GO-394). {@code InstallService} writes it from the + * dash-node poll ({@code RestContentClient.Listener} for the "basemaps" job); the progress screen + * observes it beside {@code ModuleQueueRepository} (the phase spine). One writer, so there is no + * second place inventing a download percent. + * + *

Starts and resets to {@link MapsDownloadProgress#none()} -- the honest "nothing to show", which + * is also what a non-maps module leaves it at, so the UI simply shows the phase-only Variant 3. + */ +public final class MapsDownloadRepository { + + private static final MapsDownloadRepository INSTANCE = new MapsDownloadRepository(); + + private final MutableLiveData state = + new MutableLiveData<>(MapsDownloadProgress.none()); + + private MapsDownloadRepository() { + } + + public static MapsDownloadRepository get() { + return INSTANCE; + } + + public LiveData state() { + return state; + } + + public MapsDownloadProgress current() { + MapsDownloadProgress v = state.getValue(); + return v != null ? v : MapsDownloadProgress.none(); + } + + /** + * Post a new snapshot. {@code postValue} on purpose: the poll listener delivers on the main thread + * but InstallService clears from its background install thread, so the write must be thread-safe. + */ + public void post(MapsDownloadProgress p) { + state.postValue(p != null ? p : MapsDownloadProgress.none()); + } + + /** Clear back to "nothing downloading" -- on teardown, or when the download finishes. */ + public void clear() { + state.postValue(MapsDownloadProgress.none()); + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsCatalog.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsCatalog.java index d9f67bab4..158d1828e 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsCatalog.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsCatalog.java @@ -3,12 +3,14 @@ * Name : MapsCatalog.java * Author : AppDevForAll * Copyright : Copyright (c) 2026 AppDevForAll - * Description : ADFA-4848. Offline size catalog for the Maps "Choose" screen. Reads - * assets/maps_sizes.csv (group,level,bytes,date), which the refreshMapsSizes - * Gradle task regenerates from the maps mirror's .meta4 pointers at package - * time — so the last-known sizes are captured automatically, never hand-kept. - * Sizes are whole-world pmtiles and can be very large; the Choose screen's - * free-space guard is what keeps the estimate honest on a phone. + * Description : ADFA-4848 / K2GO-394. Offline maps catalog for the "Choose" screen and + * the base-map download. Reads assets/maps_catalog.csv + * (group,level,file,bytes,date), which tools/build_maps_catalog.py regenerates + * from the maps mirror's .meta4 pointers at package time -- so the last-known + * file names and sizes are captured automatically, never hand-kept. The Choose + * screen uses the size (whole-world pmtiles are large; its free-space guard keeps + * the estimate honest); the download uses the file name as the id it sends to the + * box, which composes the mirror URL (K2GO-394, same split as kiwix). * ============================================================================ */ package org.appdevforall.k2go.redesign; @@ -25,11 +27,13 @@ public class MapsCatalog { private static final String TAG = "MapsCatalog"; - private static final String CSV_ASSET = "maps_sizes.csv"; + private static final String CSV_ASSET = "maps_catalog.csv"; private static final long MB = 1024L * 1024L; /** Parsed CSV (group|level -> bytes), loaded once per process. */ private static volatile Map csvSizes; + /** Parsed CSV (group|level -> mirror file name), loaded with the sizes. */ + private static volatile Map csvFiles; public MapsCatalog(Context context) { ensureCsvLoaded(context); } @@ -37,24 +41,30 @@ private static void ensureCsvLoaded(Context context) { if (csvSizes != null || context == null) return; synchronized (MapsCatalog.class) { if (csvSizes != null) return; - Map m = new HashMap<>(); + Map sizes = new HashMap<>(); + Map files = new HashMap<>(); try (BufferedReader r = new BufferedReader( new InputStreamReader(context.getAssets().open(CSV_ASSET)))) { String line; while ((line = r.readLine()) != null) { line = line.trim(); if (line.isEmpty() || line.startsWith("#")) continue; + // group,level,file,bytes,date String[] p = line.split(","); - if (p.length >= 3) { + if (p.length >= 4) { + String k = key(p[0].trim(), p[1].trim()); + String file = p[2].trim(); + if (!file.isEmpty()) files.put(k, file); try { - m.put(key(p[0].trim(), p[1].trim()), Long.parseLong(p[2].trim())); - } catch (NumberFormatException ignore) { /* skip malformed row */ } + sizes.put(k, Long.parseLong(p[3].trim())); + } catch (NumberFormatException ignore) { /* skip malformed size */ } } } } catch (Exception e) { - Log.w(TAG, "maps_sizes.csv not read (" + e.getMessage() + "); Choose uses its built-in fallbacks"); + Log.w(TAG, "maps_catalog.csv not read (" + e.getMessage() + "); Choose uses its built-in fallbacks"); } - csvSizes = m; + csvFiles = files; + csvSizes = sizes; // set last: it is the "loaded" flag the double-check reads } } @@ -75,4 +85,17 @@ public long sizeMb(String group, String level, long fallbackMb) { } return fallbackMb; } + + /** + * K2GO-394: the mirror file name for a group+level, or {@code null} when the level is off + * ({@code null}) or the catalog has no row for it. This is the download id the app sends to the + * box; the box composes the real URL (MAPS_BASE_URL + file), so the app never holds the host. + */ + public String fileFor(String group, String level) { + if (group == null || level == null) return null; + Map files = csvFiles; + if (files == null) return null; + String f = files.get(key(group, level)); + return (f != null && !f.isEmpty()) ? f : null; + } } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsChooseFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsChooseFragment.java index c34454f5a..c8f030f83 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsChooseFragment.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsChooseFragment.java @@ -9,7 +9,7 @@ * Each group shows an icon, a bold name + muted hint, and the CURRENT * selection's size on the right; each pill shows its own size — all live. * A free-space guard (StatFs) disables the CTA when it won't fit. Sizes are - * PLACEHOLDERS until the refreshMapsSizes build task lands maps_sizes.csv. + * PLACEHOLDERS until the refreshMapsCatalog build task lands maps_catalog.csv. * No language picker, no search box (Maps has neither). Download hands off to * Confirm (next slice). * ============================================================================ @@ -47,7 +47,7 @@ private static final class Grp { // Layers/levels are constrained to the Android maps support matrix; the `level` keys map to the // mirror files (roles/maps/defaults). The mb values are last-known fallbacks (whole-world sizes); - // resolveSizes() overwrites them with the packaged maps_sizes.csv at runtime. + // resolveSizes() overwrites them with the packaged maps_catalog.csv at runtime. private final Grp[] GROUPS = { new Grp(R.drawable.ic_maps_base, R.string.k2go_maps_grp_base, R.string.k2go_maps_grp_base_hint, "base", new Opt[]{ new Opt(R.string.k2go_maps_lvl_low, "nat-z8", 85), new Opt(R.string.k2go_maps_lvl_standard, "11", 8602), @@ -83,7 +83,7 @@ private android.content.SharedPreferences selPrefs() { return requireContext().getApplicationContext().getSharedPreferences(SEL_PREFS, android.content.Context.MODE_PRIVATE); } - /** Overwrite each option's size with the packaged last-known value (maps_sizes.csv); + /** Overwrite each option's size with the packaged last-known value (maps_catalog.csv); * the built-in mb stays as the fallback when a row is missing. */ private void resolveSizes() { MapsCatalog cat = new MapsCatalog(requireContext()); diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsPreparingFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsPreparingFragment.java index da2e870ee..323aa5790 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsPreparingFragment.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/MapsPreparingFragment.java @@ -61,6 +61,11 @@ public static MapsPreparingFragment newInstance(boolean fromIndex) { private View progressRow; // ADFA-5228 private com.google.android.material.progressindicator.LinearProgressIndicator progress; private TextView progressPct, progressEta; + // K2GO-394: subordinate download card (the in-proot aria2c's live progress + Pause/Resume). + private View downloadCard; + private com.google.android.material.progressindicator.LinearProgressIndicator dlBar; + private TextView dlBytes, dlRate; + private android.widget.Button dlPause; private boolean fromIndex = false; // ADFA-4901: hosted by the Finishing-setup index (observe only) private boolean launched = false; // ADFA-4900: guard against re-launching maps on view recreation @@ -84,6 +89,23 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c progress = root.findViewById(R.id.k2go_prep_progress); progressPct = root.findViewById(R.id.k2go_prep_progress_pct); progressEta = root.findViewById(R.id.k2go_prep_progress_eta); + // K2GO-394: the subordinate download card + its Pause/Resume, fed by MapsDownloadRepository. + downloadCard = root.findViewById(R.id.k2go_prep_download); + dlBar = root.findViewById(R.id.k2go_prep_dl_bar); + dlBytes = root.findViewById(R.id.k2go_prep_dl_bytes); + dlRate = root.findViewById(R.id.k2go_prep_dl_rate); + dlPause = root.findViewById(R.id.k2go_prep_dl_pause); + dlPause.setOnClickListener(v -> { + // Toggle by the current phase: pause an active download, resume a paused one. The service + // routes ACTION_PAUSE/RESUME to the in-proot aria2c over RPC (InstallService.doPause/doResume). + boolean paused = org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().current().isPaused(); + android.content.Intent i = new android.content.Intent(requireContext(), + org.appdevforall.k2go.install.presentation.InstallService.class); + i.setAction(paused + ? org.appdevforall.k2go.install.presentation.InstallService.ACTION_RESUME + : org.appdevforall.k2go.install.presentation.InstallService.ACTION_PAUSE); + requireContext().startService(i); + }); // ADFA-4919: the fromIndex=false branch below is the DEPRECATED standalone Get More route // (its own "Run in background", no index, no gate). Get More now routes through the install @@ -138,6 +160,11 @@ && getActivity() instanceof SetupLibraryActivity } }); + // K2GO-394: the subordinate download bar. Present only while a live in-proot download exists + // (the RPC feed); hidden otherwise, so a stock rootfs with no RPC shows the phase spine alone. + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().state() + .observe(getViewLifecycleOwner(), this::renderDownload); + // ADFA-4901: collapsible live log. The status line mirrors the latest line; expanding shows // the full terminal (LogRepository snapshot + live appends), auto-scrolled. org.appdevforall.k2go.util.ProgressVisuals.apply(root, org.appdevforall.k2go.system.domain.ContentType.MAPS); // ADFA-5074 @@ -160,6 +187,41 @@ && getActivity() instanceof SetupLibraryActivity return root; } + /** K2GO-394: render (or hide) the subordinate download card from the dash-node progress feed. */ + private void renderDownload(org.appdevforall.k2go.maps.domain.MapsDownloadProgress p) { + if (downloadCard == null) { + return; + } + if (p == null || !p.isRunning()) { + downloadCard.setVisibility(View.GONE); + return; + } + downloadCard.setVisibility(View.VISIBLE); + // A network drop: dash-node is re-establishing (aria2 --continue). Show the attempt counter + // and an indeterminate bar; the partial is kept, so the percent will pick back up on resume. + if (p.isReconnecting()) { + dlBar.setIndeterminate(true); + dlBytes.setText(""); + dlRate.setText(getString(R.string.k2go_dl_attempt, p.reconnectAttempt, p.reconnectTotal)); + dlPause.setText(getString(R.string.k2go_dl_pause)); + return; + } + int pct = p.percent; + dlBar.setIndeterminate(pct < 0); + if (pct >= 0) { + dlBar.setProgressCompat(pct, true); + } + dlBytes.setText(pct >= 0 ? (pct + "%") : ""); + if (p.isPaused()) { + dlRate.setText(getString(R.string.k2go_dl_paused)); + dlPause.setText(getString(R.string.k2go_dl_resume)); + } else { + // The poll gives the rate as a display token ("3.4 MB"); append the per-second suffix. + dlRate.setText(p.speed.isEmpty() ? "" : p.speed + "/s"); + dlPause.setText(getString(R.string.k2go_dl_pause)); + } + } + /** K2GO-374: seed the panel's deque from the current log snapshot when it is expanded. */ private void seedLog() { logLines.clear(); diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressActivity.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressActivity.java index bdea5feb1..55b6dcfcf 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressActivity.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressActivity.java @@ -1358,8 +1358,12 @@ private boolean isLiveDetail(String key) { */ private void configureDetailBar() { if (!showingDetail || detailKey == null || detailBackBtn == null) return; - final boolean isModule = detailKey.startsWith("mod:"); - final String moduleKey = isModule ? detailKey.substring(4) : null; + // K2GO-394: the maps detail opens under the legacy key "maps" (not "mod:maps"), but maps IS a + // module, so treat it as one here -> it gets the same Cancel-while-running (the mockup's op-level + // Cancel install) and Retry-on-failure the other modules already have, with no duplicated logic. + final boolean isModule = detailKey.startsWith("mod:") || "maps".equals(detailKey); + final String moduleKey = !isModule ? null + : (detailKey.startsWith("mod:") ? detailKey.substring(4) : detailKey); ModuleQueueState mq = ModuleQueueRepository.get().current(); boolean moduleFailed = isModule && mq.didFail(moduleKey); boolean moduleRunning = isModule && mq.isInstalling(moduleKey); diff --git a/controller/app/src/main/res/layout/fragment_k2go_maps_preparing.xml b/controller/app/src/main/res/layout/fragment_k2go_maps_preparing.xml index 7f7262af5..e87cd8333 100644 --- a/controller/app/src/main/res/layout/fragment_k2go_maps_preparing.xml +++ b/controller/app/src/main/res/layout/fragment_k2go_maps_preparing.xml @@ -79,6 +79,72 @@ + + + + + + + + + + + + + + +