import Foundation
@preconcurrency import CoreData
import UIKit
struct Photo: Hashable {
var photoID: Int64
var attribution: String?
var licenseCode: Int16 = 0
var smallURL: String?
var mediumURL: String?
var largeURL: String?
var squareURL: String?
var originalURL: String?
var nativePageURL: String?
var nativePhotoID: Int64 = 0
var type: String?
var url: String?
var imageData: Data?
}
class CorePhoto: NSManagedObject {
@NSManaged var photoID: Int64
@NSManaged var attribution: String?
@NSManaged var licenseCode: Int16
@NSManaged var imageData: Data?
@NSManaged var smallURL: String?
@NSManaged var mediumURL: String?
@NSManaged var largeURL: String?
@NSManaged var squareURL: String?
@NSManaged var originalURL: String?
@NSManaged var nativePageURL: String?
@NSManaged var nativePhotoID: Int64
@NSManaged var originalHeight: Double
@NSManaged var originalWidth: Double
@NSManaged var type: String?
@NSManaged var url: String?
}
enum WSError: Error {
case noItemsInCollection
case unableToCreateURL(String)
case coreDataRequest(Error)
case anyError(String)
func message() -> String { String(describing: self) }
}
class ViewModel {
func handleFirebase(_ a: Any?, _ error: Error?, _ b: Any?, _ message: String? = nil) {
print("logged: \(String(describing: error)) \(message ?? "")")
}
}
actor PhotosViewModel: NSObject, URLSessionTaskDelegate {
private let photoQuality: PhotoQuality = .medium
private var myCorePhotos = Set<CorePhoto>()
private var corePhotosIDs = Set<Int64>() {
didSet {
if oldValue.count != corePhotosIDs.count { getAllTasks() }
userDefaults(saveItem: corePhotosIDs, withKey: "corePhotosIDs")
}
}
var canCall = true
var startTime: UInt64 = 0
private var downloadPhotoArrays = [[Photo]]() {
didSet {
if canCall {
canCall = false
self.startTime = DispatchTime.now().uptimeNanoseconds
}
if willCall {
willCall = false
if let firstArray = downloadPhotoArrays.first {
startDownloadingImages(firstArray)
} else {
willCall = true
}
}
}
}
private var retryPhotos = [Photo]()
private var viewModel: ViewModel?
private var context: NSManagedObjectContext?
@Published private(set) var willCall: Bool = true
private var retryPhotosTimer = 20
private lazy var highPrioritySession: URLSession = {
let config = URLSessionConfiguration.default
config.httpMaximumConnectionsPerHost = 50
config.httpShouldUsePipelining = true
config.isDiscretionary = true
config.multipathServiceType = .handover
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
config.networkServiceType = .background
config.shouldUseExtendedBackgroundIdleMode = true
config.waitsForConnectivity = true
let operationQueue = OperationQueue()
operationQueue.qualityOfService = .userInitiated
operationQueue.maxConcurrentOperationCount = 20
let session = URLSession(configuration: config, delegate: nil, delegateQueue: operationQueue)
session.sessionDescription = "high-priority-photo-session"
return session
}()
private lazy var lowPrioritySession: URLSession = {
let config = URLSessionConfiguration.default
config.httpMaximumConnectionsPerHost = 5
config.httpShouldUsePipelining = true
config.isDiscretionary = true
config.multipathServiceType = .handover
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
config.networkServiceType = .background
config.shouldUseExtendedBackgroundIdleMode = true
config.waitsForConnectivity = true
let operationQueue = OperationQueue()
operationQueue.qualityOfService = .background
operationQueue.maxConcurrentOperationCount = 3
let session = URLSession(configuration: config, delegate: nil, delegateQueue: operationQueue)
session.sessionDescription = "low-priority-photo-session"
return session
}()
private var retryTask: Task<Void, Error>?
init(viewModel: ViewModel) {
super.init()
self.viewModel = viewModel
Task { await setCorePhotosIDs() }
}
private func appendRetryPhoto(photo: Photo) async { retryPhotos.append(photo) }
private func incRetryTimer(by int: Int) async { retryPhotosTimer += int }
private func setRetryTask(_ error: Error) async {
retryTask = Task { [weak self] in
guard let self else { return }
try await Task.sleep(for: .seconds(retryPhotosTimer))
guard !Task.isCancelled else { return }
await save(photos: retryPhotos)
await sendToHandle(retryPhotos.count, error)
}
}
private func sendToHandle<U: Comparable, V: Error>(_ value: U, _ error: V) async {
let adminMessage = "There were errors downloading some photos. Retrying photo downloads with \(value) photos."
viewModel?.handleFirebase(nil, error, nil, adminMessage)
}
private func startDownloadingImages(_ newPhotos: [Photo]) {
Task {
await withTaskGroup(of: Photo?.self) { group in
for photo in newPhotos {
group.addTask { [weak self] in
guard let self else { return nil }
do {
return try await highPriorityDownload(photo, quality: photoQuality)
} catch {
await appendRetryPhoto(photo: photo)
await retryTask?.cancel()
await setRetryTask(error)
await incRetryTimer(by: 120)
return nil
}
}
}
for await photo in group {
if let photo {
await moveToCompletedPhotos(photo)
if downloadPhotoArrays.first != nil {
downloadPhotoArrays[0].removeAll(where: { $0 == photo })
}
}
}
if group.isEmpty {
if !downloadPhotoArrays.isEmpty {
let endTime = DispatchTime.now().uptimeNanoseconds
let totalTime = (endTime - startTime) / 1_000_000_000
self.startTime = DispatchTime.now().uptimeNanoseconds
print("_call.photosAdded: \(downloadPhotoArrays.first!.count), time: \(totalTime)")
startDownloadingImages(downloadPhotoArrays.first!)
downloadPhotoArrays.removeFirst()
if downloadPhotoArrays.isEmpty {
print("_call.All photos downloaded")
willCall = true
}
}
}
}
}
}
private func userDefaults<T: Codable>(saveItem item: T, withKey key: String) {
Task {
let encoder = JSONEncoder()
do {
let data = try encoder.encode(item)
UserDefaults.standard.set(data, forKey: key)
} catch {
viewModel?.handleFirebase(nil, error, nil)
}
}
}
private func setCorePhotosIDs() async {
let decoder = JSONDecoder()
if let data = UserDefaults.standard.data(forKey: "corePhotosIDs") {
do {
if let decoded = try decodeData(data: data, type: Set<Int64>.self) {
self.corePhotosIDs = decoded
}
} catch {
UserDefaults.standard.removeObject(forKey: "corePhotosIDs")
viewModel?.handleFirebase(nil, error, nil)
}
}
func decodeData<T: Decodable>(data: Data, type: T.Type) throws -> T? {
do { return try decoder.decode(type, from: data) } catch { throw error }
}
}
private func fetchCorePhoto(using photoID: Int64) async throws -> CorePhoto? {
guard let context else { return nil }
let fetchRequest = fetchRequestBy(name: "fetchByPhotoID",
variables: ["PHOTO_ID": photoID]) as! NSFetchRequest<CorePhoto>
do {
return try context.fetch(fetchRequest).first
} catch {
let nserror = error as NSError
viewModel?.handleFirebase(nil, nserror, nil, "\(nserror.userInfo)")
return nil
}
}
enum PhotoQuality { case small, medium, large, original, square }
private func highPriorityDownload(_ photo: Photo, quality: PhotoQuality = .medium) async throws -> Photo {
guard let urlString: String = {
switch quality {
case .small: return photo.smallURL
case .medium: return photo.mediumURL
case .large: return photo.largeURL
default: return nil
}
}() else { throw WSError.noItemsInCollection }
var photo = photo
if let url = URL(string: urlString) {
let data = try await highPrioritySession.data(from: url, delegate: self).0
photo.imageData = data
return photo
}
throw WSError.unableToCreateURL("\(String(format: "%4d", #line)) \(#function) \(#fileID)")
}
private func moveToCompletedPhotos(_ photo: Photo, caller: StaticString = #function) async {
if !myCorePhotos.contains(where: { $0.photoID == photo.photoID }) {
if let newCorePhoto = await makeCorePhoto(photo) {
myCorePhotos.insert(newCorePhoto)
corePhotosIDs.insert(photo.photoID)
}
}
}
private func makeCorePhoto(_ photo: Photo) async -> CorePhoto? {
if let context {
let corePhoto = CorePhoto(entity: CorePhoto.entity(), insertInto: context)
corePhoto.attribution = photo.attribution
corePhoto.imageData = photo.imageData
corePhoto.largeURL = photo.largeURL
corePhoto.licenseCode = photo.licenseCode
corePhoto.mediumURL = photo.mediumURL
corePhoto.nativePageURL = photo.nativePageURL
corePhoto.nativePhotoID = photo.nativePhotoID
corePhoto.originalURL = photo.originalURL
corePhoto.photoID = photo.photoID
corePhoto.smallURL = photo.smallURL
corePhoto.squareURL = photo.squareURL
corePhoto.type = photo.type
corePhoto.url = photo.url
if context.hasChanges {
do { try context.save() }
catch {
let wsError = WSError.coreDataRequest(error)
viewModel?.handleFirebase(nil, wsError, nil, wsError.message())
}
}
return corePhoto
} else {
let wsError = WSError.anyError("Unable to make a CorePhoto")
viewModel?.handleFirebase(nil, wsError, nil, wsError.message())
return nil
}
}
private func makeOptionalPhoto(_ corePhoto: CorePhoto?) -> Photo? {
guard let corePhoto else { return nil }
return Photo(photoID: corePhoto.photoID,
attribution: corePhoto.attribution,
licenseCode: corePhoto.licenseCode,
smallURL: corePhoto.smallURL,
mediumURL: corePhoto.mediumURL,
largeURL: corePhoto.largeURL,
squareURL: corePhoto.squareURL,
originalURL: corePhoto.originalURL,
nativePageURL: corePhoto.nativePageURL,
nativePhotoID: corePhoto.nativePhotoID,
type: corePhoto.type,
url: corePhoto.url,
imageData: corePhoto.imageData)
}
internal func savePhotos(for photoArrays: [[Photo]]) async {
willCall = true
for array in photoArrays {
var newPhotos = array
newPhotos.removeAll { corePhotosIDs.contains($0.photoID) }
newPhotos.removeAll { newPhoto in
myCorePhotos.contains { $0.photoID == newPhoto.photoID }
}
let flatPhotoArrays = downloadPhotoArrays.compactMap({ $0 }).flatMap({ $0 })
newPhotos.removeAll { newPhoto in
flatPhotoArrays.contains { $0.photoID == newPhoto.photoID }
}
if !newPhotos.isEmpty { downloadPhotoArrays.append(newPhotos) }
}
}
private func save(photos: [Photo]) async {
if photos.isEmpty { return }
downloadPhotoArrays.append(photos)
}
private func finalGetPhoto(photoID: Int64, remotePhoto: Photo) async -> Photo? {
do {
if let hit = myCorePhotos.first(where: { $0.photoID == photoID }),
let photo = makeOptionalPhoto(hit) {
return photo
}
else if corePhotosIDs.contains(photoID),
let corePhoto = try await fetchCorePhoto(using: photoID),
let photo = makeOptionalPhoto(corePhoto) {
return photo
}
else if let corePhoto = try await fetchCorePhoto(using: photoID) {
let photo = makeOptionalPhoto(corePhoto)
corePhotosIDs.insert(photoID)
return photo
}
else {
let photo = try await highPriorityDownload(remotePhoto, quality: photoQuality)
await moveToCompletedPhotos(photo)
return photo
}
} catch {
viewModel?.handleFirebase(nil, error, nil)
return nil
}
}
private func fetchRequestBy(name: String, variables: [String: Any]) -> NSFetchRequest<NSFetchRequestResult>? {
guard let model = self.context?.persistentStoreCoordinator?.managedObjectModel else { return nil }
return model.fetchRequestFromTemplate(withName: name, substitutionVariables: variables)
}
private func getAllTasks() {
Task { _ = await highPrioritySession.allTasks.count }
}
internal func getAllPhotoIDs() -> Set<Int64> { corePhotosIDs }
}