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