Background execution (#100)

* #99 Implement background tasks

* #99 Add changelog

* #99 Fix some code smells

* #99 Use background tasks in room timeline controller

* #99 Move background task service into room proxy and media provider
This commit is contained in:
ismailgulek
2022-06-29 13:03:28 +03:00
committed by GitHub
parent d74158ced1
commit dbee1924eb
13 changed files with 619 additions and 9 deletions
+5 -1
View File
@@ -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
@@ -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 {}
@@ -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()
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<String, UIKitBackgroundTask> = 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"
}
}
}
@@ -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<ClientProxyCallback, Never>()
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 }
}
@@ -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
+10 -1
View File
@@ -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<Void, RoomProxyError> {
sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
defer {
sendMessageBgTask?.stop()
}
let messageContent = messageEventContentFromMarkdown(md: message)
let transactionId = genTransactionId()
@@ -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<UserSession, UserSessionStoreError> {
@@ -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)
}