From 23480203eaa96ac2be486f6ac3eebe60f59ef8b9 Mon Sep 17 00:00:00 2001 From: flaviuvsp Date: Tue, 15 Oct 2024 14:36:18 +0300 Subject: [PATCH] Create POC for uploading photos in the background --- Permanent.xcodeproj/project.pbxproj | 60 ++++ Permanent/App/AppDelegate.swift | 10 + .../Common/Extensions/PHAssetExtension.swift | 46 +++ Permanent/Common/Models/FileInfo.swift | 15 + .../BiometricsViewController.swift | 8 +- .../CodeVerificationController.swift | 16 +- .../ViewController/LoginViewController.swift | 8 +- .../Managers/OtherUploadManager.swift | 43 +++ .../Managers/OtherUploadOperation.swift | 296 ++++++++++++++++++ .../PhotoAlbum/Managers/PhotoManager.swift | 149 +++++++++ .../Managers/UserDefaultManager.swift | 23 ++ .../Modules/PhotoAlbum/UI/PhotoGalery.swift | 49 +++ .../PhotoAlbum/UI/PhotoThumbnail.swift | 70 +++++ .../ViewModel/FetchAlbumsViewModel.swift | 126 ++++++++ Permanent/Resources/Assets/Info.plist | 5 + Podfile.lock | 28 +- 16 files changed, 934 insertions(+), 18 deletions(-) create mode 100644 Permanent/Modules/PhotoAlbum/Managers/OtherUploadManager.swift create mode 100644 Permanent/Modules/PhotoAlbum/Managers/OtherUploadOperation.swift create mode 100644 Permanent/Modules/PhotoAlbum/Managers/PhotoManager.swift create mode 100644 Permanent/Modules/PhotoAlbum/Managers/UserDefaultManager.swift create mode 100644 Permanent/Modules/PhotoAlbum/UI/PhotoGalery.swift create mode 100644 Permanent/Modules/PhotoAlbum/UI/PhotoThumbnail.swift create mode 100644 Permanent/Modules/PhotoAlbum/ViewModel/FetchAlbumsViewModel.swift diff --git a/Permanent.xcodeproj/project.pbxproj b/Permanent.xcodeproj/project.pbxproj index d9ded4c7..f933cb52 100644 --- a/Permanent.xcodeproj/project.pbxproj +++ b/Permanent.xcodeproj/project.pbxproj @@ -485,6 +485,13 @@ 927AE0CB2A2634CA00BDF26A /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 927AE0CA2A2634CA00BDF26A /* GradientView.swift */; }; 927AE0CD2A2F290E00BDF26A /* BannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 927AE0CC2A2F290E00BDF26A /* BannerView.swift */; }; 9295F3F32A9CC2C70061C9C9 /* AddLocationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9295F3F22A9CC2C70061C9C9 /* AddLocationViewModel.swift */; }; + 9298AD512CB6A26200048768 /* PhotoThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9298AD4E2CB6A26200048768 /* PhotoThumbnail.swift */; }; + 9298AD522CB6A26200048768 /* FetchAlbumsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9298AD4F2CB6A26200048768 /* FetchAlbumsViewModel.swift */; }; + 9298AD532CB6A26200048768 /* PhotoGalery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9298AD502CB6A26200048768 /* PhotoGalery.swift */; }; + 9298AD582CB6BF8400048768 /* PhotoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9298AD572CB6BF8400048768 /* PhotoManager.swift */; }; + 9298AD5A2CB6CC8800048768 /* OtherUploadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9298AD592CB6CC8800048768 /* OtherUploadManager.swift */; }; + 9298AD5C2CB8155400048768 /* OtherUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9298AD5B2CB8155400048768 /* OtherUploadOperation.swift */; }; + 9298AD5E2CBD170100048768 /* UserDefaultManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9298AD5D2CBD170100048768 /* UserDefaultManager.swift */; }; 92B9886B2A9782AC0040941E /* AddLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92B9886A2A9782AC0040941E /* AddLocationView.swift */; }; 92B9886E2A9783F30040941E /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92B9886D2A9783F30040941E /* MapView.swift */; }; 92C73E402A13BDC8000EF633 /* LegacyAccountStatusCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C73E3F2A13BDC8000EF633 /* LegacyAccountStatusCell.swift */; }; @@ -1312,6 +1319,13 @@ 927AE0CA2A2634CA00BDF26A /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; 927AE0CC2A2F290E00BDF26A /* BannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerView.swift; sourceTree = ""; }; 9295F3F22A9CC2C70061C9C9 /* AddLocationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddLocationViewModel.swift; sourceTree = ""; }; + 9298AD4E2CB6A26200048768 /* PhotoThumbnail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoThumbnail.swift; sourceTree = ""; }; + 9298AD4F2CB6A26200048768 /* FetchAlbumsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchAlbumsViewModel.swift; sourceTree = ""; }; + 9298AD502CB6A26200048768 /* PhotoGalery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoGalery.swift; sourceTree = ""; }; + 9298AD572CB6BF8400048768 /* PhotoManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoManager.swift; sourceTree = ""; }; + 9298AD592CB6CC8800048768 /* OtherUploadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OtherUploadManager.swift; sourceTree = ""; }; + 9298AD5B2CB8155400048768 /* OtherUploadOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherUploadOperation.swift; sourceTree = ""; }; + 9298AD5D2CBD170100048768 /* UserDefaultManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultManager.swift; sourceTree = ""; }; 92B9886A2A9782AC0040941E /* AddLocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddLocationView.swift; sourceTree = ""; }; 92B9886D2A9783F30040941E /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; 92C73E3F2A13BDC8000EF633 /* LegacyAccountStatusCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyAccountStatusCell.swift; sourceTree = ""; }; @@ -2409,6 +2423,7 @@ 5E47399B2A40B8A700A20D85 /* Modules */ = { isa = PBXGroup; children = ( + 9298AD4D2CB6A22100048768 /* PhotoAlbum */, 5E4739AD2A40FA1F00A20D85 /* Onboarding */, 5E4739AE2A40FA6000A20D85 /* Authentication */, 5E4739AC2A40F9F100A20D85 /* AccountOnboarding */, @@ -3248,6 +3263,44 @@ path = Banner; sourceTree = ""; }; + 9298AD4D2CB6A22100048768 /* PhotoAlbum */ = { + isa = PBXGroup; + children = ( + 9298AD562CB6BF6C00048768 /* Managers */, + 9298AD552CB6BF6100048768 /* ViewModel */, + 9298AD542CB6BF5000048768 /* UI */, + ); + path = PhotoAlbum; + sourceTree = ""; + }; + 9298AD542CB6BF5000048768 /* UI */ = { + isa = PBXGroup; + children = ( + 9298AD502CB6A26200048768 /* PhotoGalery.swift */, + 9298AD4E2CB6A26200048768 /* PhotoThumbnail.swift */, + ); + path = UI; + sourceTree = ""; + }; + 9298AD552CB6BF6100048768 /* ViewModel */ = { + isa = PBXGroup; + children = ( + 9298AD4F2CB6A26200048768 /* FetchAlbumsViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + 9298AD562CB6BF6C00048768 /* Managers */ = { + isa = PBXGroup; + children = ( + 9298AD592CB6CC8800048768 /* OtherUploadManager.swift */, + 9298AD572CB6BF8400048768 /* PhotoManager.swift */, + 9298AD5B2CB8155400048768 /* OtherUploadOperation.swift */, + 9298AD5D2CBD170100048768 /* UserDefaultManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; 92B9886C2A9783D30040941E /* SetLocation */ = { isa = PBXGroup; children = ( @@ -4688,6 +4741,7 @@ 92B9886B2A9782AC0040941E /* AddLocationView.swift in Sources */, F5428E7A2641DDC20096C7B8 /* FileDetailsBaseCollectionViewCell.swift in Sources */, F558C24928B605F9008BC77B /* FileMenuViewController.swift in Sources */, + 9298AD5A2CB6CC8800048768 /* OtherUploadManager.swift in Sources */, 92430A5D2AF2A1F00098597D /* EmailChip.swift in Sources */, F540A678267B649800962B2D /* AccountDeleteViewModel.swift in Sources */, 5E1582BF2C5CD7C600103EA8 /* OnboardingChartYourPathViewModel.swift in Sources */, @@ -4729,6 +4783,7 @@ 5EADF803262475D500D14E9C /* TagCollectionViewCell.swift in Sources */, BC600DF825B6E6C200193C96 /* DownloadManagerGCD.swift in Sources */, 5E7AE8AB291CE7FF00F2A41D /* ShareManagementSeparatorFooterCollectionViewCell.swift in Sources */, + 9298AD5C2CB8155400048768 /* OtherUploadOperation.swift in Sources */, BCAEED8725C1B96200A13C0F /* InviteStatus.swift in Sources */, 5E051C072B6AEB2900274657 /* SettingsScreenViewModel.swift in Sources */, 5EE0689B2B9FAA5A004055A4 /* RoundButtonRightImageView.swift in Sources */, @@ -4736,6 +4791,7 @@ 5E548E842B29F9D600DD2C59 /* AddStorageView.swift in Sources */, BC30AFD925A46E7900D37BB5 /* ArchiveSharePayload.swift in Sources */, BC6AF9B92593675600483BBA /* ArchiveVOPayload.swift in Sources */, + 9298AD5E2CBD170100048768 /* UserDefaultManager.swift in Sources */, BCB0725925221494003E2F66 /* PreferencesManager.swift in Sources */, 5E1C3C4E28ECA78D007C1A63 /* TagsRemoteDataSourceInterface.swift in Sources */, BCC1E3222549866300B71866 /* FileListType.swift in Sources */, @@ -4754,12 +4810,14 @@ 5E5EF858273B2416004F7EBC /* PublicProfilePageViewModel.swift in Sources */, F559F87B28F9A0750015A522 /* FolderViewSelectionViewModel.swift in Sources */, BC6358AC2536FCDA00EEC48C /* FolderLinkVO.swift in Sources */, + 9298AD532CB6A26200048768 /* PhotoGalery.swift in Sources */, BCA120C925627ADF00ECAD7B /* UIViewExtension.swift in Sources */, BC4278C8255D70DF00270B34 /* LocnVO.swift in Sources */, 06644A7E24EA6E47003CD359 /* ViewModelInterface.swift in Sources */, F52D2B88292E3CA40008D047 /* ShareManagementHeaderCollectionReusableView.swift in Sources */, BCD414E4257FCEB50019548F /* SharebyURLVOPayload.swift in Sources */, BC6D3B512514EF3300390927 /* OperationProtocol.swift in Sources */, + 9298AD582CB6BF8400048768 /* PhotoManager.swift in Sources */, 5EE77BD92C3EADD4006D854C /* AccessRoleChipView.swift in Sources */, F559F87528F990F20015A522 /* FolderContentViewModel.swift in Sources */, F58B8B9C2757F67A00D43606 /* PublicArchiveViewController.swift in Sources */, @@ -4866,6 +4924,7 @@ 5EB4C6382BD2F00100561F0F /* CustomBorderTextField.swift in Sources */, 5EA94A122B3486B000AD67F5 /* BottomInvalidAlertMessageView.swift in Sources */, 5ED3B3BC29FAB2BE000CFF48 /* LegacyPlanningSaveButton.swift in Sources */, + 9298AD522CB6A26200048768 /* FetchAlbumsViewModel.swift in Sources */, 5E559EC829BF438200F129BF /* IntExtension.swift in Sources */, 5EF0B6E927F43D62000CBAF6 /* PublicGalleryViewModel.swift in Sources */, 5E3E121D2A41902C00682DE5 /* AccountRemoteDataSource.swift in Sources */, @@ -4887,6 +4946,7 @@ F58EBC2E25DE963800D2D383 /* SharedFilesViewModel.swift in Sources */, 5E1582BB2C5CD6CB00103EA8 /* OnboardingCreateFirstArchiveViewModel.swift in Sources */, BCEAB27F2580F6D700567E8C /* MinArchiveVO.swift in Sources */, + 9298AD512CB6A26200048768 /* PhotoThumbnail.swift in Sources */, BC11C80525594B41008BDEFA /* APIResults.swift in Sources */, BC11C7FB2555769A008BDEFA /* Strings.swift in Sources */, F58B8B9E2758047200D43606 /* PublicArchiveFileViewController.swift in Sources */, diff --git a/Permanent/App/AppDelegate.swift b/Permanent/App/AppDelegate.swift index df0ed708..b255274b 100644 --- a/Permanent/App/AppDelegate.swift +++ b/Permanent/App/AppDelegate.swift @@ -419,4 +419,14 @@ extension AppDelegate { func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return Constants.Design.orientationLock } + + func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + PhotoManager.shared.start() + } + + func applicationDidBecomeActive(_ application: UIApplication) { + if PermSession.currentSession != nil { + PhotoManager.shared.start() + } + } } diff --git a/Permanent/Common/Extensions/PHAssetExtension.swift b/Permanent/Common/Extensions/PHAssetExtension.swift index 8928d6e0..c2032907 100644 --- a/Permanent/Common/Extensions/PHAssetExtension.swift +++ b/Permanent/Common/Extensions/PHAssetExtension.swift @@ -53,4 +53,50 @@ extension PHAsset { }) } } + + func getURL() async -> AssetDescriptor? { + if self.mediaType == .image { + let options = PHContentEditingInputRequestOptions() + options.isNetworkAccessAllowed = true + options.canHandleAdjustmentData = { _ in false } + + return await withCheckedContinuation { continuation in + self.requestContentEditingInput(with: options) { contentEditingInput, _ in + guard let url = contentEditingInput?.fullSizeImageURL as URL? else { + continuation.resume(returning: nil) + return + } + + var fileName = url.lastPathComponent + if url.lastPathComponent.contains("FullSizeRender") { + let fileExtention = url.lastPathComponent.components(separatedBy: ".").last ?? "" + fileName = url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent + if fileExtention.isNotEmpty { fileName.append(".\(fileExtention)") } + } + + let descriptor = AssetDescriptor(url: url, name: fileName) + continuation.resume(returning: descriptor) + } + } + } else if self.mediaType == .video { + let options = PHVideoRequestOptions() + options.version = .original + options.isNetworkAccessAllowed = true + + return await withCheckedContinuation { continuation in + PHImageManager.default().requestAVAsset(forVideo: self, options: options) { asset, _, _ in + if let urlAsset = asset as? AVURLAsset { + let url: URL = urlAsset.url as URL + let fileName = url.lastPathComponent + let descriptor = AssetDescriptor(url: url, name: fileName) + continuation.resume(returning: descriptor) + } else { + continuation.resume(returning: nil) + } + } + } + } + + return nil // For other media types + } } diff --git a/Permanent/Common/Models/FileInfo.swift b/Permanent/Common/Models/FileInfo.swift index 6d08b17e..ed6da3e8 100644 --- a/Permanent/Common/Models/FileInfo.swift +++ b/Permanent/Common/Models/FileInfo.swift @@ -10,6 +10,7 @@ import Foundation class FileInfo: NSObject, NSCoding { var id: String = NSUUID().uuidString var archiveId: Int + var creationDate: Date? var fileContents: Data? var mimeType: String? { @@ -18,6 +19,7 @@ class FileInfo: NSObject, NSCoding { var name: String var url: URL var folder: FolderInfo + var assetID: String? var didFailUpload = false @@ -40,6 +42,19 @@ class FileInfo: NSObject, NSCoding { fileContents = try? Data(contentsOf: url) } } + + init(withURL url: URL, named name: String, assetId: String? = nil, folder: FolderInfo, creationDate: Date?, archiveId: Int = -1, loadInMemory: Bool = false) { + self.name = name + self.url = url + self.folder = folder + self.archiveId = archiveId + self.creationDate = creationDate + self.assetID = assetId + + if loadInMemory { + fileContents = try? Data(contentsOf: url) + } + } static func createFiles(from urls: [URL], parentFolder: FolderInfo, loadInMemory: Bool = false) -> [FileInfo] { let files = urls.map { (url) -> FileInfo in diff --git a/Permanent/Modules/Authentication/ViewController/BiometricsViewController.swift b/Permanent/Modules/Authentication/ViewController/BiometricsViewController.swift index f90610ca..c84ef2fa 100644 --- a/Permanent/Modules/Authentication/ViewController/BiometricsViewController.swift +++ b/Permanent/Modules/Authentication/ViewController/BiometricsViewController.swift @@ -55,7 +55,13 @@ class BiometricsViewController: BaseViewController { host.modalPresentationStyle = .fullScreen AppDelegate.shared.rootViewController.present(host, animated: true) } else { - AppDelegate.shared.rootViewController.setDrawerRoot() +// AppDelegate.shared.rootViewController.setDrawerRoot() + let photoLibraryService = FetchAlbumsViewModel() + let screenView = PhotoGalery() + .environmentObject(photoLibraryService) + let host = UIHostingController(rootView: screenView) + host.modalPresentationStyle = .fullScreen + AppDelegate.shared.rootViewController.present(host, animated: true) } } }, onFailure: { error in diff --git a/Permanent/Modules/Authentication/ViewController/CodeVerificationController.swift b/Permanent/Modules/Authentication/ViewController/CodeVerificationController.swift index 6c49cbf6..fed1134d 100644 --- a/Permanent/Modules/Authentication/ViewController/CodeVerificationController.swift +++ b/Permanent/Modules/Authentication/ViewController/CodeVerificationController.swift @@ -69,7 +69,13 @@ class CodeVerificationController: BaseViewController { self.dismiss(animated: true) EventsManager.trackEvent(event: .SignIn) if AuthenticationManager.shared.session?.account.defaultArchiveID != nil { - AppDelegate.shared.rootViewController.setDrawerRoot() +// AppDelegate.shared.rootViewController.setDrawerRoot() + let photoLibraryService = FetchAlbumsViewModel() + let screenView = PhotoGalery() + .environmentObject(photoLibraryService) + let host = UIHostingController(rootView: screenView) + host.modalPresentationStyle = .fullScreen + AppDelegate.shared.rootViewController.present(host, animated: true) } else { let screenView = OnboardingView(viewModel: OnboardingContainerViewModel(username: nil, password: nil)) let host = UIHostingController(rootView: screenView) @@ -90,7 +96,13 @@ class CodeVerificationController: BaseViewController { switch status { case .success: - AppDelegate.shared.rootViewController.setDrawerRoot() +// AppDelegate.shared.rootViewController.setDrawerRoot() + let photoLibraryService = FetchAlbumsViewModel() + let screenView = PhotoGalery() + .environmentObject(photoLibraryService) + let host = UIHostingController(rootView: screenView) + host.modalPresentationStyle = .fullScreen + AppDelegate.shared.rootViewController.present(host, animated: true) case .error(let message): showAlert(title: .error, message: message) } diff --git a/Permanent/Modules/Authentication/ViewController/LoginViewController.swift b/Permanent/Modules/Authentication/ViewController/LoginViewController.swift index ff94ef31..f66b15ec 100644 --- a/Permanent/Modules/Authentication/ViewController/LoginViewController.swift +++ b/Permanent/Modules/Authentication/ViewController/LoginViewController.swift @@ -88,7 +88,13 @@ class LoginViewController: BaseViewController { switch loginStatus { case .success: if AuthenticationManager.shared.session?.account.defaultArchiveID != nil { - AppDelegate.shared.rootViewController.setDrawerRoot() +// AppDelegate.shared.rootViewController.setDrawerRoot() + let photoLibraryService = FetchAlbumsViewModel() + let screenView = PhotoGalery() + .environmentObject(photoLibraryService) + let host = UIHostingController(rootView: screenView) + host.modalPresentationStyle = .fullScreen + AppDelegate.shared.rootViewController.present(host, animated: true) } else { let screenView = OnboardingView(viewModel: OnboardingContainerViewModel(username: self?.emailField.text, password: self?.passwordField.text)) let host = UIHostingController(rootView: screenView) diff --git a/Permanent/Modules/PhotoAlbum/Managers/OtherUploadManager.swift b/Permanent/Modules/PhotoAlbum/Managers/OtherUploadManager.swift new file mode 100644 index 00000000..133980f8 --- /dev/null +++ b/Permanent/Modules/PhotoAlbum/Managers/OtherUploadManager.swift @@ -0,0 +1,43 @@ +// +// UploadManager.swift +// VSPFetchAssets +// +// Created by Flaviu Silaghi on 09.10.2024. + +import Foundation + +class OtherUploadManager { + + static let shared = OtherUploadManager() + + let uploadQueue: OperationQueue = OperationQueue() + + init() { + uploadQueue.maxConcurrentOperationCount = 1 + } + + func upload(files: [FileInfo]) async { + for file in files { + let uploadOperation = OtherUploadOperation(file: file) { error in + if error == nil { + FileHelper().deleteFile(at: file.url) + } else { + print("Fail") + } + } + + uploadOperation.name = file.id + uploadQueue.addOperation(uploadOperation) + } + + await withCheckedContinuation { continuation in // Use continuation to await barrier block + uploadQueue.addBarrierBlock { + DispatchQueue.main.async { + continuation.resume() // Resume after barrier block executes + print(" Resume after barrier block executes") + } + } + } + } + +} diff --git a/Permanent/Modules/PhotoAlbum/Managers/OtherUploadOperation.swift b/Permanent/Modules/PhotoAlbum/Managers/OtherUploadOperation.swift new file mode 100644 index 00000000..44e59c09 --- /dev/null +++ b/Permanent/Modules/PhotoAlbum/Managers/OtherUploadOperation.swift @@ -0,0 +1,296 @@ +// +// OtherUploadOperation.swift +// Permanent +// +// Created by Vlad Alexandru Rusu on 09.06.2021. +// + +import Foundation +import UIKit + +class OtherUploadOperation: BaseOperation { + static let uploadProgressNotification = Notification.Name("UploadOperation.uploadProgressNotification") + static let uploadFinishedNotification = Notification.Name("UploadOperation.uploadFinishedNotification") + + let file: FileInfo + let handler: ((Error?) -> Void) + + var s3Url: String! + var destinationUrl: String! + var fields: [String: String]! + var createdDT: String! + + var progress: Double = 0 + var error: UploadError? + + var urlSession: URLSession! + + var uploadTask: URLSessionUploadTask? + + var didSentFinishNotification: Bool = false + + var backgroundTask: UIBackgroundTaskIdentifier = .invalid + + lazy var prefixData: Data = { + return getHttpBody() + }() + + lazy var boundary: String = { + var uuid = UUID().uuidString + uuid = uuid.replacingOccurrences(of: "-", with: "") + uuid = uuid.map { $0.lowercased() }.joined() + + let boundary = uuid + "\(Int(Date.timeIntervalSinceReferenceDate))" + + return boundary + }() + + var didAppendPrefix = false + var isEOF = false + + var uploadedFile: RecordVOData? + + var getPresignedURLOperation: APIOperation? + var registerRecordOperation: APIOperation? + + init(file:FileInfo, handler: @escaping ((Error?) -> Void)) { + self.file = file + self.handler = handler + } + + override func start() { + if isCancelled { + finish() + return + } + + urlSession = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: nil) + + getPresignedUrl { [self] in + uploadFileDataToS3 { [self] in + registerRecord() + } + } + + super.start() + } + + override func finish() { + super.finish() + + if !didSentFinishNotification { + DispatchQueue.main.async { + let userInfo: [String: Any]? + if let error = self.error { + userInfo = ["error": error] + self.file.didFailUpload = true + } else { + userInfo = nil + } + + NotificationCenter.default.post(name: Self.uploadFinishedNotification, object: self, userInfo: userInfo) + } + } + } + + override func cancel() { + super.cancel() + + print("cancel upload task") + + getPresignedURLOperation?.cancel() + registerRecordOperation?.cancel() + uploadTask?.cancel() + + DispatchQueue.main.async { + let userInfo: [String: Any]? + if let error = self.error { + userInfo = ["error": error] + self.file.didFailUpload = true + } else { + userInfo = nil + } + + NotificationCenter.default.post(name: Self.uploadFinishedNotification, object: self, userInfo: userInfo) + + self.handler(nil) + } + + didSentFinishNotification = true + + finish() + } + + private func getPresignedUrl(success: @escaping (() -> Void)) { + guard let resources = try? file.url.resourceValues(forKeys:[.fileSizeKey]), + let fileSize = resources.fileSize else { + error = UploadError.presignedURL + handler(UploadError.presignedURL) + finish() + return + } + + let mimeType = (file.url.mimeType ?? "application/octet-stream") + let params: GetPresignedUrlParams = GetPresignedUrlParams(file.folder.folderId, file.folder.folderLinkId, mimeType, file.name, fileSize, nil) + + let apiOperation = APIOperation(FilesEndpoint.getPresignedUrl(params: params)) + apiOperation.execute(in: APIRequestDispatcher()) { [self] result in + guard isCancelled == false else { return } + + switch result { + case .json(let response, _): + guard let model: GetPresignedUrlResponse = JSONHelper.convertToModel(from: response) else { + error = UploadError.presignedURL + handler(UploadError.presignedURL) + finish() + return + } + + if model.isSuccessful == true, + let voValue = model.results?.first?.data?.first?.simpleVO?.value, + let s3Url = voValue.presignedPost?.url, + let destinationUrl = voValue.destinationUrl, + let fields = voValue.presignedPost?.fields { + self.s3Url = s3Url + self.destinationUrl = destinationUrl + self.fields = fields + + success() + } else { + error = UploadError.presignedURL + handler(UploadError.presignedURL) + finish() + } + case .error(_, _): + self.error = UploadError.presignedURL + handler(UploadError.presignedURL) + finish() + default: + finish() + break + } + } + } + + private func uploadFileDataToS3(success: @escaping (() -> Void)) { + var contentLength = prefixData.count + let resources = try! file.url.resourceValues(forKeys:[.fileSizeKey, .creationDateKey]) + let fileSize = resources.fileSize! + contentLength += fileSize + contentLength += "\r\n--\(boundary)--".data(using: .utf8)!.count + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + + let creationDate = resources.creationDate! + createdDT = dateFormatter.string(from: creationDate) + + var uploadRequest = URLRequest(url: URL(string: s3Url)!) + uploadRequest.timeoutInterval = 86400 + uploadRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "content-type") + uploadRequest.addValue("\(contentLength)", forHTTPHeaderField: "Content-Length") + + let prefixStream = InputStream(data: prefixData) + let fileStream = InputStream(url: file.url)! + let postfixStream = InputStream(data: "\r\n--\(boundary)--".data(using: .utf8)!) + + uploadRequest.httpBodyStream = SerialInputStream(inputStreams: [prefixStream, fileStream, postfixStream]) + uploadRequest.httpMethod = "POST" + + uploadTask = urlSession.uploadTask(with: uploadRequest, from: nil) { [self] data, response, error in + guard isCancelled == false else { return } + + if let response = response as? HTTPURLResponse, response.statusCode >= 200 && response.statusCode < 300 { + success() + } else { + self.error = UploadError.s3 + handler(UploadError.s3) + finish() + } + } + uploadTask?.resume() + } + + private func registerRecord() { + let params = RegisterRecordParams(file.folder.folderId, file.folder.folderLinkId, file.name, createdDT, s3Url, destinationUrl) + + let apiOperation = APIOperation(FilesEndpoint.registerRecord(params: params)) + apiOperation.execute(in: APIRequestDispatcher()) { [self] result in + guard isCancelled == false else { return } + + switch result { + case .json(let response, _): + guard let model: UploadFileMetaResponse = JSONHelper.convertToModel(from: response) else { + self.error = UploadError.registerRecord + handler(UploadError.registerRecord) + finish() + return + } + + if model.isSuccessful == true { + uploadedFile = model.results?.first?.data?.first?.recordVO + handler(nil) + finish() + print("Success upload file: \(file.name)") + UserDefaults.standard.set(file.creationDate, forKey: "lastCheckedPhotoDate") + UserDefaultManager().saveFile(fileInfo: file) + } else { + self.error = UploadError.registerRecord + handler(UploadError.registerRecord) + finish() + } + case .error(_, _): + self.error = UploadError.registerRecord + handler(UploadError.registerRecord) + finish() + default: + finish() + break + } + } + } + +} + +// MARK: - Upload request body methods +extension OtherUploadOperation { + private func getHttpBody() -> Data { + var body = Data() + + for (key, value) in fields { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + + let mimeType = (file.url.mimeType ?? "application/octet-stream") + + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"Content-Type\"\r\n\r\n".data(using: .utf8)!) + body.append(mimeType.data(using: .utf8)!) + body.append("\r\n".data(using: .utf8)!) + + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(file.name)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + + return body + } +} + +extension OtherUploadOperation: URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend) + + DispatchQueue.main.async { [self] in + let userInfo: [String : Any] = ["fileInfoId": file.id, "progress": progress] + NotificationCenter.default.post(name: Self.uploadProgressNotification, object: self, userInfo: userInfo) + } + + if isCancelled { + urlSession.invalidateAndCancel() + } + } +} diff --git a/Permanent/Modules/PhotoAlbum/Managers/PhotoManager.swift b/Permanent/Modules/PhotoAlbum/Managers/PhotoManager.swift new file mode 100644 index 00000000..c726a43b --- /dev/null +++ b/Permanent/Modules/PhotoAlbum/Managers/PhotoManager.swift @@ -0,0 +1,149 @@ +// +// PhotoManager.swift +// VSPFetchAssets +// +// Created by Flaviu Silaghi on 30.09.2024. + +import Foundation +import Photos +import UIKit + +class PhotoManager { + var folderInfo: FolderInfo? + + private var isLoading = false + + static let shared = PhotoManager() + + var backgroundTask: UIBackgroundTaskIdentifier = .invalid + + init() { + getRoot(then: { status in + self.start() + }) + } + + func getRoot(then handler: @escaping ServerResponse) { + let currentArchive = AuthenticationManager.shared.session?.selectedArchive + let apiOperation = APIOperation(FilesEndpoint.getPublicRoot(archiveNbr: currentArchive!.archiveNbr!)) + + apiOperation.execute(in: APIRequestDispatcher()) {[weak self] result in + switch result { + case .json(let response, _): + guard let model: GetRootResponse = JSONHelper.convertToModel(from: response) else { + handler(.error(message: .errorMessage)) + return + } + + if model.isSuccessful == true { + let folderVO = model.results?.first?.data?.first?.folderVO + if let folderID = folderVO?.folderID, let folderLinkId = folderVO?.folderLinkID { + self?.folderInfo = FolderInfo(folderId: folderID, folderLinkId: folderLinkId) + } + handler(.success) + } else { + handler(.error(message: .errorMessage)) + } + + case .error(let error, _): + handler(.error(message: error?.localizedDescription)) + + default: + break + } + } + } + + func start() { + self.checkForNewPhotos { assets in + self.startUpload(assets: assets) + } + } + + func checkForNewPhotos(completion: @escaping ([PHAsset]) -> Void) { + guard !isLoading else { return } + let fetchOptions = PHFetchOptions() + fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] + let lastCheckedDate = UserDefaults.standard.object(forKey: "lastCheckedPhotoDate") as? Date + + if let lastCheckedDate = lastCheckedDate { + let predicate = NSPredicate(format: "creationDate > %@", lastCheckedDate as CVarArg) + fetchOptions.predicate = predicate + } + + let result = PHAsset.fetchAssets(with: .image, options: fetchOptions) + + var assets: [PHAsset] = [] + result.enumerateObjects { object, index, stop in + assets.append(object) + } + + print("assets fetched:\(assets.count)") + + completion(assets) + } + + func startUpload(assets: [PHAsset]) { + Task { + let chunkedAssets = assets.chunked(into: 10) + for chunk in chunkedAssets { + isLoading = true + do { + backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: "com.permanent.backgroundTask") { + OtherUploadManager.shared.uploadQueue.cancelAllOperations() + UIApplication.shared.endBackgroundTask(self.backgroundTask) + self.backgroundTask = .invalid + self.isLoading = false + print("canceling background task") + } + + let fileInfos = try await getURLS(assets: chunk) + await OtherUploadManager.shared.upload(files: fileInfos) // Wait for upload to complete + + await UIApplication.shared.endBackgroundTask(backgroundTask) + isLoading = false + backgroundTask = .invalid + } catch { + print("Error processing chunk: \(error)") + } + } + } + } + + func getURLS(assets: [PHAsset]) async throws -> [FileInfo] { + try await withThrowingTaskGroup(of: FileInfo.self) { group in + var fileInfos: [FileInfo] = [] + + for photo in assets { + group.addTask { + guard let descriptor = await photo.getURL() else { + throw NSError(domain: "PhotoError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to get URL for photo"]) + } + + do { + let localURL = try FileHelper().copyFile(withURL: descriptor.url, name: descriptor.name) + let fileInfo = FileInfo(withURL: localURL, named: descriptor.name, assetId: photo.localIdentifier, folder: self.folderInfo!, creationDate: photo.creationDate) + return fileInfo + } catch { + print(error) + throw error + } + } + } + + for try await fileInfo in group { + fileInfos.append(fileInfo) + } + + return fileInfos + } + } +} + +extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} diff --git a/Permanent/Modules/PhotoAlbum/Managers/UserDefaultManager.swift b/Permanent/Modules/PhotoAlbum/Managers/UserDefaultManager.swift new file mode 100644 index 00000000..4d7d01e0 --- /dev/null +++ b/Permanent/Modules/PhotoAlbum/Managers/UserDefaultManager.swift @@ -0,0 +1,23 @@ +// +// UserDefaultManager.swift +// Permanent +// +// Created by Flaviu Silaghi on 14.10.2024. + +import Foundation + +struct UserDefaultManager { + + func saveFile(fileInfo: FileInfo) { + var array = UserDefaults.standard.array(forKey: "savedFiles") + if array == nil { + array = [] + } + array?.append(fileInfo.assetID) + UserDefaults.standard.set(array, forKey: "savedFiles") + } + + func getFiles() -> [String] { + return UserDefaults.standard.array(forKey: "savedFiles") as? [String] ?? [] + } +} diff --git a/Permanent/Modules/PhotoAlbum/UI/PhotoGalery.swift b/Permanent/Modules/PhotoAlbum/UI/PhotoGalery.swift new file mode 100644 index 00000000..9887425c --- /dev/null +++ b/Permanent/Modules/PhotoAlbum/UI/PhotoGalery.swift @@ -0,0 +1,49 @@ +// +// PhotoGalery.swift +// VSPFetchAssets +// +// Created by Flaviu Silaghi on 18.10.2023. + +import SwiftUI +import Photos + +struct PhotoGalery: View { + + @EnvironmentObject var service: FetchAlbumsViewModel + + var body: some View { + CustomNavigationView { + VStack { + libraryView + .onAppear { + service.fetchAssets() + } + } + .padding() + } + } + + var libraryView: some View { + ScrollView { + LazyVGrid( + columns: Array( + repeating: .init(.adaptive(minimum: 60), spacing: 1), + count: 4 + ), + spacing: 1 + ) { + ForEach(service.assets, id: \.asset.localIdentifier) { fetchedAsset in + Button { + // TODO: Add tapping action here + } label: { + PhotoThumbnailView(fetchedAsset: fetchedAsset) + } + } + }.id(service.id) + } + } +} + +#Preview { + PhotoGalery() +} diff --git a/Permanent/Modules/PhotoAlbum/UI/PhotoThumbnail.swift b/Permanent/Modules/PhotoAlbum/UI/PhotoThumbnail.swift new file mode 100644 index 00000000..dc838f1f --- /dev/null +++ b/Permanent/Modules/PhotoAlbum/UI/PhotoThumbnail.swift @@ -0,0 +1,70 @@ +// +// PhotoThumbnail.swift +// VSPFetchAssets +// +// Created by Flaviu Silaghi on 02.10.2024. + +import SwiftUI +import Photos + +struct PhotoThumbnailView: View { + +// private var assetLocalId: String + @State private var image: Image? + @ObservedObject var fetchedAsset: FetchedAssets + + @EnvironmentObject var service: FetchAlbumsViewModel + +// init(assetLocalId: String, isBacked: Binding) { +// self.assetLocalId = assetLocalId +// _isBacked = isBacked +// } + + var body: some View { + ZStack { + if let image = image { + GeometryReader { proxy in + ZStack(alignment: .bottomTrailing) { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame( + width: proxy.size.width, + height: proxy.size.width + ) + .clipped() + if fetchedAsset.isUploaded { + Image(systemName: "cloud") + .tint(.white) + .padding(5) + } + } + } + .aspectRatio(1, contentMode: .fit) + } else { + Rectangle() + .foregroundColor(.gray) + .aspectRatio(1, contentMode: .fit) + ProgressView() + } + } + .task { + await loadImageAsset(targetSize: CGSizeMake(60, 60)) + } + .onDisappear { + image = nil + } + } + + func loadImageAsset(targetSize: CGSize = PHImageManagerMaximumSize) async { + guard let uiImage = try? await service + .fetchImage( + byLocalIdentifier: fetchedAsset.asset.localIdentifier, + targetSize: targetSize + ) else { + image = nil + return + } + image = Image(uiImage: uiImage) + } +} diff --git a/Permanent/Modules/PhotoAlbum/ViewModel/FetchAlbumsViewModel.swift b/Permanent/Modules/PhotoAlbum/ViewModel/FetchAlbumsViewModel.swift new file mode 100644 index 00000000..bf01e99a --- /dev/null +++ b/Permanent/Modules/PhotoAlbum/ViewModel/FetchAlbumsViewModel.swift @@ -0,0 +1,126 @@ +// +// FetchAlbumsViewModel.swift +// VSPFetchAssets +// +// Created by Flaviu Silaghi on 18.10.2023. + +import Foundation +import Photos +import UIKit + +struct PHFetchResultCollection: RandomAccessCollection, Equatable { + + typealias Element = PHAsset + typealias Index = Int + + let fetchResult: PHFetchResult + + var endIndex: Int { fetchResult.count } + var startIndex: Int { 0 } + + subscript(position: Int) -> PHAsset { + fetchResult.object(at: fetchResult.count - position - 1) + } +} + +enum QueryError: Error { + case NotFound +} + +class FetchedAssets: Identifiable, ObservableObject { + let id = UUID() + var asset: PHAsset + @Published var isUploaded: Bool + + init(asset: PHAsset, isUploaded: Bool) { + self.asset = asset + self.isUploaded = isUploaded + } +} + +class FetchAlbumsViewModel: ObservableObject { + + @Published var assets: [FetchedAssets] = [] + + @Published var id = UUID().uuidString + + var imageCachingManager = PHCachingImageManager() + + init() { + NotificationCenter.default.addObserver(forName: OtherUploadOperation.uploadFinishedNotification, object: nil, queue: nil) {[weak self] notif in + print("update photo") + guard let operation = notif.object as? OtherUploadOperation else { return } + self?.update(fileInfo: operation.file) + } + + PHPhotoLibrary.requestAuthorization(for: .readWrite) {[weak self] status in + switch status { + case .authorized: + self?.fetchAssets() + PhotoManager.shared + default: + break + } + } + } + + func update(fileInfo: FileInfo) { + assets.first(where: {$0.asset.localIdentifier == fileInfo.assetID })?.isUploaded = true + } + + func fetchAssets() { + imageCachingManager.allowsCachingHighQualityImages = false + + let fetchOptions = PHFetchOptions() + fetchOptions.includeHiddenAssets = false + fetchOptions.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: true) + ] + + DispatchQueue.main.async { + let result = PHAsset.fetchAssets(with: .image, options: fetchOptions) + var assets: [FetchedAssets] = [] + result.enumerateObjects { object, index, stop in + assets.append(FetchedAssets(asset: object, isUploaded: self.isUploaded(asset: object))) + } + self.assets = assets + } + } + + func fetchImage( + byLocalIdentifier localId: String, + targetSize: CGSize = PHImageManagerMaximumSize, + contentMode: PHImageContentMode = .default + ) async throws -> UIImage? { + guard let fetchedAsset = assets.first(where: { $0.asset.localIdentifier == localId }) else { + throw QueryError.NotFound + } + + let options = PHImageRequestOptions() + options.deliveryMode = .opportunistic + options.resizeMode = .fast + options.isNetworkAccessAllowed = true + options.isSynchronous = true + + return try await withCheckedThrowingContinuation { [weak self] continuation in + self?.imageCachingManager.requestImage( + for: fetchedAsset.asset, + targetSize: targetSize, + contentMode: contentMode, + options: options, + resultHandler: { image, info in + if let error = info?[PHImageErrorKey] as? Error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: image) + } + ) + } + } + + func isUploaded(asset: PHAsset) -> Bool { + return UserDefaultManager().getFiles().contains(asset.localIdentifier) + } + +} diff --git a/Permanent/Resources/Assets/Info.plist b/Permanent/Resources/Assets/Info.plist index f730daa5..09e56109 100644 --- a/Permanent/Resources/Assets/Info.plist +++ b/Permanent/Resources/Assets/Info.plist @@ -71,6 +71,11 @@ OpenSans-Regular.ttf OpenSans-SemiBold.ttf + UIBackgroundModes + + fetch + processing + UIFileSharingEnabled UILaunchStoryboardName diff --git a/Podfile.lock b/Podfile.lock index fe73f669..b57c2cc0 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -17,7 +17,7 @@ PODS: - Firebase/RemoteConfig (11.1.0): - Firebase/CoreOnly - FirebaseRemoteConfig (~> 11.1.0) - - FirebaseABTesting (11.1.0): + - FirebaseABTesting (11.3.0): - FirebaseCore (~> 11.0) - FirebaseAnalytics (11.1.0): - FirebaseAnalytics/AdIdSupport (= 11.1.0) @@ -41,9 +41,9 @@ PODS: - FirebaseCoreInternal (~> 11.0) - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Logger (~> 8.0) - - FirebaseCoreExtension (11.1.0): + - FirebaseCoreExtension (11.3.0): - FirebaseCore (~> 11.0) - - FirebaseCoreInternal (11.1.0): + - FirebaseCoreInternal (11.3.0): - "GoogleUtilities/NSData+zlib (~> 8.0)" - FirebaseCrashlytics (11.1.0): - FirebaseCore (~> 11.0) @@ -54,7 +54,7 @@ PODS: - GoogleUtilities/Environment (~> 8.0) - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - FirebaseInstallations (11.1.0): + - FirebaseInstallations (11.3.0): - FirebaseCore (~> 11.0) - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) @@ -76,8 +76,8 @@ PODS: - FirebaseSharedSwift (~> 11.0) - GoogleUtilities/Environment (~> 8.0) - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseRemoteConfigInterop (11.1.0) - - FirebaseSessions (11.1.0): + - FirebaseRemoteConfigInterop (11.3.0) + - FirebaseSessions (11.3.0): - FirebaseCore (~> 11.0) - FirebaseCoreExtension (~> 11.0) - FirebaseInstallations (~> 11.0) @@ -86,7 +86,7 @@ PODS: - GoogleUtilities/UserDefaults (~> 8.0) - nanopb (~> 3.30910.0) - PromisesSwift (~> 2.1) - - FirebaseSharedSwift (11.1.0) + - FirebaseSharedSwift (11.3.0) - GoogleAppMeasurement (11.1.0): - GoogleAppMeasurement/AdIdSupport (= 11.1.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0) @@ -222,18 +222,18 @@ SPEC REPOS: SPEC CHECKSUMS: Firebase: fdb3bd378401f26a7adfcf446b0a630f8c20c0e8 - FirebaseABTesting: c2e22c3aab99afa81d0561708b2c1c356c556976 + FirebaseABTesting: c4559fcd2eba9f6bdaf0599e2c37ded01c343e4c FirebaseAnalytics: 9fcdb2e9844174bb405b34cc47092c9b91993d83 FirebaseCore: 6e2a2782e234b14d48e880ed369ac55cda87fed7 - FirebaseCoreExtension: aa5c9779c2d0d39d83f1ceb3fdbafe80c4feecfa - FirebaseCoreInternal: adefedc9a88dbe393c4884640a73ec9e8e790f8c + FirebaseCoreExtension: 30bb063476ef66cd46925243d64ad8b2c8ac3264 + FirebaseCoreInternal: ac26d09a70c730e497936430af4e60fb0c68ec4e FirebaseCrashlytics: 95cfe27373ff2edab39c28583d93cbf2dfff401d - FirebaseInstallations: d0a8fea5a6fa91abc661591cf57c0f0d70863e57 + FirebaseInstallations: 58cf94dabf1e2bb2fa87725a9be5c2249171cda0 FirebaseMessaging: 61014ecade746724664eee1f6c066c058a7c9fa7 FirebaseRemoteConfig: 05521e937b72e01847a7128da5a492327364c705 - FirebaseRemoteConfigInterop: abf8b1bbc0bf1b84abd22b66746926410bf91a87 - FirebaseSessions: 78f137e68dc01ca71606169ba4ac73b98c13752a - FirebaseSharedSwift: 260a35e08943ec810d820a70bc0359136351d0c5 + FirebaseRemoteConfigInterop: c3a5c31b3c22079f41ba1dc645df889d9ce38cb9 + FirebaseSessions: 655ff17f3cc1a635cbdc2d69b953878001f9e25b + FirebaseSharedSwift: d39c2ad64a11a8d936ce25a42b00df47078bb59c GoogleAppMeasurement: 8bb20efc67c8fc1cff9c42a06c256caf55289bbf GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d