Skip to content

Commit

Permalink
Merge pull request #1126 from kiwix/live-activities-update
Browse files Browse the repository at this point in the history
Use progress bar with timer updates for live activities
  • Loading branch information
kelson42 authored Mar 1, 2025
2 parents 6d17ce0 + 9b65e7f commit 83518f2
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 163 deletions.
21 changes: 19 additions & 2 deletions Common/DownloadActivityAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -70,19 +78,28 @@ public struct DownloadActivityAttributes: ActivityAttributes {
let downloaded: Int64
let total: Int64
let timeRemaining: TimeInterval
let isPaused: Bool
var progress: Double {
progressFor(items: [self]).fractionCompleted
}
var progressDescription: String {
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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions Model/Downloads/DownloadState.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
73 changes: 73 additions & 0 deletions Model/Downloads/DownloadTasksPublisher.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
3 changes: 1 addition & 2 deletions ViewModel/BrowserViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
43 changes: 32 additions & 11 deletions Views/LiveActivity/ActivityService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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
Loading

0 comments on commit 83518f2

Please sign in to comment.