diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 127c8405e..38b3f219a 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -232,6 +232,12 @@ EA31DD9043B91ECB8E45A9A6 /* ScreenshotDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03C9D319676F3C0DC6B0203 /* ScreenshotDetectorTests.swift */; }; EA65360A0EC026DD83AC0CF5 /* AuthenticationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA5F386C7701C129398945 /* AuthenticationCoordinator.swift */; }; EBD6C79705B3DDB2F7E5F554 /* UserSessionStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1B52D0ABBA7091A991CAFE /* UserSessionStoreProtocol.swift */; }; + EC7FFD61286B143500DF372A /* BackgroundTaskServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7FFD60286B143500DF372A /* BackgroundTaskServiceProtocol.swift */; }; + EC7FFD63286B14B200DF372A /* BackgroundTaskProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7FFD62286B14B200DF372A /* BackgroundTaskProtocol.swift */; }; + EC7FFD65286B15CA00DF372A /* UIKitBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7FFD64286B15CA00DF372A /* UIKitBackgroundTask.swift */; }; + EC7FFD67286B18B900DF372A /* ApplicationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7FFD66286B18B900DF372A /* ApplicationProtocol.swift */; }; + EC7FFD69286B1D6B00DF372A /* UIKitBackgroundTaskService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7FFD68286B1D6B00DF372A /* UIKitBackgroundTaskService.swift */; }; + EC7FFD6E286B42D000DF372A /* BackgroundTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7FFD6D286B42D000DF372A /* BackgroundTaskTests.swift */; }; ED4F663C783E9A8C0E80B983 /* TemplateSimpleScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47543EB19F3DCF308751F53C /* TemplateSimpleScreenViewModel.swift */; }; EE8491AD81F47DF3C192497B /* DecorationTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */; }; EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */; }; @@ -595,6 +601,12 @@ E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = ""; }; E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = ""; }; EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; + EC7FFD60286B143500DF372A /* BackgroundTaskServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskServiceProtocol.swift; sourceTree = ""; }; + EC7FFD62286B14B200DF372A /* BackgroundTaskProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskProtocol.swift; sourceTree = ""; }; + EC7FFD64286B15CA00DF372A /* UIKitBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTask.swift; sourceTree = ""; }; + EC7FFD66286B18B900DF372A /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = ""; }; + EC7FFD68286B1D6B00DF372A /* UIKitBackgroundTaskService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTaskService.swift; sourceTree = ""; }; + EC7FFD6D286B42D000DF372A /* BackgroundTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskTests.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; EEE384418EB1FEDFA62C9CD0 /* RoomTimelineViewFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactoryProtocol.swift; sourceTree = ""; }; @@ -691,6 +703,7 @@ 0787F81684E503024BD0C051 /* Services */ = { isa = PBXGroup; children = ( + EC7FFD6C286B270500DF372A /* Background */, 0ED3F5C21537519389C07644 /* BugReport */, 8039515BAA53B7C3275AC64A /* Client */, 79E560F5113ED25D172E550C /* Media */, @@ -990,6 +1003,7 @@ F03C9D319676F3C0DC6B0203 /* ScreenshotDetectorTests.swift */, 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */, 7B5CF94E124616FD89424B73 /* SplashScreenViewModelTests.swift */, + EC7FFD6D286B42D000DF372A /* BackgroundTaskTests.swift */, AF552BB969DC98A4BB8CF8D5 /* UserIndicators */, ); path = Sources; @@ -1377,6 +1391,18 @@ path = Authentication; sourceTree = ""; }; + EC7FFD6C286B270500DF372A /* Background */ = { + isa = PBXGroup; + children = ( + EC7FFD60286B143500DF372A /* BackgroundTaskServiceProtocol.swift */, + EC7FFD68286B1D6B00DF372A /* UIKitBackgroundTaskService.swift */, + EC7FFD62286B14B200DF372A /* BackgroundTaskProtocol.swift */, + EC7FFD64286B15CA00DF372A /* UIKitBackgroundTask.swift */, + EC7FFD66286B18B900DF372A /* ApplicationProtocol.swift */, + ); + path = Background; + sourceTree = ""; + }; F8474EB69289112888B65518 /* UserIndicators */ = { isa = PBXGroup; children = ( @@ -1715,6 +1741,7 @@ 7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */, C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */, CA1E41AE5CDCB8D801DE0830 /* BuildSettings.swift in Sources */, + EC7FFD6E286B42D000DF372A /* BackgroundTaskTests.swift in Sources */, 9C45CE85325CD591DADBC4CA /* ElementXTests.swift in Sources */, F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */, 0B1F80C2BF7D223159FBA82C /* ImageAnonymizerTests.swift in Sources */, @@ -1805,6 +1832,7 @@ BCC3EDB7AD0902797CB4BBC2 /* MXLogger.m in Sources */, F03E16ED043C62FED5A07AE0 /* MatrixEntitityRegex.swift in Sources */, EA1E7949533E19C6D862680A /* MediaProvider.swift in Sources */, + EC7FFD67286B18B900DF372A /* ApplicationProtocol.swift in Sources */, 7002C55A4C917F3715765127 /* MediaProviderProtocol.swift in Sources */, 62BBF5BE7B905222F0477FF2 /* MediaSource.swift in Sources */, 03B8FEA668A5B76A93113BB1 /* MemberDetailProviderManager.swift in Sources */, @@ -1849,6 +1877,7 @@ 1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */, 9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */, 77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */, + EC7FFD69286B1D6B00DF372A /* UIKitBackgroundTaskService.swift in Sources */, 5D430CDE11EAC3E8E6B80A66 /* RoomTimelineViewFactory.swift in Sources */, 297CD0A27C87B0C50FF192EE /* RoomTimelineViewFactoryProtocol.swift in Sources */, CF82143AA4A4F7BD11D22946 /* RoomTimelineViewProvider.swift in Sources */, @@ -1872,6 +1901,7 @@ 5E1FCC43B738941D5A5F1794 /* SplashScreenViewModelProtocol.swift in Sources */, FCB640C576292BEAF7FA3B2E /* SplashViewController.swift in Sources */, B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */, + EC7FFD65286B15CA00DF372A /* UIKitBackgroundTask.swift in Sources */, 2F94054F50E312AF30BE07F3 /* String.swift in Sources */, A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */, 066A1E9B94723EE9F3038044 /* Strings.swift in Sources */, @@ -1897,6 +1927,7 @@ 0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */, 004561D297DC8B9786AE136F /* UITestScreenIdentifier.swift in Sources */, 03CB204C52F18E24A5C3D219 /* UITestsAppCoordinator.swift in Sources */, + EC7FFD63286B14B200DF372A /* BackgroundTaskProtocol.swift in Sources */, 17CC4FB64F3A670F43ECBE5F /* UITestsRootView.swift in Sources */, 8775F46AE3234A5A5688C19D /* UserIndicator.swift in Sources */, 7FA4227B2BAAA71560252866 /* UserIndicatorDismissal.swift in Sources */, @@ -1909,6 +1940,7 @@ 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */, 978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */, 79A6E08ADE6E7C460A8A17A5 /* UserSessionStore.swift in Sources */, + EC7FFD61286B143500DF372A /* BackgroundTaskServiceProtocol.swift in Sources */, EBD6C79705B3DDB2F7E5F554 /* UserSessionStoreProtocol.swift in Sources */, 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */, 01F4A40C1EDCEC8DC4EC9CFA /* WeakDictionary.swift in Sources */, diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index 322799339..a62de203c 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -26,6 +26,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { private let bugReportService: BugReportServiceProtocol private let screenshotDetector: ScreenshotDetector + private let backgroundTaskService: BackgroundTaskServiceProtocol private var indicatorPresenter: UserIndicatorTypePresenterProtocol private var loadingIndicator: UserIndicator? @@ -58,8 +59,11 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { guard let bundleIdentifier = Bundle.main.bundleIdentifier else { fatalError("Should have a valid bundle identifier at this point") } + + backgroundTaskService = UIKitBackgroundTaskService(withApplication: UIApplication.shared) - userSessionStore = UserSessionStore(bundleIdentifier: bundleIdentifier) + userSessionStore = UserSessionStore(bundleIdentifier: bundleIdentifier, + backgroundTaskService: backgroundTaskService) screenshotDetector = ScreenshotDetector() screenshotDetector.callback = processScreenshotDetection diff --git a/ElementX/Sources/Services/Background/ApplicationProtocol.swift b/ElementX/Sources/Services/Background/ApplicationProtocol.swift new file mode 100644 index 000000000..149dbe780 --- /dev/null +++ b/ElementX/Sources/Services/Background/ApplicationProtocol.swift @@ -0,0 +1,24 @@ +// +// ApplicationProtocol.swift +// ElementX +// +// Created by Ismail on 28.06.2022. +// Copyright © 2022 Element. All rights reserved. +// + +import Foundation +import UIKit + +protocol ApplicationProtocol { + func beginBackgroundTask(expirationHandler handler: (() -> Void)?) -> UIBackgroundTaskIdentifier + + func beginBackgroundTask(withName taskName: String?, expirationHandler handler: (() -> Void)?) -> UIBackgroundTaskIdentifier + + func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) + + var backgroundTimeRemaining: TimeInterval { get } + + var applicationState: UIApplication.State { get } +} + +extension UIApplication: ApplicationProtocol {} diff --git a/ElementX/Sources/Services/Background/BackgroundTaskProtocol.swift b/ElementX/Sources/Services/Background/BackgroundTaskProtocol.swift new file mode 100644 index 000000000..962667312 --- /dev/null +++ b/ElementX/Sources/Services/Background/BackgroundTaskProtocol.swift @@ -0,0 +1,35 @@ +// +// BackgroundTaskProtocol.swift +// ElementX +// +// Created by Ismail on 28.06.2022. +// Copyright © 2022 Element. All rights reserved. +// + +import Foundation + +typealias BackgroundTaskExpirationHandler = (BackgroundTaskProtocol) -> Void + +/// BackgroundTaskProtocol is the protocol describing a background task regardless of the platform used. +protocol BackgroundTaskProtocol: AnyObject { + /// Name of the background task for debug. + var name: String { get } + + /// `true` if the background task is currently running. + var isRunning: Bool { get } + + /// Flag indicating the background task is reusable. If reusable, `name` is the key to distinguish background tasks. + var isReusable: Bool { get } + + /// Elapsed time after the task started. In milliseconds. + var elapsedTime: TimeInterval { get } + + /// Expiration handler for the background task + var expirationHandler: BackgroundTaskExpirationHandler? { get } + + /// Method to be called when a task reused one more time. Should only be valid for reusable tasks. + func reuse() + + /// Stop the background task. Cannot be started anymore. For reusable tasks, should be called same number of times `reuse` called. + func stop() +} diff --git a/ElementX/Sources/Services/Background/BackgroundTaskServiceProtocol.swift b/ElementX/Sources/Services/Background/BackgroundTaskServiceProtocol.swift new file mode 100644 index 000000000..76e1fde01 --- /dev/null +++ b/ElementX/Sources/Services/Background/BackgroundTaskServiceProtocol.swift @@ -0,0 +1,40 @@ +// +// BackgroundTaskServiceProtocol.swift +// ElementX +// +// Created by Ismail on 28.06.2022. +// Copyright © 2022 Element. All rights reserved. +// + +import Foundation + +protocol BackgroundTaskServiceProtocol { + + func startBackgroundTask(withName name: String, + isReusable: Bool, + expirationHandler: (() -> Void)?) -> BackgroundTaskProtocol? + +} + +extension BackgroundTaskServiceProtocol { + + func startBackgroundTask(withName name: String) -> BackgroundTaskProtocol? { + return startBackgroundTask(withName: name, + expirationHandler: nil) + } + + func startBackgroundTask(withName name: String, + isReusable: Bool) -> BackgroundTaskProtocol? { + return startBackgroundTask(withName: name, + isReusable: isReusable, + expirationHandler: nil) + } + + func startBackgroundTask(withName name: String, + expirationHandler: (() -> Void)?) -> BackgroundTaskProtocol? { + return startBackgroundTask(withName: name, + isReusable: false, + expirationHandler: expirationHandler) + } + +} diff --git a/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift b/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift new file mode 100644 index 000000000..89a05457b --- /dev/null +++ b/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift @@ -0,0 +1,95 @@ +// +// UIKitBackgroundTask.swift +// ElementX +// +// Created by Ismail on 28.06.2022. +// Copyright © 2022 Element. All rights reserved. +// + +import Foundation +import UIKit + +/// UIKitBackgroundTask is a concrete implementation of BackgroundTaskProtocol using UIApplication background task. +class UIKitBackgroundTask: BackgroundTaskProtocol { + let name: String + var isRunning: Bool { + identifier != .invalid + } + let isReusable: Bool + let expirationHandler: BackgroundTaskExpirationHandler? + var elapsedTime: TimeInterval { + return Date().timeIntervalSince(startDate) * 1000 + } + + private let application: ApplicationProtocol + private var identifier: UIBackgroundTaskIdentifier = .invalid + private var useCounter: Int = 0 + private let startDate: Date = Date() + + /// Initializes and starts a new background task + /// - Parameters: + /// - name: name + /// - isReusable: flag indicating the task is reusable + /// - application: application instance + /// - expirationHandler: expiration handler + init?(name: String, + isReusable: Bool, + application: ApplicationProtocol, + expirationHandler: BackgroundTaskExpirationHandler?) { + self.name = name + self.isReusable = isReusable + self.application = application + self.expirationHandler = expirationHandler + + // attempt to start + identifier = application.beginBackgroundTask(withName: name) { [weak self] in + guard let self = self else { return } + self.expirationHandler?(self) + } + + if identifier == .invalid { + MXLog.debug("[UIKitBackgroundTask] Do not start background task: \(name), as OS declined") + // call expiration handler immediately + expirationHandler?(self) + return nil + } + + if isReusable { + // creation itself is a use + reuse() + } + + MXLog.debug("[UIKitBackgroundTask] Start background task #\(identifier.rawValue) - \(name)") + } + + func reuse() { + guard isReusable else { + return + } + useCounter += 1 + } + + func stop() { + if isReusable { + useCounter -= 1 + if useCounter <= 0 { + endTask() + } + } else { + endTask() + } + } + + private func endTask() { + if identifier != .invalid { + MXLog.debug("[UIKitBackgroundTask] End background task #\(identifier.rawValue) - \(name) after \(readableElapsedTime)") + + application.endBackgroundTask(identifier) + identifier = .invalid + } + } + + private var readableElapsedTime: String { + String(format: "%.3fms", elapsedTime) + } +} diff --git a/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift b/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift new file mode 100644 index 000000000..b9e2069bc --- /dev/null +++ b/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift @@ -0,0 +1,123 @@ +// +// UIKitBackgroundTaskService.swift +// ElementX +// +// Created by Ismail on 28.06.2022. +// Copyright © 2022 Element. All rights reserved. +// + +import Foundation +import UIKit + +/// /// UIKitBackgroundTaskService is a concrete implementation of BackgroundTaskServiceProtocol using a given `ApplicationProtocol` instance. +class UIKitBackgroundTaskService: BackgroundTaskServiceProtocol { + + private let application: ApplicationProtocol? + private var reusableTasks: WeakDictionary = WeakDictionary() + + /// Initializer + /// - Parameter application: application instance to use. Defaults to `UIApplication.extensionSafeShared`. + init(withApplication application: ApplicationProtocol? = UIApplication.extensionSafeShared) { + self.application = application + } + + func startBackgroundTask(withName name: String, + isReusable: Bool, + expirationHandler: (() -> Void)?) -> BackgroundTaskProtocol? { + guard let application = application else { + MXLog.debug("[UIKitBackgroundTaskService] Do not start background task: \(name). Application is nil") + return nil + } + + if avoidStartingNewTasks(for: application) { + MXLog.debug("[UIKitBackgroundTaskService] Do not start background task: \(name), as not enough time exists") + // call expiration handler immediately + expirationHandler?() + return nil + } + var created: Bool = false + var result: BackgroundTaskProtocol? + + if isReusable { + if let oldTask = reusableTasks[name], oldTask.isRunning { + oldTask.reuse() + result = oldTask + } else { + if let newTask = UIKitBackgroundTask(name: name, + isReusable: isReusable, + application: application, + expirationHandler: { [weak self] task in + guard let self = self else { return } + self.reusableTasks[task.name] = nil + expirationHandler?() + }) { + created = true + reusableTasks[name] = newTask + result = newTask + } + } + } else { + if let newTask = UIKitBackgroundTask(name: name, + isReusable: isReusable, + application: application, + expirationHandler: { _ in + expirationHandler?() + }) { + result = newTask + created = true + } + } + + let appState = application.applicationState + let remainingTime = readableBackgroundTimeRemaining(application.backgroundTimeRemaining) + + MXLog.debug("[UIKitBackgroundTaskService] Background task \(name) \(created ? "started" : "reused") with app state: \(appState) and estimated background time remaining: \(remainingTime)") + + return result + } + + private func readableBackgroundTimeRemaining(_ backgroundTimeRemaining: TimeInterval) -> String { + if backgroundTimeRemaining == .greatestFiniteMagnitude { + return "undetermined" + } else { + return String(format: "%.0f seconds", backgroundTimeRemaining) + } + } + + private func avoidStartingNewTasks(for application: ApplicationProtocol) -> Bool { + if application.applicationState == .background + && application.backgroundTimeRemaining < .backgroundTimeRemainingThresholdToStartTasks { + return true + } + return false + } + +} + +private extension TimeInterval { + static let backgroundTimeRemainingThresholdToStartTasks: TimeInterval = 5 +} + +private extension UIApplication { + /// Application instance extension-safe. Will be `nil` on app extensions. + static var extensionSafeShared: UIApplication? { + let selector = NSSelectorFromString("sharedApplication") + guard Self.responds(to: selector) else { return nil } + return Self.perform(selector).takeUnretainedValue() as? UIApplication + } +} + +extension UIApplication.State: CustomStringConvertible { + public var description: String { + switch self { + case .active: + return "active" + case .inactive: + return "inactive" + case .background: + return "background" + @unknown default: + return "unknown" + } + } +} diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 275fb641c..40e7c67aa 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -26,6 +26,7 @@ private class WeakClientProxyWrapper: ClientDelegate { class ClientProxy: ClientProxyProtocol { private let client: Client + private let backgroundTaskService: BackgroundTaskServiceProtocol private(set) var rooms: [RoomProxy] = [] { didSet { @@ -39,8 +40,10 @@ class ClientProxy: ClientProxyProtocol { let callbacks = PassthroughSubject() - init(client: Client) { + init(client: Client, + backgroundTaskService: BackgroundTaskServiceProtocol) { self.client = client + self.backgroundTaskService = backgroundTaskService client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self)) @@ -119,7 +122,9 @@ class ClientProxy: ClientProxyProtocol { MXLog.error("Failed retrieving sdk room with id: \(id)") break } - currentRooms.append(RoomProxy(room: sdkRoom, roomMessageFactory: RoomMessageFactory())) + currentRooms.append(RoomProxy(room: sdkRoom, + roomMessageFactory: RoomMessageFactory(), + backgroundTaskService: backgroundTaskService)) case .remove(_, let id, _): currentRooms.removeAll { $0.id == id } } diff --git a/ElementX/Sources/Services/Media/MediaProvider.swift b/ElementX/Sources/Services/Media/MediaProvider.swift index 570643d66..988024981 100644 --- a/ElementX/Sources/Services/Media/MediaProvider.swift +++ b/ElementX/Sources/Services/Media/MediaProvider.swift @@ -12,11 +12,15 @@ import Kingfisher struct MediaProvider: MediaProviderProtocol { private let clientProxy: ClientProxyProtocol private let imageCache: Kingfisher.ImageCache + private let backgroundTaskService: BackgroundTaskServiceProtocol private let processingQueue: DispatchQueue - init(clientProxy: ClientProxyProtocol, imageCache: Kingfisher.ImageCache) { + init(clientProxy: ClientProxyProtocol, + imageCache: Kingfisher.ImageCache, + backgroundTaskService: BackgroundTaskServiceProtocol) { self.clientProxy = clientProxy self.imageCache = imageCache + self.backgroundTaskService = backgroundTaskService self.processingQueue = DispatchQueue(label: "MediaProviderProcessingQueue", attributes: .concurrent) } @@ -44,6 +48,11 @@ struct MediaProvider: MediaProviderProtocol { if let image = imageFromSource(source) { return .success(image) } + + let loadImageBgTask = backgroundTaskService.startBackgroundTask(withName: "LoadImage: \(source.underlyingSource.url().hashValue)") + defer { + loadImageBgTask?.stop() + } let cachedImageLoadResult = await withCheckedContinuation { continuation in imageCache.retrieveImage(forKey: source.underlyingSource.url()) { result in diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index cc483b21a..4c856d374 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -29,8 +29,10 @@ private class WeakRoomProxyWrapper: RoomDelegate { class RoomProxy: RoomProxyProtocol { private let room: Room private let roomMessageFactory: RoomMessageFactoryProtocol + private let backgroundTaskService: BackgroundTaskServiceProtocol private var backwardStream: BackwardsStreamProtocol? + private var sendMessageBgTask: BackgroundTaskProtocol? private(set) var displayName: String? @@ -38,9 +40,12 @@ class RoomProxy: RoomProxyProtocol { private(set) var messages: [RoomMessageProtocol] - init(room: Room, roomMessageFactory: RoomMessageFactoryProtocol) { + init(room: Room, + roomMessageFactory: RoomMessageFactoryProtocol, + backgroundTaskService: BackgroundTaskServiceProtocol) { self.room = room self.roomMessageFactory = roomMessageFactory + self.backgroundTaskService = backgroundTaskService messages = [] room.setDelegate(delegate: WeakRoomProxyWrapper(roomProxy: self)) @@ -157,6 +162,10 @@ class RoomProxy: RoomProxyProtocol { } func sendMessage(_ message: String) async -> Result { + sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true) + defer { + sendMessageBgTask?.stop() + } let messageContent = messageEventContentFromMarkdown(md: message) let transactionId = genTransactionId() diff --git a/ElementX/Sources/Services/UserSessionStore/UserSessionStore.swift b/ElementX/Sources/Services/UserSessionStore/UserSessionStore.swift index df26ccf94..942e7264b 100644 --- a/ElementX/Sources/Services/UserSessionStore/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSessionStore/UserSessionStore.swift @@ -21,12 +21,14 @@ import Kingfisher class UserSessionStore: UserSessionStoreProtocol { private let keychainController: KeychainControllerProtocol + private let backgroundTaskService: BackgroundTaskServiceProtocol /// Whether or not there are sessions in the store. var hasSessions: Bool { !keychainController.accessTokens().isEmpty } - init(bundleIdentifier: String) { + init(bundleIdentifier: String, backgroundTaskService: BackgroundTaskServiceProtocol) { keychainController = KeychainController(identifier: bundleIdentifier) + self.backgroundTaskService = backgroundTaskService } func restoreUserSession() async -> Result { @@ -39,7 +41,9 @@ class UserSessionStore: UserSessionStoreProtocol { switch await restorePreviousLogin(usernameTokenTuple) { case .success(let clientProxy): return .success(UserSession(clientProxy: clientProxy, - mediaProvider: MediaProvider(clientProxy: clientProxy, imageCache: ImageCache.default))) + mediaProvider: MediaProvider(clientProxy: clientProxy, + imageCache: ImageCache.default, + backgroundTaskService: backgroundTaskService))) case .failure(let error): MXLog.error("Failed restoring login with error: \(error)") @@ -55,7 +59,9 @@ class UserSessionStore: UserSessionStoreProtocol { switch await setupProxyForClient(client) { case .success(let clientProxy): return .success(UserSession(clientProxy: clientProxy, - mediaProvider: MediaProvider(clientProxy: clientProxy, imageCache: ImageCache.default))) + mediaProvider: MediaProvider(clientProxy: clientProxy, + imageCache: ImageCache.default, + backgroundTaskService: backgroundTaskService))) case .failure(let error): MXLog.error("Failed creating user session with error: \(error)") return .failure(error) @@ -99,7 +105,7 @@ class UserSessionStore: UserSessionStoreProtocol { return .failure(.failedSettingUpSession) } - let clientProxy = ClientProxy(client: client) + let clientProxy = ClientProxy(client: client, backgroundTaskService: backgroundTaskService) return .success(clientProxy) } diff --git a/UnitTests/Sources/BackgroundTaskTests.swift b/UnitTests/Sources/BackgroundTaskTests.swift new file mode 100644 index 000000000..58b02fe4e --- /dev/null +++ b/UnitTests/Sources/BackgroundTaskTests.swift @@ -0,0 +1,227 @@ +// +// BackgroundTaskTests.swift +// UnitTests +// +// Created by Ismail on 28.06.2022. +// Copyright © 2022 Element. All rights reserved. +// + +import XCTest + +@testable import ElementX + +class BackgroundTaskTests: XCTestCase { + + private enum Constants { + static let bgTaskName: String = "test" + } + + func testInAnExtension() { + let service = UIKitBackgroundTaskService(withApplication: nil) + let task = service.startBackgroundTask(withName: Constants.bgTaskName) + + XCTAssertNil(task, "Task should not be created") + } + + func testInitAndStop() { + let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + guard let task = service.startBackgroundTask(withName: Constants.bgTaskName) else { + XCTFail("Failed to setup test conditions") + return + } + + XCTAssertEqual(task.name, Constants.bgTaskName, "Task name should be persisted") + XCTAssertFalse(task.isReusable, "Task should be not reusable by default") + XCTAssertTrue(task.isRunning, "Task should be running") + + task.stop() + + XCTAssertFalse(task.isRunning, "Task should be stopped") + } + + func testNotReusableInit() { + let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + + // create two not reusable task with the same name + guard let task1 = service.startBackgroundTask(withName: Constants.bgTaskName), + let task2 = service.startBackgroundTask(withName: Constants.bgTaskName) else { + XCTFail("Failed to setup test conditions") + return + } + + // task1 & task2 should be different instances + XCTAssertFalse(task1 === task2, + "Handler should create different tasks when reusability disabled") + } + + func testReusableInit() { + let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + + // create two reusable task with the same name + guard let task1 = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true), + let task2 = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true) else { + XCTFail("Failed to setup test conditions") + return + } + + // task1 and task2 should be the same instance + XCTAssertTrue(task1 === task2, + "Handler should create different tasks when reusability disabled") + + XCTAssertEqual(task1.name, Constants.bgTaskName, "Task name should be persisted") + XCTAssertTrue(task1.isReusable, "Task should be reusable") + XCTAssertTrue(task1.isRunning, "Task should be running") + } + + func testMultipleStops() { + let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + + // create two reusable task with the same name + guard let task = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true), + service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true) != nil else { + XCTFail("Failed to setup test conditions") + return + } + + XCTAssertTrue(task.isRunning, "Task should be running") + + task.stop() + + XCTAssertTrue(task.isRunning, "Task should be still running after one stop call") + + task.stop() + + XCTAssertFalse(task.isRunning, "Task should be stopped after two stop calls") + } + + func testNotValidReuse() { + let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + + // create two reusable task with the same name + guard let task = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true) else { + XCTFail("Failed to setup test conditions") + return + } + + XCTAssertTrue(task.isRunning, "Task should be running") + + task.stop() + + XCTAssertFalse(task.isRunning, "Task should be stopped after stop") + + task.reuse() + + XCTAssertFalse(task.isRunning, "Task should be stopped after one stop call, even if reuse is called after") + } + + func testValidReuse() { + let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockHealty) + + // create two reusable task with the same name + guard let task = service.startBackgroundTask(withName: Constants.bgTaskName, isReusable: true) else { + XCTFail("Failed to setup test conditions") + return + } + + XCTAssertTrue(task.isRunning, "Task should be running") + + task.reuse() + + XCTAssertTrue(task.isRunning, "Task should be still running") + + task.stop() + + XCTAssertTrue(task.isRunning, "Task should be still running after one stop call") + + task.stop() + + XCTAssertFalse(task.isRunning, "Task should be stopped after two stop calls") + } + + func testBrokenApp() { + let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockBroken) + + // create two reusable task with the same name + let task = service.startBackgroundTask(withName: Constants.bgTaskName) + + XCTAssertNil(task, "Task should not be created") + } + + func testNoTimeApp() { + let service = UIKitBackgroundTaskService(withApplication: UIApplication.mockAboutToSuspend) + + // create two reusable task with the same name + let task = service.startBackgroundTask(withName: Constants.bgTaskName) + + XCTAssertNil(task, "Task should not be created") + } + +} + +private extension UIApplication { + + static var mockHealty: ApplicationProtocol { + MockApplication() + } + + static var mockBroken: ApplicationProtocol { + MockApplication(withState: .inactive, + backgroundTimeRemaining: 0, + allowTasks: false) + } + + static var mockAboutToSuspend: ApplicationProtocol { + MockApplication(withState: .background, + backgroundTimeRemaining: 2, + allowTasks: false) + } + +} + +private class MockApplication: ApplicationProtocol { + + let applicationState: UIApplication.State + let backgroundTimeRemaining: TimeInterval + private let allowTasks: Bool + + init(withState applicationState: UIApplication.State = .active, + backgroundTimeRemaining: TimeInterval = 10, + allowTasks: Bool = true) { + self.applicationState = applicationState + self.backgroundTimeRemaining = backgroundTimeRemaining + self.allowTasks = allowTasks + } + + private static var bgTaskIdentifier: Int = 0 + + private var bgTasks: [UIBackgroundTaskIdentifier: Bool] = [:] + + func beginBackgroundTask(expirationHandler handler: (() -> Void)?) -> UIBackgroundTaskIdentifier { + guard allowTasks else { + return .invalid + } + return beginBackgroundTask(withName: nil, expirationHandler: handler) + } + + func beginBackgroundTask(withName taskName: String?, expirationHandler handler: (() -> Void)?) -> UIBackgroundTaskIdentifier { + guard allowTasks else { + return .invalid + } + Self.bgTaskIdentifier += 1 + + let identifier = UIBackgroundTaskIdentifier(rawValue: Self.bgTaskIdentifier) + bgTasks[identifier] = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + handler?() + } + return identifier + } + + func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) { + guard allowTasks else { + return + } + bgTasks.removeValue(forKey: identifier) + } + +} diff --git a/changelog.d/99.feature b/changelog.d/99.feature new file mode 100644 index 000000000..80af975fe --- /dev/null +++ b/changelog.d/99.feature @@ -0,0 +1 @@ +Implement and use background tasks.