Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,6 +64,8 @@ public final class InstallService extends Service {
private static final String TAG = "IIAB-InstallService";
private static final String CHANNEL_ID = "install_channel";
private static final int NOTIFICATION_ID = 3;
/** ADFA-4898: id for the dismissible "Couldn't install" notification (one place, so post and cancel can't drift). */
private static final int NOTIFICATION_ID_MODULE_FAIL = NOTIFICATION_ID + 4;

public static final String ACTION_START = "org.iiab.controller.INSTALL_START";
public static final String ACTION_CANCEL = "org.iiab.controller.INSTALL_CANCEL";
Expand DownExpand Up@@ -206,6 +208,10 @@ public final class InstallService extends Service {
// ADFA-4900: wizard maps per-layer config (only set when the queue is {"maps"} from the wizard).
private boolean hasMapsConfig;
private String mapsVector, mapsSat, mapsTerrain;
// ADFA-4898: last maps layer selection, retained process-scoped so a user-confirmed Retry of a
// failed maps install re-fires with the same layers (same session; not persisted to disk).
private static String sRetryMapsVector, sRetryMapsSat, sRetryMapsTerrain;
private static boolean sRetryMapsSearch;
private boolean mapsSearchOn;

private File iiabRootDir; // filesDir/rootfs
Expand DownExpand Up@@ -348,8 +354,18 @@ public int onStartCommand(Intent intent, int flags, int startId) {
mapsTerrain = intent.getStringExtra(EXTRA_MAPS_TERRAIN);
mapsSearchOn = intent.getBooleanExtra(EXTRA_MAPS_SEARCH, false);
hasMapsConfig = mapsVector != null && moduleQueue.contains("maps");
if (hasMapsConfig) { // ADFA-4898: retain for a possible user-confirmed Retry of maps
sRetryMapsVector = mapsVector; sRetryMapsSat = mapsSat;
sRetryMapsTerrain = mapsTerrain; sRetryMapsSearch = mapsSearchOn;
}

startForeground(NOTIFICATION_ID, buildNotification(getString(R.string.install_busy_modules)));
// ADFA-4898: a new batch supersedes any earlier "Couldn't install" notification. Clearing it
// at batch start covers both the retry case (no stale + live notification side by side) and
// the success case (the old failure notification is gone before this batch's foreground one is
// removed by teardown). The failure notification only ever auto-cancelled on tap before.
NotificationManager nmClear = getSystemService(NotificationManager.class);
if (nmClear != null) nmClear.cancel(NOTIFICATION_ID_MODULE_FAIL);
acquireHardwareLocks();
persistQueue();
// Mark "running" immediately (currentModule null until the first dequeue) so the UI
Expand DownExpand Up@@ -1054,6 +1070,29 @@ private String mapsInstallCmd() {
mapsVector, mapsSat, mapsTerrain, mapsSearchOn);
}

/**
* 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
* session's retained layer selection. No auto-retry: only a user action calls this.
*/
public static void retryModules(Context ctx, java.util.List<String> modules) {
if (ctx == null || modules == null || modules.isEmpty()) return;
// Go through ModuleProvisioner.startBatch (the same path the wishlist drain uses) so the retry
// ALSO records the ModuleBatch. Firing ACTION_START_MODULES raw here skipped the batch save, so
// the install index came up empty ("Finishing setup" with no rows). Maps keeps its retained
// per-layer selection by passing it as extras.
android.os.Bundle extras = null;
if (modules.contains("maps") && sRetryMapsVector != null) {
extras = new android.os.Bundle();
extras.putString(EXTRA_MAPS_VECTOR, sRetryMapsVector);
extras.putString(EXTRA_MAPS_SAT, sRetryMapsSat);
extras.putString(EXTRA_MAPS_TERRAIN, sRetryMapsTerrain);
extras.putBoolean(EXTRA_MAPS_SEARCH, sRetryMapsSearch);
}
org.iiab.controller.redesign.ModuleProvisioner.startBatch(
ctx, modules.toArray(new String[0]), extras);
}

private void finishModuleQueue() {
if (finished) return;
finished = true;
Expand All@@ -1062,10 +1101,38 @@ private void finishModuleQueue() {
// observer that restarts the server (canStartServer() requires !InstallGuard.inProgress) is not
// raced by teardown()'s later clear. teardown() clears it again (idempotent).
org.iiab.controller.InstallGuard.end(this);
ModuleQueueRepository.get().postDone(new java.util.ArrayList<>(failedModules));
java.util.List<String> failed = new java.util.ArrayList<>(failedModules);
ModuleQueueRepository.get().postDone(failed);
// ADFA-4898: a module batch that finished with failures leaves a dismissible notification, so a
// user who backgrounded the install learns it did not succeed and can reopen to Retry.
if (!failed.isEmpty()) postModuleFailureNotification(failed);
teardown();
}

/**
* ADFA-4898: dismissible "install failed" notification for a module batch that ended with failures.
* Distinct id from the foreground one (removed by teardown's stopForeground); tapping opens Module
* management, where the failed module shows a Retry.
*/
private void postModuleFailureNotification(java.util.List<String> failed) {
NotificationManager m = getSystemService(NotificationManager.class);
if (m == null) return;
Intent open = new Intent(this, org.iiab.controller.redesign.SetupLibraryActivity.class)
.putExtra(org.iiab.controller.redesign.SetupLibraryActivity.EXTRA_MODULE_MGMT, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
android.app.PendingIntent pi = android.app.PendingIntent.getActivity(this, 0, open,
android.app.PendingIntent.FLAG_IMMUTABLE | android.app.PendingIntent.FLAG_UPDATE_CURRENT);
Notification n = new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.k2go_mod_phase_failed))
.setContentText(android.text.TextUtils.join(", ", failed))
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentIntent(pi)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build();
m.notify(NOTIFICATION_ID_MODULE_FAIL, n);
}

private void persistQueue() {
getSharedPreferences("iiab_queue_prefs", Context.MODE_PRIVATE).edit()
.putString("pending_modules", android.text.TextUtils.join(",", new java.util.ArrayList<>(moduleQueue)))
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -68,6 +68,21 @@ public boolean isInstalling(String moduleKey) {
return phase == Phase.RUNNING && currentModule != null && currentModule.equals(moduleKey);
}

/**
* ADFA-4898: the queue finished with at least one module's runrole failed. An explicit signal so a
* failed batch is not read as a clean success — kept on the DONE terminal (the failedModules list is
* already published there) rather than a separate phase, so every existing "queue finished" consumer
* keeps working and only the surfaces that care read this.
*/
public boolean hasFailures() {
return phase == Phase.DONE && !failedModules.isEmpty();
}

/** True when {@code moduleKey} failed in the batch that just finished. */
public boolean didFail(String moduleKey) {
return phase == Phase.DONE && failedModules.contains(moduleKey);
}

public static ModuleQueueState idle() {
return new ModuleQueueState(Phase.IDLE, null, 0, INDETERMINATE, ETA_UNKNOWN, null, 0L);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,34 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
chipRow.addView(chip(getString(R.string.k2go_mod_phase_done), R.color.k2go_leaf));
return; // nothing to offer: a module cannot be uninstalled or reinstalled here
}
// ADFA-4898: this module's runrole failed in the last finished batch — the SAME per-module
// didFail(key) that colours the hub's "Couldn't install" pill, so only the module that
// actually failed lands here (a batch where one of many failed does not turn the rest into
// Retry). Installed outranks it (a module genuinely on disk is not "failed"), which is why
// this sits just below the isInstalled branch. The two action buttons take on the recovery
// roles: primary Schedule -> Retry (re-fires just this module, user-confirmed, no auto-retry),
// secondary Install now -> Back — the same slot-reuse this switch already does for Recover.
// Retry then bounces to the hub, which observes the queue live; this detail is a one-shot
// snapshot (no observer) and would otherwise sit on a stale "Couldn't install".
if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().current().didFail(c.key())) {
chipRow.addView(chip(getString(R.string.k2go_mod_phase_failed), R.color.k2go_clay));
schedule.setText(R.string.k2go_home_retry);
// Shared, busy-gated retry (same action the live progress card fires). On a real start,
// land on the install index — the same destination as a normal install (openModuleIndex)
// — where the batch we just re-fired shows its rows, progress and log. NOT onBackPressed:
// that dropped the user on the hub, which during an install only shows "Adding content"
// with no route to the progress (the bug seen retrying from Module management).
schedule.setOnClickListener(v -> {
if (ModuleRetry.fire(v, c.key())) {
startActivity(new android.content.Intent(requireContext(), SetupProgressActivity.class));
}
});
schedule.setVisibility(View.VISIBLE);
installNowBtn.setText(R.string.k2go_setup_back);
installNowBtn.setOnClickListener(v -> requireActivity().getOnBackPressedDispatcher().onBackPressed());
installNowBtn.setVisibility(View.VISIBLE);
return;
}
if (unknown) {
chipRow.addView(chip(getString(R.string.k2go_state_no_answer),
R.color.k2go_amber_text));
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -414,6 +414,9 @@ private void addHiddenSection() {
* badge when banked, else a chevron. */
private View cardRow(final ModuleCards.Card c, final boolean isInstalled,
final boolean unknown) {
// ADFA-4898: did this module's runrole fail in the last finished batch? (DONE + in failedModules)
final boolean failed = !isInstalled
&& org.iiab.controller.install.presentation.ModuleQueueRepository.get().current().didFail(c.key());
LinearLayout row = new LinearLayout(requireContext());
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
Expand All@@ -435,7 +438,7 @@ private View cardRow(final ModuleCards.Card c, final boolean isInstalled,
// no checkbox. The row still opens its detail, which is where "what is this" lives.
// ADFA-5104: and no tick when the flags could not be read either. Ticking would bank an
// order we have no grounds to take.
if (!isInstalled && !unknown && !c.hasSelector) { // ADFA-4958: tick to schedule several at once (maps uses its own selector)
if (!isInstalled && !unknown && !failed && !c.hasSelector) { // ADFA-4958: tick to schedule several at once (maps uses its own selector). ADFA-4898: a failed module shows Retry, not the checkbox.
com.google.android.material.checkbox.MaterialCheckBox cb =
new com.google.android.material.checkbox.MaterialCheckBox(requireContext());
cb.setChecked(ModuleWishlist.contains(requireContext(), c.key()));
Expand DownExpand Up@@ -485,16 +488,22 @@ private View cardRow(final ModuleCards.Card c, final boolean isInstalled,
boolean scheduled = !isInstalled && !unknown && ModuleWishlist.contains(requireContext(), c.key());
TextView pill = statePill(
isInstalled ? getString(R.string.k2go_mod_phase_done)
: unknown ? getString(R.string.k2go_state_no_answer)
: scheduled ? getString(R.string.k2go_mod_scheduled)
: getString(R.string.k2go_state_not_installed),
: failed ? getString(R.string.k2go_mod_phase_failed)
: unknown ? getString(R.string.k2go_state_no_answer)
: scheduled ? getString(R.string.k2go_mod_scheduled)
: getString(R.string.k2go_state_not_installed),
isInstalled ? R.color.k2go_leaf
: unknown ? R.color.k2go_amber_text
: scheduled ? R.color.k2go_teal : R.color.k2go_muted);
: failed ? R.color.k2go_clay
: unknown ? R.color.k2go_amber_text
: scheduled ? R.color.k2go_teal : R.color.k2go_muted);
LinearLayout.LayoutParams tlp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
tlp.leftMargin = px(10);
row.addView(pill, tlp);
// ADFA-4898: Retry does NOT live on the hub row. The "Couldn't install" pill is signal enough
// to draw the user into the module; the per-module Retry lives on the module detail (the same
// per-module didFail(key) that colours this pill drives the detail's Retry/Back there), so a
// batch where only one module failed never sprays Retry across the whole list.
return row;
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,7 +125,10 @@ private void updateStatus() {
}
}

if (mq.failedModules != null && mq.failedModules.contains(key)) {
// ADFA-4898: one predicate for "did this module fail" across every surface (hub pill, detail
// Retry/Back, this progress line) — ModuleQueueState.didFail(key). Was hand-rolled here as
// failedModules.contains(...); routed through the shared atom so the three can't drift apart.
if (mq.didFail(key)) {
terminalDone = true;
status.setText(getString(R.string.k2go_mod_phase_failed));
return;
Expand All@@ -136,6 +139,9 @@ private void updateStatus() {
return;
}
if (installing()) {
// ADFA-4898: a retry from this card brought the module back to RUNNING — clear the terminal
// latch so the live log resumes driving the status line (it stops again on the next terminal).
terminalDone = false;
// live status is driven by the log; keep the phase text only until the first line arrives.
if (logLines.isEmpty()) status.setText(getString(R.string.k2go_mod_phase_installing));
return;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,18 +70,34 @@ public static OperationDispatcher.Dispatch drain(Context ctx) {
+ " (" + modules.length + " module(s) still banked)");
return verdict;
}
// Record the ordered batch so the install index can render a row per module (the queue only
// reports the current module + remaining, not the full list).
ModuleBatch.save(app, modules);
Intent i = new Intent(app, InstallService.class);
i.setAction(InstallService.ACTION_START_MODULES);
i.putExtra(InstallService.EXTRA_MODULES, modules);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) app.startForegroundService(i);
else app.startService(i);
// Record the ordered batch and hand it to the queue engine — via the one startBatch path, so
// the install index always has its rows (the queue only reports current + remaining).
startBatch(app, modules, null);
Log.i(TAG, "module drain: handed " + modules.length + " module(s) to InstallService");
// Handed off; the module-queue owns the run from here. Wishlist cleared; the batch persists
// until the index sees the run finish.
ModuleWishlist.clear(app);
return verdict;
}

/**
* ADFA-4898: the single entry point that starts a module batch. Saves the ordered batch so the
* install index renders a row per module (the queue only reports current + remaining), then hands
* the keys to InstallService. Every caller — the wishlist {@link #drain} and the user-confirmed
* Retry ({@code InstallService.retryModules}) — goes through here, so nothing can start the
* runroles without the batch the index needs. A raw ACTION_START_MODULES that skipped the save
* left the index empty ("Finishing setup" with no rows); centralising it makes that unrepresentable.
*
* @param extras optional intent extras (maps carries its retained per-layer selection here); null otherwise.
*/
public static void startBatch(Context ctx, String[] modules, android.os.Bundle extras) {
if (ctx == null || modules == null || modules.length == 0) return;
final Context app = ctx.getApplicationContext();
ModuleBatch.save(app, modules);
Intent i = new Intent(app, InstallService.class).setAction(InstallService.ACTION_START_MODULES);
i.putExtra(InstallService.EXTRA_MODULES, modules);
if (extras != null) i.putExtras(extras);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) app.startForegroundService(i);
else app.startService(i);
}
}
Loading
Loading