Skip to content

Commit

Permalink
Merge pull request #1114 from kiwix/1107-improve-live-activities
Browse files Browse the repository at this point in the history
1107 improve live activities
  • Loading branch information
kelson42 authored Feb 16, 2025
2 parents 2382a35 + ce266fb commit 594c2d5
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 14 deletions.
8 changes: 7 additions & 1 deletion Common/DownloadActivityAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -65,18 +69,20 @@ public struct DownloadActivityAttributes: ActivityAttributes {
let description: String
let downloaded: Int64
let total: Int64
let timeRemaining: TimeInterval
var progress: Double {
progressFor(items: [self]).fractionCompleted
}
var progressDescription: String {
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
}
}
}
Expand Down
96 changes: 96 additions & 0 deletions Model/Utilities/DownloadTime.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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 weightedMean(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 weightedMean(_ values: [Double]) -> Double {
let weights: [Double] = (0...values.count).map { (Double($0) + 1.0) * 1.2 }
let sum = values.enumerated().reduce(1.0) { partialResult, iterator in
partialResult + (iterator.element * weights[iterator.offset])
}
let sumOfWeights = weights.reduce(1.0) { partialResult, value in
partialResult + value
}
return sum / sumOfWeights
}
}
50 changes: 43 additions & 7 deletions Views/LiveActivity/ActivityService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,22 @@ final class ActivityService {
private var activity: Activity<DownloadActivityAttributes>?
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 = 10,
averageDownloadSpeedFromLastSeconds: Double = 30
) {
assert(updateFrequency > 0)
assert(averageDownloadSpeedFromLastSeconds > 0)
self.updateFrequency = updateFrequency
self.averageDownloadSpeedFromLastSeconds = averageDownloadSpeedFromLastSeconds
self.publisher = publisher
}

Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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<DownloadActivityAttributes.ContentState>(
state: activityState,
Expand All @@ -102,11 +108,37 @@ 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 {
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 = [:]
// make sure we clean up orphan activities of the same type as well
for activity in Activity<DownloadActivityAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}

Expand All @@ -122,7 +154,10 @@ 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)
Expand All @@ -135,7 +170,8 @@ final class ActivityService {
uuid: key,
description: titles[key] ?? key.uuidString,
downloaded: download.downloaded,
total: download.total)
total: download.total,
timeRemaining: downloadTimes[key] ?? 0)
})
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
{
"colors" : [
{
"color" : {
"color-space" : "extended-gray",
"components" : {
"alpha" : "0.360",
"white" : "1.000"
}
},
"idiom" : "universal"
}
],
Expand Down
32 changes: 26 additions & 6 deletions Widgets/DownloadsLiveActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import WidgetKit
import SwiftUI

struct DownloadsLiveActivity: Widget {
// @Environment(\.isActivityFullscreen) var isActivityFullScreen has a bug, when min iOS is 16
// https://developer.apple.com/forums/thread/763594

var body: some WidgetConfiguration {
ActivityConfiguration(for: DownloadActivityAttributes.self) { context in
// Lock screen/banner UI goes here
Expand All @@ -31,11 +34,23 @@ struct DownloadsLiveActivity: Widget {
.multilineTextAlignment(.leading)
.font(.headline)
.bold()
Text(context.state.progressDescription)
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)
Expand All @@ -44,6 +59,7 @@ struct DownloadsLiveActivity: Widget {
.padding()
}
}
.modifier(WidgetBackgroundModifier())

} dynamicIsland: { context in
DynamicIsland {
Expand Down Expand Up @@ -87,7 +103,7 @@ struct DownloadsLiveActivity: Widget {
}
.widgetURL(URL(string: "https://www.kiwix.org"))
.keylineTint(Color.red)
}
}.containerBackgroundRemovable()
}
}

Expand All @@ -106,13 +122,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
)
]
)
Expand All @@ -126,13 +144,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
)
]
)
Expand Down
2 changes: 2 additions & 0 deletions Widgets/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleDisplayName</key>
<string>$(PRODUCT_NAME)</string>
<key>NSExtension</key>
Expand Down
31 changes: 31 additions & 0 deletions Widgets/WidgetBackgroundModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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 SwiftUI

struct WidgetBackgroundModifier: ViewModifier {

func body(content: Content) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
content.containerBackground(for: .widget) {
Color.widgetBackground
}
.activityBackgroundTint(Color("WidgetBackground"))
} else {
content
.activityBackgroundTint(Color("WidgetBackground"))
}
}
}

0 comments on commit 594c2d5

Please sign in to comment.