Skip to content

Commit 7fd719f

Browse files
Merge pull request #271 from appdevforall/feat/ADFA-4900-wizard-maps-runrole
ADFA-4900: wizard maps drive a per-layer install via local_vars + run…
2 parents 40bdef1 + 6cb0237 commit 7fd719f

15 files changed

Lines changed: 526 additions & 73 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* ============================================================================
3+
* Name : MapsRunroleCommand.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : ADFA-4900. Pure builder for the maps runrole command driven by the wizard's
7+
* per-layer selection. Kept out of InstallService so it is unit-testable (no Android
8+
* deps). Translates the selection into the maps role's local_vars
9+
* (roles/maps/tasks/install_frontend.yml): satellite/terrain "none" turns the layer
10+
* off; search maps to maps_search_engine + maps_search_static_db. Every var the role's
11+
* iiab.ini step references is written so the play never hits an undefined var. Values
12+
* are validated against a fixed allowlist (D2 shell-injection guard); anything
13+
* unexpected falls back to a safe default. --reinstall forces install.yml to re-fetch
14+
* the chosen tiles (a plain runrole skips it because maps ships in the base image).
15+
* ============================================================================
16+
*/
17+
package org.iiab.controller.install.domain;
18+
19+
import java.util.Arrays;
20+
import java.util.HashSet;
21+
import java.util.Set;
22+
23+
public final class MapsRunroleCommand {
24+
private MapsRunroleCommand() {}
25+
26+
private static final Set<String> VECTOR_OK = new HashSet<>(Arrays.asList("nat-z8", "osm-z11", "osm-z14"));
27+
private static final Set<String> SAT_OK = new HashSet<>(Arrays.asList("none", "7", "9", "11", "13"));
28+
private static final Set<String> TERRAIN_OK = new HashSet<>(Arrays.asList("none", "7", "8", "9", "10"));
29+
private static final String LV = "/etc/iiab/local_vars.yml";
30+
31+
/** Build the sed-delete + echo (append-if-missing) + runrole command for the given selection. */
32+
public static String build(String vector, String sat, String terrain, boolean searchOn) {
33+
String vq = VECTOR_OK.contains(vector) ? vector : "osm-z11";
34+
String s = SAT_OK.contains(sat) ? sat : "none";
35+
String t = TERRAIN_OK.contains(terrain) ? terrain : "none";
36+
String engine = searchOn ? "static" : "";
37+
return "sed -i -E '/^[[:space:]]*maps_(install|enabled|region_downloader|vector_quality|" +
38+
"satellite_zoom|terrain_zoom|search_engine|search_static_db|search_nominatim_db|" +
39+
"ne6_zoom|preset_full_quality_regions)[[:space:]]*:/d' " + LV +
40+
" && echo 'maps_install: True' >> " + LV +
41+
" && echo 'maps_enabled: True' >> " + LV +
42+
" && echo 'maps_region_downloader: True' >> " + LV +
43+
" && echo 'maps_vector_quality: " + vq + "' >> " + LV +
44+
" && echo 'maps_satellite_zoom: " + s + "' >> " + LV +
45+
" && echo 'maps_terrain_zoom: " + t + "' >> " + LV +
46+
" && echo 'maps_search_engine: \"" + engine + "\"' >> " + LV +
47+
" && echo 'maps_search_static_db: pop-1k-cities' >> " + LV +
48+
" && echo 'maps_search_nominatim_db: basic' >> " + LV +
49+
" && echo 'maps_ne6_zoom: full' >> " + LV +
50+
" && echo 'maps_preset_full_quality_regions: []' >> " + LV +
51+
" && cd /opt/iiab/iiab && ./runrole --reinstall maps";
52+
}
53+
}

‎controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ public final class InstallService extends Service {
8787
public static final String MODE_INSTALL = "install";
8888
public static final String MODE_RESET = "reset";
8989

90+
// ADFA-4900: per-layer maps config carried with a module queue of {"maps"}. When present, the
91+
// "maps" module writes the full maps_* var set to local_vars (from the wizard selection) before
92+
// runrole, instead of the generic <key>_install/_enabled echo. Values are validated against a
93+
// fixed allowlist before interpolation (D2).
94+
public static final String EXTRA_MAPS_VECTOR = "mapsVector"; // nat-z8 | osm-z11 | osm-z14
95+
public static final String EXTRA_MAPS_SAT = "mapsSat"; // 7|9|11|13 | none
96+
public static final String EXTRA_MAPS_TERRAIN = "mapsTerrain"; // 7|8|9|10 | none
97+
public static final String EXTRA_MAPS_SEARCH = "mapsSearch"; // boolean: static pop-1k-cities on/off
98+
9099
private PowerManager.WakeLock wakeLock;
91100
private WifiManager.WifiLock wifiLock;
92101

@@ -113,6 +122,11 @@ public final class InstallService extends Service {
113122
private java.util.Deque<String> moduleQueue;
114123
private java.util.List<String> failedModules;
115124

125+
// ADFA-4900: wizard maps per-layer config (only set when the queue is {"maps"} from the wizard).
126+
private boolean hasMapsConfig;
127+
private String mapsVector, mapsSat, mapsTerrain;
128+
private boolean mapsSearchOn;
129+
116130
private File iiabRootDir; // filesDir/rootfs
117131
private File debianRootfs; // filesDir/rootfs/installed-rootfs/iiab
118132

@@ -156,6 +170,13 @@ public int onStartCommand(Intent intent, int flags, int startId) {
156170
}
157171
failedModules = new java.util.ArrayList<>();
158172

173+
// ADFA-4900: pick up the wizard maps per-layer selection, if any.
174+
mapsVector = intent.getStringExtra(EXTRA_MAPS_VECTOR);
175+
mapsSat = intent.getStringExtra(EXTRA_MAPS_SAT);
176+
mapsTerrain = intent.getStringExtra(EXTRA_MAPS_TERRAIN);
177+
mapsSearchOn = intent.getBooleanExtra(EXTRA_MAPS_SEARCH, false);
178+
hasMapsConfig = mapsVector != null && moduleQueue.contains("maps");
179+
159180
startForeground(NOTIFICATION_ID, buildNotification(getString(R.string.install_busy_modules)));
160181
acquireHardwareLocks();
161182
persistQueue();
@@ -658,7 +679,11 @@ private void installNextModule() {
658679
return;
659680
}
660681

661-
String installCmd = "sed -i -E '/^[[:space:]]*" + nextModule + "_(install|enabled)[[:space:]]*:/d' /etc/iiab/local_vars.yml && " +
682+
// ADFA-4900: for the wizard maps flow, write the full per-layer maps_* var set before
683+
// runrole (the generic <key>_install/_enabled echo can't express quality/off/search).
684+
final String installCmd = ("maps".equals(nextModule) && hasMapsConfig)
685+
? mapsInstallCmd()
686+
: "sed -i -E '/^[[:space:]]*" + nextModule + "_(install|enabled)[[:space:]]*:/d' /etc/iiab/local_vars.yml && " +
662687
"echo '" + nextModule + "_install: True' >> /etc/iiab/local_vars.yml && " +
663688
"echo '" + nextModule + "_enabled: True' >> /etc/iiab/local_vars.yml && " +
664689
"cd /opt/iiab/iiab && ./runrole " + roleName;
@@ -717,6 +742,20 @@ private void revertModuleInLocalVars(String module, Runnable then) {
717742
});
718743
}
719744

745+
/**
746+
* ADFA-4900: build the maps runrole command from the wizard's per-layer selection. Translates
747+
* the selection into the maps role's local_vars (roles/maps/tasks/install_frontend.yml):
748+
* satellite/terrain "none" turns the layer off; search maps to maps_search_engine +
749+
* maps_search_static_db. Every var the role's iiab.ini step references is written so the play
750+
* never hits an undefined var. Values are validated against a fixed allowlist (D2); anything
751+
* unexpected falls back to a safe default. Uses sed-delete + echo (append-if-missing).
752+
*/
753+
// ADFA-4900: the maps runrole command is a pure, unit-tested builder (MapsRunroleCommand).
754+
private String mapsInstallCmd() {
755+
return org.iiab.controller.install.domain.MapsRunroleCommand.build(
756+
mapsVector, mapsSat, mapsTerrain, mapsSearchOn);
757+
}
758+
720759
private void finishModuleQueue() {
721760
if (finished) return;
722761
finished = true;

‎controller/app/src/main/java/org/iiab/controller/redesign/BooksProvisioner.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ public static boolean hasPending(Context ctx) {
3636
/** Hand the wishlist to BooksDownloadService (requires the server to be up) and clear it.
3737
* No-op if empty or a session is already running. */
3838
public static void drain(Context ctx) {
39+
// ADFA-4900: proot (runrole) and REST downloads must not run at the same time — Ansible forks
40+
// background processes and concurrent REST work is a recipe for corruption. Defer REST while a
41+
// module-queue (proot) job is pending or running; a later drain pass picks it up once idle.
42+
if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().isRunning()
43+
|| MapsProvisioner.hasPending(ctx)) {
44+
Log.d(TAG, "books drain deferred: proot (runrole) work is pending/running");
45+
return;
46+
}
3947
if (BooksDownloadService.isRunning() || BooksDownloadService.hasSession()) {
4048
Log.d(TAG, "books drain skipped: a session is already active");
4149
return;

‎controller/app/src/main/java/org/iiab/controller/redesign/LibraryHomeFragment.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ private void refreshStatuses() {
255255
// fail with no content installed. So gate the drain on the REST API actually answering;
256256
// the wishlist is untouched until then, and this poll (~3s) retries until it's ready.
257257
if (alive && !provisionProbing
258-
&& (BooksProvisioner.hasPending(requireContext()) || ZimProvisioner.hasPending(requireContext()))) {
258+
&& (BooksProvisioner.hasPending(requireContext()) || ZimProvisioner.hasPending(requireContext())
259+
|| MapsProvisioner.hasPending(requireContext()))) { // ADFA-4900
259260
provisionProbing = true;
260261
AppExecutors.get().io().execute(() -> {
261262
final boolean ready = RestReadiness.apiReady();
@@ -269,6 +270,7 @@ private void refreshStatuses() {
269270
android.util.Log.i("K2Go-Provision", "REST API ready -> draining wishlists (home fallback)");
270271
if (BooksProvisioner.hasPending(requireContext())) BooksProvisioner.drain(requireContext());
271272
if (ZimProvisioner.hasPending(requireContext())) ZimProvisioner.drain(requireContext());
273+
if (MapsProvisioner.hasPending(requireContext())) MapsProvisioner.drain(requireContext()); // ADFA-4900
272274
});
273275
});
274276
}

‎controller/app/src/main/java/org/iiab/controller/redesign/MapsChooseFragment.java‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ private static final class Grp {
7777

7878
private int px(int dp) { return Math.round(dp * getResources().getDisplayMetrics().density); }
7979

80+
// ADFA-4900: persist the user's per-layer choices so leaving and returning to the selector shows
81+
// what they picked, not the system defaults. Kept SEPARATE from MapsWishlist (which is the
82+
// "banked for install" order written only at Confirm) so editing pills never triggers an install.
83+
private static final String SEL_PREFS = "k2go_maps_selector";
84+
private android.content.SharedPreferences selPrefs() {
85+
return requireContext().getApplicationContext().getSharedPreferences(SEL_PREFS, android.content.Context.MODE_PRIVATE);
86+
}
87+
8088
/** Overwrite each option's size with the packaged last-known value (maps_sizes.csv);
8189
* the built-in mb stays as the fallback when a row is missing. */
8290
private void resolveSizes() {
@@ -118,12 +126,16 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
118126
String[] names = new String[GROUPS.length];
119127
String[] opts = new String[GROUPS.length];
120128
long[] mb = new long[GROUPS.length];
129+
// ADFA-4900: carry the machine level keys (null = "off") so the install can write
130+
// local_vars — aligned to GROUPS order [base, satellite, terrain, search].
131+
String[] levels = new String[GROUPS.length];
121132
for (int gi = 0; gi < GROUPS.length; gi++) {
122133
names[gi] = getString(GROUPS[gi].label);
123134
opts[gi] = getString(GROUPS[gi].opts[selectedIdx[gi]].label);
124135
mb[gi] = selectedMb[gi];
136+
levels[gi] = GROUPS[gi].opts[selectedIdx[gi]].level;
125137
}
126-
((SetupLibraryActivity) getActivity()).openMapsConfirm(names, opts, mb);
138+
((SetupLibraryActivity) getActivity()).openMapsConfirm(names, opts, mb, levels);
127139
}
128140
});
129141

@@ -134,8 +146,11 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
134146
private void buildGroups(LinearLayout host) {
135147
for (int gi = 0; gi < GROUPS.length; gi++) {
136148
final Grp g = GROUPS[gi];
137-
selectedMb[gi] = g.opts[g.def].mb;
138-
selectedIdx[gi] = g.def;
149+
// Restore the saved choice for this group; fall back to the default the first time.
150+
int savedIdx = selPrefs().getInt("sel_" + g.key, g.def);
151+
if (savedIdx < 0 || savedIdx >= g.opts.length) savedIdx = g.def;
152+
selectedIdx[gi] = savedIdx;
153+
selectedMb[gi] = g.opts[savedIdx].mb;
139154

140155
// Header row: icon · name (bold) · hint (muted) · [spacer] · current size (teal, right).
141156
LinearLayout header = new LinearLayout(requireContext());
@@ -213,13 +228,14 @@ private void buildGroups(LinearLayout host) {
213228
flow.addView(pill);
214229
pillViews[gi][oi] = pill;
215230
}
216-
applyGroupSelection(gi, g.def);
231+
applyGroupSelection(gi, selectedIdx[gi]);
217232
}
218233
}
219234

220235
private void selectOpt(int gi, int oi) {
221236
selectedMb[gi] = GROUPS[gi].opts[oi].mb;
222237
selectedIdx[gi] = oi;
238+
selPrefs().edit().putInt("sel_" + GROUPS[gi].key, oi).apply(); // persist on each change
223239
applyGroupSelection(gi, oi);
224240
refresh();
225241
}

‎controller/app/src/main/java/org/iiab/controller/redesign/MapsConfirmFragment.java‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@
3131

3232
public class MapsConfirmFragment extends Fragment {
3333

34-
private static final String ARG_NAMES = "names", ARG_OPTS = "opts", ARG_MB = "mb";
34+
private static final String ARG_NAMES = "names", ARG_OPTS = "opts", ARG_MB = "mb", ARG_LEVELS = "levels";
3535

36-
public static MapsConfirmFragment newInstance(String[] names, String[] opts, long[] mb) {
36+
public static MapsConfirmFragment newInstance(String[] names, String[] opts, long[] mb, String[] levels) {
3737
MapsConfirmFragment f = new MapsConfirmFragment();
3838
Bundle b = new Bundle();
3939
b.putStringArray(ARG_NAMES, names);
4040
b.putStringArray(ARG_OPTS, opts);
4141
b.putLongArray(ARG_MB, mb);
42+
b.putStringArray(ARG_LEVELS, levels); // ADFA-4900: per-layer machine keys (null = off)
4243
f.setArguments(b);
4344
return f;
4445
}
@@ -72,11 +73,19 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
7273
}
7374
box.addView(row(getString(R.string.k2go_maps_total), "", fmt(total), true));
7475

76+
final String[] levels = a != null ? a.getStringArray(ARG_LEVELS) : null;
77+
78+
// ADFA-4900: in the wizard (pre-install) Maps banks the selection; post-install it installs.
79+
final boolean wizard = getActivity() instanceof SetupLibraryActivity
80+
&& ((SetupLibraryActivity) getActivity()).isMapsWizard();
81+
7582
Button start = root.findViewById(R.id.k2go_start_btn);
76-
start.setText(getString(R.string.k2go_maps_start_building, fmt(total)));
83+
start.setText(getString(wizard ? R.string.k2go_maps_add_setup_fmt : R.string.k2go_maps_start_building, fmt(total)));
7784
start.setOnClickListener(v -> {
7885
if (getActivity() instanceof SetupLibraryActivity) {
79-
((SetupLibraryActivity) getActivity()).openMapsPreparing();
86+
SetupLibraryActivity act = (SetupLibraryActivity) getActivity();
87+
if (act.isMapsWizard()) act.mapsWizardConfirm(levels);
88+
else act.openMapsPreparing(levels);
8089
}
8190
});
8291

0 commit comments

Comments
 (0)