From bb2c16e760c2fbc84977283a3bd7f0449153d25f Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Mon, 7 Sep 2026 01:21:58 -0600 Subject: [PATCH 01/11] K2GO-394 feat(maps): RPC client, progress domain, and runrole handshake (B.app foundation) The app-side of the resilient maps download. proot shares the host netns, so the in-proot aria2c the is_proot download task opens on 127.0.0.1:6810 is reachable here. - MapsDownloadProgress (pure domain): aria2's status + bytes -> phase, percent, ETA. - MapsDownloadRpc: ~1s poll of aria2.tellActive for the download bar, pause/resume, and shutdown-on-complete -- device-verified that aria2c with --enable-rpc does NOT exit on complete, so the blocking Ansible task needs the app to call aria2.shutdown. Tolerant of an absent RPC (a stock rootfs, or between files) -> reports idle, no crash. - MapsDownloadRepository: the one LiveData the progress screen observes. - MapsRunroleCommand: an overload writes the RPC handshake (maps_download_rpc_secret/ port) into local_vars, with a D2 hex-token guard; the 4-arg form stays RPC-free. Not wired into the install pipeline yet (next commit). No behavior change on its own. --- .../install/domain/MapsRunroleCommand.java | 29 +- .../k2go/maps/data/MapsDownloadRpc.java | 308 ++++++++++++++++++ .../maps/domain/MapsDownloadProgress.java | 95 ++++++ .../presentation/MapsDownloadRepository.java | 57 ++++ .../domain/MapsRunroleCommandTest.java | 28 ++ .../maps/domain/MapsDownloadProgressTest.java | 65 ++++ 6 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 controller/app/src/main/java/org/appdevforall/k2go/maps/data/MapsDownloadRpc.java create mode 100644 controller/app/src/main/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgress.java create mode 100644 controller/app/src/main/java/org/appdevforall/k2go/maps/presentation/MapsDownloadRepository.java create mode 100644 controller/app/src/test/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgressTest.java 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..e496cd09a 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,8 @@ * 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: an optional overload also writes the download RPC handshake + * (maps_download_rpc_secret/port) so the is_proot download task is app-controllable. * ============================================================================ */ package org.appdevforall.k2go.install.domain; @@ -36,18 +38,39 @@ private MapsRunroleCommand() {} // K2GO-393: the completion marker (maps_installed: True) lives here, written only at the END of the // maps role's install.yml. runrole gates on this file (and the vars files), not on iiab.ini. private static final String IIAB_STATE = "/etc/iiab/iiab_state.yml"; + /** K2GO-394: the loopback port the is_proot download task opens aria2's JSON-RPC on. */ + public static final int RPC_PORT = 6810; + // K2GO-394: a per-run RPC secret must be a hex token -- the app generates it, and this is the D2 + // shell-injection guard: anything else is dropped (the download runs without RPC control). + private static final java.util.regex.Pattern RPC_SECRET_OK = + java.util.regex.Pattern.compile("[a-fA-F0-9]{8,64}"); /** Build the sed-delete + echo (append-if-missing) + runrole command for the given selection. */ public static String build(String vector, String sat, String terrain, boolean searchOn) { + return build(vector, sat, terrain, searchOn, null, 0); + } + + /** + * K2GO-394: as {@link #build(String, String, String, boolean)}, plus the RPC handshake the + * resilient-download path uses -- {@code maps_download_rpc_secret} / {@code maps_download_rpc_port} + * written into {@code local_vars} so the is_proot download task opens a loopback JSON-RPC the app + * drives. The secret is dropped unless it is a safe hex token (D2); with no secret the download + * still runs, just without app control (the role's default port, no auth). + */ + public static String build(String vector, String sat, String terrain, boolean searchOn, + String rpcSecret, int rpcPort) { String vq = VECTOR_OK.contains(vector) ? vector : "11"; String s = SAT_OK.contains(sat) ? sat : "none"; String t = TERRAIN_OK.contains(terrain) ? terrain : "0-none"; String engine = searchOn ? "static" : ""; + boolean rpc = rpcSecret != null && RPC_SECRET_OK.matcher(rpcSecret).matches() + && rpcPort > 1024 && rpcPort < 65536; // ADFA-5075: purge both the current key (vector_zoom) and the pre-rename one (vector_quality) // so a box that already wrote the old line doesn't trip the role's transition guard. + // K2GO-394: also purge the download_rpc_* keys so a re-run does not stack stale secrets/ports. return "sed -i -E '/^[[:space:]]*maps_(install|enabled|region_downloader|vector_zoom|vector_quality|" + "satellite_zoom|terrain_zoom|search_engine|search_static_db|search_nominatim_db|" + - "ne6_zoom|preset_full_quality_regions)[[:space:]]*:/d' " + LV + + "ne6_zoom|preset_full_quality_regions|download_rpc_secret|download_rpc_port)[[:space:]]*:/d' " + LV + " && echo 'maps_install: True' >> " + LV + " && echo 'maps_enabled: True' >> " + LV + " && echo 'maps_region_downloader: True' >> " + LV + @@ -59,6 +82,10 @@ public static String build(String vector, String sat, String terrain, boolean se " && echo 'maps_search_nominatim_db: basic' >> " + LV + " && echo 'maps_ne6_zoom: 6' >> " + LV + " && echo 'maps_preset_full_quality_regions: []' >> " + LV + + (rpc + ? " && echo 'maps_download_rpc_secret: " + rpcSecret + "' >> " + LV + + " && echo 'maps_download_rpc_port: " + rpcPort + "' >> " + LV + : "") + " && cd /opt/iiab/iiab" + // K2GO-393: pick the mode at runtime by the marker, mirroring runrole's own // `grep -q "^maps_" $IIAB_STATE_FILE` gate. Marker present (a first selection, or a diff --git a/controller/app/src/main/java/org/appdevforall/k2go/maps/data/MapsDownloadRpc.java b/controller/app/src/main/java/org/appdevforall/k2go/maps/data/MapsDownloadRpc.java new file mode 100644 index 000000000..330c5a4d0 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/maps/data/MapsDownloadRpc.java @@ -0,0 +1,308 @@ +/* + * ============================================================================ + * Name : MapsDownloadRpc.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-394. Drives the in-proot maps aria2c over loopback JSON-RPC: + * live progress, pause/resume, and shutdown-on-complete so the + * blocking Ansible download task returns. Degrades to idle when the + * RPC is absent (a stock rootfs without the is_proot patch). + * ============================================================================ + */ +package org.appdevforall.k2go.maps.data; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.util.Log; + +import org.appdevforall.k2go.maps.domain.MapsDownloadProgress; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * The app-side driver for the maps download's in-proot aria2c (K2GO-394). + * + *

proot shares the host network namespace, so the aria2c the is_proot download task opens on + * {@code 127.0.0.1:} is reachable here. This polls {@code aria2.tellActive} ~1 s for the + * subordinate download bar, exposes pause / resume, and -- the load-bearing bit verified on device -- + * calls {@code aria2.shutdown} the moment the download completes, because aria2c with {@code + * --enable-rpc} does NOT exit on its own and the Ansible {@code shell} task would otherwise block + * forever. + * + *

Per file, and tolerant of absence. Each downloaded file is a fresh aria2c on the same + * port; between files, and on a stock rootfs that has no is_proot patch, the RPC simply does not + * answer. That is not an error -- it reports {@link Listener#onDownloadIdle()} and keeps polling, so + * the caller falls back to phase-only progress (Variant 3) with no crash. + * + *

Shutdown fires only on a genuine {@code complete}: an errored or paused download is never shut + * down (that would let the shell task move a partial file), so a give-up or a hang is left to the + * stall watch / recovery. Cancellation is the operation-level kill (it takes proot and aria2c with + * it), not an RPC call here. + */ +public final class MapsDownloadRpc { + + private static final String TAG = "K2Go-MapsRpc"; + private static final long POLL_MS = 1000L; + private static final int TIMEOUT_MS = 3000; + + /** What the caller (InstallService) bridges to the UI repository and the reconnection manager. */ + public interface Listener { + /** A live download exists (active or paused): render the subordinate bar. Main thread. */ + void onProgress(MapsDownloadProgress progress); + + /** No download to show right now -- RPC absent, between files, or finished. Main thread. */ + void onDownloadIdle(); + } + + private final String endpoint; + private final String token; // "token:", or null when no secret was set + private final Listener listener; + private final Handler main = new Handler(Looper.getMainLooper()); + + private HandlerThread thread; + private Handler poll; // the background poll looper + private volatile boolean running; + private boolean sawActive; // seen an active download since the RPC last came up + private boolean rpcUpLastPoll; // to detect a fresh aria2c (a new per-file session) + + public MapsDownloadRpc(int port, String secret, Listener listener) { + this.endpoint = "http://127.0.0.1:" + port + "/jsonrpc"; + this.token = (secret != null && !secret.isEmpty()) ? "token:" + secret : null; + this.listener = listener; + } + + /** Begin polling on a background looper. Idempotent. */ + public void start() { + if (running) { + return; + } + running = true; + sawActive = false; + rpcUpLastPoll = false; + thread = new HandlerThread("maps-download-rpc"); + thread.start(); + poll = new Handler(thread.getLooper()); + poll.post(this::tick); + } + + /** Stop polling and drop the looper. Does NOT shut aria2c down -- teardown/cancel own that. */ + public void stop() { + running = false; + if (poll != null) { + poll.removeCallbacksAndMessages(null); + } + if (thread != null) { + thread.quitSafely(); + thread = null; + } + } + + /** Pause the active download (best-effort). Safe to call when nothing is downloading. */ + public void pause() { + callAsync("aria2.pauseAll"); + } + + /** Resume a paused download (best-effort). */ + public void resume() { + callAsync("aria2.unpauseAll"); + } + + // ---- the poll loop --------------------------------------------------------------------------- + + private void tick() { + if (!running) { + return; + } + JSONObject stat = rpc("aria2.getGlobalStat", null); + if (stat == null) { + // RPC not answering: no aria2c up (between files, done, or a stock rootfs). Not an error. + rpcUpLastPoll = false; + postIdle(); + rearm(); + return; + } + if (!rpcUpLastPoll) { + sawActive = false; // a fresh aria2c since the last poll -- start this file's session clean + rpcUpLastPoll = true; + } + int active = optInt(stat, "numActive"); + int waiting = optInt(stat, "numWaiting"); + if (active > 0) { + sawActive = true; + postProgress(readOne("aria2.tellActive", true)); + } else if (waiting > 0) { + // A paused (or queued) download: show it, never shut it down. + postProgress(readOne("aria2.tellWaiting", false)); + } else if (sawActive) { + // Was downloading, now nothing active or waiting -> the file finished. Shut aria2c down so + // the blocking shell task returns. Only on a genuine complete (guarded below). + if (isLastStoppedComplete()) { + Log.i(TAG, "download complete; shutting aria2c down so the runrole task advances"); + call("aria2.shutdown", null); + postProgress(MapsDownloadProgress.of("complete", 0, 0, 0)); + sawActive = false; + } else { + // Errored or an unclear stop -- do not shut down (would move a partial). Leave it for + // the stall watch / recovery. Report idle so the bar does not sit on a stale percent. + postIdle(); + } + } else { + postIdle(); // aria2c up but nothing has started yet (resolving the metalink) + } + rearm(); + } + + private void rearm() { + if (running && poll != null) { + poll.postDelayed(this::tick, POLL_MS); + } + } + + /** Read the single active/waiting download's status+bytes into a progress snapshot. */ + private MapsDownloadProgress readOne(String method, boolean active) { + JSONObject params = null; + JSONArray arr = rpcArray(method, active + ? new Object[]{keysParam()} + : new Object[]{0, 1, keysParam()}); + if (arr == null || arr.length() == 0) { + return MapsDownloadProgress.none(); + } + JSONObject d = arr.optJSONObject(0); + if (d == null) { + return MapsDownloadProgress.none(); + } + return MapsDownloadProgress.of( + d.optString("status", ""), + optLong(d, "completedLength"), + optLong(d, "totalLength"), + optLong(d, "downloadSpeed")); + } + + /** Whether the most recent stopped download completed cleanly (vs. errored) -- the shutdown guard. */ + private boolean isLastStoppedComplete() { + JSONArray arr = rpcArray("aria2.tellStopped", new Object[]{0, 5, keysParam()}); + if (arr == null || arr.length() == 0) { + return false; + } + // Any error among the recent stops means the file did not finish -- do not shut down. + for (int i = 0; i < arr.length(); i++) { + JSONObject d = arr.optJSONObject(i); + if (d != null && "error".equals(d.optString("status"))) { + return false; + } + } + return true; + } + + private JSONArray keysParam() { + JSONArray keys = new JSONArray(); + keys.put("gid"); + keys.put("status"); + keys.put("completedLength"); + keys.put("totalLength"); + keys.put("downloadSpeed"); + return keys; + } + + // ---- JSON-RPC over HTTP ---------------------------------------------------------------------- + + private void callAsync(final String method) { + if (poll != null) { + poll.post(() -> call(method, null)); + } + } + + /** Fire a method for its side effect; ignore the result. */ + private void call(String method, Object[] extraParams) { + rpc(method, extraParams); + } + + /** Call a method whose result is a JSON object (getGlobalStat). Null on any failure. */ + private JSONObject rpc(String method, Object[] extraParams) { + JSONObject resp = post(method, extraParams); + return resp == null ? null : resp.optJSONObject("result"); + } + + /** Call a method whose result is a JSON array (tellActive/Waiting/Stopped). Null on any failure. */ + private JSONArray rpcArray(String method, Object[] extraParams) { + JSONObject resp = post(method, extraParams); + return resp == null ? null : resp.optJSONArray("result"); + } + + private JSONObject post(String method, Object[] extraParams) { + HttpURLConnection c = null; + try { + JSONArray params = new JSONArray(); + if (token != null) { + params.put(token); + } + if (extraParams != null) { + for (Object p : extraParams) { + params.put(p); + } + } + JSONObject body = new JSONObject(); + body.put("jsonrpc", "2.0"); + body.put("id", "k2go"); + body.put("method", method); + body.put("params", params); + + c = (HttpURLConnection) new URL(endpoint).openConnection(); + c.setConnectTimeout(TIMEOUT_MS); + c.setReadTimeout(TIMEOUT_MS); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + byte[] out = body.toString().getBytes(StandardCharsets.UTF_8); + try (OutputStream os = c.getOutputStream()) { + os.write(out); + } + if (c.getResponseCode() != 200) { + return null; + } + java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int r; + while ((r = c.getInputStream().read(chunk)) != -1) { + buf.write(chunk, 0, r); + } + return new JSONObject(new String(buf.toByteArray(), StandardCharsets.UTF_8)); + } catch (Exception e) { + return null; // unreachable / between files / stock rootfs -- caller treats as idle + } finally { + if (c != null) { + c.disconnect(); + } + } + } + + private void postProgress(final MapsDownloadProgress p) { + main.post(() -> listener.onProgress(p)); + } + + private void postIdle() { + main.post(listener::onDownloadIdle); + } + + private static int optInt(JSONObject o, String key) { + try { + return Integer.parseInt(o.optString(key, "0")); + } catch (NumberFormatException e) { + return 0; + } + } + + private static long optLong(JSONObject o, String key) { + try { + return Long.parseLong(o.optString(key, "0")); + } catch (NumberFormatException e) { + return 0L; + } + } +} 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..4e487b037 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgress.java @@ -0,0 +1,95 @@ +/* + * ============================================================================ + * Name : MapsDownloadProgress.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-394. One in-proot maps download's progress, derived from + * aria2's RPC fields. Pure JVM, no Android, no JSON framework. + * ============================================================================ + */ +package org.appdevforall.k2go.maps.domain; + +/** + * A snapshot of the one aria2 download the maps runrole is on, as the subordinate download bar + * needs it: a phase, the bytes, and the two derived numbers (percent, ETA). + * + *

Pure by design: the RPC client ({@code MapsDownloadRpc}) parses aria2's JSON and hands the + * primitives here; the rule for "what percent / what ETA / which phase" lives in one testable place, + * not scattered in the client or the UI. aria2's own {@code status} string is the source of the + * phase -- {@code active} / {@code paused} / {@code complete} -- so the app never invents one. + */ +public final class MapsDownloadProgress { + + /** aria2's download lifecycle, only the states the UI distinguishes. */ + public enum Phase { NONE, ACTIVE, PAUSED, COMPLETE } + + public final Phase phase; + public final long completedBytes; + public final long totalBytes; + public final long speedBytesPerSec; + + private MapsDownloadProgress(Phase phase, long completed, long total, long speed) { + this.phase = phase; + this.completedBytes = Math.max(0, completed); + this.totalBytes = Math.max(0, total); + this.speedBytesPerSec = Math.max(0, speed); + } + + /** Nothing is downloading right now -- between files, or the RPC has no active/paused job. */ + public static MapsDownloadProgress none() { + return new MapsDownloadProgress(Phase.NONE, 0, 0, 0); + } + + /** + * Build from aria2's fields ({@code aria2.tellActive} / {@code tellStatus}): the {@code status} + * string plus {@code completedLength} / {@code totalLength} / {@code downloadSpeed} in bytes. + * An unknown or empty status is {@link Phase#NONE}. + */ + public static MapsDownloadProgress of(String status, long completed, long total, long speed) { + Phase p; + if ("active".equals(status)) { + p = Phase.ACTIVE; + } else if ("paused".equals(status)) { + p = Phase.PAUSED; + } else if ("complete".equals(status)) { + p = Phase.COMPLETE; + } else { + p = Phase.NONE; + } + return new MapsDownloadProgress(p, completed, total, speed); + } + + /** 0..100, or -1 when the total is not known yet (aria2 is still resolving the metalink). */ + public int percent() { + if (totalBytes <= 0) { + return -1; + } + return (int) Math.min(100L, completedBytes * 100L / totalBytes); + } + + /** Seconds left at the current rate, or -1 when there is nothing to go on (paused, or no rate). */ + public long etaSeconds() { + if (speedBytesPerSec <= 0 || totalBytes <= completedBytes) { + return -1L; + } + return (totalBytes - completedBytes) / speedBytesPerSec; + } + + public boolean isActive() { + return phase == Phase.ACTIVE; + } + + public boolean isPaused() { + return phase == Phase.PAUSED; + } + + /** The download finished -- the app's cue to {@code aria2.shutdown} so the runrole task returns. */ + public boolean isComplete() { + return phase == Phase.COMPLETE; + } + + /** Whether there is a live download to show a bar for (active or paused). */ + public boolean isRunning() { + return phase == Phase.ACTIVE || phase == Phase.PAUSED; + } +} 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..3e738f441 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/maps/presentation/MapsDownloadRepository.java @@ -0,0 +1,57 @@ +/* + * ============================================================================ + * 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 + * {@code MapsDownloadRpc} listener; 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 stock rootfs (no RPC) 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 (main thread only, as MapsDownloadRpc delivers on the main thread). */ + public void post(MapsDownloadProgress p) { + state.setValue(p != null ? p : MapsDownloadProgress.none()); + } + + /** Clear back to "nothing downloading" -- on teardown, or when the RPC goes idle. */ + public void clear() { + state.setValue(MapsDownloadProgress.none()); + } +} diff --git a/controller/app/src/test/java/org/appdevforall/k2go/install/domain/MapsRunroleCommandTest.java b/controller/app/src/test/java/org/appdevforall/k2go/install/domain/MapsRunroleCommandTest.java index 3f18d47d2..7213cf9a0 100644 --- a/controller/app/src/test/java/org/appdevforall/k2go/install/domain/MapsRunroleCommandTest.java +++ b/controller/app/src/test/java/org/appdevforall/k2go/install/domain/MapsRunroleCommandTest.java @@ -11,6 +11,7 @@ */ package org.appdevforall.k2go.install.domain; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -39,6 +40,33 @@ public void selectsRunroleModeAtRuntimeFromTheMarker() { assertTrue(cmd.contains("./runrole maps")); // marker absent (recovery) } + /** K2GO-394: a safe hex secret + a valid port write the RPC handshake, and the sed purges the + * old download_rpc_* lines so a re-run does not stack stale ones. */ + @Test + public void writesRpcHandshakeWhenSecretIsSafe() { + String cmd = MapsRunroleCommand.build("11", "9", "7", true, "deadbeefcafe", 6810); + assertTrue(cmd.contains("maps_download_rpc_secret: deadbeefcafe")); + assertTrue(cmd.contains("maps_download_rpc_port: 6810")); + assertTrue(cmd.contains("download_rpc_secret|download_rpc_port")); // purged by the sed + } + + /** K2GO-394 (D2): an unsafe secret or a bad port drops the handshake entirely -- the download still + * runs, just without RPC control. Never interpolate an unvalidated token into the shell command. */ + @Test + public void dropsRpcHandshakeForUnsafeSecretOrPort() { + String injified = MapsRunroleCommand.build("11", "9", "7", true, "x; rm -rf /", 6810); + assertFalse(injified.contains("maps_download_rpc_secret")); + assertFalse(injified.contains("rm -rf")); + String badPort = MapsRunroleCommand.build("11", "9", "7", true, "deadbeefcafe", 22); + assertFalse(badPort.contains("maps_download_rpc_secret")); + } + + /** The 4-arg build stays RPC-free (the recovery/A1 path and these tests rely on it). */ + @Test + public void fourArgBuildHasNoRpcHandshake() { + assertFalse(MapsRunroleCommand.build("11", "9", "7", true).contains("maps_download_rpc")); + } + @Test public void offLayersMapToNoneAndSearchEngineEmpty() { String cmd = MapsRunroleCommand.build("nat-z8", null, "0-none", false); diff --git a/controller/app/src/test/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgressTest.java b/controller/app/src/test/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgressTest.java new file mode 100644 index 000000000..c01215202 --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/maps/domain/MapsDownloadProgressTest.java @@ -0,0 +1,65 @@ +/* + * ============================================================================ + * Name : MapsDownloadProgressTest.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-394. Unit tests for the maps download progress rule. + * ============================================================================ + */ +package org.appdevforall.k2go.maps.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class MapsDownloadProgressTest { + + @Test + public void activeYieldsPercentAndEta() { + // 1.0 GiB of 5.99 GiB at ~2.9 MB/s -- the device sample. + MapsDownloadProgress p = MapsDownloadProgress.of("active", 1_015_808L, 6_427_331_113L, 3_027_977L); + assertTrue(p.isActive()); + assertTrue(p.isRunning()); + assertEquals(0, p.percent()); // <1% + assertEquals((6_427_331_113L - 1_015_808L) / 3_027_977L, p.etaSeconds()); + } + + @Test + public void pausedHasNoEtaButKeepsBytes() { + MapsDownloadProgress p = MapsDownloadProgress.of("paused", 902_299_648L, 6_427_331_113L, 0L); + assertTrue(p.isPaused()); + assertTrue(p.isRunning()); + assertEquals(14, p.percent()); + assertEquals(-1L, p.etaSeconds()); // no rate while paused + } + + @Test + public void completeIsTheShutdownCue() { + MapsDownloadProgress p = MapsDownloadProgress.of("complete", 6_427_331_113L, 6_427_331_113L, 0L); + assertTrue(p.isComplete()); + assertFalse(p.isRunning()); + assertEquals(100, p.percent()); + } + + /** aria2 is still resolving the metalink: total is 0, so percent is "unknown", not a divide-by-zero. */ + @Test + public void unknownTotalIsMinusOnePercent() { + MapsDownloadProgress p = MapsDownloadProgress.of("active", 0L, 0L, 0L); + assertEquals(-1, p.percent()); + assertEquals(-1L, p.etaSeconds()); + } + + @Test + public void noneAndUnknownStatusAreNotRunning() { + assertFalse(MapsDownloadProgress.none().isRunning()); + assertEquals(MapsDownloadProgress.Phase.NONE, MapsDownloadProgress.of("waiting", 1, 2, 0).phase); + } + + /** Percent never exceeds 100 even if aria2 reports completed slightly over total (rounding). */ + @Test + public void percentClampsAtHundred() { + assertEquals(100, MapsDownloadProgress.of("active", 101L, 100L, 5L).percent()); + } +} From a2d00a45c6b31923f06aeaecb92058ae7ecc658c Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Mon, 7 Sep 2026 01:22:11 -0600 Subject: [PATCH 02/11] K2GO-394 chore(maps): TEMP app-side role overlay to de-risk the RPC (remove before merge) Test-only scaffolding so the is_proot download task (with --enable-rpc/--continue) can be exercised on device without waiting on a rootfs bake. InstallService copies the patched download_large_file.yml over the role before the runrole reads it. TEMPORARY: this asset + overlayMapsDownloadTask() must be removed before merge; the real role change ships as an upstream_patches patch (rootfs bake). Tracked so it leaves no trace. See the asset header. --- .../main/assets/maps/download_large_file.yml | 194 ++++++++++++++++++ .../install/presentation/InstallService.java | 40 ++++ 2 files changed, 234 insertions(+) create mode 100644 controller/app/src/main/assets/maps/download_large_file.yml diff --git a/controller/app/src/main/assets/maps/download_large_file.yml b/controller/app/src/main/assets/maps/download_large_file.yml new file mode 100644 index 000000000..d00032f76 --- /dev/null +++ b/controller/app/src/main/assets/maps/download_large_file.yml @@ -0,0 +1,194 @@ +# K2GO-394 (Deliverable B) -- app-side runtime overlay of the upstream maps download task. +# +# The app copies this file over roles/maps/tasks/download_large_file.yml in the rootfs +# BEFORE running the maps runrole. It is byte-identical to the upstream task EXCEPT the +# single "Download ..." task is split into two by is_proot, so the app (a proot host) can +# drive the in-proot aria2c over JSON-RPC: +# - when: not is_proot -> the upstream aria2c, untouched. +# - when: is_proot -> the same aria2c with --continue (resume the partial) and +# --enable-rpc --rpc-listen-all=false (loopback control), plus the +# per-run secret/port the app writes into local_vars. +# Two separate tasks (not conditional flags on one) so upstream can evolve their non-proot +# block without touching ours. Both keep `creates: dest_path` and the identical post-download +# tail (duplicated on purpose, for block isolation). This overlay is TEMPORARY, pending the +# upstream PR to iiab/iiab (see controller/docs/K2GO-394-maps-download-rpc-patch.md). +# +# Below the split, everything is verbatim from the upstream task. + +- name: "Make sure archives end in .tar.gz as expected" + assert: + that: (not (expand_archive | default(false))) or item[-7:] == ".tar.gz" + fail_msg: "Error: expect download file name to end in .tar.gz when expand_archive=true" + quiet: yes + +- vars: + file_name: "{{ item | split('/') | last }}" + + # Where the file eventually goes. [:-7] is for .tar.gz + dest_path: "{{ dest_base_path }}/{{ file_name[:-7] if expand_archive | default(false) else file_name }}" + + # Use md5sum to generate the log file name: + # * Name has short, consistent length. The "to check progress..." message + # fits within 80 characters on terminal (at least in the current + # ansible version) in case users have small screens. + # * If the user mis-copies, it's less likely to do anything destructive. + log_file: "{{ item | hash('md5') }}.log" + working_dir: "/library/downloads/maps" + + # If `file_name` is an archive, it needs to be extracted to `extracted_dir_name` + # before moving to the final destination. + extracted_dir_name: "{{ file_name[:-7] if expand_archive | default(false) else '' }}" + + block: + - name: "Fetching file size for {{ item }} via .meta4 file" + shell: | + import sys, xmltodict, requests, os + + if os.path.exists("{{ dest_path }}"): + sys.exit(0) + + size = int(xmltodict.parse( + requests.get("{{ item }}.meta4").content + )['metalink']['file']['size']) + KiB = 1024 + MiB = KiB * 1024 + GiB = MiB * 1024 + + if size < MiB: + size_disp = str(round(size / KiB, 2)) + ' KiB' + elif size < GiB: + size_disp = str(round(size / MiB, 2)) + ' MiB' + else: + size_disp = str(round(size / GiB, 2)) + ' GiB' + print('(' + size_disp + ') To check download progress, see previous step (above).') + args: + executable: /usr/bin/python3 + + # The existence of console output in download_file_details + # implies that the download should happen. + register: download_file_details + + # Bright green Ansible output, so blank lines are probably not necessary in the end? + - debug: + msg: + - " TO CHECK DOWNLOAD PROGRESS, RUN: " + - " tail {{ working_dir }}/{{ log_file }} " + # If the file already exists (and thus no download will happen) the above + # task should exit before any console output. + when: download_file_details.stdout_lines|length != 0 + + # K2GO-394: non-proot -- the upstream aria2c, untouched. + - name: "Download {{ file_name }} {{ download_file_details.stdout_lines.0 | default('(done)')}}" + # cd to the temp directory so that the aria2c file, data file, and (perhaps + # eventually) torrent file all end up there. + shell: | + set -euo pipefail + + cd {{ working_dir }} + + date > "{{ log_file }}" + echo "Downloading {{ file_name }}..." >> "{{ log_file }}" + echo >> "{{ log_file }}" + aria2c \ + --async-dns=false \ + --connect-timeout="{{ download_timeout }}" \ + --log-level=warn \ + --console-log-level=warn \ + --summary-interval=60 \ + --download-result=hide \ + --follow-metalink=mem \ + --max-connection-per-server=4 \ + --file-allocation=falloc \ + --show-console-readout=false \ + --enable-http-pipelining=true \ + --seed-time=0 \ + --allow-overwrite=true \ + "{{ item }}.meta4" \ + >> "{{ log_file }}" + + chmod 644 "{{ file_name }}" + if "{{ 'true' if expand_archive | default(false) else 'false' }}"; then + rm -rf "{{ extracted_dir_name }}" + mkdir "{{ extracted_dir_name }}" + chmod 755 "{{ extracted_dir_name }}" + tar xzf "{{ file_name }}" -C "{{ extracted_dir_name }}" --strip-components 1 + rm "{{ file_name }}" + mv "{{ extracted_dir_name }}" "{{ dest_path }}" + else + mv "{{ file_name }}" "{{ dest_path }}" + fi + rm "{{ log_file }}" + args: + executable: /bin/bash + creates: "{{ dest_path }}" + when: not is_proot + + # K2GO-394: proot -- the same aria2c, but resumable (--continue, no --allow-overwrite) and + # host-controllable over loopback JSON-RPC (--enable-rpc). The app pauses/resumes/monitors it + # by the per-run secret + port written into local_vars (maps_download_rpc_secret/port). The + # RPC listener is loopback-only (--rpc-listen-all=false). aria2c stays alive while its one + # download is paused, so this Ansible task keeps blocking and the play does not advance. + - name: "Download {{ file_name }} {{ download_file_details.stdout_lines.0 | default('(done)')}}" + shell: | + set -euo pipefail + + cd {{ working_dir }} + + date > "{{ log_file }}" + echo "Downloading {{ file_name }}..." >> "{{ log_file }}" + echo >> "{{ log_file }}" + aria2c \ + --async-dns=false \ + --connect-timeout="{{ download_timeout }}" \ + --log-level=warn \ + --console-log-level=warn \ + --summary-interval=60 \ + --download-result=hide \ + --follow-metalink=mem \ + --max-connection-per-server=4 \ + --file-allocation=falloc \ + --show-console-readout=false \ + --enable-http-pipelining=true \ + --seed-time=0 \ + --continue=true \ + --enable-rpc=true \ + --rpc-listen-all=false \ + --rpc-listen-port="{{ maps_download_rpc_port | default(6810) }}" \ + {{ ('--rpc-secret=' + maps_download_rpc_secret) if (maps_download_rpc_secret | default('')) | length > 0 else '' }} \ + "{{ item }}.meta4" \ + >> "{{ log_file }}" + + chmod 644 "{{ file_name }}" + if "{{ 'true' if expand_archive | default(false) else 'false' }}"; then + rm -rf "{{ extracted_dir_name }}" + mkdir "{{ extracted_dir_name }}" + chmod 755 "{{ extracted_dir_name }}" + tar xzf "{{ file_name }}" -C "{{ extracted_dir_name }}" --strip-components 1 + rm "{{ file_name }}" + mv "{{ extracted_dir_name }}" "{{ dest_path }}" + else + mv "{{ file_name }}" "{{ dest_path }}" + fi + rm "{{ log_file }}" + args: + executable: /bin/bash + creates: "{{ dest_path }}" + when: is_proot + + rescue: + # We output summaries to a log file for the user's benefit, + # but all of the errors necessarily show up there as well. + # Here, we parse out the error message, ignoring almost all of + # the summary lines, which could be a lot. + # Finally, we exit 1 so Ansible shows this error. + - name: "Error downloading {{ file_name }}" + shell: | + set -euo pipefail + + cd {{ working_dir }} + + echo "aria2c error:" 1>&2 + tac "{{ log_file }}" | sed '/'"Download Progress Summary as of"'/q' | tac 1>&2 + exit 1 + args: + executable: /bin/bash 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..1928e3a0c 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 @@ -958,6 +958,13 @@ private void installNextModule() { ModuleQueueRepository.get().postRunning(nextModule, remainingSnapshot, 0); } + // K2GO-394 (B): overlay the RPC-enabled maps download task before the runrole reads it, so + // the in-proot aria2c is resumable + loopback-controllable. Best-effort; the stock task + // still downloads if it fails. + if ("maps".equals(nextModule)) { + overlayMapsDownloadTask(); + } + // ADFA-4900: for the wizard maps flow, write the full per-layer maps_* var set before // runrole (the generic _install/_enabled echo can't express quality/off/search). final String installCmd = ("maps".equals(nextModule) && hasMapsConfig) @@ -1213,6 +1220,39 @@ private String mapsInstallCmd() { mapsVector, mapsSat, mapsTerrain, mapsSearchOn); } + /** + * K2GO-394 (B): overlay the in-rootfs maps download task with our is_proot RPC variant, so the + * in-proot aria2c is resumable ({@code --continue}) and controllable over loopback JSON-RPC + * ({@code --enable-rpc --rpc-listen-all=false}). The app copies {@code assets/maps/} over the + * role file before the runrole reads it; the file is byte-identical to upstream except the + * single "Download" task is split by {@code is_proot} (see the asset header). + * + *

Temporary until the upstream PR lands. Idempotent (rewritten each run). Best-effort: on any + * failure the stock task stays in place, which still downloads -- just without RPC control. + */ + private void overlayMapsDownloadTask() { + if (debianRootfs == null) { + return; + } + File dest = new File(debianRootfs, "opt/iiab/iiab/roles/maps/tasks/download_large_file.yml"); + File dir = dest.getParentFile(); + if (dir == null || !dir.isDirectory()) { + log("[maps] role tasks dir missing; skipping RPC download overlay"); + return; + } + try (java.io.InputStream in = getAssets().open("maps/download_large_file.yml"); + java.io.OutputStream out = new java.io.FileOutputStream(dest)) { + byte[] buf = new byte[8192]; + int r; + while ((r = in.read(buf)) != -1) { + out.write(buf, 0, r); + } + log("[maps] overlaid the RPC-enabled download task (K2GO-394)"); + } catch (Exception e) { + log("[maps] RPC download overlay failed (continuing with stock task): " + e.getMessage()); + } + } + /** * 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 From c65f90e1635aebd76f6eddfc5fa7ce5eb8c5b651 Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Mon, 7 Sep 2026 01:27:55 -0600 Subject: [PATCH 03/11] K2GO-394 feat(maps): wire the download RPC into the install pipeline (B.app) Start the MapsDownloadRpc monitor when the maps runrole begins and stop it when it ends: it mints a per-run RPC secret (written to local_vars via MapsRunroleCommand), publishes the subordinate download bar to MapsDownloadRepository, and shuts aria2c down on complete so the blocking task returns. - Reconnection: the existing NetworkStateLiveData observer now also pauses the maps download when validated internet is lost and resumes it when it returns (aria2 --continue picks up the partial) -- Android drives it, not aria2's blind retry. - doPause/doResume route a maps operation through the RPC (relaxing the rootfs-only guard), so the notification/UI pause/resume reach the in-proot aria2c. - Cleanup on onProcessExit/onError/teardown. Degrades cleanly when the RPC is absent (stock rootfs): the monitor reports idle. --- .../install/presentation/InstallService.java | 81 ++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) 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 1928e3a0c..c8e1f553a 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 per-run aria2 RPC secret and the live download monitor for the maps download. + private String mapsRpcSecret; + private volatile org.appdevforall.k2go.maps.data.MapsDownloadRpc mapsRpc; private File iiabRootDir; // filesDir/rootfs private File debianRootfs; // filesDir/rootfs/installed-rootfs/iiab @@ -256,6 +259,18 @@ public void onCreate() { */ private void onValidatedNetworkReturned() { if (finished || cancelled) return; + // K2GO-394: maps download reconnection. Android drives it (not aria2's blind retry): on a + // network change, pause the in-proot aria2c when there is no validated internet and resume it + // (aria2 --continue picks up the partial) when there is. Best-effort; a no-op between files. + if (mapsRpc != null) { + if (hasValidatedInternet()) { + log("[maps] validated network -- resuming the download"); + mapsRpc.resume(); + } else { + log("[maps] network lost -- pausing the download"); + mapsRpc.pause(); + } + } if (!InstallProgressRepository.get().current().isSoftFailed()) return; if (!hasValidatedInternet()) return; Log.i(TAG, "ADFA-4895: a validated network returned while the download was held — resuming"); @@ -960,9 +975,12 @@ private void installNextModule() { // K2GO-394 (B): overlay the RPC-enabled maps download task before the runrole reads it, so // the in-proot aria2c is resumable + loopback-controllable. Best-effort; the stock task - // still downloads if it fails. + // still downloads if it fails. Mint a per-run RPC secret and start the monitor: it polls the + // loopback aria2c for the download bar, drives pause/resume, and shuts aria2c down on complete. if ("maps".equals(nextModule)) { overlayMapsDownloadTask(); + mapsRpcSecret = newRpcSecret(); + startMapsRpc(); } // ADFA-4900: for the wizard maps flow, write the full per-layer maps_* var set before @@ -1002,6 +1020,7 @@ public void onOutputLine(String line) { @Override public void onProcessExit(int exitCode) { if (cancelled) return; + stopMapsRpc(); // K2GO-394: the runrole ended -> no more download to monitor // Phantom-process killer (Android 12+) can SIGKILL container children -> exit 137. if (exitCode == 137) log("[Install] " + nextModule + " killed by the system (exit 137)"); if (outcome.failed(exitCode)) { @@ -1028,6 +1047,7 @@ public void onProcessExit(int exitCode) { @Override public void onError(String error) { if (cancelled) return; + stopMapsRpc(); // K2GO-394 // The container could not run at all: report this module and stop the batch // (matches the former loop, which aborted on a proot error). failedModules.add(nextModule); @@ -1215,9 +1235,52 @@ 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: also carries the per-run RPC handshake so the is_proot download task is app-controllable. private String mapsInstallCmd() { return org.appdevforall.k2go.install.domain.MapsRunroleCommand.build( - mapsVector, mapsSat, mapsTerrain, mapsSearchOn); + mapsVector, mapsSat, mapsTerrain, mapsSearchOn, + mapsRpcSecret, org.appdevforall.k2go.install.domain.MapsRunroleCommand.RPC_PORT); + } + + /** K2GO-394: a fresh 128-bit hex token for this maps run's aria2 RPC (matches the D2 guard). */ + private static String newRpcSecret() { + byte[] b = new byte[16]; + new java.security.SecureRandom().nextBytes(b); + StringBuilder sb = new StringBuilder(32); + for (byte x : b) { + sb.append(Character.forDigit((x >> 4) & 0xF, 16)).append(Character.forDigit(x & 0xF, 16)); + } + return sb.toString(); + } + + /** + * K2GO-394: start the download monitor for this maps run. It publishes the subordinate download + * bar to {@link org.appdevforall.k2go.maps.presentation.MapsDownloadRepository} and calls + * aria2.shutdown on complete so the blocking runrole task returns. A no-op-safe idle when the + * RPC is absent (a stock rootfs) -- the screen then shows phase-only progress. + */ + private void startMapsRpc() { + stopMapsRpc(); + mapsRpc = new org.appdevforall.k2go.maps.data.MapsDownloadRpc( + org.appdevforall.k2go.install.domain.MapsRunroleCommand.RPC_PORT, mapsRpcSecret, + new org.appdevforall.k2go.maps.data.MapsDownloadRpc.Listener() { + @Override public void onProgress(org.appdevforall.k2go.maps.domain.MapsDownloadProgress p) { + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().post(p); + } + @Override public void onDownloadIdle() { + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().clear(); + } + }); + mapsRpc.start(); + } + + /** K2GO-394: stop the monitor and clear the download bar. Null-safe (only maps ever starts one). */ + private void stopMapsRpc() { + if (mapsRpc != null) { + mapsRpc.stop(); + mapsRpc = null; + } + org.appdevforall.k2go.maps.presentation.MapsDownloadRepository.get().clear(); } /** @@ -1431,6 +1494,13 @@ private String formatEta(int bucket) { */ private void doPause() { if (finished || cancelled) return; + // K2GO-394: a maps (proot) download pauses through its in-proot aria2c over RPC, not the rootfs + // aria2 the checks below govern. The poll reflects the paused state on the download bar. + if (mapsRpc != null) { + log("[maps] pause requested"); + mapsRpc.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"); @@ -1619,6 +1689,12 @@ private static int softFailLine(org.appdevforall.k2go.download.domain.Aria2Exit. */ private void doResume() { if (finished || cancelled) return; + // K2GO-394: resume a maps download through its RPC (aria2 --continue picks up the partial). + if (mapsRpc != null) { + log("[maps] resume requested"); + mapsRpc.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 @@ -1809,6 +1885,7 @@ private void teardown(boolean clearMarker) { // ADFA-5119: nothing to wait for once this is over — neither the window nor a queued attempt. cancelHeldWindow(); cancelPendingRetry(); + stopMapsRpc(); // K2GO-394: drop the download monitor if it is still up if (clearMarker) { org.appdevforall.k2go.InstallGuard.end(this); } else { From 9070357c28732c8b0a120a67176e6ff266cb5e8f Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Mon, 7 Sep 2026 01:41:01 -0600 Subject: [PATCH 04/11] K2GO-394 fix(maps): MapsDownloadRepository uses postValue (thread-safe) InstallService clears/posts the download progress from its background install thread, so setValue crashed (LiveData.setValue is main-thread-only). postValue is safe from any thread. Device-verified: a full maps install now writes the RPC handshake, the monitor drives the in-proot aria2c, and the app shuts it down on complete so the play advances (PLAY RECAP failed=0), with no crash. --- .../k2go/maps/presentation/MapsDownloadRepository.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 3e738f441..338685028 100644 --- 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 @@ -45,13 +45,16 @@ public MapsDownloadProgress current() { return v != null ? v : MapsDownloadProgress.none(); } - /** Post a new snapshot (main thread only, as MapsDownloadRpc delivers on the main thread). */ + /** + * Post a new snapshot. {@code postValue} on purpose: the RPC monitor 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.setValue(p != null ? p : MapsDownloadProgress.none()); + state.postValue(p != null ? p : MapsDownloadProgress.none()); } /** Clear back to "nothing downloading" -- on teardown, or when the RPC goes idle. */ public void clear() { - state.setValue(MapsDownloadProgress.none()); + state.postValue(MapsDownloadProgress.none()); } } From 8b17a0faa777b3476e27de7ce56014f6b08fba71 Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Mon, 7 Sep 2026 01:51:09 -0600 Subject: [PATCH 05/11] K2GO-394 feat(maps): subordinate download bar + Pause/Resume in the maps detail (B.ui) The honest second measure from the mockup: a thinner download bar under the phase spine in the "Preparing your maps" detail, fed by MapsDownloadRepository -- real percent, bytes, rate and ETA from the in-proot aria2c. A download-scope Pause/Resume toggles ACTION_PAUSE/RESUME, which the service routes to aria2 over RPC. Present only while a live download exists; on a stock rootfs (no RPC) the repository stays idle and the card stays hidden -- the screen is then the phase-spine-only Variant 3. Title string parked in strings_untranslated pending l10n at close. --- .../k2go/redesign/MapsPreparingFragment.java | 56 ++++++++++++++++ .../layout/fragment_k2go_maps_preparing.xml | 66 +++++++++++++++++++ .../main/res/values/strings_untranslated.xml | 5 +- 3 files changed, 125 insertions(+), 2 deletions(-) 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..e8d5de46a 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,35 @@ && getActivity() instanceof SetupLibraryActivity return root; } + /** K2GO-394: render (or hide) the subordinate download card from the RPC 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); + int pct = p.percent(); + dlBar.setIndeterminate(pct < 0); + if (pct >= 0) { + dlBar.setProgressCompat(pct, true); + } + String bytes = org.appdevforall.k2go.util.ByteFormatter.toHuman(p.completedBytes) + + " / " + org.appdevforall.k2go.util.ByteFormatter.toHuman(p.totalBytes); + dlBytes.setText(pct >= 0 ? (pct + "% · " + bytes) : bytes); + if (p.isPaused()) { + dlRate.setText(getString(R.string.k2go_dl_paused)); + dlPause.setText(getString(R.string.k2go_dl_resume)); + } else { + String rate = org.appdevforall.k2go.util.ByteFormatter.toHuman(p.speedBytesPerSec) + "/s"; + String eta = org.appdevforall.k2go.install.presentation.EtaText.of(requireContext(), p.etaSeconds()); + dlRate.setText(eta == null || eta.isEmpty() ? rate : (eta + " · " + rate)); + 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/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 @@ + + + + + + + + + + + + + + +