Refactored progress bar to show fractional percentages and improved ETA smoothing

Progress values and callbacks (labels, progressbar) now use float instead of int to reflect fractional percentages for a better / more responsive user experience. Improved ETA display for APK install, OBB copy and ZIP extraction operations by introducing a reusable EtaEstimator class for smoother and more accurate ETA calculations
This commit is contained in:
jp64k
2025-12-17 02:20:40 +01:00
parent 2f843bc458
commit c5b151471a
4 changed files with 252 additions and 205 deletions

266
ADB.cs
View File

@@ -154,7 +154,7 @@ namespace AndroidSideloader
// Copies and installs an APK with real-time progress reporting using AdvancedSharpAdbClient // Copies and installs an APK with real-time progress reporting using AdvancedSharpAdbClient
public static async Task<ProcessOutput> SideloadWithProgressAsync( public static async Task<ProcessOutput> SideloadWithProgressAsync(
string path, string path,
Action<int, TimeSpan?> progressCallback = null, // Now includes ETA Action<float, TimeSpan?> progressCallback = null,
Action<string> statusCallback = null, Action<string> statusCallback = null,
string packagename = "", string packagename = "",
string gameName = "") string gameName = "")
@@ -177,112 +177,79 @@ namespace AndroidSideloader
// Throttle UI updates to prevent lag // Throttle UI updates to prevent lag
DateTime lastProgressUpdate = DateTime.MinValue; DateTime lastProgressUpdate = DateTime.MinValue;
int lastReportedPercent = -1; float lastReportedPercent = -1;
const int ThrottleMs = 100; // Update UI at most every 100ms const int ThrottleMs = 100; // Update UI every 100ms
// ETA tracking with smoothing // Shared ETA engine (percent-units)
DateTime installStart = DateTime.UtcNow; var eta = new EtaEstimator(alpha: 0.05, reanchorThreshold: 0.20);
int etaLastPercent = 0;
DateTime etaLastPercentTime = DateTime.UtcNow;
double smoothedSecondsPerPercent = 0;
TimeSpan? lastReportedEta = null;
const double SmoothingAlpha = 0.15; // Lower = smoother, less responsive
const double EtaChangeThreshold = 0.10; // Only update if ETA changes by >10%
// Create install progress handler // Create install progress handler
Action<InstallProgressEventArgs> installProgress = (args) => Action<InstallProgressEventArgs> installProgress = (args) =>
{ {
int percent = 0; float percent = 0;
string status = null; string status = null;
TimeSpan? eta = null; TimeSpan? displayEta = null;
switch (args.State) switch (args.State)
{ {
case PackageInstallProgressState.Preparing: case PackageInstallProgressState.Preparing:
percent = 0; percent = 0;
status = "Preparing..."; status = "Preparing...";
installStart = DateTime.UtcNow; eta.Reset();
etaLastPercent = 0;
etaLastPercentTime = installStart;
smoothedSecondsPerPercent = 0;
lastReportedEta = null;
break; break;
case PackageInstallProgressState.Uploading: case PackageInstallProgressState.Uploading:
percent = (int)Math.Round(args.UploadProgress); percent = (float)args.UploadProgress;
// Calculate ETA with smoothing // Update ETA engine using percent as units (0..100)
if (percent > etaLastPercent && percent < 100) if (percent > 0 && percent < 100)
{ {
var now = DateTime.UtcNow; eta.Update(totalUnits: 100, doneUnits: (long)Math.Round(percent));
double secondsForThisChunk = (now - etaLastPercentTime).TotalSeconds; displayEta = eta.GetDisplayEta();
int percentGained = percent - etaLastPercent;
if (percentGained > 0 && secondsForThisChunk > 0)
{
double secondsPerPercent = secondsForThisChunk / percentGained;
// Exponential smoothing
if (smoothedSecondsPerPercent == 0)
smoothedSecondsPerPercent = secondsPerPercent;
else
smoothedSecondsPerPercent = SmoothingAlpha * secondsPerPercent + (1 - SmoothingAlpha) * smoothedSecondsPerPercent;
int remainingPercent = 100 - percent;
double etaSeconds = remainingPercent * smoothedSecondsPerPercent;
var newEta = TimeSpan.FromSeconds(Math.Max(0, etaSeconds));
// Only update if significant change
if (!lastReportedEta.HasValue ||
Math.Abs(newEta.TotalSeconds - lastReportedEta.Value.TotalSeconds) / Math.Max(1, lastReportedEta.Value.TotalSeconds) > EtaChangeThreshold)
{
eta = newEta;
lastReportedEta = eta;
}
else
{
eta = lastReportedEta; // Keep previous ETA
}
etaLastPercent = percent;
etaLastPercentTime = now;
}
} }
else else
{ {
eta = lastReportedEta; displayEta = eta.GetDisplayEta();
} }
status = $"Installing · {percent}%"; status = $"Installing · {percent:0.0}%";
break; break;
case PackageInstallProgressState.Installing: case PackageInstallProgressState.Installing:
percent = 100; percent = 100;
status = "Completing Installation..."; status = "Completing Installation...";
displayEta = null;
break; break;
case PackageInstallProgressState.Finished: case PackageInstallProgressState.Finished:
percent = 100; percent = 100;
status = ""; status = "";
displayEta = null;
break; break;
default: default:
percent = 50; percent = 100;
status = "";
displayEta = null;
break; break;
} }
// Throttle updates
var updateNow = DateTime.UtcNow; var updateNow = DateTime.UtcNow;
bool shouldUpdate = (updateNow - lastProgressUpdate).TotalMilliseconds >= ThrottleMs bool shouldUpdate = (updateNow - lastProgressUpdate).TotalMilliseconds >= ThrottleMs
|| percent != lastReportedPercent || Math.Abs(percent - lastReportedPercent) >= 0.1f
|| args.State != PackageInstallProgressState.Uploading; || args.State != PackageInstallProgressState.Uploading;
if (shouldUpdate) if (shouldUpdate)
{ {
lastProgressUpdate = updateNow; lastProgressUpdate = updateNow;
lastReportedPercent = percent; lastReportedPercent = percent;
progressCallback?.Invoke(percent, eta);
// ETA goes back via progress callback (label); status remains percent-only string for inner bar
progressCallback?.Invoke(percent, displayEta);
if (status != null) statusCallback?.Invoke(status); if (status != null) statusCallback?.Invoke(status);
} }
}; };
// Install the package with progress
await Task.Run(() => await Task.Run(() =>
{ {
packageManager.InstallPackage(path, installProgress); packageManager.InstallPackage(path, installProgress);
@@ -297,7 +264,6 @@ namespace AndroidSideloader
{ {
Logger.Log($"SideloadWithProgressAsync error: {ex.Message}", LogLevel.ERROR); Logger.Log($"SideloadWithProgressAsync error: {ex.Message}", LogLevel.ERROR);
// Check for signature mismatch errors
if (ex.Message.Contains("INSTALL_FAILED") || if (ex.Message.Contains("INSTALL_FAILED") ||
ex.Message.Contains("signatures do not match")) ex.Message.Contains("signatures do not match"))
{ {
@@ -318,7 +284,6 @@ namespace AndroidSideloader
if (cancelClicked) if (cancelClicked)
return new ProcessOutput("", "Installation cancelled by user"); return new ProcessOutput("", "Installation cancelled by user");
// Perform reinstall
statusCallback?.Invoke("Performing reinstall..."); statusCallback?.Invoke("Performing reinstall...");
try try
@@ -338,7 +303,7 @@ namespace AndroidSideloader
{ {
if (args.State == PackageInstallProgressState.Uploading) if (args.State == PackageInstallProgressState.Uploading)
{ {
progressCallback?.Invoke((int)Math.Round(args.UploadProgress), null); progressCallback?.Invoke((float)args.UploadProgress, null);
} }
}; };
packageManager.InstallPackage(path, reinstallProgress); packageManager.InstallPackage(path, reinstallProgress);
@@ -368,7 +333,7 @@ namespace AndroidSideloader
// Copies OBB folder with real-time progress reporting using AdvancedSharpAdbClient // Copies OBB folder with real-time progress reporting using AdvancedSharpAdbClient
public static async Task<ProcessOutput> CopyOBBWithProgressAsync( public static async Task<ProcessOutput> CopyOBBWithProgressAsync(
string localPath, string localPath,
Action<int, TimeSpan?> progressCallback = null, // Now includes ETA Action<float, TimeSpan?> progressCallback = null,
Action<string> statusCallback = null, Action<string> statusCallback = null,
string gameName = "") string gameName = "")
{ {
@@ -404,18 +369,11 @@ namespace AndroidSideloader
// Throttle UI updates to prevent lag // Throttle UI updates to prevent lag
DateTime lastProgressUpdate = DateTime.MinValue; DateTime lastProgressUpdate = DateTime.MinValue;
int lastReportedPercent = -1; float lastReportedPercent = -1;
const int ThrottleMs = 100; // Update UI at most every 100ms const int ThrottleMs = 100; // Update UI every 100ms
// ETA tracking with smoothing // Shared ETA engine (bytes-units)
DateTime copyStart = DateTime.UtcNow; var eta = new EtaEstimator(alpha: 0.10, reanchorThreshold: 0.20);
int etaLastPercent = 0;
DateTime etaLastPercentTime = DateTime.UtcNow;
double smoothedSecondsPerPercent = 0;
TimeSpan? lastReportedEta = null;
TimeSpan? currentEta = null;
const double SmoothingAlpha = 0.15; // Lower = smoother, less responsive
const double EtaChangeThreshold = 0.10; // 10% change threshold
statusCallback?.Invoke($"Copying: {folderName}"); statusCallback?.Invoke($"Copying: {folderName}");
@@ -437,71 +395,37 @@ namespace AndroidSideloader
long fileSize = fileInfo.Length; long fileSize = fileInfo.Length;
long capturedTransferredBytes = transferredBytes; long capturedTransferredBytes = transferredBytes;
// Progress handler for this file with throttling
Action<SyncProgressChangedEventArgs> progressHandler = (args) => Action<SyncProgressChangedEventArgs> progressHandler = (args) =>
{ {
long totalProgressBytes = capturedTransferredBytes + args.ReceivedBytesSize; long totalProgressBytes = capturedTransferredBytes + args.ReceivedBytesSize;
double overallPercent = totalBytes > 0 float overallPercent = totalBytes > 0
? (totalProgressBytes * 100.0) / totalBytes ? (float)(totalProgressBytes * 100.0 / totalBytes)
: 0.0; : 0f;
int overallPercentInt = (int)Math.Round(overallPercent); overallPercent = Math.Max(0, Math.Min(100, overallPercent));
overallPercentInt = Math.Max(0, Math.Min(100, overallPercentInt));
// Calculate ETA with smoothing // Update ETA engine in bytes
if (overallPercentInt > etaLastPercent && overallPercentInt < 100) if (totalBytes > 0 && totalProgressBytes > 0 && overallPercent < 100)
{ {
var now = DateTime.UtcNow; eta.Update(totalUnits: totalBytes, doneUnits: totalProgressBytes);
double secondsForThisChunk = (now - etaLastPercentTime).TotalSeconds;
int percentGained = overallPercentInt - etaLastPercent;
if (percentGained > 0 && secondsForThisChunk > 0)
{
double secondsPerPercent = secondsForThisChunk / percentGained;
// Exponential smoothing
if (smoothedSecondsPerPercent == 0)
smoothedSecondsPerPercent = secondsPerPercent;
else
smoothedSecondsPerPercent = SmoothingAlpha * secondsPerPercent + (1 - SmoothingAlpha) * smoothedSecondsPerPercent;
int remainingPercent = 100 - overallPercentInt;
double etaSeconds = remainingPercent * smoothedSecondsPerPercent;
var newEta = TimeSpan.FromSeconds(Math.Max(0, etaSeconds));
// Only update if significant change
if (!lastReportedEta.HasValue ||
Math.Abs(newEta.TotalSeconds - lastReportedEta.Value.TotalSeconds) / Math.Max(1, lastReportedEta.Value.TotalSeconds) > EtaChangeThreshold)
{
currentEta = newEta;
lastReportedEta = currentEta;
}
else
{
currentEta = lastReportedEta;
}
etaLastPercent = overallPercentInt;
etaLastPercentTime = now;
}
} }
// Throttle updates TimeSpan? displayEta = eta.GetDisplayEta();
var now2 = DateTime.UtcNow; var now2 = DateTime.UtcNow;
bool shouldUpdate = (now2 - lastProgressUpdate).TotalMilliseconds >= ThrottleMs bool shouldUpdate = (now2 - lastProgressUpdate).TotalMilliseconds >= ThrottleMs
|| overallPercentInt != lastReportedPercent; || Math.Abs(overallPercent - lastReportedPercent) >= 0.1f;
if (shouldUpdate) if (shouldUpdate)
{ {
lastProgressUpdate = now2; lastProgressUpdate = now2;
lastReportedPercent = overallPercentInt; lastReportedPercent = overallPercent;
progressCallback?.Invoke(overallPercentInt, currentEta); progressCallback?.Invoke(overallPercent, displayEta);
statusCallback?.Invoke(fileName); statusCallback?.Invoke(fileName);
} }
}; };
// Push the file with progress
using (var stream = File.OpenRead(file)) using (var stream = File.OpenRead(file))
{ {
await Task.Run(() => await Task.Run(() =>
@@ -786,4 +710,102 @@ namespace AndroidSideloader
: new ProcessOutput("No OBB Folder found"); : new ProcessOutput("No OBB Folder found");
} }
} }
internal class EtaEstimator
{
private readonly double _alpha; // EWMA smoothing
private readonly double _reanchorThreshold; // % difference required to re-anchor
private readonly double _minSampleSeconds; // ignore too-short dt
private DateTime _lastSampleTimeUtc;
private long _lastSampleDoneUnits;
private double _smoothedUnitsPerSecond;
private TimeSpan? _etaAnchorValue;
private DateTime _etaAnchorTimeUtc;
public EtaEstimator(double alpha, double reanchorThreshold, double minSampleSeconds = 0.15)
{
_alpha = alpha;
_reanchorThreshold = reanchorThreshold;
_minSampleSeconds = minSampleSeconds;
Reset();
}
public void Reset()
{
_lastSampleTimeUtc = DateTime.UtcNow;
_lastSampleDoneUnits = 0;
_smoothedUnitsPerSecond = 0;
_etaAnchorValue = null;
_etaAnchorTimeUtc = DateTime.UtcNow;
}
// Updates internal rate estimate and re-anchors ETA
// totalUnits: total work units (e.g., 100 for percent, or totalBytes for bytes)
// doneUnits: completed work units so far (e.g., percent, or bytes transferred)
public void Update(long totalUnits, long doneUnits)
{
var now = DateTime.UtcNow;
if (totalUnits <= 0) return;
doneUnits = Math.Max(0, Math.Min(totalUnits, doneUnits));
long remainingUnits = Math.Max(0, totalUnits - doneUnits);
double dt = (now - _lastSampleTimeUtc).TotalSeconds;
long dUnits = doneUnits - _lastSampleDoneUnits;
if (dt >= _minSampleSeconds && dUnits > 0)
{
double instUnitsPerSecond = dUnits / dt;
if (_smoothedUnitsPerSecond <= 0)
_smoothedUnitsPerSecond = instUnitsPerSecond;
else
_smoothedUnitsPerSecond = _alpha * instUnitsPerSecond + (1 - _alpha) * _smoothedUnitsPerSecond;
_lastSampleTimeUtc = now;
_lastSampleDoneUnits = doneUnits;
}
if (_smoothedUnitsPerSecond > 1e-6 && remainingUnits > 0)
{
var newEta = TimeSpan.FromSeconds(remainingUnits / _smoothedUnitsPerSecond);
if (newEta < TimeSpan.Zero) newEta = TimeSpan.Zero;
if (!_etaAnchorValue.HasValue)
{
_etaAnchorValue = newEta;
_etaAnchorTimeUtc = now;
}
else
{
// What countdown would currently show
var predictedNow = _etaAnchorValue.Value - (now - _etaAnchorTimeUtc);
if (predictedNow < TimeSpan.Zero) predictedNow = TimeSpan.Zero;
double baseSeconds = Math.Max(1, predictedNow.TotalSeconds);
double diffRatio = Math.Abs(newEta.TotalSeconds - predictedNow.TotalSeconds) / baseSeconds;
if (diffRatio > _reanchorThreshold)
{
_etaAnchorValue = newEta;
_etaAnchorTimeUtc = now;
}
}
}
}
// Returns a countdown ETA for UI display
public TimeSpan? GetDisplayEta()
{
if (!_etaAnchorValue.HasValue) return null;
var remaining = _etaAnchorValue.Value - (DateTime.UtcNow - _etaAnchorTimeUtc);
if (remaining < TimeSpan.Zero) remaining = TimeSpan.Zero;
return TimeSpan.FromSeconds(Math.Ceiling(remaining.TotalSeconds));
}
}
} }

View File

@@ -3052,7 +3052,7 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
gamesQueueList.RemoveAt(0); gamesQueueList.RemoveAt(0);
} }
public void SetProgress(int progress) public void SetProgress(float progress)
{ {
if (progressBar.InvokeRequired) if (progressBar.InvokeRequired)
{ {
@@ -3264,7 +3264,7 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
speedLabel.Text = "Starting download..."; speedLabel.Text = "Starting download...";
// Track the highest valid progress to prevent brief progress bar flashes during multi-file transfers // Track the highest valid progress to prevent brief progress bar flashes during multi-file transfers
int highestValidPercent = 0; float highestValidPercent = 0;
// Download // Download
while (t1.IsAlive) while (t1.IsAlive)
@@ -3311,10 +3311,10 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
progressBar.IsIndeterminate = false; progressBar.IsIndeterminate = false;
int percent = 0; float percent = 0;
if (totalSize > 0) if (totalSize > 0)
{ {
percent = Convert.ToInt32((downloadedSize / totalSize) * 100); percent = (float)(downloadedSize / totalSize * 100);
} }
// Clamp to 0-99 while download is in progress to prevent brief 100% flashes // Clamp to 0-99 while download is in progress to prevent brief 100% flashes
@@ -3339,7 +3339,7 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
"Downloading", "Downloading",
(int)transfersComplete + 1, (int)transfersComplete + 1,
(int)fileCount, (int)fileCount,
percent, (int)Math.Round(percent),
time, time,
downloadSpeed); downloadSpeed);
} }
@@ -3441,9 +3441,9 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
this.Invoke(() => this.Invoke(() =>
{ {
progressBar.Value = percent; progressBar.Value = percent;
UpdateProgressStatus("Extracting", percent: percent, eta: eta); UpdateProgressStatus("Extracting", percent: (int)Math.Round(percent), eta: eta);
progressBar.StatusText = $"Extracting · {percent}%"; progressBar.StatusText = $"Extracting · {percent:0.0}%";
}); });
}; };
@@ -3570,14 +3570,17 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
progressBar.IsIndeterminate = false; progressBar.IsIndeterminate = false;
progressBar.Value = progress; progressBar.Value = progress;
} }
UpdateProgressStatus("Installing APK", percent: progress, eta: eta); UpdateProgressStatus("Installing", percent: (int)Math.Round(progress), eta: eta);
progressBar.StatusText = $"Installing · {progress}%"; progressBar.StatusText = $"Installing · {progress:0.0}%";
}), }),
status => this.Invoke(() => { status => this.Invoke(() => {
if (!string.IsNullOrEmpty(status)) if (!string.IsNullOrEmpty(status))
{ {
// "Completing Installation..." if (status.Contains("Completing Installation"))
speedLabel.Text = status; {
// "Completing Installation..."
speedLabel.Text = status;
}
progressBar.StatusText = status; progressBar.StatusText = status;
} }
}), }),
@@ -3605,15 +3608,15 @@ If the problem persists, visit our Telegram (https://t.me/VRPirates) or Discord
(progress, eta) => this.Invoke(() => (progress, eta) => this.Invoke(() =>
{ {
progressBar.Value = progress; progressBar.Value = progress;
UpdateProgressStatus("Copying OBB", percent: progress, eta: eta); UpdateProgressStatus("Copying OBB", percent: (int)Math.Round(progress), eta: eta);
if (!string.IsNullOrEmpty(currentObbStatusBase)) if (!string.IsNullOrEmpty(currentObbStatusBase))
{ {
progressBar.StatusText = $"{currentObbStatusBase} · {progress}%"; progressBar.StatusText = $"{currentObbStatusBase} · {progress:0.0}%";
} }
else else
{ {
progressBar.StatusText = $"{progress}%"; progressBar.StatusText = $"{progress:0.0}%";
} }
}), }),
status => this.Invoke(() => status => this.Invoke(() =>
@@ -4743,6 +4746,8 @@ let player;
let pendingId = null; let pendingId = null;
function onYouTubeIframeAPIReady() { function onYouTubeIframeAPIReady() {
player = new YT.Player('player', { player = new YT.Player('player', {
width: '100%',
height: '100%',
playerVars: { playerVars: {
autoplay: 0, autoplay: 0,
mute: 1, mute: 1,

View File

@@ -13,9 +13,9 @@ namespace AndroidSideloader
{ {
#region Fields #region Fields
private int _value; private float _value;
private int _minimum; private float _minimum;
private int _maximum = 100; private float _maximum = 100f;
private int _radius = 8; private int _radius = 8;
private bool _isIndeterminate; private bool _isIndeterminate;
private string _statusText = string.Empty; private string _statusText = string.Empty;
@@ -66,7 +66,7 @@ namespace AndroidSideloader
[Category("Progress")] [Category("Progress")]
[Description("The current value of the progress bar.")] [Description("The current value of the progress bar.")]
public int Value public float Value
{ {
get => _value; get => _value;
set set
@@ -78,7 +78,7 @@ namespace AndroidSideloader
[Category("Progress")] [Category("Progress")]
[Description("The minimum value of the progress bar.")] [Description("The minimum value of the progress bar.")]
public int Minimum public float Minimum
{ {
get => _minimum; get => _minimum;
set set
@@ -91,7 +91,7 @@ namespace AndroidSideloader
[Category("Progress")] [Category("Progress")]
[Description("The maximum value of the progress bar.")] [Description("The maximum value of the progress bar.")]
public int Maximum public float Maximum
{ {
get => _maximum; get => _maximum;
set set
@@ -122,7 +122,7 @@ namespace AndroidSideloader
set set
{ {
// If there is no change, do nothing // If there is no change, do nothing
if (_isIndeterminate == value) if (_isIndeterminate == value)
return; return;
_isIndeterminate = value; _isIndeterminate = value;
@@ -205,7 +205,7 @@ namespace AndroidSideloader
// Gets the progress as a percentage (0-100) // Gets the progress as a percentage (0-100)
public float ProgressPercent => public float ProgressPercent =>
_maximum > _minimum ? (float)(_value - _minimum) / (_maximum - _minimum) * 100f : 0f; _maximum > _minimum ? (_value - _minimum) / (_maximum - _minimum) * 100f : 0f;
#endregion #endregion
@@ -250,7 +250,7 @@ namespace AndroidSideloader
private void DrawProgress(Graphics g, Rectangle outerRect) private void DrawProgress(Graphics g, Rectangle outerRect)
{ {
float percent = (_maximum > _minimum) float percent = (_maximum > _minimum)
? (float)(_value - _minimum) / (_maximum - _minimum) ? (_value - _minimum) / (_maximum - _minimum)
: 0f; : 0f;
if (percent <= 0f) return; if (percent <= 0f) return;
@@ -363,10 +363,11 @@ namespace AndroidSideloader
if (!_isIndeterminate && _value > _minimum) if (!_isIndeterminate && _value > _minimum)
{ {
string percentText = $"{(int)ProgressPercent}%"; // Show one decimal place for sub-percent precision
string percentText = $"{ProgressPercent:0.0}%";
if (!string.IsNullOrEmpty(_operationType)) if (!string.IsNullOrEmpty(_operationType))
{ {
// E.g. "Downloading · 73%" // E.g. "Downloading · 73.5%"
return $"{_operationType} · {percentText}"; return $"{_operationType} · {percentText}";
} }
return percentText; return percentText;
@@ -435,4 +436,4 @@ namespace AndroidSideloader
#endregion #endregion
} }
} }

View File

@@ -18,7 +18,7 @@ namespace AndroidSideloader.Utilities
private static readonly SettingsManager settings = SettingsManager.Instance; private static readonly SettingsManager settings = SettingsManager.Instance;
// Progress callback: (percent, eta) // Progress callback: (percent, eta)
public static Action<int, TimeSpan?> ExtractionProgressCallback { get; set; } public static Action<float, TimeSpan?> ExtractionProgressCallback { get; set; }
public static Action<string> ExtractionStatusCallback { get; set; } public static Action<string> ExtractionStatusCallback { get; set; }
public static void ExtractFile(string sourceArchive, string destination) public static void ExtractFile(string sourceArchive, string destination)
@@ -35,6 +35,7 @@ namespace AndroidSideloader.Utilities
private static string extractionError = null; private static string extractionError = null;
private static bool errorMessageShown = false; private static bool errorMessageShown = false;
private static void DoExtract(string args) private static void DoExtract(string args)
{ {
if (!File.Exists(Path.Combine(Environment.CurrentDirectory, "7z.exe")) || !File.Exists(Path.Combine(Environment.CurrentDirectory, "7z.dll"))) if (!File.Exists(Path.Combine(Environment.CurrentDirectory, "7z.exe")) || !File.Exists(Path.Combine(Environment.CurrentDirectory, "7z.dll")))
@@ -70,15 +71,20 @@ namespace AndroidSideloader.Utilities
_ = Logger.Log($"Extract: 7z {string.Join(" ", args.Split(' ').Where(a => !a.StartsWith("-p")))}"); _ = Logger.Log($"Extract: 7z {string.Join(" ", args.Split(' ').Where(a => !a.StartsWith("-p")))}");
// ETA tracking // Throttle percent reports
DateTime extractStart = DateTime.UtcNow; float lastReportedPercent = -1;
int etaLastPercent = 0;
DateTime etaLastPercentTime = DateTime.UtcNow; // ETA engine (percent units)
double smoothedSecondsPerPercent = 0; var etaEstimator = new EtaEstimator(alpha: 0.10, reanchorThreshold: 0.20, minSampleSeconds: 0.10);
TimeSpan? lastReportedEta = null;
int lastReportedPercent = -1; // Smooth progress (sub-percent) interpolation (because 7z -bsp1 is integer-only)
const double SmoothingAlpha = 0.15; System.Threading.Timer smoothTimer = null;
const double EtaChangeThreshold = 0.10; int extractingFlag = 1; // 1 = extracting, 0 = stop
float smoothLastTickPercent = 0f;
DateTime smoothLastTickTime = DateTime.UtcNow;
float smoothLastReported = -1f;
const int SmoothIntervalMs = 80; // ~12.5 updates/sec
const float SmoothReportDelta = 0.10f; // report only if change >= 0.10%
using (Process x = new Process()) using (Process x = new Process())
{ {
@@ -86,59 +92,67 @@ namespace AndroidSideloader.Utilities
if (MainForm.isInDownloadExtract && x != null) if (MainForm.isInDownloadExtract && x != null)
{ {
// Smooth sub-percent UI, while keeping ETA ticking
smoothTimer = new System.Threading.Timer(_ =>
{
if (System.Threading.Volatile.Read(ref extractingFlag) == 0) return;
if (smoothLastTickPercent <= 0) return; // need at least one 7z tick
// Use current ETA to approximate seconds-per-percent
TimeSpan? displayEta = etaEstimator.GetDisplayEta();
if (!displayEta.HasValue) return; // Skip until ETA exists
var now = DateTime.UtcNow;
var elapsed = (now - smoothLastTickTime).TotalSeconds;
// Approx seconds-per-percent from remaining ETA / remaining percent
double remainingPercent = Math.Max(1.0, 100.0 - smoothLastTickPercent);
double spp = Math.Max(0.05, displayEta.Value.TotalSeconds / remainingPercent);
float candidate = smoothLastTickPercent + (float)(elapsed / spp);
// Clamp
float floorTick = (float)Math.Floor(smoothLastTickPercent);
float ceiling = Math.Min(99.99f, floorTick + 0.999f);
if (candidate > ceiling) candidate = ceiling;
if (candidate < smoothLastTickPercent) candidate = smoothLastTickPercent;
if (smoothLastReported >= 0 && Math.Abs(candidate - smoothLastReported) < SmoothReportDelta) return;
smoothLastReported = candidate;
try
{
MainForm mainForm = (MainForm)Application.OpenForms[0];
if (mainForm != null && !mainForm.IsDisposed)
{
mainForm.BeginInvoke((Action)(() => mainForm.SetProgress(candidate)));
}
}
catch { }
// ETA countdown ticks even if 7z percent is unchanged
ExtractionProgressCallback?.Invoke(candidate, etaEstimator.GetDisplayEta());
}, null, SmoothIntervalMs, SmoothIntervalMs);
x.OutputDataReceived += (sender, e) => x.OutputDataReceived += (sender, e) =>
{ {
if (e.Data != null) if (e.Data != null)
{ {
// Parse 7-Zip progress output (e.g., " 45% - filename")
var match = Regex.Match(e.Data, @"^\s*(\d+)%"); var match = Regex.Match(e.Data, @"^\s*(\d+)%");
if (match.Success && int.TryParse(match.Groups[1].Value, out int percent)) if (match.Success && float.TryParse(match.Groups[1].Value, out float percent))
{ {
TimeSpan? eta = null; // Update ETA from integer percent
if (percent <= 0.0f) etaEstimator.Reset();
else if (percent < 100.0f) etaEstimator.Update(totalUnits: 100, doneUnits: (long)Math.Round(percent));
// Calculate ETA // Reset smoothing baseline on each integer tick
if (percent > etaLastPercent && percent < 100) smoothLastTickPercent = percent;
{ smoothLastTickTime = DateTime.UtcNow;
var now = DateTime.UtcNow; smoothLastReported = percent;
double secondsForThisChunk = (now - etaLastPercentTime).TotalSeconds;
int percentGained = percent - etaLastPercent;
if (percentGained > 0 && secondsForThisChunk > 0) if (Math.Abs(percent - lastReportedPercent) >= 0.1f)
{
double secondsPerPercent = secondsForThisChunk / percentGained;
if (smoothedSecondsPerPercent == 0)
smoothedSecondsPerPercent = secondsPerPercent;
else
smoothedSecondsPerPercent = SmoothingAlpha * secondsPerPercent + (1 - SmoothingAlpha) * smoothedSecondsPerPercent;
int remainingPercent = 100 - percent;
double etaSeconds = remainingPercent * smoothedSecondsPerPercent;
var newEta = TimeSpan.FromSeconds(Math.Max(0, etaSeconds));
// Only update if significant change
if (!lastReportedEta.HasValue ||
Math.Abs(newEta.TotalSeconds - lastReportedEta.Value.TotalSeconds) / Math.Max(1, lastReportedEta.Value.TotalSeconds) > EtaChangeThreshold)
{
eta = newEta;
lastReportedEta = eta;
}
else
{
eta = lastReportedEta;
}
etaLastPercent = percent;
etaLastPercentTime = now;
}
}
else
{
eta = lastReportedEta;
}
// Only report if percent changed
if (percent != lastReportedPercent)
{ {
lastReportedPercent = percent; lastReportedPercent = percent;
@@ -148,7 +162,7 @@ namespace AndroidSideloader.Utilities
mainForm.Invoke((Action)(() => mainForm.SetProgress(percent))); mainForm.Invoke((Action)(() => mainForm.SetProgress(percent)));
} }
ExtractionProgressCallback?.Invoke(percent, eta); ExtractionProgressCallback?.Invoke(percent, etaEstimator.GetDisplayEta());
} }
} }
} }
@@ -183,6 +197,11 @@ namespace AndroidSideloader.Utilities
x.BeginErrorReadLine(); x.BeginErrorReadLine();
x.WaitForExit(); x.WaitForExit();
// Stop smoother
System.Threading.Interlocked.Exchange(ref extractingFlag, 0);
smoothTimer?.Dispose();
smoothTimer = null;
// Clear callbacks // Clear callbacks
ExtractionProgressCallback?.Invoke(100, null); ExtractionProgressCallback?.Invoke(100, null);
ExtractionStatusCallback?.Invoke(""); ExtractionStatusCallback?.Invoke("");
@@ -198,4 +217,4 @@ namespace AndroidSideloader.Utilities
} }
} }
} }
} }