diff --git a/Common/DownloadActivityAttributes.swift b/Common/DownloadActivityAttributes.swift index 0050ba25..0b3790e5 100644 --- a/Common/DownloadActivityAttributes.swift +++ b/Common/DownloadActivityAttributes.swift @@ -51,6 +51,10 @@ public struct DownloadActivityAttributes: ActivityAttributes { return first.description } + public var estimatedTimeLeft: TimeInterval { + items.map(\.timeRemaining).max() ?? 0 + } + public var progress: Double { progressFor(items: items).fractionCompleted } @@ -65,6 +69,7 @@ public struct DownloadActivityAttributes: ActivityAttributes { let description: String let downloaded: Int64 let total: Int64 + let timeRemaining: TimeInterval var progress: Double { progressFor(items: [self]).fractionCompleted } @@ -72,11 +77,12 @@ public struct DownloadActivityAttributes: ActivityAttributes { progressFor(items: [self]).localizedAdditionalDescription } - public init(uuid: UUID, description: String, downloaded: Int64, total: Int64) { + public init(uuid: UUID, description: String, downloaded: Int64, total: Int64, timeRemaining: TimeInterval) { self.uuid = uuid self.description = description self.downloaded = downloaded self.total = total + self.timeRemaining = timeRemaining } } } diff --git a/Model/Utilities/DownloadTime.swift b/Model/Utilities/DownloadTime.swift new file mode 100644 index 00000000..c4d40f9b --- /dev/null +++ b/Model/Utilities/DownloadTime.swift @@ -0,0 +1,93 @@ +// This file is part of Kiwix for iOS & macOS. +// +// Kiwix is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// any later version. +// +// Kiwix is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kiwix; If not, see https://www.gnu.org/licenses/. +import Foundation +import QuartzCore + +@MainActor +final class DownloadTime { + + /// Only consider these last seconds, when calculating the average speed, hence the remaining time + private let considerLastSeconds: Double + /// sampled data: seconds to % of download + private var samples: [CFTimeInterval: Int64] = [:] + private let totalAmount: Int64 + + init(considerLastSeconds: Double = 2, total: Int64) { + assert(considerLastSeconds > 0) + assert(total > 0) + self.considerLastSeconds = considerLastSeconds + self.totalAmount = total + } + + func update(downloaded: Int64, now: CFTimeInterval = CACurrentMediaTime()) { + filterOutSamples(now: now) + samples[now] = downloaded + } + + func remainingTime(now: CFTimeInterval = CACurrentMediaTime()) -> CFTimeInterval { + filterOutSamples(now: now) + guard samples.count > 1, let (latestTime, latestAmount) = latestSample() else { + return .infinity + } + let average = averagePerSecond() + let remainingAmount = totalAmount - latestAmount + let remaingTime = Double(remainingAmount) / average - (now - latestTime) + guard remaingTime > 0 else { + return 0 + } + return remaingTime + } + + private func filterOutSamples(now: CFTimeInterval) { + samples = samples.filter { time, _ in + time + considerLastSeconds > now + } + } + + private func averagePerSecond() -> Double { + var time: CFTimeInterval? + var amount: Int64? + var averages: [Double] = [] + for key in samples.keys.sorted() { + let value = samples[key]! + if let time, let amount { + let took = key - time + let downloaded = value - amount + if took > 0, downloaded > 0 { + averages.append(Double(downloaded) / took) + } + } + time = key + amount = value + } + return mean(averages) + } + + private func latestSample() -> (CFTimeInterval, Int64)? { + guard let lastTime = samples.keys.sorted().reversed().first, + let lastAmount = samples[lastTime] else { + return nil + } + return (lastTime, lastAmount) + } + + private func mean(_ values: [Double]) -> Double { + let sum = values.reduce(0) { partialResult, value in + partialResult + value + } + let average = sum / Double(values.count) + return average + } +} diff --git a/Views/LiveActivity/ActivityService.swift b/Views/LiveActivity/ActivityService.swift index 016f1b50..6df9100a 100644 --- a/Views/LiveActivity/ActivityService.swift +++ b/Views/LiveActivity/ActivityService.swift @@ -26,17 +26,22 @@ final class ActivityService { private var activity: Activity? private var lastUpdate = CACurrentMediaTime() private let updateFrequency: Double + private let averageDownloadSpeedFromLastSeconds: Double private let publisher: @MainActor () -> CurrentValueSubject<[UUID: DownloadState], Never> private var isStarted: Bool = false + private var downloadTimes: [UUID: DownloadTime] = [:] init( publisher: @MainActor @escaping () -> CurrentValueSubject<[UUID: DownloadState], Never> = { DownloadService.shared.progress.publisher }, - updateFrequency: Double = 1 + updateFrequency: Double = 1, + averageDownloadSpeedFromLastSeconds: Double = 30 ) { assert(updateFrequency > 0) + assert(averageDownloadSpeedFromLastSeconds > 0) self.updateFrequency = updateFrequency + self.averageDownloadSpeedFromLastSeconds = averageDownloadSpeedFromLastSeconds self.publisher = publisher } @@ -51,9 +56,9 @@ final class ActivityService { }.store(in: &cancellables) } - private func start(with state: [UUID: DownloadState]) { + private func start(with state: [UUID: DownloadState], downloadTimes: [UUID: CFTimeInterval]) { Task { - let activityState = await activityState(from: state) + let activityState = await activityState(from: state, downloadTimes: downloadTimes) let content = ActivityContent( state: activityState, staleDate: nil, @@ -81,9 +86,10 @@ final class ActivityService { } private func update(state: [UUID: DownloadState]) { + let downloadTimes: [UUID: CFTimeInterval] = updatedDownloadTimes(from: state) guard isStarted else { isStarted = true - start(with: state) + start(with: state, downloadTimes: downloadTimes) return } let now = CACurrentMediaTime() @@ -92,7 +98,7 @@ final class ActivityService { } lastUpdate = now Task { - let activityState = await activityState(from: state) + let activityState = await activityState(from: state, downloadTimes: downloadTimes) await activity.update( ActivityContent( state: activityState, @@ -102,11 +108,36 @@ final class ActivityService { } } + private func updatedDownloadTimes(from states: [UUID: DownloadState]) -> [UUID: CFTimeInterval] { + // remove the ones we should no longer track + downloadTimes = downloadTimes.filter({ key, _ in + states.keys.contains(key) + }) + + let now = CACurrentMediaTime() + for (key, state) in states { + if downloadTimes[key] == nil { + debugPrint("Creating new downloadTimes for \(key)") + } + let downloadTime: DownloadTime = downloadTimes[key] ?? DownloadTime( + considerLastSeconds: averageDownloadSpeedFromLastSeconds, + total: state.total + ) + downloadTime.update(downloaded: state.downloaded, now: now) + downloadTimes[key] = downloadTime + } + return downloadTimes.reduce(into: [:], { partialResult, time in + let (key, value) = time + partialResult.updateValue(value.remainingTime(now: now), forKey: key) + }) + } + private func stop() { Task { await activity?.end(nil, dismissalPolicy: .immediate) activity = nil isStarted = false + downloadTimes = [:] } } @@ -122,7 +153,7 @@ final class ActivityService { } } - private func activityState(from state: [UUID: DownloadState]) async -> DownloadActivityAttributes.ContentState { + private func activityState(from state: [UUID: DownloadState], downloadTimes: [UUID: CFTimeInterval]) async -> DownloadActivityAttributes.ContentState { var titles: [UUID: String] = [:] for key in state.keys { titles[key] = await getDownloadTitle(for: key) @@ -135,7 +166,8 @@ final class ActivityService { uuid: key, description: titles[key] ?? key.uuidString, downloaded: download.downloaded, - total: download.total) + total: download.total, + timeRemaining: downloadTimes[key] ?? 0) }) } } diff --git a/Widgets/DownloadsLiveActivity.swift b/Widgets/DownloadsLiveActivity.swift index 31499bc7..71e5851f 100644 --- a/Widgets/DownloadsLiveActivity.swift +++ b/Widgets/DownloadsLiveActivity.swift @@ -18,7 +18,6 @@ import WidgetKit import SwiftUI struct DownloadsLiveActivity: Widget { - var isActivityFullScreen: Bool = false // @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16 // https://developer.apple.com/forums/thread/763594 @@ -35,11 +34,18 @@ struct DownloadsLiveActivity: Widget { .multilineTextAlignment(.leading) .font(.headline) .bold() - Text(context.state.progressDescription) - .lineLimit(1) - .multilineTextAlignment(.leading) - .font(.caption) - .tint(.secondary) + HStack { + Text(timerInterval: Date.now...Date(timeInterval: context.state.estimatedTimeLeft, since: .now)) + .lineLimit(1) + .multilineTextAlignment(.leading) + .font(.caption) + .tint(.secondary) + Text(context.state.progressDescription) + .lineLimit(1) + .multilineTextAlignment(.leading) + .font(.caption) + .tint(.secondary) + } } Spacer() ProgressView(value: context.state.progress) @@ -111,13 +117,15 @@ extension DownloadActivityAttributes.ContentState { uuid: UUID(), description: "First item", downloaded: 128, - total: 256 + total: 256, + timeRemaining: 3 ), DownloadActivityAttributes.DownloadItem( uuid: UUID(), description: "2nd item", downloaded: 90, - total: 124 + total: 124, + timeRemaining: 2 ) ] ) @@ -131,13 +139,15 @@ extension DownloadActivityAttributes.ContentState { uuid: UUID(), description: "First item", downloaded: 256, - total: 256 + total: 256, + timeRemaining: 0 ), DownloadActivityAttributes.DownloadItem( uuid: UUID(), description: "2nd item", downloaded: 110, - total: 124 + total: 124, + timeRemaining: 2 ) ] )