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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions controller/app/src/main/java/org/iiab/controller/RsyncManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public boolean startServer(Context context, ShareConfig config, String pass, Str
}

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

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

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

RsyncProgress progress = RsyncProgress.parse(line);
if (progress != null) {
// ADFA-5160: rsync's own percent divides by an estimate that keeps growing as
// it discovers files, so it lurches. Anchor to the dry-run bytes-to-transfer
// (what rsync computed for this transfer up front) and let the transferred-byte
// count climb it. Hold at 99% until rsync's success lands; never go backwards.
int pct;
if (expectedTotalBytes > 0) {
pct = (int) Math.min(99L, 100L * progress.bytes / expectedTotalBytes);
} else {
pct = Math.min(99, progress.percent);
}
if (pct < lastEmittedPct) pct = lastEmittedPct;
lastEmittedPct = pct;

// ADFA-5160: rsync's ETA is computed against its per-file plan, so it jumps
// the same way the old percent did. When we have a whole-set total, derive
// the ETA from the bytes still to go and the current speed instead.
String eta = progress.eta;
if (expectedTotalBytes > 0) {
double bps = RsyncProgress.parseSpeedBytesPerSec(progress.speed);
if (bps > 0) {
long remaining = Math.max(0L, expectedTotalBytes - progress.bytes);
eta = RsyncProgress.formatEta((long) (remaining / bps));
}
}

String finalFile = lastFile;
mainHandler.post(() -> listener.onProgress(progress.percent, progress.speed, progress.eta, finalFile));
int finalPct = pct;
String finalEta = eta;
mainHandler.post(() -> listener.onProgress(finalPct, progress.speed, finalEta, finalFile));
}
// PHASE 1 FIX: Strict match for actual rsync errors, ignoring files named "error"
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1101,7 +1101,12 @@ private void startReceiveTransfer() {
// orders are not discarded until it actually completes.
org.iiab.controller.system.data.ContentStateInvalidator.replacementStarting(app,
org.iiab.controller.system.domain.SystemReplacement.Cause.CLONE_RECEIVE);
transport.startClient(app, shareConfig, fcreds.ip, fcreds.port, fcreds.user, fcreds.pass, destPath,
// ADFA-5160: anchor the progress bar to the dry-run's bytes-to-transfer (startProbe
// ran it before this point), i.e. what rsync computed for THIS transfer. Not the QR
// estimate: it reflects the sender's initial install and can be stale. 0 falls back
// to rsync's own percent.
long expectedTotal = syncVm.getPendingBytes();
transport.startClient(app, shareConfig, fcreds.ip, fcreds.port, fcreds.user, fcreds.pass, destPath, expectedTotal,
new TransportEngine.SyncListener() {
@Override public void onProgress(int pct, String speed, String eta, String file) { SyncProgressRepository.get().postTransferring(pct, speed, eta, file); }
@Override public void onComplete(String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,35 @@

public final class RsyncProgress {

/** Bytes transferred so far this run — the leading column of a progress2 line. */
public final long bytes;
public final int percent;
public final String speed;
public final String eta;

private RsyncProgress(int percent, String speed, String eta) {
private RsyncProgress(long bytes, int percent, String speed, String eta) {
this.bytes = bytes;
this.percent = percent;
this.speed = speed;
this.eta = eta;
}

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

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

// ADFA-5160: rsync's speed column, e.g. "12.34MB/s" / "512.00B/s". Used to turn the
// whole-set remaining bytes into a whole-set ETA, instead of rsync's own ETA, which is
// computed against its per-file plan and jumps the same way the old percent did.
private static final Pattern SPEED =
Pattern.compile("([\\d\\.]+)([kMGT]?B)/s");

/**
* Parses one rsync {@code --info=progress2} line. Returns {@code null} if the
* line carries no progress token or the percentage is not a number.
Expand All @@ -43,7 +56,8 @@ public static RsyncProgress parse(String line) {
Matcher m = PROGRESS.matcher(line);
if (!m.find()) return null;
try {
return new RsyncProgress(Integer.parseInt(m.group(1)), m.group(2), m.group(3));
long bytes = Long.parseLong(m.group(1).replaceAll("[,\\.]", ""));
return new RsyncProgress(bytes, Integer.parseInt(m.group(2)), m.group(3), m.group(4));
} catch (NumberFormatException e) {
return null;
}
Expand All @@ -64,4 +78,38 @@ public static long parseTransferredBytes(String line, long fallback) {
return fallback;
}
}

/**
* Parses a rsync speed column ("12.34MB/s") into bytes per second (1024-based units, as
* rsync prints them). Returns {@code -1} when the string does not match. ADFA-5160.
*/
public static double parseSpeedBytesPerSec(String speed) {
if (speed == null) return -1d;
Matcher m = SPEED.matcher(speed);
if (!m.find()) return -1d;
try {
double n = Double.parseDouble(m.group(1));
double mult;
switch (m.group(2)) {
case "B": mult = 1d; break;
case "kB": mult = 1024d; break;
case "MB": mult = 1024d * 1024d; break;
case "GB": mult = 1024d * 1024d * 1024d; break;
case "TB": mult = 1024d * 1024d * 1024d * 1024d; break;
default: return -1d;
}
return n * mult;
} catch (NumberFormatException e) {
return -1d;
}
}

/** Formats a duration in seconds as rsync's {@code H:MM:SS}. Negatives clamp to zero. */
public static String formatEta(long seconds) {
if (seconds < 0) seconds = 0;
long h = seconds / 3600;
long m = (seconds % 3600) / 60;
long s = seconds % 60;
return h + String.format(java.util.Locale.US, ":%02d:%02d", m, s);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,13 @@ private void startTransfer(SyncHandshakeHelper.SyncCredentials creds, File destD
// the application context (it lives in the Activity-scoped ViewModel).
SyncProgressRepository.get().postTransferring(0, "", "", "RootFS");

syncVm.getTransport().startClient(fragment.requireContext().getApplicationContext(), shareConfig, creds.ip, creds.port, creds.user, creds.pass, destDir.getAbsolutePath(), new TransportEngine.SyncListener() {
// ADFA-5160: measure the bar against the dry-run's bytes-to-transfer — the amount
// rsync itself computed for THIS transfer (resume-aware). Not the QR size estimate:
// that reflects the sender's initial install and can be stale. 0 (no dry-run) makes
// the transport fall back to rsync's own percent rather than a value we can't trust.
long expectedTotal = syncVm.getPendingBytes();

syncVm.getTransport().startClient(fragment.requireContext().getApplicationContext(), shareConfig, creds.ip, creds.port, creds.user, creds.pass, destDir.getAbsolutePath(), expectedTotal, new TransportEngine.SyncListener() {
@Override
public void onProgress(int percentage, String speed, String eta, String currentFile) {
SyncProgressRepository.get().postTransferring(percentage, speed, eta, currentFile);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public class SyncStateViewModel extends ViewModel {
// credentials/destination are kept here so the re-bound fragment can start the transfer.
private SyncHandshakeHelper.SyncCredentials pendingCreds;
private File pendingDestDir;
private long pendingBytes; // ADFA-5160: dry-run bytes-to-transfer, the transfer progress denominator
private Context appContext; // application context, for releasing the network binding

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

public File getPendingDestDir() { return pendingDestDir; }

/** ADFA-5160: the dry-run's bytes-to-transfer (0 = not yet calculated), used as the transfer
* progress denominator so the bar climbs a fixed total instead of rsync's growing estimate. */
public long getPendingBytes() { return pendingBytes; }

/**
* Reachability probe + rsync dry-run, off the fragment. Publishes CONNECTING -> CALCULATING ->
* CONFIRM (ready, with size) or ABORTED (unreachable / not enough space / dry-run error). The
Expand All @@ -74,6 +79,7 @@ public TransportEngine getTransport() {
public void startProbe(Context appCtx, ShareConfig shareConfig, SyncHandshakeHelper.SyncCredentials creds) {
this.pendingCreds = creds;
this.pendingDestDir = null;
this.pendingBytes = 0L;
this.appContext = appCtx.getApplicationContext();
final SyncProgressRepository repo = SyncProgressRepository.get();
repo.postConnecting();
Expand Down Expand Up @@ -110,6 +116,7 @@ public void startProbe(Context appCtx, ShareConfig shareConfig, SyncHandshakeHel
new TransportEngine.DryRunListener() {
@Override
public void onCalculated(long bytesToTransfer) {
pendingBytes = bytesToTransfer; // ADFA-5160: denominator for the transfer bar
double gigabytes = bytesToTransfer / (1024.0 * 1024.0 * 1024.0);
// ADFA-5105: one margin (StorageGuard) on the real write target (StorageProbe),
// not a second -5 GB copy. Clone-receive overwrites the library, so refuse on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,16 @@ interface DryRunListener {
/** Starts the read-only sharing server. Returns false if it could not start. */
boolean startServer(Context context, ShareConfig config, String password, String shareDir);

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

/** Estimates the bytes a pull would transfer, without writing anything. */
void calculateTransferPlan(Context context, ShareConfig config, String hostIp, int port,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,21 @@ public class RsyncProgressTest {
public void parsesProgressLine() {
RsyncProgress p = RsyncProgress.parse(" 32,768 45% 12.34MB/s 0:00:12");
assertNotNull(p);
assertEquals(32768L, p.bytes);
assertEquals(45, p.percent);
assertEquals("12.34MB/s", p.speed);
assertEquals("0:00:12", p.eta);
}

// ADFA-5160: the leading byte column is the numerator the caller anchors to a known total.
@Test
public void parsesLeadingTransferredBytesWithSeparators() {
RsyncProgress p = RsyncProgress.parse("1,234,567,890 88% 40.00MB/s 0:00:03");
assertNotNull(p);
assertEquals(1234567890L, p.bytes);
assertEquals(88, p.percent);
}

@Test
public void returnsNullWhenNoProgressToken() {
assertNull(RsyncProgress.parse("sending incremental file list"));
Expand All @@ -40,4 +50,23 @@ public void returnsFallbackWhenStatsLineAbsent() {
assertEquals(99L, RsyncProgress.parseTransferredBytes("some other line", 99L));
assertEquals(0L, RsyncProgress.parseTransferredBytes(null, 0L));
}

// ADFA-5160: speed -> bytes/sec, for deriving a whole-set ETA.
@Test
public void parsesSpeedToBytesPerSecond() {
assertEquals(1024.0 * 1024.0, RsyncProgress.parseSpeedBytesPerSec("1.00MB/s"), 0.001);
assertEquals(1024.0, RsyncProgress.parseSpeedBytesPerSec("1.00kB/s"), 0.001);
assertEquals(512.0, RsyncProgress.parseSpeedBytesPerSec("512.00B/s"), 0.001);
assertEquals(-1.0, RsyncProgress.parseSpeedBytesPerSec("n/a"), 0.001);
assertEquals(-1.0, RsyncProgress.parseSpeedBytesPerSec(null), 0.001);
}

// ADFA-5160: whole-set ETA formatting, rsync's H:MM:SS.
@Test
public void formatsEtaAsHoursMinutesSeconds() {
assertEquals("0:00:12", RsyncProgress.formatEta(12));
assertEquals("0:01:15", RsyncProgress.formatEta(75));
assertEquals("1:02:05", RsyncProgress.formatEta(3725));
assertEquals("0:00:00", RsyncProgress.formatEta(-5));
}
}
Loading