// // DownloadManager.swift // AtProtoBackup // // Created by Corey Alexander on 8/25/25. // import SwiftUI import Combine import ATProtoKit #if os(iOS) import ActivityKit #endif struct DownloadInfo: Identifiable { let id = UUID() let accountID: String var progress: Double = 0 var totalBlobs: Int? var isDownloading: Bool = false var blobDownloader: Bool = false } class DownloadManager: ObservableObject { @Published private var downloads: [String: DownloadInfo] = [:] private let blobDownloader = BlobDownloader() #if os(iOS) private var liveActivities: [String: Activity] = [:] #endif func getDownload(for account: Account) -> DownloadInfo? { downloads[account.did] } func startDownload(for account: Account) { let accountDid = account.did if downloads[account.did] == nil { downloads[account.did] = DownloadInfo(accountID: account.did) } else { // Reset progress for resume downloads[account.did]?.progress = downloads[account.did]?.progress ?? 0 } #if os(iOS) // Start Live Activity startLiveActivity(for: account) #endif Task { await MainActor.run { downloads[accountDid]?.isDownloading = true } #if os(iOS) // Update Live Activity to fetching state updateLiveActivity(for: accountDid, status: .fetchingData, progress: 0, downloadedBlobs: 0, totalBlobs: nil, isPaused: false) #endif do { let saveLocation = try BackupDiscovery.accountBackupDirectory(for: account.did) print("Saving to \(saveLocation.path)") let startDate = Date() let formatter = ISO8601DateFormatter() let utcString = formatter.string(from: startDate) let fileName = "\(account.handle)-\(utcString).car" do { try FileManager.default.createDirectory( at: saveLocation, withIntermediateDirectories: true, attributes: nil) } catch CocoaError.fileWriteFileExists { print("Folder already exists at: \(saveLocation.path)") } catch { throw error } let result = try await blobDownloader.getCar(from: account.did, since: nil, fileManager: FileManager.default, saveLocation: saveLocation, fileName: fileName, pdsURL: account.pds) print("All Done! \(result)") // let config = ATProtocolConfiguration(pdsURL: account.pds) // let atProtoKit = await ATProtoKit(sessionConfiguration: config, pdsURL: account.pds) var allBlobCids: [String] = [] var cursor: String? = nil repeat { let blobsResponse = try await listBlobs(from: account.did, pds: account.pds, sinceRevision: nil, cursor: cursor) allBlobCids.append(contentsOf: blobsResponse.accountCIDs) cursor = blobsResponse.cursor print("Listed \(blobsResponse.accountCIDs.count) blob cids, total: \(allBlobCids.count)") } while cursor != nil print("List all \(allBlobCids.count) blob cids") let totalCount: Int = allBlobCids.count await MainActor.run { downloads[accountDid]?.totalBlobs = totalCount downloads[accountDid]?.progress = 0 // Reset progress for blob downloads } #if os(iOS) // Update Live Activity to downloading state updateLiveActivity(for: accountDid, status: .downloading, progress: 0, downloadedBlobs: 0, totalBlobs: totalCount, isPaused: false) // Register with background tracker await BackgroundDownloadTracker.shared.registerDownload(accountDid: accountDid, totalBlobs: totalCount) if let activity = liveActivities[accountDid] { await BackgroundDownloadTracker.shared.registerLiveActivity(activity, for: accountDid) } #endif // guard let saveUrl = saveLocation.fileURL else { // throw GenericIntentError.message( // "Was not able to get a valid url for the save location") // } let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() defer { if didStartAccessing { saveLocation.stopAccessingSecurityScopedResource() } } var bookmarkData: Data? if didStartAccessing { do { bookmarkData = try saveLocation.bookmarkData( options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil ) } catch { print("Failed to create bookmark: \(error)") } } let (_, newBlobsDownloaded) = try await blobDownloader.downloadBlobs(repo: account.did, pdsURL: account.pds, cids: allBlobCids, saveLocationBookmark: bookmarkData) { [weak self] downloaded, total in Task { @MainActor in if let totalBlobs = self?.downloads[accountDid]?.totalBlobs { let progress = Double(downloaded) / Double(totalBlobs) self?.downloads[accountDid]?.progress = progress // Live Activity updates are now handled by BackgroundDownloadTracker } } } await MainActor.run { downloads[accountDid]?.progress = 1.0 downloads[accountDid]?.isDownloading = false } // Save backup metadata let metadata = BackupMetadata( completedAt: Date(), startedAt: startDate, did: account.did, handle: account.handle, pds: account.pds, carFileName: fileName, totalBlobs: totalCount, newBlobsDownloaded: newBlobsDownloaded, deviceInfo: BackupMetadata.DeviceInfo.current() ) do { try metadata.save(to: saveLocation) print("Backup metadata saved to \(saveLocation.path)/backup-metadata.json") } catch { print("Failed to save backup metadata: \(error)") } #if os(iOS) // Final update is handled by BackgroundDownloadTracker when last blob completes // Clean up the tracker await BackgroundDownloadTracker.shared.cleanup(for: accountDid) #endif } catch { print("Download error: \(error)") await MainActor.run { downloads[accountDid]?.isDownloading = false } } } func listBlobs( from repositoryDID: String, pds: String, sinceRevision: String?, limit: Int? = 500, cursor: String? = nil ) async throws -> ComAtprotoLexicon.Sync.ListBlobsOutput { guard let requestURL = URL(string: pds + "/xrpc/com.atproto.sync.listBlobs") else { throw ATProtocolError.invalidURL } var queryItems = [(String, String)]() queryItems.append(("did", repositoryDID)) if let sinceRevision { queryItems.append(("since", sinceRevision)) } if let limit { let finalLimit = max(1, min(limit, 1_000)) queryItems.append(("limit", "\(finalLimit)")) } if let cursor { queryItems.append(("cursor", cursor)) } var components = URLComponents(url: requestURL, resolvingAgainstBaseURL: true)! components.queryItems = queryItems.map { URLQueryItem(name: $0.0, value: $0.1) } guard let queryURL = components.url else { throw ATProtocolError.invalidURL } var request = URLRequest(url: queryURL) request.httpMethod = "GET" request.setValue("application/vnd.ipld.car", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { throw ATProtocolError.invalidResponse } let decoder = JSONDecoder() return try decoder.decode(ComAtprotoLexicon.Sync.ListBlobsOutput.self, from: data) } } // MARK: - Live Activity Management #if os(iOS) private func startLiveActivity(for account: Account) { print("[LiveActivity] Checking if activities are enabled...") guard ActivityAuthorizationInfo().areActivitiesEnabled else { print("[LiveActivity] Activities are not enabled") return } print("[LiveActivity] Starting Live Activity for account: \(account.handle)") let attributes = DownloadActivityAttributes( accountDid: account.did, accountHandle: account.handle ) let initialState = DownloadActivityAttributes.ContentState( progress: 0, downloadedBlobs: 0, totalBlobs: nil, accountHandle: account.handle, isPaused: false, status: .fetchingData ) let content = ActivityContent(state: initialState, staleDate: nil) do { let activity = try Activity.request( attributes: attributes, content: content, pushType: nil ) liveActivities[account.did] = activity print("[LiveActivity] Successfully started Live Activity with ID: \(activity.id)") } catch { print("[LiveActivity] Failed to start Live Activity: \(error)") } } private func updateLiveActivity( for accountDid: String, status: DownloadActivityAttributes.ContentState.DownloadStatus, progress: Double, downloadedBlobs: Int, totalBlobs: Int?, isPaused: Bool ) { guard let activity = liveActivities[accountDid] else { print("[LiveActivity] No activity found for account \(accountDid)") return } Task { let updatedState = DownloadActivityAttributes.ContentState( progress: progress, downloadedBlobs: downloadedBlobs, totalBlobs: totalBlobs, accountHandle: activity.attributes.accountHandle, isPaused: isPaused, status: status ) await activity.update(using: updatedState) print("[LiveActivity] Updated activity for \(accountDid) - Status: \(status), Progress: \(Int(progress * 100))%") } } private func endLiveActivity(for accountDid: String) { guard let activity = liveActivities[accountDid] else { return } Task { await activity.end(dismissalPolicy: .immediate) liveActivities.removeValue(forKey: accountDid) } } #endif }