mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Add background app refresh support (#892)
* Add background app refresh support * Cleanup: move methods around so delegates are implemented above privates and privates are ordered better * Add discardableResult on the userSessionStore's refreshRestorationToken
This commit is contained in:
@@ -14,12 +14,13 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BackgroundTasks
|
||||||
import Combine
|
import Combine
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Version
|
import Version
|
||||||
|
|
||||||
class AppCoordinator: AppCoordinatorProtocol {
|
class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, NotificationManagerDelegate {
|
||||||
private let stateMachine: AppCoordinatorStateMachine
|
private let stateMachine: AppCoordinatorStateMachine
|
||||||
private let navigationRootCoordinator: NavigationRootCoordinator
|
private let navigationRootCoordinator: NavigationRootCoordinator
|
||||||
private let userSessionStore: UserSessionStoreProtocol
|
private let userSessionStore: UserSessionStoreProtocol
|
||||||
@@ -49,6 +50,7 @@ class AppCoordinator: AppCoordinatorProtocol {
|
|||||||
private var userSessionObserver: AnyCancellable?
|
private var userSessionObserver: AnyCancellable?
|
||||||
private var networkMonitorObserver: AnyCancellable?
|
private var networkMonitorObserver: AnyCancellable?
|
||||||
private var initialSyncObserver: AnyCancellable?
|
private var initialSyncObserver: AnyCancellable?
|
||||||
|
private var backgroundRefreshSyncObserver: AnyCancellable?
|
||||||
|
|
||||||
let notificationManager: NotificationManagerProtocol
|
let notificationManager: NotificationManagerProtocol
|
||||||
|
|
||||||
@@ -93,6 +95,8 @@ class AppCoordinator: AppCoordinatorProtocol {
|
|||||||
|
|
||||||
observeApplicationState()
|
observeApplicationState()
|
||||||
observeNetworkState()
|
observeNetworkState()
|
||||||
|
|
||||||
|
registerBackgroundAppRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -111,7 +115,63 @@ class AppCoordinator: AppCoordinatorProtocol {
|
|||||||
func toPresentable() -> AnyView {
|
func toPresentable() -> AnyView {
|
||||||
ServiceLocator.shared.userIndicatorController.toPresentable()
|
ServiceLocator.shared.userIndicatorController.toPresentable()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - AuthenticationCoordinatorDelegate
|
||||||
|
|
||||||
|
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
|
||||||
|
self.userSession = userSession
|
||||||
|
stateMachine.processEvent(.createdUserSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NotificationManagerDelegate
|
||||||
|
|
||||||
|
func authorizationStatusUpdated(_ service: NotificationManagerProtocol, granted: Bool) {
|
||||||
|
if granted {
|
||||||
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldDisplayInAppNotification(_ service: NotificationManagerProtocol, content: UNNotificationContent) -> Bool {
|
||||||
|
guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard let userSessionFlowCoordinator else {
|
||||||
|
// there is not a user session yet
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomId: roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationTapped(_ service: NotificationManagerProtocol, content: UNNotificationContent) async {
|
||||||
|
MXLog.info("[AppCoordinator] tappedNotification")
|
||||||
|
|
||||||
|
guard let roomID = content.roomID,
|
||||||
|
content.receiverID != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle here the account switching when available
|
||||||
|
|
||||||
|
handleAppRoute(.room(roomID: roomID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
|
||||||
|
MXLog.info("[AppCoordinator] handle notification reply")
|
||||||
|
|
||||||
|
guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let roomProxy = await userSession.clientProxy.roomForIdentifier(roomId)
|
||||||
|
switch await roomProxy?.sendMessage(replyText) {
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// error or no room proxy
|
||||||
|
await service.showLocalNotification(with: "⚠️ " + L10n.commonError,
|
||||||
|
subtitle: L10n.errorSomeMessagesHaveNotBeenSent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private static func setupServiceLocator(navigationRootCoordinator: NavigationRootCoordinator) {
|
private static func setupServiceLocator(navigationRootCoordinator: NavigationRootCoordinator) {
|
||||||
@@ -143,6 +203,7 @@ class AppCoordinator: AppCoordinatorProtocol {
|
|||||||
userSessionStore.reset()
|
userSessionStore.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next cyclomatic_complexity
|
||||||
private func setupStateMachine() {
|
private func setupStateMachine() {
|
||||||
stateMachine.addTransitionHandler { [weak self] context in
|
stateMachine.addTransitionHandler { [weak self] context in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -349,89 +410,6 @@ class AppCoordinator: AppCoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Toasts and loading indicators
|
|
||||||
|
|
||||||
private static let loadingIndicatorIdentifier = "AppCoordinatorLoading"
|
|
||||||
|
|
||||||
private func showLoadingIndicator() {
|
|
||||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
|
|
||||||
type: .modal,
|
|
||||||
title: L10n.commonLoading,
|
|
||||||
persistent: true))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hideLoadingIndicator() {
|
|
||||||
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showLoginErrorToast() {
|
|
||||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: "Failed logging in"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Application State
|
|
||||||
|
|
||||||
private func stopSync() {
|
|
||||||
userSession?.clientProxy.stopSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startSync() {
|
|
||||||
userSession?.clientProxy.startSync()
|
|
||||||
|
|
||||||
let identifier = "StaleDataIndicator"
|
|
||||||
|
|
||||||
ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: identifier, type: .toast, title: L10n.commonLoading, persistent: true))
|
|
||||||
|
|
||||||
initialSyncObserver = userSession.clientProxy
|
|
||||||
.callbacks
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] callback in
|
|
||||||
if case .receivedSyncUpdate = callback {
|
|
||||||
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(identifier)
|
|
||||||
self?.initialSyncObserver?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeApplicationState() {
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(applicationWillResignActive),
|
|
||||||
name: UIApplication.willResignActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(applicationDidBecomeActive),
|
|
||||||
name: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func applicationWillResignActive() {
|
|
||||||
MXLog.info("Application will resign active")
|
|
||||||
|
|
||||||
guard backgroundTask == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
backgroundTask = backgroundTaskService.startBackgroundTask(withName: "SuspendApp: \(UUID().uuidString)") { [weak self] in
|
|
||||||
self?.stopSync()
|
|
||||||
|
|
||||||
self?.backgroundTask = nil
|
|
||||||
self?.isSuspended = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func applicationDidBecomeActive() {
|
|
||||||
MXLog.info("Application did become active")
|
|
||||||
|
|
||||||
backgroundTask?.stop()
|
|
||||||
backgroundTask = nil
|
|
||||||
|
|
||||||
if isSuspended {
|
|
||||||
isSuspended = false
|
|
||||||
startSync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeNetworkState() {
|
private func observeNetworkState() {
|
||||||
let reachabilityNotificationIdentifier = "io.element.elementx.reachability.notification"
|
let reachabilityNotificationIdentifier = "io.element.elementx.reachability.notification"
|
||||||
networkMonitorObserver = ServiceLocator.shared.networkMonitor.reachabilityPublisher.sink { reachable in
|
networkMonitorObserver = ServiceLocator.shared.networkMonitor.reachabilityPublisher.sink { reachable in
|
||||||
@@ -474,64 +452,149 @@ class AppCoordinator: AppCoordinatorProtocol {
|
|||||||
hideLoadingIndicator()
|
hideLoadingIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// MARK: Toasts and loading indicators
|
||||||
// MARK: - AuthenticationCoordinatorDelegate
|
|
||||||
|
private static let loadingIndicatorIdentifier = "AppCoordinatorLoading"
|
||||||
extension AppCoordinator: AuthenticationCoordinatorDelegate {
|
|
||||||
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
|
private func showLoadingIndicator() {
|
||||||
self.userSession = userSession
|
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
|
||||||
stateMachine.processEvent(.createdUserSession)
|
type: .modal,
|
||||||
|
title: L10n.commonLoading,
|
||||||
|
persistent: true))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private func hideLoadingIndicator() {
|
||||||
// MARK: - NotificationManagerDelegate
|
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
|
||||||
|
}
|
||||||
extension AppCoordinator: NotificationManagerDelegate {
|
|
||||||
func authorizationStatusUpdated(_ service: NotificationManagerProtocol, granted: Bool) {
|
private func showLoginErrorToast() {
|
||||||
if granted {
|
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: "Failed logging in"))
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldDisplayInAppNotification(_ service: NotificationManagerProtocol, content: UNNotificationContent) -> Bool {
|
// MARK: - Application State
|
||||||
guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else {
|
|
||||||
return true
|
private func stopSync() {
|
||||||
}
|
userSession?.clientProxy.stopSync()
|
||||||
guard let userSessionFlowCoordinator else {
|
|
||||||
// there is not a user session yet
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomId: roomId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func notificationTapped(_ service: NotificationManagerProtocol, content: UNNotificationContent) async {
|
private func startSync() {
|
||||||
MXLog.info("[AppCoordinator] tappedNotification")
|
userSession?.clientProxy.startSync()
|
||||||
|
|
||||||
|
let identifier = "StaleDataIndicator"
|
||||||
|
|
||||||
|
ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: identifier, type: .toast, title: L10n.commonLoading, persistent: true))
|
||||||
|
|
||||||
|
initialSyncObserver = userSession.clientProxy
|
||||||
|
.callbacks
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.filter(\.isSyncUpdate)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(identifier)
|
||||||
|
self?.initialSyncObserver?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard let roomID = content.roomID,
|
private func observeApplicationState() {
|
||||||
content.receiverID != nil else {
|
NotificationCenter.default.addObserver(self,
|
||||||
|
selector: #selector(applicationWillResignActive),
|
||||||
|
name: UIApplication.willResignActiveNotification,
|
||||||
|
object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self,
|
||||||
|
selector: #selector(applicationDidBecomeActive),
|
||||||
|
name: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func applicationWillResignActive() {
|
||||||
|
MXLog.info("Application will resign active")
|
||||||
|
|
||||||
|
guard backgroundTask == nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle here the account switching when available
|
backgroundTask = backgroundTaskService.startBackgroundTask(withName: "SuspendApp: \(UUID().uuidString)") { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
handleAppRoute(.room(roomID: roomID))
|
|
||||||
|
stopSync()
|
||||||
|
|
||||||
|
backgroundTask = nil
|
||||||
|
isSuspended = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// This does seem to work if scheduled from the background task above
|
||||||
|
// Schedule it here instead but with an earliest being date of 30 seconds
|
||||||
|
scheduleBackgroundAppRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
|
@objc
|
||||||
MXLog.info("[AppCoordinator] handle notification reply")
|
private func applicationDidBecomeActive() {
|
||||||
|
MXLog.info("Application did become active")
|
||||||
|
|
||||||
|
backgroundTask?.stop()
|
||||||
|
backgroundTask = nil
|
||||||
|
|
||||||
guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else {
|
if isSuspended {
|
||||||
return
|
isSuspended = false
|
||||||
|
startSync()
|
||||||
}
|
}
|
||||||
let roomProxy = await userSession.clientProxy.roomForIdentifier(roomId)
|
}
|
||||||
switch await roomProxy?.sendMessage(replyText) {
|
|
||||||
case .success:
|
// MARK: Background app refresh
|
||||||
break
|
|
||||||
default:
|
private func registerBackgroundAppRefresh() {
|
||||||
// error or no room proxy
|
let result = BGTaskScheduler.shared.register(forTaskWithIdentifier: ServiceLocator.shared.settings.backgroundAppRefreshTaskIdentifier, using: .main) { [weak self] task in
|
||||||
await service.showLocalNotification(with: "⚠️ " + L10n.commonError,
|
guard let task = task as? BGAppRefreshTask else {
|
||||||
subtitle: L10n.errorSomeMessagesHaveNotBeenSent)
|
MXLog.error("Invalid background app refresh configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.handleBackgroundAppRefresh(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MXLog.info("Register background app refresh with result: \(result)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleBackgroundAppRefresh() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: ServiceLocator.shared.settings.backgroundAppRefreshTaskIdentifier)
|
||||||
|
|
||||||
|
// We have other background tasks that keep the app alive
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 30)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
MXLog.info("Successfully scheduled background app refresh task")
|
||||||
|
} catch {
|
||||||
|
MXLog.error("Failed scheduling background app refresh with error :\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBackgroundAppRefresh(_ task: BGAppRefreshTask) {
|
||||||
|
MXLog.info("Started background app refresh")
|
||||||
|
|
||||||
|
// This is important for the app to keep refreshing in the background
|
||||||
|
scheduleBackgroundAppRefresh()
|
||||||
|
|
||||||
|
task.expirationHandler = { [weak self] in
|
||||||
|
MXLog.info("Background app refresh task expired")
|
||||||
|
self?.stopSync()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
startSync()
|
||||||
|
|
||||||
|
// Be a good citizen, run for a max of 10 SS responses or 10 seconds
|
||||||
|
// An SS request will time out after 30 seconds if no new data is available
|
||||||
|
backgroundRefreshSyncObserver = userSession?.clientProxy
|
||||||
|
.callbacks
|
||||||
|
.filter(\.isSyncUpdate)
|
||||||
|
.collect(.byTimeOrCount(DispatchQueue.main, .seconds(10), 10))
|
||||||
|
.sink(receiveValue: { [weak self] _ in
|
||||||
|
MXLog.info("Background app refresh finished")
|
||||||
|
self?.backgroundRefreshSyncObserver?.cancel()
|
||||||
|
self?.stopSync()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ final class AppSettings {
|
|||||||
/// that don't yet have an officially trusted proxy configured in their well-known.
|
/// that don't yet have an officially trusted proxy configured in their well-known.
|
||||||
let slidingSyncProxyURL: URL? = nil
|
let slidingSyncProxyURL: URL? = nil
|
||||||
|
|
||||||
|
/// The task identifier used for background app refresh. Also used in main target's the Info.plist
|
||||||
|
let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh"
|
||||||
|
|
||||||
// MARK: - Authentication
|
// MARK: - Authentication
|
||||||
|
|
||||||
/// The URL that is opened when tapping the Learn more button on the sliding sync alert during authentication.
|
/// The URL that is opened when tapping the Learn more button on the sliding sync alert during authentication.
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ enum ClientProxyCallback {
|
|||||||
case receivedAuthError(isSoftLogout: Bool)
|
case receivedAuthError(isSoftLogout: Bool)
|
||||||
case receivedNotification(NotificationItemProxyProtocol)
|
case receivedNotification(NotificationItemProxyProtocol)
|
||||||
case updateRestorationToken
|
case updateRestorationToken
|
||||||
|
|
||||||
|
var isSyncUpdate: Bool {
|
||||||
|
if case .receivedSyncUpdate = self {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ClientProxyError: Error {
|
enum ClientProxyError: Error {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ protocol UserSessionStoreProtocol {
|
|||||||
func userSession(for client: Client) async -> Result<UserSessionProtocol, UserSessionStoreError>
|
func userSession(for client: Client) async -> Result<UserSessionProtocol, UserSessionStoreError>
|
||||||
|
|
||||||
/// Refresh the restore token of the client for a given session.
|
/// Refresh the restore token of the client for a given session.
|
||||||
|
@discardableResult
|
||||||
func refreshRestorationToken(for userSession: UserSessionProtocol) -> Result<Void, UserSessionStoreError>
|
func refreshRestorationToken(for userSession: UserSessionProtocol) -> Result<Void, UserSessionStoreError>
|
||||||
|
|
||||||
/// Logs out of the specified session.
|
/// Logs out of the specified session.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>io.element.elementx.background.refresh</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -30,6 +34,10 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>INSendMessageIntent</string>
|
<string>INSendMessageIntent</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ targets:
|
|||||||
]
|
]
|
||||||
NSCameraUsageDescription: The camera is used to take and upload photos and videos.
|
NSCameraUsageDescription: The camera is used to take and upload photos and videos.
|
||||||
NSMicrophoneUsageDescription: The microphone is used to take videos.
|
NSMicrophoneUsageDescription: The microphone is used to take videos.
|
||||||
|
UIBackgroundModes: [
|
||||||
|
fetch
|
||||||
|
]
|
||||||
|
BGTaskSchedulerPermittedIdentifiers: [
|
||||||
|
io.element.elementx.background.refresh
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Add background app refresh support
|
||||||
Reference in New Issue
Block a user