From bd1f257ccb0c7fe06deaf0cc520e5495f4a36d91 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 31 Jul 2026 08:15:41 -0600 Subject: [PATCH 1/5] ADFA-4961: backup/restore UI polished to the module-install template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyle the backup/restore screens as a compact copy of the module-management install template (fragment_k2go_module_install), not Clone. First integration, kept simple. - BackupJobFragment: now inflates fragment_k2go_backup_job.xml (title + subtitle, the shared working Lottie with the app logo composited on top, a fixed 2-line status line, the 'this takes time' card, a collapsible log left GONE for a future output-plumbing pass) instead of building views by hand. No Start button — the intro card is the trigger, so the screen auto-opens the file picker on a fresh entry; it drives from DeepOpProgressRepository and re-binds on recreation / notification return. At the terminal state it shows the result and STAYS (like the module card), with a Finish button. Back while running is consumed with a styled snackbar (was a Toast). - BackupRestoreFragment (bifurcation): the two cards get Material icons (archive / unarchive); on return from a finished op (Finish), it runs the module-index-style 'returning to Home' 3s countdown with a Cancel. - New: ic_archive / ic_unarchive vectors, fragment_k2go_backup_job.xml, k2go_bj_* strings. Note: the Lottie still has the little cloud; a cloud-less variant is a separate later pass. Wants a device look-over. --- .../redesign/BackupJobFragment.java | 223 ++++++------------ .../redesign/BackupRestoreFragment.java | 98 +++++++- .../app/src/main/res/drawable/ic_archive.xml | 8 + .../src/main/res/drawable/ic_unarchive.xml | 8 + .../res/layout/fragment_k2go_backup_job.xml | 197 ++++++++++++++++ .../app/src/main/res/values/strings_k2go.xml | 4 + 6 files changed, 379 insertions(+), 159 deletions(-) create mode 100644 controller/app/src/main/res/drawable/ic_archive.xml create mode 100644 controller/app/src/main/res/drawable/ic_unarchive.xml create mode 100644 controller/app/src/main/res/layout/fragment_k2go_backup_job.xml diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java index 564c9c32..b5cd5a68 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java @@ -3,19 +3,17 @@ * Name : BackupJobFragment.java * Author : AppDevForAll * Copyright : Copyright (c) 2026 AppDevForAll - * Description : ADFA-4952 / ADFA-4957. Dedicated per-operation screen for Backup OR Restore (mode arg), - * with the shared "working" Lottie + an animated status line. The heavy, kill-sensitive - * work is now OWNED by the foreground DeepOpService (not this Fragment): the screen just - * starts it and OBSERVES DeepOpProgressRepository — the app-scoped single source of truth - * — so the UI (status line, hard gate, Done) re-binds after a recreation or backgrounding - * and a notification tap can land back here. + * Description : ADFA-4952 / 4957 / 4961. Backup OR Restore operation screen (mode arg), styled as a + * compact copy of the module-install template (fragment_k2go_backup_job.xml): title + + * subtitle, the shared working Lottie with the app logo, a fixed 2-line status line, the + * "this takes time" card, and a Finish button. There is no Start button — the intro card + * is the trigger, so this screen auto-opens the file picker on a fresh entry. * - * Backup: SAF CreateDocument -> DeepOpService.startBackup (stop -> stream tar|gzip -> done). - * Restore: SAF OpenDocument -> copy to temp + validate + destructive confirm HERE (the - * pre-extract work is safe to lose on a kill) -> DeepOpService.startRestore (stop -> - * extract -> done). The service brackets EnvironmentLock (+ InstallGuard for restore); it - * does NOT boot the environment — LibraryActivity's auto-start boots it when the user - * returns Home (a failed restore keeps InstallGuard set, so recovery repairs it instead). + * Flow: pick (SAF) -> DeepOpService owns the kill-sensitive work off the UI -> this screen + * observes DeepOpProgressRepository (re-binds after recreation / notification return). At + * the terminal state it shows the result and STAYS (like the module card); Finish returns + * to the bifurcation (BackupRestoreFragment), which then runs the index-style "returning + * to Home" countdown. Back while running is consumed with a styled snackbar (hard gate). * ============================================================================ */ package org.iiab.controller.redesign; @@ -24,12 +22,9 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; import android.widget.TextView; import androidx.activity.result.ActivityResultLauncher; @@ -37,20 +32,18 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; -import androidx.core.widget.NestedScrollView; import androidx.fragment.app.Fragment; import com.airbnb.lottie.LottieAnimationView; -import com.airbnb.lottie.LottieDrawable; import org.iiab.controller.R; -import org.iiab.controller.backup.domain.BackupEngine; import org.iiab.controller.deepop.DeepOpProgressRepository; import org.iiab.controller.deepop.DeepOpService; import org.iiab.controller.deepop.DeepOpState; import org.iiab.controller.env.EnvironmentLock; import org.iiab.controller.ui.dialog.BrandDialog; import org.iiab.controller.util.AppExecutors; +import org.iiab.controller.util.Snackbars; import java.io.File; import java.io.InputStream; @@ -73,25 +66,21 @@ public static BackupJobFragment newInstance(String mode) { private final Handler main = new Handler(Looper.getMainLooper()); private String mode; private LottieAnimationView anim; - private TextView title, status; - private Button primary, done; + private TextView title, sub, status, finish; private org.iiab.controller.util.EllipsisAnimator statusDots; private boolean running = false; - private long lastSeq = -1L; // ADFA-4957: only react to NEW terminal transitions from the repo - // ADFA-4952: hard gate — while an op runs, back is consumed so a half-applied backup/restore can't - // be abandoned. Enabled only while running. + private long lastSeq = -1L; private androidx.activity.OnBackPressedCallback backGate; private final ActivityResultLauncher createDoc = registerForActivityResult(new ActivityResultContracts.CreateDocument("application/gzip"), uri -> { - if (uri != null) startBackup(uri); else showReady(); + if (uri != null) startBackup(uri); else popToIntro(false); }); private final ActivityResultLauncher openDoc = registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> { - if (uri != null) prepareRestore(uri); else showReady(); + if (uri != null) prepareRestore(uri); else popToIntro(false); }); - private int px(int dp) { return Math.round(dp * getResources().getDisplayMetrics().density); } private boolean isRestore() { return MODE_RESTORE.equals(mode); } private EnvironmentLock.Owner myOwner() { return isRestore() ? EnvironmentLock.Owner.RESTORE : EnvironmentLock.Owner.BACKUP; } @@ -99,108 +88,52 @@ public static BackupJobFragment newInstance(String mode) { @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle s) { mode = getArguments() != null ? getArguments().getString(ARG_MODE, MODE_BACKUP) : MODE_BACKUP; - - NestedScrollView scroll = new NestedScrollView(requireContext()); - scroll.setFillViewport(true); - LinearLayout col = new LinearLayout(requireContext()); - col.setOrientation(LinearLayout.VERTICAL); - col.setGravity(Gravity.CENTER_HORIZONTAL); - col.setPadding(px(24), px(20), px(24), px(24)); - scroll.addView(col, new NestedScrollView.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - - TextView back = new TextView(requireContext()); - back.setText(getString(R.string.k2go_back_link)); - back.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal)); - back.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium); - LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - back.setLayoutParams(blp); - back.setOnClickListener(v -> { if (!running) requireActivity().getOnBackPressedDispatcher().onBackPressed(); }); - col.addView(back); - - anim = new LottieAnimationView(requireContext()); - anim.setAnimation(R.raw.k2go_working_loop); - anim.setRepeatCount(LottieDrawable.INFINITE); - LinearLayout.LayoutParams alp = new LinearLayout.LayoutParams(px(180), px(180)); - alp.topMargin = px(24); - col.addView(anim, alp); - - title = new TextView(requireContext()); - title.setGravity(Gravity.CENTER); - title.setTypeface(title.getTypeface(), android.graphics.Typeface.BOLD); - title.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_ink)); - title.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_HeadlineSmall); - LinearLayout.LayoutParams tlp = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - tlp.topMargin = px(16); - col.addView(title, tlp); - - status = new TextView(requireContext()); - status.setGravity(Gravity.CENTER); - status.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_muted)); - status.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium); - LinearLayout.LayoutParams slp = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - slp.topMargin = px(10); - col.addView(status, slp); + View v = inflater.inflate(R.layout.fragment_k2go_backup_job, container, false); + anim = v.findViewById(R.id.k2go_bj_anim); + title = v.findViewById(R.id.k2go_bj_title); + sub = v.findViewById(R.id.k2go_bj_sub); + status = v.findViewById(R.id.k2go_bj_status); + finish = v.findViewById(R.id.k2go_bj_finish); statusDots = new org.iiab.controller.util.EllipsisAnimator(status); - primary = new Button(requireContext()); - LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - plp.topMargin = px(28); - col.addView(primary, plp); - primary.setOnClickListener(v -> onPrimary()); - - done = new Button(requireContext()); - done.setText(getString(R.string.k2go_br_done)); - LinearLayout.LayoutParams donelp = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - donelp.topMargin = px(16); - col.addView(done, donelp); - done.setVisibility(View.GONE); - done.setOnClickListener(v -> goLibraryHome()); + title.setText(getString(isRestore() ? R.string.k2go_br_restore_title : R.string.k2go_br_backup_title)); + sub.setText(getString(isRestore() ? R.string.k2go_br_restore_sub : R.string.k2go_br_backup_sub)); + finish.setOnClickListener(x -> popToIntro(true)); - // ADFA-4952: intercept system/gesture back too (the back link already guards on `running`). + // Hard gate: while the op runs, back is consumed with a styled snackbar (module-index behavior). backGate = new androidx.activity.OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { - android.widget.Toast.makeText(requireContext(), getString(R.string.k2go_br_busy_wait), - android.widget.Toast.LENGTH_SHORT).show(); + Snackbars.make(v, R.string.k2go_br_busy_wait).show(); } }; requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), backGate); - showReady(); - // ADFA-4957: bind to the app-scoped op state. Seed lastSeq so a terminal from a PREVIOUS op - // (already the current value) doesn't fire a stale "done"; only new transitions count. - lastSeq = DeepOpProgressRepository.get().current().seq; + DeepOpState cur = DeepOpProgressRepository.get().current(); + lastSeq = cur.seq; DeepOpProgressRepository.get().state().observe(getViewLifecycleOwner(), this::onDeepOpState); - return scroll; - } - /** Idle "ready to start" state: title + warning + a Start button. */ - private void showReady() { - running = false; - if (backGate != null) backGate.setEnabled(false); // ADFA-4952: idle → back allowed - if (statusDots != null) statusDots.stop(); - title.setText(getString(isRestore() ? R.string.k2go_br_restore_title : R.string.k2go_br_backup_title)); - status.setText(getString(R.string.k2go_br_backup_warn)); // both stop the server - status.setVisibility(View.VISIBLE); - primary.setText(getString(isRestore() ? R.string.k2go_br_restore_pick : R.string.k2go_br_start)); - primary.setVisibility(View.VISIBLE); - done.setVisibility(View.GONE); - if (anim != null) { anim.setProgress(0f); anim.pauseAnimation(); } + if (cur.owner == myOwner() && cur.isRunning()) { + beginRunning(); // deep-link / recreation into a live op + setStatusAnimated(cur.step); + } else if (s != null && cur.owner == myOwner() && cur.isTerminal()) { + showTerminal(cur.phase == DeepOpState.Phase.SUCCESS, cur.message); // recreated at the result + } else if (s == null) { + v.post(this::launchPicker); // fresh entry: the intro card is the Start + } + return v; } - private void onPrimary() { - if (running) return; - if (EnvironmentLock.isHeld(requireContext())) { setStatusStatic(getString(R.string.k2go_install_busy)); return; } + private void launchPicker() { + if (EnvironmentLock.isHeld(requireContext())) { // another deep-env op owns the environment + Snackbars.make(requireView(), R.string.k2go_install_busy).show(); + popToIntro(false); + return; + } if (isRestore()) openDoc.launch(new String[]{"application/gzip", "application/x-gzip", "*/*"}); - else createDoc.launch(BackupEngine.suggestedFileName(requireContext())); + else createDoc.launch(org.iiab.controller.backup.domain.BackupEngine.suggestedFileName(requireContext())); } - // ---- BACKUP: hand off to the service; the observer drives the UI from here ---- + // ---- BACKUP ---- private void startBackup(Uri uri) { if (!isAdded()) return; beginRunning(); @@ -208,13 +141,12 @@ private void startBackup(Uri uri) { DeepOpService.startBackup(requireContext(), uri); } - // ---- RESTORE: copy + validate + confirm HERE (pre-extract, safe to lose), then the service ---- + // ---- RESTORE: copy + validate + destructive confirm HERE (pre-extract), then the service ---- private void prepareRestore(Uri uri) { if (!isAdded()) return; beginRunning(); setStatusAnimated(getString(R.string.k2go_br_status_checking)); AppExecutors.get().io().execute(() -> { - // Copy the SAF stream to a temp file — the validator (and the service's extractor) need a path. File temp = new File(requireContext().getCacheDir(), "restore.tar.gz"); boolean copied = false; try (InputStream in = requireContext().getContentResolver().openInputStream(uri); @@ -242,10 +174,9 @@ private void onValidated(File temp, org.iiab.controller.deploy.data.RootfsArchiv int msg = vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.WRONG_ARCH ? R.string.install_error_wrong_arch : vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.CORRUPT ? R.string.install_error_corrupt : R.string.install_error_not_rootfs; - finishResult(false, getString(msg)); + showTerminal(false, getString(msg)); return; } - // Valid → confirm the destructive replace, then hand the validated temp path to the service. new BrandDialog(requireContext()) .setTitle(getString(R.string.k2go_br_restore_title)) .setMessage(getString(R.string.k2go_br_restore_warn)) @@ -253,67 +184,61 @@ private void onValidated(File temp, org.iiab.controller.deploy.data.RootfsArchiv setStatusAnimated(getString(R.string.k2go_br_status_stopping)); DeepOpService.startRestore(requireContext(), temp.getAbsolutePath()); }) - .setNegative(R.string.cancel, () -> { if (temp.exists()) temp.delete(); showReady(); }) + .setNegative(R.string.cancel, () -> { if (temp.exists()) temp.delete(); popToIntro(false); }) .show(); } - // ---- observe the app-scoped op state (the service is the writer) ---- + // ---- observe the app-scoped op state (DeepOpService is the writer) ---- private void onDeepOpState(DeepOpState st) { - if (st == null || !isAdded()) return; - if (st.owner != myOwner()) return; // ignore an op for the other direction + if (st == null || !isAdded() || st.owner != myOwner()) return; if (st.isRunning()) { if (!running) beginRunning(); setStatusAnimated(st.step); } else if (st.isTerminal() && st.seq > lastSeq) { lastSeq = st.seq; - finishResult(st.phase == DeepOpState.Phase.SUCCESS, st.message); + showTerminal(st.phase == DeepOpState.Phase.SUCCESS, st.message); } } private void beginRunning() { running = true; - if (backGate != null) backGate.setEnabled(true); // ADFA-4952: hard gate on - primary.setVisibility(View.GONE); - done.setVisibility(View.GONE); - if (anim != null && !reduceMotion()) { anim.setRepeatCount(LottieDrawable.INFINITE); anim.playAnimation(); } + if (backGate != null) backGate.setEnabled(true); + finish.setVisibility(View.GONE); + if (anim != null) anim.playAnimation(); } - private void finishResult(boolean ok, String message) { + private void showTerminal(boolean ok, String message) { running = false; - if (backGate != null) backGate.setEnabled(false); // ADFA-4952: done → back allowed (Done button) - if (anim != null) anim.pauseAnimation(); + if (backGate != null) backGate.setEnabled(false); // done → back / Finish returns to the bifurcation if (statusDots != null) statusDots.stop(); + if (anim != null) anim.pauseAnimation(); title.setText(getString(ok ? R.string.k2go_br_done_title : R.string.k2go_br_failed_title)); status.setText(message); status.setTextColor(ContextCompat.getColor(requireContext(), ok ? R.color.k2go_leaf : R.color.k2go_amber_text)); - status.setVisibility(View.VISIBLE); - done.setVisibility(View.VISIBLE); - primary.setVisibility(View.GONE); + finish.setVisibility(View.VISIBLE); } private void setStatusAnimated(String text) { status.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_muted)); - status.setVisibility(View.VISIBLE); if (statusDots != null) statusDots.start(text); } - private void setStatusStatic(String text) { - if (statusDots != null) statusDots.stop(); - status.setText(text); - status.setVisibility(View.VISIBLE); - } - - private void goLibraryHome() { - startActivity(new android.content.Intent(requireContext(), LibraryActivity.class) - .addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP | android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) - .putExtra(LibraryActivity.EXTRA_TAB, R.id.nav_library)); - requireActivity().finish(); - } - private boolean reduceMotion() { - try { - return android.provider.Settings.Global.getFloat(requireContext().getContentResolver(), - android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f; - } catch (Exception e) { return false; } + /** Return to the bifurcation (BackupRestoreFragment). When {@code fromFinish}, arm the intro's + * index-style "returning to Home" countdown. */ + private void popToIntro(boolean fromFinish) { + if (!isAdded()) return; + androidx.fragment.app.FragmentManager fm = requireActivity().getSupportFragmentManager(); + if (fm.getBackStackEntryCount() > 0) { + if (fromFinish) BackupRestoreFragment.armReturnCountdown(); + fm.popBackStack(); + } else { + // Deep-linked straight to the job screen (no bifurcation underneath, e.g. the notification + // reopen) — there is nowhere to pop to, so go Home directly. + startActivity(new android.content.Intent(requireContext(), LibraryActivity.class) + .addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP | android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(LibraryActivity.EXTRA_TAB, R.id.nav_library)); + requireActivity().finish(); + } } @Override public void onDestroyView() { diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java index a97cf6e3..baa10b56 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java @@ -3,18 +3,25 @@ * Name : BackupRestoreFragment.java * Author : AppDevForAll * Copyright : Copyright (c) 2026 AppDevForAll - * Description : ADFA-4952. Backup & restore intro — a Clone-style screen: title + description + - * "what do you want to do?" with two cards (Back up / Restore). Each card opens its own - * dedicated job screen (BackupJobFragment) with the working Lottie + status. Hosted in - * SetupLibraryActivity (which owns the ServerController + coordinates via EnvironmentLock). + * Description : ADFA-4952 / 4961. Backup & restore bifurcation — title + description + "what do you + * want to do?" with two icon cards (Back up / Restore). Tapping a card IS the Start: it + * opens BackupJobFragment, which auto-opens the file picker. ADFA-4961: after an op + * finishes and its screen taps Finish, this bifurcation shows the module-index-style + * "returning to Home" countdown with a Cancel (armed via armReturnCountdown()). Hosted in + * SetupLibraryActivity. * ============================================================================ */ package org.iiab.controller.redesign; +import android.content.Intent; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; @@ -28,6 +35,14 @@ public class BackupRestoreFragment extends Fragment { + // ADFA-4961: one-shot flag set by BackupJobFragment's Finish, consumed here to run the "returning + // to Home" countdown. Process-scoped: the bifurcation is recreated when the job screen pops. + private static boolean sReturnPending = false; + public static void armReturnCountdown() { sReturnPending = true; } + + private final Handler main = new Handler(Looper.getMainLooper()); + private Runnable homeRunnable; + private int px(int dp) { return Math.round(dp * getResources().getDisplayMetrics().density); } @Nullable @@ -63,11 +78,19 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); clp.topMargin = px(10); col.addView(cards, clp); - cards.addView(card(R.string.k2go_br_backup_title, R.string.k2go_br_backup_sub, + cards.addView(card(R.drawable.ic_archive, R.string.k2go_br_backup_title, R.string.k2go_br_backup_sub, v -> open(BackupJobFragment.MODE_BACKUP))); - cards.addView(card(R.string.k2go_br_restore_title, R.string.k2go_br_restore_sub, + cards.addView(card(R.drawable.ic_unarchive, R.string.k2go_br_restore_title, R.string.k2go_br_restore_sub, v -> open(BackupJobFragment.MODE_RESTORE))); + // ADFA-4961: returning from a finished op → index-style "returning to Home" countdown + Cancel. + if (sReturnPending) { + sReturnPending = false; + col.addView(buildReturnBar()); + homeRunnable = this::goHome; + main.postDelayed(homeRunnable, 3000L); + } + return scroll; } @@ -77,6 +100,44 @@ private void open(String jobMode) { } } + private View buildReturnBar() { + LinearLayout bar = new LinearLayout(requireContext()); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.setGravity(Gravity.CENTER_VERTICAL); + bar.setBackgroundResource(R.drawable.k2go_info_bg); + bar.setPadding(px(14), px(12), px(14), px(12)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lp.topMargin = px(16); + bar.setLayoutParams(lp); + + TextView msg = new TextView(requireContext()); + msg.setText(getString(R.string.k2go_bj_returning)); + msg.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal)); + msg.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium); + bar.addView(msg, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + TextView cancel = new TextView(requireContext()); + cancel.setText(getString(R.string.cancel)); + cancel.setTypeface(cancel.getTypeface(), android.graphics.Typeface.BOLD); + cancel.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal)); + cancel.setPadding(px(12), px(4), px(4), px(4)); + cancel.setOnClickListener(v -> { + if (homeRunnable != null) main.removeCallbacks(homeRunnable); + bar.setVisibility(View.GONE); + }); + bar.addView(cancel); + return bar; + } + + private void goHome() { + if (!isAdded()) return; + startActivity(new Intent(requireContext(), LibraryActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(LibraryActivity.EXTRA_TAB, R.id.nav_library)); + requireActivity().finish(); + } + private TextView heading(String text, boolean bold, int appearance, int colorRes, int topDp) { TextView t = new TextView(requireContext()); t.setText(text); @@ -90,9 +151,10 @@ private TextView heading(String text, boolean bold, int appearance, int colorRes return t; } - private View card(int titleRes, int subRes, View.OnClickListener onClick) { + private View card(int iconRes, int titleRes, int subRes, View.OnClickListener onClick) { LinearLayout row = new LinearLayout(requireContext()); - row.setOrientation(LinearLayout.VERTICAL); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); row.setBackgroundResource(R.drawable.k2go_card_bg); row.setPadding(px(16), px(16), px(16), px(16)); LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( @@ -102,12 +164,23 @@ private View card(int titleRes, int subRes, View.OnClickListener onClick) { row.setClickable(true); row.setOnClickListener(onClick); + ImageView icon = new ImageView(requireContext()); + icon.setImageResource(iconRes); + icon.setColorFilter(ContextCompat.getColor(requireContext(), R.color.k2go_teal)); + LinearLayout.LayoutParams ilp = new LinearLayout.LayoutParams(px(28), px(28)); + ilp.rightMargin = px(14); + row.addView(icon, ilp); + + LinearLayout textCol = new LinearLayout(requireContext()); + textCol.setOrientation(LinearLayout.VERTICAL); + row.addView(textCol, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + TextView t = new TextView(requireContext()); t.setText(getString(titleRes)); t.setTypeface(t.getTypeface(), android.graphics.Typeface.BOLD); t.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_ink)); t.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium); - row.addView(t); + textCol.addView(t); TextView sub = new TextView(requireContext()); sub.setText(getString(subRes)); @@ -116,7 +189,12 @@ private View card(int titleRes, int subRes, View.OnClickListener onClick) { LinearLayout.LayoutParams slp = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); slp.topMargin = px(4); - row.addView(sub, slp); + textCol.addView(sub, slp); return row; } + + @Override public void onDestroyView() { + if (homeRunnable != null) main.removeCallbacks(homeRunnable); + super.onDestroyView(); + } } diff --git a/controller/app/src/main/res/drawable/ic_archive.xml b/controller/app/src/main/res/drawable/ic_archive.xml new file mode 100644 index 00000000..8c14e765 --- /dev/null +++ b/controller/app/src/main/res/drawable/ic_archive.xml @@ -0,0 +1,8 @@ + + + + diff --git a/controller/app/src/main/res/drawable/ic_unarchive.xml b/controller/app/src/main/res/drawable/ic_unarchive.xml new file mode 100644 index 00000000..05bafb9a --- /dev/null +++ b/controller/app/src/main/res/drawable/ic_unarchive.xml @@ -0,0 +1,8 @@ + + + + diff --git a/controller/app/src/main/res/layout/fragment_k2go_backup_job.xml b/controller/app/src/main/res/layout/fragment_k2go_backup_job.xml new file mode 100644 index 00000000..f0451e31 --- /dev/null +++ b/controller/app/src/main/res/layout/fragment_k2go_backup_job.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index 58f514b0..7f5bfd1e 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -183,6 +183,10 @@ Keeps a device-to-device transfer running Knowledge to Go Transferring between devices — tap to return + Finish + This can take a while + Keep K2Go open. You can leave — it keeps running in the background, and the notification brings you back. + Done — returning to Home… DEVELOPER Network & DNS Use a custom DNS. Off = server defaults. From 8c2fb4a4c091b7249e26f1b9d19374031d76f2ec Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 31 Jul 2026 08:29:57 -0600 Subject: [PATCH 2/5] ADFA-4961: soft mode-specific 'you can leave' message + Finish always lands on the bifurcation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Since the op runs in DeepOpService and survives backgrounding (ADFA-4957/4960), leaving no longer interrupts it. Back while running now shows a soft, mode-specific hint and returns to the bifurcation instead of hard-blocking: 'Backup/Restore is running — you can leave; it keeps going and will be ready when you come back.' (matches the module-index leave-hint). Drops the old 'can't be interrupted' string (k2go_br_busy_wait, now unused). - popToIntro: Finish (and cancels) always land on the bifurcation. In the deep-link case (reopened via the notification, no bifurcation underneath) it now shows the bifurcation — which runs the return-to-Home countdown — instead of jumping straight Home. --- .../controller/redesign/BackupJobFragment.java | 17 ++++++++++------- .../app/src/main/res/values/strings_k2go.xml | 3 ++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java index b5cd5a68..0c8a5b02 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java @@ -103,7 +103,12 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c // Hard gate: while the op runs, back is consumed with a styled snackbar (module-index behavior). backGate = new androidx.activity.OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { - Snackbars.make(v, R.string.k2go_br_busy_wait).show(); + // ADFA-4961 (B): the op keeps running in DeepOpService, so leaving doesn't interrupt it. + // Show a soft, mode-specific hint (anchored to the activity so it survives the pop) and + // return to the bifurcation — no hard block. The notification brings the user back. + Snackbars.make(requireActivity().findViewById(android.R.id.content), + isRestore() ? R.string.k2go_br_leave_restore : R.string.k2go_br_leave_backup).show(); + popToIntro(false); } }; requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), backGate); @@ -227,17 +232,15 @@ private void setStatusAnimated(String text) { * index-style "returning to Home" countdown. */ private void popToIntro(boolean fromFinish) { if (!isAdded()) return; + if (fromFinish) BackupRestoreFragment.armReturnCountdown(); androidx.fragment.app.FragmentManager fm = requireActivity().getSupportFragmentManager(); if (fm.getBackStackEntryCount() > 0) { - if (fromFinish) BackupRestoreFragment.armReturnCountdown(); fm.popBackStack(); } else { // Deep-linked straight to the job screen (no bifurcation underneath, e.g. the notification - // reopen) — there is nowhere to pop to, so go Home directly. - startActivity(new android.content.Intent(requireContext(), LibraryActivity.class) - .addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP | android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) - .putExtra(LibraryActivity.EXTRA_TAB, R.id.nav_library)); - requireActivity().finish(); + // reopen) — show the bifurcation so Finish always lands there (which then runs the countdown + // to Home), never jumping straight Home. + fm.beginTransaction().replace(R.id.k2go_setup_host, new BackupRestoreFragment()).commit(); } } diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index 7f5bfd1e..603c49bf 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -174,7 +174,6 @@ Replace system System restored Restore failed - This is running and can\'t be interrupted — please wait. Backup & restore Keeps a backup or restore running safely in the background Backup & restore @@ -187,6 +186,8 @@ This can take a while Keep K2Go open. You can leave — it keeps running in the background, and the notification brings you back. Done — returning to Home… + Backup is running — you can leave; it keeps going and will be ready when you come back. + Restore is running — you can leave; it keeps going and will be ready when you come back. DEVELOPER Network & DNS Use a custom DNS. Off = server defaults. From 51d45ebdc9bae22b5b7d3cbc6b926d02a3bb0300 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 31 Jul 2026 08:34:55 -0600 Subject: [PATCH 3/5] ADFA-4961: bifurcation waits for the environment to be live before redirecting (like the index) The return-to-Home countdown was a blind 3s -> Home, which could drop the user on a Home still starting (the state we hardened yesterday). Now it mirrors SetupProgressActivity: boot the environment via serverController.startEnvironment() (unconditional, never the toggle) and poll RestReadiness.apiReady() every 2s off the main thread until the REST core answers, THEN redirect to an already-live Home. Cancel keeps the user on the bifurcation; a 45s timeout still goes Home so they're never trapped (Home's boot gate then owns the rest). --- .../redesign/BackupRestoreFragment.java | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java index baa10b56..f979ecef 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java @@ -40,8 +40,13 @@ public class BackupRestoreFragment extends Fragment { private static boolean sReturnPending = false; public static void armReturnCountdown() { sReturnPending = true; } + // ADFA-4961: like the module index, wait for the environment to actually be live before redirecting. + private static final long READY_POLL_MS = 2000L; + private static final long READY_TIMEOUT_MS = 45000L; private final Handler main = new Handler(Looper.getMainLooper()); - private Runnable homeRunnable; + private Runnable readyPoll; + private boolean returnCancelled = false; + private long returnStartMs = 0L; private int px(int dp) { return Math.round(dp * getResources().getDisplayMetrics().density); } @@ -87,8 +92,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c if (sReturnPending) { sReturnPending = false; col.addView(buildReturnBar()); - homeRunnable = this::goHome; - main.postDelayed(homeRunnable, 3000L); + startReturnToHome(); } return scroll; @@ -123,13 +127,46 @@ private View buildReturnBar() { cancel.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal)); cancel.setPadding(px(12), px(4), px(4), px(4)); cancel.setOnClickListener(v -> { - if (homeRunnable != null) main.removeCallbacks(homeRunnable); + returnCancelled = true; + if (readyPoll != null) main.removeCallbacks(readyPoll); bar.setVisibility(View.GONE); }); bar.addView(cancel); return bar; } + /** + * ADFA-4961: mirror the module index — boot the environment (startEnvironment, never the toggle) + * and WAIT for RestReadiness.apiReady() before redirecting, so we never drop the user on a Home that + * is still starting. Cancel keeps them here; a READY_TIMEOUT_MS fallback still goes Home so the user + * is never trapped (Home's boot gate then owns the rest). + */ + private void startReturnToHome() { + returnCancelled = false; + returnStartMs = android.os.SystemClock.elapsedRealtime(); + if (getActivity() instanceof SetupLibraryActivity) { + org.iiab.controller.ServerController sc = ((SetupLibraryActivity) getActivity()).server(); + if (sc != null) sc.startEnvironment(); + } + readyPoll = new Runnable() { + @Override public void run() { + if (returnCancelled || !isAdded()) return; + org.iiab.controller.util.AppExecutors.get().io().execute(() -> { + final boolean up = RestReadiness.apiReady(); + main.post(() -> { + if (returnCancelled || !isAdded()) return; + if (up || android.os.SystemClock.elapsedRealtime() - returnStartMs > READY_TIMEOUT_MS) { + goHome(); // live (or timed out → Home's boot gate takes over) + } else { + main.postDelayed(readyPoll, READY_POLL_MS); + } + }); + }); + } + }; + main.postDelayed(readyPoll, READY_POLL_MS); + } + private void goHome() { if (!isAdded()) return; startActivity(new Intent(requireContext(), LibraryActivity.class) @@ -194,7 +231,7 @@ private View card(int iconRes, int titleRes, int subRes, View.OnClickListener on } @Override public void onDestroyView() { - if (homeRunnable != null) main.removeCallbacks(homeRunnable); + if (readyPoll != null) main.removeCallbacks(readyPoll); super.onDestroyView(); } } From b339b43280a43fefb7eed5278292c7e6e75ce38d Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 31 Jul 2026 08:59:54 -0600 Subject: [PATCH 4/5] ADFA-4961: mode-specific backup/restore Lottie loops (replace the generic template loop) Add the dedicated working loops (open box + streaming dots + glow; backup = phone->box, restore = box->phone) and select them by mode in BackupJobFragment instead of the shared k2go_working_loop. Per the design spec these replace the cloud with a box and still composite the K2Go logo via the app, so the existing logo scaffold (right ~0.82W/0.53H) is unchanged. Also resolves the 'little cloud' note. --- .../org/iiab/controller/redesign/BackupJobFragment.java | 5 +++++ .../app/src/main/res/layout/fragment_k2go_backup_job.xml | 7 +++---- controller/app/src/main/res/raw/k2go_backup_loop.json | 1 + controller/app/src/main/res/raw/k2go_restore_loop.json | 1 + 4 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 controller/app/src/main/res/raw/k2go_backup_loop.json create mode 100644 controller/app/src/main/res/raw/k2go_restore_loop.json diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java index 0c8a5b02..e2ed026c 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BackupJobFragment.java @@ -90,6 +90,11 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c mode = getArguments() != null ? getArguments().getString(ARG_MODE, MODE_BACKUP) : MODE_BACKUP; View v = inflater.inflate(R.layout.fragment_k2go_backup_job, container, false); anim = v.findViewById(R.id.k2go_bj_anim); + // ADFA-4961: mode-specific working loop (box + streaming dots + glow); the app logo is composited + // on top by the layout scaffold, same as the module-install template. + anim.setAnimation(isRestore() ? R.raw.k2go_restore_loop : R.raw.k2go_backup_loop); + anim.setRepeatCount(com.airbnb.lottie.LottieDrawable.INFINITE); + anim.playAnimation(); title = v.findViewById(R.id.k2go_bj_title); sub = v.findViewById(R.id.k2go_bj_sub); status = v.findViewById(R.id.k2go_bj_status); diff --git a/controller/app/src/main/res/layout/fragment_k2go_backup_job.xml b/controller/app/src/main/res/layout/fragment_k2go_backup_job.xml index f0451e31..baa15a5c 100644 --- a/controller/app/src/main/res/layout/fragment_k2go_backup_job.xml +++ b/controller/app/src/main/res/layout/fragment_k2go_backup_job.xml @@ -42,15 +42,14 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp"> + + android:scaleType="fitCenter" /> Date: Fri, 31 Jul 2026 09:12:59 -0600 Subject: [PATCH 5/5] ADFA-4961: fix the 'returning to Home' bar text contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTextAppearance() was called after setTextColor() on the countdown message, so Material3's default (light) color overrode the intended teal, leaving it near-invisible on the pale info background (Cancel was fine — it has no appearance). Set the color after the appearance. --- .../org/iiab/controller/redesign/BackupRestoreFragment.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java index f979ecef..7652d345 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/BackupRestoreFragment.java @@ -117,8 +117,10 @@ private View buildReturnBar() { TextView msg = new TextView(requireContext()); msg.setText(getString(R.string.k2go_bj_returning)); - msg.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal)); msg.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium); + // ADFA-4961: set the color AFTER the appearance — setTextAppearance() overrides textColor, which + // was leaving this line the theme's light default (invisible on the pale info background). + msg.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal)); bar.addView(msg, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); TextView cancel = new TextView(requireContext());