Skip to content

Commit 894c2b3

Browse files
committed
Merge branch 'live-activities-update' into try-xcode-16.1-liveactivities
2 parents 63923b2 + c1a4b53 commit 894c2b3

File tree

8 files changed

+259
-163
lines changed

8 files changed

+259
-163
lines changed

Common/DownloadActivityAttributes.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,15 @@ public struct DownloadActivityAttributes: ActivityAttributes {
5252
}
5353

5454
public var estimatedTimeLeft: TimeInterval {
55-
items.map(\.timeRemaining).max() ?? 0
55+
items.filter { (item: DownloadActivityAttributes.DownloadItem) in
56+
!item.isPaused
57+
}.map(\.timeRemaining).max() ?? 0
58+
}
59+
60+
public var isAllPaused: Bool {
61+
items.allSatisfy { (item: DownloadActivityAttributes.DownloadItem) in
62+
item.isPaused
63+
}
5664
}
5765

5866
public var progress: Double {
@@ -70,19 +78,28 @@ public struct DownloadActivityAttributes: ActivityAttributes {
7078
let downloaded: Int64
7179
let total: Int64
7280
let timeRemaining: TimeInterval
81+
let isPaused: Bool
7382
var progress: Double {
7483
progressFor(items: [self]).fractionCompleted
7584
}
7685
var progressDescription: String {
7786
progressFor(items: [self]).localizedAdditionalDescription
7887
}
7988

80-
public init(uuid: UUID, description: String, downloaded: Int64, total: Int64, timeRemaining: TimeInterval) {
89+
public init(
90+
uuid: UUID,
91+
description: String,
92+
downloaded: Int64,
93+
total: Int64,
94+
timeRemaining: TimeInterval,
95+
isPaused: Bool
96+
) {
8197
self.uuid = uuid
8298
self.description = description
8399
self.downloaded = downloaded
84100
self.total = total
85101
self.timeRemaining = timeRemaining
102+
self.isPaused = isPaused
86103
}
87104
}
88105
}

Model/DownloadService.swift renamed to Model/Downloads/DownloadService.swift

Lines changed: 0 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -13,102 +13,11 @@
1313
// You should have received a copy of the GNU General Public License
1414
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
1515

16-
//
17-
// DownloadService.swift
18-
// Kiwix
19-
2016
import Combine
2117
import CoreData
2218
import UserNotifications
2319
import os
2420

25-
struct DownloadState: Codable {
26-
let downloaded: Int64
27-
let total: Int64
28-
let resumeData: Data?
29-
30-
static func empty() -> DownloadState {
31-
.init(downloaded: 0, total: 1, resumeData: nil)
32-
}
33-
34-
init(downloaded: Int64, total: Int64, resumeData: Data?) {
35-
guard total >= downloaded, total > 0 else {
36-
assertionFailure("invalid download progress values: downloaded \(downloaded) total: \(total)")
37-
self.downloaded = downloaded
38-
self.total = downloaded
39-
self.resumeData = resumeData
40-
return
41-
}
42-
self.downloaded = downloaded
43-
self.total = total
44-
self.resumeData = resumeData
45-
}
46-
47-
func updatedWith(downloaded: Int64, total: Int64) -> DownloadState {
48-
DownloadState(downloaded: downloaded, total: total, resumeData: resumeData)
49-
}
50-
51-
func updatedWith(resumeData: Data?) -> DownloadState {
52-
DownloadState(downloaded: downloaded, total: total, resumeData: resumeData)
53-
}
54-
}
55-
56-
@MainActor
57-
final class DownloadTasksPublisher {
58-
59-
let publisher: CurrentValueSubject<[UUID: DownloadState], Never>
60-
private var states = [UUID: DownloadState]()
61-
62-
init() {
63-
publisher = CurrentValueSubject(states)
64-
if let jsonData = UserDefaults.standard.object(forKey: "downloadStates") as? Data,
65-
let storedStates = try? JSONDecoder().decode([UUID: DownloadState].self, from: jsonData) {
66-
states = storedStates
67-
publisher.send(states)
68-
}
69-
}
70-
71-
func updateFor(uuid: UUID, downloaded: Int64, total: Int64) {
72-
if let state = states[uuid] {
73-
states[uuid] = state.updatedWith(downloaded: downloaded, total: total)
74-
} else {
75-
states[uuid] = DownloadState(downloaded: downloaded, total: total, resumeData: nil)
76-
}
77-
publisher.send(states)
78-
saveState()
79-
}
80-
81-
func resetFor(uuid: UUID) {
82-
states.removeValue(forKey: uuid)
83-
publisher.send(states)
84-
saveState()
85-
}
86-
87-
func isEmpty() -> Bool {
88-
states.isEmpty
89-
}
90-
91-
func resumeDataFor(uuid: UUID) -> Data? {
92-
states[uuid]?.resumeData
93-
}
94-
95-
func updateFor(uuid: UUID, withResumeData resumeData: Data?) {
96-
if let state = states[uuid] {
97-
states[uuid] = state.updatedWith(resumeData: resumeData)
98-
publisher.send(states)
99-
saveState()
100-
} else {
101-
assertionFailure("there should be a download task for: \(uuid)")
102-
}
103-
}
104-
105-
private func saveState() {
106-
if let jsonStates = try? JSONEncoder().encode(states) {
107-
UserDefaults.standard.setValue(jsonStates, forKey: "downloadStates")
108-
}
109-
}
110-
}
111-
11221
final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDownloadDelegate {
11322
static let shared = DownloadService()
11423
private let queue = DispatchQueue(label: "downloads", qos: .background)

Model/Downloads/DownloadState.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// This file is part of Kiwix for iOS & macOS.
2+
//
3+
// Kiwix is free software; you can redistribute it and/or modify it
4+
// under the terms of the GNU General Public License as published by
5+
// the Free Software Foundation; either version 3 of the License, or
6+
// any later version.
7+
//
8+
// Kiwix is distributed in the hope that it will be useful, but
9+
// WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
11+
// General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License
14+
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
15+
16+
import Foundation
17+
import Combine
18+
19+
struct DownloadState: Codable {
20+
let downloaded: Int64
21+
let total: Int64
22+
let resumeData: Data?
23+
24+
var isPaused: Bool {
25+
resumeData != nil
26+
}
27+
28+
static func empty() -> DownloadState {
29+
.init(downloaded: 0, total: 1, resumeData: nil)
30+
}
31+
32+
init(downloaded: Int64, total: Int64, resumeData: Data?) {
33+
guard total >= downloaded, total > 0 else {
34+
assertionFailure("invalid download progress values: downloaded \(downloaded) total: \(total)")
35+
self.downloaded = downloaded
36+
self.total = downloaded
37+
self.resumeData = resumeData
38+
return
39+
}
40+
self.downloaded = downloaded
41+
self.total = total
42+
self.resumeData = resumeData
43+
}
44+
45+
func updatedWith(downloaded: Int64, total: Int64) -> DownloadState {
46+
DownloadState(downloaded: downloaded, total: total, resumeData: resumeData)
47+
}
48+
49+
func updatedWith(resumeData: Data?) -> DownloadState {
50+
DownloadState(downloaded: downloaded, total: total, resumeData: resumeData)
51+
}
52+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// This file is part of Kiwix for iOS & macOS.
2+
//
3+
// Kiwix is free software; you can redistribute it and/or modify it
4+
// under the terms of the GNU General Public License as published by
5+
// the Free Software Foundation; either version 3 of the License, or
6+
// any later version.
7+
//
8+
// Kiwix is distributed in the hope that it will be useful, but
9+
// WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
11+
// General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License
14+
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
15+
16+
import Foundation
17+
import Combine
18+
19+
@MainActor
20+
final class DownloadTasksPublisher {
21+
22+
let publisher: CurrentValueSubject<[UUID: DownloadState], Never>
23+
private var states = [UUID: DownloadState]()
24+
25+
init() {
26+
publisher = CurrentValueSubject(states)
27+
if let jsonData = UserDefaults.standard.object(forKey: "downloadStates") as? Data,
28+
let storedStates = try? JSONDecoder().decode([UUID: DownloadState].self, from: jsonData) {
29+
states = storedStates
30+
publisher.send(states)
31+
}
32+
}
33+
34+
func updateFor(uuid: UUID, downloaded: Int64, total: Int64) {
35+
if let state = states[uuid] {
36+
states[uuid] = state.updatedWith(downloaded: downloaded, total: total)
37+
} else {
38+
states[uuid] = DownloadState(downloaded: downloaded, total: total, resumeData: nil)
39+
}
40+
publisher.send(states)
41+
saveState()
42+
}
43+
44+
func resetFor(uuid: UUID) {
45+
states.removeValue(forKey: uuid)
46+
publisher.send(states)
47+
saveState()
48+
}
49+
50+
func isEmpty() -> Bool {
51+
states.isEmpty
52+
}
53+
54+
func resumeDataFor(uuid: UUID) -> Data? {
55+
states[uuid]?.resumeData
56+
}
57+
58+
func updateFor(uuid: UUID, withResumeData resumeData: Data?) {
59+
if let state = states[uuid] {
60+
states[uuid] = state.updatedWith(resumeData: resumeData)
61+
publisher.send(states)
62+
saveState()
63+
} else {
64+
assertionFailure("there should be a download task for: \(uuid)")
65+
}
66+
}
67+
68+
private func saveState() {
69+
if let jsonStates = try? JSONEncoder().encode(states) {
70+
UserDefaults.standard.setValue(jsonStates, forKey: "downloadStates")
71+
}
72+
}
73+
}

ViewModel/BrowserViewModel.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,8 +611,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
611611
)
612612
actions.append(
613613
UIAction(title: LocalString.common_dialog_button_open_in_new_tab,
614-
image: UIImage(systemName: "doc.badge.plus")) { [weak self] _ in
615-
guard let self else { return }
614+
image: UIImage(systemName: "doc.badge.plus")) { _ in
616615
Task { @MainActor in
617616
NotificationCenter.openURL(url, inNewTab: true)
618617
}

Views/LiveActivity/ActivityService.swift

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// General Public License for more details.
1212
//
1313
// You should have received a copy of the GNU General Public License
14-
// along with Kiwix; If not, see https://www.gnu.orgllll/llicenses/.
14+
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
1515

1616
#if os(iOS)
1717

@@ -49,9 +49,9 @@ final class ActivityService {
4949
publisher().sink { [weak self] (state: [UUID: DownloadState]) in
5050
guard let self else { return }
5151
if state.isEmpty {
52-
stop()
52+
self.stop()
5353
} else {
54-
update(state: state)
54+
self.update(state: state)
5555
}
5656
}.store(in: &cancellables)
5757
}
@@ -93,19 +93,31 @@ final class ActivityService {
9393
return
9494
}
9595
let now = CACurrentMediaTime()
96-
guard let activity, (now - lastUpdate) > updateFrequency else {
96+
// make sure we don't update too frequently
97+
// unless there's a pause, we do want immediate update
98+
let isTooEarlyToUpdate = if hasAnyPause(in: state) {
99+
false
100+
} else {
101+
(now - lastUpdate) <= updateFrequency
102+
}
103+
guard let activity, !isTooEarlyToUpdate else {
97104
return
98105
}
99-
lastUpdate = now
100106
Task {
101107
let activityState = await activityState(from: state, downloadTimes: downloadTimes)
102-
await activity.update(
103-
ActivityContent<DownloadActivityAttributes.ContentState>(
104-
state: activityState,
105-
staleDate: nil
106-
)
108+
let newContent = ActivityContent<DownloadActivityAttributes.ContentState>(
109+
state: activityState,
110+
staleDate: nil
107111
)
112+
if #available(iOS 17.2, *) {
113+
// important to define a timestamp, this way iOS knows which updates
114+
// can be dropped, if too many of them queues up
115+
await activity.update(newContent, timestamp: Date.now)
116+
} else {
117+
await activity.update(newContent)
118+
}
108119
}
120+
lastUpdate = now
109121
}
110122

111123
private func updatedDownloadTimes(from states: [UUID: DownloadState]) -> [UUID: CFTimeInterval] {
@@ -171,9 +183,18 @@ final class ActivityService {
171183
description: titles[key] ?? key.uuidString,
172184
downloaded: download.downloaded,
173185
total: download.total,
174-
timeRemaining: downloadTimes[key] ?? 0)
186+
timeRemaining: downloadTimes[key] ?? 0,
187+
isPaused: download.isPaused
188+
)
175189
})
176190
}
191+
192+
private func hasAnyPause(in state: [UUID: DownloadState]) -> Bool {
193+
guard !state.isEmpty else { return false }
194+
return !state.values.allSatisfy { (download: DownloadState) in
195+
download.isPaused == false
196+
}
197+
}
177198
}
178199

179200
#endif

0 commit comments

Comments
 (0)