diff --git a/.package.resolved b/.package.resolved index aaa57d82f..9c8e383f2 100644 --- a/.package.resolved +++ b/.package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Infomaniak/ios-core", "state" : { - "revision" : "4eaefd644f75d833d6b1009dd94a9d6d674ccb53" + "revision" : "d779a9f6619615a4b4a91fa9d3fbb48415a27470" } }, { diff --git a/Mail/AppDelegate.swift b/Mail/AppDelegate.swift index eb19f6b36..8473f70f9 100644 --- a/Mail/AppDelegate.swift +++ b/Mail/AppDelegate.swift @@ -17,19 +17,27 @@ */ import CocoaLumberjackSwift +import InfomaniakCore import InfomaniakDI import InfomaniakNotifications import MailCore import UIKit -class AppDelegate: UIResponder, UIApplicationDelegate { +@available(iOSApplicationExtension, unavailable) +final class AppDelegate: UIResponder, UIApplicationDelegate { private let notificationCenterDelegate = NotificationCenterDelegate() - static var orientationLock = UIInterfaceOrientationMask.all - func application( - _ application: UIApplication, - willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { + @LazyInjectService private var orientationManager: OrientationManageable + @LazyInjectService private var accountManager: AccountManager + @LazyInjectService private var applicationState: ApplicationStatable + + /// Making sure the DI is registered at a very early stage of the app launch. + private let dependencyInjectionHook = EarlyDIHook() + + func application(_ application: UIApplication, + willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + DDLogInfo("Application starting in foreground ? \(applicationState.applicationState != .background)") + UNUserNotificationCenter.current().delegate = notificationCenterDelegate Task { // Ask permission app launch @@ -44,14 +52,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { @InjectService var notificationService: InfomaniakNotifications @InjectService var tokenStore: TokenStore - for account in AccountManager.instance.accounts { + for account in accountManager.accounts { Task { /* Because of a backend issue we can't register the notification token directly after the creation or refresh of an API token. We wait at least 15 seconds before trying to register. */ try? await Task.sleep(nanoseconds: 15_000_000_000) guard let token = tokenStore.tokenFor(userId: account.userId) else { return } - let userApiFetcher = AccountManager.instance.getApiFetcher(for: token.userId, token: token) + let userApiFetcher = accountManager.getApiFetcher(for: token.userId, token: token) await notificationService.updateRemoteNotificationsToken(tokenData: deviceToken, userApiFetcher: userApiFetcher, updatePolicy: .always) @@ -65,6 +73,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - return AppDelegate.orientationLock + return orientationManager.orientationLock } } diff --git a/Mail/Components/UnavailableMailboxListView.swift b/Mail/Components/UnavailableMailboxListView.swift index 0108a2ab1..38fb3d1b7 100644 --- a/Mail/Components/UnavailableMailboxListView.swift +++ b/Mail/Components/UnavailableMailboxListView.swift @@ -16,6 +16,7 @@ along with this program. If not, see . */ +import InfomaniakDI import MailCore import MailResources import RealmSwift @@ -27,14 +28,20 @@ struct UnavailableMailboxListView: View { @ObservedResults( Mailbox.self, configuration: MailboxInfosManager.instance.realmConfiguration, - where: { $0.userId == AccountManager.instance.currentUserId && $0.isPasswordValid == false }, + where: { mailbox in + @InjectService var accountManager: AccountManager + return mailbox.userId == accountManager.currentUserId && mailbox.isPasswordValid == false + }, sortDescriptor: SortDescriptor(keyPath: \Mailbox.mailboxId) ) private var passwordBlockedMailboxes @ObservedResults( Mailbox.self, configuration: MailboxInfosManager.instance.realmConfiguration, - where: { $0.userId == AccountManager.instance.currentUserId && $0.isLocked == true }, + where: { mailbox in + @InjectService var accountManager: AccountManager + return mailbox.userId == accountManager.currentUserId && mailbox.isLocked == true + }, sortDescriptor: SortDescriptor(keyPath: \Mailbox.mailboxId) ) private var lockedMailboxes diff --git a/Mail/Helpers/AppAssembly.swift b/Mail/Helpers/AppAssembly.swift new file mode 100644 index 000000000..c98f437af --- /dev/null +++ b/Mail/Helpers/AppAssembly.swift @@ -0,0 +1,141 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 InfomaniakBugTracker +import InfomaniakCore +import InfomaniakCoreUI +import InfomaniakDI +import InfomaniakLogin +import InfomaniakNotifications +import MailCore + +private let realmRootPath = "mailboxes" +private let appGroupIdentifier = "group.com.infomaniak.mail" + +extension Array where Element == Factory { + func registerFactoriesInDI() { + forEach { SimpleResolver.sharedResolver.store(factory: $0) } + } +} + +/// Something that prepares the application Dependency Injection +enum ApplicationAssembly { + static func setupDI() { + // Setup main servicies + setupMainServices() + + // Setup proxy types necessary for the App code to work in Extension mode + setupProxyTypes() + } + + private static func setupMainServices() { + let factories = [ + Factory(type: InfomaniakNetworkLoginable.self) { _, _ in + InfomaniakNetworkLogin(clientId: MailApiFetcher.clientId) + }, + Factory(type: InfomaniakLoginable.self) { _, _ in + InfomaniakLogin(clientId: MailApiFetcher.clientId) + }, + Factory(type: KeychainHelper.self) { _, _ in + KeychainHelper(accessGroup: AccountManager.accessGroup) + }, + Factory(type: InfomaniakNotifications.self) { _, _ in + InfomaniakNotifications(appGroup: AccountManager.appGroup) + }, + Factory(type: AppLockHelper.self) { _, _ in + AppLockHelper() + }, + Factory(type: BugTracker.self) { _, _ in + BugTracker(info: BugTrackerInfo(project: "app-mobile-mail", gitHubRepoName: "ios-mail", appReleaseType: .beta)) + }, + Factory(type: MatomoUtils.self) { _, _ in + MatomoUtils(siteId: Constants.matomoId, baseURL: URLConstants.matomo.url) + }, + Factory(type: IKSnackBarAvoider.self) { _, _ in + IKSnackBarAvoider() + }, + Factory(type: DraftManager.self) { _, _ in + DraftManager() + }, + Factory(type: AccountManager.self) { _, _ in + AccountManager() + }, + Factory(type: SnackBarPresentable.self) { _, _ in + SnackBarPresenter() + }, + Factory(type: UserAlertDisplayable.self) { _, _ in + UserAlertDisplayer() + }, + Factory(type: ApplicationStatable.self) { _, _ in + ApplicationState() + }, + Factory(type: UserActivityController.self) { _, _ in + UserActivityController() + }, + Factory(type: PlatformDetectable.self) { _, _ in + PlatformDetector() + }, + Factory(type: AppGroupPathProvidable.self) { _, _ in + guard let provider = AppGroupPathProvider( + realmRootPath: realmRootPath, + appGroupIdentifier: appGroupIdentifier + ) else { + fatalError("could not safely init AppGroupPathProvider") + } + + return provider + }, + Factory(type: TokenStore.self) { _, _ in + TokenStore() + } + ] + + factories.registerFactoriesInDI() + } + + private static func setupProxyTypes() { + let factories = [ + Factory(type: CacheManageable.self) { _, _ in + CacheManager() + }, + Factory(type: OrientationManageable.self) { _, _ in + OrientationManager() + }, + Factory(type: RemoteNotificationRegistrable.self) { _, _ in + RemoteNotificationRegistrer() + } + ] + + factories.registerFactoriesInDI() + } +} + +/// Something that loads the DI on init +public struct EarlyDIHook { + public init() { + // Setup date encoding + ApiFetcher.decoder.dateDecodingStrategy = .iso8601 + + // setup DI ASAP + ApplicationAssembly.setupDI() + + // Setup debug stack early, requires DI to be setup to work + Logging.initLogging() + } +} diff --git a/Mail/NotificationCenterDelegate.swift b/Mail/Helpers/NotificationCenterDelegate.swift similarity index 74% rename from Mail/NotificationCenterDelegate.swift rename to Mail/Helpers/NotificationCenterDelegate.swift index 865409eef..db09644ef 100644 --- a/Mail/NotificationCenterDelegate.swift +++ b/Mail/Helpers/NotificationCenterDelegate.swift @@ -17,6 +17,7 @@ */ import Foundation +import InfomaniakDI import MailCore import UIKit import UserNotifications @@ -26,24 +27,26 @@ public struct NotificationTappedPayload { } @MainActor -class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { +final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { + @LazyInjectService private var accountManager: AccountManager + private func handleClickOnNotification(scene: UIScene?, content: UNNotificationContent) { guard let mailboxId = content.userInfo[NotificationsHelper.UserInfoKeys.mailboxId] as? Int, let userId = content.userInfo[NotificationsHelper.UserInfoKeys.userId] as? Int, let mailbox = MailboxInfosManager.instance.getMailbox(id: mailboxId, userId: userId), - let mailboxManager = AccountManager.instance.getMailboxManager(for: mailbox) else { + let mailboxManager = accountManager.getMailboxManager(for: mailbox) else { return } - if AccountManager.instance.currentMailboxManager?.mailbox != mailboxManager.mailbox { - if AccountManager.instance.getCurrentAccount()?.userId != mailboxManager.mailbox.userId { - if let switchedAccount = AccountManager.instance.accounts.values + if accountManager.currentMailboxManager?.mailbox != mailboxManager.mailbox { + if accountManager.getCurrentAccount()?.userId != mailboxManager.mailbox.userId { + if let switchedAccount = accountManager.accounts.values .first(where: { $0.userId == mailboxManager.mailbox.userId }) { - AccountManager.instance.switchAccount(newAccount: switchedAccount) - AccountManager.instance.switchMailbox(newMailbox: mailbox) + accountManager.switchAccount(newAccount: switchedAccount) + accountManager.switchMailbox(newMailbox: mailbox) } } else { - AccountManager.instance.switchMailbox(newMailbox: mailbox) + accountManager.switchMailbox(newMailbox: mailbox) } } diff --git a/Mail/Helpers/WorkInProgress.swift b/Mail/Helpers/WorkInProgress.swift index 711d7a063..246261271 100644 --- a/Mail/Helpers/WorkInProgress.swift +++ b/Mail/Helpers/WorkInProgress.swift @@ -18,11 +18,13 @@ import Foundation import InfomaniakCoreUI +import InfomaniakDI import MailCore import MailResources // To delete: alert to facilitate tests for beta version @MainActor func showWorkInProgressSnackBar() { - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.workInProgressTitle) + @InjectService var snackbarPresenter: SnackBarPresentable + snackbarPresenter.show(message: MailResourcesStrings.Localizable.workInProgressTitle) } diff --git a/Mail/MailApp.swift b/Mail/MailApp.swift index ca32cc5d8..efd0a763a 100644 --- a/Mail/MailApp.swift +++ b/Mail/MailApp.swift @@ -28,67 +28,13 @@ import Sentry import SwiftUI import UIKit -public struct EarlyDIHook { - public init() { - // setup DI and logging ASAP - Logging.initLogging() - setupDI() - } - - func setupDI() { - let networkLoginService = Factory(type: InfomaniakNetworkLoginable.self) { _, _ in - InfomaniakNetworkLogin(clientId: MailApiFetcher.clientId) - } - let loginService = Factory(type: InfomaniakLoginable.self) { _, _ in - InfomaniakLogin(clientId: MailApiFetcher.clientId) - } - let keychainHelper = Factory(type: KeychainHelper.self) { _, _ in - KeychainHelper(accessGroup: AccountManager.accessGroup) - } - let tokenStore = Factory(type: TokenStore.self) { _, _ in - TokenStore() - } - let notificationService = Factory(type: InfomaniakNotifications.self) { _, _ in - InfomaniakNotifications(appGroup: AccountManager.appGroup) - } - let appLockHelper = Factory(type: AppLockHelper.self) { _, _ in - AppLockHelper() - } - let bugTracker = Factory(type: BugTracker.self) { _, _ in - BugTracker(info: BugTrackerInfo(project: "app-mobile-mail", gitHubRepoName: "ios-mail", appReleaseType: .beta)) - } - let matomoUtils = Factory(type: MatomoUtils.self) { _, _ in - MatomoUtils(siteId: Constants.matomoId, baseURL: URLConstants.matomo.url) - } - let avoider = Factory(type: SnackBarAvoider.self) { _, _ in - SnackBarAvoider() - } - let draftManager = Factory(type: DraftManager.self) { _, _ in - DraftManager() - } - let userActivityController = Factory(type: UserActivityController.self) { _, _ in - UserActivityController() - } - - SimpleResolver.sharedResolver.store(factory: networkLoginService) - SimpleResolver.sharedResolver.store(factory: loginService) - SimpleResolver.sharedResolver.store(factory: notificationService) - SimpleResolver.sharedResolver.store(factory: keychainHelper) - SimpleResolver.sharedResolver.store(factory: tokenStore) - SimpleResolver.sharedResolver.store(factory: appLockHelper) - SimpleResolver.sharedResolver.store(factory: bugTracker) - SimpleResolver.sharedResolver.store(factory: matomoUtils) - SimpleResolver.sharedResolver.store(factory: avoider) - SimpleResolver.sharedResolver.store(factory: draftManager) - SimpleResolver.sharedResolver.store(factory: userActivityController) - } -} - @main struct MailApp: App { /// Making sure the DI is registered at a very early stage of the app launch. private let dependencyInjectionHook = EarlyDIHook() - @LazyInjectService var appLockHelper: AppLockHelper + + @LazyInjectService private var appLockHelper: AppLockHelper + @LazyInjectService private var accountManager: AccountManager @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @@ -99,11 +45,8 @@ struct MailApp: App { @StateObject private var navigationState = NavigationState() - private let accountManager = AccountManager.instance - init() { DDLogInfo("Application starting in foreground ? \(UIApplication.shared.applicationState != .background)") - ApiFetcher.decoder.dateDecodingStrategy = .iso8601 } var body: some Scene { @@ -161,7 +104,7 @@ struct MailApp: App { try await accountManager.updateUser(for: account) accountManager.enableBugTrackerIfAvailable() - try await accountManager.currentMailboxManager?.contactManager.fetchContactsAndAddressBooks() + try await accountManager.currentContactManager?.fetchContactsAndAddressBooks() } catch { DDLogError("Error while updating user account: \(error)") } diff --git a/Mail/Proxy/Implementation/ApplicationState.swift b/Mail/Proxy/Implementation/ApplicationState.swift new file mode 100644 index 000000000..b15fd87f4 --- /dev/null +++ b/Mail/Proxy/Implementation/ApplicationState.swift @@ -0,0 +1,26 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 MailCore +import UIKit + +public struct ApplicationState: ApplicationStatable { + public var applicationState: UIApplication.State? { + UIApplication.shared.applicationState + } +} diff --git a/Mail/Proxy/Implementation/CacheManager.swift b/Mail/Proxy/Implementation/CacheManager.swift new file mode 100644 index 000000000..3010b7d1f --- /dev/null +++ b/Mail/Proxy/Implementation/CacheManager.swift @@ -0,0 +1,45 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 CocoaLumberjackSwift +import Foundation +import InfomaniakDI +import MailCore +import UIKit + +@available(iOSApplicationExtension, unavailable) +public final class CacheManager: CacheManageable { + @LazyInjectService private var accountManager: AccountManager + + public func refreshCacheData() { + guard let currentAccount = accountManager.getCurrentAccount() else { + return + } + + Task { + do { + try await accountManager.updateUser(for: currentAccount) + accountManager.enableBugTrackerIfAvailable() + + try await accountManager.currentContactManager?.fetchContactsAndAddressBooks() + } catch { + DDLogError("Error while updating user account: \(error)") + } + } + } +} diff --git a/Mail/Proxy/Implementation/OrientationLock.swift b/Mail/Proxy/Implementation/OrientationLock.swift new file mode 100644 index 000000000..61c8445da --- /dev/null +++ b/Mail/Proxy/Implementation/OrientationLock.swift @@ -0,0 +1,35 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 UIKit + +@available(iOSApplicationExtension, unavailable) +public final class OrientationManager: OrientationManageable { + /// Default to .all + public var orientationLock = UIInterfaceOrientationMask.all + + public func setOrientationLock(_ orientation: UIInterfaceOrientationMask) { + orientationLock = orientation + } + + public var interfaceOrientation: UIInterfaceOrientation? { + UIApplication.shared.mainSceneKeyWindow?.windowScene?.interfaceOrientation + } +} diff --git a/Mail/Proxy/Implementation/RemoteNotificationRegistrer.swift b/Mail/Proxy/Implementation/RemoteNotificationRegistrer.swift new file mode 100644 index 000000000..35a754849 --- /dev/null +++ b/Mail/Proxy/Implementation/RemoteNotificationRegistrer.swift @@ -0,0 +1,27 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 UIKit + +@available(iOSApplicationExtension, unavailable) +public final class RemoteNotificationRegistrer: RemoteNotificationRegistrable { + public func register() { + UIApplication.shared.registerForRemoteNotifications() + } +} diff --git a/Mail/Proxy/Protocols/CacheManageable.swift b/Mail/Proxy/Protocols/CacheManageable.swift new file mode 100644 index 000000000..0941b482f --- /dev/null +++ b/Mail/Proxy/Protocols/CacheManageable.swift @@ -0,0 +1,24 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 + +/// Something that can manipulate cached data +public protocol CacheManageable { + func refreshCacheData() +} diff --git a/Mail/Proxy/Protocols/OrientationManageable.swift b/Mail/Proxy/Protocols/OrientationManageable.swift new file mode 100644 index 000000000..760162cf1 --- /dev/null +++ b/Mail/Proxy/Protocols/OrientationManageable.swift @@ -0,0 +1,32 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 UIKit + +/// Something that can manage rotation state +public protocol OrientationManageable { + /// Access the orientation lock mask + var orientationLock: UIInterfaceOrientationMask { get } + + /// Set the orientation lock mask + func setOrientationLock(_ orientation: UIInterfaceOrientationMask) + + /// Read the interface orientation + var interfaceOrientation: UIInterfaceOrientation? { get } +} diff --git a/Mail/Proxy/Protocols/RemoteNotificationRegistrable.swift b/Mail/Proxy/Protocols/RemoteNotificationRegistrable.swift new file mode 100644 index 000000000..d84c57682 --- /dev/null +++ b/Mail/Proxy/Protocols/RemoteNotificationRegistrable.swift @@ -0,0 +1,24 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 + +/// Something that can register remote notifications +public protocol RemoteNotificationRegistrable { + func register() +} diff --git a/Mail/Proxy/Protocols/RootViewManageable.swift b/Mail/Proxy/Protocols/RootViewManageable.swift new file mode 100644 index 000000000..c99444dcc --- /dev/null +++ b/Mail/Proxy/Protocols/RootViewManageable.swift @@ -0,0 +1,29 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 UIKit + +/// Something that can fetch the Root View Controller +public protocol RootViewManageable { + /// The current rootViewController + var rootViewController: UIViewController? { get } + + /// The current mainSceneKeyWindow + var mainSceneKeyWindow: UIWindow? { get } +} diff --git a/Mail/Utils/NavigationState.swift b/Mail/Utils/NavigationState.swift index db580e4c0..66838a435 100644 --- a/Mail/Utils/NavigationState.swift +++ b/Mail/Utils/NavigationState.swift @@ -81,7 +81,6 @@ enum RootViewDestination { class NavigationState: ObservableObject { @LazyInjectService private var appLockHelper: AppLockHelper - private let accountManager = AccountManager.instance private var accountManagerObservation: AnyCancellable? @Published private(set) var rootViewState: RootViewState @@ -96,17 +95,20 @@ class NavigationState: ObservableObject { private(set) var account: Account? init() { - account = AccountManager.instance.getCurrentAccount() + @InjectService var accountManager: AccountManager + + account = accountManager.getCurrentAccount() rootViewState = NavigationState.getMainViewStateIfPossible() accountManagerObservation = accountManager.objectWillChange.receive(on: RunLoop.main).sink { [weak self] in - self?.account = AccountManager.instance.getCurrentAccount() + self?.account = accountManager.getCurrentAccount() self?.rootViewState = NavigationState.getMainViewStateIfPossible() } } static func getMainViewStateIfPossible() -> RootViewState { - let accountManager = AccountManager.instance + @InjectService var accountManager: AccountManager + if let currentAccount = accountManager.getCurrentAccount() { if let currentMailboxManager = accountManager.currentMailboxManager { return .mainView(currentMailboxManager) diff --git a/Mail/Utils/SnackBarAwareModifier.swift b/Mail/Utils/SnackBarAwareModifier.swift index 188d1092a..8b5cf42e8 100644 --- a/Mail/Utils/SnackBarAwareModifier.swift +++ b/Mail/Utils/SnackBarAwareModifier.swift @@ -16,12 +16,13 @@ along with this program. If not, see . */ +import InfomaniakCoreUI import InfomaniakDI import MailCore import SwiftUI struct SnackBarAwareModifier: ViewModifier { - @InjectService var avoider: SnackBarAvoider + @InjectService var avoider: IKSnackBarAvoider var inset: CGFloat { didSet { avoider.addAvoider(inset: inset) diff --git a/Mail/Utils/View+Extension.swift b/Mail/Utils/View+Extension.swift index 868e36127..8021b147b 100644 --- a/Mail/Utils/View+Extension.swift +++ b/Mail/Utils/View+Extension.swift @@ -17,26 +17,41 @@ */ import InfomaniakCoreUI +import InfomaniakDI import MailCore import MailResources import SwiftUI struct DeviceRotationViewModifier: ViewModifier { let action: (UIInterfaceOrientation?) -> Void - @State private var lastOrientation = UIApplication.shared.mainSceneKeyWindow?.windowScene?.interfaceOrientation + + private var orientationManager: OrientationManageable + + @State private var lastOrientation: UIInterfaceOrientation? + + init(action: @escaping (UIInterfaceOrientation?) -> Void) { + let orientationSource = InjectService().wrappedValue + let orientation = orientationSource.interfaceOrientation + self.action = action + orientationManager = orientationSource + lastOrientation = orientation + } func body(content: Content) -> some View { content .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - if lastOrientation != UIApplication.shared.mainSceneKeyWindow?.windowScene?.interfaceOrientation { - lastOrientation = UIApplication.shared.mainSceneKeyWindow?.windowScene?.interfaceOrientation - action(lastOrientation) + guard let currentOrientation = orientationManager.interfaceOrientation else { + return + } + if lastOrientation != currentOrientation { + lastOrientation = currentOrientation + action(currentOrientation) } } } } -// A View wrapper to make the modifier easier to use +/// A View wrapper to make the modifier easier to use extension View { func onRotate(perform action: @escaping (UIInterfaceOrientation?) -> Void) -> some View { modifier(DeviceRotationViewModifier(action: action)) diff --git a/Mail/Views/Alerts/AddLinkView.swift b/Mail/Views/Alerts/AddLinkView.swift index 321601606..a00df99d4 100644 --- a/Mail/Views/Alerts/AddLinkView.swift +++ b/Mail/Views/Alerts/AddLinkView.swift @@ -27,6 +27,7 @@ struct AddLinkView: View { @State private var url = "" @FocusState private var isFocused: Bool + @LazyInjectService private var snackbarPresenter: SnackBarPresentable @LazyInjectService private var matomo: MatomoUtils var actionHandler: ((String) -> Void)? @@ -50,14 +51,14 @@ struct AddLinkView: View { matomo.track(eventWithCategory: .editorActions, name: "addLinkConfirm") guard var urlComponents = URLComponents(string: url) else { - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarInvalidUrl) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarInvalidUrl) return } if urlComponents.scheme == nil { urlComponents.scheme = URLConstants.schemeUrl } guard let url = urlComponents.url?.absoluteString else { - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarInvalidUrl) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarInvalidUrl) return } actionHandler?(url) diff --git a/Mail/Views/Alerts/DetachMailboxConfirmationView.swift b/Mail/Views/Alerts/DetachMailboxConfirmationView.swift index a96053f14..87ac9373c 100644 --- a/Mail/Views/Alerts/DetachMailboxConfirmationView.swift +++ b/Mail/Views/Alerts/DetachMailboxConfirmationView.swift @@ -23,6 +23,8 @@ import MailResources import SwiftUI struct DetachMailboxConfirmationView: View { + @LazyInjectService private var accountManager: AccountManager + @EnvironmentObject private var navigationState: NavigationState let mailbox: Mailbox @@ -58,7 +60,7 @@ struct DetachMailboxConfirmationView: View { Task { await tryOrDisplayError { - try await AccountManager.instance.detachMailbox(mailbox: mailbox) + try await accountManager.detachMailbox(mailbox: mailbox) navigationState.transitionToRootViewDestination(.mainView) } } diff --git a/Mail/Views/Alerts/LogoutConfirmationView.swift b/Mail/Views/Alerts/LogoutConfirmationView.swift index fabceb5f1..b7b235ad5 100644 --- a/Mail/Views/Alerts/LogoutConfirmationView.swift +++ b/Mail/Views/Alerts/LogoutConfirmationView.swift @@ -25,6 +25,8 @@ import MailResources import SwiftUI struct LogoutConfirmationView: View { + @LazyInjectService private var accountManager: AccountManager + let account: Account var body: some View { @@ -44,11 +46,11 @@ struct LogoutConfirmationView: View { @InjectService var notificationService: InfomaniakNotifications await notificationService.removeStoredTokenFor(userId: account.userId) - AccountManager.instance.removeTokenAndAccount(account: account) - if let nextAccount = AccountManager.instance.accounts.first { - AccountManager.instance.switchAccount(newAccount: nextAccount) + accountManager.removeTokenAndAccount(account: account) + if let nextAccount = accountManager.accounts.first { + accountManager.switchAccount(newAccount: nextAccount) } - AccountManager.instance.saveAccounts() + accountManager.saveAccounts() } } } diff --git a/Mail/Views/Alerts/ReportPhishingView.swift b/Mail/Views/Alerts/ReportPhishingView.swift index bee83b6ab..c7e9748f3 100644 --- a/Mail/Views/Alerts/ReportPhishingView.swift +++ b/Mail/Views/Alerts/ReportPhishingView.swift @@ -17,11 +17,13 @@ */ import InfomaniakCoreUI +import InfomaniakDI import MailCore import MailResources import SwiftUI struct ReportPhishingView: View { + @LazyInjectService private var snackbarPresenter: SnackBarPresentable @EnvironmentObject private var mailboxManager: MailboxManager let message: Message @@ -45,7 +47,7 @@ struct ReportPhishingView: View { var messages = [message.freezeIfNeeded()] messages.append(contentsOf: message.duplicates) _ = try await mailboxManager.move(messages: messages, to: .spam) - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarReportPhishingConfirmation) + await snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarReportPhishingConfirmation) } } } diff --git a/Mail/Views/Attachment/AttachmentPreview.swift b/Mail/Views/Attachment/AttachmentPreview.swift index 69119cb8b..43d32344a 100644 --- a/Mail/Views/Attachment/AttachmentPreview.swift +++ b/Mail/Views/Attachment/AttachmentPreview.swift @@ -30,6 +30,8 @@ struct AttachmentPreview: View { @Environment(\.verticalSizeClass) var sizeClass + @LazyInjectService var rootViewControllerFetcher: RootViewManageable + var body: some View { NavigationView { Group { @@ -72,7 +74,7 @@ struct AttachmentPreview: View { @InjectService var matomo: MatomoUtils matomo.track(eventWithCategory: .message, name: "download") guard let url = attachment.localUrl, - var source = UIApplication.shared.mainSceneKeyWindow?.rootViewController else { + var source = rootViewControllerFetcher.rootViewController else { return } if let presentedViewController = source.presentedViewController { diff --git a/Mail/Views/Bottom sheets/Actions/ActionsViewModel.swift b/Mail/Views/Bottom sheets/Actions/ActionsViewModel.swift index 11a00945f..6d05b9887 100644 --- a/Mail/Views/Bottom sheets/Actions/ActionsViewModel.swift +++ b/Mail/Views/Bottom sheets/Actions/ActionsViewModel.swift @@ -239,6 +239,7 @@ enum ActionsTarget: Equatable, Identifiable { @Published var listActions: [Action] = [] @LazyInjectService private var matomo: MatomoUtils + @LazyInjectService private var snackbarPresenter: SnackBarPresentable init(mailboxManager: MailboxManager, target: ActionsTarget, @@ -457,7 +458,7 @@ enum ActionsTarget: Equatable, Identifiable { guard case .message(let message) = target else { return } let response = try await mailboxManager.apiFetcher.blockSender(message: message) if response { - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarSenderBlacklisted(1)) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarSenderBlacklisted(1)) } } diff --git a/Mail/Views/Bottom sheets/Actions/ContactActionsView.swift b/Mail/Views/Bottom sheets/Actions/ContactActionsView.swift index a320074dd..e3e9c10d0 100644 --- a/Mail/Views/Bottom sheets/Actions/ContactActionsView.swift +++ b/Mail/Views/Bottom sheets/Actions/ContactActionsView.swift @@ -29,6 +29,7 @@ struct ContactActionsView: View { @EnvironmentObject private var mailboxManager: MailboxManager @LazyInjectService private var matomo: MatomoUtils + @LazyInjectService private var snackbarPresenter: SnackBarPresentable @State private var writtenToRecipient: Recipient? @@ -98,14 +99,14 @@ struct ContactActionsView: View { Task { await tryOrDisplayError { try await mailboxManager.contactManager.addContact(recipient: recipient) - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarContactSaved) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarContactSaved) } } } private func copyEmail() { UIPasteboard.general.string = recipient.email - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarEmailCopiedToClipboard) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarEmailCopiedToClipboard) } } diff --git a/Mail/Views/Bottom sheets/ReportDisplayProblemView.swift b/Mail/Views/Bottom sheets/ReportDisplayProblemView.swift index 4a1239ae5..e67e209c7 100644 --- a/Mail/Views/Bottom sheets/ReportDisplayProblemView.swift +++ b/Mail/Views/Bottom sheets/ReportDisplayProblemView.swift @@ -18,12 +18,14 @@ import InfomaniakCore import InfomaniakCoreUI +import InfomaniakDI import MailCore import MailResources import Sentry import SwiftUI struct ReportDisplayProblemView: View { + @LazyInjectService private var snackbarPresenter: SnackBarPresentable @EnvironmentObject private var mailboxManager: MailboxManager let message: Message @@ -55,7 +57,7 @@ struct ReportDisplayProblemView: View { _ = SentrySDK.capture(message: "Message display problem reported") { scope in scope.add(fileAttachment) } - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarDisplayProblemReported) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarDisplayProblemReported) } } } diff --git a/Mail/Views/Bottom sheets/RestoreEmailsView.swift b/Mail/Views/Bottom sheets/RestoreEmailsView.swift index 71764957c..833d8822a 100644 --- a/Mail/Views/Bottom sheets/RestoreEmailsView.swift +++ b/Mail/Views/Bottom sheets/RestoreEmailsView.swift @@ -28,10 +28,10 @@ struct RestoreEmailsView: View { @State private var selectedDate = "" @State private var availableDates = [String]() - @State private var pickerNoSelectionText = MailResourcesStrings.Localizable.loadingText @LazyInjectService private var matomo: MatomoUtils + @LazyInjectService private var snackbarPresenter: SnackBarPresentable var body: some View { VStack(alignment: .leading) { @@ -76,7 +76,7 @@ struct RestoreEmailsView: View { Task { await tryOrDisplayError { try await mailboxManager.apiFetcher.restoreBackup(mailbox: mailboxManager.mailbox, date: selectedDate) - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarRestorationLaunched) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarRestorationLaunched) } } } diff --git a/Mail/LockedAppView.swift b/Mail/Views/LockedAppView.swift similarity index 100% rename from Mail/LockedAppView.swift rename to Mail/Views/LockedAppView.swift diff --git a/Mail/Views/Menu Drawer/Items/MenuDrawerItemsListView.swift b/Mail/Views/Menu Drawer/Items/MenuDrawerItemsListView.swift index 4a1804914..2eed3c6d2 100644 --- a/Mail/Views/Menu Drawer/Items/MenuDrawerItemsListView.swift +++ b/Mail/Views/Menu Drawer/Items/MenuDrawerItemsListView.swift @@ -27,6 +27,8 @@ import SwiftUI struct MenuDrawerItemsAdvancedListView: View { @State private var isShowingRestoreMails = false + @Environment(\.openURL) private var openURL + let mailboxCanRestoreEmails: Bool var body: some View { @@ -35,7 +37,7 @@ struct MenuDrawerItemsAdvancedListView: View { MenuDrawerItemCell(icon: MailResourcesAsset.drawerDownload, label: MailResourcesStrings.Localizable.buttonImportEmails, matomoName: "importEmails") { - UIApplication.shared.open(URLConstants.importMails.url) + openURL(URLConstants.importMails.url) } if mailboxCanRestoreEmails { MenuDrawerItemCell( @@ -56,6 +58,8 @@ struct MenuDrawerItemsAdvancedListView: View { struct MenuDrawerItemsHelpListView: View { @EnvironmentObject private var mailboxManager: MailboxManager + @Environment(\.openURL) private var openURL + @State private var isShowingHelp = false @State private var isShowingBugTracker = false @@ -85,7 +89,7 @@ struct MenuDrawerItemsHelpListView: View { if mailboxManager.account.user?.isStaff == true { isShowingBugTracker.toggle() } else if let userReportURL = URL(string: MailResourcesStrings.Localizable.urlUserReportiOS) { - UIApplication.shared.open(userReportURL) + openURL(userReportURL) } } } diff --git a/Mail/Views/Menu Drawer/MailboxManagement/MailboxCell.swift b/Mail/Views/Menu Drawer/MailboxManagement/MailboxCell.swift index fa5f7b526..555dd84ce 100644 --- a/Mail/Views/Menu Drawer/MailboxManagement/MailboxCell.swift +++ b/Mail/Views/Menu Drawer/MailboxManagement/MailboxCell.swift @@ -41,6 +41,8 @@ extension View { } struct MailboxCell: View { + @LazyInjectService private var accountManager: AccountManager + @Environment(\.mailboxCellStyle) private var style: Style @EnvironmentObject private var navigationDrawerState: NavigationDrawerState @@ -77,7 +79,7 @@ struct MailboxCell: View { case .account: matomo.track(eventWithCategory: .account, name: "switchMailbox") } - AccountManager.instance.switchMailbox(newMailbox: mailbox) + accountManager.switchMailbox(newMailbox: mailbox) navigationDrawerState.close() } .floatingPanel(isPresented: $isShowingLockedView) { diff --git a/Mail/Views/Menu Drawer/MailboxManagement/MailboxesManagementView.swift b/Mail/Views/Menu Drawer/MailboxManagement/MailboxesManagementView.swift index 5a8a25e6f..b40a30d82 100644 --- a/Mail/Views/Menu Drawer/MailboxManagement/MailboxesManagementView.swift +++ b/Mail/Views/Menu Drawer/MailboxManagement/MailboxesManagementView.swift @@ -28,6 +28,8 @@ struct MailboxesManagementView: View { @EnvironmentObject var mailboxManager: MailboxManager @EnvironmentObject var navigationDrawerState: NavigationDrawerState + @LazyInjectService private var accountManager: AccountManager + @ObservedResults( Mailbox.self, configuration: MailboxInfosManager.instance.realmConfiguration, @@ -94,8 +96,8 @@ struct MailboxesManagementView: View { } private func updateAccount() async throws { - guard let account = AccountManager.instance.account(for: mailboxManager.mailbox.userId) else { return } - try await AccountManager.instance.updateUser(for: account) + guard let account = accountManager.account(for: mailboxManager.mailbox.userId) else { return } + try await accountManager.updateUser(for: account) } } diff --git a/Mail/Views/New Message/Attachments/Attachable.swift b/Mail/Views/New Message/Attachments/Attachable.swift index 09420d564..91a3c510e 100644 --- a/Mail/Views/New Message/Attachments/Attachable.swift +++ b/Mail/Views/New Message/Attachments/Attachable.swift @@ -16,7 +16,10 @@ along with this program. If not, see . */ +import Combine import Foundation +import InfomaniakCore +import InfomaniakCoreUI import MailCore import PhotosUI import UniformTypeIdentifiers @@ -28,6 +31,10 @@ protocol Attachable { } extension NSItemProvider: Attachable { + enum ErrorDomain: Error { + case UTINotFound + } + private var preferredIdentifier: String { return registeredTypeIdentifiers .first { UTType($0)?.conforms(to: .image) == true || UTType($0)?.conforms(to: .movie) == true } ?? "" @@ -38,27 +45,29 @@ extension NSItemProvider: Attachable { } func writeToTemporaryURL() async throws -> URL { - return try await loadFileRepresentation(typeIdentifier: preferredIdentifier) - } + switch underlyingType { + case .isURL: + let getPlist = try ItemProviderURLRepresentation(from: self) + return try await getPlist.result.get() + + case .isText: + let getText = try ItemProviderTextRepresentation(from: self) + return try await getText.result.get() + + case .isUIImage: + let getUIImage = try ItemProviderUIImageRepresentation(from: self) + return try await getUIImage.result.get() + + case .isImageData, .isCompressedData, .isMiscellaneous: + let getFile = try ItemProviderFileRepresentation(from: self) + return try await getFile.result.get() + + case .isDirectory: + let getFile = try ItemProviderZipRepresentation(from: self) + return try await getFile.result.get() - private func loadFileRepresentation(typeIdentifier: String) async throws -> URL { - try await withCheckedThrowingContinuation { continuation in - loadFileRepresentation(forTypeIdentifier: typeIdentifier) { fileProviderURL, error in - guard let fileProviderURL else { - continuation.resume(throwing: error ?? MailError.unknownError) - return - } - - do { - let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let temporaryFileURL = temporaryURL.appendingPathComponent(fileProviderURL.lastPathComponent) - try FileManager.default.createDirectory(at: temporaryURL, withIntermediateDirectories: true) - try FileManager.default.copyItem(atPath: fileProviderURL.path, toPath: temporaryFileURL.path) - continuation.resume(returning: temporaryFileURL) - } catch { - continuation.resume(throwing: error) - } - } + case .none: + throw ErrorDomain.UTINotFound } } } diff --git a/Mail/Views/New Message/Attachments/AttachmentsManager.swift b/Mail/Views/New Message/Attachments/AttachmentsManager.swift index 6207deab9..fd8547de9 100644 --- a/Mail/Views/New Message/Attachments/AttachmentsManager.swift +++ b/Mail/Views/New Message/Attachments/AttachmentsManager.swift @@ -17,12 +17,14 @@ */ import CocoaLumberjackSwift +import Combine import Foundation +import InfomaniakCore import MailCore import PhotosUI import SwiftUI -class AttachmentUploadTask: ObservableObject { +final class AttachmentUploadTask: ObservableObject { @Published var progress: Double = 0 var task: Task? @Published var error: MailError? @@ -32,14 +34,22 @@ class AttachmentUploadTask: ObservableObject { } @MainActor -class AttachmentsManager: ObservableObject { +final class AttachmentsManager: ObservableObject { private let draft: Draft private let mailboxManager: MailboxManager + private let parallelTaskMapper = ParallelTaskMapper() + private let backgroundRealm: BackgroundRealm + + /// Something to debounce content will change updates + private let contentWillChangeSubject = PassthroughSubject() + private var contentWillChangeObserver: AnyCancellable? + var attachments: [Attachment] { return draft.attachments.filter { $0.contentId == nil }.toArray() } - private(set) var attachmentUploadTasks = [String: AttachmentUploadTask]() + private var attachmentUploadTasks = SendableDictionary() + var allAttachmentsUploaded: Bool { return attachmentUploadTasks.values.allSatisfy(\.uploadDone) } @@ -55,6 +65,14 @@ class AttachmentsManager: ObservableObject { init(draft: Draft, mailboxManager: MailboxManager) { self.draft = draft self.mailboxManager = mailboxManager + + // Debouncing objectWillChange helps a lot scaling with numerous attachments + backgroundRealm = BackgroundRealm(configuration: mailboxManager.realmConfiguration) + contentWillChangeObserver = contentWillChangeSubject + .debounce(for: .milliseconds(150), scheduler: DispatchQueue.main) + .sink { _ in + self.objectWillChange.send() + } } func completeUploadedAttachments() { @@ -62,26 +80,39 @@ class AttachmentsManager: ObservableObject { let uploadTask = attachmentUploadTaskOrCreate(for: attachment.uuid) uploadTask.progress = 1 } - objectWillChange.send() + contentWillChangeSubject.send() } - private func updateAttachment(oldAttachment: Attachment, newAttachment: Attachment) { - guard let realm = draft.realm, - let oldAttachment = draft.attachments.first(where: { $0.uuid == oldAttachment.uuid }) else { + private func updateAttachment(oldAttachment: Attachment, newAttachment: Attachment) async { + guard let oldAttachment = draft.attachments.first(where: { $0.uuid == oldAttachment.uuid }) else { return } - if oldAttachment.uuid != newAttachment.uuid { - attachmentUploadTasks[newAttachment.uuid] = attachmentUploadTasks[oldAttachment.uuid] - attachmentUploadTasks.removeValue(forKey: oldAttachment.uuid) + let oldAttachmentUUID = oldAttachment.uuid + let newAttachmentUUID = newAttachment.uuid + let primaryKey = draft.localUUID + + if oldAttachmentUUID != newAttachmentUUID { + attachmentUploadTasks[newAttachmentUUID] = attachmentUploadTasks[oldAttachmentUUID] + attachmentUploadTasks.removeValue(forKey: oldAttachmentUUID) } - try? realm.write { - // We need to update every field of the local attachment because embedded objects don't have a primary key - oldAttachment.update(with: newAttachment) + await backgroundRealm.execute { realm in + try? realm.write { + guard let draftInContext = realm.object(ofType: Draft.self, forPrimaryKey: primaryKey) else { + return + } + + guard let liveOldAttachment = draftInContext.attachments.first(where: { $0.uuid == oldAttachmentUUID }) else { + return + } + + // We need to update every field of the local attachment because embedded objects don't have a primary key + liveOldAttachment.update(with: newAttachment) + } } - objectWillChange.send() + contentWillChangeSubject.send() } /// Lookup and return. New object created and returned instead @@ -117,26 +148,50 @@ class AttachmentsManager: ObservableObject { } func removeAttachment(_ attachment: Attachment) { - guard let realm = draft.realm, - let liveAttachment = draft.attachments.first(where: { $0.uuid == attachment.uuid }) else { return } + let attachmentUUID = attachment.uuid + let primaryKey = draft.localUUID - let attachmentUUID = liveAttachment.uuid - try? realm.write { - realm.delete(liveAttachment) - } - attachmentUploadTasks[attachmentUUID]?.task?.cancel() - attachmentUploadTasks.removeValue(forKey: attachmentUUID) + Task { + await backgroundRealm.execute { realm in + try? realm.write { + guard let draftInContext = realm.object(ofType: Draft.self, forPrimaryKey: primaryKey) else { + return + } + + guard let liveAttachment = draftInContext.attachments.first(where: { $0.uuid == attachmentUUID }) else { + return + } + + realm.delete(liveAttachment) + } + } - objectWillChange.send() + attachmentUploadTasks[attachmentUUID]?.task?.cancel() + attachmentUploadTasks.removeValue(forKey: attachmentUUID) + + contentWillChangeSubject.send() + } } - private func addLocalAttachment(attachment: Attachment) -> Attachment { + private func addLocalAttachment(attachment: Attachment) async -> Attachment? { attachmentUploadTasks[attachment.uuid] = AttachmentUploadTask() - try? draft.realm?.write { - draft.attachments.append(attachment) + let primaryKey = draft.localUUID + + var detached: Attachment? + await backgroundRealm.execute { realm in + try? realm.write { + guard let draftInContext = realm.object(ofType: Draft.self, forPrimaryKey: primaryKey) else { + return + } + + draftInContext.attachments.append(attachment) + } + + detached = attachment.detached() } - objectWillChange.send() - return attachment.freeze() + + contentWillChangeSubject.send() + return detached } private func updateAttachmentUploadError(attachment: Attachment, error: Error?) { @@ -147,10 +202,9 @@ class AttachmentsManager: ObservableObject { } } - @MainActor private func createLocalAttachment(name: String, type: UTType?, - disposition: AttachmentDisposition) -> Attachment { + disposition: AttachmentDisposition) async -> Attachment? { let name = nameWithExtension(name: name, correspondingTo: type) let attachment = Attachment(uuid: UUID().uuidString, @@ -159,14 +213,15 @@ class AttachmentsManager: ObservableObject { size: 0, name: name, disposition: disposition) - let savedAttachment = addLocalAttachment(attachment: attachment) + let savedAttachment = await addLocalAttachment(attachment: attachment) return savedAttachment } private func updateLocalAttachment(url: URL, attachment: Attachment) async -> Attachment { let urlResources = try? url.resourceValues(forKeys: [.typeIdentifierKey, .fileSizeKey]) let uti = UTType(urlResources?.typeIdentifier ?? "") - let updatedName = nameWithExtension(name: attachment.name, + let name = url.lastPathComponent + let updatedName = nameWithExtension(name: name, correspondingTo: uti) let mimeType = uti?.preferredMIMEType ?? attachment.mimeType let size = Int64(urlResources?.fileSize ?? 0) @@ -178,23 +233,33 @@ class AttachmentsManager: ObservableObject { name: updatedName, disposition: attachment.disposition) - updateAttachment(oldAttachment: attachment, newAttachment: newAttachment) + await updateAttachment(oldAttachment: attachment, newAttachment: newAttachment) return newAttachment } - func importAttachments(attachments: [Attachable], disposition: AttachmentDisposition = .attachment) { - for attachment in attachments { - Task { - let cid = await importAttachment(attachment: attachment, disposition: disposition) + func importAttachments(attachments: [Attachable], draft: Draft, disposition: AttachmentDisposition = .attachment) { + guard !attachments.isEmpty else { + return + } + + // Cap max number of attachments, API errors out at 100 + let attachmentsSlice = attachments[safe: 0 ..< draft.availableAttachmentsSlots] + + Task { + try? await self.parallelTaskMapper.map(collection: attachmentsSlice) { attachment in + _ = await self.importAttachment(attachment: attachment, disposition: disposition) // TODO: - Manage inline attachment } } } private func importAttachment(attachment: Attachable, disposition: AttachmentDisposition) async -> String? { - let localAttachment = createLocalAttachment(name: attachment.suggestedName ?? getDefaultFileName(), - type: attachment.type, - disposition: disposition) + guard let localAttachment = await createLocalAttachment(name: attachment.suggestedName ?? getDefaultFileName(), + type: attachment.type, + disposition: disposition) else { + return nil + } + let importTask = Task { () -> String? in do { let url = try await attachment.writeToTemporaryURL() @@ -252,7 +317,7 @@ class AttachmentsManager: ObservableObject { self?.attachmentUploadTasks[localAttachment.uuid]?.progress = progress } } - updateAttachment(oldAttachment: localAttachment, newAttachment: remoteAttachment) + await updateAttachment(oldAttachment: localAttachment, newAttachment: remoteAttachment) return remoteAttachment } } diff --git a/Mail/Views/New Message/ComposeMessageBodyView.swift b/Mail/Views/New Message/ComposeMessageBodyView.swift index f7d909a8d..ece4799c2 100644 --- a/Mail/Views/New Message/ComposeMessageBodyView.swift +++ b/Mail/Views/New Message/ComposeMessageBodyView.swift @@ -63,19 +63,19 @@ struct ComposeMessageBodyView: View { } .fullScreenCover(isPresented: $isShowingCamera) { CameraPicker { data in - attachmentsManager.importAttachments(attachments: [data]) + attachmentsManager.importAttachments(attachments: [data], draft: draft) } .ignoresSafeArea() } .sheet(isPresented: $isShowingFileSelection) { DocumentPicker(pickerType: .selectContent([.item]) { urls in - attachmentsManager.importAttachments(attachments: urls) + attachmentsManager.importAttachments(attachments: urls, draft: draft) }) .ignoresSafeArea() } .sheet(isPresented: $isShowingPhotoLibrary) { ImagePicker { results in - attachmentsManager.importAttachments(attachments: results) + attachmentsManager.importAttachments(attachments: results, draft: draft) } .ignoresSafeArea() } diff --git a/Mail/Views/New Message/ComposeMessageView+Init.swift b/Mail/Views/New Message/ComposeMessageView+Init.swift index 71ccba60f..e3a53cd65 100644 --- a/Mail/Views/New Message/ComposeMessageView+Init.swift +++ b/Mail/Views/New Message/ComposeMessageView+Init.swift @@ -23,8 +23,9 @@ import MailCore import RealmSwift extension ComposeMessageView { - static func newMessage(_ draft: Draft, mailboxManager: MailboxManager) -> ComposeMessageView { - return ComposeMessageView(draft: draft, mailboxManager: mailboxManager) + static func newMessage(_ draft: Draft, mailboxManager: MailboxManager, + itemProviders: [NSItemProvider] = []) -> ComposeMessageView { + return ComposeMessageView(draft: draft, mailboxManager: mailboxManager, attachments: itemProviders) } static func replyOrForwardMessage(messageReply: MessageReply, mailboxManager: MailboxManager) -> ComposeMessageView { diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index d7e7ee6ed..da5d65852 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -16,6 +16,7 @@ along with this program. If not, see . */ +import InfomaniakCore import InfomaniakCoreUI import InfomaniakDI import Introspect @@ -59,15 +60,18 @@ final class NewMessageAlert: SheetState { struct ComposeMessageView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.dismissModal) var dismissModal @LazyInjectService private var matomo: MatomoUtils @LazyInjectService private var draftManager: DraftManager + @LazyInjectService private var snackbarPresenter: SnackBarPresentable @State private var isLoadingContent = true @State private var isShowingCancelAttachmentsError = false @State private var autocompletionType: ComposeViewFieldType? @State private var editorFocus = false @State private var currentSignature: Signature? + @State private var initialAttachments = [Attachable]() @State private var editorModel = RichTextEditorModel() @State private var scrollView: UIScrollView? @@ -98,7 +102,7 @@ struct ComposeMessageView: View { // MARK: - Init - init(draft: Draft, mailboxManager: MailboxManager, messageReply: MessageReply? = nil) { + init(draft: Draft, mailboxManager: MailboxManager, messageReply: MessageReply? = nil, attachments: [Attachable] = []) { self.messageReply = messageReply Self.saveNewDraftInRealm(mailboxManager.getRealm(), draft: draft) @@ -112,6 +116,7 @@ struct ComposeMessageView: View { self.mailboxManager = mailboxManager _attachmentsManager = StateObject(wrappedValue: AttachmentsManager(draft: draft, mailboxManager: mailboxManager)) + _initialAttachments = State(wrappedValue: attachments) } // MARK: - View @@ -120,6 +125,7 @@ struct ComposeMessageView: View { NavigationView { composeMessage } + .navigationViewStyle(.stack) .task { do { isLoadingContent = true @@ -128,11 +134,14 @@ struct ComposeMessageView: View { isLoadingContent = false } catch { // Unable to get signatures, "An error occurred" and close modal. - IKSnackBar.showSnackBar(message: MailError.unknownError.localizedDescription) + snackbarPresenter.show(message: MailError.unknownError.localizedDescription) dismiss() } } .onAppear { + attachmentsManager.importAttachments(attachments: initialAttachments, draft: draft) + initialAttachments = [] + switch messageReply?.replyMode { case .reply, .replyAll: focusedField = .editor @@ -156,7 +165,7 @@ struct ComposeMessageView: View { } .customAlert(isPresented: $isShowingCancelAttachmentsError) { AttachmentsUploadInProgressErrorView { - dismiss() + dismissMessageView() } } .matomoView(view: ["ComposeMessage"]) @@ -242,12 +251,18 @@ struct ComposeMessageView: View { // MARK: - Func + /// Something to dismiss the view regardless of presentation context + private func dismissMessageView() { + dismissModal() + dismiss() + } + private func didTouchDismiss() { guard attachmentsManager.allAttachmentsUploaded else { isShowingCancelAttachmentsError = true return } - dismiss() + dismissMessageView() } private func didTouchSend() { @@ -266,7 +281,7 @@ struct ComposeMessageView: View { liveDraft.action = .send } } - dismiss() + dismissMessageView() } private static func saveNewDraftInRealm(_ realm: Realm, draft: Draft) { diff --git a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift index 9d684b9f2..41d1b1129 100644 --- a/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift +++ b/Mail/Views/New Message/Header Cells/ComposeMessageCellRecipients.swift @@ -48,6 +48,9 @@ struct ComposeMessageCellRecipients: View { @FocusState var focusedField: ComposeViewFieldType? + @LazyInjectService private var snackbarPresenter: SnackBarPresentable + @LazyInjectService private var matomo: MatomoUtils + let type: ComposeViewFieldType var areCCAndBCCEmpty = false @@ -111,16 +114,15 @@ struct ComposeMessageCellRecipients: View { } @MainActor private func addNewRecipient(_ recipient: Recipient) { - @InjectService var matomo: MatomoUtils matomo.track(eventWithCategory: .newMessage, name: "addNewRecipient") guard Constants.isEmailAddress(recipient.email) else { - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientInvalidEmail) return } guard !recipients.contains(where: { $0.isSameRecipient(as: recipient) }) else { - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.addUnknownRecipientAlreadyUsed) return } diff --git a/Mail/Views/New Message/Recipients/FullRecipientsList.swift b/Mail/Views/New Message/Recipients/FullRecipientsList.swift index 2b597caa5..5ece0bed4 100644 --- a/Mail/Views/New Message/Recipients/FullRecipientsList.swift +++ b/Mail/Views/New Message/Recipients/FullRecipientsList.swift @@ -16,12 +16,12 @@ along with this program. If not, see . */ +import InfomaniakCoreUI +import InfomaniakDI import MailCore import RealmSwift import SwiftUI import WrappingHStack -import InfomaniakDI -import InfomaniakCoreUI struct FullRecipientsList: View { @EnvironmentObject private var mailboxManager: MailboxManager diff --git a/Mail/Views/New Message/Recipients/RecipientChip.swift b/Mail/Views/New Message/Recipients/RecipientChip.swift index abbfbd5cf..8fec6017e 100644 --- a/Mail/Views/New Message/Recipients/RecipientChip.swift +++ b/Mail/Views/New Message/Recipients/RecipientChip.swift @@ -17,6 +17,7 @@ */ import InfomaniakCoreUI +import InfomaniakDI import MailCore import MailResources import Popovers @@ -26,6 +27,7 @@ struct RecipientChip: View { @EnvironmentObject private var mailboxManager: MailboxManager @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor + @LazyInjectService private var snackbarPresenter: SnackBarPresentable let recipient: Recipient let fieldType: ComposeViewFieldType @@ -48,7 +50,7 @@ struct RecipientChip: View { Templates.MenuButton(text: Text(MailResourcesStrings.Localizable.contactActionCopyEmailAddress), image: MailResourcesAsset.duplicate.swiftUIImage) { UIPasteboard.general.string = recipient.email - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarEmailCopiedToClipboard) + snackbarPresenter.show(message: MailResourcesStrings.Localizable.snackbarEmailCopiedToClipboard) } Templates.MenuButton(text: Text(MailResourcesStrings.Localizable.actionDelete), diff --git a/Mail/Views/Onboarding/OnboardingView.swift b/Mail/Views/Onboarding/OnboardingView.swift index 3aa1aa7bf..5c6bff498 100644 --- a/Mail/Views/Onboarding/OnboardingView.swift +++ b/Mail/Views/Onboarding/OnboardingView.swift @@ -68,9 +68,12 @@ struct Slide: Identifiable { } @MainActor -class LoginHandler: InfomaniakLoginDelegate, ObservableObject { - @LazyInjectService var loginService: InfomaniakLoginable - @LazyInjectService var matomo: MatomoUtils +final class LoginHandler: InfomaniakLoginDelegate, ObservableObject { + @LazyInjectService private var loginService: InfomaniakLoginable + @LazyInjectService private var matomo: MatomoUtils + @LazyInjectService private var remoteNotificationRegistrer: RemoteNotificationRegistrable + @LazyInjectService private var accountManager: AccountManager + @LazyInjectService private var snackbarPresenter: SnackBarPresentable @Published var isLoading = false @Published var isPresentingErrorAlert = false @@ -123,18 +126,18 @@ class LoginHandler: InfomaniakLoginDelegate, ObservableObject { private func loginSuccessful(code: String, codeVerifier verifier: String) { matomo.track(eventWithCategory: .account, name: "loggedIn") - let previousAccount = AccountManager.instance.getCurrentAccount() + let previousAccount = accountManager.getCurrentAccount() Task { do { - _ = try await AccountManager.instance.createAndSetCurrentAccount(code: code, codeVerifier: verifier) - UIApplication.shared.registerForRemoteNotifications() + _ = try await accountManager.createAndSetCurrentAccount(code: code, codeVerifier: verifier) + remoteNotificationRegistrer.register() } catch let error as MailError where error == MailError.noMailbox { shouldShowEmptyMailboxesView = true } catch { if let previousAccount { - AccountManager.instance.switchAccount(newAccount: previousAccount) + accountManager.switchAccount(newAccount: previousAccount) } - IKSnackBar.showSnackBar(message: error.localizedDescription) + snackbarPresenter.show(message: error.localizedDescription) } isLoading = false } @@ -150,6 +153,8 @@ class LoginHandler: InfomaniakLoginDelegate, ObservableObject { struct OnboardingView: View { @Environment(\.dismiss) private var dismiss + @LazyInjectService var orientationManager: OrientationManageable + @EnvironmentObject private var navigationState: NavigationState @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor @@ -245,7 +250,7 @@ struct OnboardingView: View { if UIDevice.current.userInterfaceIdiom == .phone { UIDevice.current .setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") - AppDelegate.orientationLock = .portrait + orientationManager.setOrientationLock(.portrait) UIViewController.attemptRotationToDeviceOrientation() } } diff --git a/Mail/Views/Settings/General/SettingsNotificationsInstructionsView.swift b/Mail/Views/Settings/General/SettingsNotificationsInstructionsView.swift index 22d00fe0d..dc4046dcb 100644 --- a/Mail/Views/Settings/General/SettingsNotificationsInstructionsView.swift +++ b/Mail/Views/Settings/General/SettingsNotificationsInstructionsView.swift @@ -16,11 +16,13 @@ along with this program. If not, see . */ +import InfomaniakDI import MailResources import SwiftUI struct SettingsNotificationsInstructionsView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) var openURL var body: some View { VStack(alignment: .leading, spacing: 24) { @@ -40,9 +42,7 @@ struct SettingsNotificationsInstructionsView: View { return } - if UIApplication.shared.canOpenURL(settingsUrl) { - UIApplication.shared.open(settingsUrl) - } + openURL(settingsUrl) } } diff --git a/Mail/Views/Settings/General/SettingsNotificationsView.swift b/Mail/Views/Settings/General/SettingsNotificationsView.swift index 36c3b90e5..0d6e939b6 100644 --- a/Mail/Views/Settings/General/SettingsNotificationsView.swift +++ b/Mail/Views/Settings/General/SettingsNotificationsView.swift @@ -27,9 +27,12 @@ import SwiftUI struct SettingsNotificationsView: View { @LazyInjectService private var notificationService: InfomaniakNotifications @LazyInjectService private var matomo: MatomoUtils + @LazyInjectService private var accountManager: AccountManager @EnvironmentObject private var mailboxManager: MailboxManager + @Environment(\.openURL) private var openURL + @AppStorage(UserDefaults.shared.key(.notificationsEnabled)) private var notificationsEnabled = DefaultPreferences .notificationsEnabled @State var subscribedTopics: [String]? @@ -50,9 +53,7 @@ struct SettingsNotificationsView: View { return } - if UIApplication.shared.canOpenURL(settingsUrl) { - UIApplication.shared.open(settingsUrl) - } + openURL(settingsUrl) } .mailButtonStyle(.link) } diff --git a/Mail/Views/Settings/General/SettingsView.swift b/Mail/Views/Settings/General/SettingsView.swift index ddafc07bd..96816125b 100644 --- a/Mail/Views/Settings/General/SettingsView.swift +++ b/Mail/Views/Settings/General/SettingsView.swift @@ -24,6 +24,8 @@ import MailResources import SwiftUI struct SettingsView: View { + @InjectService private var accountManager: AccountManager + @EnvironmentObject private var mailboxManager: MailboxManager @LazyInjectService private var appLockHelper: AppLockHelper @@ -40,7 +42,7 @@ struct SettingsView: View { .textStyle(.bodySmallSecondary) ForEachMailboxView(userId: mailboxManager.account.userId) { mailbox in - if let mailboxManager = AccountManager.instance.getMailboxManager(for: mailbox) { + if let mailboxManager = accountManager.getMailboxManager(for: mailbox) { SettingsSubMenuCell(title: mailbox.email) { MailboxSettingsView(mailboxManager: mailboxManager) } diff --git a/Mail/Views/Settings/SettingsOptionView.swift b/Mail/Views/Settings/SettingsOptionView.swift index 77ec230ca..56922d9a3 100644 --- a/Mail/Views/Settings/SettingsOptionView.swift +++ b/Mail/Views/Settings/SettingsOptionView.swift @@ -34,6 +34,8 @@ struct SettingsOptionView: View where OptionEnum: CaseIterable, Opti private let matomoValue: Float? private let matomoName: KeyPath? + @LazyInjectService private var matomo: MatomoUtils + @State private var values: [OptionEnum] @State private var selectedValue: OptionEnum { didSet { @@ -51,8 +53,6 @@ struct SettingsOptionView: View where OptionEnum: CaseIterable, Opti } } - @LazyInjectService private var matomo: MatomoUtils - init(title: String, subtitle: String? = nil, values: [OptionEnum] = Array(OptionEnum.allCases), diff --git a/Mail/Views/SplitView.swift b/Mail/Views/SplitView.swift index 693cf344b..32060a556 100644 --- a/Mail/Views/SplitView.swift +++ b/Mail/Views/SplitView.swift @@ -19,6 +19,7 @@ import InfomaniakBugTracker import InfomaniakCore import InfomaniakCoreUI +import InfomaniakDI import Introspect import MailCore import MailResources @@ -27,11 +28,17 @@ import RealmSwift import SwiftUI public class SplitViewManager: ObservableObject { + @LazyInjectService private var platformDetector: PlatformDetectable + @Published var showSearch = false @Published var selectedFolder: Folder? var splitViewController: UISplitViewController? func adaptToProminentThreadView() { + guard !platformDetector.isMacCatalyst, !platformDetector.isiOSAppOnMac else { + return + } + splitViewController?.hide(.primary) if splitViewController?.splitBehavior == .overlay { splitViewController?.hide(.supplementary) @@ -51,6 +58,10 @@ struct SplitView: View { @StateObject private var navigationDrawerController = NavigationDrawerState() @StateObject private var splitViewManager = SplitViewManager() + @LazyInjectService private var orientationManager: OrientationManageable + @LazyInjectService private var snackbarPresenter: SnackBarPresentable + @LazyInjectService private var platformDetector: PlatformDetectable + let mailboxManager: MailboxManager var body: some View { @@ -114,14 +125,14 @@ struct SplitView: View { if let tappedNotificationThread = tappedNotificationMessage?.originalThread { navigationState.threadPath = [tappedNotificationThread] } else { - IKSnackBar.showSnackBar(message: MailError.localMessageNotFound.errorDescription) + snackbarPresenter.show(message: MailError.localMessageNotFound.errorDescription) } } .onReceive(NotificationCenter.default.publisher(for: .onOpenedMailTo)) { identifiableURLComponents in mailToURLComponents = identifiableURLComponents.object as? IdentifiableURLComponents } .onAppear { - AppDelegate.orientationLock = .all + orientationManager.setOrientationLock(.all) } .task(id: mailboxManager.mailbox.objectId) { await fetchSignatures() @@ -148,7 +159,10 @@ struct SplitView: View { } private func setupBehaviour(orientation: UIInterfaceOrientation) { - if orientation.isLandscape { + if platformDetector.isMacCatalyst || platformDetector.isiOSAppOnMac { + splitViewController?.preferredSplitBehavior = .tile + splitViewController?.preferredDisplayMode = .twoBesideSecondary + } else if orientation.isLandscape { splitViewController?.preferredSplitBehavior = .displace splitViewController?.preferredDisplayMode = splitViewManager.selectedFolder == nil ? .twoDisplaceSecondary diff --git a/Mail/Views/Switch User/AccountCellView.swift b/Mail/Views/Switch User/AccountCellView.swift index 64482eb3f..a07d12074 100644 --- a/Mail/Views/Switch User/AccountCellView.swift +++ b/Mail/Views/Switch User/AccountCellView.swift @@ -26,6 +26,8 @@ import RealmSwift import SwiftUI struct AccountCellView: View { + @LazyInjectService private var accountManager: AccountManager + @Environment(\.dismissModal) var dismissModal let account: Account @@ -50,7 +52,7 @@ struct AccountCellView: View { @InjectService var matomo: MatomoUtils matomo.track(eventWithCategory: .account, name: "switch") dismissModal() - AccountManager.instance.switchAccount(newAccount: account) + accountManager.switchAccount(newAccount: account) } label: { AccountHeaderCell(account: account, isSelected: Binding(get: { isSelected diff --git a/Mail/Views/Switch User/AccountListView.swift b/Mail/Views/Switch User/AccountListView.swift index 8e309e603..d6dcd8b53 100644 --- a/Mail/Views/Switch User/AccountListView.swift +++ b/Mail/Views/Switch User/AccountListView.swift @@ -24,8 +24,13 @@ import MailResources import RealmSwift import SwiftUI -class AccountListViewModel: ObservableObject { - @Published var selectedUserId: Int? = AccountManager.instance.currentUserId +final class AccountListViewModel: ObservableObject { + @LazyInjectService private var accountManager: AccountManager + + @Published var selectedUserId: Int? = { + @InjectService var accountManager: AccountManager + return accountManager.currentUserId + }() @Published var accounts = [Account: [Mailbox]]() @@ -50,7 +55,7 @@ class AccountListViewModel: ObservableObject { } private func handleMailboxChanged(_ mailboxes: [Mailbox]) { - for account in AccountManager.instance.accounts { + for account in accountManager.accounts { accounts[account] = mailboxes.filter { $0.userId == account.userId } } } @@ -61,6 +66,8 @@ struct AccountListView: View { @State var isShowingNewAccountView = false @LazyInjectService private var matomo: MatomoUtils + @LazyInjectService private var orientationManager: OrientationManageable + @LazyInjectService private var accountManager: AccountManager var body: some View { ScrollView { @@ -79,7 +86,7 @@ struct AccountListView: View { isShowingNewAccountView = true } .fullScreenCover(isPresented: $isShowingNewAccountView, onDismiss: { - AppDelegate.orientationLock = .all + orientationManager.setOrientationLock(.all) }, content: { OnboardingView(page: 4, isScrollEnabled: false) }) @@ -91,9 +98,9 @@ struct AccountListView: View { private func updateUsers() async throws { await withThrowingTaskGroup(of: Void.self) { group in - for account in AccountManager.instance.accounts { + for account in accountManager.accounts { group.addTask { - _ = try await AccountManager.instance.updateUser(for: account) + _ = try await accountManager.updateUser(for: account) } } } diff --git a/Mail/Views/Switch User/AccountView.swift b/Mail/Views/Switch User/AccountView.swift index 78a554ba0..325c219b8 100644 --- a/Mail/Views/Switch User/AccountView.swift +++ b/Mail/Views/Switch User/AccountView.swift @@ -25,29 +25,32 @@ import MailResources import Sentry import SwiftUI -class AccountViewDelegate: DeleteAccountDelegate { +final class AccountViewDelegate: DeleteAccountDelegate { + @LazyInjectService private var accountManager: AccountManager + @LazyInjectService private var snackbarPresenter: SnackBarPresentable + @MainActor func didCompleteDeleteAccount() { Task { - guard let account = AccountManager.instance.getCurrentAccount() else { return } - AccountManager.instance.removeTokenAndAccount(account: account) - if let nextAccount = AccountManager.instance.accounts.first { - AccountManager.instance.switchAccount(newAccount: nextAccount) - IKSnackBar.showSnackBar(message: "Account deleted") + guard let account = accountManager.getCurrentAccount() else { return } + accountManager.removeTokenAndAccount(account: account) + if let nextAccount = accountManager.accounts.first { + accountManager.switchAccount(newAccount: nextAccount) + snackbarPresenter.show(message: "Account deleted") } - AccountManager.instance.saveAccounts() + accountManager.saveAccounts() } } @MainActor func didFailDeleteAccount(error: InfomaniakLoginError) { SentrySDK.capture(error: error) - IKSnackBar.showSnackBar(message: "Failed to delete account") + snackbarPresenter.show(message: "Failed to delete account") } } struct AccountView: View { @LazyInjectService private var matomo: MatomoUtils @LazyInjectService private var tokenStore: TokenStore - + @Environment(\.dismiss) private var dismiss @EnvironmentObject private var mailboxManager: MailboxManager diff --git a/Mail/Views/Switch User/AddMailboxView.swift b/Mail/Views/Switch User/AddMailboxView.swift index 98dd85a93..b09e5a34a 100644 --- a/Mail/Views/Switch User/AddMailboxView.swift +++ b/Mail/Views/Switch User/AddMailboxView.swift @@ -25,6 +25,9 @@ import SwiftUI struct AddMailboxView: View { @Environment(\.dismiss) var dismiss + @LazyInjectService private var accountManager: AccountManager + @LazyInjectService private var snackbarPresenter: SnackBarPresentable + @State private var newAddress = "" @State private var password = "" @State private var showError = false @@ -123,7 +126,7 @@ struct AddMailboxView: View { Task { do { isButtonLoading = true - try await AccountManager.instance.addMailbox(mail: newAddress, password: password) + try await accountManager.addMailbox(mail: newAddress, password: password) isButtonLoading = false } catch let error as MailApiError where error == .apiInvalidCredential { withAnimation { @@ -136,7 +139,7 @@ struct AddMailboxView: View { password = "" isButtonLoading = false } - await IKSnackBar.showSnackBar(message: error.localizedDescription) + snackbarPresenter.show(message: error.localizedDescription) } } } diff --git a/Mail/Views/Thread/ThreadView.swift b/Mail/Views/Thread/ThreadView.swift index 4cec2d5f0..2ffa9d1b5 100644 --- a/Mail/Views/Thread/ThreadView.swift +++ b/Mail/Views/Thread/ThreadView.swift @@ -153,14 +153,16 @@ struct ThreadView: View { } switch action { case .reply: - guard let message = thread.lastMessageToExecuteAction(currentMailboxEmail: mailboxManager.mailbox.email) else { return } + guard let message = thread.lastMessageToExecuteAction(currentMailboxEmail: mailboxManager.mailbox.email) + else { return } if message.canReplyAll(currentMailboxEmail: mailboxManager.mailbox.email) { replyOrReplyAllMessage = message } else { navigationState.messageReply = MessageReply(message: message, replyMode: .reply) } case .forward: - guard let message = thread.lastMessageToExecuteAction(currentMailboxEmail: mailboxManager.mailbox.email) else { return } + guard let message = thread.lastMessageToExecuteAction(currentMailboxEmail: mailboxManager.mailbox.email) + else { return } navigationState.messageReply = MessageReply(message: message, replyMode: .forward) case .archive: Task { diff --git a/Mail/Views/Thread/WebView.swift b/Mail/Views/Thread/WebView.swift index 05265a846..59195b736 100644 --- a/Mail/Views/Thread/WebView.swift +++ b/Mail/Views/Thread/WebView.swift @@ -17,6 +17,7 @@ */ import Combine +import InfomaniakDI import MailCore import SwiftUI import WebKit @@ -132,7 +133,7 @@ extension WebViewController: WKNavigationDelegate { if navigationAction.navigationType == .linkActivated { if let url = navigationAction.request.url { decisionHandler(.cancel) - UIApplication.shared.open(url) + openURL?(url) } } else { decisionHandler(.allow) diff --git a/Mail/Views/Unavailable Mailbox/UnavailableMailboxesView.swift b/Mail/Views/Unavailable Mailbox/UnavailableMailboxesView.swift index 46f4d8c74..10252b0b3 100644 --- a/Mail/Views/Unavailable Mailbox/UnavailableMailboxesView.swift +++ b/Mail/Views/Unavailable Mailbox/UnavailableMailboxesView.swift @@ -23,6 +23,7 @@ import MailResources import SwiftUI struct UnavailableMailboxesView: View { + @LazyInjectService private var orientationManager: OrientationManageable @LazyInjectService private var matomo: MatomoUtils @State private var isShowingNewAccountView = false @@ -86,7 +87,7 @@ struct UnavailableMailboxesView: View { } .navigationViewStyle(.stack) .fullScreenCover(isPresented: $isShowingNewAccountView) { - AppDelegate.orientationLock = .all + orientationManager.setOrientationLock(.all) } content: { OnboardingView(page: 4, isScrollEnabled: false) } diff --git a/Mail/Views/Unavailable Mailbox/UpdateMailboxPasswordView.swift b/Mail/Views/Unavailable Mailbox/UpdateMailboxPasswordView.swift index 281bccc7d..2f4840e37 100644 --- a/Mail/Views/Unavailable Mailbox/UpdateMailboxPasswordView.swift +++ b/Mail/Views/Unavailable Mailbox/UpdateMailboxPasswordView.swift @@ -23,10 +23,11 @@ import MailResources import SwiftUI struct UpdateMailboxPasswordView: View { - @EnvironmentObject private var navigationState: NavigationState - + @LazyInjectService private var accountManager: AccountManager @LazyInjectService private var matomo: MatomoUtils + @EnvironmentObject private var navigationState: NavigationState + @State private var updatedMailboxPassword = "" @State private var isShowingError = false @State private var isLoading = false @@ -115,7 +116,7 @@ struct UpdateMailboxPasswordView: View { Task { isLoading = true do { - try await AccountManager.instance.updateMailboxPassword(mailbox: mailbox, password: updatedMailboxPassword) + try await accountManager.updateMailboxPassword(mailbox: mailbox, password: updatedMailboxPassword) navigationState.transitionToRootViewDestination(.mainView) } catch { isShowingError = true diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift index 604c96041..6bac549f0 100644 --- a/MailCore/API/MailApiFetcher.swift +++ b/MailCore/API/MailApiFetcher.swift @@ -374,7 +374,7 @@ public class MailApiFetcher: ApiFetcher { } } -class SyncedAuthenticator: OAuthAuthenticator { +final class SyncedAuthenticator: OAuthAuthenticator { func handleFailedRefreshingToken(oldToken: ApiToken, error: Error?) -> Result { guard let error = error as NSError?, error.domain == "invalid_grant" else { diff --git a/MailCore/Cache/AccountManager.swift b/MailCore/Cache/AccountManager.swift index 35fbe5cc1..639d38fce 100644 --- a/MailCore/Cache/AccountManager.swift +++ b/MailCore/Cache/AccountManager.swift @@ -59,7 +59,7 @@ public extension InfomaniakNetworkLoginable { } } -public class AccountManager: RefreshTokenDelegate, ObservableObject { +public final class AccountManager: RefreshTokenDelegate, ObservableObject { @LazyInjectService var networkLoginService: InfomaniakNetworkLoginable @LazyInjectService var tokenStore: TokenStore @LazyInjectService var bugTracker: BugTracker @@ -68,12 +68,18 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { private static let appIdentifierPrefix = Bundle.main.infoDictionary!["AppIdentifierPrefix"] as! String private static let group = "com.infomaniak.mail" + + private let tag = "ch.infomaniak.token".data(using: .utf8)! + private var currentAccount: Account? + public static let appGroup = "group." + group public static let accessGroup: String = AccountManager.appIdentifierPrefix + AccountManager.group - public static var instance = AccountManager() - private var currentAccount: Account? public var accounts = SendableArray() + public var tokens = [ApiToken]() + public let refreshTokenLockedQueue = DispatchQueue(label: "com.infomaniak.mail.refreshtoken") + public static var instance = AccountManager() + public weak var delegate: AccountManagerDelegate? public var currentUserId: Int { didSet { @@ -102,6 +108,11 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { } } + /// Shorthand for `currentMailboxManager?.contactManager` + public var currentContactManager: ContactManager? { + currentMailboxManager?.contactManager + } + public var currentApiFetcher: MailApiFetcher? { return apiFetchers[currentUserId] } @@ -110,7 +121,7 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { private let contactManagers = SendableDictionary() private let apiFetchers = SendableDictionary() - private init() { + public init() { currentMailboxId = UserDefaults.shared.currentMailboxId currentUserId = UserDefaults.shared.currentMailUserId @@ -207,7 +218,7 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { } tokenStore.removeTokenFor(userId: token.userId) if let account = account(for: token.userId), - account.userId == currentUserId { + account.userId == currentUserId { delegate?.currentAccountNeedsAuthentication() NotificationsHelper.sendDisconnectedNotification() } diff --git a/MailCore/Cache/BackgroundRealm.swift b/MailCore/Cache/BackgroundRealm.swift index 533c908f1..eec14f5b2 100644 --- a/MailCore/Cache/BackgroundRealm.swift +++ b/MailCore/Cache/BackgroundRealm.swift @@ -20,11 +20,11 @@ import Foundation import RealmSwift import Sentry -public class BackgroundRealm { +public final class BackgroundRealm { private let configuration: Realm.Configuration private let queue: DispatchQueue - init(configuration: Realm.Configuration) { + public init(configuration: Realm.Configuration) { guard let fileURL = configuration.fileURL else { fatalError("Realm configurations without file URL not supported") } diff --git a/MailCore/Cache/DraftManager.swift b/MailCore/Cache/DraftManager.swift index cd6bb57b1..17e2c1fdf 100644 --- a/MailCore/Cache/DraftManager.swift +++ b/MailCore/Cache/DraftManager.swift @@ -70,6 +70,7 @@ public final class DraftManager { private static let saveExpirationSec = 3 @LazyInjectService private var matomo: MatomoUtils + @LazyInjectService private var alertDisplayable: UserAlertDisplayable /// Used by DI only public init() { @@ -91,15 +92,14 @@ public final class DraftManager { do { try await mailboxManager.save(draft: draft) } catch { - if error.shouldDisplay { - await IKSnackBar.showSnackBar(message: error.localizedDescription) - } + guard error.shouldDisplay else { return } + alertDisplayable.show(message: error.localizedDescription) } await draftQueue.endBackgroundTask(uuid: draft.localUUID) } public func send(draft: Draft, mailboxManager: MailboxManager) async -> Date? { - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarEmailSending) + alertDisplayable.show(message: MailResourcesStrings.Localizable.snackbarEmailSending) var sendDate: Date? await draftQueue.cleanQueueElement(uuid: draft.localUUID) @@ -107,10 +107,10 @@ public final class DraftManager { do { let cancelableResponse = try await mailboxManager.send(draft: draft) - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarEmailSent) + alertDisplayable.show(message: MailResourcesStrings.Localizable.snackbarEmailSent) sendDate = cancelableResponse.scheduledDate } catch { - await IKSnackBar.showSnackBar(message: error.localizedDescription) + alertDisplayable.show(message: error.localizedDescription) } await draftQueue.endBackgroundTask(uuid: draft.localUUID) return sendDate @@ -125,7 +125,7 @@ public final class DraftManager { var sendDate: Date? switch draft.action { case .initialSave: - await self.initialSave(draft: draft, mailboxManager: mailboxManager) + await self.initialSaveRemotely(draft: draft, mailboxManager: mailboxManager) case .save: await self.saveDraftRemotely(draft: draft, mailboxManager: mailboxManager) case .send: @@ -148,8 +148,48 @@ public final class DraftManager { } } + /// First save of a draft with the remote, if non empty. + /// + /// Present a message with a `delete draft` action + @discardableResult + public func initialSaveRemotely(draft: Draft, mailboxManager: MailboxManager) async -> Bool { + guard !isDraftEmpty(draft: draft) else { + deleteEmptyDraft(draft: draft, for: mailboxManager) + return false + } + + await saveDraftRemotely(draft: draft, mailboxManager: mailboxManager) + + let messageAction: UserAlertAction = (MailResourcesStrings.Localizable.actionDelete, { [weak self] in + self?.matomo.track(eventWithCategory: .snackbar, name: "deleteDraft") + self?.deleteDraftSnackBarAction(draft: draft, mailboxManager: mailboxManager) + }) + alertDisplayable.show(message: MailResourcesStrings.Localizable.snackbarDraftSaved, action: messageAction) + + return true + } + + /// Check multiple conditions to infer if a draft is empty or not + private func isDraftEmpty(draft: Draft) -> Bool { + guard isDraftBodyEmptyOfAttachments(draft: draft) else { + return false + } + + guard (try? isDraftBodyEmptyOfChanges(draft.body)) ?? true else { + return false + } + + return true + } + + /// Check that the draft has some Attachments of not + private func isDraftBodyEmptyOfAttachments(draft: Draft) -> Bool { + // This excludes the signature attachments that are present in Draft.attachments + return draft.attachments.filter { $0.contentId == nil }.isEmpty + } + /// Check if once the Signature node is removed, we still have content - func isDraftBodyEmptyOfChanges(_ body: String) throws -> Bool { + internal func isDraftBodyEmptyOfChanges(_ body: String) throws -> Bool { guard !body.isEmpty else { return true } @@ -167,22 +207,6 @@ public final class DraftManager { return !document.hasText() } - private func initialSave(draft: Draft, mailboxManager: MailboxManager) async { - // We consider the body to be not-empty on HTML parsing failure to keep user content. - let isDraftEmpty = (try? isDraftBodyEmptyOfChanges(draft.body)) ?? false - guard !isDraftEmpty else { - deleteEmptyDraft(draft: draft, for: mailboxManager) - return - } - - await saveDraftRemotely(draft: draft, mailboxManager: mailboxManager) - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarDraftSaved, - action: .init(title: MailResourcesStrings.Localizable.actionDelete) { [weak self] in - self?.matomo.track(eventWithCategory: .snackbar, name: "deleteDraft") - self?.deleteDraftSnackBarAction(draft: draft, mailboxManager: mailboxManager) - }) - } - private func refreshDraftFolder(latestSendDate: Date?, mailboxManager: MailboxManager) async throws { if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { await mailboxManager.refresh(folder: draftFolder) @@ -206,7 +230,7 @@ public final class DraftManager { await tryOrDisplayError { if let liveDraft = draft.thaw() { try await mailboxManager.delete(draft: liveDraft.freeze()) - await IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.snackbarDraftDeleted) + alertDisplayable.show(message: MailResourcesStrings.Localizable.snackbarDraftDeleted) if let draftFolder = mailboxManager.getFolder(with: .draft)?.freeze() { await mailboxManager.refresh(folder: draftFolder) } diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index cf663a616..343316c13 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -20,38 +20,28 @@ import CocoaLumberjackSwift import Foundation import InfomaniakCore import InfomaniakCoreUI +import InfomaniakDI import MailResources import RealmSwift import Sentry import SwiftRegex -public class MailboxManager: ObservableObject { - public class MailboxManagerConstants { +public final class MailboxManager: ObservableObject { + @LazyInjectService private var snackbarPresenter: SnackBarPresentable + + public final class MailboxManagerConstants { private let fileManager = FileManager.default public let rootDocumentsURL: URL public let groupDirectoryURL: URL public let cacheDirectoryURL: URL init() { - groupDirectoryURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: AccountManager.appGroup)! - rootDocumentsURL = groupDirectoryURL.appendingPathComponent("mailboxes", isDirectory: true) - cacheDirectoryURL = groupDirectoryURL.appendingPathComponent("Library/Caches", isDirectory: true) - print(groupDirectoryURL) - try? fileManager.setAttributes( - [FileAttributeKey.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: groupDirectoryURL.path - ) - try? FileManager.default.createDirectory( - atPath: rootDocumentsURL.path, - withIntermediateDirectories: true, - attributes: nil - ) - try? FileManager.default.createDirectory( - atPath: cacheDirectoryURL.path, - withIntermediateDirectories: true, - attributes: nil - ) + @InjectService var appGroupPathProvider: AppGroupPathProvidable + groupDirectoryURL = appGroupPathProvider.groupDirectoryURL + rootDocumentsURL = appGroupPathProvider.realmRootURL + cacheDirectoryURL = appGroupPathProvider.cacheDirectoryURL + DDLogInfo("groupDirectoryURL: \(groupDirectoryURL)") DDLogInfo( "App working path is: \(fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.absoluteString ?? "")" ) @@ -984,7 +974,7 @@ public class MailboxManager: ObservableObject { || firstMessageFolderRole == .spam || firstMessageFolderRole == .draft { try await delete(messages: messagesToMoveOrDelete) - async let _ = IKSnackBar.showSnackBar(message: deletionSnackbarMessage(for: messages, permanentlyDelete: true)) + async let _ = snackbarPresenter.show(message: deletionSnackbarMessage(for: messages, permanentlyDelete: true)) } else { let undoRedoAction = try await move(messages: messagesToMoveOrDelete, to: .trash) async let _ = IKSnackBar.showCancelableSnackBar( diff --git a/MailCore/Models/Draft.swift b/MailCore/Models/Draft.swift index e76ddd6ee..28d031754 100644 --- a/MailCore/Models/Draft.swift +++ b/MailCore/Models/Draft.swift @@ -288,3 +288,13 @@ public final class Draft: Object, Codable, Identifiable { try container.encode(delay, forKey: .delay) } } + +public extension Draft { + /// Returns the available attachments slots + var availableAttachmentsSlots: Int { + let maxBound = 96 + let offset = min(attachments.count, maxBound) + let available = max(maxBound - offset, 0) + return available + } +} diff --git a/MailCore/Models/MergedContact.swift b/MailCore/Models/MergedContact.swift index 7a251a3cf..4d06a0321 100644 --- a/MailCore/Models/MergedContact.swift +++ b/MailCore/Models/MergedContact.swift @@ -35,7 +35,7 @@ extension CNContact { } } -public class MergedContact { +public final class MergedContact { private static let contactFormatter = CNContactFormatter() public var email: String diff --git a/MailCore/Models/Recipient.swift b/MailCore/Models/Recipient.swift index b6abc01f1..3b5c5cd9d 100644 --- a/MailCore/Models/Recipient.swift +++ b/MailCore/Models/Recipient.swift @@ -37,7 +37,7 @@ public struct RecipientHolder { var bcc = [Recipient]() } -public class Recipient: EmbeddedObject, Codable { +public final class Recipient: EmbeddedObject, Codable { @Persisted public var email: String @Persisted public var name: String diff --git a/MailCore/Models/Thread.swift b/MailCore/Models/Thread.swift index 76cc5ff77..94bf51b31 100644 --- a/MailCore/Models/Thread.swift +++ b/MailCore/Models/Thread.swift @@ -157,7 +157,8 @@ public class Thread: Object, Decodable, Identifiable { } public func lastMessageToExecuteAction(currentMailboxEmail: String) -> Message? { - if let message = messages.last(where: { $0.isDraft == false && $0.fromMe(currentMailboxEmail: currentMailboxEmail) == false }) { + if let message = messages + .last(where: { $0.isDraft == false && $0.fromMe(currentMailboxEmail: currentMailboxEmail) == false }) { return message } else if let message = messages.last(where: { $0.isDraft == false }) { return message diff --git a/MailCore/Utils/ApplicationStatable.swift b/MailCore/Utils/ApplicationStatable.swift new file mode 100644 index 000000000..191042f88 --- /dev/null +++ b/MailCore/Utils/ApplicationStatable.swift @@ -0,0 +1,26 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 UIKit + +// TODO: Move to CoreUI + +/// Something that reads the application state if available +public protocol ApplicationStatable { + @MainActor var applicationState: UIApplication.State? { get } +} diff --git a/MailCore/Utils/Error+Extension.swift b/MailCore/Utils/Error+Extension.swift index 7d147d170..fa1782784 100644 --- a/MailCore/Utils/Error+Extension.swift +++ b/MailCore/Utils/Error+Extension.swift @@ -19,6 +19,7 @@ import CocoaLumberjackSwift import Foundation import InfomaniakCoreUI +import InfomaniakDI import Sentry public func tryOrDisplayError(_ body: () throws -> Void) { @@ -38,11 +39,10 @@ public func tryOrDisplayError(_ body: () async throws -> Void) async { } private func displayErrorIfNeeded(error: Error) { + @InjectService var snackbarPresenter: SnackBarPresentable if let error = error as? MailError { - if error.shouldDisplay && !Bundle.main.isExtension { - Task.detached { - await IKSnackBar.showSnackBar(message: error.errorDescription) - } + if error.shouldDisplay { + snackbarPresenter.show(message: error.errorDescription) } else { SentrySDK.capture(message: "Encountered error that we didn't display to the user") { scope in scope.setContext( @@ -52,23 +52,25 @@ private func displayErrorIfNeeded(error: Error) { } } DDLogError("MailError: \(error)") - } else if error.shouldDisplay && !Bundle.main.isExtension { - Task.detached { - await IKSnackBar.showSnackBar(message: error.localizedDescription) - } + } else if error.shouldDisplay { + snackbarPresenter.show(message: error.localizedDescription) DDLogError("Error: \(error)") } } public extension Error { var shouldDisplay: Bool { + guard !Bundle.main.isExtension else { + return false + } + switch asAFError { case .explicitlyCancelled: return false case .sessionTaskFailed(let error): return (error as NSError).code != NSURLErrorNotConnectedToInternet default: - return true + return false } } } diff --git a/MailCore/Utils/NotificationsHelper.swift b/MailCore/Utils/NotificationsHelper.swift index 88d71e536..4f4562be0 100644 --- a/MailCore/Utils/NotificationsHelper.swift +++ b/MailCore/Utils/NotificationsHelper.swift @@ -60,13 +60,14 @@ public enum NotificationsHelper { public static func getUnreadCount() async -> Int { var totalUnreadCount = 0 @InjectService var notificationService: InfomaniakNotifications + @InjectService var accountManager: AccountManager - for account in AccountManager.instance.accounts { + for account in accountManager.accounts { let currentSubscription = await notificationService.subscriptionForUser(id: account.userId) for mailbox in MailboxInfosManager.instance.getMailboxes(for: account.userId) where currentSubscription?.topics.contains(mailbox.notificationTopicName) == true { - if let mailboxManager = AccountManager.instance.getMailboxManager(for: mailbox) { + if let mailboxManager = accountManager.getMailboxManager(for: mailbox) { totalUnreadCount += mailboxManager.getFolder(with: .inbox)?.unreadCount ?? 0 } } @@ -106,7 +107,8 @@ public enum NotificationsHelper { private static func sendImmediately(notification: UNMutableNotificationContent, id: String, action: IKSnackBar.Action? = nil) { DispatchQueue.main.async { - let isInBackground = Bundle.main.isExtension || UIApplication.shared.applicationState != .active + @LazyInjectService var applicationState: ApplicationStatable + let isInBackground = Bundle.main.isExtension || applicationState.applicationState != .active if isInBackground { let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) diff --git a/MailCore/Utils/PlatformDetectable.swift b/MailCore/Utils/PlatformDetectable.swift new file mode 100644 index 000000000..390150568 --- /dev/null +++ b/MailCore/Utils/PlatformDetectable.swift @@ -0,0 +1,59 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 + +// TODO: Move to core + +/// Something to help with current running context +public protocol PlatformDetectable { + /// We are running in Mac Catalyst mode + var isMacCatalyst: Bool { get } + + /// We are running an iOS App on Mac + var isiOSAppOnMac: Bool { get } + + /// We are running in extension mode + var isInExtension: Bool { get } +} + +public struct PlatformDetector: PlatformDetectable { + public init() { + // META: Keep SonarCloud happy + } + + public var isMacCatalyst: Bool = { + #if targetEnvironment(macCatalyst) + true + #else + false + #endif + }() + + public var isiOSAppOnMac: Bool = { + ProcessInfo().isiOSAppOnMac + }() + + public var isInExtension: Bool = { + guard Bundle.main.bundlePath.hasSuffix(".appex") else { + return false + } + + return true + }() +} diff --git a/MailCore/Utils/IKSnackBar+Extension.swift b/MailCore/Utils/SnackBar/IKSnackBar+Extension.swift similarity index 64% rename from MailCore/Utils/IKSnackBar+Extension.swift rename to MailCore/Utils/SnackBar/IKSnackBar+Extension.swift index c430663df..98b915f55 100644 --- a/MailCore/Utils/IKSnackBar+Extension.swift +++ b/MailCore/Utils/SnackBar/IKSnackBar+Extension.swift @@ -22,6 +22,7 @@ import InfomaniakCoreUI import InfomaniakDI import MailResources import SnackBar +import UIKit public extension SnackBarStyle { static func mailStyle(withAnchor anchor: CGFloat) -> SnackBarStyle { @@ -40,42 +41,51 @@ public extension SnackBarStyle { } } -public class SnackBarAvoider { - public var snackBarInset: CGFloat = 0 - - public init() { /* Needed to init */ } - - public func addAvoider(inset: CGFloat) { - if inset != snackBarInset { - snackBarInset = inset - } - } - - public func removeAvoider() { - snackBarInset = 0 - } -} - public extension IKSnackBar { @discardableResult @MainActor - static func showSnackBar( + /// Call this method to display a `SnackBar` + /// - Parameters: + /// - message: The message to display + /// - duration: The time the message should be displayed + /// - action: The action to perform if any + /// - anchor: The anchor to use for presenting + /// - contextView: Set a context view, when displaying in extension mode for eg. + /// - Returns: An IKSnackBar if any + static func showMailSnackBar( message: String, duration: SnackBar.Duration = .lengthLong, action: IKSnackBar.Action? = nil, - anchor: CGFloat = 0 + anchor: CGFloat = 0, + contextView: UIView? = nil ) -> IKSnackBar? { - @LazyInjectService var avoider: SnackBarAvoider - let snackbar = IKSnackBar.make( - message: message, - duration: duration, - style: .mailStyle(withAnchor: avoider.snackBarInset), - elevation: 0 - ) + @LazyInjectService var avoider: IKSnackBarAvoider + + let snackbar: IKSnackBar? + if let contextView = contextView { + snackbar = IKSnackBar.make( + in: contextView, + message: message, + duration: duration, + style: .mailStyle(withAnchor: avoider.snackBarInset) + ) + } else { + snackbar = IKSnackBar.make( + message: message, + duration: duration, + style: .mailStyle(withAnchor: avoider.snackBarInset), + elevation: 0 + ) + } + + guard let snackbar = snackbar else { + return nil + } + if let action { - snackbar?.setAction(action).show() + snackbar.setAction(action).show() } else { - snackbar?.show() + snackbar.show() } return snackbar } @@ -89,10 +99,11 @@ public extension IKSnackBar { undoRedoAction: UndoRedoAction, mailboxManager: MailboxManager ) -> IKSnackBar? { - return IKSnackBar.showSnackBar( + return IKSnackBar.showMailSnackBar( message: message, duration: duration, action: .init(title: MailResourcesStrings.Localizable.buttonCancel) { + @InjectService var snackbarPresenter: SnackBarPresentable Task { do { @InjectService var matomo: MatomoUtils @@ -101,11 +112,11 @@ public extension IKSnackBar { let cancelled = try await mailboxManager.apiFetcher.undoAction(resource: undoRedoAction.undo.resource) if cancelled { - IKSnackBar.showSnackBar(message: cancelSuccessMessage) + snackbarPresenter.show(message: cancelSuccessMessage) try await undoRedoAction.redo?() } } catch { - IKSnackBar.showSnackBar(message: error.localizedDescription) + snackbarPresenter.show(message: error.localizedDescription) } } } diff --git a/MailCore/Utils/SnackBar/SnackBarPresentable.swift b/MailCore/Utils/SnackBar/SnackBarPresentable.swift new file mode 100644 index 000000000..77010e85b --- /dev/null +++ b/MailCore/Utils/SnackBar/SnackBarPresentable.swift @@ -0,0 +1,70 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 SnackBar +import UIKit + +// todo use the version in CoreUI +public protocol SnackBarPresentable { + func show(message: String) + func show(message: String, action: IKSnackBar.Action?) + func show( + message: String, + duration: SnackBar.Duration, + action: IKSnackBar.Action?, + anchor: CGFloat, + contextView: UIView? + ) +} + +public final class SnackBarPresenter: SnackBarPresentable { + /// Set to display the snack bar is a specific context, like + private var contextView: UIView? + + public init(contextView: UIView? = nil) { + self.contextView = contextView + } + + public func show(message: String) { + show(message: message, contextView: contextView) + } + + public func show(message: String, action: IKSnackBar.Action?) { + show(message: message, action: action, contextView: nil) + } + + public func show( + message: String, + duration: SnackBar.Duration = .lengthLong, + action: IKSnackBar.Action? = nil, + anchor: CGFloat = 0, + contextView: UIView? = nil + ) { + Task { @MainActor in + IKSnackBar.showMailSnackBar( + message: message, + duration: duration, + action: action, + anchor: anchor, + contextView: contextView + ) + } + } +} diff --git a/MailCore/Utils/URLSchemeHandler.swift b/MailCore/Utils/URLSchemeHandler.swift index 5ffebd9b2..2b7b7336c 100644 --- a/MailCore/Utils/URLSchemeHandler.swift +++ b/MailCore/Utils/URLSchemeHandler.swift @@ -17,22 +17,25 @@ */ import Foundation +import InfomaniakDI import WebKit -public class URLSchemeHandler: NSObject, WKURLSchemeHandler { +public final class URLSchemeHandler: NSObject, WKURLSchemeHandler { public static let scheme = "mail-infomaniak" public static let domain = "://mail.infomaniak.com" private var dataTasksInProgress = [Int: URLSessionDataTask]() private let syncQueue = DispatchQueue(label: "com.infomaniak.mail.URLSchemeHandler") + @LazyInjectService private var accountManager: AccountManager + public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { guard let url = urlSchemeTask.request.url else { urlSchemeTask.didFailWithError(MailError.resourceError) return } - - guard let currentAccessToken = AccountManager.instance.getCurrentAccount()?.token?.accessToken else { + + guard let currentAccessToken = accountManager.getCurrentAccount()?.token?.accessToken else { urlSchemeTask.didFailWithError(MailError.unknownError) return } diff --git a/MailCore/Utils/UserAlertDisplayable.swift b/MailCore/Utils/UserAlertDisplayable.swift new file mode 100644 index 000000000..4db668ba5 --- /dev/null +++ b/MailCore/Utils/UserAlertDisplayable.swift @@ -0,0 +1,118 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 CocoaLumberjackSwift +import Foundation +import InfomaniakCore +import InfomaniakCoreUI +import InfomaniakDI +import UserNotifications + +// TODO: Move to CoreUI / use with kDrive + +/// Something that can present a message to the user, abstracted of execution context (App / NSExtension) +/// +/// Will present a snackbar while main app is opened, a local notification in Extension or Background. +public protocol UserAlertDisplayable { + /// Will present a snackbar while main app is opened, a local notification in Extension or Background. + /// - Parameter message: The message to display + func show(message: String) + + /// Will present a snackbar while main app is opened, a local notification in Extension or Background. + /// - Parameters: + /// - message: The message to display + /// - action: Title and closure associated with the action + func show(message: String, action: UserAlertAction) +} + +public typealias UserAlertAction = (name: String, closure: () -> Void) + +public final class UserAlertDisplayer: UserAlertDisplayable { + @LazyInjectService private var snackbarPresenter: SnackBarPresentable + @LazyInjectService private var applicationState: ApplicationStatable + + /// Used by DI + public init() { + // META: keep sonarcloud happy + } + + // MARK: - UserAlertDisplayable + + public func show(message: String) { + showInContext(message: message, action: nil) + } + + public func show(message: String, action: UserAlertAction) { + showInContext(message: message, action: action) + } + + // MARK: - private + + private func showInContext(message: String, action: UserAlertAction?) { + Task { @MainActor in + // check not in extension mode + guard !Bundle.main.isExtension else { + presentInLocalNotification(message: message, action: action) + return + } + + // if app not in foreground, we use the local notifications + guard applicationState.applicationState == .active else { + presentInLocalNotification(message: message, action: action) + return + } + + // Present the message as we are in foreground app context + presentInSnackbar(message: message, action: action) + } + } + + // MARK: Private + + private func presentInSnackbar(message: String, action: UserAlertAction?) { + guard let action = action else { + snackbarPresenter.show(message: message) + return + } + + let snackBarAction = IKSnackBar.Action(title: action.name, action: action.closure) + snackbarPresenter.show(message: message, action: snackBarAction) + } + + private func presentInLocalNotification(message: String, action: UserAlertAction?) { + if action != nil { + DDLogError("Action not implemented in notifications for now") + } + + let content = UNMutableNotificationContent() + content.body = message + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.3, repeats: false) + let uuidString = UUID().uuidString + let request = UNNotificationRequest(identifier: uuidString, content: content, trigger: trigger) + let notificationCenter = UNUserNotificationCenter.current() + notificationCenter.add(request) { error in + DDLogError("UserAlertDisplayer local notification error:\(String(describing: error)) ") + } + + // Self destruct this notification, as used only for user feedback + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { + notificationCenter.removeDeliveredNotifications(withIdentifiers: [uuidString]) + } + } +} diff --git a/MailNotificationServiceExtension/NotificationService.swift b/MailNotificationServiceExtension/NotificationService.swift index 2bd3ba774..71e30e971 100644 --- a/MailNotificationServiceExtension/NotificationService.swift +++ b/MailNotificationServiceExtension/NotificationService.swift @@ -25,34 +25,18 @@ import MailResources import RealmSwift import UserNotifications -class NotificationService: UNNotificationServiceExtension { +final class NotificationService: UNNotificationServiceExtension { + /// Making sure the DI is registered at a very early stage of the app launch. + private let dependencyInjectionHook = EarlyDIHook() + + @LazyInjectService private var accountManager: AccountManager + var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? override init() { super.init() Logging.initLogging() - let networkLoginService = Factory(type: InfomaniakNetworkLoginable.self) { _, _ in - InfomaniakNetworkLogin(clientId: MailApiFetcher.clientId) - } - let loginService = Factory(type: InfomaniakLoginable.self) { _, _ in - InfomaniakLogin(clientId: MailApiFetcher.clientId) - } - let keychainHelper = Factory(type: KeychainHelper.self) { _, _ in - KeychainHelper(accessGroup: AccountManager.accessGroup) - } - let tokenStore = Factory(type: TokenStore.self) { _, _ in - TokenStore() - } - let notificationService = Factory(type: InfomaniakNotifications.self) { _, _ in - InfomaniakNotifications(appGroup: AccountManager.appGroup) - } - - SimpleResolver.sharedResolver.store(factory: networkLoginService) - SimpleResolver.sharedResolver.store(factory: loginService) - SimpleResolver.sharedResolver.store(factory: keychainHelper) - SimpleResolver.sharedResolver.store(factory: tokenStore) - SimpleResolver.sharedResolver.store(factory: notificationService) } func fetchMessage(uid: String, in mailboxManager: MailboxManager) async throws -> Message? { @@ -95,7 +79,7 @@ class NotificationService: UNNotificationServiceExtension { guard let mailboxId = userInfos[NotificationsHelper.UserInfoKeys.mailboxId] as? Int, let userId = userInfos[NotificationsHelper.UserInfoKeys.userId] as? Int, let mailbox = MailboxInfosManager.instance.getMailbox(id: mailboxId, userId: userId), - let mailboxManager = AccountManager.instance.getMailboxManager(for: mailbox) else { + let mailboxManager = accountManager.getMailboxManager(for: mailbox) else { // This should never happen, we received a notification for an unknown mailbox logNotificationFailed(userInfo: userInfos, type: .mailboxNotFound) return contentHandler(bestAttemptContent) diff --git a/MailNotificationServiceExtension/NotificationServiceAssembly.swift b/MailNotificationServiceExtension/NotificationServiceAssembly.swift new file mode 100644 index 000000000..13ca3688e --- /dev/null +++ b/MailNotificationServiceExtension/NotificationServiceAssembly.swift @@ -0,0 +1,101 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 InfomaniakCore +import InfomaniakDI +import InfomaniakLogin +import InfomaniakNotifications +import MailCore + +private let realmRootPath = "mailboxes" +private let appGroupIdentifier = "group.com.infomaniak.mail" + +extension Array where Element == Factory { + func registerFactoriesInDI() { + forEach { SimpleResolver.sharedResolver.store(factory: $0) } + } +} + +/// Something that prepares the extension Dependency Injection +enum NotificationServiceAssembly { + static func setupDI() { + // Setup main servicies + setupMainServices() + } + + private static func setupMainServices() { + let factories = [ + Factory(type: InfomaniakNetworkLoginable.self) { _, _ in + InfomaniakNetworkLogin(clientId: MailApiFetcher.clientId) + }, + Factory(type: InfomaniakLoginable.self) { _, _ in + InfomaniakLogin(clientId: MailApiFetcher.clientId) + }, + Factory(type: KeychainHelper.self) { _, _ in + KeychainHelper(accessGroup: AccountManager.accessGroup) + }, + Factory(type: InfomaniakNotifications.self) { _, _ in + InfomaniakNotifications(appGroup: AccountManager.appGroup) + }, + Factory(type: DraftManager.self) { _, _ in + DraftManager() + }, + Factory(type: AccountManager.self) { _, _ in + AccountManager() + }, + Factory(type: SnackBarPresentable.self) { _, _ in + SnackBarPresenter() + }, + Factory(type: UserAlertDisplayable.self) { _, _ in + UserAlertDisplayer() + }, + Factory(type: UserActivityController.self) { _, _ in + UserActivityController() + }, + Factory(type: PlatformDetectable.self) { _, _ in + PlatformDetector() + }, + Factory(type: AppGroupPathProvidable.self) { _, _ in + guard let provider = AppGroupPathProvider( + realmRootPath: realmRootPath, + appGroupIdentifier: appGroupIdentifier + ) else { + fatalError("could not safely init AppGroupPathProvider") + } + + return provider + }, + Factory(type: TokenStore.self) { _, _ in + TokenStore() + }, + ] + + factories.registerFactoriesInDI() + } +} + +/// Something that loads the DI on init +public struct EarlyDIHook { + public init() { + ApiFetcher.decoder.dateDecodingStrategy = .iso8601 + + // setup DI ASAP + NotificationServiceAssembly.setupDI() + } +} diff --git a/MailResources/Localizable/de.lproj/Localizable.strings b/MailResources/Localizable/de.lproj/Localizable.strings index 0f7ff86b2..553c68b81 100644 --- a/MailResources/Localizable/de.lproj/Localizable.strings +++ b/MailResources/Localizable/de.lproj/Localizable.strings @@ -3,9 +3,8 @@ * Project: kMail * Release: Working copy * Locale: de, German - * Tagged: ios - * Exported by: Ambroise Decouttere - * Exported at: Thu, 27 Jul 2023 16:53:47 +0200 + * Exported by: Adrien Coye + * Exported at: Wed, 02 Aug 2023 10:42:34 +0200 */ /* loco:6256793050618f7416758a32 */ @@ -584,6 +583,12 @@ /* loco:62bc67601f560d734f2fbe58 */ "snackbarContactSaved" = "Neuer Kontakt erfolgreich registriert"; +/* loco:62bd4fa9c71d395cd431e6a2 */ +"NSFaceIDUsageDescription" = "Face ID wird zum Entsperren der Anwendung verwendet."; + +/* loco:62bd538485526660827f95d2 */ +"NSContactsUsageDescription" = "Lokale Kontakte werden verwendet, um Empfängerinformationen anzuzeigen. Wir werden sie NICHT an den Server senden."; + /* loco:62bd85a64880c362bd5ece72 */ "snackbarEmailCopiedToClipboard" = "E-Mail Adresse in die Zwischenablage kopiert"; @@ -710,6 +715,9 @@ /* loco:62cd644f5141ee164b494272 */ "snackbarDisplayProblemReported" = "Anzeigeproblem gemeldet"; +/* loco:62ce828ff799954b2054aac2 */ +"NSCameraUsageDescription" = "Die Kamera wird zum Anhängen von Fotos an Ihre E-Mail verwendet"; + /* loco:62cecdf0413c882ede7451c2 */ "reportPhishingTitle" = "Einen Phishing-Versuch melden"; @@ -719,6 +727,9 @@ /* loco:62cfb94ffe73905627441c62 */ "settingsOptionSystemTheme" = "System"; +/* loco:62cfdf508559983b710b7bd2 */ +"threadListHeaderLastUpdateNow" = "jetzt"; + /* loco:62d5561a4fa44b4a25143fc2 */ "searchFilterRead" = "Lesen"; @@ -788,6 +799,12 @@ /* loco:634ea0a369dac5469e1ce6d2 */ "noConversationSelected" = "Kein Gespräch in %@ ausgewählt"; +/* loco:6357867da9aca53d914670a2 */ +"settingsSwipeLeft" = "Links wischen"; + +/* loco:63578695fa87a96c446068f2 */ +"settingsSwipeRight" = "Rechts wischen"; + /* loco:635fab674606bd5f944f7784 */ "workInProgressTitle" = "Bald verfügbar"; @@ -803,6 +820,15 @@ /* loco:637ce6f461e6ca5762134f53 */ "noFolderTitle" = "Sie haben noch keinen Ordner…"; +/* loco:6384d9c01d10203fe203b7e2 */ +"notificationSyncDraftChannelName" = "Speichern von Entwürfen"; + +/* loco:6384dc46d8b6482c3f1962a4 */ +"notificationSyncMessagesChannelName" = "Abrufen neuer E-Mails"; + +/* loco:6384e1c927eaf70c585c8732 */ +"notificationNewMessagesChannelName" = "Neue Emails"; + /* loco:638f5a17e9a49034ae006a92 */ "snackbarThreadDeletedPermanently" = "Konversation gelöscht"; @@ -839,6 +865,15 @@ /* loco:63a474bd648cd86d56444012 */ "confirmLogoutDescription" = "Sind Sie sicher, dass Sie sich von %@ abmelden möchten?"; +/* loco:63bc30711f2f796a1c262c92 */ +"newMessageNotificationSummary" = "%d neue Nachricht"; + +/* loco:63bc31091f2f796a1c262c95 */ +"newMessageNotificationSummary-plural" = "%d neue Nachrichten"; + +/* loco:63bc31577985706f5870b192 */ +"newMessageNotificationSummary-plural-many" = ""; + /* loco:63c64f5d53fe7b61a46310c3 */ "newFolderDialogHint" = "Name des Ordners"; @@ -851,9 +886,15 @@ /* loco:63d14902e3c4eb4832777c42 */ "errorCancelAttachmentsUploadInProgress" = "Einige Anhänge werden noch importiert. Wenn Sie die Ansicht schließen, gehen die Anhänge verloren."; +/* loco:63d27324e186fd10f243ce82 */ +"webViewCantHandleAction" = "Es wurde keine Anwendung gefunden, die diese Aktion bearbeitet"; + /* loco:63d7e73a70ff84756f78f0b3 */ "threadListHeaderUnreadCountMore" = "99 ungelesen"; +/* loco:63e0c11f3fd6c70fb7723192 */ +"contentDescriptionIconFolderSelected" = "Ordner ausgewählt"; + /* loco:63e11e5771d13a480771a162 */ "SettingsEnableNotifications" = "Benachrichtigungen einschalten"; @@ -896,6 +937,9 @@ /* loco:63edf28c20221c0f3a6d30a2 */ "threadListDeletionConfirmationAlertTitle-plural" = "%d Nachrichten löschen"; +/* loco:63edf7b80fc3f74f890dd0c4 */ +"threadListDeletionConfirmationAlertTitle-plural-many" = ""; + /* loco:63ee0cbbad127b43f8650242 */ "newFolderDialogMovePositiveButton" = "Erstellen und verschieben"; @@ -926,6 +970,9 @@ /* loco:6400bb16c117ec0d866887b5 */ "messageHideQuotedText" = "Das Gespräch ausblenden"; +/* loco:6401f75b518ceb46ca297432 */ +"googlePlayServicesAreRequired" = "Google Play Services sind erforderlich"; + /* loco:640836aa0f09a82bfa0da572 */ "searchAllMessages" = "Alle Meldungen"; @@ -962,15 +1009,33 @@ /* loco:641d60b6f5c19a18734d9192 */ "urlUserReportiOS" = "https://feedback.userreport.com/efffca51-9fab-4c6c-95d8-148d5a0fc8a9/"; +/* loco:641d6129a45dca299a5fdf02 */ +"urlUserReportAndroid" = "https://feedback.userreport.com/8f405fa0-e5b0-49e7-85d8-477b64ea48f7/"; + /* loco:641db88f88b1f63cb05aad52 */ "tooManyRecipients" = "Sie können diese Adresse nicht hinzufügen, da Sie die Höchstzahl an Empfängern erreicht haben"; /* loco:642179d9733cd4523d03dcf2 */ "accentColorSystemTitle" = "Farbe des Systems"; +/* loco:6422a877e133d2121822bf72 */ +"contentDescriptionButtonDelete" = "%@ löschen"; + +/* loco:6422d0ea459bcf36c5099782 */ +"contentDescriptionDownloadIndicator" = "Herunterladen von"; + +/* loco:6422da8be273963aac69e902 */ +"contentDescriptionAccountSelection" = "Ausgewähltes Konto"; + /* loco:6422e08598a76c7f98590952 */ "contentDescriptionButtonExpandRecipients" = "Siehe Details"; +/* loco:6422e0f363700359984130d3 */ +"contentDescriptionOpenMailActions" = "Mail-Aktionen öffnen"; + +/* loco:64241f061119dc0dd86b0272 */ +"contentDescriptionButtonOpenSystemSettings" = "Öffnen Sie die Benachrichtigungseinstellungen des Systems"; + /* loco:642430a9ce12f43b026e7ef2 */ "contentDescriptionSelectedItem" = "Ausgewählte"; @@ -980,6 +1045,9 @@ /* loco:64243b4a9a9a1f26302e33f3 */ "emailWithoutSubjectDescription" = "Sie sind dabei, eine Nachricht ohne Betreff zu senden. Möchten Sie fortfahren?"; +/* loco:6424488fdb64c92719278303 */ +"contentDescriptionButtonDeleteSignature" = "Unterschrift löschen"; + /* loco:64245322f2b3ab37ed1b26b2 */ "buttonContinue" = "Weiter"; @@ -1055,6 +1123,12 @@ /* loco:6450d3f93e5fcf13c9037b02 */ "errorMailboxLocked" = "Diese Mailbox ist gesperrt"; +/* loco:64522a87e05780ced808fd62 */ +"errorCorruptAttachment" = "Der Entwurf konnte nicht bearbeitet werden, ein Anhang ist beschädigt"; + +/* loco:64539f465ac8a1dc1b0a87c2 */ +"contentDescriptionButtonDeleteQuote" = "Zitierte Nachrichten löschen"; + /* loco:645b485e158c37a429012092 */ "buttonLoadMore" = "Weitere Gespräche laden"; @@ -1222,3 +1296,9 @@ /* loco:64c21f7b8b1932323c047792 */ "newAccountStorageDrive" = "15 GB kDrive-Speicher"; + +/* loco:64c389458fb94726cf010c52 */ +"snackbarDownloadInProgress" = "Download läuft…"; + +/* loco:64ca16484dbf65eb9b0ea862 */ +"PleaseLogInFirst" = "Bitte melden Sie sich zuerst in der ikMail-App an."; diff --git a/MailResources/Localizable/en.lproj/Localizable.strings b/MailResources/Localizable/en.lproj/Localizable.strings index b5a076b4f..05bcf84b4 100644 --- a/MailResources/Localizable/en.lproj/Localizable.strings +++ b/MailResources/Localizable/en.lproj/Localizable.strings @@ -3,9 +3,8 @@ * Project: kMail * Release: Working copy * Locale: en, English - * Tagged: ios - * Exported by: Ambroise Decouttere - * Exported at: Thu, 27 Jul 2023 16:53:47 +0200 + * Exported by: Adrien Coye + * Exported at: Wed, 02 Aug 2023 10:42:34 +0200 */ /* loco:6256793050618f7416758a32 */ @@ -584,6 +583,12 @@ /* loco:62bc67601f560d734f2fbe58 */ "snackbarContactSaved" = "New contact successfully registered"; +/* loco:62bd4fa9c71d395cd431e6a2 */ +"NSFaceIDUsageDescription" = "Face ID will be used to unlock the application."; + +/* loco:62bd538485526660827f95d2 */ +"NSContactsUsageDescription" = "Local contacts will be used to show recipient info. We will NOT send them to the server."; + /* loco:62bd85a64880c362bd5ece72 */ "snackbarEmailCopiedToClipboard" = "Email address copied to the clipboard"; @@ -710,6 +715,9 @@ /* loco:62cd644f5141ee164b494272 */ "snackbarDisplayProblemReported" = "Display problem reported"; +/* loco:62ce828ff799954b2054aac2 */ +"NSCameraUsageDescription" = "Camera will be used to attach photo to your email"; + /* loco:62cecdf0413c882ede7451c2 */ "reportPhishingTitle" = "Report a phishing attempt"; @@ -719,6 +727,9 @@ /* loco:62cfb94ffe73905627441c62 */ "settingsOptionSystemTheme" = "System"; +/* loco:62cfdf508559983b710b7bd2 */ +"threadListHeaderLastUpdateNow" = "now"; + /* loco:62d5561a4fa44b4a25143fc2 */ "searchFilterRead" = "Read"; @@ -788,6 +799,12 @@ /* loco:634ea0a369dac5469e1ce6d2 */ "noConversationSelected" = "No conversation selected in %@"; +/* loco:6357867da9aca53d914670a2 */ +"settingsSwipeLeft" = "Left swipe"; + +/* loco:63578695fa87a96c446068f2 */ +"settingsSwipeRight" = "Right swipe"; + /* loco:635fab674606bd5f944f7784 */ "workInProgressTitle" = "Available soon"; @@ -803,6 +820,15 @@ /* loco:637ce6f461e6ca5762134f53 */ "noFolderTitle" = "You don’t have any folder yet…"; +/* loco:6384d9c01d10203fe203b7e2 */ +"notificationSyncDraftChannelName" = "Saving drafts"; + +/* loco:6384dc46d8b6482c3f1962a4 */ +"notificationSyncMessagesChannelName" = "Retrieving new emails"; + +/* loco:6384e1c927eaf70c585c8732 */ +"notificationNewMessagesChannelName" = "New emails"; + /* loco:638f5a17e9a49034ae006a92 */ "snackbarThreadDeletedPermanently" = "Conversation deleted"; @@ -839,6 +865,15 @@ /* loco:63a474bd648cd86d56444012 */ "confirmLogoutDescription" = "Are you sure you want to log out from %@?"; +/* loco:63bc30711f2f796a1c262c92 */ +"newMessageNotificationSummary" = "%d new message"; + +/* loco:63bc31091f2f796a1c262c95 */ +"newMessageNotificationSummary-plural" = "%d new messages"; + +/* loco:63bc31577985706f5870b192 */ +"newMessageNotificationSummary-plural-many" = ""; + /* loco:63c64f5d53fe7b61a46310c3 */ "newFolderDialogHint" = "Folder name"; @@ -851,9 +886,15 @@ /* loco:63d14902e3c4eb4832777c42 */ "errorCancelAttachmentsUploadInProgress" = "Some attachments are still being imported. If you close the view attachments will be lost."; +/* loco:63d27324e186fd10f243ce82 */ +"webViewCantHandleAction" = "No application has been found to handle this action"; + /* loco:63d7e73a70ff84756f78f0b3 */ "threadListHeaderUnreadCountMore" = "99+ unread"; +/* loco:63e0c11f3fd6c70fb7723192 */ +"contentDescriptionIconFolderSelected" = "Folder selected"; + /* loco:63e11e5771d13a480771a162 */ "SettingsEnableNotifications" = "Enable notifications"; @@ -896,6 +937,9 @@ /* loco:63edf28c20221c0f3a6d30a2 */ "threadListDeletionConfirmationAlertTitle-plural" = "Delete %d messages"; +/* loco:63edf7b80fc3f74f890dd0c4 */ +"threadListDeletionConfirmationAlertTitle-plural-many" = ""; + /* loco:63ee0cbbad127b43f8650242 */ "newFolderDialogMovePositiveButton" = "Create and move"; @@ -926,6 +970,9 @@ /* loco:6400bb16c117ec0d866887b5 */ "messageHideQuotedText" = "Hide the conversation"; +/* loco:6401f75b518ceb46ca297432 */ +"googlePlayServicesAreRequired" = "Google Play Services are required"; + /* loco:640836aa0f09a82bfa0da572 */ "searchAllMessages" = "All messages"; @@ -962,15 +1009,33 @@ /* loco:641d60b6f5c19a18734d9192 */ "urlUserReportiOS" = "https://feedback.userreport.com/5f64b035-33e5-4e71-9572-dc9d1d451c18/"; +/* loco:641d6129a45dca299a5fdf02 */ +"urlUserReportAndroid" = "https://feedback.userreport.com/dbf02b8c-36f7-4388-839c-a2b6a3029704/"; + /* loco:641db88f88b1f63cb05aad52 */ "tooManyRecipients" = "You can’t add this address because you have reached the limit of recipients"; /* loco:642179d9733cd4523d03dcf2 */ "accentColorSystemTitle" = "System color"; +/* loco:6422a877e133d2121822bf72 */ +"contentDescriptionButtonDelete" = "Delete %@"; + +/* loco:6422d0ea459bcf36c5099782 */ +"contentDescriptionDownloadIndicator" = "Downloading"; + +/* loco:6422da8be273963aac69e902 */ +"contentDescriptionAccountSelection" = "Selected account"; + /* loco:6422e08598a76c7f98590952 */ "contentDescriptionButtonExpandRecipients" = "See details"; +/* loco:6422e0f363700359984130d3 */ +"contentDescriptionOpenMailActions" = "Open mail actions"; + +/* loco:64241f061119dc0dd86b0272 */ +"contentDescriptionButtonOpenSystemSettings" = "Open system’s notification settings"; + /* loco:642430a9ce12f43b026e7ef2 */ "contentDescriptionSelectedItem" = "Selected"; @@ -980,6 +1045,9 @@ /* loco:64243b4a9a9a1f26302e33f3 */ "emailWithoutSubjectDescription" = "You are about to send a message without a subject. Do you want to continue?"; +/* loco:6424488fdb64c92719278303 */ +"contentDescriptionButtonDeleteSignature" = "Delete signature"; + /* loco:64245322f2b3ab37ed1b26b2 */ "buttonContinue" = "Continue"; @@ -1055,6 +1123,12 @@ /* loco:6450d3f93e5fcf13c9037b02 */ "errorMailboxLocked" = "This mailbox is locked"; +/* loco:64522a87e05780ced808fd62 */ +"errorCorruptAttachment" = "Failed to handle draft, an attachment is corrupted"; + +/* loco:64539f465ac8a1dc1b0a87c2 */ +"contentDescriptionButtonDeleteQuote" = "Delete quoted messages"; + /* loco:645b485e158c37a429012092 */ "buttonLoadMore" = "Load more conversations"; @@ -1222,3 +1296,9 @@ /* loco:64c21f7b8b1932323c047792 */ "newAccountStorageDrive" = "15 GB of kDrive storage"; + +/* loco:64c389458fb94726cf010c52 */ +"snackbarDownloadInProgress" = "Download in progress…"; + +/* loco:64ca16484dbf65eb9b0ea862 */ +"PleaseLogInFirst" = "Please login in the ikMail app first."; diff --git a/MailResources/Localizable/es.lproj/Localizable.strings b/MailResources/Localizable/es.lproj/Localizable.strings index 2dca542bc..da05a42b5 100644 --- a/MailResources/Localizable/es.lproj/Localizable.strings +++ b/MailResources/Localizable/es.lproj/Localizable.strings @@ -3,9 +3,8 @@ * Project: kMail * Release: Working copy * Locale: es, Spanish - * Tagged: ios - * Exported by: Ambroise Decouttere - * Exported at: Thu, 27 Jul 2023 16:53:47 +0200 + * Exported by: Adrien Coye + * Exported at: Wed, 02 Aug 2023 10:42:34 +0200 */ /* loco:6256793050618f7416758a32 */ @@ -584,6 +583,12 @@ /* loco:62bc67601f560d734f2fbe58 */ "snackbarContactSaved" = "Nuevo contacto registrado correctamente"; +/* loco:62bd4fa9c71d395cd431e6a2 */ +"NSFaceIDUsageDescription" = "Face ID se utilizará para desbloquear la aplicación."; + +/* loco:62bd538485526660827f95d2 */ +"NSContactsUsageDescription" = "Los contactos locales se utilizarán para mostrar la información del destinatario. NO los enviaremos al servidor."; + /* loco:62bd85a64880c362bd5ece72 */ "snackbarEmailCopiedToClipboard" = "Dirección de correo electrónico copiada en el portapapeles"; @@ -710,6 +715,9 @@ /* loco:62cd644f5141ee164b494272 */ "snackbarDisplayProblemReported" = "Problema de visualización notificado"; +/* loco:62ce828ff799954b2054aac2 */ +"NSCameraUsageDescription" = "La cámara se utilizará para adjuntar la foto a su correo electrónico"; + /* loco:62cecdf0413c882ede7451c2 */ "reportPhishingTitle" = "Informar de un intento de phishing"; @@ -719,6 +727,9 @@ /* loco:62cfb94ffe73905627441c62 */ "settingsOptionSystemTheme" = "Sistema"; +/* loco:62cfdf508559983b710b7bd2 */ +"threadListHeaderLastUpdateNow" = "ahora"; + /* loco:62d5561a4fa44b4a25143fc2 */ "searchFilterRead" = "Leer"; @@ -788,6 +799,12 @@ /* loco:634ea0a369dac5469e1ce6d2 */ "noConversationSelected" = "Ninguna conversación seleccionada en %@"; +/* loco:6357867da9aca53d914670a2 */ +"settingsSwipeLeft" = "Deslizar a la izquierda"; + +/* loco:63578695fa87a96c446068f2 */ +"settingsSwipeRight" = "Deslizar a la derecha"; + /* loco:635fab674606bd5f944f7784 */ "workInProgressTitle" = "Disponible en breve"; @@ -803,6 +820,15 @@ /* loco:637ce6f461e6ca5762134f53 */ "noFolderTitle" = "Aún no tienes ninguna carpeta…"; +/* loco:6384d9c01d10203fe203b7e2 */ +"notificationSyncDraftChannelName" = "Guardar borradores"; + +/* loco:6384dc46d8b6482c3f1962a4 */ +"notificationSyncMessagesChannelName" = "Recuperación de nuevos correos electrónicos"; + +/* loco:6384e1c927eaf70c585c8732 */ +"notificationNewMessagesChannelName" = "Nuevos correos electrónicos"; + /* loco:638f5a17e9a49034ae006a92 */ "snackbarThreadDeletedPermanently" = "Conversación eliminada"; @@ -839,6 +865,15 @@ /* loco:63a474bd648cd86d56444012 */ "confirmLogoutDescription" = "¿Seguro que quieres desconectarte de %@?"; +/* loco:63bc30711f2f796a1c262c92 */ +"newMessageNotificationSummary" = "%d nuevo mensaje"; + +/* loco:63bc31091f2f796a1c262c95 */ +"newMessageNotificationSummary-plural" = "%d nuevos mensajes"; + +/* loco:63bc31577985706f5870b192 */ +"newMessageNotificationSummary-plural-many" = ""; + /* loco:63c64f5d53fe7b61a46310c3 */ "newFolderDialogHint" = "Nombre de la carpeta"; @@ -851,9 +886,15 @@ /* loco:63d14902e3c4eb4832777c42 */ "errorCancelAttachmentsUploadInProgress" = "Algunos archivos adjuntos se siguen importando. Si cierra la vista se perderán los archivos adjuntos."; +/* loco:63d27324e186fd10f243ce82 */ +"webViewCantHandleAction" = "No se ha encontrado ninguna aplicación que gestione esta acción"; + /* loco:63d7e73a70ff84756f78f0b3 */ "threadListHeaderUnreadCountMore" = "99 no leídos"; +/* loco:63e0c11f3fd6c70fb7723192 */ +"contentDescriptionIconFolderSelected" = "Carpeta seleccionada"; + /* loco:63e11e5771d13a480771a162 */ "SettingsEnableNotifications" = "Activar las notificaciones"; @@ -896,6 +937,9 @@ /* loco:63edf28c20221c0f3a6d30a2 */ "threadListDeletionConfirmationAlertTitle-plural" = "Borrar %d mensajes"; +/* loco:63edf7b80fc3f74f890dd0c4 */ +"threadListDeletionConfirmationAlertTitle-plural-many" = ""; + /* loco:63ee0cbbad127b43f8650242 */ "newFolderDialogMovePositiveButton" = "Crear y mover"; @@ -926,6 +970,9 @@ /* loco:6400bb16c117ec0d866887b5 */ "messageHideQuotedText" = "Ocultar la conversación"; +/* loco:6401f75b518ceb46ca297432 */ +"googlePlayServicesAreRequired" = "Se requieren los servicios de Google Play"; + /* loco:640836aa0f09a82bfa0da572 */ "searchAllMessages" = "Todos los mensajes"; @@ -962,15 +1009,33 @@ /* loco:641d60b6f5c19a18734d9192 */ "urlUserReportiOS" = "https://feedback.userreport.com/bc05436b-f496-4b6e-934a-338b4b0f1cda/"; +/* loco:641d6129a45dca299a5fdf02 */ +"urlUserReportAndroid" = "https://feedback.userreport.com/f0daec0a-6950-4891-a064-7d040bdce127/"; + /* loco:641db88f88b1f63cb05aad52 */ "tooManyRecipients" = "No puede añadir esta dirección porque ha alcanzado el límite de destinatarios"; /* loco:642179d9733cd4523d03dcf2 */ "accentColorSystemTitle" = "Color del sistema"; +/* loco:6422a877e133d2121822bf72 */ +"contentDescriptionButtonDelete" = "Borrar %@"; + +/* loco:6422d0ea459bcf36c5099782 */ +"contentDescriptionDownloadIndicator" = "Descargar"; + +/* loco:6422da8be273963aac69e902 */ +"contentDescriptionAccountSelection" = "Cuenta seleccionada"; + /* loco:6422e08598a76c7f98590952 */ "contentDescriptionButtonExpandRecipients" = "Ver detalles"; +/* loco:6422e0f363700359984130d3 */ +"contentDescriptionOpenMailActions" = "Acciones de mensajes abiertos"; + +/* loco:64241f061119dc0dd86b0272 */ +"contentDescriptionButtonOpenSystemSettings" = "Abrir la configuración de notificaciones del sistema"; + /* loco:642430a9ce12f43b026e7ef2 */ "contentDescriptionSelectedItem" = "Selección"; @@ -980,6 +1045,9 @@ /* loco:64243b4a9a9a1f26302e33f3 */ "emailWithoutSubjectDescription" = "Está a punto de enviar un mensaje sin asunto. ¿Desea continuar?"; +/* loco:6424488fdb64c92719278303 */ +"contentDescriptionButtonDeleteSignature" = "Borrar firma"; + /* loco:64245322f2b3ab37ed1b26b2 */ "buttonContinue" = "Continuar"; @@ -1055,6 +1123,12 @@ /* loco:6450d3f93e5fcf13c9037b02 */ "errorMailboxLocked" = "Esta dirección de correo electrónico está bloqueada"; +/* loco:64522a87e05780ced808fd62 */ +"errorCorruptAttachment" = "No se ha podido gestionar el borrador, un archivo adjunto está dañado"; + +/* loco:64539f465ac8a1dc1b0a87c2 */ +"contentDescriptionButtonDeleteQuote" = "Borrar mensajes citados"; + /* loco:645b485e158c37a429012092 */ "buttonLoadMore" = "Cargar más conversaciones"; @@ -1222,3 +1296,9 @@ /* loco:64c21f7b8b1932323c047792 */ "newAccountStorageDrive" = "15 GB de almacenamiento kDrive"; + +/* loco:64c389458fb94726cf010c52 */ +"snackbarDownloadInProgress" = "Descarga en proceso…"; + +/* loco:64ca16484dbf65eb9b0ea862 */ +"PleaseLogInFirst" = "Inicia sesión primero en la aplicación ikMail."; diff --git a/MailResources/Localizable/fr.lproj/Localizable.strings b/MailResources/Localizable/fr.lproj/Localizable.strings index 0380c56a3..c9616ba65 100644 --- a/MailResources/Localizable/fr.lproj/Localizable.strings +++ b/MailResources/Localizable/fr.lproj/Localizable.strings @@ -3,9 +3,8 @@ * Project: kMail * Release: Working copy * Locale: fr, French - * Tagged: ios - * Exported by: Ambroise Decouttere - * Exported at: Thu, 27 Jul 2023 16:53:47 +0200 + * Exported by: Adrien Coye + * Exported at: Wed, 02 Aug 2023 10:42:34 +0200 */ /* loco:6256793050618f7416758a32 */ @@ -584,6 +583,12 @@ /* loco:62bc67601f560d734f2fbe58 */ "snackbarContactSaved" = "Nouveau contact enregistré avec succès"; +/* loco:62bd4fa9c71d395cd431e6a2 */ +"NSFaceIDUsageDescription" = "Face ID sera utilisé pour déverrouiller l’application."; + +/* loco:62bd538485526660827f95d2 */ +"NSContactsUsageDescription" = "Les contacts locaux seront utilisés pour afficher les informations sur les destinataires. Nous ne les enverrons PAS au serveur."; + /* loco:62bd85a64880c362bd5ece72 */ "snackbarEmailCopiedToClipboard" = "Adresse mail copiée dans le presse-papiers"; @@ -710,6 +715,9 @@ /* loco:62cd644f5141ee164b494272 */ "snackbarDisplayProblemReported" = "Problème d’affichage signalé"; +/* loco:62ce828ff799954b2054aac2 */ +"NSCameraUsageDescription" = "L'appareil photo sera utilisé pour joindre une photo à votre e-mail."; + /* loco:62cecdf0413c882ede7451c2 */ "reportPhishingTitle" = "Signaler une tentative d’hameçonnage"; @@ -719,6 +727,9 @@ /* loco:62cfb94ffe73905627441c62 */ "settingsOptionSystemTheme" = "Système"; +/* loco:62cfdf508559983b710b7bd2 */ +"threadListHeaderLastUpdateNow" = "à l’instant"; + /* loco:62d5561a4fa44b4a25143fc2 */ "searchFilterRead" = "Lus"; @@ -788,6 +799,12 @@ /* loco:634ea0a369dac5469e1ce6d2 */ "noConversationSelected" = "Aucune conversation sélectionnée dans %@"; +/* loco:6357867da9aca53d914670a2 */ +"settingsSwipeLeft" = "Balayage vers la gauche"; + +/* loco:63578695fa87a96c446068f2 */ +"settingsSwipeRight" = "Balayage vers la droite"; + /* loco:635fab674606bd5f944f7784 */ "workInProgressTitle" = "Disponible prochainement"; @@ -803,6 +820,15 @@ /* loco:637ce6f461e6ca5762134f53 */ "noFolderTitle" = "Vous n’avez pas encore de dossier…"; +/* loco:6384d9c01d10203fe203b7e2 */ +"notificationSyncDraftChannelName" = "Sauvegarde des brouillons"; + +/* loco:6384dc46d8b6482c3f1962a4 */ +"notificationSyncMessagesChannelName" = "Récupération des nouveaux emails"; + +/* loco:6384e1c927eaf70c585c8732 */ +"notificationNewMessagesChannelName" = "Nouveaux e-mails"; + /* loco:638f5a17e9a49034ae006a92 */ "snackbarThreadDeletedPermanently" = "Conversation supprimée"; @@ -839,6 +865,15 @@ /* loco:63a474bd648cd86d56444012 */ "confirmLogoutDescription" = "Êtes-vous sûr de vouloir vous déconnecter du compte %@ ?"; +/* loco:63bc30711f2f796a1c262c92 */ +"newMessageNotificationSummary" = "%d nouveau message"; + +/* loco:63bc31091f2f796a1c262c95 */ +"newMessageNotificationSummary-plural" = "%d nouveaux messages"; + +/* loco:63bc31577985706f5870b192 */ +"newMessageNotificationSummary-plural-many" = "%d nouveaux messages"; + /* loco:63c64f5d53fe7b61a46310c3 */ "newFolderDialogHint" = "Nom du dossier"; @@ -851,9 +886,15 @@ /* loco:63d14902e3c4eb4832777c42 */ "errorCancelAttachmentsUploadInProgress" = "Certaines pièces jointes sont toujours en cours d'importation. Si vous fermez la vue, les pièces jointes seront perdues."; +/* loco:63d27324e186fd10f243ce82 */ +"webViewCantHandleAction" = "Aucune application n’a été trouvée pour gérer cette action"; + /* loco:63d7e73a70ff84756f78f0b3 */ "threadListHeaderUnreadCountMore" = "99+ non lus"; +/* loco:63e0c11f3fd6c70fb7723192 */ +"contentDescriptionIconFolderSelected" = "Dossier sélectionné"; + /* loco:63e11e5771d13a480771a162 */ "SettingsEnableNotifications" = "Activer les notifications"; @@ -896,6 +937,9 @@ /* loco:63edf28c20221c0f3a6d30a2 */ "threadListDeletionConfirmationAlertTitle-plural" = "Supprimer %d messages"; +/* loco:63edf7b80fc3f74f890dd0c4 */ +"threadListDeletionConfirmationAlertTitle-plural-many" = "Supprimer %d de messages"; + /* loco:63ee0cbbad127b43f8650242 */ "newFolderDialogMovePositiveButton" = "Créer et déplacer"; @@ -926,6 +970,9 @@ /* loco:6400bb16c117ec0d866887b5 */ "messageHideQuotedText" = "Cacher la conversation"; +/* loco:6401f75b518ceb46ca297432 */ +"googlePlayServicesAreRequired" = "Les Google Play Services sont requis"; + /* loco:640836aa0f09a82bfa0da572 */ "searchAllMessages" = "Tous les messages"; @@ -962,15 +1009,33 @@ /* loco:641d60b6f5c19a18734d9192 */ "urlUserReportiOS" = "https://feedback.userreport.com/d6b0e711-0c77-459c-9d06-79f92264f221/"; +/* loco:641d6129a45dca299a5fdf02 */ +"urlUserReportAndroid" = "https://feedback.userreport.com/91aeb5c2-f609-4b9c-9787-755e83ed940c/"; + /* loco:641db88f88b1f63cb05aad52 */ "tooManyRecipients" = "Vous ne pouvez pas ajouter cette adresse car vous avez atteint la limite de destinataires"; /* loco:642179d9733cd4523d03dcf2 */ "accentColorSystemTitle" = "Couleur du système"; +/* loco:6422a877e133d2121822bf72 */ +"contentDescriptionButtonDelete" = "Supprimer %@"; + +/* loco:6422d0ea459bcf36c5099782 */ +"contentDescriptionDownloadIndicator" = "Téléchargement"; + +/* loco:6422da8be273963aac69e902 */ +"contentDescriptionAccountSelection" = "Compte sélectionné"; + /* loco:6422e08598a76c7f98590952 */ "contentDescriptionButtonExpandRecipients" = "Voir les détails"; +/* loco:6422e0f363700359984130d3 */ +"contentDescriptionOpenMailActions" = "Ouvrir les actions du message"; + +/* loco:64241f061119dc0dd86b0272 */ +"contentDescriptionButtonOpenSystemSettings" = "Ouvrir les paramètres de notification du système"; + /* loco:642430a9ce12f43b026e7ef2 */ "contentDescriptionSelectedItem" = "Sélectionné"; @@ -980,6 +1045,9 @@ /* loco:64243b4a9a9a1f26302e33f3 */ "emailWithoutSubjectDescription" = "Vous êtes sur le point d’envoyer un message sans objet. Voulez-vous continuer ?"; +/* loco:6424488fdb64c92719278303 */ +"contentDescriptionButtonDeleteSignature" = "Supprimer la signature"; + /* loco:64245322f2b3ab37ed1b26b2 */ "buttonContinue" = "Continuer"; @@ -1055,6 +1123,12 @@ /* loco:6450d3f93e5fcf13c9037b02 */ "errorMailboxLocked" = "Cette adresse est bloquée"; +/* loco:64522a87e05780ced808fd62 */ +"errorCorruptAttachment" = "Échec du traitement du brouillon, une pièce jointe est corrompue"; + +/* loco:64539f465ac8a1dc1b0a87c2 */ +"contentDescriptionButtonDeleteQuote" = "Supprimer les messages cités"; + /* loco:645b485e158c37a429012092 */ "buttonLoadMore" = "Charger plus de conversations"; @@ -1222,3 +1296,9 @@ /* loco:64c21f7b8b1932323c047792 */ "newAccountStorageDrive" = "15 Go de stockage kDrive"; + +/* loco:64c389458fb94726cf010c52 */ +"snackbarDownloadInProgress" = "Téléchargement en cours…"; + +/* loco:64ca16484dbf65eb9b0ea862 */ +"PleaseLogInFirst" = "Veuillez d'abord vous connecter à l'application ikMail."; diff --git a/MailResources/Localizable/it.lproj/Localizable.strings b/MailResources/Localizable/it.lproj/Localizable.strings index aefa3485f..20549c5fc 100644 --- a/MailResources/Localizable/it.lproj/Localizable.strings +++ b/MailResources/Localizable/it.lproj/Localizable.strings @@ -3,9 +3,8 @@ * Project: kMail * Release: Working copy * Locale: it, Italian - * Tagged: ios - * Exported by: Ambroise Decouttere - * Exported at: Thu, 27 Jul 2023 16:53:47 +0200 + * Exported by: Adrien Coye + * Exported at: Wed, 02 Aug 2023 10:42:34 +0200 */ /* loco:6256793050618f7416758a32 */ @@ -584,6 +583,12 @@ /* loco:62bc67601f560d734f2fbe58 */ "snackbarContactSaved" = "Nuovo contatto registrato con successo"; +/* loco:62bd4fa9c71d395cd431e6a2 */ +"NSFaceIDUsageDescription" = "Il Face ID verrà utilizzato per sbloccare l'applicazione."; + +/* loco:62bd538485526660827f95d2 */ +"NSContactsUsageDescription" = "I contatti locali saranno utilizzati per mostrare le informazioni sui destinatari. NON verranno inviati al server."; + /* loco:62bd85a64880c362bd5ece72 */ "snackbarEmailCopiedToClipboard" = "Indirizzo e-mail copiato negli appunti"; @@ -710,6 +715,9 @@ /* loco:62cd644f5141ee164b494272 */ "snackbarDisplayProblemReported" = "Problema di visualizzazione segnalato"; +/* loco:62ce828ff799954b2054aac2 */ +"NSCameraUsageDescription" = "La fotocamera verrà utilizzata per allegare le foto all'e-mail."; + /* loco:62cecdf0413c882ede7451c2 */ "reportPhishingTitle" = "Segnala un tentativo di phishing"; @@ -719,6 +727,9 @@ /* loco:62cfb94ffe73905627441c62 */ "settingsOptionSystemTheme" = "Sistema"; +/* loco:62cfdf508559983b710b7bd2 */ +"threadListHeaderLastUpdateNow" = "ora"; + /* loco:62d5561a4fa44b4a25143fc2 */ "searchFilterRead" = "Leggi"; @@ -788,6 +799,12 @@ /* loco:634ea0a369dac5469e1ce6d2 */ "noConversationSelected" = "Nessuna conversazione selezionata in %@"; +/* loco:6357867da9aca53d914670a2 */ +"settingsSwipeLeft" = "Passaggio del dito a sinistra"; + +/* loco:63578695fa87a96c446068f2 */ +"settingsSwipeRight" = "Passaggio del dito a destra"; + /* loco:635fab674606bd5f944f7784 */ "workInProgressTitle" = "Disponibile a breve"; @@ -803,6 +820,15 @@ /* loco:637ce6f461e6ca5762134f53 */ "noFolderTitle" = "Non hai ancora nessuna cartella…"; +/* loco:6384d9c01d10203fe203b7e2 */ +"notificationSyncDraftChannelName" = "Salvataggio delle bozze"; + +/* loco:6384dc46d8b6482c3f1962a4 */ +"notificationSyncMessagesChannelName" = "Recupero di nuove e-mail"; + +/* loco:6384e1c927eaf70c585c8732 */ +"notificationNewMessagesChannelName" = "Nuove e-mail"; + /* loco:638f5a17e9a49034ae006a92 */ "snackbarThreadDeletedPermanently" = "Conversazione cancellata"; @@ -839,6 +865,15 @@ /* loco:63a474bd648cd86d56444012 */ "confirmLogoutDescription" = "Sei sicuro di volerti disconnettere da %@?"; +/* loco:63bc30711f2f796a1c262c92 */ +"newMessageNotificationSummary" = "%d nuovo messaggio"; + +/* loco:63bc31091f2f796a1c262c95 */ +"newMessageNotificationSummary-plural" = "%d nuovi messaggi"; + +/* loco:63bc31577985706f5870b192 */ +"newMessageNotificationSummary-plural-many" = ""; + /* loco:63c64f5d53fe7b61a46310c3 */ "newFolderDialogHint" = "Nome della cartella"; @@ -851,9 +886,15 @@ /* loco:63d14902e3c4eb4832777c42 */ "errorCancelAttachmentsUploadInProgress" = "Alcuni allegati vengono ancora importati. Se si chiude la vista, gli allegati andranno persi."; +/* loco:63d27324e186fd10f243ce82 */ +"webViewCantHandleAction" = "Non è stata trovata alcuna applicazione in grado di gestire questa azione"; + /* loco:63d7e73a70ff84756f78f0b3 */ "threadListHeaderUnreadCountMore" = "99 non letti"; +/* loco:63e0c11f3fd6c70fb7723192 */ +"contentDescriptionIconFolderSelected" = "Cartella selezionata"; + /* loco:63e11e5771d13a480771a162 */ "SettingsEnableNotifications" = "Abilitazione delle notifiche"; @@ -896,6 +937,9 @@ /* loco:63edf28c20221c0f3a6d30a2 */ "threadListDeletionConfirmationAlertTitle-plural" = "Cancella %d messaggi"; +/* loco:63edf7b80fc3f74f890dd0c4 */ +"threadListDeletionConfirmationAlertTitle-plural-many" = ""; + /* loco:63ee0cbbad127b43f8650242 */ "newFolderDialogMovePositiveButton" = "Crea e sposta"; @@ -926,6 +970,9 @@ /* loco:6400bb16c117ec0d866887b5 */ "messageHideQuotedText" = "Nascondi la conversazione"; +/* loco:6401f75b518ceb46ca297432 */ +"googlePlayServicesAreRequired" = "I servizi Google Play sono necessari"; + /* loco:640836aa0f09a82bfa0da572 */ "searchAllMessages" = "Tutti i messaggi"; @@ -962,15 +1009,33 @@ /* loco:641d60b6f5c19a18734d9192 */ "urlUserReportiOS" = "https://feedback.userreport.com/b0fada78-1555-4c59-abad-423e887534d5/"; +/* loco:641d6129a45dca299a5fdf02 */ +"urlUserReportAndroid" = "https://feedback.userreport.com/3cc072b7-61ff-4592-b1aa-307a58fdcec9/"; + /* loco:641db88f88b1f63cb05aad52 */ "tooManyRecipients" = "Non è possibile aggiungere questo indirizzo perché è stato raggiunto il limite di destinatari"; /* loco:642179d9733cd4523d03dcf2 */ "accentColorSystemTitle" = "Colore del sistema"; +/* loco:6422a877e133d2121822bf72 */ +"contentDescriptionButtonDelete" = "Cancella %@"; + +/* loco:6422d0ea459bcf36c5099782 */ +"contentDescriptionDownloadIndicator" = "Scarica"; + +/* loco:6422da8be273963aac69e902 */ +"contentDescriptionAccountSelection" = "Conto selezionato"; + /* loco:6422e08598a76c7f98590952 */ "contentDescriptionButtonExpandRecipients" = "Vedi dettagli"; +/* loco:6422e0f363700359984130d3 */ +"contentDescriptionOpenMailActions" = "Azioni di apertura dei messaggi"; + +/* loco:64241f061119dc0dd86b0272 */ +"contentDescriptionButtonOpenSystemSettings" = "Apri le impostazioni di notifica del sistema"; + /* loco:642430a9ce12f43b026e7ef2 */ "contentDescriptionSelectedItem" = "Selezionato"; @@ -980,6 +1045,9 @@ /* loco:64243b4a9a9a1f26302e33f3 */ "emailWithoutSubjectDescription" = "State per inviare un messaggio senza oggetto. Volete continuare?"; +/* loco:6424488fdb64c92719278303 */ +"contentDescriptionButtonDeleteSignature" = "Cancella la firma"; + /* loco:64245322f2b3ab37ed1b26b2 */ "buttonContinue" = "Continua"; @@ -1055,6 +1123,12 @@ /* loco:6450d3f93e5fcf13c9037b02 */ "errorMailboxLocked" = "Questo indirizzo e-mail è bloccato"; +/* loco:64522a87e05780ced808fd62 */ +"errorCorruptAttachment" = "Impossibile gestire la bozza, un allegato è danneggiato"; + +/* loco:64539f465ac8a1dc1b0a87c2 */ +"contentDescriptionButtonDeleteQuote" = "Eliminare i messaggi citati"; + /* loco:645b485e158c37a429012092 */ "buttonLoadMore" = "Carica altre conversazioni"; @@ -1222,3 +1296,9 @@ /* loco:64c21f7b8b1932323c047792 */ "newAccountStorageDrive" = "15 GB di memoria kDrive"; + +/* loco:64c389458fb94726cf010c52 */ +"snackbarDownloadInProgress" = "Download in corso…"; + +/* loco:64ca16484dbf65eb9b0ea862 */ +"PleaseLogInFirst" = "Effettuare prima il login nell'app ikMail."; diff --git a/MailShareExtension/Base.lproj/MainInterface.storyboard b/MailShareExtension/Base.lproj/MainInterface.storyboard new file mode 100644 index 000000000..34049b372 --- /dev/null +++ b/MailShareExtension/Base.lproj/MainInterface.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MailShareExtension/ComposeMessageWrapperView.swift b/MailShareExtension/ComposeMessageWrapperView.swift new file mode 100644 index 000000000..581a01ef0 --- /dev/null +++ b/MailShareExtension/ComposeMessageWrapperView.swift @@ -0,0 +1,76 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 InfomaniakCore +import InfomaniakCoreUI +import InfomaniakDI +import MailCore +import MailResources +import Social +import SwiftUI +import UIKit + +struct ComposeMessageWrapperView: View { + private var itemProviders: [NSItemProvider] + private var dismissHandler: SimpleClosure + + @State private var draft: Draft + + @LazyInjectService private var accountManager: AccountManager + + init(dismissHandler: @escaping SimpleClosure, itemProviders: [NSItemProvider], draft: Draft = Draft()) { + _draft = State(wrappedValue: draft) + self.dismissHandler = dismissHandler + self.itemProviders = itemProviders + } + + var body: some View { + if let mailboxManager = accountManager.currentMailboxManager { + ComposeMessageView.newMessage(draft, mailboxManager: mailboxManager, itemProviders: itemProviders) + .environmentObject(mailboxManager) + .environment(\.dismissModal) { + dismissHandler(()) + } + } else { + PleaseLoginView(tapHandler: dismissHandler) + } + } +} + +struct PleaseLoginView: View { + @State var slide = Slide.onBoardingSlides.first! + + var tapHandler: SimpleClosure + + var body: some View { + VStack { + MailShareExtensionAsset.logoText.swiftUIImage + .resizable() + .scaledToFit() + .frame(height: UIConstants.onboardingLogoHeight) + .padding(.top, UIConstants.onboardingLogoPaddingTop) + Text(MailResourcesStrings.Localizable.pleaseLogInFirst) + .textStyle(.header2) + .padding(.top, UIConstants.onboardingLogoPaddingTop) + LottieView(configuration: slide.lottieConfiguration!) + Spacer() + }.onTapGesture { + tapHandler(()) + } + } +} diff --git a/MailShareExtension/Info.plist b/MailShareExtension/Info.plist new file mode 100644 index 000000000..7c89d6499 --- /dev/null +++ b/MailShareExtension/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleName + $(PRODUCT_NAME) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleDisplayName + $(PRODUCT_NAME) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + AppIdentifierPrefix + $(AppIdentifierPrefix) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, (ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.data")).@count == $extensionItem.attachments.@count ).@count > 0 + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/MailShareExtension/Proxy/ApplicationState.swift b/MailShareExtension/Proxy/ApplicationState.swift new file mode 100644 index 000000000..926be5016 --- /dev/null +++ b/MailShareExtension/Proxy/ApplicationState.swift @@ -0,0 +1,26 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 MailCore +import UIKit + +public struct ApplicationState: ApplicationStatable { + public var applicationState: UIApplication.State? { + nil + } +} diff --git a/MailShareExtension/Proxy/CacheManager.swift b/MailShareExtension/Proxy/CacheManager.swift new file mode 100644 index 000000000..49f24d499 --- /dev/null +++ b/MailShareExtension/Proxy/CacheManager.swift @@ -0,0 +1,27 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 UIKit + +/// A cache manager that works in Extension mode +public final class CacheManager: CacheManageable { + public func refreshCacheData() { + // NOOP in shareExtension + } +} diff --git a/MailShareExtension/Proxy/OrientationManager.swift b/MailShareExtension/Proxy/OrientationManager.swift new file mode 100644 index 000000000..0c315b132 --- /dev/null +++ b/MailShareExtension/Proxy/OrientationManager.swift @@ -0,0 +1,32 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 UIKit + +/// An OrientationManager that works in Extension mode +public final class OrientationManager: OrientationManageable { + public var orientationLock = UIInterfaceOrientationMask.all + + public func setOrientationLock(_ orientation: UIInterfaceOrientationMask) { + // NOOP in share extension + } + + public var interfaceOrientation: UIInterfaceOrientation? +} diff --git a/MailShareExtension/Proxy/RemoteNotificationRegistrer.swift b/MailShareExtension/Proxy/RemoteNotificationRegistrer.swift new file mode 100644 index 000000000..c3ecc243e --- /dev/null +++ b/MailShareExtension/Proxy/RemoteNotificationRegistrer.swift @@ -0,0 +1,27 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 UIKit + +/// A RemoteNotificationRegistrer that works in Extension mode +public final class RemoteNotificationRegistrer: RemoteNotificationRegistrable { + public func register() { + // NOOP in share extension + } +} diff --git a/MailShareExtension/ShareExtension.entitlements b/MailShareExtension/ShareExtension.entitlements new file mode 100644 index 000000000..483c59d33 --- /dev/null +++ b/MailShareExtension/ShareExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.com.infomaniak.mail + + keychain-access-groups + + $(AppIdentifierPrefix)com.infomaniak.mail + + + diff --git a/MailShareExtension/ShareViewController.swift b/MailShareExtension/ShareViewController.swift new file mode 100644 index 000000000..27bb48aa2 --- /dev/null +++ b/MailShareExtension/ShareViewController.swift @@ -0,0 +1,94 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 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 InfomaniakDI +import MailCore +import Social +import SwiftUI +import UIKit + +final class ShareNavigationViewController: UIViewController { + /// Making sure the DI is registered at a very early stage of the app launch. + private let dependencyInjectionHook = EarlyDIHook() + + @LazyInjectService private var accountManager: AccountManager + + private func overrideSnackBarPresenter(contextView: UIView) { + let snackBarPresenter = Factory(type: SnackBarPresentable.self) { _, _ in + SnackBarPresenter(contextView: contextView) + } + SimpleResolver.sharedResolver.store(factory: snackBarPresenter) + } + + override public func viewDidLoad() { + super.viewDidLoad() + + overrideSnackBarPresenter(contextView: view) + + // Set theme + overrideUserInterfaceStyle = UserDefaults.shared.theme.interfaceStyle + view.tintColor = UserDefaults.shared.accentColor.primary.color + + // Modify sheet size on iPadOS, property is ignored on iOS + preferredContentSize = CGSize(width: 540, height: 620) + + // Make sure we are handling [NSExtensionItem] + guard let extensionItems: [NSExtensionItem] = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem }, + !extensionItems.isEmpty else { + dismiss(animated: true) + return + } + + let itemProviders: [NSItemProvider] = extensionItems.compactMap(\.attachments).flatMap { $0 } + guard !itemProviders.isEmpty else { + dismiss(animated: true) + return + } + + /// make sure we load the contact list asap. + if let currentContactManager = accountManager.currentContactManager { + Task { + try await currentContactManager.fetchContactsAndAddressBooks() + } + } + + // We need to go threw wrapping to use SwiftUI in an NSExtension. + let rootView = ComposeMessageWrapperView(dismissHandler: { + self.dismiss(animated: true) + }, + itemProviders: itemProviders) + .defaultAppStorage(.shared) + let hostingController = UIHostingController(rootView: rootView) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + addChild(hostingController) + view.addSubview(hostingController.view) + hostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor, constant: 0), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + extensionContext!.completeRequest(returningItems: nil, completionHandler: nil) + } +} diff --git a/MailTests/MailboxManagerTests.swift b/MailTests/MailboxManagerTests.swift index 60d9943e9..7ed20a28e 100644 --- a/MailTests/MailboxManagerTests.swift +++ b/MailTests/MailboxManagerTests.swift @@ -18,6 +18,7 @@ import Foundation import InfomaniakCore +import InfomaniakDI import InfomaniakLogin @testable import MailCore import XCTest @@ -27,7 +28,8 @@ final class MailboxManagerTests: XCTestCase { override class func setUp() { super.setUp() - mailboxManager = AccountManager.instance.getMailboxManager(for: Env.mailboxId, userId: Env.userId) + @InjectService var accountManager: AccountManager + mailboxManager = accountManager.getMailboxManager(for: Env.mailboxId, userId: Env.userId) let token = ApiToken(accessToken: Env.token, expiresIn: Int.max, diff --git a/MailTests/SignatureTests.swift b/MailTests/SignatureTests.swift index d65df772c..31f2dbb21 100644 --- a/MailTests/SignatureTests.swift +++ b/MailTests/SignatureTests.swift @@ -23,7 +23,7 @@ import SwiftSoup import XCTest final class SignatureTests: XCTestCase { - /// Some randon HTML content + /// Some random HTML content static let someMailContent = "

Hello



" /// A basic signature wrapped in the "editorUserSignature" class diff --git a/Project.swift b/Project.swift index a216d2316..60862aa31 100644 --- a/Project.swift +++ b/Project.swift @@ -18,18 +18,16 @@ import Foundation import ProjectDescription - -let deploymentTarget = DeploymentTarget.iOS(targetVersion: "15.0", devices: [.iphone, .ipad]) -let baseSettings = SettingsDictionary() - .currentProjectVersion("1") - .marketingVersion("1.0.3") - .automaticCodeSigning(devTeam: "864VDCS2QY") +import ProjectDescriptionHelpers let project = Project(name: "Mail", packages: [ .package(url: "https://github.com/Infomaniak/ios-login", .upToNextMajor(from: "4.0.0")), .package(url: "https://github.com/Infomaniak/ios-dependency-injection", .upToNextMajor(from: "1.1.6")), - .package(url: "https://github.com/Infomaniak/ios-core", .revision("4eaefd644f75d833d6b1009dd94a9d6d674ccb53")), + .package( + url: "https://github.com/Infomaniak/ios-core", + .revision("d779a9f6619615a4b4a91fa9d3fbb48415a27470") + ), .package(url: "https://github.com/Infomaniak/ios-core-ui", .upToNextMajor(from: "2.5.2")), .package(url: "https://github.com/Infomaniak/ios-notifications", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Infomaniak/ios-create-account", .upToNextMajor(from: "1.1.0")), @@ -41,7 +39,10 @@ let project = Project(name: "Mail", .package(url: "https://github.com/flowbe/SwiftRegex", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/matomo-org/matomo-sdk-ios", .upToNextMajor(from: "7.5.1")), .package(url: "https://github.com/siteline/SwiftUI-Introspect", .upToNextMajor(from: "0.9.0")), - .package(url: "https://github.com/Ambrdctr/SQRichTextEditor", .revision("04737b7694ecc6cfd78631bce5fc370f310e7e14")), + .package( + url: "https://github.com/Ambrdctr/SQRichTextEditor", + .revision("04737b7694ecc6cfd78631bce5fc370f310e7e14") + ), .package(url: "https://github.com/markiv/SwiftUI-Shimmer", .upToNextMajor(from: "1.0.1")), .package(url: "https://github.com/dkk/WrappingHStack", .upToNextMajor(from: "2.0.0")), .package(url: "https://github.com/kean/Nuke", .upToNextMajor(from: "12.1.3")), @@ -56,7 +57,7 @@ let project = Project(name: "Mail", platform: .iOS, product: .app, bundleId: "com.infomaniak.mail", - deploymentTarget: deploymentTarget, + deploymentTarget: Constants.deploymentTarget, infoPlist: "Mail/Info.plist", sources: "Mail/**", resources: [ @@ -69,12 +70,11 @@ let project = Project(name: "Mail", "MailResources/**/*.js" ], entitlements: "MailResources/Mail.entitlements", - scripts: [ - .post(path: "scripts/lint.sh", name: "Swiftlint") - ], + scripts: [Constants.swiftlintScript], dependencies: [ .target(name: "MailCore"), .target(name: "MailNotificationServiceExtension"), + .target(name: "MailShareExtension"), .package(product: "Introspect"), .package(product: "SQRichTextEditor"), .package(product: "Shimmer"), @@ -84,7 +84,7 @@ let project = Project(name: "Mail", .package(product: "Popovers"), .package(product: "SwiftUIBackports") ], - settings: .settings(base: baseSettings), + settings: .settings(base: Constants.baseSettings), environment: ["hostname": "\(ProcessInfo.processInfo.hostName)."]), Target(name: "MailTests", platform: .iOS, @@ -106,12 +106,51 @@ let project = Project(name: "Mail", .target(name: "Mail") ] ), + Target( + name: "MailShareExtension", + platform: .iOS, + product: .appExtension, + bundleId: "com.infomaniak.mail.ShareExtension", + deploymentTarget: Constants.deploymentTarget, + infoPlist: .file(path: "MailShareExtension/Info.plist"), + sources: ["MailShareExtension/**", + "Mail/Views/**", + "Mail/Components/**", + "Mail/Helpers/**", + "Mail/Utils/**", + "Mail/Views/**", + "Mail/Proxy/Protocols/**"], + resources: [ + "MailShareExtension/Base.lproj/MainInterface.storyboard", + "Mail/**/*.storyboard", + "MailResources/**/*.xcassets", + "MailResources/**/*.strings", + "MailResources/**/*.stringsdict", + "MailResources/**/*.json", + "MailResources/**/*.css", + "MailResources/**/*.js" + ], + entitlements: "MailShareExtension/ShareExtension.entitlements", + scripts: [Constants.swiftlintScript], + dependencies: [ + .target(name: "MailCore"), + .package(product: "Introspect"), + .package(product: "SQRichTextEditor"), + .package(product: "Shimmer"), + .package(product: "WrappingHStack"), + .package(product: "Lottie"), + .package(product: "NavigationBackport"), + .package(product: "Popovers"), + .package(product: "SwiftUIBackports") + ], + settings: .settings(base: Constants.baseSettings) + ), Target( name: "MailNotificationServiceExtension", platform: .iOS, product: .appExtension, bundleId: "com.infomaniak.mail.NotificationServiceExtension", - deploymentTarget: deploymentTarget, + deploymentTarget: Constants.deploymentTarget, infoPlist: .extendingDefault(with: [ "AppIdentifierPrefix": "$(AppIdentifierPrefix)", "CFBundleDisplayName": "$(PRODUCT_NAME)", @@ -127,14 +166,14 @@ let project = Project(name: "Mail", dependencies: [ .target(name: "MailCore") ], - settings: .settings(base: baseSettings) + settings: .settings(base: Constants.baseSettings) ), Target( name: "MailResources", platform: .iOS, product: .staticLibrary, bundleId: "com.infomaniak.mail.resources", - deploymentTarget: deploymentTarget, + deploymentTarget: Constants.deploymentTarget, infoPlist: .default, resources: [ "MailResources/**/*.xcassets", @@ -144,14 +183,14 @@ let project = Project(name: "Mail", "MailResources/**/*.css", "MailResources/**/*.js" ], - settings: .settings(base: baseSettings) + settings: .settings(base: Constants.baseSettings) ), Target( name: "MailCore", platform: .iOS, product: .framework, bundleId: "com.infomaniak.mail.core", - deploymentTarget: deploymentTarget, + deploymentTarget: Constants.deploymentTarget, infoPlist: "MailCore/Info.plist", sources: "MailCore/**", dependencies: [ @@ -172,7 +211,7 @@ let project = Project(name: "Mail", .package(product: "NukeUI"), .package(product: "SwiftSoup") ], - settings: .settings(base: baseSettings) + settings: .settings(base: Constants.baseSettings) ) ], fileHeaderTemplate: .file("file-header-template.txt")) diff --git a/Tuist/ProjectDescriptionHelpers/Constants.swift b/Tuist/ProjectDescriptionHelpers/Constants.swift new file mode 100644 index 000000000..31070de2c --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Constants.swift @@ -0,0 +1,30 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2022 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 ProjectDescription + +public enum Constants { + public static let baseSettings = SettingsDictionary() + .currentProjectVersion("1") + .marketingVersion("1.0.3") + .automaticCodeSigning(devTeam: "864VDCS2QY") + + public static let deploymentTarget = DeploymentTarget.iOS(targetVersion: "15.0", devices: [.iphone, .ipad]) + + public static let swiftlintScript = TargetScript.post(path: "scripts/lint.sh", name: "Swiftlint") +} diff --git a/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift b/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift new file mode 100644 index 000000000..4d2d540a2 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ExtensionTarget.swift @@ -0,0 +1,23 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2022 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 ProjectDescription + +public extension Target { + // TODO: move contructors here if needed +}