From ab53d4e7f81786eb188e31342d015fa9f49bf77b Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Tue, 23 Jun 2026 01:08:29 +0000 Subject: [PATCH 1/8] feat(backup): keep imported backup's exact name (-1/-2/-3 on collision) Imported backups were renamed to imported_backup_.tar.gz, losing the original name. Now: - new pure domain BackupNameResolver: keeps the EXACT desired name; on collision inserts -1/-2/... before the extension; preserves .tar.gz/.tar.xz; sanitizes path/empty/null. Unit-tested (BackupNameResolverTest). - importBackupSafely queries the SAF content:// DISPLAY_NAME (ContentResolver + OpenableColumns) and resolves a unique name against the existing backups. First part of the backups UX work (single PR); button animations + CLI extraction log follow on this branch. (cherry picked from commit da2bd8b5b087a9da068dd8bd5fcb205f559e51d4) --- .../org/iiab/controller/DeployFragment.java | 23 ++++++- .../backup/domain/BackupNameResolver.java | 68 +++++++++++++++++++ .../backup/domain/BackupNameResolverTest.java | 50 ++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/backup/domain/BackupNameResolver.java create mode 100644 controller/app/src/test/java/org/iiab/controller/backup/domain/BackupNameResolverTest.java diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index 35f1622..3687d5a 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -2164,6 +2164,20 @@ public void onError(String error) { } } + /** Best-effort original filename from a SAF content:// URI (DISPLAY_NAME), or null. */ + private String queryDisplayName(Uri uri) { + try (android.database.Cursor c = requireContext().getContentResolver() + .query(uri, new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, null, null, null)) { + if (c != null && c.moveToFirst()) { + int idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); + if (idx >= 0) return c.getString(idx); + } + } catch (Exception e) { + Log.w(TAG, "queryDisplayName failed: " + e.getMessage()); + } + return null; + } + private void importBackupSafely(Uri sourceUri) { isImporting = true; updateDynamicButtons(); @@ -2177,7 +2191,14 @@ private void importBackupSafely(Uri sourceUri) { File backupsDir = new File(requireContext().getFilesDir(), "rootfs/backups"); if (!backupsDir.exists()) backupsDir.mkdirs(); - String fileName = "imported_backup_" + System.currentTimeMillis() + ".tar.gz"; + // Keep the imported file's EXACT name; disambiguate with -1/-2/... on collision. + String desiredName = queryDisplayName(sourceUri); + java.util.Set existingNames = new java.util.HashSet<>(); + File[] existingFiles = backupsDir.listFiles(); + if (existingFiles != null) { + for (File f : existingFiles) existingNames.add(f.getName()); + } + String fileName = org.iiab.controller.backup.domain.BackupNameResolver.resolve(desiredName, existingNames); File destFile = new File(backupsDir, fileName); InputStream is = requireContext().getContentResolver().openInputStream(sourceUri); diff --git a/controller/app/src/main/java/org/iiab/controller/backup/domain/BackupNameResolver.java b/controller/app/src/main/java/org/iiab/controller/backup/domain/BackupNameResolver.java new file mode 100644 index 0000000..0671576 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/backup/domain/BackupNameResolver.java @@ -0,0 +1,68 @@ +/* + * ============================================================================ + * Name : BackupNameResolver.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Pure rule for keeping an imported backup's exact filename, with + * -1/-2/-3 disambiguation on collision. + * ============================================================================ + */ +package org.iiab.controller.backup.domain; + +import java.util.Set; + +/** + * Decides the on-disk filename for an imported backup. Keeps the original name + * EXACTLY; only if that name already exists does it insert {@code -1}, {@code -2}, ... + * before the extension. Compound extensions ({@code .tar.gz}, {@code .tar.xz}) are + * preserved. Pure domain logic (no Android, no I/O) so it is fully unit-testable. + */ +public final class BackupNameResolver { + + private static final String FALLBACK = "backup.tar.gz"; + + private BackupNameResolver() { } + + /** + * @param desired the original filename (e.g. from SAF DISPLAY_NAME); may be null/dirty + * @param existing the set of filenames already present in the backups dir + * @return the exact desired name if free, else the same name with -1/-2/... before the ext + */ + public static String resolve(String desired, Set existing) { + String name = sanitize(desired); + if (existing == null || !existing.contains(name)) { + return name; + } + String base = baseName(name); + String ext = extension(name); + for (int i = 1; ; i++) { + String candidate = base + "-" + i + ext; + if (!existing.contains(candidate)) { + return candidate; + } + } + } + + /** Strip any path, trim, and fall back to a safe default when empty. */ + static String sanitize(String name) { + if (name == null) return FALLBACK; + String n = name.trim(); + int sep = Math.max(n.lastIndexOf('/'), n.lastIndexOf('\\')); + if (sep >= 0) n = n.substring(sep + 1); + return n.isEmpty() ? FALLBACK : n; + } + + /** Compound-extension aware: {@code .tar.gz}/{@code .tar.xz}, else the last {@code .ext}. */ + static String extension(String name) { + String lower = name.toLowerCase(); + if (lower.endsWith(".tar.gz") || lower.endsWith(".tar.xz")) { + return name.substring(name.length() - 7); + } + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(dot) : ""; + } + + static String baseName(String name) { + return name.substring(0, name.length() - extension(name).length()); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/backup/domain/BackupNameResolverTest.java b/controller/app/src/test/java/org/iiab/controller/backup/domain/BackupNameResolverTest.java new file mode 100644 index 0000000..ee2ccca --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/backup/domain/BackupNameResolverTest.java @@ -0,0 +1,50 @@ +package org.iiab.controller.backup.domain; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.junit.Test; + +/** Pure-JVM tests for keeping the exact backup name with -N disambiguation. */ +public class BackupNameResolverTest { + + private static Set set(String... names) { + return new HashSet<>(Arrays.asList(names)); + } + + @Test public void keepsExactNameWhenFree() { + assertEquals("iiab-backup.tar.gz", + BackupNameResolver.resolve("iiab-backup.tar.gz", Collections.emptySet())); + } + + @Test public void appendsDashOneOnCollision() { + assertEquals("iiab-backup-1.tar.gz", + BackupNameResolver.resolve("iiab-backup.tar.gz", set("iiab-backup.tar.gz"))); + } + + @Test public void incrementsUntilFree() { + assertEquals("iiab-backup-3.tar.gz", + BackupNameResolver.resolve("iiab-backup.tar.gz", + set("iiab-backup.tar.gz", "iiab-backup-1.tar.gz", "iiab-backup-2.tar.gz"))); + } + + @Test public void preservesTarXzCompoundExtension() { + assertEquals("snap-1.tar.xz", + BackupNameResolver.resolve("snap.tar.xz", set("snap.tar.xz"))); + } + + @Test public void handlesNameWithoutExtension() { + assertEquals("myfile-1", + BackupNameResolver.resolve("myfile", set("myfile"))); + } + + @Test public void stripsPathAndFallsBackOnEmptyOrNull() { + assertEquals("a.tar.gz", BackupNameResolver.resolve("/sdcard/Download/a.tar.gz", Collections.emptySet())); + assertEquals("backup.tar.gz", BackupNameResolver.resolve(null, Collections.emptySet())); + assertEquals("backup.tar.gz", BackupNameResolver.resolve(" ", Collections.emptySet())); + } +} From 1e0a05873753f4c64fa4aac74dc5a687ce865880 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Tue, 23 Jun 2026 01:23:08 +0000 Subject: [PATCH 2/8] feat(backup): braille spinner on import + streamable extraction (TarExtractor.onProgress) - Import button shows a minimalist braille spinner while copying, then settles (the exact resolved name is shown by the success path). - TarExtractor: ExtractionListener gains a default no-op onProgress(line); tar now runs verbose (-xvf) and streams output lines to the listener on the main thread, throttled to ~20/s so it never floods the UI. Enables the contextual CLI log (Deploy) without breaking existing listeners. Next on this branch: ProgressButton on Restore/Backup/Fast-install/Delete + the contextual CLI log view + persistent result. (cherry picked from commit da22f14d92c36ea85569734f05cf7facb30c7666) --- .../org/iiab/controller/DeployFragment.java | 30 ++++++++++++++++++- .../org/iiab/controller/TarExtractor.java | 13 +++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index 3687d5a..3fdad17 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -137,6 +137,9 @@ public class DeployFragment extends Fragment { private boolean isRestoring = false; private boolean isDeleting = false; private boolean isImporting = false; + private static final String[] IMPORT_SPINNER = {"\u28BF", "\u28FB", "\u28FD", "\u28FE", "\u28F7", "\u28EF", "\u28DF", "\u287F"}; + private android.os.Handler importSpinnerHandler; + private int importSpinnerFrame = 0; private PRootEngine prootEngine; // Background Handlers @@ -2164,6 +2167,29 @@ public void onError(String error) { } } + private void startImportSpinner() { + stopImportSpinner(); + importSpinnerFrame = 0; + importSpinnerHandler = new android.os.Handler(android.os.Looper.getMainLooper()); + final Runnable r = new Runnable() { + @Override public void run() { + if (btnImportBackup != null) { + String f = IMPORT_SPINNER[importSpinnerFrame++ % IMPORT_SPINNER.length]; + btnImportBackup.setText(f + " " + getString(R.string.install_msg_importing)); + } + if (importSpinnerHandler != null) importSpinnerHandler.postDelayed(this, 90); + } + }; + importSpinnerHandler.post(r); + } + + private void stopImportSpinner() { + if (importSpinnerHandler != null) { + importSpinnerHandler.removeCallbacksAndMessages(null); + importSpinnerHandler = null; + } + } + /** Best-effort original filename from a SAF content:// URI (DISPLAY_NAME), or null. */ private String queryDisplayName(Uri uri) { try (android.database.Cursor c = requireContext().getContentResolver() @@ -2182,7 +2208,7 @@ private void importBackupSafely(Uri sourceUri) { isImporting = true; updateDynamicButtons(); btnImportBackup.setEnabled(false); - btnImportBackup.setText(getString(R.string.install_msg_importing)); + startImportSpinner(); Snackbar.make(getView(), getString(R.string.install_msg_importing), Snackbar.LENGTH_LONG).show(); new Thread(() -> { @@ -2215,6 +2241,7 @@ private void importBackupSafely(Uri sourceUri) { if (getActivity() != null) { getActivity().runOnUiThread(() -> { isImporting = false; + stopImportSpinner(); btnImportBackup.setEnabled(true); btnImportBackup.setText(getString(R.string.install_btn_import_backup)); selectedBackupFile = fileName; @@ -2226,6 +2253,7 @@ private void importBackupSafely(Uri sourceUri) { if (getActivity() != null) { getActivity().runOnUiThread(() -> { isImporting = false; + stopImportSpinner(); updateDynamicButtons(); btnImportBackup.setEnabled(true); btnImportBackup.setText(getString(R.string.install_btn_import_backup)); diff --git a/controller/app/src/main/java/org/iiab/controller/TarExtractor.java b/controller/app/src/main/java/org/iiab/controller/TarExtractor.java index b977d12..f6ee3a3 100644 --- a/controller/app/src/main/java/org/iiab/controller/TarExtractor.java +++ b/controller/app/src/main/java/org/iiab/controller/TarExtractor.java @@ -34,6 +34,9 @@ public interface ExtractionListener { void onComplete(String destDir); void onError(String error); + + /** A streamed line of extraction output (verbose tar). Default no-op. */ + default void onProgress(String line) { } } public void startExtraction(Context context, String archivePath, String destDir, ExtractionListener listener) { @@ -66,7 +69,7 @@ public void startExtraction(Context context, String archivePath, String destDir, // 2. BUILD THE COMMAND List command = new ArrayList<>(); command.add(tarBinary); - command.add("-xf"); + command.add("-xvf"); if (isGzip) { // Tell tar to read the uncompressed raw bytes from standard input (stdin) @@ -84,11 +87,19 @@ public void startExtraction(Context context, String archivePath, String destDir, tarProcess = pb.start(); // 3. READ TAR OUTPUT (Prevents buffer blocking and logs errors) + final Handler uiHandler = new Handler(Looper.getMainLooper()); new Thread(() -> { + long[] lastEmit = {0L}; try (BufferedReader reader = new BufferedReader(new InputStreamReader(tarProcess.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { Log.d(TAG, "Tar Output: " + line); + long now = System.currentTimeMillis(); + if (now - lastEmit[0] >= 50) { + lastEmit[0] = now; + final String l = line; + uiHandler.post(() -> listener.onProgress(l)); + } } } catch (Exception ignored) { } From 1980688d18b2a8048fabe66e1042528d792b176e Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Tue, 23 Jun 2026 01:43:08 +0000 Subject: [PATCH 3/8] feat(backup): contextual CLI extraction log for restore (live + persistent result) Restore now surfaces extraction in-app instead of only logcat: - new collapsible 'restore_log_panel' in fragment_deploy.xml (terminal-style monospace ScrollView + header + result chip), hidden until a restore starts. - DeployFragment: restore start reveals + clears the panel; the listener's onProgress streams tar lines into it (auto-scroll); onComplete shows a green check, onError a warning mark -- both PERSIST so the user always sees what happened. Reuses existing restore strings; only one new string added. - New string restore_log_label added to ALL 6 locales (en/es/fr/pt source-quality; ru/hi as English placeholders pending the i18n PR). Compiles as a standalone increment. ProgressButton on the action buttons is next. (cherry picked from commit e2853d5da1b8c2a34806b9a9c84ea6b4c17bad53) --- .../org/iiab/controller/DeployFragment.java | 23 +++++++++ .../src/main/res/layout/fragment_deploy.xml | 49 +++++++++++++++++++ .../app/src/main/res/values-es/strings.xml | 1 + .../app/src/main/res/values-fr/strings.xml | 1 + .../app/src/main/res/values-hi/strings.xml | 1 + .../app/src/main/res/values-pt/strings.xml | 1 + .../src/main/res/values-ru-rRU/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 8 files changed, 78 insertions(+) diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index 3fdad17..09aa7aa 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -87,6 +87,9 @@ public class DeployFragment extends Fragment { private LinearLayout rolesContainer, discrepancyWarning; private Button btnLaunchInstall, btnFastInstall, btnFastDelete, btnAdvancedReset; private Button btnAdvancedBackup, btnAdvancedRestore, btnAdvancedForceStop; + private LinearLayout restoreLogPanel; + private TextView restoreLogText, restoreLogResult; + private android.widget.ScrollView restoreLogScroll; // Backup Menu UI private TextView txtSelectBackupTitle, txtBackupStatus; @@ -284,6 +287,10 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat btnAdvancedBackup = view.findViewById(R.id.btn_advanced_backup); btnAdvancedRestore = view.findViewById(R.id.btn_advanced_restore); btnAdvancedForceStop = view.findViewById(R.id.btn_advanced_force_stop); + restoreLogPanel = view.findViewById(R.id.restore_log_panel); + restoreLogText = view.findViewById(R.id.restore_log_text); + restoreLogResult = view.findViewById(R.id.restore_log_result); + restoreLogScroll = view.findViewById(R.id.restore_log_scroll); txtSelectBackupTitle = view.findViewById(R.id.txt_select_backup_title); containerBackupList = view.findViewById(R.id.container_backup_list); txtBackupStatus = view.findViewById(R.id.txt_backup_status); @@ -2134,6 +2141,11 @@ private void refreshRestoreButtonLogic() { btnAdvancedRestore.setEnabled(false); btnAdvancedRestore.setText(getString(R.string.install_status_restoring)); + if (restoreLogPanel != null) { + restoreLogPanel.setVisibility(View.VISIBLE); + if (restoreLogText != null) restoreLogText.setText(""); + if (restoreLogResult != null) restoreLogResult.setText(""); + } File iiabRootDir = new File(requireContext().getFilesDir(), "rootfs"); TarExtractor tarExtractor = new TarExtractor(); @@ -2147,6 +2159,7 @@ public void onComplete(String destDir) { btnAdvancedRestore.setEnabled(true); btnAdvancedRestore.setText(getString(R.string.install_btn_restore)); Snackbar.make(getView(), R.string.install_success_restore, Snackbar.LENGTH_LONG).show(); + if (restoreLogResult != null) { restoreLogResult.setText("\u2713"); restoreLogResult.setTextColor(ContextCompat.getColor(requireContext(), R.color.status_success)); } updateDynamicButtons(); }); } @@ -2159,9 +2172,19 @@ public void onError(String error) { btnAdvancedRestore.setEnabled(true); btnAdvancedRestore.setText(getString(R.string.install_btn_restore)); Snackbar.make(getView(), getString(R.string.install_msg_restore_failed) + " " + error, Snackbar.LENGTH_LONG).show(); + if (restoreLogResult != null) { restoreLogResult.setText("\u2717"); restoreLogResult.setTextColor(ContextCompat.getColor(requireContext(), R.color.status_warning)); } updateDynamicButtons(); }); } + + @Override + public void onProgress(String line) { + if (restoreLogText == null) return; + restoreLogText.append(line + "\n"); + if (restoreLogScroll != null) { + restoreLogScroll.post(() -> restoreLogScroll.fullScroll(View.FOCUS_DOWN)); + } + } }); }); } diff --git a/controller/app/src/main/res/layout/fragment_deploy.xml b/controller/app/src/main/res/layout/fragment_deploy.xml index 12c499b..3db6e53 100644 --- a/controller/app/src/main/res/layout/fragment_deploy.xml +++ b/controller/app/src/main/res/layout/fragment_deploy.xml @@ -737,6 +737,55 @@ android:textAllCaps="false" /> + + + + + + + + + + + + + + +