Skip to content

Commit bbaa170

Browse files
Merge pull request #34 from appdevforall/feat/backup-ux
feat(backup): import/restore UX — exact name, progress buttons, spinner, extraction log
2 parents ec5670a + 0f4da76 commit bbaa170

11 files changed

Lines changed: 302 additions & 15 deletions

File tree

‎controller/app/src/main/java/org/iiab/controller/DeployFragment.java‎

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,13 @@ public class DeployFragment extends Fragment {
8585
private View ledInternet, ledDevMode, ledDcpr, ledPpk;
8686
private TextView txtDcpr, txtPpk, btnRefreshModules;
8787
private LinearLayout rolesContainer, discrepancyWarning;
88-
private Button btnLaunchInstall, btnFastInstall, btnFastDelete, btnAdvancedReset;
89-
private Button btnAdvancedBackup, btnAdvancedRestore, btnAdvancedForceStop;
88+
private Button btnLaunchInstall, btnAdvancedReset;
89+
private ProgressButton btnFastInstall, btnFastDelete;
90+
private Button btnAdvancedForceStop;
91+
private ProgressButton btnAdvancedBackup, btnAdvancedRestore;
92+
private LinearLayout restoreLogPanel;
93+
private TextView restoreLogText, restoreLogResult;
94+
private androidx.core.widget.NestedScrollView restoreLogScroll;
9095

9196
// Backup Menu UI
9297
private TextView txtSelectBackupTitle, txtBackupStatus;
@@ -137,6 +142,9 @@ public class DeployFragment extends Fragment {
137142
private boolean isRestoring = false;
138143
private boolean isDeleting = false;
139144
private boolean isImporting = false;
145+
private static final String[] IMPORT_SPINNER = {"\u28BF", "\u28FB", "\u28FD", "\u28FE", "\u28F7", "\u28EF", "\u28DF", "\u287F"};
146+
private android.os.Handler importSpinnerHandler;
147+
private int importSpinnerFrame = 0;
140148
private PRootEngine prootEngine;
141149

142150
// Background Handlers
@@ -281,6 +289,14 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
281289
btnAdvancedBackup = view.findViewById(R.id.btn_advanced_backup);
282290
btnAdvancedRestore = view.findViewById(R.id.btn_advanced_restore);
283291
btnAdvancedForceStop = view.findViewById(R.id.btn_advanced_force_stop);
292+
restoreLogPanel = view.findViewById(R.id.restore_log_panel);
293+
restoreLogText = view.findViewById(R.id.restore_log_text);
294+
restoreLogResult = view.findViewById(R.id.restore_log_result);
295+
restoreLogScroll = view.findViewById(R.id.restore_log_scroll);
296+
View restoreLogClose = view.findViewById(R.id.restore_log_close);
297+
if (restoreLogClose != null) {
298+
restoreLogClose.setOnClickListener(vv -> { if (restoreLogPanel != null) restoreLogPanel.setVisibility(View.GONE); });
299+
}
284300
txtSelectBackupTitle = view.findViewById(R.id.txt_select_backup_title);
285301
containerBackupList = view.findViewById(R.id.container_backup_list);
286302
txtBackupStatus = view.findViewById(R.id.txt_backup_status);
@@ -1044,6 +1060,7 @@ private void bindInstallButtonLogic(MainActivity mainAct, File debianRootfs, Fil
10441060
mainAct.invalidateModuleStateTrust();
10451061
isDownloadingRootfs = true;
10461062
btnFastInstall.setAlpha(0.8f);
1063+
btnFastInstall.startProgress();
10471064
btnFastInstall.setTextSize(12f);
10481065

10491066
if (aria2Manager == null) aria2Manager = new Aria2Manager();
@@ -1183,6 +1200,7 @@ private void bindDeleteButtonLogic(MainActivity mainAct, File debianRootfs) {
11831200

11841201
mainAct.invalidateModuleStateTrust();
11851202
btnFastDelete.setEnabled(false);
1203+
btnFastDelete.startProgress();
11861204
Snackbar.make(getView(), R.string.install_status_deleting, Snackbar.LENGTH_SHORT).show();
11871205
new Thread(() -> {
11881206
enableSystemProtection();
@@ -1195,7 +1213,7 @@ private void bindDeleteButtonLogic(MainActivity mainAct, File debianRootfs) {
11951213
mainAct.runOnUiThread(() -> Snackbar.make(getView(), getString(R.string.install_error_delete, e.getMessage()), Snackbar.LENGTH_LONG).show());
11961214
} finally {
11971215
isDeleting = false;
1198-
mainAct.runOnUiThread(this::updateDynamicButtons);
1216+
mainAct.runOnUiThread(() -> { btnFastDelete.stopProgress(); updateDynamicButtons(); });
11991217
disableSystemProtection();
12001218
}
12011219
}).start();
@@ -1590,6 +1608,7 @@ private void finishInstallationSuccess() {
15901608
isDownloadingRootfs = false;
15911609
if (isAdded() && getActivity() != null) {
15921610
getActivity().runOnUiThread(() -> {
1611+
btnFastInstall.stopProgress();
15931612
btnFastInstall.setText(R.string.install_btn_reinstall);
15941613
btnFastInstall.setAlpha(1.0f);
15951614
updateDynamicButtons();
@@ -1605,6 +1624,7 @@ private void abortInstallation(String message) {
16051624
isDownloadingRootfs = false;
16061625
if (isAdded() && getActivity() != null) {
16071626
getActivity().runOnUiThread(() -> {
1627+
btnFastInstall.stopProgress();
16081628
btnFastInstall.setText(R.string.install_btn_install);
16091629
btnFastInstall.setAlpha(1.0f);
16101630
updateDynamicButtons();
@@ -1838,7 +1858,7 @@ private void bindBackupButtonLogic(MainActivity mainAct, File backupsDir, File i
18381858
.setMessage(getString(R.string.install_msg_backup_in_progress_body))
18391859
.setPositiveButton(getString(R.string.install_btn_force_stop_process), (dialog, which) -> {
18401860
isBackupInProgress = false;
1841-
btnAdvancedBackup.setText(getString(R.string.install_btn_backup));
1861+
btnAdvancedBackup.setText(getString(R.string.install_btn_backup)); btnAdvancedBackup.stopProgress();
18421862
Snackbar.make(getView(), getString(R.string.install_msg_backup_aborted), Snackbar.LENGTH_SHORT).show();
18431863
})
18441864
.setNegativeButton(getString(R.string.install_btn_let_finish), null)
@@ -1849,6 +1869,7 @@ private void bindBackupButtonLogic(MainActivity mainAct, File backupsDir, File i
18491869

18501870
isBackupInProgress = true;
18511871
btnAdvancedBackup.setText(getString(R.string.install_msg_compressing));
1872+
btnAdvancedBackup.startProgress();
18521873
Snackbar.make(v, getString(R.string.install_msg_creating_backup), Snackbar.LENGTH_LONG).show();
18531874

18541875
new Thread(() -> {
@@ -1917,14 +1938,14 @@ private void bindBackupButtonLogic(MainActivity mainAct, File backupsDir, File i
19171938
prefs.edit().putInt("backup_daily_id", currentId - 1).apply();
19181939
}
19191940
isBackupInProgress = false;
1920-
btnAdvancedBackup.setText(getString(R.string.install_btn_backup));
1941+
btnAdvancedBackup.setText(getString(R.string.install_btn_backup)); btnAdvancedBackup.stopProgress();
19211942
updateDynamicButtons();
19221943
disableSystemProtection();
19231944
});
19241945
} catch (Exception e) {
19251946
mainAct.runOnUiThread(() -> {
19261947
isBackupInProgress = false;
1927-
btnAdvancedBackup.setText(getString(R.string.install_btn_backup));
1948+
btnAdvancedBackup.setText(getString(R.string.install_btn_backup)); btnAdvancedBackup.stopProgress();
19281949
Snackbar.make(getView(), getString(R.string.install_msg_backup_error, e.getMessage()), Snackbar.LENGTH_LONG).show();
19291950
updateDynamicButtons();
19301951
disableSystemProtection();
@@ -2131,6 +2152,12 @@ private void refreshRestoreButtonLogic() {
21312152

21322153
btnAdvancedRestore.setEnabled(false);
21332154
btnAdvancedRestore.setText(getString(R.string.install_status_restoring));
2155+
btnAdvancedRestore.startProgress();
2156+
if (restoreLogPanel != null) {
2157+
restoreLogPanel.setVisibility(View.VISIBLE);
2158+
if (restoreLogText != null) restoreLogText.setText("");
2159+
if (restoreLogResult != null) restoreLogResult.setText("");
2160+
}
21342161
File iiabRootDir = new File(requireContext().getFilesDir(), "rootfs");
21352162
TarExtractor tarExtractor = new TarExtractor();
21362163

@@ -2144,6 +2171,8 @@ public void onComplete(String destDir) {
21442171
btnAdvancedRestore.setEnabled(true);
21452172
btnAdvancedRestore.setText(getString(R.string.install_btn_restore));
21462173
Snackbar.make(getView(), R.string.install_success_restore, Snackbar.LENGTH_LONG).show();
2174+
if (restoreLogResult != null) { restoreLogResult.setText("\u2713"); restoreLogResult.setTextColor(ContextCompat.getColor(requireContext(), R.color.status_success)); }
2175+
btnAdvancedRestore.stopProgress();
21472176
updateDynamicButtons();
21482177
});
21492178
}
@@ -2156,19 +2185,67 @@ public void onError(String error) {
21562185
btnAdvancedRestore.setEnabled(true);
21572186
btnAdvancedRestore.setText(getString(R.string.install_btn_restore));
21582187
Snackbar.make(getView(), getString(R.string.install_msg_restore_failed) + " " + error, Snackbar.LENGTH_LONG).show();
2188+
if (restoreLogResult != null) { restoreLogResult.setText("\u2717"); restoreLogResult.setTextColor(ContextCompat.getColor(requireContext(), R.color.status_warning)); }
2189+
btnAdvancedRestore.stopProgress();
21592190
updateDynamicButtons();
21602191
});
21612192
}
2193+
2194+
@Override
2195+
public void onProgress(String line) {
2196+
if (restoreLogText == null) return;
2197+
restoreLogText.append(line + "\n");
2198+
if (restoreLogScroll != null) {
2199+
restoreLogScroll.post(() -> restoreLogScroll.fullScroll(View.FOCUS_DOWN));
2200+
}
2201+
}
21622202
});
21632203
});
21642204
}
21652205
}
21662206

2207+
private void startImportSpinner() {
2208+
stopImportSpinner();
2209+
importSpinnerFrame = 0;
2210+
importSpinnerHandler = new android.os.Handler(android.os.Looper.getMainLooper());
2211+
final Runnable r = new Runnable() {
2212+
@Override public void run() {
2213+
if (btnImportBackup != null) {
2214+
String f = IMPORT_SPINNER[importSpinnerFrame++ % IMPORT_SPINNER.length];
2215+
btnImportBackup.setText(getString(R.string.install_msg_importing) + " " + f);
2216+
}
2217+
if (importSpinnerHandler != null) importSpinnerHandler.postDelayed(this, 90);
2218+
}
2219+
};
2220+
importSpinnerHandler.post(r);
2221+
}
2222+
2223+
private void stopImportSpinner() {
2224+
if (importSpinnerHandler != null) {
2225+
importSpinnerHandler.removeCallbacksAndMessages(null);
2226+
importSpinnerHandler = null;
2227+
}
2228+
}
2229+
2230+
/** Best-effort original filename from a SAF content:// URI (DISPLAY_NAME), or null. */
2231+
private String queryDisplayName(Uri uri) {
2232+
try (android.database.Cursor c = requireContext().getContentResolver()
2233+
.query(uri, new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, null, null, null)) {
2234+
if (c != null && c.moveToFirst()) {
2235+
int idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME);
2236+
if (idx >= 0) return c.getString(idx);
2237+
}
2238+
} catch (Exception e) {
2239+
Log.w(TAG, "queryDisplayName failed: " + e.getMessage());
2240+
}
2241+
return null;
2242+
}
2243+
21672244
private void importBackupSafely(Uri sourceUri) {
21682245
isImporting = true;
21692246
updateDynamicButtons();
21702247
btnImportBackup.setEnabled(false);
2171-
btnImportBackup.setText(getString(R.string.install_msg_importing));
2248+
startImportSpinner();
21722249
Snackbar.make(getView(), getString(R.string.install_msg_importing), Snackbar.LENGTH_LONG).show();
21732250

21742251
new Thread(() -> {
@@ -2177,7 +2254,14 @@ private void importBackupSafely(Uri sourceUri) {
21772254
File backupsDir = new File(requireContext().getFilesDir(), "rootfs/backups");
21782255
if (!backupsDir.exists()) backupsDir.mkdirs();
21792256

2180-
String fileName = "imported_backup_" + System.currentTimeMillis() + ".tar.gz";
2257+
// Keep the imported file's EXACT name; disambiguate with -1/-2/... on collision.
2258+
String desiredName = queryDisplayName(sourceUri);
2259+
java.util.Set<String> existingNames = new java.util.HashSet<>();
2260+
File[] existingFiles = backupsDir.listFiles();
2261+
if (existingFiles != null) {
2262+
for (File f : existingFiles) existingNames.add(f.getName());
2263+
}
2264+
String fileName = org.iiab.controller.backup.domain.BackupNameResolver.resolve(desiredName, existingNames);
21812265
File destFile = new File(backupsDir, fileName);
21822266

21832267
InputStream is = requireContext().getContentResolver().openInputStream(sourceUri);
@@ -2194,6 +2278,7 @@ private void importBackupSafely(Uri sourceUri) {
21942278
if (getActivity() != null) {
21952279
getActivity().runOnUiThread(() -> {
21962280
isImporting = false;
2281+
stopImportSpinner();
21972282
btnImportBackup.setEnabled(true);
21982283
btnImportBackup.setText(getString(R.string.install_btn_import_backup));
21992284
selectedBackupFile = fileName;
@@ -2205,6 +2290,7 @@ private void importBackupSafely(Uri sourceUri) {
22052290
if (getActivity() != null) {
22062291
getActivity().runOnUiThread(() -> {
22072292
isImporting = false;
2293+
stopImportSpinner();
22082294
updateDynamicButtons();
22092295
btnImportBackup.setEnabled(true);
22102296
btnImportBackup.setText(getString(R.string.install_btn_import_backup));

‎controller/app/src/main/java/org/iiab/controller/TarExtractor.java‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public interface ExtractionListener {
3434
void onComplete(String destDir);
3535

3636
void onError(String error);
37+
38+
/** A streamed line of extraction output (verbose tar). Default no-op. */
39+
default void onProgress(String line) { }
3740
}
3841

3942
public void startExtraction(Context context, String archivePath, String destDir, ExtractionListener listener) {
@@ -66,7 +69,7 @@ public void startExtraction(Context context, String archivePath, String destDir,
6669
// 2. BUILD THE COMMAND
6770
List<String> command = new ArrayList<>();
6871
command.add(tarBinary);
69-
command.add("-xf");
72+
command.add("-xvf");
7073

7174
if (isGzip) {
7275
// 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,
8487
tarProcess = pb.start();
8588

8689
// 3. READ TAR OUTPUT (Prevents buffer blocking and logs errors)
90+
final Handler uiHandler = new Handler(Looper.getMainLooper());
8791
new Thread(() -> {
92+
long[] lastEmit = {0L};
8893
try (BufferedReader reader = new BufferedReader(new InputStreamReader(tarProcess.getInputStream()))) {
8994
String line;
9095
while ((line = reader.readLine()) != null) {
9196
Log.d(TAG, "Tar Output: " + line);
97+
long now = System.currentTimeMillis();
98+
if (now - lastEmit[0] >= 50) {
99+
lastEmit[0] = now;
100+
final String l = line;
101+
uiHandler.post(() -> listener.onProgress(l));
102+
}
92103
}
93104
} catch (Exception ignored) {
94105
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* ============================================================================
3+
* Name : BackupNameResolver.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : Pure rule for keeping an imported backup's exact filename, with
7+
* -1/-2/-3 disambiguation on collision.
8+
* ============================================================================
9+
*/
10+
package org.iiab.controller.backup.domain;
11+
12+
import java.util.Set;
13+
14+
/**
15+
* Decides the on-disk filename for an imported backup. Keeps the original name
16+
* EXACTLY; only if that name already exists does it insert {@code -1}, {@code -2}, ...
17+
* before the extension. Compound extensions ({@code .tar.gz}, {@code .tar.xz}) are
18+
* preserved. Pure domain logic (no Android, no I/O) so it is fully unit-testable.
19+
*/
20+
public final class BackupNameResolver {
21+
22+
private static final String FALLBACK = "backup.tar.gz";
23+
24+
private BackupNameResolver() { }
25+
26+
/**
27+
* @param desired the original filename (e.g. from SAF DISPLAY_NAME); may be null/dirty
28+
* @param existing the set of filenames already present in the backups dir
29+
* @return the exact desired name if free, else the same name with -1/-2/... before the ext
30+
*/
31+
public static String resolve(String desired, Set<String> existing) {
32+
String name = sanitize(desired);
33+
if (existing == null || !existing.contains(name)) {
34+
return name;
35+
}
36+
String base = baseName(name);
37+
String ext = extension(name);
38+
for (int i = 1; ; i++) {
39+
String candidate = base + "-" + i + ext;
40+
if (!existing.contains(candidate)) {
41+
return candidate;
42+
}
43+
}
44+
}
45+
46+
/** Strip any path, trim, and fall back to a safe default when empty. */
47+
static String sanitize(String name) {
48+
if (name == null) return FALLBACK;
49+
String n = name.trim();
50+
int sep = Math.max(n.lastIndexOf('/'), n.lastIndexOf('\\'));
51+
if (sep >= 0) n = n.substring(sep + 1);
52+
return n.isEmpty() ? FALLBACK : n;
53+
}
54+
55+
/** Compound-extension aware: {@code .tar.gz}/{@code .tar.xz}, else the last {@code .ext}. */
56+
static String extension(String name) {
57+
String lower = name.toLowerCase();
58+
if (lower.endsWith(".tar.gz") || lower.endsWith(".tar.xz")) {
59+
return name.substring(name.length() - 7);
60+
}
61+
int dot = name.lastIndexOf('.');
62+
return dot > 0 ? name.substring(dot) : "";
63+
}
64+
65+
static String baseName(String name) {
66+
return name.substring(0, name.length() - extension(name).length());
67+
}
68+
}

0 commit comments

Comments
 (0)