Merge pull request #1126 from kiwix/live-activities-update

Use progress bar with timer updates for live activities
This commit is contained in:
Kelson 2025-03-01 21:29:52 +01:00 committed by GitHub
commit 83518f2640
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 259 additions and 163 deletions

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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")
}
}
}

View File

@ -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)
}

View File

@ -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<DownloadActivityAttributes.ContentState>(
state: activityState,
staleDate: nil
)
let newContent = ActivityContent<DownloadActivityAttributes.ContentState>(
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

View File

@ -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<Date>
) -> 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
)
]
)

View File

@ -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)
}
}
}