Skip to content

Commit 594c2d5

Browse files
authored
Merge pull request #1114 from kiwix/1107-improve-live-activities
1107 improve live activities
2 parents 2382a35 + ce266fb commit 594c2d5

File tree

7 files changed

+212
-14
lines changed

7 files changed

+212
-14
lines changed

Common/DownloadActivityAttributes.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ public struct DownloadActivityAttributes: ActivityAttributes {
5151
return first.description
5252
}
5353

54+
public var estimatedTimeLeft: TimeInterval {
55+
items.map(\.timeRemaining).max() ?? 0
56+
}
57+
5458
public var progress: Double {
5559
progressFor(items: items).fractionCompleted
5660
}
@@ -65,18 +69,20 @@ public struct DownloadActivityAttributes: ActivityAttributes {
6569
let description: String
6670
let downloaded: Int64
6771
let total: Int64
72+
let timeRemaining: TimeInterval
6873
var progress: Double {
6974
progressFor(items: [self]).fractionCompleted
7075
}
7176
var progressDescription: String {
7277
progressFor(items: [self]).localizedAdditionalDescription
7378
}
7479

75-
public init(uuid: UUID, description: String, downloaded: Int64, total: Int64) {
80+
public init(uuid: UUID, description: String, downloaded: Int64, total: Int64, timeRemaining: TimeInterval) {
7681
self.uuid = uuid
7782
self.description = description
7883
self.downloaded = downloaded
7984
self.total = total
85+
self.timeRemaining = timeRemaining
8086
}
8187
}
8288
}

Model/Utilities/DownloadTime.swift

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
import Foundation
16+
import QuartzCore
17+
18+
@MainActor
19+
final class DownloadTime {
20+
21+
/// Only consider these last seconds, when calculating the average speed, hence the remaining time
22+
private let considerLastSeconds: Double
23+
/// sampled data: seconds to % of download
24+
private var samples: [CFTimeInterval: Int64] = [:]
25+
private let totalAmount: Int64
26+
27+
init(considerLastSeconds: Double = 2, total: Int64) {
28+
assert(considerLastSeconds > 0)
29+
assert(total > 0)
30+
self.considerLastSeconds = considerLastSeconds
31+
self.totalAmount = total
32+
}
33+
34+
func update(downloaded: Int64, now: CFTimeInterval = CACurrentMediaTime()) {
35+
filterOutSamples(now: now)
36+
samples[now] = downloaded
37+
}
38+
39+
func remainingTime(now: CFTimeInterval = CACurrentMediaTime()) -> CFTimeInterval {
40+
filterOutSamples(now: now)
41+
guard samples.count > 1, let (latestTime, latestAmount) = latestSample() else {
42+
return .infinity
43+
}
44+
let average = averagePerSecond()
45+
let remainingAmount = totalAmount - latestAmount
46+
let remaingTime = Double(remainingAmount) / average - (now - latestTime)
47+
guard remaingTime > 0 else {
48+
return 0
49+
}
50+
return remaingTime
51+
}
52+
53+
private func filterOutSamples(now: CFTimeInterval) {
54+
samples = samples.filter { time, _ in
55+
time + considerLastSeconds > now
56+
}
57+
}
58+
59+
private func averagePerSecond() -> Double {
60+
var time: CFTimeInterval?
61+
var amount: Int64?
62+
var averages: [Double] = []
63+
for key in samples.keys.sorted() {
64+
let value = samples[key]!
65+
if let time, let amount {
66+
let took = key - time
67+
let downloaded = value - amount
68+
if took > 0, downloaded > 0 {
69+
averages.append(Double(downloaded) / took)
70+
}
71+
}
72+
time = key
73+
amount = value
74+
}
75+
return weightedMean(averages)
76+
}
77+
78+
private func latestSample() -> (CFTimeInterval, Int64)? {
79+
guard let lastTime = samples.keys.sorted().reversed().first,
80+
let lastAmount = samples[lastTime] else {
81+
return nil
82+
}
83+
return (lastTime, lastAmount)
84+
}
85+
86+
private func weightedMean(_ values: [Double]) -> Double {
87+
let weights: [Double] = (0...values.count).map { (Double($0) + 1.0) * 1.2 }
88+
let sum = values.enumerated().reduce(1.0) { partialResult, iterator in
89+
partialResult + (iterator.element * weights[iterator.offset])
90+
}
91+
let sumOfWeights = weights.reduce(1.0) { partialResult, value in
92+
partialResult + value
93+
}
94+
return sum / sumOfWeights
95+
}
96+
}

Views/LiveActivity/ActivityService.swift

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,22 @@ final class ActivityService {
2626
private var activity: Activity<DownloadActivityAttributes>?
2727
private var lastUpdate = CACurrentMediaTime()
2828
private let updateFrequency: Double
29+
private let averageDownloadSpeedFromLastSeconds: Double
2930
private let publisher: @MainActor () -> CurrentValueSubject<[UUID: DownloadState], Never>
3031
private var isStarted: Bool = false
32+
private var downloadTimes: [UUID: DownloadTime] = [:]
3133

3234
init(
3335
publisher: @MainActor @escaping () -> CurrentValueSubject<[UUID: DownloadState], Never> = {
3436
DownloadService.shared.progress.publisher
3537
},
36-
updateFrequency: Double = 1
38+
updateFrequency: Double = 10,
39+
averageDownloadSpeedFromLastSeconds: Double = 30
3740
) {
3841
assert(updateFrequency > 0)
42+
assert(averageDownloadSpeedFromLastSeconds > 0)
3943
self.updateFrequency = updateFrequency
44+
self.averageDownloadSpeedFromLastSeconds = averageDownloadSpeedFromLastSeconds
4045
self.publisher = publisher
4146
}
4247

@@ -51,9 +56,9 @@ final class ActivityService {
5156
}.store(in: &cancellables)
5257
}
5358

54-
private func start(with state: [UUID: DownloadState]) {
59+
private func start(with state: [UUID: DownloadState], downloadTimes: [UUID: CFTimeInterval]) {
5560
Task {
56-
let activityState = await activityState(from: state)
61+
let activityState = await activityState(from: state, downloadTimes: downloadTimes)
5762
let content = ActivityContent(
5863
state: activityState,
5964
staleDate: nil,
@@ -81,9 +86,10 @@ final class ActivityService {
8186
}
8287

8388
private func update(state: [UUID: DownloadState]) {
89+
let downloadTimes: [UUID: CFTimeInterval] = updatedDownloadTimes(from: state)
8490
guard isStarted else {
8591
isStarted = true
86-
start(with: state)
92+
start(with: state, downloadTimes: downloadTimes)
8793
return
8894
}
8995
let now = CACurrentMediaTime()
@@ -92,7 +98,7 @@ final class ActivityService {
9298
}
9399
lastUpdate = now
94100
Task {
95-
let activityState = await activityState(from: state)
101+
let activityState = await activityState(from: state, downloadTimes: downloadTimes)
96102
await activity.update(
97103
ActivityContent<DownloadActivityAttributes.ContentState>(
98104
state: activityState,
@@ -102,11 +108,37 @@ final class ActivityService {
102108
}
103109
}
104110

111+
private func updatedDownloadTimes(from states: [UUID: DownloadState]) -> [UUID: CFTimeInterval] {
112+
// remove the ones we should no longer track
113+
downloadTimes = downloadTimes.filter({ key, _ in
114+
states.keys.contains(key)
115+
})
116+
117+
let now = CACurrentMediaTime()
118+
for (key, state) in states {
119+
let downloadTime: DownloadTime = downloadTimes[key] ?? DownloadTime(
120+
considerLastSeconds: averageDownloadSpeedFromLastSeconds,
121+
total: state.total
122+
)
123+
downloadTime.update(downloaded: state.downloaded, now: now)
124+
downloadTimes[key] = downloadTime
125+
}
126+
return downloadTimes.reduce(into: [:], { partialResult, time in
127+
let (key, value) = time
128+
partialResult.updateValue(value.remainingTime(now: now), forKey: key)
129+
})
130+
}
131+
105132
private func stop() {
106133
Task {
107134
await activity?.end(nil, dismissalPolicy: .immediate)
108135
activity = nil
109136
isStarted = false
137+
downloadTimes = [:]
138+
// make sure we clean up orphan activities of the same type as well
139+
for activity in Activity<DownloadActivityAttributes>.activities {
140+
await activity.end(nil, dismissalPolicy: .immediate)
141+
}
110142
}
111143
}
112144

@@ -122,7 +154,10 @@ final class ActivityService {
122154
}
123155
}
124156

125-
private func activityState(from state: [UUID: DownloadState]) async -> DownloadActivityAttributes.ContentState {
157+
private func activityState(
158+
from state: [UUID: DownloadState],
159+
downloadTimes: [UUID: CFTimeInterval]
160+
) async -> DownloadActivityAttributes.ContentState {
126161
var titles: [UUID: String] = [:]
127162
for key in state.keys {
128163
titles[key] = await getDownloadTitle(for: key)
@@ -135,7 +170,8 @@ final class ActivityService {
135170
uuid: key,
136171
description: titles[key] ?? key.uuidString,
137172
downloaded: download.downloaded,
138-
total: download.total)
173+
total: download.total,
174+
timeRemaining: downloadTimes[key] ?? 0)
139175
})
140176
}
141177
}

Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
{
22
"colors" : [
33
{
4+
"color" : {
5+
"color-space" : "extended-gray",
6+
"components" : {
7+
"alpha" : "0.360",
8+
"white" : "1.000"
9+
}
10+
},
411
"idiom" : "universal"
512
}
613
],

Widgets/DownloadsLiveActivity.swift

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import WidgetKit
1818
import SwiftUI
1919

2020
struct DownloadsLiveActivity: Widget {
21+
// @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16
22+
// https://developer.apple.com/forums/thread/763594
23+
2124
var body: some WidgetConfiguration {
2225
ActivityConfiguration(for: DownloadActivityAttributes.self) { context in
2326
// Lock screen/banner UI goes here
@@ -31,11 +34,23 @@ struct DownloadsLiveActivity: Widget {
3134
.multilineTextAlignment(.leading)
3235
.font(.headline)
3336
.bold()
34-
Text(context.state.progressDescription)
37+
HStack {
38+
Text(
39+
timerInterval: Date.now...Date(
40+
timeInterval: context.state.estimatedTimeLeft,
41+
since: .now
42+
)
43+
)
3544
.lineLimit(1)
3645
.multilineTextAlignment(.leading)
3746
.font(.caption)
3847
.tint(.secondary)
48+
Text(context.state.progressDescription)
49+
.lineLimit(1)
50+
.multilineTextAlignment(.leading)
51+
.font(.caption)
52+
.tint(.secondary)
53+
}
3954
}
4055
Spacer()
4156
ProgressView(value: context.state.progress)
@@ -44,6 +59,7 @@ struct DownloadsLiveActivity: Widget {
4459
.padding()
4560
}
4661
}
62+
.modifier(WidgetBackgroundModifier())
4763

4864
} dynamicIsland: { context in
4965
DynamicIsland {
@@ -87,7 +103,7 @@ struct DownloadsLiveActivity: Widget {
87103
}
88104
.widgetURL(URL(string: "https://www.kiwix.org"))
89105
.keylineTint(Color.red)
90-
}
106+
}.containerBackgroundRemovable()
91107
}
92108
}
93109

@@ -106,13 +122,15 @@ extension DownloadActivityAttributes.ContentState {
106122
uuid: UUID(),
107123
description: "First item",
108124
downloaded: 128,
109-
total: 256
125+
total: 256,
126+
timeRemaining: 3
110127
),
111128
DownloadActivityAttributes.DownloadItem(
112129
uuid: UUID(),
113130
description: "2nd item",
114131
downloaded: 90,
115-
total: 124
132+
total: 124,
133+
timeRemaining: 2
116134
)
117135
]
118136
)
@@ -126,13 +144,15 @@ extension DownloadActivityAttributes.ContentState {
126144
uuid: UUID(),
127145
description: "First item",
128146
downloaded: 256,
129-
total: 256
147+
total: 256,
148+
timeRemaining: 0
130149
),
131150
DownloadActivityAttributes.DownloadItem(
132151
uuid: UUID(),
133152
description: "2nd item",
134153
downloaded: 110,
135-
total: 124
154+
total: 124,
155+
timeRemaining: 2
136156
)
137157
]
138158
)

Widgets/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>CFBundleShortVersionString</key>
6+
<string>$(MARKETING_VERSION)</string>
57
<key>CFBundleDisplayName</key>
68
<string>$(PRODUCT_NAME)</string>
79
<key>NSExtension</key>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 SwiftUI
17+
18+
struct WidgetBackgroundModifier: ViewModifier {
19+
20+
func body(content: Content) -> some View {
21+
if #available(iOSApplicationExtension 17.0, *) {
22+
content.containerBackground(for: .widget) {
23+
Color.widgetBackground
24+
}
25+
.activityBackgroundTint(Color("WidgetBackground"))
26+
} else {
27+
content
28+
.activityBackgroundTint(Color("WidgetBackground"))
29+
}
30+
}
31+
}

0 commit comments

Comments
 (0)