Skip to content

Commit 8a4c425

Browse files
Merge pull request #420 from appdevforall/feat/ADFA-5160-clone-receive-progress
ADFA-5160: anchor clone-receive progress to the dry-run total
2 parents 7dac08a + 7974f12 commit 8a4c425

7 files changed

Lines changed: 139 additions & 9 deletions

File tree

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public boolean startServer(Context context, ShareConfig config, String pass, Str
101101
}
102102

103103
@Override
104-
public void startClient(Context context, ShareConfig config, String hostIp, int port, String user, String pass, String destinationDir, TransportEngine.SyncListener listener) {
104+
public void startClient(Context context, ShareConfig config, String hostIp, int port, String user, String pass, String destinationDir, long expectedTotalBytes, TransportEngine.SyncListener listener) {
105105
stop();
106106
isCancelled = false;
107107
Handler mainHandler = new Handler(Looper.getMainLooper());
@@ -138,6 +138,7 @@ public void startClient(Context context, ShareConfig config, String hostIp, int
138138
String line;
139139

140140
String lastFile = "";
141+
int lastEmittedPct = 0; // ADFA-5160: smoothed percent, never walked back
141142

142143
while ((line = reader.readLine()) != null) {
143144
if (isCancelled) {
@@ -147,8 +148,35 @@ public void startClient(Context context, ShareConfig config, String hostIp, int
147148

148149
RsyncProgress progress = RsyncProgress.parse(line);
149150
if (progress != null) {
151+
// ADFA-5160: rsync's own percent divides by an estimate that keeps growing as
152+
// it discovers files, so it lurches. Anchor to the dry-run bytes-to-transfer
153+
// (what rsync computed for this transfer up front) and let the transferred-byte
154+
// count climb it. Hold at 99% until rsync's success lands; never go backwards.
155+
int pct;
156+
if (expectedTotalBytes > 0) {
157+
pct = (int) Math.min(99L, 100L * progress.bytes / expectedTotalBytes);
158+
} else {
159+
pct = Math.min(99, progress.percent);
160+
}
161+
if (pct < lastEmittedPct) pct = lastEmittedPct;
162+
lastEmittedPct = pct;
163+
164+
// ADFA-5160: rsync's ETA is computed against its per-file plan, so it jumps
165+
// the same way the old percent did. When we have a whole-set total, derive
166+
// the ETA from the bytes still to go and the current speed instead.
167+
String eta = progress.eta;
168+
if (expectedTotalBytes > 0) {
169+
double bps = RsyncProgress.parseSpeedBytesPerSec(progress.speed);
170+
if (bps > 0) {
171+
long remaining = Math.max(0L, expectedTotalBytes - progress.bytes);
172+
eta = RsyncProgress.formatEta((long) (remaining / bps));
173+
}
174+
}
175+
150176
String finalFile = lastFile;
151-
mainHandler.post(() -> listener.onProgress(progress.percent, progress.speed, progress.eta, finalFile));
177+
int finalPct = pct;
178+
String finalEta = eta;
179+
mainHandler.post(() -> listener.onProgress(finalPct, progress.speed, finalEta, finalFile));
152180
}
153181
// PHASE 1 FIX: Strict match for actual rsync errors, ignoring files named "error"
154182
//

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1282,7 +1282,12 @@ private void startReceiveTransfer() {
12821282
// orders are not discarded until it actually completes.
12831283
org.iiab.controller.system.data.ContentStateInvalidator.replacementStarting(app,
12841284
org.iiab.controller.system.domain.SystemReplacement.Cause.CLONE_RECEIVE);
1285-
transport.startClient(app, shareConfig, fcreds.ip, fcreds.port, fcreds.user, fcreds.pass, destPath,
1285+
// ADFA-5160: anchor the progress bar to the dry-run's bytes-to-transfer (startProbe
1286+
// ran it before this point), i.e. what rsync computed for THIS transfer. Not the QR
1287+
// estimate: it reflects the sender's initial install and can be stale. 0 falls back
1288+
// to rsync's own percent.
1289+
long expectedTotal = syncVm.getPendingBytes();
1290+
transport.startClient(app, shareConfig, fcreds.ip, fcreds.port, fcreds.user, fcreds.pass, destPath, expectedTotal,
12861291
new TransportEngine.SyncListener() {
12871292
@Override public void onProgress(int pct, String speed, String eta, String file) { SyncProgressRepository.get().postTransferring(pct, speed, eta, file); }
12881293
@Override public void onComplete(String message) {

‎controller/app/src/main/java/org/iiab/controller/sync/domain/RsyncProgress.java‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,35 @@
1818

1919
public final class RsyncProgress {
2020

21+
/** Bytes transferred so far this run — the leading column of a progress2 line. */
22+
public final long bytes;
2123
public final int percent;
2224
public final String speed;
2325
public final String eta;
2426

25-
private RsyncProgress(int percent, String speed, String eta) {
27+
private RsyncProgress(long bytes, int percent, String speed, String eta) {
28+
this.bytes = bytes;
2629
this.percent = percent;
2730
this.speed = speed;
2831
this.eta = eta;
2932
}
3033

34+
// ADFA-5160: capture the leading transferred-bytes column (with grouping separators)
35+
// as well as the percent. rsync's own percent divides by an estimate that grows as it
36+
// discovers files, so it jumps around; the byte count is a stable numerator the caller
37+
// can divide by a known total instead.
3138
private static final Pattern PROGRESS =
32-
Pattern.compile("(\\d+)%\\s+([\\d\\.]+[a-zA-Z/s]+)\\s+([\\d:]+)");
39+
Pattern.compile("([\\d,]+)\\s+(\\d+)%\\s+([\\d\\.]+[a-zA-Z/s]+)\\s+([\\d:]+)");
3340

3441
private static final Pattern STATS =
3542
Pattern.compile("Total transferred file size:\\s+([\\d,\\.]+)\\s+bytes");
3643

44+
// ADFA-5160: rsync's speed column, e.g. "12.34MB/s" / "512.00B/s". Used to turn the
45+
// whole-set remaining bytes into a whole-set ETA, instead of rsync's own ETA, which is
46+
// computed against its per-file plan and jumps the same way the old percent did.
47+
private static final Pattern SPEED =
48+
Pattern.compile("([\\d\\.]+)([kMGT]?B)/s");
49+
3750
/**
3851
* Parses one rsync {@code --info=progress2} line. Returns {@code null} if the
3952
* line carries no progress token or the percentage is not a number.
@@ -43,7 +56,8 @@ public static RsyncProgress parse(String line) {
4356
Matcher m = PROGRESS.matcher(line);
4457
if (!m.find()) return null;
4558
try {
46-
return new RsyncProgress(Integer.parseInt(m.group(1)), m.group(2), m.group(3));
59+
long bytes = Long.parseLong(m.group(1).replaceAll("[,\\.]", ""));
60+
return new RsyncProgress(bytes, Integer.parseInt(m.group(2)), m.group(3), m.group(4));
4761
} catch (NumberFormatException e) {
4862
return null;
4963
}
@@ -64,4 +78,38 @@ public static long parseTransferredBytes(String line, long fallback) {
6478
return fallback;
6579
}
6680
}
81+
82+
/**
83+
* Parses a rsync speed column ("12.34MB/s") into bytes per second (1024-based units, as
84+
* rsync prints them). Returns {@code -1} when the string does not match. ADFA-5160.
85+
*/
86+
public static double parseSpeedBytesPerSec(String speed) {
87+
if (speed == null) return -1d;
88+
Matcher m = SPEED.matcher(speed);
89+
if (!m.find()) return -1d;
90+
try {
91+
double n = Double.parseDouble(m.group(1));
92+
double mult;
93+
switch (m.group(2)) {
94+
case "B": mult = 1d; break;
95+
case "kB": mult = 1024d; break;
96+
case "MB": mult = 1024d * 1024d; break;
97+
case "GB": mult = 1024d * 1024d * 1024d; break;
98+
case "TB": mult = 1024d * 1024d * 1024d * 1024d; break;
99+
default: return -1d;
100+
}
101+
return n * mult;
102+
} catch (NumberFormatException e) {
103+
return -1d;
104+
}
105+
}
106+
107+
/** Formats a duration in seconds as rsync's {@code H:MM:SS}. Negatives clamp to zero. */
108+
public static String formatEta(long seconds) {
109+
if (seconds < 0) seconds = 0;
110+
long h = seconds / 3600;
111+
long m = (seconds % 3600) / 60;
112+
long s = seconds % 60;
113+
return h + String.format(java.util.Locale.US, ":%02d:%02d", m, s);
114+
}
67115
}

‎controller/app/src/main/java/org/iiab/controller/sync/presentation/ReceiveController.java‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,13 @@ private void startTransfer(SyncHandshakeHelper.SyncCredentials creds, File destD
157157
// the application context (it lives in the Activity-scoped ViewModel).
158158
SyncProgressRepository.get().postTransferring(0, "", "", "RootFS");
159159

160-
syncVm.getTransport().startClient(fragment.requireContext().getApplicationContext(), shareConfig, creds.ip, creds.port, creds.user, creds.pass, destDir.getAbsolutePath(), new TransportEngine.SyncListener() {
160+
// ADFA-5160: measure the bar against the dry-run's bytes-to-transfer — the amount
161+
// rsync itself computed for THIS transfer (resume-aware). Not the QR size estimate:
162+
// that reflects the sender's initial install and can be stale. 0 (no dry-run) makes
163+
// the transport fall back to rsync's own percent rather than a value we can't trust.
164+
long expectedTotal = syncVm.getPendingBytes();
165+
166+
syncVm.getTransport().startClient(fragment.requireContext().getApplicationContext(), shareConfig, creds.ip, creds.port, creds.user, creds.pass, destDir.getAbsolutePath(), expectedTotal, new TransportEngine.SyncListener() {
161167
@Override
162168
public void onProgress(int percentage, String speed, String eta, String currentFile) {
163169
SyncProgressRepository.get().postTransferring(percentage, speed, eta, currentFile);

‎controller/app/src/main/java/org/iiab/controller/sync/presentation/SyncStateViewModel.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public class SyncStateViewModel extends ViewModel {
4545
// credentials/destination are kept here so the re-bound fragment can start the transfer.
4646
private SyncHandshakeHelper.SyncCredentials pendingCreds;
4747
private File pendingDestDir;
48+
private long pendingBytes; // ADFA-5160: dry-run bytes-to-transfer, the transfer progress denominator
4849
private Context appContext; // application context, for releasing the network binding
4950

5051
/** The single transport instance for this Activity; created lazily, reused across recreations. */
@@ -66,6 +67,10 @@ public TransportEngine getTransport() {
6667

6768
public File getPendingDestDir() { return pendingDestDir; }
6869

70+
/** ADFA-5160: the dry-run's bytes-to-transfer (0 = not yet calculated), used as the transfer
71+
* progress denominator so the bar climbs a fixed total instead of rsync's growing estimate. */
72+
public long getPendingBytes() { return pendingBytes; }
73+
6974
/**
7075
* Reachability probe + rsync dry-run, off the fragment. Publishes CONNECTING -> CALCULATING ->
7176
* CONFIRM (ready, with size) or ABORTED (unreachable / not enough space / dry-run error). The
@@ -74,6 +79,7 @@ public TransportEngine getTransport() {
7479
public void startProbe(Context appCtx, ShareConfig shareConfig, SyncHandshakeHelper.SyncCredentials creds) {
7580
this.pendingCreds = creds;
7681
this.pendingDestDir = null;
82+
this.pendingBytes = 0L;
7783
this.appContext = appCtx.getApplicationContext();
7884
final SyncProgressRepository repo = SyncProgressRepository.get();
7985
repo.postConnecting();
@@ -110,6 +116,7 @@ public void startProbe(Context appCtx, ShareConfig shareConfig, SyncHandshakeHel
110116
new TransportEngine.DryRunListener() {
111117
@Override
112118
public void onCalculated(long bytesToTransfer) {
119+
pendingBytes = bytesToTransfer; // ADFA-5160: denominator for the transfer bar
113120
double gigabytes = bytesToTransfer / (1024.0 * 1024.0 * 1024.0);
114121
// ADFA-5105: one margin (StorageGuard) on the real write target (StorageProbe),
115122
// not a second -5 GB copy. Clone-receive overwrites the library, so refuse on

‎controller/app/src/main/java/org/iiab/controller/sync/transport/TransportEngine.java‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,16 @@ interface DryRunListener {
4545
/** Starts the read-only sharing server. Returns false if it could not start. */
4646
boolean startServer(Context context, ShareConfig config, String password, String shareDir);
4747

48-
/** Pulls from a peer (host/port/user/password come from the scanned handshake). */
48+
/**
49+
* Pulls from a peer (host/port/user/password come from the scanned handshake).
50+
* {@code expectedTotalBytes} is the dry-run's bytes-to-transfer — what rsync computed for
51+
* this transfer up front — so the reported percent climbs a fixed denominator instead of
52+
* rsync's own estimate, which grows as it discovers files (ADFA-5160). Pass 0 when no
53+
* dry-run figure is available to fall back to rsync's raw percent.
54+
*/
4955
void startClient(Context context, ShareConfig config, String hostIp, int port,
50-
String user, String password, String destDir, SyncListener listener);
56+
String user, String password, String destDir, long expectedTotalBytes,
57+
SyncListener listener);
5158

5259
/** Estimates the bytes a pull would transfer, without writing anything. */
5360
void calculateTransferPlan(Context context, ShareConfig config, String hostIp, int port,

‎controller/app/src/test/java/org/iiab/controller/sync/domain/RsyncProgressTest.java‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,21 @@ public class RsyncProgressTest {
1616
public void parsesProgressLine() {
1717
RsyncProgress p = RsyncProgress.parse(" 32,768 45% 12.34MB/s 0:00:12");
1818
assertNotNull(p);
19+
assertEquals(32768L, p.bytes);
1920
assertEquals(45, p.percent);
2021
assertEquals("12.34MB/s", p.speed);
2122
assertEquals("0:00:12", p.eta);
2223
}
2324

25+
// ADFA-5160: the leading byte column is the numerator the caller anchors to a known total.
26+
@Test
27+
public void parsesLeadingTransferredBytesWithSeparators() {
28+
RsyncProgress p = RsyncProgress.parse("1,234,567,890 88% 40.00MB/s 0:00:03");
29+
assertNotNull(p);
30+
assertEquals(1234567890L, p.bytes);
31+
assertEquals(88, p.percent);
32+
}
33+
2434
@Test
2535
public void returnsNullWhenNoProgressToken() {
2636
assertNull(RsyncProgress.parse("sending incremental file list"));
@@ -40,4 +50,23 @@ public void returnsFallbackWhenStatsLineAbsent() {
4050
assertEquals(99L, RsyncProgress.parseTransferredBytes("some other line", 99L));
4151
assertEquals(0L, RsyncProgress.parseTransferredBytes(null, 0L));
4252
}
53+
54+
// ADFA-5160: speed -> bytes/sec, for deriving a whole-set ETA.
55+
@Test
56+
public void parsesSpeedToBytesPerSecond() {
57+
assertEquals(1024.0 * 1024.0, RsyncProgress.parseSpeedBytesPerSec("1.00MB/s"), 0.001);
58+
assertEquals(1024.0, RsyncProgress.parseSpeedBytesPerSec("1.00kB/s"), 0.001);
59+
assertEquals(512.0, RsyncProgress.parseSpeedBytesPerSec("512.00B/s"), 0.001);
60+
assertEquals(-1.0, RsyncProgress.parseSpeedBytesPerSec("n/a"), 0.001);
61+
assertEquals(-1.0, RsyncProgress.parseSpeedBytesPerSec(null), 0.001);
62+
}
63+
64+
// ADFA-5160: whole-set ETA formatting, rsync's H:MM:SS.
65+
@Test
66+
public void formatsEtaAsHoursMinutesSeconds() {
67+
assertEquals("0:00:12", RsyncProgress.formatEta(12));
68+
assertEquals("0:01:15", RsyncProgress.formatEta(75));
69+
assertEquals("1:02:05", RsyncProgress.formatEta(3725));
70+
assertEquals("0:00:00", RsyncProgress.formatEta(-5));
71+
}
4372
}

0 commit comments

Comments
 (0)