diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx
index 42446285..ded59848 100644
--- a/components/downloads/DownloadCard.tsx
+++ b/components/downloads/DownloadCard.tsx
@@ -14,6 +14,7 @@ import {
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
+import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
@@ -62,16 +63,23 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
};
- const eta = (p: JobStatus) => {
- if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
+ const eta = useMemo(() => {
+ if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
+ return null;
+ }
- const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
- if (bytesRemaining <= 0) return null;
+ const secondsRemaining = calculateSmoothedETA(
+ process.id,
+ process.bytesDownloaded,
+ process.estimatedTotalSizeBytes,
+ );
- const secondsRemaining = bytesRemaining / p.speed;
+ if (!secondsRemaining || secondsRemaining <= 0) {
+ return null;
+ }
return formatTimeString(secondsRemaining, "s");
- };
+ }, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
@@ -161,9 +169,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{bytesToMB(process.speed).toFixed(2)} MB/s
)}
- {eta(process) && (
+ {eta && (
- {t("home.downloads.eta", { eta: eta(process) })}
+ {t("home.downloads.eta", { eta: eta })}
)}
diff --git a/modules/background-downloader/ios/BackgroundDownloaderModule.swift b/modules/background-downloader/ios/BackgroundDownloaderModule.swift
index a3f8fca2..958d97d0 100644
--- a/modules/background-downloader/ios/BackgroundDownloaderModule.swift
+++ b/modules/background-downloader/ios/BackgroundDownloaderModule.swift
@@ -19,6 +19,7 @@ class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate {
init(module: BackgroundDownloaderModule) {
self.module = module
super.init()
+ print("[DownloadSessionDelegate] Delegate initialized with module: \(String(describing: module))")
}
func urlSession(
@@ -141,10 +142,73 @@ public class BackgroundDownloaderModule: Module {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
session.getAllTasks { tasks in
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
- print("[BackgroundDownloader] Task state after 0.5s: \(self.taskStateString(downloadTask.state))")
+ print("[BackgroundDownloader] === 0.5s CHECK ===")
+ print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
if let response = downloadTask.response as? HTTPURLResponse {
print("[BackgroundDownloader] Response status: \(response.statusCode)")
print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
+ } else {
+ print("[BackgroundDownloader] No HTTP response yet after 0.5s")
+ }
+ } else {
+ print("[BackgroundDownloader] Task not found after 0.5s")
+ }
+ }
+ }
+
+ // Additional diagnostics at 1s, 2s, and 3s
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ session.getAllTasks { tasks in
+ if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
+ print("[BackgroundDownloader] === 1s CHECK ===")
+ print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
+ print("[BackgroundDownloader] Task error: \(String(describing: downloadTask.error))")
+ print("[BackgroundDownloader] Current request URL: \(downloadTask.currentRequest?.url?.absoluteString ?? "nil")")
+ print("[BackgroundDownloader] Original request URL: \(downloadTask.originalRequest?.url?.absoluteString ?? "nil")")
+
+ if let response = downloadTask.response as? HTTPURLResponse {
+ print("[BackgroundDownloader] HTTP Status: \(response.statusCode)")
+ print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
+ print("[BackgroundDownloader] All headers: \(response.allHeaderFields)")
+ } else {
+ print("[BackgroundDownloader] ⚠️ STILL NO HTTP RESPONSE after 1s")
+ }
+
+ let countOfBytesReceived = downloadTask.countOfBytesReceived
+ if countOfBytesReceived > 0 {
+ print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
+ } else {
+ print("[BackgroundDownloader] ⚠️ NO BYTES RECEIVED YET")
+ }
+ } else {
+ print("[BackgroundDownloader] ⚠️ Task disappeared after 1s")
+ }
+ }
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ session.getAllTasks { tasks in
+ if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
+ print("[BackgroundDownloader] === 2s CHECK ===")
+ print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
+ let countOfBytesReceived = downloadTask.countOfBytesReceived
+ print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
+ if downloadTask.error != nil {
+ print("[BackgroundDownloader] ⚠️ Task has error: \(String(describing: downloadTask.error))")
+ }
+ }
+ }
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
+ session.getAllTasks { tasks in
+ if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
+ print("[BackgroundDownloader] === 3s CHECK ===")
+ print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
+ let countOfBytesReceived = downloadTask.countOfBytesReceived
+ print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
+ if downloadTask.error != nil {
+ print("[BackgroundDownloader] ⚠️ Task has error: \(String(describing: downloadTask.error))")
}
}
}
@@ -173,17 +237,20 @@ public class BackgroundDownloaderModule: Module {
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
return try await withCheckedThrowingContinuation { continuation in
+ let downloadTasks = self.downloadTasks
+ let taskStateString = self.taskStateString
+
self.session?.getAllTasks { tasks in
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
guard task is URLSessionDownloadTask,
- let info = self.downloadTasks[task.taskIdentifier] else {
+ let info = downloadTasks[task.taskIdentifier] else {
return nil
}
return [
"taskId": task.taskIdentifier,
"url": info.url,
- "state": self.taskStateString(task.state)
+ "state": taskStateString(task.state)
]
}
continuation.resume(returning: activeDownloads)
@@ -210,6 +277,15 @@ public class BackgroundDownloaderModule: Module {
)
print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))")
+ print("[BackgroundDownloader] Session identifier: \(config.identifier ?? "nil")")
+ print("[BackgroundDownloader] Delegate queue: nil (uses default)")
+
+ // Verify delegate is connected
+ if let session = self.session, session.delegate != nil {
+ print("[BackgroundDownloader] ✅ Delegate successfully attached to session")
+ } else {
+ print("[BackgroundDownloader] ⚠️ DELEGATE NOT ATTACHED!")
+ }
}
private func taskStateString(_ state: URLSessionTask.State) -> String {
diff --git a/providers/Downloads/hooks/useDownloadSpeedCalculator.ts b/providers/Downloads/hooks/useDownloadSpeedCalculator.ts
index 1881c9d3..531e1f27 100644
--- a/providers/Downloads/hooks/useDownloadSpeedCalculator.ts
+++ b/providers/Downloads/hooks/useDownloadSpeedCalculator.ts
@@ -4,11 +4,13 @@ interface SpeedDataPoint {
}
const WINDOW_DURATION = 60000; // 1 minute in ms
-const MIN_DATA_POINTS = 3; // Need at least 3 points for accurate speed
+const MIN_DATA_POINTS = 5; // Need at least 5 points for accurate speed
const MAX_REASONABLE_SPEED = 1024 * 1024 * 1024; // 1 GB/s sanity check
+const EMA_ALPHA = 0.2; // Smoothing factor for EMA (lower = smoother, 0-1 range)
// Private state
const dataPoints = new Map();
+const emaSpeed = new Map(); // Store EMA speed for each process
function isValidBytes(bytes: number): boolean {
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0;
@@ -161,7 +163,8 @@ export function calculateWeightedSpeed(processId: string): number | undefined {
}
// More recent points get exponentially higher weight
- const weight = 2 ** i;
+ // Using 1.3 instead of 2 for gentler weighting (less sensitive to recent changes)
+ const weight = 1.3 ** i;
totalWeightedSpeed += speed * weight;
totalWeight += weight;
}
@@ -180,12 +183,74 @@ export function calculateWeightedSpeed(processId: string): number | undefined {
return weightedSpeed;
}
+// Calculate ETA in seconds
+export function calculateETA(
+ processId: string,
+ bytesDownloaded: number,
+ totalBytes: number,
+): number | undefined {
+ const speed = calculateWeightedSpeed(processId);
+
+ if (!speed || speed <= 0 || !totalBytes || totalBytes <= 0) {
+ return undefined;
+ }
+
+ const bytesRemaining = totalBytes - bytesDownloaded;
+ if (bytesRemaining <= 0) {
+ return 0;
+ }
+
+ const secondsRemaining = bytesRemaining / speed;
+
+ // Sanity check
+ if (!Number.isFinite(secondsRemaining) || secondsRemaining < 0) {
+ return undefined;
+ }
+
+ return secondsRemaining;
+}
+
+// Calculate smoothed ETA using Exponential Moving Average (EMA)
+// This provides much smoother ETA estimates, reducing jumpy time estimates
+const emaETA = new Map();
+
+export function calculateSmoothedETA(
+ processId: string,
+ bytesDownloaded: number,
+ totalBytes: number,
+): number | undefined {
+ const currentETA = calculateETA(processId, bytesDownloaded, totalBytes);
+
+ if (currentETA === undefined) {
+ return undefined;
+ }
+
+ const previousEma = emaETA.get(processId);
+
+ if (previousEma === undefined) {
+ // First calculation, initialize with current ETA
+ emaETA.set(processId, currentETA);
+ return currentETA;
+ }
+
+ // EMA formula: EMA(t) = α * current + (1 - α) * EMA(t-1)
+ // Lower alpha = smoother but slower to respond
+ const smoothed = EMA_ALPHA * currentETA + (1 - EMA_ALPHA) * previousEma;
+
+ emaETA.set(processId, smoothed);
+ return smoothed;
+}
+
export function clearSpeedData(processId: string): void {
dataPoints.delete(processId);
+ emaSpeed.delete(processId);
+ emaETA.delete(processId);
}
export function resetAllSpeedData(): void {
dataPoints.clear();
+ emaSpeed.clear();
+ emaETA.clear();
}
// Debug function to inspect current state