diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 63c21146f..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @Infomaniak/ios diff --git a/.package.resolved b/.package.resolved index bd560da22..36a0b84af 100644 --- a/.package.resolved +++ b/.package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ProxymanApp/atlantis", "state" : { - "revision" : "131d757cf8e6e368ad338728379174f7cfff9326", - "version" : "1.23.0" + "revision" : "5145a7041ec71421d09653db87dcc80c81792004", + "version" : "1.24.0" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack", "state" : { - "revision" : "363ed23d19a931809ea834a7d722da830353806a", - "version" : "3.8.2" + "revision" : "af4973721172dd7850a42a58a9ffcec0ec82e5a9", + "version" : "3.8.4" } }, { @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Infomaniak/ios-core", "state" : { - "revision" : "504932a51c978b6aa0bbc086054875a36241674e", - "version" : "5.0.1" + "revision" : "a1689a335a84f1396816d25955cfcd2069be62ae", + "version" : "6.2.0" } }, { @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Infomaniak/ios-core-ui", "state" : { - "revision" : "2ade7b9cfc5942d1126570b40a6680e4ffcff105", - "version" : "3.0.0" + "revision" : "d593bdc3d788cadc8856137ff6d2cffbfd707163", + "version" : "4.1.0" } }, { @@ -113,8 +113,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Infomaniak/ios-login", "state" : { - "revision" : "ba2e72bc84a673d4e1626f898419da82b54baefb", - "version" : "5.0.0" + "revision" : "94dee9d95d92c5fbe152476b78b04985fbddaa7c", + "version" : "6.0.1" + } + }, + { + "identity" : "ios-version-checker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Infomaniak/ios-version-checker", + "state" : { + "revision" : "867d65cd4fc23d2a58b35975742bb448fd686f2c", + "version" : "1.1.2" } }, { @@ -122,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "277f1ab2c6664b19b4a412e32b094b201e2d5757", - "version" : "7.10.0" + "revision" : "3ec0ab0bca4feb56e8b33e289c9496e89059dd08", + "version" : "7.10.2" } }, { @@ -138,9 +147,9 @@ { "identity" : "localizekit", "kind" : "remoteSourceControl", - "location" : "https://github.com/PhilippeWeidmann/LocalizeKit", + "location" : "https://github.com/Infomaniak/LocalizeKit", "state" : { - "revision" : "1abb86ce614ae8d925bf82123c7360ab95827975", + "revision" : "a8f5b63a242a7cb5d8939db011fc9ee871a2a300", "version" : "1.0.1" } }, @@ -194,8 +203,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/realm-core.git", "state" : { - "revision" : "7227d6a447821c28895daa099b6c7cd4c99d461b", - "version" : "13.25.1" + "revision" : "a5e87a39cffdcc591f3203c11cfca68100d0b9a6", + "version" : "13.26.0" } }, { @@ -203,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/realm-swift", "state" : { - "revision" : "836cc4b8619886f979f8961c3f592a82b0741591", - "version" : "10.45.3" + "revision" : "eafdd3720a8cc750bdd38bf776082d2c8cf743fc", + "version" : "10.46.0" } }, { @@ -212,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa", "state" : { - "revision" : "3b9a8e69ca296bd8cd0e317ad7a448e5daf4a342", - "version" : "8.18.0" + "revision" : "b847a202a517a90763e8fd0656d8028aeee7b78d", + "version" : "8.20.0" } }, { @@ -221,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Infomaniak/SnackBar.swift", "state" : { - "revision" : "9a3c0c71796625ec3804df993483aa80266fa1ff", - "version" : "1.1.0" + "revision" : "7d8d20af50c6b744aa9791b597f7efbd0a15add2", + "version" : "1.2.0" } }, { @@ -230,8 +239,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SnapKit/SnapKit.git", "state" : { - "revision" : "f222cbdf325885926566172f6f5f06af95473158", - "version" : "5.6.0" + "revision" : "e74fe2a978d1216c3602b129447c7301573cc2d8", + "version" : "5.7.0" } }, { @@ -257,8 +266,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", - "version" : "1.0.5" + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" } }, { @@ -273,10 +282,10 @@ { "identity" : "swift-log", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", + "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", - "version" : "1.5.3" + "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", + "version" : "1.5.4" } }, { @@ -284,8 +293,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "702cd7c56d5d44eeba73fdf83918339b26dc855c", - "version" : "2.62.0" + "revision" : "635b2589494c97e48c62514bc8b37ced762e0a62", + "version" : "2.63.0" } }, { @@ -293,8 +302,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "320bd978cceb8e88c125dcbb774943a92f6286e9", - "version" : "2.25.0" + "revision" : "7c381eb6083542b124a6c18fae742f55001dc2b5", + "version" : "2.26.0" } }, { @@ -302,8 +311,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "ebf8b9c365a6ce043bf6e6326a04b15589bd285e", - "version" : "1.20.0" + "revision" : "6cbe0ed2b394f21ab0d46b9f0c50c6be964968ce", + "version" : "1.20.1" } }, { @@ -315,6 +324,15 @@ "version" : "1.0.2" } }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" + } + }, { "identity" : "swiftregex", "kind" : "remoteSourceControl", diff --git a/Project.swift b/Project.swift index 662cd093e..2dd4b6785 100644 --- a/Project.swift +++ b/Project.swift @@ -24,9 +24,9 @@ let project = Project(name: "kDrive", packages: [ .package(url: "https://github.com/apple/swift-algorithms", .upToNextMajor(from: "1.2.0")), .package(url: "https://github.com/Alamofire/Alamofire", .upToNextMajor(from: "5.2.2")), - .package(url: "https://github.com/Infomaniak/ios-core", .upToNextMajor(from: "5.0.1")), - .package(url: "https://github.com/Infomaniak/ios-core-ui", .upToNextMajor(from: "3.0.0")), - .package(url: "https://github.com/Infomaniak/ios-login", .upToNextMajor(from: "5.0.0")), + .package(url: "https://github.com/Infomaniak/ios-core", .upToNextMajor(from: "6.2.0")), + .package(url: "https://github.com/Infomaniak/ios-core-ui", .upToNextMajor(from: "4.1.0")), + .package(url: "https://github.com/Infomaniak/ios-login", .upToNextMajor(from: "6.0.1")), .package(url: "https://github.com/Infomaniak/ios-dependency-injection", .upToNextMajor(from: "2.0.0")), .package(url: "https://github.com/Infomaniak/swift-concurrency", .upToNextMajor(from: "0.0.4")), .package(url: "https://github.com/realm/realm-swift", .upToNextMajor(from: "10.43.0")), @@ -35,7 +35,6 @@ let project = Project(name: "kDrive", .package(url: "https://github.com/flowbe/MaterialOutlinedTextField", .upToNextMajor(from: "0.1.0")), .package(url: "https://github.com/ProxymanApp/atlantis", .upToNextMajor(from: "1.3.0")), .package(url: "https://github.com/ra1028/DifferenceKit", .upToNextMajor(from: "1.3.0")), - .package(url: "https://github.com/PhilippeWeidmann/LocalizeKit", .upToNextMajor(from: "1.0.1")), .package(url: "https://github.com/airbnb/lottie-ios", .upToNextMinor(from: "3.4.0")), .package(url: "https://github.com/CocoaLumberjack/CocoaLumberjack", .upToNextMajor(from: "3.7.0")), .package(url: "https://github.com/RomanTysiachnik/DropDown", .branch("master")), @@ -46,7 +45,8 @@ let project = Project(name: "kDrive", .package(url: "https://github.com/Cocoanetics/Kvitto", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/raspu/Highlightr", .upToNextMajor(from: "2.1.0")), .package(url: "https://github.com/bmoliveira/MarkdownKit", .upToNextMajor(from: "1.7.0")), - .package(url: "https://github.com/matomo-org/matomo-sdk-ios", .upToNextMajor(from: "7.5.1")) + .package(url: "https://github.com/matomo-org/matomo-sdk-ios", .upToNextMajor(from: "7.5.1")), + .package(url: "https://github.com/Infomaniak/ios-version-checker", .upToNextMajor(from: "1.1.2")), ], targets: [ Target(name: "kDrive", @@ -164,13 +164,13 @@ let project = Project(name: "kDrive", .package(product: "InfomaniakDI"), .package(product: "InfomaniakConcurrency"), .package(product: "RealmSwift"), - .package(product: "LocalizeKit"), .package(product: "Kingfisher"), .package(product: "DifferenceKit"), .package(product: "CocoaLumberjackSwift"), .package(product: "MaterialOutlinedTextField"), .package(product: "SwiftRegex"), - .package(product: "Sentry") + .package(product: "Sentry"), + .package(product: "VersionChecker") ]), Target(name: "kDriveFileProvider", platform: .iOS, diff --git a/Tuist/ProjectDescriptionHelpers/Constants.swift b/Tuist/ProjectDescriptionHelpers/Constants.swift index ce25a1306..0a9761d35 100644 --- a/Tuist/ProjectDescriptionHelpers/Constants.swift +++ b/Tuist/ProjectDescriptionHelpers/Constants.swift @@ -26,7 +26,7 @@ public enum Constants { public static let baseSettings = SettingsDictionary() .automaticCodeSigning(devTeam: "864VDCS2QY") .currentProjectVersion("1") - .marketingVersion("4.4.0") + .marketingVersion("4.4.2") public static let deploymentTarget = DeploymentTarget.iOS(targetVersion: "13.4", devices: [.iphone, .ipad]) diff --git a/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift b/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift index 05f85e5c9..0cbd4d243 100644 --- a/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift +++ b/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift @@ -63,6 +63,7 @@ public extension Target { infoPlist: .file(path: "\(name)/Info.plist"), sources: [ "\(name)/**", + "kDrive/UI/Controller/DriveUpdateRequiredViewController.swift", "kDrive/UI/Controller/FloatingPanelSelectOptionViewController.swift", "kDrive/UI/Controller/Create File/FloatingPanelUtils.swift", "kDrive/UI/Controller/Files/Categories/**", diff --git a/kDrive/AppDelegate+BGAppRefresh.swift b/kDrive/AppDelegate+BGAppRefresh.swift deleted file mode 100644 index d69094dd3..000000000 --- a/kDrive/AppDelegate+BGAppRefresh.swift +++ /dev/null @@ -1,139 +0,0 @@ -/* - Infomaniak kDrive - iOS App - Copyright (C) 2023 Infomaniak Network SA - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - */ - -import BackgroundTasks -import CocoaLumberjackSwift -import Foundation -import InfomaniakDI -import kDriveCore - -extension AppDelegate { - /* To debug background tasks: - Launch -> - e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.infomaniak.background.refresh"] - OR - e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.infomaniak.background.long-refresh"] - - Force early termination -> - e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.infomaniak.background.refresh"] - OR - e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.infomaniak.background.long-refresh"] - */ - - /// schedule background tasks - func scheduleBackgroundRefresh() { - Log.bgTaskScheduling("scheduleBackgroundRefresh") - // List pictures + upload files (+pictures) / photoKit - let backgroundRefreshRequest = BGAppRefreshTaskRequest(identifier: Constants.backgroundRefreshIdentifier) - #if DEBUG - // Required for debugging - backgroundRefreshRequest.earliestBeginDate = Date() - #else - backgroundRefreshRequest.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) - #endif - - // Upload files (+pictures) / photokit - let longBackgroundRefreshRequest = BGProcessingTaskRequest(identifier: Constants.longBackgroundRefreshIdentifier) - #if DEBUG - // Required for debugging - longBackgroundRefreshRequest.earliestBeginDate = Date() - #else - longBackgroundRefreshRequest.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) - #endif - longBackgroundRefreshRequest.requiresNetworkConnectivity = true - longBackgroundRefreshRequest.requiresExternalPower = true - do { - try backgroundTaskScheduler.submit(backgroundRefreshRequest) - Log.bgTaskScheduling("scheduled task: \(backgroundRefreshRequest)") - try backgroundTaskScheduler.submit(longBackgroundRefreshRequest) - Log.bgTaskScheduling("scheduled task: \(longBackgroundRefreshRequest)") - - } catch { - Log.bgTaskScheduling("Error scheduling background task: \(error)", level: .error) - } - } - - /// Register BackgroundTasks in scheduler for later - func registerBackgroundTasks() { - Log.bgTaskScheduling("registerBackgroundTasks") - var registered = backgroundTaskScheduler.register( - forTaskWithIdentifier: Constants.backgroundRefreshIdentifier, - using: nil - ) { task in - self.scheduleBackgroundRefresh() - @InjectService var uploadQueue: UploadQueue - task.expirationHandler = { - Log.bgTaskScheduling("Task \(Constants.backgroundRefreshIdentifier) EXPIRED", level: .error) - uploadQueue.suspendAllOperations() - uploadQueue.rescheduleRunningOperations() - task.setTaskCompleted(success: false) - } - - self.handleBackgroundRefresh { _ in - Log.bgTaskScheduling("Task \(Constants.backgroundRefreshIdentifier) completed with SUCCESS") - task.setTaskCompleted(success: true) - } - } - Log.bgTaskScheduling("Task \(Constants.backgroundRefreshIdentifier) registered ? \(registered)") - - registered = backgroundTaskScheduler.register( - forTaskWithIdentifier: Constants.longBackgroundRefreshIdentifier, - using: nil - ) { task in - self.scheduleBackgroundRefresh() - @InjectService var uploadQueue: UploadQueue - task.expirationHandler = { - Log.bgTaskScheduling("Task \(Constants.longBackgroundRefreshIdentifier) EXPIRED", level: .error) - uploadQueue.suspendAllOperations() - uploadQueue.rescheduleRunningOperations() - task.setTaskCompleted(success: false) - } - - self.handleBackgroundRefresh { _ in - Log.bgTaskScheduling("Task \(Constants.longBackgroundRefreshIdentifier) completed with SUCCESS") - task.setTaskCompleted(success: true) - } - } - Log.bgTaskScheduling("Task \(Constants.longBackgroundRefreshIdentifier) registered ? \(registered)") - } - - func handleBackgroundRefresh(completion: @escaping (Bool) -> Void) { - Log.bgTaskScheduling("handleBackgroundRefresh") - // User installed the app but never logged in - if accountManager.accounts.isEmpty { - completion(false) - return - } - - Log.bgTaskScheduling("Enqueue new pictures") - @InjectService var photoUploader: PhotoLibraryUploader - photoUploader.scheduleNewPicturesForUpload() - - Log.bgTaskScheduling("Clean errors for all uploads") - @InjectService var uploadQueue: UploadQueue - uploadQueue.cleanNetworkAndLocalErrorsForAllOperations() - - Log.bgTaskScheduling("Reload operations in queue") - uploadQueue.rebuildUploadQueueFromObjectsInRealm() - - Log.bgTaskScheduling("waitForCompletion") - uploadQueue.waitForCompletion { - completion(true) - } - } -} diff --git a/kDrive/AppDelegate+Launch.swift b/kDrive/AppDelegate+Launch.swift index 0eb046353..405a54153 100644 --- a/kDrive/AppDelegate+Launch.swift +++ b/kDrive/AppDelegate+Launch.swift @@ -38,6 +38,8 @@ extension AppDelegate { askUserToRemovePicturesIfNecessary() case .onboarding: showOnboarding() + case .updateRequired: + showUpdateRequired() } } @@ -102,6 +104,16 @@ extension AppDelegate { } } + private func showUpdateRequired() { + guard let window else { + SentryDebug.captureNoWindow() + return + } + + window.rootViewController = DriveUpdateRequiredViewController() + window.makeKeyAndVisible() + } + // MARK: Misc private func requestAppStoreReview() { @@ -193,7 +205,7 @@ extension AppDelegate { } group.leave() } - uploadQueue.saveToRealmAndAddToQueue(uploadFile: uploadFile, itemIdentifier: nil) + uploadQueue.saveToRealm(uploadFile, itemIdentifier: nil) } } diff --git a/kDrive/AppDelegate.swift b/kDrive/AppDelegate.swift index e31bb955f..a34cd0b12 100644 --- a/kDrive/AppDelegate.swift +++ b/kDrive/AppDelegate.swift @@ -31,11 +31,12 @@ import os.log import StoreKit import UIKit import UserNotifications +import VersionChecker @main final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDelegate { /// Making sure the DI is registered at a very early stage of the app launch. - private let dependencyInjectionHook = EarlyDIHook() + private let dependencyInjectionHook = EarlyDIHook(context: .app) private var reachabilityListener: ReachabilityListener! private static let currentStateVersion = 4 @@ -49,9 +50,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDeleg @LazyInjectService var backgroundUploadSessionManager: BackgroundUploadSessionManager @LazyInjectService var backgroundDownloadSessionManager: BackgroundDownloadSessionManager @LazyInjectService var photoLibraryUploader: PhotoLibraryUploader - @LazyInjectService var backgroundTaskScheduler: BGTaskScheduler @LazyInjectService var notificationHelper: NotificationsHelpable @LazyInjectService var accountManager: AccountManageable + @LazyInjectService var backgroundTasksService: BackgroundTasksServiceable // MARK: - UIApplicationDelegate @@ -71,7 +72,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDeleg SentryDebug.capture(error: error) } - registerBackgroundTasks() + backgroundTasksService.registerBackgroundTasks() // In some cases the application can show the old Nextcloud import notification badge UIApplication.shared.applicationIconBadgeNumber = 0 @@ -167,8 +168,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDeleg func applicationDidEnterBackground(_ application: UIApplication) { Log.appDelegate("applicationDidEnterBackground") + backgroundTasksService.scheduleBackgroundRefresh() - scheduleBackgroundRefresh() if UserDefaults.shared.isAppLockEnabled, !(window?.rootViewController?.isKind(of: LockedAppViewController.self) ?? false) { lockHelper.setTime() @@ -183,19 +184,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDeleg shortcutItemToProcess = shortcutItem } - func application(_ application: UIApplication, - performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - Log.appDelegate("application performFetchWithCompletionHandler") - - handleBackgroundRefresh { newData in - if newData { - completionHandler(.newData) - } else { - completionHandler(.noData) - } - } - } - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { Log.appDelegate("application app open url\(url)") @@ -218,12 +206,18 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDeleg UserDefaults.shared.numberOfConnections += 1 refreshCacheScanLibraryAndUpload(preload: false, isSwitching: false) uploadEditedFiles() - case .onboarding: break + case .onboarding, .updateRequired: break // NOOP } // Remove all notifications on App Opening UNUserNotificationCenter.current().removeAllDeliveredNotifications() + + Task { + if try await VersionChecker.standard.checkAppVersionStatus() == .updateIsRequired { + prepareRootViewController(currentState: .updateRequired) + } + } } /// Set global tint color @@ -390,14 +384,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDeleg func setRootViewController(_ vc: UIViewController, animated: Bool = true) { - guard animated, let window else { - self.window?.rootViewController = vc - self.window?.makeKeyAndVisible() + guard let window else { return } window.rootViewController = vc window.makeKeyAndVisible() + + guard animated else { + return + } + UIView.transition(with: window, duration: 0.3, options: .transitionCrossDissolve, animations: nil, @@ -430,9 +427,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDeleg viewController: fileListViewController) } else { let filePresenter = FilePresenter(viewController: fileListViewController) - filePresenter.present(driveFileManager: driveFileManager, - file: file, + filePresenter.present(for: file, files: [file], + driveFileManager: driveFileManager, normalFolderHierarchy: false) } } @@ -471,11 +468,15 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, AccountManagerDeleg } func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool { - let encodedVersion = coder.decodeInteger(forKey: AppDelegate.appStateVersionKey) + // App Restore disabled until we rework it + return false + + /* + let encodedVersion = coder.decodeInteger(forKey: AppDelegate.appStateVersionKey) - return AppDelegate - .currentStateVersion == encodedVersion && - !(UserDefaults.shared.legacyIsFirstLaunch || accountManager.accounts.isEmpty) + return AppDelegate + .currentStateVersion == encodedVersion && + !(UserDefaults.shared.legacyIsFirstLaunch || accountManager.accounts.isEmpty)*/ } // MARK: - User activity diff --git a/kDrive/Resources/Assets.xcassets/update-required.imageset/Contents.json b/kDrive/Resources/Assets.xcassets/update-required.imageset/Contents.json new file mode 100644 index 000000000..d51d2bd80 --- /dev/null +++ b/kDrive/Resources/Assets.xcassets/update-required.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "update-required-light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "update-required-dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/kDrive/Resources/Assets.xcassets/update-required.imageset/update-required-dark.svg b/kDrive/Resources/Assets.xcassets/update-required.imageset/update-required-dark.svg new file mode 100644 index 000000000..df8283ef2 --- /dev/null +++ b/kDrive/Resources/Assets.xcassets/update-required.imageset/update-required-dark.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kDrive/Resources/Assets.xcassets/update-required.imageset/update-required-light.svg b/kDrive/Resources/Assets.xcassets/update-required.imageset/update-required-light.svg new file mode 100644 index 000000000..129bf2360 --- /dev/null +++ b/kDrive/Resources/Assets.xcassets/update-required.imageset/update-required-light.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kDrive/UI/Controller/DriveUpdateRequiredViewController.swift b/kDrive/UI/Controller/DriveUpdateRequiredViewController.swift new file mode 100644 index 000000000..08d942a28 --- /dev/null +++ b/kDrive/UI/Controller/DriveUpdateRequiredViewController.swift @@ -0,0 +1,82 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCoreUI +import kDriveCore +import kDriveResources +import SwiftUI +import UIKit +import VersionChecker + +class DriveUpdateRequiredViewController: UIViewController { + var dismissHandler: (() -> Void)? + + private let sharedStyle: TemplateSharedStyle = { + let largeButtonStyle = IKLargeButton.Style.primaryButton + return TemplateSharedStyle( + background: KDriveResourcesAsset.backgroundColor.swiftUIColor, + titleTextStyle: .init(font: Font(TextStyle.header2.font), color: Color(TextStyle.header2.color)), + descriptionTextStyle: .init(font: Font(TextStyle.body1.font), color: Color(TextStyle.body1.color)), + buttonStyle: .init( + background: Color(largeButtonStyle.backgroundColor), + textStyle: .init(font: Font(largeButtonStyle.titleFont), color: Color(largeButtonStyle.titleColor)), + height: 60, + radius: UIConstants.buttonCornerRadius + ) + ) + }() + + override func viewDidLoad() { + super.viewDidLoad() + + let hostingViewController = UIHostingController(rootView: UpdateRequiredView( + image: KDriveResourcesAsset.updateRequired.swiftUIImage, + sharedStyle: sharedStyle, + updateHandler: updateApp, + dismissHandler: dismissHandler + )) + guard let hostingView = hostingViewController.view else { return } + + hostingView.translatesAutoresizingMaskIntoConstraints = false + addChild(hostingViewController) + view.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: view.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingView.leftAnchor.constraint(equalTo: view.leftAnchor), + hostingView.rightAnchor.constraint(equalTo: view.rightAnchor) + ]) + + hostingViewController.didMove(toParent: self) + } + + override func viewWillAppear(_ animated: Bool) { + navigationController?.setNavigationBarHidden(true, animated: animated) + } + + private func updateApp() { + let storeURL: URLConstants = Bundle.main.isRunningInTestFlight ? .testFlight : .appStore + UIConstants.openUrl(storeURL.url, from: self) + } +} + +@available(iOS 17.0, *) +#Preview { + return DriveUpdateRequiredViewController() +} diff --git a/kDrive/UI/Controller/Files/File List/DragAndDropFileListViewModel.swift b/kDrive/UI/Controller/Files/File List/DragAndDropFileListViewModel.swift index 785b56102..57af7776a 100644 --- a/kDrive/UI/Controller/Files/File List/DragAndDropFileListViewModel.swift +++ b/kDrive/UI/Controller/Files/File List/DragAndDropFileListViewModel.swift @@ -139,10 +139,11 @@ final class DroppableFileListViewModel { let drive = driveFileManager.drive Task { do { - try await self.fileImportHelper.upload( - files: importedFiles, + try await self.fileImportHelper.saveForUpload( + importedFiles, in: frozenDestination, - drive: drive + drive: drive, + addToQueue: true ) } catch { UIConstants.showSnackBarIfNeeded(error: error) diff --git a/kDrive/UI/Controller/Files/File List/FileListViewController.swift b/kDrive/UI/Controller/Files/File List/FileListViewController.swift index aeaf3bceb..d2c7679fb 100644 --- a/kDrive/UI/Controller/Files/File List/FileListViewController.swift +++ b/kDrive/UI/Controller/Files/File List/FileListViewController.swift @@ -582,9 +582,9 @@ class FileListViewController: UIViewController, UICollectionViewDataSource, Swip func onFilePresented(_ file: File) { #if !ISEXTENSION - filePresenter.present(driveFileManager: viewModel.driveFileManager, - file: file, + filePresenter.present(for: file, files: viewModel.getAllFiles(), + driveFileManager: viewModel.driveFileManager, normalFolderHierarchy: viewModel.configuration.normalFolderHierarchy, fromActivities: viewModel.configuration.fromActivities) #endif diff --git a/kDrive/UI/Controller/Files/File List/InMemoryFileListViewModel.swift b/kDrive/UI/Controller/Files/File List/InMemoryFileListViewModel.swift index 27877bdc2..e82163d25 100644 --- a/kDrive/UI/Controller/Files/File List/InMemoryFileListViewModel.swift +++ b/kDrive/UI/Controller/Files/File List/InMemoryFileListViewModel.swift @@ -25,7 +25,14 @@ class InMemoryFileListViewModel: FileListViewModel { private let realm: Realm override init(configuration: Configuration, driveFileManager: DriveFileManager, currentDirectory: File) { - if let realm = currentDirectory.realm { + // TODO: Refactor to explicit realm state + /// We expect the object to be live in this view controller, if not detached. + var currentDirectory = currentDirectory + if currentDirectory.isFrozen, let liveDirectory = currentDirectory.thaw() { + currentDirectory = liveDirectory + } + + if let realm = currentDirectory.realm, !currentDirectory.isFrozen { self.realm = realm } else { let unCachedRealmConfiguration = Realm.Configuration( @@ -40,6 +47,7 @@ class InMemoryFileListViewModel: FileListViewModel { } super.init(configuration: configuration, driveFileManager: driveFileManager, currentDirectory: currentDirectory) + try? realm.write { realm.add(currentDirectory) } diff --git a/kDrive/UI/Controller/Files/FilePresenter.swift b/kDrive/UI/Controller/Files/FilePresenter.swift index 5b647da48..bd7a46aa1 100644 --- a/kDrive/UI/Controller/Files/FilePresenter.swift +++ b/kDrive/UI/Controller/Files/FilePresenter.swift @@ -63,18 +63,16 @@ final class FilePresenter { func presentParent(of file: File, driveFileManager: DriveFileManager, animated: Bool = true) { if let parent = file.parent { - present(driveFileManager: driveFileManager, file: parent, files: [], normalFolderHierarchy: true, animated: animated) + present(for: parent, files: [], driveFileManager: driveFileManager, normalFolderHierarchy: true, animated: animated) } else if file.parentId != 0 { Task { do { let parent = try await driveFileManager.file(id: file.parentId) - present( - driveFileManager: driveFileManager, - file: parent, - files: [], - normalFolderHierarchy: true, - animated: animated - ) + present(for: parent, + files: [], + driveFileManager: driveFileManager, + normalFolderHierarchy: true, + animated: animated) } catch { UIConstants.showSnackBarIfNeeded(error: error) } @@ -84,93 +82,124 @@ final class FilePresenter { } } - func present(driveFileManager: DriveFileManager, - file: File, + func present(for file: File, files: [File], + driveFileManager: DriveFileManager, normalFolderHierarchy: Bool, fromActivities: Bool = false, animated: Bool = true, completion: ((Bool) -> Void)? = nil) { if file.isDirectory { - // Show files list - let viewModel: FileListViewModel - if driveFileManager.drive.sharedWithMe { - viewModel = SharedWithMeViewModel(driveFileManager: driveFileManager, currentDirectory: file) - } else if file.isTrashed || file.deletedAt != nil { - viewModel = TrashListViewModel(driveFileManager: driveFileManager, currentDirectory: file) - } else { - viewModel = ConcreteFileListViewModel(driveFileManager: driveFileManager, currentDirectory: file) - } - let nextVC = FileListViewController.instantiate(viewModel: viewModel) - if file.isDisabled { - if driveFileManager.drive.isUserAdmin { - let accessFileDriveFloatingPanelController = AccessFileFloatingPanelViewController.instantiatePanel() - let floatingPanelViewController = accessFileDriveFloatingPanelController - .contentViewController as? AccessFileFloatingPanelViewController - floatingPanelViewController?.actionHandler = { [weak self] _ in - guard let self else { return } - floatingPanelViewController?.rightButton.setLoading(true) - Task { [proxyFile = file.proxify()] in - do { - let response = try await driveFileManager.apiFetcher.forceAccess(to: proxyFile) - if response { - accessFileDriveFloatingPanelController.dismiss(animated: true) - self.navigationController?.pushViewController(nextVC, animated: true) - } else { - UIConstants.showSnackBar(message: KDriveResourcesStrings.Localizable.errorRightModification) - } - } catch { - UIConstants.showSnackBarIfNeeded(error: error) + presentDirectory(for: file, driveFileManager: driveFileManager, animated: animated, completion: completion) + } else if file.isBookmark { + downloadAndPresentBookmark(for: file, completion: completion) + } else { + presentFile( + for: file, + files: files, + driveFileManager: driveFileManager, + normalFolderHierarchy: normalFolderHierarchy, + fromActivities: fromActivities, + animated: animated, + completion: completion + ) + } + } + + private func presentFile(for file: File, + files: [File], + driveFileManager: DriveFileManager, + normalFolderHierarchy: Bool, + fromActivities: Bool, + animated: Bool, + completion: ((Bool) -> Void)?) { + // Show file preview + let files = files.filter { !$0.isDirectory && !$0.isTrashed } + if let index = files.firstIndex(where: { $0.id == file.id }) { + let previewViewController = PreviewViewController.instantiate( + files: files, + index: Int(index), + driveFileManager: driveFileManager, + normalFolderHierarchy: normalFolderHierarchy, + fromActivities: fromActivities + ) + navigationController?.pushViewController(previewViewController, animated: animated) + completion?(true) + } + if file.isTrashed { + UIConstants.showSnackBar(message: KDriveResourcesStrings.Localizable.errorPreviewTrash) + completion?(false) + } + } + + private func presentDirectory( + for file: File, + driveFileManager: DriveFileManager, + animated: Bool, + completion: ((Bool) -> Void)? + ) { + // Show files list + let viewModel: FileListViewModel + if driveFileManager.drive.sharedWithMe { + viewModel = SharedWithMeViewModel(driveFileManager: driveFileManager, currentDirectory: file) + } else if file.isTrashed || file.deletedAt != nil { + viewModel = TrashListViewModel(driveFileManager: driveFileManager, currentDirectory: file) + } else { + viewModel = ConcreteFileListViewModel(driveFileManager: driveFileManager, currentDirectory: file) + } + let nextVC = FileListViewController.instantiate(viewModel: viewModel) + if file.isDisabled { + if driveFileManager.drive.isUserAdmin { + let accessFileDriveFloatingPanelController = AccessFileFloatingPanelViewController.instantiatePanel() + let floatingPanelViewController = accessFileDriveFloatingPanelController + .contentViewController as? AccessFileFloatingPanelViewController + floatingPanelViewController?.actionHandler = { [weak self] _ in + guard let self else { return } + floatingPanelViewController?.rightButton.setLoading(true) + Task { [proxyFile = file.proxify()] in + do { + let response = try await driveFileManager.apiFetcher.forceAccess(to: proxyFile) + if response { + accessFileDriveFloatingPanelController.dismiss(animated: true) + self.navigationController?.pushViewController(nextVC, animated: true) + } else { + UIConstants.showSnackBar(message: KDriveResourcesStrings.Localizable.errorRightModification) } + } catch { + UIConstants.showSnackBarIfNeeded(error: error) } } - viewController?.present(accessFileDriveFloatingPanelController, animated: true) - } else { - viewController?.present(NoAccessFloatingPanelViewController.instantiatePanel(), animated: true) } + viewController?.present(accessFileDriveFloatingPanelController, animated: true) } else { - navigationController?.pushViewController(nextVC, animated: animated) + viewController?.present(NoAccessFloatingPanelViewController.instantiatePanel(), animated: true) } - completion?(true) - } else if file.isBookmark { - // Open bookmark URL - if file.isMostRecentDownloaded { - presentBookmark(for: file, completion: completion) - } else { - // Download file - DownloadQueue.instance.temporaryDownload(file: file, userId: accountManager.currentUserId) { error in - Task { - if let error { - UIConstants.showSnackBarIfNeeded(error: error) - completion?(false) - } else { - self.presentBookmark(for: file, completion: completion) - } + } else { + navigationController?.pushViewController(nextVC, animated: animated) + } + completion?(true) + } + + private func downloadAndPresentBookmark(for file: File, completion: ((Bool) -> Void)?) { + // Open bookmark URL + if file.isMostRecentDownloaded { + presentBookmark(for: file, completion: completion) + } else { + // Download file + DownloadQueue.instance.temporaryDownload(file: file, userId: accountManager.currentUserId) { error in + Task { + if let error { + UIConstants.showSnackBarIfNeeded(error: error) + completion?(false) + } else { + self.presentBookmark(for: file, completion: completion) } } } - } else { - // Show file preview - let files = files.filter { !$0.isDirectory && !$0.isTrashed } - if let index = files.firstIndex(where: { $0.id == file.id }) { - let previewViewController = PreviewViewController.instantiate( - files: files, - index: Int(index), - driveFileManager: driveFileManager, - normalFolderHierarchy: normalFolderHierarchy, - fromActivities: fromActivities - ) - navigationController?.pushViewController(previewViewController, animated: animated) - completion?(true) - } - if file.isTrashed { - UIConstants.showSnackBar(message: KDriveResourcesStrings.Localizable.errorPreviewTrash) - completion?(false) - } } } - private func presentBookmark(for file: File, animated: Bool = true, completion: ((Bool) -> Void)? = nil) { + private func presentBookmark(for file: File, animated: Bool = true, completion: ((Bool) -> Void)?) { if let url = file.getBookmarkURL() { if url.scheme == "http" || url.scheme == "https" { let safariViewController = SFSafariViewController(url: url) diff --git a/kDrive/UI/Controller/Files/Preview/PreviewViewController.swift b/kDrive/UI/Controller/Files/Preview/PreviewViewController.swift index 90e52243d..500b0c28b 100644 --- a/kDrive/UI/Controller/Files/Preview/PreviewViewController.swift +++ b/kDrive/UI/Controller/Files/Preview/PreviewViewController.swift @@ -402,9 +402,9 @@ class PreviewViewController: UIViewController, PreviewContentCellDelegate { if currentFile.isBookmark { floatingPanelViewController.dismiss(animated: false) FilePresenter(viewController: self).present( - driveFileManager: driveFileManager, - file: currentFile, + for: currentFile, files: [], + driveFileManager: driveFileManager, normalFolderHierarchy: true ) { success in if !success { diff --git a/kDrive/UI/Controller/Files/RecentActivityFilesViewController.swift b/kDrive/UI/Controller/Files/RecentActivityFilesViewController.swift index ec7ad9407..f0b7c648e 100644 --- a/kDrive/UI/Controller/Files/RecentActivityFilesViewController.swift +++ b/kDrive/UI/Controller/Files/RecentActivityFilesViewController.swift @@ -113,9 +113,9 @@ class RecentActivityFilesViewController: FileListViewController { #if !ISEXTENSION if file.isDirectory { let managedFile = driveFileManager.getManagedFile(from: file.detached()) - filePresenter.present(driveFileManager: viewModel.driveFileManager, - file: managedFile, + filePresenter.present(for: managedFile, files: viewModel.getAllFiles(), + driveFileManager: viewModel.driveFileManager, normalFolderHierarchy: viewModel.configuration.normalFolderHierarchy, fromActivities: viewModel.configuration.fromActivities) } else { diff --git a/kDrive/UI/Controller/Files/Save File/SaveFileViewController+FooterButtonDelegate.swift b/kDrive/UI/Controller/Files/Save File/SaveFileViewController+FooterButtonDelegate.swift new file mode 100644 index 000000000..b0992a25c --- /dev/null +++ b/kDrive/UI/Controller/Files/Save File/SaveFileViewController+FooterButtonDelegate.swift @@ -0,0 +1,100 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakCoreUI +import InfomaniakDI +import kDriveCore +import kDriveResources +import UIKit + +// MARK: - FooterButtonDelegate + +extension SaveFileViewController: FooterButtonDelegate { + @objc func didClickOnButton(_ sender: AnyObject) { + guard let drive = selectedDriveFileManager?.drive, + let directory = selectedDirectory else { + return + } + + // Making sure the user cannot spam the button on tasks that may take a while + let button = sender as? IKLargeButton + button?.setLoading(true) + + let items = items + guard !items.isEmpty else { + dismiss(animated: true) + return + } + + Task { + await presentSnackBarSaveAndDismiss(files: items, directory: directory, drive: drive) + } + } + + private func presentSnackBarSaveAndDismiss(files: [ImportedFile], directory: File, drive: Drive) async { + let message: String + do { + try await processForUpload(files: files, directory: directory, drive: drive) + + message = files.count > 1 ? KDriveResourcesStrings.Localizable + .allUploadInProgressPlural(files.count) : KDriveResourcesStrings.Localizable + .allUploadInProgress(files[0].name) + } catch { + message = error.localizedDescription + } + + Task { @MainActor in + self.dismiss(animated: true, clean: false) { + UIConstants.showSnackBar(message: message) + } + } + } + + private func processForUpload(files: [ImportedFile], directory: File, drive: Drive) async throws { + // We only schedule for uploading in main app target + let addToQueue = !appContextService.isExtension + try await fileImportHelper.saveForUpload(files, in: directory, drive: drive, addToQueue: addToQueue) + #if ISEXTENSION + showOpenAppToContinueNotification() + #endif + } + + #if ISEXTENSION + // Dynamic hook to open an URL within an extension + @objc func openURL(_ url: URL) -> Bool { + var responder: UIResponder? = self + while responder != nil { + if let application = responder as? UIApplication { + return application.perform(#selector(openURL(_:)), with: url) != nil + } + responder = responder?.next + } + return false + } + + func showOpenAppToContinueNotification() { + guard openURL(URLConstants.kDriveRedirection.url) else { + // Fallback on a local notification if failure to open URL + @InjectService var notificationHelper: NotificationsHelpable + notificationHelper.sendPausedUploadQueueNotification() + return + } + } + #endif +} diff --git a/kDrive/UI/Controller/Files/Save File/SaveFileViewController.swift b/kDrive/UI/Controller/Files/Save File/SaveFileViewController.swift index fe0d1116a..8384f9293 100644 --- a/kDrive/UI/Controller/Files/Save File/SaveFileViewController.swift +++ b/kDrive/UI/Controller/Files/Save File/SaveFileViewController.swift @@ -28,6 +28,7 @@ import UIKit class SaveFileViewController: UIViewController { @LazyInjectService var accountManager: AccountManageable @LazyInjectService var fileImportHelper: FileImportHelper + @LazyInjectService var appContextService: AppContextServiceable enum SaveFileSection { case alert @@ -181,6 +182,19 @@ class SaveFileViewController: UIViewController { } } + func dismiss(animated: Bool, clean: Bool = true, completion: (() -> Void)? = nil) { + Task { + // Cleanup file that were duplicated to appGroup on extension mode + if appContextService.isExtension && clean { + await items.concurrentForEach { item in + try? FileManager.default.removeItem(at: item.path) + } + } + + navigationController?.dismiss(animated: animated, completion: completion) + } + } + deinit { NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) @@ -275,7 +289,7 @@ class SaveFileViewController: UIViewController { @IBAction func close(_ sender: Any) { importProgress?.cancel() - navigationController?.dismiss(animated: true) + dismiss(animated: true) } } @@ -474,43 +488,3 @@ extension SaveFileViewController: SelectPhotoFormatDelegate { } } } - -// MARK: - FooterButtonDelegate - -extension SaveFileViewController: FooterButtonDelegate { - @objc func didClickOnButton(_ sender: AnyObject) { - guard let selectedDriveFileManager, - let selectedDirectory else { - return - } - - // Making sure the user cannot spam the button on tasks that may take a while - let button = sender as? IKLargeButton - button?.setLoading(true) - - let items = items - guard !items.isEmpty else { - navigationController?.dismiss(animated: true) - return - } - - Task { - let message: String - do { - try await fileImportHelper.upload(files: items, in: selectedDirectory, drive: selectedDriveFileManager.drive) - - message = items.count > 1 ? KDriveResourcesStrings.Localizable - .allUploadInProgressPlural(items.count) : KDriveResourcesStrings.Localizable - .allUploadInProgress(items[0].name) - } catch { - message = error.localizedDescription - } - - Task { @MainActor in - self.navigationController?.dismiss(animated: true) { - UIConstants.showSnackBar(message: message) - } - } - } - } -} diff --git a/kDrive/UI/Controller/Home/HomeViewController.swift b/kDrive/UI/Controller/Home/HomeViewController.swift index eec71e634..edab7ecef 100644 --- a/kDrive/UI/Controller/Home/HomeViewController.swift +++ b/kDrive/UI/Controller/Home/HomeViewController.swift @@ -712,9 +712,9 @@ extension HomeViewController { switch viewModel.recentFiles { case .file(let files): filePresenter.present( - driveFileManager: driveFileManager, - file: files[indexPath.row], + for: files[indexPath.row], files: files, + driveFileManager: driveFileManager, normalFolderHierarchy: false ) case .fileActivity: @@ -788,9 +788,9 @@ extension HomeViewController: RecentActivityDelegate { filePresenter.navigationController?.pushViewController(nextVC, animated: true) } else { filePresenter.present( - driveFileManager: driveFileManager, - file: driveFileManager.getManagedFile(from: file), + for: driveFileManager.getManagedFile(from: file), files: activities.compactMap(\.file), + driveFileManager: driveFileManager, normalFolderHierarchy: false ) } diff --git a/kDrive/UI/Controller/MainTabViewController.swift b/kDrive/UI/Controller/MainTabViewController.swift index e74857a7b..7e98e006c 100644 --- a/kDrive/UI/Controller/MainTabViewController.swift +++ b/kDrive/UI/Controller/MainTabViewController.swift @@ -277,14 +277,15 @@ extension MainTabViewController: UIDocumentPickerDelegate { } try FileManager.default.moveItem(at: url, to: targetURL) - uploadQueue.saveToRealmAndAddToQueue(uploadFile: + uploadQueue.saveToRealm( UploadFile( parentDirectoryId: documentPicker.importDriveDirectory.id, userId: accountManager.currentUserId, driveId: documentPicker.importDrive.id, url: targetURL, name: url.lastPathComponent - )) + ) + ) } catch { UIConstants.showSnackBarIfNeeded(error: DriveError.unknownError) } diff --git a/kDrive/UI/Controller/RegisterViewController.swift b/kDrive/UI/Controller/RegisterViewController.swift index ee948fcbc..3abaa7b2e 100644 --- a/kDrive/UI/Controller/RegisterViewController.swift +++ b/kDrive/UI/Controller/RegisterViewController.swift @@ -123,7 +123,7 @@ extension RegisterViewController: WKNavigationDelegate { ) { guard let host = navigationAction.request.url?.host, let kDriveHost = URLConstants.kDriveWeb.url.host, - let loginHost = URL(string: InfomaniakCore.Constants.LOGIN_URL)?.host else { + let loginHost = infomaniakLogin.config.loginURL.host else { decisionHandler(.allow) return } diff --git a/kDrive/UI/View/Files/FileCollectionViewCell.swift b/kDrive/UI/View/Files/FileCollectionViewCell.swift index 548cb3c02..479795f89 100644 --- a/kDrive/UI/View/Files/FileCollectionViewCell.swift +++ b/kDrive/UI/View/Files/FileCollectionViewCell.swift @@ -47,6 +47,7 @@ protocol FileCellDelegate: AnyObject { ] var file: File var selectionMode: Bool + var isSelected = false private var downloadProgressObserver: ObservationToken? private var downloadObserver: ObservationToken? var thumbnailDownloadTask: Kingfisher.DownloadTask? @@ -148,7 +149,8 @@ protocol FileCellDelegate: AnyObject { // Fetch thumbnail thumbnailDownloadTask = file.getThumbnail { [requestFileId = file.id, weak self] image, _ in guard let self, - !self.file.isInvalidated else { + !self.file.isInvalidated, + !self.isSelected else { return } @@ -190,12 +192,13 @@ class FileCollectionViewCell: UICollectionViewCell, SwipableCell { @IBOutlet weak var downloadProgressView: RPCircularProgress? @IBOutlet weak var highlightedView: UIView! - var viewModel: FileViewModel! + var viewModel: FileViewModel? weak var delegate: FileCellDelegate? override var isSelected: Bool { didSet { + viewModel?.isSelected = isSelected configureForSelection() } } @@ -323,13 +326,14 @@ class FileCollectionViewCell: UICollectionViewCell, SwipableCell { /// Update the cell selection mode. /// - Parameter selectionMode: The new selection mode (enabled/disabled). func setSelectionMode(_ selectionMode: Bool) { - guard viewModel != nil else { return } + guard let viewModel else { return } viewModel.selectionMode = selectionMode configure(with: viewModel) } func configureForSelection() { guard viewModel?.selectionMode == true else { return } + if isSelected { configureCheckmarkImage() configureImport(shouldDisplay: false) @@ -339,6 +343,7 @@ class FileCollectionViewCell: UICollectionViewCell, SwipableCell { } private func configureLogoImage() { + guard let viewModel else { return } logoImage.isAccessibilityElement = true logoImage.accessibilityLabel = viewModel.iconAccessibilityLabel logoImage.image = viewModel.icon @@ -360,6 +365,8 @@ class FileCollectionViewCell: UICollectionViewCell, SwipableCell { } func configureImport(shouldDisplay: Bool) { + guard let viewModel else { return } + if shouldDisplay && viewModel.isImporting { logoImage.isHidden = true importProgressView?.isHidden = false @@ -391,10 +398,13 @@ class FileCollectionViewCell: UICollectionViewCell, SwipableCell { extension FileCollectionViewCell: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return min(viewModel.categories.count, 3) + return min(viewModel?.categories.count ?? 0, 3) } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let viewModel else { + return UICollectionViewCell() + } let cell = collectionView.dequeueReusableCell(type: CategoryBadgeCollectionViewCell.self, for: indexPath) let category = viewModel.categories[indexPath.row] let more = indexPath.item == 2 && viewModel.categories.count > 3 ? viewModel.categories.count - 3 : nil diff --git a/kDrive/Utils/AppFactoryService.swift b/kDrive/Utils/AppFactoryService.swift index 5b63f7ac8..155c601c1 100644 --- a/kDrive/Utils/AppFactoryService.swift +++ b/kDrive/Utils/AppFactoryService.swift @@ -23,14 +23,16 @@ import os.log /// Something that loads the DI on init public struct EarlyDIHook { - public init() { - // setup DI ASAP + public init(context: DriveAppContext) { + os_log("EarlyDIHook") - let navigationManagerFactory = Factory(type: NavigationManageable.self) { _, _ in + let extraDependencies = [Factory(type: NavigationManageable.self) { _, _ in NavigationManager() - } + }, Factory(type: AppContextServiceable.self) { _, _ in + AppContextService(context: context) + }] - os_log("EarlyDIHook") - FactoryService.setupDependencyInjection(other: [navigationManagerFactory]) + // setup DI ASAP + FactoryService.setupDependencyInjection(other: extraDependencies) } } diff --git a/kDrive/Utils/RootViewControllerState.swift b/kDrive/Utils/RootViewControllerState.swift index 03722e027..ca43a476e 100644 --- a/kDrive/Utils/RootViewControllerState.swift +++ b/kDrive/Utils/RootViewControllerState.swift @@ -26,6 +26,7 @@ enum RootViewControllerState { case onboarding case appLock case mainViewController(DriveFileManager) + case updateRequired static func getCurrentState() -> RootViewControllerState { @InjectService var accountManager: AccountManageable diff --git a/kDriveActionExtension/ActionNavigationController.swift b/kDriveActionExtension/ActionNavigationController.swift index d705249ed..0cd4090d1 100644 --- a/kDriveActionExtension/ActionNavigationController.swift +++ b/kDriveActionExtension/ActionNavigationController.swift @@ -21,10 +21,11 @@ import InfomaniakDI import InfomaniakLogin import kDriveCore import UIKit +import VersionChecker final class ActionNavigationController: TitleSizeAdjustingNavigationController { /// Making sure the DI is registered at a very early stage of the app launch. - private let dependencyInjectionHook = EarlyDIHook() + private let dependencyInjectionHook = EarlyDIHook(context: .actionExtension) @LazyInjectService var accountManager: AccountManageable @@ -44,6 +45,10 @@ final class ActionNavigationController: TitleSizeAdjustingNavigationController { saveFileViewController.itemProviders = itemProviders viewControllers = [saveFileViewController] + + Task { + try? await checkAppVersion() + } } override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { @@ -52,4 +57,15 @@ final class ActionNavigationController: TitleSizeAdjustingNavigationController { } extensionContext.completeRequest(returningItems: extensionContext.inputItems, completionHandler: nil) } + + private func checkAppVersion() async throws { + guard try await VersionChecker.standard.checkAppVersionStatus() == .updateIsRequired else { return } + Task { @MainActor in + let updateRequiredViewController = DriveUpdateRequiredViewController() + updateRequiredViewController.dismissHandler = { [weak self] in + self?.dismiss(animated: true) + } + viewControllers = [updateRequiredViewController] + } + } } diff --git a/kDriveCore/DI/FactoryService.swift b/kDriveCore/DI/FactoryService.swift index f6465660c..cf10e9340 100644 --- a/kDriveCore/DI/FactoryService.swift +++ b/kDriveCore/DI/FactoryService.swift @@ -29,6 +29,7 @@ public typealias FactoryWithIdentifier = (factory: Factory, identifier: String?) private let appGroupName = "group.com.infomaniak.drive" private let realmRootPath = "drives" +private let loginConfig = InfomaniakLogin.Config(clientId: "9473D73C-C20F-4971-9E10-D957C563FA68", accessType: nil) /// Something that setups the service factories public enum FactoryService { @@ -42,9 +43,7 @@ public enum FactoryService { private static var networkingServices: [Factory] { let services = [ Factory(type: InfomaniakNetworkLogin.self) { _, _ in - let clientId = "9473D73C-C20F-4971-9E10-D957C563FA68" - let redirectUri = "com.infomaniak.drive://oauth2redirect" - return InfomaniakNetworkLogin(clientId: clientId, redirectUri: redirectUri) + return InfomaniakNetworkLogin(config: loginConfig) }, Factory(type: InfomaniakNetworkLoginable.self) { _, resolver in try resolver.resolve(type: InfomaniakNetworkLogin.self, @@ -53,7 +52,7 @@ public enum FactoryService { resolver: resolver) }, Factory(type: InfomaniakLoginable.self) { _, _ in - InfomaniakLogin(clientId: DriveApiFetcher.clientId) + InfomaniakLogin(config: loginConfig) }, Factory(type: InfomaniakTokenable.self) { _, resolver in try resolver.resolve(type: InfomaniakLoginable.self, @@ -133,6 +132,9 @@ public enum FactoryService { }, Factory(type: PhotoLibrarySavable.self) { _, _ in PhotoLibrarySaver() + }, + Factory(type: BackgroundTasksServiceable.self) { _, _ in + BackgroundTasksService() } ] return services diff --git a/kDriveCore/Data/Api/DriveApiFetcher.swift b/kDriveCore/Data/Api/DriveApiFetcher.swift index 40c982fe5..a73afec89 100644 --- a/kDriveCore/Data/Api/DriveApiFetcher.swift +++ b/kDriveCore/Data/Api/DriveApiFetcher.swift @@ -52,8 +52,6 @@ public class AuthenticatedImageRequestModifier: ImageDownloadRequestModifier { } public class DriveApiFetcher: ApiFetcher { - public static let clientId = "9473D73C-C20F-4971-9E10-D957C563FA68" - @LazyInjectService var accountManager: AccountManageable @LazyInjectService var tokenable: InfomaniakTokenable @@ -467,6 +465,7 @@ public class DriveApiFetcher: ApiFetcher { class SyncedAuthenticator: OAuthAuthenticator { @LazyInjectService var accountManager: AccountManageable @LazyInjectService var tokenable: InfomaniakTokenable + @LazyInjectService var appContextService: AppContextServiceable override func refresh( _ credential: OAuthAuthenticator.Credential, @@ -487,21 +486,29 @@ class SyncedAuthenticator: OAuthAuthenticator { return } - // Maybe someone else refreshed our token - self.accountManager.reloadTokensAndAccounts() - if let token = self.accountManager.getTokenForUserId(credential.userId), - token.expirationDate > credential.expirationDate { - let message = "Refreshing token - Success with local" - SentryDebug.addBreadcrumb(message: message, category: .apiToken, level: .info, metadata: metadata) - - completion(.success(token)) - return + if let storedToken = self.accountManager.getTokenForUserId(credential.userId) { + // Someone else refreshed our token and we already have an infinite token + if storedToken.expirationDate == nil && credential.expirationDate != nil { + let message = "Refreshing token - Success with local (infinite)" + SentryDebug.addBreadcrumb(message: message, category: .apiToken, level: .info, metadata: metadata) + completion(.success(storedToken)) + return + } + // Someone else refreshed our token and we don't have an infinite token + if let storedTokenExpirationDate = storedToken.expirationDate, + let tokenExpirationDate = credential.expirationDate, + tokenExpirationDate > storedTokenExpirationDate { + let message = "Refreshing token - Success with local" + SentryDebug.addBreadcrumb(message: message, category: .apiToken, level: .info, metadata: metadata) + completion(.success(storedToken)) + return + } } let group = DispatchGroup() group.enter() var taskIdentifier: UIBackgroundTaskIdentifier = .invalid - if !Bundle.main.isExtension { + if !self.appContextService.isExtension { // It is absolutely necessary that the app stays awake while we refresh the token taskIdentifier = UIApplication.shared.beginBackgroundTask(withName: "Refresh token") { let message = "Refreshing token failed - Background task expired" diff --git a/kDriveCore/Data/Cache/AccountManager.swift b/kDriveCore/Data/Cache/AccountManager.swift index d97382116..017900cd1 100644 --- a/kDriveCore/Data/Cache/AccountManager.swift +++ b/kDriveCore/Data/Cache/AccountManager.swift @@ -33,19 +33,6 @@ public protocol AccountManagerDelegate: AnyObject { } public extension InfomaniakLogin { - static func apiToken(username: String, applicationPassword: String) async throws -> ApiToken { - try await withCheckedThrowingContinuation { continuation in - @InjectService var tokenable: InfomaniakTokenable - tokenable.getApiToken(username: username, applicationPassword: applicationPassword) { token, error in - if let token { - continuation.resume(returning: token) - } else { - continuation.resume(throwing: error ?? DriveError.unknownError) - } - } - } - } - static func apiToken(using code: String, codeVerifier: String) async throws -> ApiToken { try await withCheckedThrowingContinuation { continuation in @InjectService var tokenable: InfomaniakTokenable @@ -270,11 +257,13 @@ public class AccountManager: RefreshTokenDelegate, AccountManageable { } public func didUpdateToken(newToken: ApiToken, oldToken: ApiToken) { + SentryDebug.logTokenMigration(newToken: newToken, oldToken: oldToken) updateToken(newToken: newToken, oldToken: oldToken) } public func didFailRefreshToken(_ token: ApiToken) { - let context = ["User id": token.userId, "Expiration date": token.expirationDate.timeIntervalSince1970] as [String: Any] + let context = ["User id": token.userId, + "Expiration date": token.expirationDate?.timeIntervalSince1970 ?? "Infinite"] as [String: Any] SentryDebug.capture(message: "Failed refreshing token", context: context, contextKey: "Token Infos") tokens.removeAll { $0.userId == token.userId } diff --git a/kDriveCore/Data/DownloadQueue/DownloadArchiveOperation.swift b/kDriveCore/Data/DownloadQueue/DownloadArchiveOperation.swift index 7244844b0..a49585b40 100644 --- a/kDriveCore/Data/DownloadQueue/DownloadArchiveOperation.swift +++ b/kDriveCore/Data/DownloadQueue/DownloadArchiveOperation.swift @@ -26,6 +26,7 @@ public class DownloadArchiveOperation: Operation { // MARK: - Attributes @LazyInjectService var accountManager: AccountManageable + @LazyInjectService var appContextService: AppContextServiceable private let archiveId: String private let driveFileManager: DriveFileManager @@ -85,7 +86,7 @@ public class DownloadArchiveOperation: Operation { return } - if !Bundle.main.isExtension { + if !appContextService.isExtension { backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask(withName: "File Archive Downloader") { DownloadQueue.instance.suspendAllOperations() // We don't support task rescheduling for archive download but still need to pass error to differentiate from user diff --git a/kDriveCore/Data/DownloadQueue/DownloadOperation.swift b/kDriveCore/Data/DownloadQueue/DownloadOperation.swift index 1c158377a..4293d1491 100644 --- a/kDriveCore/Data/DownloadQueue/DownloadOperation.swift +++ b/kDriveCore/Data/DownloadQueue/DownloadOperation.swift @@ -36,6 +36,7 @@ public class DownloadOperation: Operation, DownloadOperationable { @LazyInjectService var accountManager: AccountManageable @LazyInjectService var downloadManager: BackgroundDownloadSessionManager + @LazyInjectService var appContextService: AppContextServiceable private let fileManager = FileManager.default private let driveFileManager: DriveFileManager @@ -116,7 +117,7 @@ public class DownloadOperation: Operation, DownloadOperationable { return } - if !Bundle.main.isExtension { + if !appContextService.isExtension { backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask(withName: "File Downloader") { DownloadQueue.instance.suspendAllOperations() DDLogInfo("[DownloadOperation] Background task expired") diff --git a/kDriveCore/Data/DownloadQueue/DownloadQueue.swift b/kDriveCore/Data/DownloadQueue/DownloadQueue.swift index 2823e9b85..6a61752c6 100644 --- a/kDriveCore/Data/DownloadQueue/DownloadQueue.swift +++ b/kDriveCore/Data/DownloadQueue/DownloadQueue.swift @@ -58,6 +58,7 @@ public final class DownloadQueue { // MARK: - Attributes @LazyInjectService var accountManager: AccountManageable + @LazyInjectService var appContextService: AppContextServiceable public static let instance = DownloadQueue() public static let backgroundIdentifier = "com.infomaniak.background.download" @@ -90,7 +91,7 @@ public final class DownloadQueue { ) private var bestSession: FileDownloadSession { - if Bundle.main.isExtension { + if appContextService.isExtension { @InjectService var backgroundDownloadSessionManager: BackgroundDownloadSessionManager return backgroundDownloadSessionManager } else { @@ -123,7 +124,7 @@ public final class DownloadQueue { self.dispatchQueue.async { self.operationsInQueue.removeValue(forKey: fileId) self.publishFileDownloaded(fileId: fileId, error: operation.error) - OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: self.operationsInQueue.isEmpty) + OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: !self.operationsInQueue.isEmpty) } } self.operationQueue.addOperation(operation) @@ -149,7 +150,7 @@ public final class DownloadQueue { self.dispatchQueue.async { self.archiveOperationsInQueue.removeValue(forKey: archiveId) self.publishArchiveDownloaded(archiveId: archiveId, archiveUrl: operation.archiveUrl, error: operation.error) - OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: self.operationsInQueue.isEmpty) + OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: !self.operationsInQueue.isEmpty) } } self.operationQueue.addOperation(operation) @@ -179,7 +180,7 @@ public final class DownloadQueue { operation.completionBlock = { self.dispatchQueue.async { self.operationsInQueue.removeValue(forKey: fileId) - OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: self.operationsInQueue.isEmpty) + OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: !self.operationsInQueue.isEmpty) completion(operation.error) } } @@ -220,25 +221,25 @@ public final class DownloadQueue { } private func publishFileDownloaded(fileId: Int, error: DriveError?) { - observations.didDownloadFile.values.forEach { closure in + for closure in observations.didDownloadFile.values { closure(fileId, error) } } func publishProgress(_ progress: Double, for fileId: Int) { - observations.didChangeProgress.values.forEach { closure in + for closure in observations.didChangeProgress.values { closure(fileId, progress) } } private func publishArchiveDownloaded(archiveId: String, archiveUrl: URL?, error: DriveError?) { - observations.didDownloadArchive.values.forEach { closure in + for closure in observations.didDownloadArchive.values { closure(archiveId, archiveUrl, error) } } func publishProgress(_ progress: Double, for archiveId: String) { - observations.didChangeArchiveProgress.values.forEach { closure in + for closure in observations.didChangeArchiveProgress.values { closure(archiveId, progress) } } diff --git a/kDriveCore/Data/Models/Upload/UploadFile.swift b/kDriveCore/Data/Models/Upload/UploadFile.swift index 1e86833ba..cf95dfcd5 100644 --- a/kDriveCore/Data/Models/Upload/UploadFile.swift +++ b/kDriveCore/Data/Models/Upload/UploadFile.swift @@ -171,6 +171,18 @@ public final class UploadFile: Object, UploadFilable { // primary key is set as default value } + /// Init method of the UploadFile object + /// - Parameters: + /// - parentDirectoryId: Parent directory. + /// - userId: linked userId. + /// - driveId: linked driveId. + /// - fileProviderItemIdentifier: optional identifier from fileProvider. + /// - url: the url of the file to be uploaded. + /// - name: the name to be used. + /// - conflictOption: How to resolve an upload conflict with the API. + /// - shouldRemoveAfterUpload: remove after the upload in finished. + /// - initiatedFromFileManager: true if created from file manager. + /// - priority: The relative priority of the upload within the upload queue, defaults to `.high`. public init( parentDirectoryId: Int, userId: Int, @@ -181,7 +193,7 @@ public final class UploadFile: Object, UploadFilable { conflictOption: ConflictOption = .rename, shouldRemoveAfterUpload: Bool = true, initiatedFromFileManager: Bool = false, - priority: Operation.QueuePriority = .normal + priority: Operation.QueuePriority = .high ) { super.init() // primary key is set as default value @@ -311,8 +323,6 @@ public extension UploadFile { error = nil // Reset retry count to default maxRetryCount = UploadFile.defaultMaxRetryCount - // Assign the UploadFile to the main app, not the FileManager extension - initiatedFromFileManager = false } } diff --git a/kDriveCore/Data/UploadQueue/Chunk/RangeProviderGuts.swift b/kDriveCore/Data/UploadQueue/Chunk/RangeProviderGuts.swift index d120c47cb..6a7664da2 100644 --- a/kDriveCore/Data/UploadQueue/Chunk/RangeProviderGuts.swift +++ b/kDriveCore/Data/UploadQueue/Chunk/RangeProviderGuts.swift @@ -17,6 +17,7 @@ */ import Foundation +import InfomaniakDI /// The internal methods of RangeProviderGuts, made testable public protocol RangeProviderGutsable { @@ -45,6 +46,8 @@ public protocol RangeProviderGutsable { /// Subdivided **RangeProvider**, so it is easier to test public struct RangeProviderGuts: RangeProviderGutsable { + @LazyInjectService private var appContextService: AppContextServiceable + /// The URL of the local file to scan public let fileURL: URL @@ -107,7 +110,7 @@ public struct RangeProviderGuts: RangeProviderGutsable { public func preferredChunkSize(for fileSize: UInt64) -> UInt64 { // In extension to reduce memory footprint, we reduce drastically chunk size - guard !Bundle.main.isExtension else { + guard !appContextService.isExtension else { let capChunkSize = min(fileSize, RangeProvider.APIConstants.chunkMinSize) return capChunkSize } diff --git a/kDriveCore/Data/UploadQueue/Operation/UploadOperation.swift b/kDriveCore/Data/UploadQueue/Operation/UploadOperation.swift index be1c90bd8..8124b70bc 100644 --- a/kDriveCore/Data/UploadQueue/Operation/UploadOperation.swift +++ b/kDriveCore/Data/UploadQueue/Operation/UploadOperation.swift @@ -245,7 +245,7 @@ public final class UploadOperation: AsynchronousOperation, UploadOperationable { Log.uploadOperation("call finish ufid:\(uploadFileId)") // Make sure we stop the expiring activity - self.expiringActivity?.end() + self.expiringActivity?.endAll() // Make sure we stop all the network requests (if any) self.cancelAllUploadRequests() diff --git a/kDriveCore/Data/UploadQueue/Queue/UploadParallelismHeuristic.swift b/kDriveCore/Data/UploadQueue/Queue/UploadParallelismHeuristic.swift new file mode 100644 index 000000000..5affeb1e8 --- /dev/null +++ b/kDriveCore/Data/UploadQueue/Queue/UploadParallelismHeuristic.swift @@ -0,0 +1,108 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakDI + +/// Delegate protocol of UploadParallelismHeuristic +protocol ParallelismHeuristicDelegate: AnyObject { + /// This method is called with a new parallelism to apply each time to the uploadQueue + /// - Parameter value: The new parallelism value to use + func parallelismShouldChange(value: Int) +} + +/// Something to maintain a coherent parallelism value for the UploadQueue +/// +/// Value can change depending on many factors, including thermal state battery or extension mode. +/// Scaling is achieved given the number of active cores available. +final class UploadParallelismHeuristic { + /// With 2 Operations max, and a chuck of 1MiB max, the UploadQueue can spike to max 4MiB memory usage. + private static let reducedParallelism = 2 + + @LazyInjectService private var appContextService: AppContextServiceable + + private weak var delegate: ParallelismHeuristicDelegate? + + init(delegate: ParallelismHeuristicDelegate) { + self.delegate = delegate + + // Update on thermal change + NotificationCenter.default.addObserver( + self, + selector: #selector(computeParallelism), + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + + // Update on low power mode + NotificationCenter.default.addObserver( + self, + selector: #selector(computeParallelism), + name: NSNotification.Name.NSProcessInfoPowerStateDidChange, + object: nil + ) + + // Update the value a first time + computeParallelism() + } + + deinit { + NotificationCenter.default.removeObserver(self, name: ProcessInfo.thermalStateDidChangeNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: NSNotification.Name.NSProcessInfoPowerStateDidChange, object: nil) + } + + @objc private func computeParallelism() { + let processInfo = ProcessInfo.processInfo + + // If the device is too hot we cool down now + let thermalState = processInfo.thermalState + guard thermalState != .critical else { + currentParallelism = Self.reducedParallelism + return + } + + // In low power mode, we reduce parallelism + guard !processInfo.isLowPowerModeEnabled else { + currentParallelism = Self.reducedParallelism + return + } + + // In extension, to reduce memory footprint, we reduce drastically parallelism + guard !appContextService.isExtension else { + currentParallelism = Self.reducedParallelism + return + } + + // Scaling with the number of activeProcessor + let parallelism = max(4, processInfo.activeProcessorCount) + + // Beginning with .serious state, we start reducing the load on the system + guard thermalState != .serious else { + currentParallelism = max(Self.reducedParallelism, parallelism / 2) + return + } + + currentParallelism = parallelism + } + + public private(set) var currentParallelism = 0 { + didSet { + delegate?.parallelismShouldChange(value: currentParallelism) + } + } +} diff --git a/kDriveCore/Data/UploadQueue/Queue/UploadQueue+Queue.swift b/kDriveCore/Data/UploadQueue/Queue/UploadQueue+Queue.swift index 68d0a3c43..93752b193 100644 --- a/kDriveCore/Data/UploadQueue/Queue/UploadQueue+Queue.swift +++ b/kDriveCore/Data/UploadQueue/Queue/UploadQueue+Queue.swift @@ -28,7 +28,14 @@ public protocol UploadQueueable { /// Read database to enqueue all non finished upload tasks. func rebuildUploadQueueFromObjectsInRealm(_ caller: StaticString) - func saveToRealmAndAddToQueue(uploadFile: UploadFile, itemIdentifier: NSFileProviderItemIdentifier?) -> UploadOperationable? + /// Save an UploadFile in base and optionally enqueue the upload in main app + /// - Parameters: + /// - uploadFile: The upload file to be processed + /// - itemIdentifier: Optional item identifier + /// - addToQueue: Should we schedule the upload as well ? + /// - Returns: An UploadOperation if any + func saveToRealm(_ uploadFile: UploadFile, itemIdentifier: NSFileProviderItemIdentifier?, addToQueue: Bool) + -> UploadOperationable? func suspendAllOperations() @@ -82,18 +89,28 @@ extension UploadQueue: UploadQueueable { public func getOperation(forUploadFileId uploadFileId: String) -> UploadOperationable? { Log.uploadQueue("getOperation ufid:\(uploadFileId)") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return nil + } + let operation = operation(uploadFileId: uploadFileId) return operation } public func rebuildUploadQueueFromObjectsInRealm(_ caller: StaticString = #function) { Log.uploadQueue("rebuildUploadQueueFromObjectsInRealm caller:\(caller)") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + concurrentQueue.sync { var uploadingFileIds = [String]() try? self.transactionWithUploadRealm { realm in - // Not uploaded yet, And can retry, And not initiated from the Files.app + // Not uploaded yet, And can retry. let uploadingFiles = realm.objects(UploadFile.self) - .filter("uploadDate = nil AND maxRetryCount > 0 AND initiatedFromFileManager = false") + .filter("uploadDate = nil AND maxRetryCount > 0") .sorted(byKeyPath: "taskCreationDate") uploadingFileIds = uploadingFiles.map(\.id) Log.uploadQueue("rebuildUploadQueueFromObjectsInRealm uploads to restart:\(uploadingFileIds.count)") @@ -104,10 +121,10 @@ extension UploadQueue: UploadQueueable { for batch in batches { Log.uploadQueue("rebuildUploadQueueFromObjectsInRealm in batch") try? self.transactionWithUploadRealm { realm in - batch.forEach { fileId in + for fileId in batch { guard let file = realm.object(ofType: UploadFile.self, forPrimaryKey: fileId), !file.isInvalidated else { - return + continue } self.addToQueueIfNecessary(uploadFile: file, using: realm) } @@ -120,9 +137,13 @@ extension UploadQueue: UploadQueueable { } @discardableResult - public func saveToRealmAndAddToQueue(uploadFile: UploadFile, - itemIdentifier: NSFileProviderItemIdentifier? = nil) -> UploadOperationable? { - Log.uploadQueue("saveToRealmAndAddToQueue ufid:\(uploadFile.id)") + public func saveToRealm(_ uploadFile: UploadFile, + itemIdentifier: NSFileProviderItemIdentifier? = nil, + addToQueue: Bool = true) -> UploadOperationable? { + let expiringActivity = ExpiringActivity() + expiringActivity.start() + + Log.uploadQueue("saveToRealm addToQueue:\(addToQueue) ufid:\(uploadFile.id)") assert(!uploadFile.isManagedByRealm, "we expect the file to be outside of realm at the moment") @@ -146,32 +167,55 @@ extension UploadQueue: UploadQueueable { Log.uploadQueue("did save ufid:\(uploadFile.id)") } - // Process adding a detached file to the uploadQueue - var uploadOperation: UploadOperation? - try? transactionWithUploadRealm { realm in - uploadOperation = self.addToQueue(uploadFile: detachedFile, itemIdentifier: itemIdentifier, using: realm) + guard addToQueue else { + expiringActivity.endAll() + return nil + } + + guard appContextService.context != .shareExtension else { + Log.uploadQueue("addToQueue disabled in ShareExtension", level: .error) + return nil } + // Process adding a detached file to the uploadQueue + let uploadOperation = self.addToQueue(uploadFile: detachedFile, itemIdentifier: itemIdentifier) + expiringActivity.endAll() + return uploadOperation } public func suspendAllOperations() { Log.uploadQueue("suspendAllOperations") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + forceSuspendQueue = true operationQueue.isSuspended = true } public func resumeAllOperations() { Log.uploadQueue("resumeAllOperations") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + forceSuspendQueue = false operationQueue.isSuspended = shouldSuspendQueue } public func rescheduleRunningOperations() { Log.uploadQueue("rescheduleRunningOperations") - operationQueue.operations.filter(\.isExecuting).forEach { operation in + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + + for operation in operationQueue.operations.filter(\.isExecuting) { guard let uploadOperation = operation as? UploadOperation else { - return + continue } // Mark the operation as rescheduled @@ -181,12 +225,22 @@ extension UploadQueue: UploadQueueable { public func cancelRunningOperations() { Log.uploadQueue("cancelRunningOperations") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + operationQueue.operations.filter(\.isExecuting).forEach { $0.cancel() } } @discardableResult public func cancel(uploadFileId: String) -> Bool { Log.uploadQueue("cancel uploadFileId:\(uploadFileId)") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return false + } + var found = false concurrentQueue.sync { try? self.transactionWithUploadRealm { realm in @@ -204,6 +258,11 @@ extension UploadQueue: UploadQueueable { public func cancel(uploadFile: UploadFile) { Log.uploadQueue("cancel UploadFile ufid:\(uploadFile.id)") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + let uploadFileId = uploadFile.id let userId = uploadFile.userId let parentId = uploadFile.parentDirectoryId @@ -240,6 +299,11 @@ extension UploadQueue: UploadQueueable { public func cancelAllOperations(withParent parentId: Int, userId: Int, driveId: Int) { Log.uploadQueue("cancelAllOperations parentId:\(parentId)") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + concurrentQueue.async { Log.uploadQueue("suspend queue") self.suspendAllOperations() @@ -266,7 +330,7 @@ extension UploadQueue: UploadQueueable { let batches = uploadingFilesIds.chunks(ofCount: 100) for fileIds in batches { autoreleasepool { - fileIds.forEach { id in + for id in fileIds { // Cancel operation if any if let operation = self.keyedUploadOperations.getObject(forKey: id) { operation.cancel() @@ -289,11 +353,16 @@ extension UploadQueue: UploadQueueable { public func cleanNetworkAndLocalErrorsForAllOperations() { Log.uploadQueue("cleanErrorsForAllOperations") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + concurrentQueue.sync { try? self.transactionWithUploadRealm { realm in - // UploadFile with an error, Or no more retry, Or is initiatedFromFileManager + // UploadFile with an error, Or no more retry. let failedUploadFiles = realm.objects(UploadFile.self) - .filter("_error != nil OR maxRetryCount <= 0 OR initiatedFromFileManager = true") + .filter("_error != nil OR maxRetryCount <= 0") .filter { file in guard let error = file.error else { return false @@ -304,7 +373,7 @@ extension UploadQueue: UploadQueueable { Log.uploadQueue("will clean errors for uploads:\(failedUploadFiles.count)") try? realm.safeWrite { - failedUploadFiles.forEach { file in + for file in failedUploadFiles { file.clearErrorsForRetry() } } @@ -315,6 +384,11 @@ extension UploadQueue: UploadQueueable { public func retry(_ uploadFileId: String) { Log.uploadQueue("retry ufid:\(uploadFileId)") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + concurrentQueue.async { try? self.transactionWithUploadRealm { realm in guard let file = realm.object(ofType: UploadFile.self, forPrimaryKey: uploadFileId), !file.isInvalidated else { @@ -341,7 +415,7 @@ extension UploadQueue: UploadQueueable { return } - self.addToQueue(uploadFile: file, using: realm) + self.addToQueue(uploadFile: file) } self.resumeAllOperations() @@ -350,6 +424,11 @@ extension UploadQueue: UploadQueueable { public func retryAllOperations(withParent parentId: Int, userId: Int, driveId: Int) { Log.uploadQueue("retryAllOperations parentId:\(parentId)") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + concurrentQueue.async { let failedFileIds = self.getFailedFileIds(parentId: parentId, userId: userId, driveId: driveId) let batches = failedFileIds.chunks(ofCount: 100) @@ -380,7 +459,7 @@ extension UploadQueue: UploadQueueable { using: realm) Log.uploadQueue("uploading:\(uploadingFiles.count)") let failedUploadFiles = uploadingFiles - .filter("_error != nil OR maxRetryCount <= 0 OR initiatedFromFileManager = true") + .filter("_error != nil OR maxRetryCount <= 0") failedFileIds = failedUploadFiles.map(\.id) Log.uploadQueue("retying:\(failedFileIds.count)") } @@ -388,8 +467,13 @@ extension UploadQueue: UploadQueueable { } private func cancelAnyInBatch(_ batch: ArraySlice) { + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + try? transactionWithUploadRealm { realm in - batch.forEach { uploadFileId in + for uploadFileId in batch { // Cancel operation if any if let operation = self.operation(uploadFileId: uploadFileId) { operation.cancel() @@ -399,7 +483,7 @@ extension UploadQueue: UploadQueueable { // Clean errors in db file guard let file = realm.object(ofType: UploadFile.self, forPrimaryKey: uploadFileId), !file.isInvalidated else { Log.uploadQueue("file invalidated ufid:\(uploadFileId) at\(#line)") - return + continue } try? realm.safeWrite { file.clearErrorsForRetry() @@ -409,11 +493,16 @@ extension UploadQueue: UploadQueueable { } private func enqueueAnyInBatch(_ batch: ArraySlice) { + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + try? transactionWithUploadRealm { realm in - batch.forEach { uploadFileId in + for uploadFileId in batch { guard let file = realm.object(ofType: UploadFile.self, forPrimaryKey: uploadFileId), !file.isInvalidated else { Log.uploadQueue("file invalidated ufid:\(uploadFileId) at\(#line)") - return + continue } self.addToQueueIfNecessary(uploadFile: file, using: realm) @@ -423,6 +512,11 @@ extension UploadQueue: UploadQueueable { private func operation(uploadFileId: String) -> UploadOperationable? { Log.uploadQueue("operation fileId:\(uploadFileId)") + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return nil + } + guard let operation = keyedUploadOperations.getObject(forKey: uploadFileId), !operation.isCancelled, !operation.isFinished else { @@ -433,6 +527,11 @@ extension UploadQueue: UploadQueueable { private func addToQueueIfNecessary(uploadFile: UploadFile, itemIdentifier: NSFileProviderItemIdentifier? = nil, using realm: Realm) { + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return + } + guard !uploadFile.isInvalidated else { return } @@ -440,7 +539,7 @@ extension UploadQueue: UploadQueueable { Log.uploadQueue("rebuildUploadQueueFromObjectsInRealm ufid:\(uploadFile.id)") guard operation(uploadFileId: uploadFile.id) != nil else { Log.uploadQueue("rebuildUploadQueueFromObjectsInRealm ADD ufid:\(uploadFile.id)") - addToQueue(uploadFile: uploadFile, itemIdentifier: nil, using: realm) + addToQueue(uploadFile: uploadFile, itemIdentifier: nil) return } @@ -449,8 +548,12 @@ extension UploadQueue: UploadQueueable { @discardableResult private func addToQueue(uploadFile: UploadFile, - itemIdentifier: NSFileProviderItemIdentifier? = nil, - using realm: Realm) -> UploadOperation? { + itemIdentifier: NSFileProviderItemIdentifier? = nil) -> UploadOperation? { + guard appContextService.context != .shareExtension else { + Log.uploadQueue("\(#function) disabled in ShareExtension", level: .error) + return nil + } + guard !uploadFile.isInvalidated, uploadFile.maxRetryCount > 0, keyedUploadOperations.getObject(forKey: uploadFile.id) == nil else { @@ -479,7 +582,7 @@ extension UploadQueue: UploadQueueable { publishFileUploaded(result: operation.result) publishUploadCount(withParent: parentId, userId: userId, driveId: driveId) - OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: keyedUploadOperations.isEmpty) + OperationQueueHelper.disableIdleTimer(false, hasOperationsInQueue: !keyedUploadOperations.isEmpty) } Log.uploadQueue("add operation :\(operation) ufid:\(uploadFileId)") diff --git a/kDriveCore/Data/UploadQueue/Queue/UploadQueue.swift b/kDriveCore/Data/UploadQueue/Queue/UploadQueue.swift index c7c1acd99..8c36750e3 100644 --- a/kDriveCore/Data/UploadQueue/Queue/UploadQueue.swift +++ b/kDriveCore/Data/UploadQueue/Queue/UploadQueue.swift @@ -22,9 +22,10 @@ import InfomaniakDI import RealmSwift import Sentry -public final class UploadQueue { +public final class UploadQueue: ParallelismHeuristicDelegate { @LazyInjectService var accountManager: AccountManageable @LazyInjectService var notificationHelper: NotificationsHelpable + @LazyInjectService var appContextService: AppContextServiceable public static let backgroundBaseIdentifier = ".backgroundsession.upload" public static var backgroundIdentifier: String { @@ -44,6 +45,8 @@ public final class UploadQueue { /// Something to track an operation for a File ID let keyedUploadOperations = KeyedUploadOperationable() + var uploadParallelismHeuristic: UploadParallelismHeuristic? + public lazy var operationQueue: OperationQueue = { let queue = OperationQueue() queue.name = "kDrive upload queue" @@ -51,7 +54,7 @@ public final class UploadQueue { // In extension to reduce memory footprint, we reduce drastically parallelism let parallelism: Int - if Bundle.main.isExtension { + if appContextService.isExtension { parallelism = 2 // With 2 Operations max, and a chuck of 1MiB max, the UploadQueue can spike to max 4MiB. } else { parallelism = max(4, ProcessInfo.processInfo.activeProcessorCount) @@ -82,6 +85,11 @@ public final class UploadQueue { /// Should suspend operation queue based on network status var shouldSuspendQueue: Bool { + // Explicitly disable the upload queue from the share extension + guard appContextService.context != .shareExtension else { + return true + } + let status = ReachabilityListener.instance.currentStatus return status == .offline || (status != .wifi && UserDefaults.shared.isWifiOnly) } @@ -96,8 +104,15 @@ public final class UploadQueue { ) public init() { + guard appContextService.context != .shareExtension else { + Log.uploadQueue("UploadQueue disabled in ShareExtension", level: .error) + return + } + Log.uploadQueue("Starting up") + uploadParallelismHeuristic = UploadParallelismHeuristic(delegate: self) + concurrentQueue.async { // Initialize operation queue with files from Realm, and make sure it restarts self.rebuildUploadQueueFromObjectsInRealm() @@ -146,4 +161,11 @@ public final class UploadQueue { public func getUploadedFiles(using realm: Realm = DriveFileManager.constants.uploadsRealm) -> Results { return realm.objects(UploadFile.self).filter(NSPredicate(format: "uploadDate != nil")) } + + // MARK: - ParallelismHeuristicDelegate + + func parallelismShouldChange(value: Int) { + Log.uploadQueue("Upload queue new parallelism: \(value)", level: .info) + operationQueue.maxConcurrentOperationCount = value + } } diff --git a/kDriveCore/Data/UploadQueue/Servicies/PhotoLibraryUploader+Scan.swift b/kDriveCore/Data/UploadQueue/Servicies/PhotoLibraryUploader+Scan.swift index d1b956308..fb2009d89 100644 --- a/kDriveCore/Data/UploadQueue/Servicies/PhotoLibraryUploader+Scan.swift +++ b/kDriveCore/Data/UploadQueue/Servicies/PhotoLibraryUploader+Scan.swift @@ -37,19 +37,31 @@ public extension PhotoLibraryUploader { let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] - let typesPredicates = getAssetPredicates(forSettings: settings) + let typesPredicates = self.getAssetPredicates(forSettings: settings) let datePredicate = getDatePredicate(with: settings) let typePredicate = NSCompoundPredicate(orPredicateWithSubpredicates: typesPredicates) options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [datePredicate, typePredicate]) Log.photoLibraryUploader("Fetching new pictures/videos with predicate: \(options.predicate!.predicateFormat)") - let assets = PHAsset.fetchAssets(with: options) + let assetsFetchResult = PHAsset.fetchAssets(with: options) let syncDate = Date() - addImageAssetsToUploadQueue(assets: assets, initial: settings.lastSync.timeIntervalSince1970 == 0, using: realm) - updateLastSyncDate(syncDate, using: realm) - newAssetsCount = assets.count - Log.photoLibraryUploader("New assets count:\(newAssetsCount)") + do { + try addImageAssetsToUploadQueue( + assetsFetchResult: assetsFetchResult, + initial: settings.lastSync.timeIntervalSince1970 == 0, + using: realm + ) + + updateLastSyncDate(syncDate, using: realm) + + newAssetsCount = assetsFetchResult.count + Log.photoLibraryUploader("New assets count:\(newAssetsCount)") + } catch ErrorDomain.importCancelledBySystem { + Log.photoLibraryUploader("System is requesting to stop", level: .error) + } catch { + Log.photoLibraryUploader("addImageAssetsToUploadQueue error:\(error)", level: .error) + } } return newAssetsCount } @@ -71,15 +83,28 @@ public extension PhotoLibraryUploader { } } - private func addImageAssetsToUploadQueue(assets: PHFetchResult, + private func addImageAssetsToUploadQueue(assetsFetchResult: PHFetchResult, initial: Bool, - using realm: Realm = DriveFileManager.constants.uploadsRealm) { + using realm: Realm = DriveFileManager.constants.uploadsRealm) throws { Log.photoLibraryUploader("addImageAssetsToUploadQueue") + let expiringActivity = ExpiringActivity(id: "addImageAssetsToUploadQueue:\(UUID().uuidString)", delegate: nil) + expiringActivity.start() + defer { + expiringActivity.endAll() + } + autoreleasepool { var burstIdentifier: String? var burstCount = 0 realm.beginWrite() - assets.enumerateObjects { [self] asset, idx, stop in + assetsFetchResult.enumerateObjects { [self] asset, idx, stop in + guard !expiringActivity.shouldTerminate else { + Log.photoLibraryUploader("system is asking to terminate") + realm.cancelWrite() + stop.pointee = true + return + } + guard let settings else { Log.photoLibraryUploader("no settings") realm.cancelWrite() @@ -111,8 +136,17 @@ public extension PhotoLibraryUploader { burstCount: &burstCount ) - let bestResourceSHA256: String? = asset.bestResourceSHA256 - Log.photoLibraryUploader("Asset hash:\(bestResourceSHA256)") + let bestResourceSHA256: String? + do { + bestResourceSHA256 = try asset.bestResourceSHA256 + } catch { + // Error thrown while hashing a resource, we stop ASAP. + Log.photoLibraryUploader("Error while hashing:\(error) asset: \(asset.localIdentifier)", level: .error) + stop.pointee = true + return + } + + Log.photoLibraryUploader("Asset hash:\(String(describing: bestResourceSHA256))") // Check if picture uploaded before guard !assetAlreadyUploaded(assetName: finalName, @@ -125,7 +159,7 @@ public extension PhotoLibraryUploader { let algorithmImportVersion = currentDiffAlgorithmVersion - // New UploadFile to be uploaded + // New UploadFile to be uploaded. Priority is `.low`, first sync is `.normal` let uploadFile = UploadFile( parentDirectoryId: settings.parentDirectoryId, userId: settings.userId, @@ -135,7 +169,7 @@ public extension PhotoLibraryUploader { bestResourceSHA256: bestResourceSHA256, algorithmImportVersion: algorithmImportVersion, conflictOption: .version, - priority: initial ? .low : .high + priority: initial ? .low : .normal ) // Lazy creation of sub folder if required in the upload file @@ -147,7 +181,7 @@ public extension PhotoLibraryUploader { realm.add(uploadFile, update: .modified) // Batching writes - if idx < assets.count - 1 && idx % 99 == 0 { + if idx < assetsFetchResult.count - 1 && idx % 99 == 0 { Log.photoLibraryUploader("Commit assets batch up to :\(idx)") // Commit write every 100 assets if it's not the last try? realm.commitWrite() @@ -160,6 +194,10 @@ public extension PhotoLibraryUploader { } try? realm.commitWrite() } + + guard !expiringActivity.shouldTerminate else { + throw ErrorDomain.importCancelledBySystem + } } private func getPhotoLibraryName( diff --git a/kDriveCore/Data/UploadQueue/Servicies/PhotoLibraryUploader.swift b/kDriveCore/Data/UploadQueue/Servicies/PhotoLibraryUploader.swift index 4b0b229cf..bacea5960 100644 --- a/kDriveCore/Data/UploadQueue/Servicies/PhotoLibraryUploader.swift +++ b/kDriveCore/Data/UploadQueue/Servicies/PhotoLibraryUploader.swift @@ -33,6 +33,11 @@ public final class PhotoLibraryUploader { /// Predicate to quickly narrow down on uploaded assets static let uploadedAssetPredicate = NSPredicate(format: "rawType = %@ AND uploadDate != nil", "phAsset") + enum ErrorDomain: Error { + /// System is asking to terminate the operation + case importCancelledBySystem + } + var _settings: PhotoSyncSettings? public var settings: PhotoSyncSettings? { _settings diff --git a/kDriveCore/Services/AppContextService.swift b/kDriveCore/Services/AppContextService.swift new file mode 100644 index 000000000..20cd4d3f2 --- /dev/null +++ b/kDriveCore/Services/AppContextService.swift @@ -0,0 +1,59 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation + +/// All the ways some code can be executed within the __kDrive__ project +public enum DriveAppContext { + /// Current execution context is the main app + case app + + /// Current execution context is an action extension + case actionExtension + + /// Current execution context is the file provider extension + case fileProviderExtension + + /// Current execution context is the share extension + case shareExtension +} + +/// Something that can provide the active execution context +public protocol AppContextServiceable { + /// Get the current execution context + var context: DriveAppContext { get } + + /// Shorthand to check if we are within the main app or any extension + var isExtension: Bool { get } +} + +public struct AppContextService: AppContextServiceable { + public var context: DriveAppContext + + public var isExtension: Bool { + guard context == .app else { + return true + } + + return false + } + + public init(context: DriveAppContext) { + self.context = context + } +} diff --git a/kDriveCore/Services/BackgroundTasksService.swift b/kDriveCore/Services/BackgroundTasksService.swift new file mode 100644 index 000000000..528c0818d --- /dev/null +++ b/kDriveCore/Services/BackgroundTasksService.swift @@ -0,0 +1,172 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import BackgroundTasks +import CocoaLumberjackSwift +import Foundation +import InfomaniakDI +import kDriveCore + +/* To debug background tasks: + Launch -> + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.infomaniak.background.refresh"] + OR + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.infomaniak.background.long-refresh"] + + Force early termination -> + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.infomaniak.background.refresh"] + OR + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.infomaniak.background.long-refresh"] + */ + +/// Service to ask the system to do some work in the background later. +public protocol BackgroundTasksServiceable { + /// Ask the system to handle the app's background refresh + func registerBackgroundTasks() + + /// Schedule next refresh with the system + func scheduleBackgroundRefresh() +} + +struct BackgroundTasksService: BackgroundTasksServiceable { + private static let activityShouldTerminateMessage = "Notified activity should terminate" + + @LazyInjectService private var scheduler: BGTaskScheduler + @LazyInjectService private var accountManager: AccountManageable + @LazyInjectService private var uploadQueue: UploadQueue + @LazyInjectService private var photoUploader: PhotoLibraryUploader + + public init() { + // META: keep SonarCloud happy + } + + public func registerBackgroundTasks() { + Log.backgroundTaskScheduling("registerBackgroundTasks") + registerBackgroundTask(identifier: Constants.backgroundRefreshIdentifier) + registerBackgroundTask(identifier: Constants.longBackgroundRefreshIdentifier) + } + + public func buildBackgroundTask(_ task: BGTask, identifier: String) { + scheduleBackgroundRefresh() + + handleBackgroundRefresh { _ in + Log.backgroundTaskScheduling("Task \(identifier) completed with SUCCESS") + task.setTaskCompleted(success: true) + } + + task.expirationHandler = { + Log.backgroundTaskScheduling("Task \(identifier) EXPIRED", level: .error) + uploadQueue.suspendAllOperations() + uploadQueue.rescheduleRunningOperations() + task.setTaskCompleted(success: false) + } + } + + func registerBackgroundTask(identifier: String) { + let registered = scheduler.register( + forTaskWithIdentifier: identifier, + using: nil + ) { task in + buildBackgroundTask(task, identifier: identifier) + } + Log.backgroundTaskScheduling("Task \(identifier) registered ? \(registered)") + } + + func handleBackgroundRefresh(completion: @escaping (Bool) -> Void) { + let expiringActivity = ExpiringActivity(id: UUID().uuidString, delegate: nil) + expiringActivity.start() + + Log.backgroundTaskScheduling("handleBackgroundRefresh") + // User installed the app but never logged in + if expiringActivity.shouldTerminate || accountManager.accounts.isEmpty { + Log.backgroundTaskScheduling(Self.activityShouldTerminateMessage, level: .error) + completion(false) + expiringActivity.endAll() + return + } + + Log.backgroundTaskScheduling("Enqueue new pictures") + photoUploader.scheduleNewPicturesForUpload() + + guard !expiringActivity.shouldTerminate else { + Log.backgroundTaskScheduling(Self.activityShouldTerminateMessage, level: .error) + completion(false) + expiringActivity.endAll() + return + } + + Log.backgroundTaskScheduling("Clean errors for all uploads") + uploadQueue.cleanNetworkAndLocalErrorsForAllOperations() + + guard !expiringActivity.shouldTerminate else { + Log.backgroundTaskScheduling(Self.activityShouldTerminateMessage, level: .error) + completion(false) + expiringActivity.endAll() + return + } + + Log.backgroundTaskScheduling("Reload operations in queue") + uploadQueue.rebuildUploadQueueFromObjectsInRealm() + + guard !expiringActivity.shouldTerminate else { + Log.backgroundTaskScheduling(Self.activityShouldTerminateMessage, level: .error) + completion(false) + expiringActivity.endAll() + return + } + + Log.backgroundTaskScheduling("waitForCompletion") + uploadQueue.waitForCompletion { + Log.backgroundTaskScheduling("Background activity ended with success") + completion(true) + expiringActivity.endAll() + } + } + + func scheduleBackgroundRefresh() { + Log.backgroundTaskScheduling("scheduleBackgroundRefresh") + // List pictures + upload files (+pictures) / photoKit + let backgroundRefreshRequest = BGAppRefreshTaskRequest(identifier: Constants.backgroundRefreshIdentifier) + #if DEBUG + // Required for debugging + backgroundRefreshRequest.earliestBeginDate = Date() + #else + backgroundRefreshRequest.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) + #endif + + // Upload files (+pictures) / photokit + let longBackgroundRefreshRequest = BGProcessingTaskRequest(identifier: Constants.longBackgroundRefreshIdentifier) + #if DEBUG + // Required for debugging + longBackgroundRefreshRequest.earliestBeginDate = Date() + #else + longBackgroundRefreshRequest.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) + #endif + longBackgroundRefreshRequest.requiresNetworkConnectivity = true + longBackgroundRefreshRequest.requiresExternalPower = true + do { + try scheduler.submit(backgroundRefreshRequest) + Log.backgroundTaskScheduling("scheduled task: \(backgroundRefreshRequest)") + try scheduler.submit(longBackgroundRefreshRequest) + Log.backgroundTaskScheduling("scheduled task: \(longBackgroundRefreshRequest)") + + } catch { + Log.backgroundTaskScheduling("Error scheduling background task: \(error)", level: .error) + } + } +} diff --git a/kDriveCore/UI/IKLargeButton.swift b/kDriveCore/UI/IKLargeButton.swift index 507aadf1d..acaf0fe84 100644 --- a/kDriveCore/UI/IKLargeButton.swift +++ b/kDriveCore/UI/IKLargeButton.swift @@ -48,9 +48,9 @@ import UIKit public var disabledBackgroundColor = KDriveResourcesAsset.buttonDisabledBackgroundColor.color public struct Style: RawRepresentable { - var titleFont: UIFont - var titleColor: UIColor - var backgroundColor: UIColor + public var titleFont: UIFont + public var titleColor: UIColor + public var backgroundColor: UIColor public static let primaryButton = Style( titleFont: UIFont.systemFont(ofSize: UIFontMetrics.default.scaledValue(for: 16), weight: .medium), diff --git a/kDriveCore/UI/UIConstants.swift b/kDriveCore/UI/UIConstants.swift index 260edf705..375c4378f 100644 --- a/kDriveCore/UI/UIConstants.swift +++ b/kDriveCore/UI/UIConstants.swift @@ -24,6 +24,13 @@ import SnackBar import UIKit public enum UIConstants { + private static let style: SnackBarStyle = { + var style = SnackBarStyle.infomaniakStyle + style.anchor = 20.0 + style.maxWidth = 600.0 + return style + }() + public static let inputCornerRadius = 2.0 public static let imageCornerRadius = 3.0 public static let cornerRadius = 6.0 @@ -41,9 +48,12 @@ public enum UIConstants { @discardableResult @MainActor - public static func showSnackBar(message: String, duration: SnackBar.Duration = .lengthLong, + public static func showSnackBar(message: String, + duration: SnackBar.Duration = .lengthLong, action: IKSnackBar.Action? = nil) -> IKSnackBar? { - let snackbar = IKSnackBar.make(message: message, duration: duration) + let snackbar = IKSnackBar.make(message: message, + duration: duration, + style: style) if let action { snackbar?.setAction(action).show() } else { diff --git a/kDriveCore/Utils/AbstractLog+Category.swift b/kDriveCore/Utils/AbstractLog+Category.swift index d8c1fe3c5..7cafd1bed 100644 --- a/kDriveCore/Utils/AbstractLog+Category.swift +++ b/kDriveCore/Utils/AbstractLog+Category.swift @@ -90,13 +90,13 @@ public enum Log { /// /// In system console, visualize them with `subsystem:com.infomaniak.drive category:BGTaskScheduling` /// - public static func bgTaskScheduling(_ message: @autoclosure () -> Any, - level: AbstractLogLevel = .debug, - context: Int = 0, - file: StaticString = #file, - function: StaticString = #function, - line: UInt = #line, - tag: Any? = nil) { + public static func backgroundTaskScheduling(_ message: @autoclosure () -> Any, + level: AbstractLogLevel = .debug, + context: Int = 0, + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line, + tag: Any? = nil) { let category = "BGTaskScheduling" defaultLogHandler(message(), category: category, diff --git a/kDriveCore/Utils/Constants.swift b/kDriveCore/Utils/Constants.swift index 0752c9a67..3d2d808f9 100644 --- a/kDriveCore/Utils/Constants.swift +++ b/kDriveCore/Utils/Constants.swift @@ -30,6 +30,7 @@ public enum PhotoLibraryImport: Int { } public struct URLConstants { + public static let kDriveRedirection = URLConstants(urlString: "https://kdrive.infomaniak.com/app/drive") public static let kDriveWeb = URLConstants(urlString: "https://kdrive.infomaniak.com") public static let signUp = URLConstants(urlString: "https://welcome.infomaniak.com/signup/ikdrive/steps") public static let shop = URLConstants(urlString: "https://shop.infomaniak.com/order/drive") diff --git a/kDriveCore/Utils/ExpiringActivity.swift b/kDriveCore/Utils/ExpiringActivity.swift index fa3d61e22..acf18e603 100644 --- a/kDriveCore/Utils/ExpiringActivity.swift +++ b/kDriveCore/Utils/ExpiringActivity.swift @@ -19,71 +19,107 @@ import Foundation import InfomaniakCore +// TODO: User core 7.0.0 version ASAP /// Delegation mechanism to notify the end of an `ExpiringActivity` public protocol ExpiringActivityDelegate: AnyObject { /// Called when the system is requiring us to terminate an expiring activity func backgroundActivityExpiring() } +/// Something that can perform arbitrary short background tasks, with a super simple API. public protocol ExpiringActivityable { + /// Common init method + /// - Parameters: + /// - id: Something to identify the background activity in debug + /// - qos: QoS used by the underlying queues + /// - delegate: The delegate to notify we should terminate + init(id: String, qos: DispatchQoS, delegate: ExpiringActivityDelegate?) + + /// init method + /// - Parameters: + /// - id: Something to identify the background activity in debug + /// - delegate: The delegate to notify we should terminate init(id: String, delegate: ExpiringActivityDelegate?) /// Register with the system an expiring activity func start() - /// Terminate the expiring activity if needed. - func end() + /// Terminate all the expiring activities + func endAll() + + /// True if the system asked to stop the background activity + var shouldTerminate: Bool { get } } public final class ExpiringActivity: ExpiringActivityable { - /// Keep track of the locks on blocks - private var locks = [TolerantDispatchGroup]() + private let qos: DispatchQoS + + private let queue: DispatchQueue - /// For thread safety - private let queue = DispatchQueue(label: "com.infomaniak.ExpiringActivity.sync") + private let processInfo = ProcessInfo.processInfo + + var locks = [TolerantDispatchGroup]() - /// Something to identify the background activity in debug let id: String - /// The delegate to notify we should terminate + public var shouldTerminate = false + weak var delegate: ExpiringActivityDelegate? // MARK: Lifecycle - public init(id: String, delegate: ExpiringActivityDelegate?) { + public init(id: String, qos: DispatchQoS, delegate: ExpiringActivityDelegate?) { self.id = id + self.qos = qos self.delegate = delegate + queue = DispatchQueue(label: "com.infomaniak.ExpiringActivity.sync", qos: qos) + } + + public convenience init(id: String = "\(#function)-\(UUID().uuidString)", delegate: ExpiringActivityDelegate? = nil) { + self.init(id: id, qos: .userInitiated, delegate: delegate) } deinit { queue.sync { - assert(locks.isEmpty, "please make sure to balance 'start()' and 'end()' before releasing this object") + assert(locks.isEmpty, "please make sure to call 'endAll()' once explicitly before releasing this object") } } public func start() { - let group = TolerantDispatchGroup() + let group = TolerantDispatchGroup(qos: qos) queue.sync { self.locks.append(group) } + #if os(macOS) + // We block a non cooperative queue that matches current QoS + DispatchQueue.global(qos: qos.qosClass).async { + self.processInfo.performActivity(options: .suddenTerminationDisabled, reason: self.id) { + // No expiration handler as we are running on macOS + group.enter() + group.wait() + } + } + #else // Make sure to not lock an unexpected thread that would deinit() - ProcessInfo.processInfo.performExpiringActivity(withReason: id) { [weak self] shouldTerminate in + processInfo.performExpiringActivity(withReason: id) { [weak self] shouldTerminate in guard let self else { return } if shouldTerminate { + self.shouldTerminate = true delegate?.backgroundActivityExpiring() } group.enter() group.wait() } + #endif } - public func end() { + public func endAll() { queue.sync { // Release locks, oldest first for group in locks.reversed() { diff --git a/kDriveCore/Utils/Files/FileImportHelper+Upload.swift b/kDriveCore/Utils/Files/FileImportHelper+Upload.swift index 1c3db97fa..7002951f0 100644 --- a/kDriveCore/Utils/Files/FileImportHelper+Upload.swift +++ b/kDriveCore/Utils/Files/FileImportHelper+Upload.swift @@ -26,11 +26,14 @@ import RealmSwift import VisionKit public extension FileImportHelper { - func upload(files: [ImportedFile], in directory: File, drive: Drive) async throws { + func saveForUpload(_ files: [ImportedFile], in directory: File, drive: Drive, addToQueue: Bool) async throws { guard directory.capabilities.canUpload else { throw ImportError.accessDenied } + let expiringActivity = ExpiringActivity() + expiringActivity.start() + let parentDirectoryId = directory.id let userId = drive.userId let driveId = drive.id @@ -43,8 +46,11 @@ public extension FileImportHelper { url: file.path, name: file.name ) - self.uploadQueue.saveToRealmAndAddToQueue(uploadFile: uploadFile) + + self.uploadQueue.saveToRealm(uploadFile, addToQueue: addToQueue) } + + expiringActivity.endAll() } func upload( @@ -133,7 +139,7 @@ public extension FileImportHelper { url: targetURL, name: name ) - uploadQueue.saveToRealmAndAddToQueue(uploadFile: newFile) + uploadQueue.saveToRealm(newFile) } } diff --git a/kDriveCore/Utils/Files/FileImportHelper.swift b/kDriveCore/Utils/Files/FileImportHelper.swift index 76d9afa97..87410a566 100644 --- a/kDriveCore/Utils/Files/FileImportHelper.swift +++ b/kDriveCore/Utils/Files/FileImportHelper.swift @@ -67,8 +67,12 @@ public enum ImportError: LocalizedError { } public final class FileImportHelper { + /// Shorthand for default FileManager + private let fileManager = FileManager.default + @LazyInjectService var pathProvider: AppGroupPathProvidable @LazyInjectService var uploadQueue: UploadQueue + @LazyInjectService var appContextService: AppContextServiceable let imageCompression = 0.8 @@ -102,7 +106,7 @@ public final class FileImportHelper { Task { do { - let results: [Result?] = await assetIdentifiers.concurrentMap { assetIdentifier in + let results: [Result?] = try await assetIdentifiers.concurrentMap { assetIdentifier in defer { progress.completedUnitCount += 1 } @@ -117,12 +121,27 @@ public final class FileImportHelper { } let uti = UTI(filenameExtension: url.pathExtension) - var name = url.lastPathComponent - if let uti, let originalName = asset.getFilename(uti: uti) { - name = originalName + var fileName = url.lastPathComponent + if let uti, + let originalName = asset.getFilename(uti: uti) { + fileName = originalName + } + + var finalUrl: URL + if self.appContextService.isExtension { + // In extension, we need to copy files to a path within appGroup to be able to upload from the main app. + let appGroupURL = try URL.appGroupUniqueFolderURL() + + // Get import URL + let appGroupFileURL = appGroupURL.appendingPathComponent(fileName) + try self.fileManager.copyItem(atPath: url.path, toPath: appGroupFileURL.path) + finalUrl = appGroupFileURL + } else { + // Path obtained within the main app are stable, and will stay accessible. + finalUrl = url } - let importedFile = ImportedFile(name: name, path: url, uti: uti ?? .data) + let importedFile = ImportedFile(name: fileName, path: finalUrl, uti: uti ?? .data) return .success(importedFile) } @@ -213,14 +232,29 @@ public final class FileImportHelper { } // Build a collection of `ImportedFile` - let processedFiles: [ImportedFile] = results.compactMap { taskResult in + let processedFiles: [ImportedFile] = try await results.concurrentCompactMap { taskResult in guard case .success(let url) = taskResult else { return nil } let fileName = url.lastPathComponent let uti = UTI(filenameExtension: url.pathExtension) ?? UTI.data - let importedFile = ImportedFile(name: fileName, path: url, uti: uti) + + var finalUrl: URL + if self.appContextService.isExtension { + // In extension, we need to copy files to a path within appGroup to be able to upload from the main app. + let appGroupURL = try URL.appGroupUniqueFolderURL() + + // Get import URL + let appGroupFileURL = appGroupURL.appendingPathComponent(fileName) + try self.fileManager.copyItem(atPath: url.path, toPath: appGroupFileURL.path) + finalUrl = appGroupFileURL + } else { + // Path obtained within the main app are stable, and will stay accessible. + finalUrl = url + } + + let importedFile = ImportedFile(name: fileName, path: finalUrl, uti: uti) return importedFile } @@ -237,3 +271,16 @@ public final class FileImportHelper { return progress } } + +// TODO: move to core +extension URL { + /// Build a path where a file can be moved within the appGroup while preventing collisions + static func appGroupUniqueFolderURL() throws -> URL { + // Use a unique folder to prevent collisions + @InjectService var pathProvider: AppGroupPathProvidable + let targetFolderURL = pathProvider.groupDirectoryURL + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: targetFolderURL, withIntermediateDirectories: true) + return targetFolderURL + } +} diff --git a/kDriveCore/Utils/KeychainHelper.swift b/kDriveCore/Utils/KeychainHelper.swift index 179bbe164..7c90fe14f 100644 --- a/kDriveCore/Utils/KeychainHelper.swift +++ b/kDriveCore/Utils/KeychainHelper.swift @@ -111,19 +111,32 @@ public enum KeychainHelper { if let savedToken = getSavedToken(for: token.userId) { keychainQueue.sync { + let queryUpdate: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "\(token.userId)" + ] + + let attributes: [String: Any] = [ + kSecValueData as String: tokenData + ] + // Save token only if it's more recent - if savedToken.expirationDate <= token.expirationDate { - let queryUpdate: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: "\(token.userId)" - ] - - let attributes: [String: Any] = [ - kSecValueData as String: tokenData - ] + if let savedTokenExpirationDate = savedToken.expirationDate, + let newTokenExpirationDate = token.expirationDate, + savedTokenExpirationDate <= newTokenExpirationDate { resultCode = SecItemUpdate(queryUpdate as CFDictionary, attributes as CFDictionary) DDLogInfo("Successfully updated token ? \(resultCode == noErr)") - + let metadata = token.breadcrumbMetadata(keychainError: resultCode) + SentryDebug.addBreadcrumb( + message: "Successfully updated token", + category: .apiToken, + level: .info, + metadata: metadata + ) + } else if savedToken.expirationDate == nil || token.expirationDate == nil { + // Or if one of them is now an infinite refresh token + resultCode = SecItemUpdate(queryUpdate as CFDictionary, attributes as CFDictionary) + DDLogInfo("Successfully updated unlimited token ? \(resultCode == noErr)") let metadata = token.breadcrumbMetadata(keychainError: resultCode) SentryDebug.addBreadcrumb( message: "Successfully updated token", diff --git a/kDriveCore/Utils/Logging.swift b/kDriveCore/Utils/Logging.swift index 27fc422dc..78a1f0074 100644 --- a/kDriveCore/Utils/Logging.swift +++ b/kDriveCore/Utils/Logging.swift @@ -80,7 +80,8 @@ public enum Logging { private static func initNetworkLogging() { #if DEBUG && !TEST - if !Bundle.main.isExtension { + @InjectService var appContextService: AppContextServiceable + if !appContextService.isExtension { Atlantis.start(hostName: ProcessInfo.processInfo.environment["hostname"]) } #endif @@ -106,7 +107,11 @@ public enum Logging { private static func copyDebugInformations() { #if DEBUG && !TEST - guard !Bundle.main.isExtension else { return } + @InjectService var appContextService: AppContextServiceable + guard !appContextService.isExtension else { + return + } + let fileManager = FileManager.default let debugDirectory = (fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent( "debug", diff --git a/kDriveCore/Utils/NotificationsHelper.swift b/kDriveCore/Utils/NotificationsHelper.swift index c1885639b..b161083d4 100644 --- a/kDriveCore/Utils/NotificationsHelper.swift +++ b/kDriveCore/Utils/NotificationsHelper.swift @@ -20,6 +20,7 @@ import CocoaLumberjackSwift import Foundation import InfomaniakCore import InfomaniakCoreUI +import InfomaniakDI import kDriveResources import UserNotifications @@ -45,6 +46,8 @@ public protocol NotificationsHelpable { } public struct NotificationsHelper: NotificationsHelpable { + @LazyInjectService private var appContextService: AppContextServiceable + public enum CategoryIdentifier { public static let general = "com.kdrive.notification.general" public static let upload = "com.kdrive.notification.upload" @@ -218,19 +221,14 @@ public struct NotificationsHelper: NotificationsHelpable { return } - let isInBackground = Bundle.main.isExtension || UIApplication.shared.applicationState != .active + let isInBackground = appContextService.isExtension || UIApplication.shared.applicationState != .active if isInBackground { let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) let request = UNNotificationRequest(identifier: id, content: notification, trigger: trigger) UNUserNotificationCenter.current().add(request) } else { - let snackbar = IKSnackBar.make(message: notification.body, duration: .lengthLong) - if let action { - snackbar?.setAction(action).show() - } else { - snackbar?.show() - } + UIConstants.showSnackBar(message: notification.body, duration: .lengthLong, action: action) } } } diff --git a/kDriveCore/Utils/PHAsset/PHAsset+Exension.swift b/kDriveCore/Utils/PHAsset/PHAsset+Exension.swift index dec06d7ba..4d38843a2 100644 --- a/kDriveCore/Utils/PHAsset/PHAsset+Exension.swift +++ b/kDriveCore/Utils/PHAsset/PHAsset+Exension.swift @@ -28,20 +28,26 @@ public extension PHAsset { /// /// Will return `nil` for any other resource type (like video) var baseImageSHA256: String? { - guard let identifier = PHAssetIdentifier(self) else { - return nil - } + get throws { + guard let identifier = PHAssetIdentifier(self) else { + return nil + } - return identifier.baseImageSHA256 + let hash = try identifier.baseImageSHA256 + return hash + } } /// Hash of the best resource available. Editing a video or a picture will change this hash var bestResourceSHA256: String? { - guard let identifier = PHAssetIdentifier(self) else { - return nil - } + get throws { + guard let identifier = PHAssetIdentifier(self) else { + return nil + } - return identifier.bestResourceSHA256 + let hash = try identifier.bestResourceSHA256 + return hash + } } // MARK: - Filename diff --git a/kDriveCore/Utils/PHAsset/PHAssetIdentifier.swift b/kDriveCore/Utils/PHAsset/PHAssetIdentifier.swift index 6150585f5..1c460eacd 100644 --- a/kDriveCore/Utils/PHAsset/PHAssetIdentifier.swift +++ b/kDriveCore/Utils/PHAsset/PHAssetIdentifier.swift @@ -32,13 +32,36 @@ protocol PHAssetIdentifiable { /// Get a hash of the base image of a PHAsset _without adjustments_ /// /// Other types of PHAsset will return nil - var baseImageSHA256: String? { get } + /// - Throws: if the system is trying to end a background activity + var baseImageSHA256: String? { get throws } /// Computes the SHA of the `.bestResource` available /// /// Anything accessible with a `requestData` will work (photo / video …) /// A picture with adjustments will see its hash change here - var bestResourceSHA256: String? { get } + /// - Throws: if the system is trying to end a background activity + var bestResourceSHA256: String? { get throws } +} + +/// Something that will be called in case an asset activity is expiring +/// +/// It will unlock a linked TolerantDispatchGroup, and write an error to a given reference +final class AssetExpiringActivityDelegate: ExpiringActivityDelegate { + enum ErrorDomain: Error { + case assetActivityExpired + } + + let group: TolerantDispatchGroup + var error: Error? + + init(group: TolerantDispatchGroup) { + self.group = group + } + + func backgroundActivityExpiring() { + error = ErrorDomain.assetActivityExpired + group.leave() + } } struct PHAssetIdentifier: PHAssetIdentifiable { @@ -53,75 +76,106 @@ struct PHAssetIdentifier: PHAssetIdentifiable { } var baseImageSHA256: String? { - guard #available(iOS 15, *) else { - return nil - } + get throws { + guard #available(iOS 15, *) else { + return nil + } - let group = TolerantDispatchGroup() - var hash: String? + // We build an ExpiringActivity to track system termination + let uid = "\(#function)-\(asset.localIdentifier)-\(UUID().uuidString)" + let group = TolerantDispatchGroup() + let activityDelegate = AssetExpiringActivityDelegate(group: group) + let activity = ExpiringActivity(id: uid, delegate: activityDelegate) + activity.start() - let options = PHContentEditingInputRequestOptions() - options.canHandleAdjustmentData = { _ -> Bool in - return true - } + var hash: String? - // Trigger a request in order to intercept change data - asset.requestContentEditingInput(with: options) { input, _ in - defer { - group.leave() + let options = PHContentEditingInputRequestOptions() + options.canHandleAdjustmentData = { _ -> Bool in + return true } - guard let input else { - return - } + // Trigger a request in order to intercept change data + asset.requestContentEditingInput(with: options) { input, _ in + defer { + group.leave() + } + + guard let input else { + return + } - guard let url = input.fullSizeImageURL else { - return + guard let url = input.fullSizeImageURL else { + return + } + + // Hashing the raw data of the picture is the only reliable solution to know when effects were applied + // This will exclude changes related to like and albums + hash = url.dataRepresentation.SHA256DigestString } - // Hashing the raw data of the picture is the only reliable solution to know when effects were applied - // This will exclude changes related to like and albums - hash = url.dataRepresentation.SHA256DigestString - } + // wait for the request to finish + group.enter() + group.wait() + activity.endAll() - // wait for the request to finish - group.enter() - group.wait() + guard let error = activityDelegate.error else { + // All good + return hash + } - return hash + // The processing of the hash was interrupted by the system + throw error + } } var bestResourceSHA256: String? { - guard #available(iOS 15, *) else { - return nil - } + get throws { + guard #available(iOS 15, *) else { + return nil + } - guard let bestResource = asset.bestResource else { - return baseImageSHA256 - } + guard let bestResource = asset.bestResource else { + let hashFallback = try baseImageSHA256 + return hashFallback + } - let group = TolerantDispatchGroup() - let hasher = StreamHasher() + let hasher = StreamHasher() - // TODO: Check iCloud behaviour - let options = PHAssetResourceRequestOptions() - options.isNetworkAccessAllowed = true - options.progressHandler = { progress in - Log.photoLibraryUploader("hashing resource \(progress * 100)% …") - } + // We build an ExpiringActivity to track system termination + let uid = "\(#function)-\(asset.localIdentifier)-\(UUID().uuidString)" + let group = TolerantDispatchGroup() + let activityDelegate = AssetExpiringActivityDelegate(group: group) + let activity = ExpiringActivity(id: uid, delegate: activityDelegate) + activity.start() - PHAssetResourceManager.default().requestData(for: bestResource, - options: options) { data in - hasher.update(data) - } completionHandler: { error in - hasher.finalize() - group.leave() - } + // TODO: Check iCloud behaviour + let options = PHAssetResourceRequestOptions() + options.isNetworkAccessAllowed = true + options.progressHandler = { progress in + Log.photoLibraryUploader("hashing resource \(progress * 100)% …") + } + + PHAssetResourceManager.default().requestData(for: bestResource, + options: options) { data in + hasher.update(data) + } completionHandler: { error in + hasher.finalize() + group.leave() + } + + // wait for the request to finish + group.enter() + group.wait() + activity.endAll() - // wait for the request to finish - group.enter() - group.wait() + guard let error = activityDelegate.error else { + // All good + return hasher.digestString + } - return hasher.digestString + // The processing of the hash was interrupted by the system + throw error + } } } diff --git a/kDriveCore/Utils/Sentry/ApiToken+Sentry.swift b/kDriveCore/Utils/Sentry/ApiToken+Sentry.swift index 390b9323d..f712bf748 100644 --- a/kDriveCore/Utils/Sentry/ApiToken+Sentry.swift +++ b/kDriveCore/Utils/Sentry/ApiToken+Sentry.swift @@ -18,11 +18,12 @@ import Foundation import InfomaniakCore +import InfomaniakLogin public extension ApiToken { func breadcrumbMetadata(keychainError: OSStatus = noErr) -> [String: Any] { return ["User id": userId, - "Expiration date": expirationDate.timeIntervalSince1970, + "Expiration date": expirationDate?.timeIntervalSince1970 ?? "", "Access Token": truncatedAccessToken, "Refresh Token": truncatedRefreshToken, "Keychain error code": keychainError] diff --git a/kDriveCore/Utils/Sentry/SentryDebug.swift b/kDriveCore/Utils/Sentry/SentryDebug.swift index 4bf224589..20b4bc3e4 100644 --- a/kDriveCore/Utils/Sentry/SentryDebug.swift +++ b/kDriveCore/Utils/Sentry/SentryDebug.swift @@ -17,6 +17,7 @@ */ import Foundation +import InfomaniakLogin import Sentry import UIKit @@ -50,6 +51,25 @@ public enum SentryDebug { static let viewModelNotConnectedToView = "ViewModelNotConnected" } + static func logTokenMigration(newToken: ApiToken, oldToken: ApiToken) { + let newTokenIsInfinite = newToken.expirationDate == nil + let oldTokenIsInfinite = oldToken.expirationDate == nil + + let additionalData = ["newTokenIsInfinite": newTokenIsInfinite, "oldTokenIsInfinite": oldTokenIsInfinite] + let breadcrumb = Breadcrumb(level: .info, category: "Token") + breadcrumb.message = "Token updated" + breadcrumb.data = additionalData + SentrySDK.addBreadcrumb(breadcrumb) + + // Only track migration + guard newTokenIsInfinite else { return } + + SentrySDK.capture(message: "Update token infinite token") { scope in + scope.setContext(value: additionalData, + key: "Migration context") + } + } + // MARK: - View Model Observation public static func viewModelObservationError(_ function: String = #function) { diff --git a/kDriveFileProvider/FileProviderExtension.swift b/kDriveFileProvider/FileProviderExtension.swift index 66e6ae2ef..145d17f86 100644 --- a/kDriveFileProvider/FileProviderExtension.swift +++ b/kDriveFileProvider/FileProviderExtension.swift @@ -26,7 +26,7 @@ import RealmSwift final class FileProviderExtension: NSFileProviderExtension { /// Making sure the DI is registered at a very early stage of the app launch. - private let dependencyInjectionHook = EarlyDIHook() + private let dependencyInjectionHook = EarlyDIHook(context: .fileProviderExtension) /// Something to enqueue async await tasks in a serial manner. let asyncAwaitQueue = TaskQueue() @@ -337,7 +337,7 @@ final class FileProviderExtension: NSFileProviderExtension { self.fileProviderState.removeWorkingDocument(forKey: item.itemIdentifier) } - _ = self.uploadQueue.saveToRealmAndAddToQueue(uploadFile: uploadFile, itemIdentifier: item.itemIdentifier) + _ = self.uploadQueue.saveToRealm(uploadFile, itemIdentifier: item.itemIdentifier, addToQueue: true) } } diff --git a/kDriveShareExtension/ShareNavigationViewController.swift b/kDriveShareExtension/ShareNavigationViewController.swift index ee5b705bc..2c955d31f 100644 --- a/kDriveShareExtension/ShareNavigationViewController.swift +++ b/kDriveShareExtension/ShareNavigationViewController.swift @@ -21,10 +21,11 @@ import InfomaniakDI import InfomaniakLogin import kDriveCore import UIKit +import VersionChecker final class ShareNavigationViewController: TitleSizeAdjustingNavigationController { /// Making sure the DI is registered at a very early stage of the app launch. - private let dependencyInjectionHook = EarlyDIHook() + private let dependencyInjectionHook = EarlyDIHook(context: .shareExtension) @LazyInjectService var accountManager: AccountManageable @@ -44,6 +45,10 @@ final class ShareNavigationViewController: TitleSizeAdjustingNavigationControlle saveViewController.itemProviders = attachments viewControllers = [saveViewController] + + Task { + try? await checkAppVersion() + } } override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { @@ -52,4 +57,15 @@ final class ShareNavigationViewController: TitleSizeAdjustingNavigationControlle } extensionContext.completeRequest(returningItems: nil, completionHandler: nil) } + + private func checkAppVersion() async throws { + guard try await VersionChecker.standard.checkAppVersionStatus() == .updateIsRequired else { return } + Task { @MainActor in + let updateRequiredViewController = DriveUpdateRequiredViewController() + updateRequiredViewController.dismissHandler = { [weak self] in + self?.dismiss(animated: true) + } + viewControllers = [updateRequiredViewController] + } + } } diff --git a/kDriveTests/kDrive/Launch/ITAppLaunchTest.swift b/kDriveTests/kDrive/Launch/ITAppLaunchTest.swift index 8b0fbc2ed..f9cbab131 100644 --- a/kDriveTests/kDrive/Launch/ITAppLaunchTest.swift +++ b/kDriveTests/kDrive/Launch/ITAppLaunchTest.swift @@ -26,6 +26,8 @@ import RealmSwift import XCTest final class ITAppLaunchTest: XCTestCase { + let loginConfig = InfomaniakLogin.Config(clientId: "9473D73C-C20F-4971-9E10-D957C563FA68", accessType: nil) + let fakeAccount = Account(apiToken: ApiToken( accessToken: "", expiresIn: 0, @@ -42,7 +44,7 @@ final class ITAppLaunchTest: XCTestCase { SimpleResolver.register(FactoryService.debugServices) let services = [ Factory(type: InfomaniakNetworkLogin.self) { _, _ in - return InfomaniakNetworkLogin(clientId: "", redirectUri: "") + return InfomaniakNetworkLogin(config: self.loginConfig) }, Factory(type: UploadQueue.self) { _, _ in UploadQueue() @@ -66,7 +68,7 @@ final class ITAppLaunchTest: XCTestCase { resolver: resolver) }, Factory(type: InfomaniakLoginable.self) { _, _ in - InfomaniakLogin(clientId: DriveApiFetcher.clientId) + InfomaniakLogin(config: self.loginConfig) }, Factory(type: AppLockHelper.self) { _, _ in AppLockHelper() diff --git a/kDriveTests/kDrive/Launch/MockAccountManager.swift b/kDriveTests/kDrive/Launch/MockAccountManager.swift index 22fbe88e7..bda697704 100644 --- a/kDriveTests/kDrive/Launch/MockAccountManager.swift +++ b/kDriveTests/kDrive/Launch/MockAccountManager.swift @@ -18,6 +18,7 @@ import Foundation import InfomaniakCore +import InfomaniakLogin import kDriveCore import RealmSwift diff --git a/kDriveTests/kDrive/Launch/UTRootViewControllerState.swift b/kDriveTests/kDrive/Launch/UTRootViewControllerState.swift index b61c8df31..e915f0080 100644 --- a/kDriveTests/kDrive/Launch/UTRootViewControllerState.swift +++ b/kDriveTests/kDrive/Launch/UTRootViewControllerState.swift @@ -26,6 +26,8 @@ import RealmSwift import XCTest final class UTRootViewControllerState: XCTestCase { + let loginConfig = InfomaniakLogin.Config(clientId: "9473D73C-C20F-4971-9E10-D957C563FA68", accessType: nil) + let fakeAccount = Account(apiToken: ApiToken( accessToken: "", expiresIn: 0, @@ -41,7 +43,7 @@ final class UTRootViewControllerState: XCTestCase { let services = [ Factory(type: InfomaniakNetworkLogin.self) { _, _ in - InfomaniakNetworkLogin(clientId: "", redirectUri: "") + InfomaniakNetworkLogin(config: self.loginConfig) }, Factory(type: UploadQueue.self) { _, _ in UploadQueue() @@ -65,7 +67,7 @@ final class UTRootViewControllerState: XCTestCase { resolver: resolver) }, Factory(type: InfomaniakLoginable.self) { _, _ in - InfomaniakLogin(clientId: DriveApiFetcher.clientId) + InfomaniakLogin(config: self.loginConfig) }, Factory(type: AppLockHelper.self) { _, _ in AppLockHelper() diff --git a/kDriveTests/kDriveCore/MenuViewControllerTests.swift b/kDriveTests/kDriveCore/MenuViewControllerTests.swift index 30330fd6b..24f440358 100644 --- a/kDriveTests/kDriveCore/MenuViewControllerTests.swift +++ b/kDriveTests/kDriveCore/MenuViewControllerTests.swift @@ -19,6 +19,7 @@ @testable import Alamofire @testable import InfomaniakCore @testable import InfomaniakDI +import InfomaniakLogin @testable import kDrive @testable import kDriveCore import XCTest