diff --git a/Common/DownloadActivityAttributes.swift b/Common/DownloadActivityAttributes.swift index 0b3790e5..e894c9b3 100644 --- a/Common/DownloadActivityAttributes.swift +++ b/Common/DownloadActivityAttributes.swift @@ -52,7 +52,15 @@ public struct DownloadActivityAttributes: ActivityAttributes { } public var estimatedTimeLeft: TimeInterval { - items.map(\.timeRemaining).max() ?? 0 + items.filter { (item: DownloadActivityAttributes.DownloadItem) in + !item.isPaused + }.map(\.timeRemaining).max() ?? 0 + } + + public var isAllPaused: Bool { + items.allSatisfy { (item: DownloadActivityAttributes.DownloadItem) in + item.isPaused + } } public var progress: Double { @@ -70,6 +78,7 @@ public struct DownloadActivityAttributes: ActivityAttributes { let downloaded: Int64 let total: Int64 let timeRemaining: TimeInterval + let isPaused: Bool var progress: Double { progressFor(items: [self]).fractionCompleted } @@ -77,12 +86,20 @@ public struct DownloadActivityAttributes: ActivityAttributes { progressFor(items: [self]).localizedAdditionalDescription } - public init(uuid: UUID, description: String, downloaded: Int64, total: Int64, timeRemaining: TimeInterval) { + public init( + uuid: UUID, + description: String, + downloaded: Int64, + total: Int64, + timeRemaining: TimeInterval, + isPaused: Bool + ) { self.uuid = uuid self.description = description self.downloaded = downloaded self.total = total self.timeRemaining = timeRemaining + self.isPaused = isPaused } } } diff --git a/Model/DownloadService.swift b/Model/Downloads/DownloadService.swift similarity index 82% rename from Model/DownloadService.swift rename to Model/Downloads/DownloadService.swift index 647c325f..8c647265 100644 --- a/Model/DownloadService.swift +++ b/Model/Downloads/DownloadService.swift @@ -13,102 +13,11 @@ // You should have received a copy of the GNU General Public License // along with Kiwix; If not, see https://www.gnu.org/licenses/. -// -// DownloadService.swift -// Kiwix - import Combine import CoreData import UserNotifications import os -struct DownloadState: Codable { - let downloaded: Int64 - let total: Int64 - let resumeData: Data? - - static func empty() -> DownloadState { - .init(downloaded: 0, total: 1, resumeData: nil) - } - - init(downloaded: Int64, total: Int64, resumeData: Data?) { - guard total >= downloaded, total > 0 else { - assertionFailure("invalid download progress values: downloaded \(downloaded) total: \(total)") - self.downloaded = downloaded - self.total = downloaded - self.resumeData = resumeData - return - } - self.downloaded = downloaded - self.total = total - self.resumeData = resumeData - } - - func updatedWith(downloaded: Int64, total: Int64) -> DownloadState { - DownloadState(downloaded: downloaded, total: total, resumeData: resumeData) - } - - func updatedWith(resumeData: Data?) -> DownloadState { - DownloadState(downloaded: downloaded, total: total, resumeData: resumeData) - } -} - -@MainActor -final class DownloadTasksPublisher { - - let publisher: CurrentValueSubject<[UUID: DownloadState], Never> - private var states = [UUID: DownloadState]() - - init() { - publisher = CurrentValueSubject(states) - if let jsonData = UserDefaults.standard.object(forKey: "downloadStates") as? Data, - let storedStates = try? JSONDecoder().decode([UUID: DownloadState].self, from: jsonData) { - states = storedStates - publisher.send(states) - } - } - - func updateFor(uuid: UUID, downloaded: Int64, total: Int64) { - if let state = states[uuid] { - states[uuid] = state.updatedWith(downloaded: downloaded, total: total) - } else { - states[uuid] = DownloadState(downloaded: downloaded, total: total, resumeData: nil) - } - publisher.send(states) - saveState() - } - - func resetFor(uuid: UUID) { - states.removeValue(forKey: uuid) - publisher.send(states) - saveState() - } - - func isEmpty() -> Bool { - states.isEmpty - } - - func resumeDataFor(uuid: UUID) -> Data? { - states[uuid]?.resumeData - } - - func updateFor(uuid: UUID, withResumeData resumeData: Data?) { - if let state = states[uuid] { - states[uuid] = state.updatedWith(resumeData: resumeData) - publisher.send(states) - saveState() - } else { - assertionFailure("there should be a download task for: \(uuid)") - } - } - - private func saveState() { - if let jsonStates = try? JSONEncoder().encode(states) { - UserDefaults.standard.setValue(jsonStates, forKey: "downloadStates") - } - } -} - final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDownloadDelegate { static let shared = DownloadService() private let queue = DispatchQueue(label: "downloads", qos: .background) diff --git a/Model/Downloads/DownloadState.swift b/Model/Downloads/DownloadState.swift new file mode 100644 index 00000000..60452b1d --- /dev/null +++ b/Model/Downloads/DownloadState.swift @@ -0,0 +1,52 @@ +// 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 Combine + +struct DownloadState: Codable { + let downloaded: Int64 + let total: Int64 + let resumeData: Data? + + var isPaused: Bool { + resumeData != nil + } + + static func empty() -> DownloadState { + .init(downloaded: 0, total: 1, resumeData: nil) + } + + init(downloaded: Int64, total: Int64, resumeData: Data?) { + guard total >= downloaded, total > 0 else { + assertionFailure("invalid download progress values: downloaded \(downloaded) total: \(total)") + self.downloaded = downloaded + self.total = downloaded + self.resumeData = resumeData + return + } + self.downloaded = downloaded + self.total = total + self.resumeData = resumeData + } + + func updatedWith(downloaded: Int64, total: Int64) -> DownloadState { + DownloadState(downloaded: downloaded, total: total, resumeData: resumeData) + } + + func updatedWith(resumeData: Data?) -> DownloadState { + DownloadState(downloaded: downloaded, total: total, resumeData: resumeData) + } +} diff --git a/Model/Downloads/DownloadTasksPublisher.swift b/Model/Downloads/DownloadTasksPublisher.swift new file mode 100644 index 00000000..5a7dd9e0 --- /dev/null +++ b/Model/Downloads/DownloadTasksPublisher.swift @@ -0,0 +1,73 @@ +// 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 Combine + +@MainActor +final class DownloadTasksPublisher { + + let publisher: CurrentValueSubject<[UUID: DownloadState], Never> + private var states = [UUID: DownloadState]() + + init() { + publisher = CurrentValueSubject(states) + if let jsonData = UserDefaults.standard.object(forKey: "downloadStates") as? Data, + let storedStates = try? JSONDecoder().decode([UUID: DownloadState].self, from: jsonData) { + states = storedStates + publisher.send(states) + } + } + + func updateFor(uuid: UUID, downloaded: Int64, total: Int64) { + if let state = states[uuid] { + states[uuid] = state.updatedWith(downloaded: downloaded, total: total) + } else { + states[uuid] = DownloadState(downloaded: downloaded, total: total, resumeData: nil) + } + publisher.send(states) + saveState() + } + + func resetFor(uuid: UUID) { + states.removeValue(forKey: uuid) + publisher.send(states) + saveState() + } + + func isEmpty() -> Bool { + states.isEmpty + } + + func resumeDataFor(uuid: UUID) -> Data? { + states[uuid]?.resumeData + } + + func updateFor(uuid: UUID, withResumeData resumeData: Data?) { + if let state = states[uuid] { + states[uuid] = state.updatedWith(resumeData: resumeData) + publisher.send(states) + saveState() + } else { + assertionFailure("there should be a download task for: \(uuid)") + } + } + + private func saveState() { + if let jsonStates = try? JSONEncoder().encode(states) { + UserDefaults.standard.setValue(jsonStates, forKey: "downloadStates") + } + } +} diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index ab7c9e0e..2bcdbbe3 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -611,8 +611,7 @@ final class BrowserViewModel: NSObject, ObservableObject, ) actions.append( UIAction(title: LocalString.common_dialog_button_open_in_new_tab, - image: UIImage(systemName: "doc.badge.plus")) { [weak self] _ in - guard let self else { return } + image: UIImage(systemName: "doc.badge.plus")) { _ in Task { @MainActor in NotificationCenter.openURL(url, inNewTab: true) } diff --git a/Views/LiveActivity/ActivityService.swift b/Views/LiveActivity/ActivityService.swift index e3380947..9f8e2354 100644 --- a/Views/LiveActivity/ActivityService.swift +++ b/Views/LiveActivity/ActivityService.swift @@ -11,7 +11,7 @@ // 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.orgllll/llicenses/. +// along with Kiwix; If not, see https://www.gnu.org/licenses/. #if os(iOS) @@ -49,9 +49,9 @@ final class ActivityService { publisher().sink { [weak self] (state: [UUID: DownloadState]) in guard let self else { return } if state.isEmpty { - stop() + self.stop() } else { - update(state: state) + self.update(state: state) } }.store(in: &cancellables) } @@ -93,19 +93,31 @@ final class ActivityService { return } let now = CACurrentMediaTime() - guard let activity, (now - lastUpdate) > updateFrequency else { + // make sure we don't update too frequently + // unless there's a pause, we do want immediate update + let isTooEarlyToUpdate = if hasAnyPause(in: state) { + false + } else { + (now - lastUpdate) <= updateFrequency + } + guard let activity, !isTooEarlyToUpdate else { return } - lastUpdate = now Task { let activityState = await activityState(from: state, downloadTimes: downloadTimes) - await activity.update( - ActivityContent( - state: activityState, - staleDate: nil - ) + let newContent = ActivityContent( + state: activityState, + staleDate: nil ) + if #available(iOS 17.2, *) { + // important to define a timestamp, this way iOS knows which updates + // can be dropped, if too many of them queues up + await activity.update(newContent, timestamp: Date.now) + } else { + await activity.update(newContent) + } } + lastUpdate = now } private func updatedDownloadTimes(from states: [UUID: DownloadState]) -> [UUID: CFTimeInterval] { @@ -171,9 +183,18 @@ final class ActivityService { description: titles[key] ?? key.uuidString, downloaded: download.downloaded, total: download.total, - timeRemaining: downloadTimes[key] ?? 0) + timeRemaining: downloadTimes[key] ?? 0, + isPaused: download.isPaused + ) }) } + + private func hasAnyPause(in state: [UUID: DownloadState]) -> Bool { + guard !state.isEmpty else { return false } + return !state.values.allSatisfy { (download: DownloadState) in + download.isPaused == false + } + } } #endif diff --git a/Widgets/DownloadsLiveActivity.swift b/Widgets/DownloadsLiveActivity.swift index 565bf845..4eaca6f5 100644 --- a/Widgets/DownloadsLiveActivity.swift +++ b/Widgets/DownloadsLiveActivity.swift @@ -21,73 +21,51 @@ struct DownloadsLiveActivity: Widget { // @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16 // https://developer.apple.com/forums/thread/763594 + /// A start time from the creation of the activity, + /// this way the progress bar is not jumping back to 0 + private let startTime: Date = .now + var body: some WidgetConfiguration { ActivityConfiguration(for: DownloadActivityAttributes.self) { context in - // Lock screen/banner UI goes here + // Lock screen/banner UI + let timeInterval = startTime...Date( + timeInterval: context.state.estimatedTimeLeft, + since: .now + ) VStack { HStack { - KiwixLogo(maxHeight: 50) - .padding() VStack(alignment: .leading) { - Text(context.state.title) - .lineLimit(1) - .multilineTextAlignment(.leading) - .font(.headline) - .bold() - 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) - } + titleFor(context.state.title) + progressFor(state: context.state, timeInterval: timeInterval) } - Spacer() - ProgressView(value: context.state.progress) - .progressViewStyle(CircularProgressGaugeStyle(lineWidth: 5.7)) - .frame(width: 24, height: 24) - .padding() + .padding() + KiwixLogo(maxHeight: 50) + .padding(.trailing) } } .modifier(WidgetBackgroundModifier()) } dynamicIsland: { context in DynamicIsland { - // Expanded UI goes here. Compose the expanded UI through - // various regions, like leading/trailing/center/bottom + // Expanded UI DynamicIslandExpandedRegion(.leading) { - Spacer() - KiwixLogo(maxHeight: 50) - Spacer() - } - DynamicIslandExpandedRegion(.trailing) { - ProgressView(value: context.state.progress) - .progressViewStyle(CircularProgressGaugeStyle(lineWidth: 11.4)) - .padding(6.0) - } - DynamicIslandExpandedRegion(.center) { + let timeInterval = startTime...Date( + timeInterval: context.state.estimatedTimeLeft, + since: .now + ) + VStack(alignment: .leading) { - Text(context.state.title) - .lineLimit(1) - .multilineTextAlignment(.leading) - .font(.headline) - .bold() - Text(context.state.progressDescription) - .lineLimit(1) - .multilineTextAlignment(.leading) - .font(.caption) - .tint(.secondary) + titleFor(context.state.title) + progressFor(state: context.state, timeInterval: timeInterval) + Spacer() } + .padding() + .dynamicIsland(verticalPlacement: .belowIfTooWide) + } + + DynamicIslandExpandedRegion(.trailing) { + KiwixLogo(maxHeight: 50) + .padding() } } compactLeading: { KiwixLogo() @@ -105,6 +83,49 @@ struct DownloadsLiveActivity: Widget { .keylineTint(Color.red) }.containerBackgroundRemovable() } + + @ViewBuilder + private func titleFor(_ title: String) -> some View { + Text(title) + .lineLimit(1) + .frame(minWidth: 150, alignment: .leading) + .font(.headline) + .bold() + } + + @ViewBuilder + private func progressText(_ description: String) -> some View { + Text(description) + .lineLimit(1) + .font(.caption) + .tint(.secondary) + } + + @ViewBuilder + private func progressFor( + state: DownloadActivityAttributes.ContentState, + timeInterval: ClosedRange + ) -> some View { + if !state.isAllPaused { + ProgressView(timerInterval: timeInterval, countsDown: false, label: { + progressText(state.progressDescription) + }, currentValueLabel: { + Text(timerInterval: timeInterval) + .font(.caption) + .tint(.secondary) + }) + .tint(Color.primary) + } else { + ProgressView(value: state.progress, label: { + progressText(state.progressDescription) + }, currentValueLabel: { + Label("", systemImage: "pause.fill") + .font(.caption) + .tint(.secondary) + }) + .tint(Color.primary) + } + } } extension DownloadActivityAttributes { @@ -123,14 +144,16 @@ extension DownloadActivityAttributes.ContentState { description: "First item", downloaded: 128, total: 256, - timeRemaining: 3 + timeRemaining: 15, + isPaused: true ), DownloadActivityAttributes.DownloadItem( uuid: UUID(), description: "2nd item", downloaded: 90, total: 124, - timeRemaining: 2 + timeRemaining: 2, + isPaused: true ) ] ) @@ -145,14 +168,16 @@ extension DownloadActivityAttributes.ContentState { description: "First item", downloaded: 256, total: 256, - timeRemaining: 0 + timeRemaining: 0, + isPaused: false ), DownloadActivityAttributes.DownloadItem( uuid: UUID(), description: "2nd item", downloaded: 110, total: 124, - timeRemaining: 2 + timeRemaining: 2, + isPaused: false ) ] ) diff --git a/Widgets/KiwixLogo.swift b/Widgets/KiwixLogo.swift index d2af4ccb..b4be4174 100644 --- a/Widgets/KiwixLogo.swift +++ b/Widgets/KiwixLogo.swift @@ -31,7 +31,7 @@ struct KiwixLogo: View { Image("KiwixLogo") .resizable() .scaledToFit() - .frame(width: maxHeight / 1.6182, height: maxHeight / 1.6182) + .frame(width: maxHeight * 0.75, height: maxHeight * 0.75) } } }