Neues Icon, parallele Downloads
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
/// Shared between the main app (to drive the activity lifecycle) and the
|
||||
/// DownloadWidgetExtension (to render the Lock Screen / Dynamic Island UI).
|
||||
/// Both targets must compile this exact definition — keep them in sync.
|
||||
struct DownloadActivityAttributes: ActivityAttributes {
|
||||
/// Item title set once at activity start; never changes during the download.
|
||||
let itemTitle: String
|
||||
|
||||
struct ContentState: Codable, Hashable {
|
||||
/// Overall fraction across all tracks (0.0 – 1.0).
|
||||
var progress: Double
|
||||
/// Total bytes written to disk so far, shown as "X MB downloaded".
|
||||
var bytesDownloaded: Int64
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ABS", category: "LiveActivity")
|
||||
|
||||
/// Manages one `Activity<DownloadActivityAttributes>` per active download key.
|
||||
/// All methods must be called from the MainActor (same isolation as DownloadManager).
|
||||
@MainActor
|
||||
final class DownloadActivityManager {
|
||||
|
||||
// Keyed by downloadKey (matches DownloadManager's key scheme).
|
||||
private var activities: [String: Activity<DownloadActivityAttributes>] = [:]
|
||||
|
||||
/// Requests a new Live Activity for a download that is starting.
|
||||
/// Logs the reason to the console (visible in Xcode) if the request fails.
|
||||
func startActivity(downloadKey: String, itemTitle: String) {
|
||||
let authInfo = ActivityAuthorizationInfo()
|
||||
guard authInfo.areActivitiesEnabled else {
|
||||
logger.warning("Live Activities disabled (areActivitiesEnabled=false) — skipping for '\(itemTitle)'")
|
||||
return
|
||||
}
|
||||
|
||||
let attributes = DownloadActivityAttributes(itemTitle: itemTitle)
|
||||
let initial = DownloadActivityAttributes.ContentState(
|
||||
progress: 0,
|
||||
bytesDownloaded: 0
|
||||
)
|
||||
let content = ActivityContent(state: initial, staleDate: nil)
|
||||
|
||||
do {
|
||||
let activity = try Activity.request(attributes: attributes, content: content)
|
||||
activities[downloadKey] = activity
|
||||
logger.info("Live Activity started: id=\(activity.id) title='\(itemTitle)'")
|
||||
} catch {
|
||||
// Most common reason: no widget extension is installed/embedded that
|
||||
// provides an ActivityConfiguration for DownloadActivityAttributes.
|
||||
logger.error("Activity.request() failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes an incremental progress update to the running Live Activity.
|
||||
func updateActivity(downloadKey: String, progress: Double, bytesDownloaded: Int64) {
|
||||
guard let activity = activities[downloadKey] else { return }
|
||||
let state = DownloadActivityAttributes.ContentState(
|
||||
progress: progress,
|
||||
bytesDownloaded: bytesDownloaded
|
||||
)
|
||||
let content = ActivityContent(state: state, staleDate: nil)
|
||||
// activity.update() is async; fire-and-forget is intentional — a skipped
|
||||
// frame doesn't hurt the download and avoids blocking the download loop.
|
||||
Task { await activity.update(content) }
|
||||
}
|
||||
|
||||
/// Ends the Live Activity with `.immediate` dismissal so the Lock Screen
|
||||
/// badge disappears right away instead of lingering for the default 4 hours.
|
||||
func endActivity(downloadKey: String) {
|
||||
guard let activity = activities.removeValue(forKey: downloadKey) else { return }
|
||||
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Manages local UNUserNotificationCenter notifications for download events.
|
||||
@MainActor
|
||||
final class NotificationManager {
|
||||
static let shared = NotificationManager()
|
||||
private init() {}
|
||||
|
||||
/// Requests notification authorization the first time the app launches.
|
||||
/// Skips the system prompt if permission has already been granted or denied.
|
||||
func requestPermissionIfNeeded() async {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
guard settings.authorizationStatus == .notDetermined else { return }
|
||||
_ = try? await center.requestAuthorization(options: [.alert, .sound])
|
||||
}
|
||||
|
||||
/// Fires a local notification when a download fails.
|
||||
/// Suppressed while the app is in the foreground — an in-app alert already
|
||||
/// surfaces the error there, so a notification would be redundant.
|
||||
func sendDownloadFailedNotification(itemTitle: String) async {
|
||||
#if canImport(UIKit)
|
||||
guard UIApplication.shared.applicationState != .active else { return }
|
||||
#endif
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Download fehlgeschlagen"
|
||||
content.body = "„\(itemTitle)" konnte nicht heruntergeladen werden."
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "download-failed-\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil // deliver immediately
|
||||
)
|
||||
try? await UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user