mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 16:40:32 +00:00
1084 lines
25 KiB
Swift
1084 lines
25 KiB
Swift
import ActivityKit
|
|
import Foundation
|
|
import Libgopeed
|
|
import UIKit
|
|
|
|
@objcMembers
|
|
final class GopeedLiveActivityManager: NSObject {
|
|
|
|
static let shared = GopeedLiveActivityManager()
|
|
|
|
// Gopeed emits progress approximately every 350 ms.
|
|
// There is no reason to hit ActivityKit that frequently.
|
|
private let minimumUpdateInterval: TimeInterval = 2.0
|
|
|
|
private let stateLock = NSLock()
|
|
|
|
private var lastUpdateTime: [String: Date] = [:]
|
|
private var suppressedTaskIDs = Set<String>()
|
|
private var taskGenerations: [String: UUID] = [:]
|
|
private var activityCreationInProgress:
|
|
[String: UUID] = [:]
|
|
|
|
private override init() {
|
|
super.init()
|
|
}
|
|
|
|
|
|
// MARK: - BGCPT coordination
|
|
|
|
func setTaskSuppressed(
|
|
_ suppressed: Bool,
|
|
taskID: String
|
|
) {
|
|
stateLock.lock()
|
|
|
|
if suppressed {
|
|
suppressedTaskIDs.insert(taskID)
|
|
taskGenerations.removeValue(
|
|
forKey: taskID
|
|
)
|
|
lastUpdateTime.removeValue(
|
|
forKey: taskID
|
|
)
|
|
} else {
|
|
suppressedTaskIDs.remove(taskID)
|
|
}
|
|
|
|
stateLock.unlock()
|
|
|
|
if suppressed {
|
|
guard #available(iOS 16.2, *) else {
|
|
return
|
|
}
|
|
|
|
Task { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
// Suppression may have been removed again if BGCPT
|
|
// submission failed before this async cleanup runs.
|
|
guard self.isTaskSuppressed(taskID) else {
|
|
return
|
|
}
|
|
|
|
await self.removeAllActivities(
|
|
taskID: taskID,
|
|
reason: "suppressed by continued processing"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isTaskSuppressed(
|
|
_ taskID: String
|
|
) -> Bool {
|
|
stateLock.lock()
|
|
let suppressed =
|
|
suppressedTaskIDs.contains(taskID)
|
|
stateLock.unlock()
|
|
|
|
return suppressed
|
|
}
|
|
|
|
private func startGeneration(
|
|
taskID: String
|
|
) -> UUID {
|
|
let generation = UUID()
|
|
|
|
stateLock.lock()
|
|
taskGenerations[taskID] = generation
|
|
lastUpdateTime.removeValue(
|
|
forKey: taskID
|
|
)
|
|
stateLock.unlock()
|
|
|
|
return generation
|
|
}
|
|
|
|
private func currentGeneration(
|
|
taskID: String
|
|
) -> UUID? {
|
|
stateLock.lock()
|
|
let generation =
|
|
taskGenerations[taskID]
|
|
stateLock.unlock()
|
|
|
|
return generation
|
|
}
|
|
|
|
private func generationForProgress(
|
|
taskID: String
|
|
) -> UUID? {
|
|
stateLock.lock()
|
|
defer { stateLock.unlock() }
|
|
|
|
guard
|
|
!suppressedTaskIDs.contains(taskID)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
if let generation =
|
|
taskGenerations[taskID] {
|
|
return generation
|
|
}
|
|
|
|
// Progress may be the first event after Continued Processing
|
|
// releases ownership. Establish a fresh lifecycle generation.
|
|
// refreshActivity still verifies that Gopeed reports the task
|
|
// as running before it can create a Live Activity.
|
|
let generation = UUID()
|
|
|
|
taskGenerations[taskID] =
|
|
generation
|
|
|
|
lastUpdateTime.removeValue(
|
|
forKey: taskID
|
|
)
|
|
|
|
return generation
|
|
}
|
|
|
|
@discardableResult
|
|
private func invalidateGeneration(
|
|
taskID: String
|
|
) -> UUID? {
|
|
stateLock.lock()
|
|
let generation =
|
|
taskGenerations.removeValue(
|
|
forKey: taskID
|
|
)
|
|
lastUpdateTime.removeValue(
|
|
forKey: taskID
|
|
)
|
|
stateLock.unlock()
|
|
|
|
return generation
|
|
}
|
|
|
|
private func isCurrentGeneration(
|
|
taskID: String,
|
|
generation: UUID
|
|
) -> Bool {
|
|
stateLock.lock()
|
|
let isCurrent =
|
|
taskGenerations[taskID]
|
|
== generation
|
|
stateLock.unlock()
|
|
|
|
return isCurrent
|
|
}
|
|
|
|
private func beginActivityCreation(
|
|
taskID: String,
|
|
generation: UUID
|
|
) -> Bool {
|
|
stateLock.lock()
|
|
defer { stateLock.unlock() }
|
|
|
|
guard
|
|
!suppressedTaskIDs.contains(taskID),
|
|
taskGenerations[taskID] == generation
|
|
else {
|
|
return false
|
|
}
|
|
|
|
if activityCreationInProgress[taskID]
|
|
== generation {
|
|
return false
|
|
}
|
|
|
|
activityCreationInProgress[taskID] =
|
|
generation
|
|
|
|
return true
|
|
}
|
|
|
|
private func endActivityCreation(
|
|
taskID: String,
|
|
generation: UUID
|
|
) {
|
|
stateLock.lock()
|
|
|
|
if activityCreationInProgress[taskID]
|
|
== generation {
|
|
activityCreationInProgress
|
|
.removeValue(
|
|
forKey: taskID
|
|
)
|
|
}
|
|
|
|
stateLock.unlock()
|
|
}
|
|
|
|
private func shouldHandleProgress(
|
|
taskID: String,
|
|
generation: UUID,
|
|
now: Date
|
|
) -> Bool {
|
|
stateLock.lock()
|
|
defer { stateLock.unlock() }
|
|
|
|
guard
|
|
!suppressedTaskIDs.contains(taskID),
|
|
taskGenerations[taskID]
|
|
== generation
|
|
else {
|
|
return false
|
|
}
|
|
|
|
if let previous = lastUpdateTime[taskID],
|
|
now.timeIntervalSince(previous)
|
|
< minimumUpdateInterval {
|
|
return false
|
|
}
|
|
|
|
lastUpdateTime[taskID] = now
|
|
return true
|
|
}
|
|
|
|
// MARK: - Gopeed event entry point
|
|
|
|
@objc
|
|
func handleTaskEventPayload(_ payload: String) {
|
|
|
|
guard #available(iOS 16.2, *) else {
|
|
return
|
|
}
|
|
|
|
guard
|
|
let data = payload.data(using: .utf8),
|
|
let json = try? JSONSerialization.jsonObject(with: data)
|
|
as? [String: Any],
|
|
let type = json["type"] as? String,
|
|
let taskID = json["taskId"] as? String
|
|
else {
|
|
print("LiveActivity: invalid task event payload")
|
|
return
|
|
}
|
|
|
|
guard !isTaskSuppressed(taskID) else {
|
|
return
|
|
}
|
|
|
|
let name = json["name"] as? String ?? "Download"
|
|
|
|
switch type {
|
|
|
|
case "task.start":
|
|
let generation =
|
|
startGeneration(
|
|
taskID: taskID
|
|
)
|
|
|
|
Task {
|
|
await refreshActivity(
|
|
taskID: taskID,
|
|
name: name,
|
|
generation: generation,
|
|
allowStart: true
|
|
)
|
|
}
|
|
|
|
case "task.progress":
|
|
guard
|
|
let generation =
|
|
generationForProgress(
|
|
taskID: taskID
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
handleProgress(
|
|
taskID: taskID,
|
|
name: name,
|
|
generation: generation
|
|
)
|
|
|
|
case "task.pause":
|
|
if let generation =
|
|
invalidateGeneration(
|
|
taskID: taskID
|
|
) {
|
|
Task {
|
|
await removeActivity(
|
|
taskID: taskID,
|
|
generation: generation,
|
|
reason: "paused"
|
|
)
|
|
}
|
|
}
|
|
|
|
case "task.done":
|
|
if let generation =
|
|
invalidateGeneration(
|
|
taskID: taskID
|
|
) {
|
|
Task {
|
|
await finishActivity(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
}
|
|
}
|
|
|
|
case "task.error":
|
|
let error =
|
|
json["error"] as? String
|
|
?? "Download failed"
|
|
|
|
if let generation =
|
|
invalidateGeneration(
|
|
taskID: taskID
|
|
) {
|
|
Task {
|
|
await failActivity(
|
|
taskID: taskID,
|
|
generation: generation,
|
|
error: error
|
|
)
|
|
}
|
|
}
|
|
|
|
case "task.delete":
|
|
if let generation =
|
|
invalidateGeneration(
|
|
taskID: taskID
|
|
) {
|
|
Task {
|
|
await removeActivity(
|
|
taskID: taskID,
|
|
generation: generation,
|
|
reason: "removed"
|
|
)
|
|
}
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Progress throttling
|
|
|
|
@available(iOS 16.2, *)
|
|
private func handleProgress(
|
|
taskID: String,
|
|
name: String,
|
|
generation: UUID
|
|
) {
|
|
let now = Date()
|
|
|
|
guard shouldHandleProgress(
|
|
taskID: taskID,
|
|
generation: generation,
|
|
now: now
|
|
) else {
|
|
return
|
|
}
|
|
|
|
Task {
|
|
await refreshActivity(
|
|
taskID: taskID,
|
|
name: name,
|
|
generation: generation,
|
|
allowStart: true
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Async Gopeed invocation
|
|
|
|
private func invokeGopeed(
|
|
method: String,
|
|
path: String,
|
|
query: String = "",
|
|
body: String = ""
|
|
) async -> String? {
|
|
|
|
await withCheckedContinuation {
|
|
continuation in
|
|
|
|
GopeedInvokeAsyncNative(
|
|
method,
|
|
path,
|
|
query,
|
|
body
|
|
) { success, payload in
|
|
|
|
continuation.resume(
|
|
returning:
|
|
success
|
|
? payload
|
|
: nil
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Gopeed runtime status
|
|
|
|
private struct RuntimeStatus {
|
|
let status: String
|
|
let downloaded: Int64
|
|
let total: Int64
|
|
let speed: Int64
|
|
}
|
|
|
|
private func getRuntimeStatus(
|
|
taskID: String
|
|
) async -> RuntimeStatus? {
|
|
|
|
guard
|
|
let response =
|
|
await invokeGopeed(
|
|
method: "GET",
|
|
path:
|
|
"/api/v1/tasks/\(taskID)/status"
|
|
)
|
|
else {
|
|
print(
|
|
"LiveActivity: status request failed for \(taskID)"
|
|
)
|
|
return nil
|
|
}
|
|
|
|
guard
|
|
let data = response.data(using: .utf8),
|
|
let root =
|
|
try? JSONSerialization.jsonObject(
|
|
with: data
|
|
) as? [String: Any]
|
|
else {
|
|
print(
|
|
"LiveActivity: failed to decode status for \(taskID)"
|
|
)
|
|
return nil
|
|
}
|
|
|
|
let code =
|
|
(root["code"] as? NSNumber)?.intValue
|
|
?? -1
|
|
|
|
guard code == 0 else {
|
|
let message =
|
|
root["msg"] as? String
|
|
?? "Unknown Gopeed API error"
|
|
|
|
print(
|
|
"LiveActivity: Gopeed status error:",
|
|
message
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
guard
|
|
let body =
|
|
root["data"] as? [String: Any]
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return RuntimeStatus(
|
|
status:
|
|
body["status"] as? String ?? "",
|
|
downloaded:
|
|
(body["downloaded"] as? NSNumber)?
|
|
.int64Value ?? 0,
|
|
total:
|
|
(body["total"] as? NSNumber)?
|
|
.int64Value ?? 0,
|
|
speed:
|
|
(body["speed"] as? NSNumber)?
|
|
.int64Value ?? 0
|
|
)
|
|
}
|
|
|
|
// MARK: - Build Live Activity state
|
|
|
|
@available(iOS 16.2, *)
|
|
private func makeState(
|
|
from runtime: RuntimeStatus
|
|
) -> GopeedDownloadAttributes.ContentState {
|
|
|
|
let now = Date()
|
|
|
|
let total =
|
|
max(runtime.total, 0)
|
|
|
|
let downloaded: Int64
|
|
|
|
if total > 0 {
|
|
downloaded =
|
|
min(
|
|
max(runtime.downloaded, 0),
|
|
total
|
|
)
|
|
} else {
|
|
downloaded =
|
|
max(runtime.downloaded, 0)
|
|
}
|
|
|
|
let progress: Double
|
|
|
|
if total > 0 {
|
|
progress =
|
|
min(
|
|
max(
|
|
Double(downloaded)
|
|
/ Double(total),
|
|
0
|
|
),
|
|
1
|
|
)
|
|
} else {
|
|
progress = 0
|
|
}
|
|
|
|
// If we don't have enough information for an ETA,
|
|
// use the real static progress bar.
|
|
guard
|
|
runtime.status == "running",
|
|
total > 0,
|
|
runtime.speed > 0,
|
|
downloaded < total,
|
|
progress > 0,
|
|
progress < 1
|
|
else {
|
|
return GopeedDownloadAttributes
|
|
.ContentState(
|
|
progress: progress,
|
|
downloaded: downloaded,
|
|
total: total,
|
|
speed: runtime.speed,
|
|
status: runtime.status,
|
|
estimatedStart: now,
|
|
estimatedEnd:
|
|
now.addingTimeInterval(1),
|
|
usesEstimatedProgress: false
|
|
)
|
|
}
|
|
|
|
let remainingBytes =
|
|
Double(total - downloaded)
|
|
|
|
var remainingSeconds =
|
|
remainingBytes
|
|
/ Double(runtime.speed)
|
|
|
|
// Don't allow corrupt speed values to create
|
|
// ridiculous date ranges.
|
|
remainingSeconds =
|
|
min(
|
|
max(remainingSeconds, 1),
|
|
86_400
|
|
)
|
|
|
|
let remainingFraction =
|
|
max(1.0 - progress, 0.001)
|
|
|
|
let estimatedTotalDuration =
|
|
remainingSeconds
|
|
/ remainingFraction
|
|
|
|
let elapsedEstimate =
|
|
estimatedTotalDuration
|
|
* progress
|
|
|
|
let estimatedStart =
|
|
now.addingTimeInterval(
|
|
-elapsedEstimate
|
|
)
|
|
|
|
let estimatedEnd =
|
|
now.addingTimeInterval(
|
|
remainingSeconds
|
|
)
|
|
|
|
return GopeedDownloadAttributes
|
|
.ContentState(
|
|
progress: progress,
|
|
downloaded: downloaded,
|
|
total: total,
|
|
speed: runtime.speed,
|
|
status: runtime.status,
|
|
estimatedStart: estimatedStart,
|
|
estimatedEnd: estimatedEnd,
|
|
usesEstimatedProgress: true
|
|
)
|
|
}
|
|
|
|
|
|
// MARK: - Find existing Activity
|
|
|
|
@available(iOS 16.2, *)
|
|
private func findActivities(
|
|
taskID: String
|
|
) -> [Activity<GopeedDownloadAttributes>] {
|
|
return Activity<
|
|
GopeedDownloadAttributes
|
|
>
|
|
.activities
|
|
.filter {
|
|
$0.attributes.taskId == taskID
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.2, *)
|
|
private func findActivities(
|
|
taskID: String,
|
|
generation: UUID
|
|
) -> [Activity<GopeedDownloadAttributes>] {
|
|
let generationID =
|
|
generation.uuidString
|
|
|
|
return findActivities(
|
|
taskID: taskID
|
|
)
|
|
.filter {
|
|
$0.attributes.generation
|
|
== generationID
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.2, *)
|
|
private func collapseDuplicateActivities(
|
|
_ activities: [Activity<GopeedDownloadAttributes>],
|
|
content: ActivityContent<GopeedDownloadAttributes.ContentState>
|
|
) async {
|
|
guard let primary = activities.first else {
|
|
return
|
|
}
|
|
|
|
await primary.update(content)
|
|
|
|
if activities.count > 1 {
|
|
for duplicate in activities.dropFirst() {
|
|
await duplicate.end(
|
|
nil,
|
|
dismissalPolicy: .immediate
|
|
)
|
|
}
|
|
|
|
print(
|
|
"LiveActivity: removed",
|
|
activities.count - 1,
|
|
"duplicate activity/activities for",
|
|
primary.attributes.taskId
|
|
)
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.2, *)
|
|
private func removeStaleActivities(
|
|
taskID: String,
|
|
keeping generation: UUID
|
|
) async {
|
|
let generationID =
|
|
generation.uuidString
|
|
|
|
let stale =
|
|
findActivities(taskID: taskID)
|
|
.filter {
|
|
$0.attributes.generation
|
|
!= generationID
|
|
}
|
|
|
|
for activity in stale {
|
|
await activity.end(
|
|
nil,
|
|
dismissalPolicy: .immediate
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Start / update Activity
|
|
|
|
@available(iOS 16.2, *)
|
|
private func refreshActivity(
|
|
taskID: String,
|
|
name: String,
|
|
generation: UUID,
|
|
allowStart: Bool
|
|
) async {
|
|
guard
|
|
!isTaskSuppressed(taskID),
|
|
isCurrentGeneration(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
guard
|
|
let runtime =
|
|
await getRuntimeStatus(
|
|
taskID: taskID
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
guard
|
|
!isTaskSuppressed(taskID),
|
|
isCurrentGeneration(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
let state =
|
|
makeState(from: runtime)
|
|
|
|
let content =
|
|
ActivityContent(
|
|
state: state,
|
|
staleDate: nil
|
|
)
|
|
|
|
let existing =
|
|
findActivities(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
|
|
if !existing.isEmpty {
|
|
await collapseDuplicateActivities(
|
|
existing,
|
|
content: content
|
|
)
|
|
return
|
|
}
|
|
|
|
guard
|
|
allowStart,
|
|
runtime.status == "running"
|
|
else {
|
|
return
|
|
}
|
|
|
|
guard
|
|
ActivityAuthorizationInfo()
|
|
.areActivitiesEnabled
|
|
else {
|
|
print(
|
|
"LiveActivity: activities disabled"
|
|
)
|
|
return
|
|
}
|
|
|
|
let appIsActive =
|
|
await MainActor.run {
|
|
UIApplication.shared
|
|
.applicationState == .active
|
|
}
|
|
|
|
guard
|
|
appIsActive,
|
|
!isTaskSuppressed(taskID),
|
|
isCurrentGeneration(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
guard beginActivityCreation(
|
|
taskID: taskID,
|
|
generation: generation
|
|
) else {
|
|
return
|
|
}
|
|
|
|
defer {
|
|
endActivityCreation(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
}
|
|
|
|
guard
|
|
!isTaskSuppressed(taskID),
|
|
isCurrentGeneration(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
await removeStaleActivities(
|
|
taskID: taskID,
|
|
keeping: generation
|
|
)
|
|
|
|
guard
|
|
!isTaskSuppressed(taskID),
|
|
isCurrentGeneration(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
let recheck =
|
|
findActivities(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
|
|
if !recheck.isEmpty {
|
|
await collapseDuplicateActivities(
|
|
recheck,
|
|
content: content
|
|
)
|
|
return
|
|
}
|
|
|
|
let attributes =
|
|
GopeedDownloadAttributes(
|
|
taskId: taskID,
|
|
fileName: name,
|
|
generation:
|
|
generation.uuidString
|
|
)
|
|
|
|
do {
|
|
let activity =
|
|
try Activity.request(
|
|
attributes: attributes,
|
|
content: content,
|
|
pushType: nil
|
|
)
|
|
|
|
if
|
|
isTaskSuppressed(taskID)
|
|
|| !isCurrentGeneration(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
{
|
|
await activity.end(
|
|
nil,
|
|
dismissalPolicy: .immediate
|
|
)
|
|
return
|
|
}
|
|
|
|
print(
|
|
"LiveActivity: started",
|
|
activity.id,
|
|
taskID
|
|
)
|
|
} catch {
|
|
print(
|
|
"LiveActivity: start failed:",
|
|
error
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Complete Activity
|
|
|
|
@available(iOS 16.2, *)
|
|
private func finishActivity(
|
|
taskID: String,
|
|
generation: UUID
|
|
) async {
|
|
let activities =
|
|
findActivities(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
|
|
guard !activities.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let now = Date()
|
|
|
|
for activity in activities {
|
|
let oldState =
|
|
activity.content.state
|
|
|
|
let finalTotal =
|
|
max(
|
|
oldState.total,
|
|
oldState.downloaded
|
|
)
|
|
|
|
let finalState =
|
|
GopeedDownloadAttributes
|
|
.ContentState(
|
|
progress: 1.0,
|
|
downloaded: finalTotal,
|
|
total: finalTotal,
|
|
speed: 0,
|
|
status: "done",
|
|
estimatedStart: now,
|
|
estimatedEnd:
|
|
now.addingTimeInterval(1),
|
|
usesEstimatedProgress: false
|
|
)
|
|
|
|
let finalContent =
|
|
ActivityContent(
|
|
state: finalState,
|
|
staleDate: nil
|
|
)
|
|
|
|
await activity.end(
|
|
finalContent,
|
|
dismissalPolicy:
|
|
.after(
|
|
Date()
|
|
.addingTimeInterval(15)
|
|
)
|
|
)
|
|
}
|
|
|
|
print(
|
|
"LiveActivity: completed",
|
|
taskID
|
|
)
|
|
}
|
|
|
|
|
|
// MARK: - Error Activity
|
|
|
|
@available(iOS 16.2, *)
|
|
private func failActivity(
|
|
taskID: String,
|
|
generation: UUID,
|
|
error: String
|
|
) async {
|
|
let activities =
|
|
findActivities(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
|
|
guard !activities.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let now = Date()
|
|
|
|
for activity in activities {
|
|
let old =
|
|
activity.content.state
|
|
|
|
let failedState =
|
|
GopeedDownloadAttributes
|
|
.ContentState(
|
|
progress: old.progress,
|
|
downloaded: old.downloaded,
|
|
total: old.total,
|
|
speed: 0,
|
|
status: "error",
|
|
estimatedStart: now,
|
|
estimatedEnd:
|
|
now.addingTimeInterval(1),
|
|
usesEstimatedProgress: false
|
|
)
|
|
|
|
let content =
|
|
ActivityContent(
|
|
state: failedState,
|
|
staleDate: nil
|
|
)
|
|
|
|
await activity.end(
|
|
content,
|
|
dismissalPolicy:
|
|
.after(
|
|
Date()
|
|
.addingTimeInterval(30)
|
|
)
|
|
)
|
|
}
|
|
|
|
print(
|
|
"LiveActivity: task failed:",
|
|
taskID,
|
|
error
|
|
)
|
|
}
|
|
|
|
|
|
// MARK: - Delete / suppression cleanup
|
|
|
|
@available(iOS 16.2, *)
|
|
private func removeActivity(
|
|
taskID: String,
|
|
generation: UUID,
|
|
reason: String
|
|
) async {
|
|
let activities =
|
|
findActivities(
|
|
taskID: taskID,
|
|
generation: generation
|
|
)
|
|
|
|
for activity in activities {
|
|
await activity.end(
|
|
nil,
|
|
dismissalPolicy: .immediate
|
|
)
|
|
}
|
|
|
|
if !activities.isEmpty {
|
|
print(
|
|
"LiveActivity:",
|
|
reason,
|
|
taskID,
|
|
"count:",
|
|
activities.count
|
|
)
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.2, *)
|
|
private func removeAllActivities(
|
|
taskID: String,
|
|
reason: String
|
|
) async {
|
|
let activities =
|
|
findActivities(
|
|
taskID: taskID
|
|
)
|
|
|
|
for activity in activities {
|
|
await activity.end(
|
|
nil,
|
|
dismissalPolicy: .immediate
|
|
)
|
|
}
|
|
|
|
if !activities.isEmpty {
|
|
print(
|
|
"LiveActivity:",
|
|
reason,
|
|
taskID,
|
|
"count:",
|
|
activities.count
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|