diff --git a/controller/app/src/main/java/org/iiab/controller/RsyncManager.java b/controller/app/src/main/java/org/iiab/controller/RsyncManager.java index efa7c01aa..9a78e6153 100644 --- a/controller/app/src/main/java/org/iiab/controller/RsyncManager.java +++ b/controller/app/src/main/java/org/iiab/controller/RsyncManager.java @@ -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()); @@ -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) { @@ -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" // diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java index 29265865a..acd370cfd 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/CloneFragment.java @@ -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) { diff --git a/controller/app/src/main/java/org/iiab/controller/sync/domain/RsyncProgress.java b/controller/app/src/main/java/org/iiab/controller/sync/domain/RsyncProgress.java index e6d6bc961..43aac8f81 100644 --- a/controller/app/src/main/java/org/iiab/controller/sync/domain/RsyncProgress.java +++ b/controller/app/src/main/java/org/iiab/controller/sync/domain/RsyncProgress.java @@ -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. @@ -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; } @@ -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); + } } diff --git a/controller/app/src/main/java/org/iiab/controller/sync/presentation/ReceiveController.java b/controller/app/src/main/java/org/iiab/controller/sync/presentation/ReceiveController.java index 01332b721..3264c0518 100644 --- a/controller/app/src/main/java/org/iiab/controller/sync/presentation/ReceiveController.java +++ b/controller/app/src/main/java/org/iiab/controller/sync/presentation/ReceiveController.java @@ -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); diff --git a/controller/app/src/main/java/org/iiab/controller/sync/presentation/SyncStateViewModel.java b/controller/app/src/main/java/org/iiab/controller/sync/presentation/SyncStateViewModel.java index b8a64a2fe..48c4855bf 100644 --- a/controller/app/src/main/java/org/iiab/controller/sync/presentation/SyncStateViewModel.java +++ b/controller/app/src/main/java/org/iiab/controller/sync/presentation/SyncStateViewModel.java @@ -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. */ @@ -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 @@ -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(); @@ -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 diff --git a/controller/app/src/main/java/org/iiab/controller/sync/transport/TransportEngine.java b/controller/app/src/main/java/org/iiab/controller/sync/transport/TransportEngine.java index f6753bc2e..71c2a6add 100644 --- a/controller/app/src/main/java/org/iiab/controller/sync/transport/TransportEngine.java +++ b/controller/app/src/main/java/org/iiab/controller/sync/transport/TransportEngine.java @@ -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, diff --git a/controller/app/src/test/java/org/iiab/controller/sync/domain/RsyncProgressTest.java b/controller/app/src/test/java/org/iiab/controller/sync/domain/RsyncProgressTest.java index ebecb1f1d..11f9fe551 100644 --- a/controller/app/src/test/java/org/iiab/controller/sync/domain/RsyncProgressTest.java +++ b/controller/app/src/test/java/org/iiab/controller/sync/domain/RsyncProgressTest.java @@ -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")); @@ -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)); + } }