session-ios/Session/Meta/AppDelegate.swift

900 lines
42 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import UserNotifications
import GRDB
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
import SignalCoreKit
import SessionSnodeKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 10
var window: UIWindow?
var backgroundSnapshotBlockerWindow: UIWindow?
var appStartupWindow: UIWindow?
var initialLaunchFailed: Bool = false
var hasInitialRootViewController: Bool = false
var startTime: CFTimeInterval = 0
private var loadingViewController: LoadingViewController?
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
lazy var poller: CurrentUserPoller = CurrentUserPoller()
// MARK: - Lifecycle
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
startTime = CACurrentMediaTime()
// These should be the first things we do (the startup process can fail without them)
SetCurrentAppContext(MainAppContext())
verifyDBKeysAvailableBeforeBackgroundLaunch()
Cryptography.seedRandom()
AppVersion.sharedInstance()
AppEnvironment.shared.pushRegistrationManager.createVoipRegistryIfNecessary()
// Prevent the device from sleeping during database view async registration
// (e.g. long database upgrades).
//
// This block will be cleared in storageIsReady.
DeviceSleepManager.sharedInstance.addBlock(blockObject: self)
let mainWindow: UIWindow = TraitObservingWindow(frame: UIScreen.main.bounds)
self.loadingViewController = LoadingViewController()
AppSetup.setupEnvironment(
appSpecificBlock: {
// Create AppEnvironment
AppEnvironment.shared.setup()
// Note: Intentionally dispatching sync as we want to wait for these to complete before
// continuing
DispatchQueue.main.sync {
ScreenLockUI.shared.setupWithRootWindow(rootWindow: mainWindow)
OWSWindowManager.shared().setup(
withRootWindow: mainWindow,
screenBlockingWindow: ScreenLockUI.shared.screenBlockingWindow
)
ScreenLockUI.shared.startObserving()
}
},
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] result, needsConfigSync in
if case .failure(let error) = result {
DispatchQueue.main.async {
self?.initialLaunchFailed = true
self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .databaseError(error))
}
return
}
/// Store a weak reference in the ThemeManager so it can properly apply themes as needed
///
/// **Note:** Need to do this after the db migrations because theme preferences are stored in the database and
/// we don't want to access it until after the migrations run
ThemeManager.mainWindow = mainWindow
self?.completePostMigrationSetup(calledFrom: .finishLaunching, needsConfigSync: needsConfigSync)
}
)
if Environment.shared?.callManager.wrappedValue?.currentCall == nil {
UserDefaults.sharedLokiProject?[.isCallOngoing] = false
UserDefaults.sharedLokiProject?[.lastCallPreOffer] = nil
}
// No point continuing if we are running tests
guard !SNUtilitiesKit.isRunningTests else { return true }
self.window = mainWindow
CurrentAppContext().mainWindow = mainWindow
// Show LoadingViewController until the async database view registrations are complete.
mainWindow.rootViewController = self.loadingViewController
mainWindow.makeKeyAndVisible()
// This must happen in appDidFinishLaunching or earlier to ensure we don't
// miss notifications.
// Setting the delegate also seems to prevent us from getting the legacy notification
// notification callbacks upon launch e.g. 'didReceiveLocalNotification'
UNUserNotificationCenter.current().delegate = self
NotificationCenter.default.addObserver(
self,
selector: #selector(registrationStateDidChange),
name: .registrationStateDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(showMissedCallTipsIfNeeded(_:)),
name: .missedCall,
object: nil
)
Logger.info("application: didFinishLaunchingWithOptions completed.")
return true
}
func applicationWillEnterForeground(_ application: UIApplication) {
/// **Note:** We _shouldn't_ need to call this here but for some reason the OS doesn't seems to
/// be calling the `userNotificationCenter(_:,didReceive:withCompletionHandler:)`
/// method when the device is locked while the app is in the foreground (or if the user returns to the
/// springboard without swapping to another app) - adding this here in addition to the one in
/// `appDidFinishLaunching` seems to fix this odd behaviour (even though it doesn't match
/// Apple's documentation on the matter)
UNUserNotificationCenter.current().delegate = self
Storage.resumeDatabaseAccess()
// Reset the 'startTime' (since it would be invalid from the last launch)
startTime = CACurrentMediaTime()
// If we've already completed migrations at least once this launch then check
// to see if any "delayed" migrations now need to run
if Storage.shared.hasCompletedMigrations {
SNLog("Checking for pending migrations")
let initialLaunchFailed: Bool = self.initialLaunchFailed
AppReadiness.invalidate()
// If the user went to the background too quickly then the database can be suspended before
// properly starting up, in this case an alert will be shown but we can recover from it so
// dismiss any alerts that were shown
if initialLaunchFailed {
self.window?.rootViewController?.dismiss(animated: false)
}
// Dispatch async so things can continue to be progressed if a migration does need to run
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { result, needsConfigSync in
if case .failure(let error) = result {
DispatchQueue.main.async {
self?.showFailedStartupAlert(
calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed),
error: .databaseError(error)
)
}
return
}
self?.completePostMigrationSetup(
calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed),
needsConfigSync: needsConfigSync
)
}
)
}
}
}
func applicationDidEnterBackground(_ application: UIApplication) {
if !hasInitialRootViewController { SNLog("Entered background before startup was completed") }
DDLog.flushLog()
// NOTE: Fix an edge case where user taps on the callkit notification
// but answers the call on another device
stopPollers(shouldStopUserPoller: !self.hasCallOngoing())
// Stop all jobs except for message sending and when completed suspend the database
JobRunner.stopAndClearPendingJobs(exceptForVariant: .messageSend) {
if !self.hasCallOngoing() {
Storage.suspendDatabaseAccess()
}
}
}
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
Logger.info("applicationDidReceiveMemoryWarning")
}
func applicationWillTerminate(_ application: UIApplication) {
DDLog.flushLog()
stopPollers()
}
func applicationDidBecomeActive(_ application: UIApplication) {
guard !SNUtilitiesKit.isRunningTests else { return }
UserDefaults.sharedLokiProject?[.isMainAppActive] = true
ensureRootViewController(calledFrom: .didBecomeActive)
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
self?.handleActivation()
/// Clear all notifications whenever we become active once the app is ready
///
/// **Note:** It looks like when opening the app from a notification, `userNotificationCenter(didReceive)` is
/// no longer always called before `applicationDidBecomeActive` we need to trigger the "clear notifications" logic
/// within the `runNowOrWhenAppDidBecomeReady` callback and dispatch to the next run loop to ensure it runs after
/// the notification has actually been handled
DispatchQueue.main.async { [weak self] in
self?.clearAllNotificationsAndRestoreBadgeCount()
}
}
// On every activation, clear old temp directories.
ClearOldTemporaryDirectories()
}
func applicationWillResignActive(_ application: UIApplication) {
clearAllNotificationsAndRestoreBadgeCount()
UserDefaults.sharedLokiProject?[.isMainAppActive] = false
DDLog.flushLog()
}
// MARK: - Orientation
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
if UIDevice.current.isIPad {
return .all
}
return .portrait
}
// MARK: - Background Fetching
func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
Storage.resumeDatabaseAccess()
// Background tasks only last for a certain amount of time (which can result in a crash and a
// prompt appearing for the user), we want to avoid this and need to make sure to suspend the
// database again before the background task ends so we start a timer that expires 1 second
// before the background task is due to expire in order to do so
let cancelTimer: Timer = Timer.scheduledTimerOnMainThread(
withTimeInterval: (application.backgroundTimeRemaining - 1),
repeats: false
) { timer in
timer.invalidate()
guard BackgroundPoller.isValid else { return }
BackgroundPoller.isValid = false
if CurrentAppContext().isInBackground() {
Storage.suspendDatabaseAccess()
}
SNLog("Background poll failed due to manual timeout")
completionHandler(.failed)
}
// Flag the background poller as valid first and then trigger it to poll once the app is
// ready (we do this here rather than in `BackgroundPoller.poll` to avoid the rare edge-case
// that could happen when the timeout triggers before the app becomes ready which would have
// incorrectly set this 'isValid' flag to true after it should have timed out)
BackgroundPoller.isValid = true
AppReadiness.runNowOrWhenAppDidBecomeReady {
// If the 'AppReadiness' process takes too long then it's possible for the user to open
// the app after this closure is registered but before it's actually triggered - this can
// result in the `BackgroundPoller` incorrectly getting called in the foreground, this check
// is here to prevent that
guard CurrentAppContext().isInBackground() else { return }
BackgroundPoller.poll { result in
guard BackgroundPoller.isValid else { return }
BackgroundPoller.isValid = false
if CurrentAppContext().isInBackground() {
Storage.suspendDatabaseAccess()
}
cancelTimer.invalidate()
completionHandler(result)
}
}
}
// MARK: - App Readiness
private func completePostMigrationSetup(calledFrom lifecycleMethod: LifecycleMethod, needsConfigSync: Bool) {
SNLog("Migrations completed, performing setup and ensuring rootViewController")
Configuration.performMainSetup()
JobRunner.setExecutor(SyncPushTokensJob.self, for: .syncPushTokens)
// Setup the UI if needed, then trigger any post-UI setup actions
self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self] success in
// If we didn't successfully ensure the rootViewController then don't continue as
// the user is in an invalid state (and should have already been shown a modal)
guard success else { return }
SNLog("RootViewController ready for state: \(Onboarding.State.current), readying remaining processes")
self?.initialLaunchFailed = false
/// Trigger any launch-specific jobs and start the JobRunner with `JobRunner.appDidFinishLaunching()` some
/// of these jobs (eg. DisappearingMessages job) can impact the interactions which get fetched to display on the home
/// screen, if the PagedDatabaseObserver hasn't been setup yet then the home screen can show stale (ie. deleted)
/// interactions incorrectly
if lifecycleMethod == .finishLaunching {
JobRunner.appDidFinishLaunching()
}
/// Flag that the app is ready via `AppReadiness.setAppIsReady()`
///
/// If we are launching the app from a push notification we need to ensure we wait until after the `HomeVC` is setup
/// otherwise it won't open the related thread
///
/// **Note:** This this does much more than set a flag - it will also run all deferred blocks (including the JobRunner
/// `appDidBecomeActive` method hence why it **must** also come after calling
/// `JobRunner.appDidFinishLaunching()`)
AppReadiness.setAppIsReady()
/// Remove the sleep blocking once the startup is done (needs to run on the main thread and sleeping while
/// doing the startup could suspend the database causing errors/crashes
DeviceSleepManager.sharedInstance.removeBlock(blockObject: self)
/// App launch hasn't really completed until the main screen is loaded so wait until then to register it
AppVersion.sharedInstance().mainAppLaunchDidComplete()
/// App won't be ready for extensions and no need to enqueue a config sync unless we successfully completed startup
Storage.shared.writeAsync { db in
// Increment the launch count (guaranteed to change which results in the write actually
// doing something and outputting and error if the DB is suspended)
db[.activeCounter] = ((db[.activeCounter] ?? 0) + 1)
// Disable the SAE until the main app has successfully completed launch process
// at least once in the post-SAE world.
db[.isReadyForAppExtensions] = true
if Identity.userCompletedRequiredOnboarding(db) {
let appVersion: AppVersion = AppVersion.sharedInstance()
// If the device needs to sync config or the user updated to a new version
if
needsConfigSync || (
(appVersion.lastAppVersion?.count ?? 0) > 0 &&
appVersion.lastAppVersion != appVersion.currentAppVersion
)
{
ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
}
}
}
// Add a log to track the proper startup time of the app so we know whether we need to
// improve it in the future from user logs
let endTime: CFTimeInterval = CACurrentMediaTime()
SNLog("\(lifecycleMethod.timingName) completed in \((self?.startTime).map { ceil((endTime - $0) * 1000) } ?? -1)ms")
}
// May as well run these on the background thread
Environment.shared?.audioSession.setup()
Environment.shared?.reachabilityManager.setup()
}
private func showFailedStartupAlert(
calledFrom lifecycleMethod: LifecycleMethod,
error: StartupError,
animated: Bool = true,
presentationCompletion: (() -> ())? = nil
) {
/// This **must** be a standard `UIAlertController` instead of a `ConfirmationModal` because we may not
/// have access to the database when displaying this so can't extract theme information for styling purposes
let alert: UIAlertController = UIAlertController(
title: "Session",
message: error.message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in
HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in
// Don't bother showing the "Failed Startup" modal again if we happen to now
// have an initial view controller (this most likely means that the startup
// completed while the user was sharing logs so we can just let the user use
// the app)
guard self?.hasInitialRootViewController == false else { return }
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: error)
}
})
switch error {
// Don't offer the 'Restore' option if it was a 'startupFailed' error as a restore is unlikely to
// resolve it (most likely the database is locked or the key was somehow lost - safer to get them
// to restart and manually reinstall/restore)
case .databaseError(StorageError.startupFailed), .databaseError(DatabaseError.SQLITE_LOCKED): break
// Offer the 'Restore' option if it was a migration error
case .databaseError:
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
if SUKLegacy.hasLegacyDatabaseFile {
// Remove the legacy database and any message hashes that have been migrated to the new DB
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
Storage.shared.write { db in
try SnodeReceivedMessageInfo.deleteAll(db)
}
}
else {
// If we don't have a legacy database then reset the current database for a clean migration
Storage.resetForCleanMigration()
}
// Hide the top banner if there was one
TopBannerController.hide()
// The re-run the migration (should succeed since there is no data)
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] result, needsConfigSync in
switch result {
case .failure:
DispatchQueue.main.async {
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore)
}
case .success:
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
}
}
)
})
default: break
}
alert.addAction(UIAlertAction(title: "APP_STARTUP_EXIT".localized(), style: .default) { _ in
DDLog.flushLog()
exit(0)
})
SNLog("Showing startup alert due to error: \(error.name)")
self.window?.rootViewController?.present(alert, animated: animated, completion: presentationCompletion)
}
/// The user must unlock the device once after reboot before the database encryption key can be accessed.
private func verifyDBKeysAvailableBeforeBackgroundLaunch() {
guard UIApplication.shared.applicationState == .background else { return }
guard !Storage.isDatabasePasswordAccessible else { return } // All good
Logger.info("Exiting because we are in the background and the database password is not accessible.")
let notificationContent: UNMutableNotificationContent = UNMutableNotificationContent()
notificationContent.body = String(
format: NSLocalizedString("NOTIFICATION_BODY_PHONE_LOCKED_FORMAT", comment: ""),
UIDevice.current.localizedModel
)
let notificationRequest: UNNotificationRequest = UNNotificationRequest(
identifier: UUID().uuidString,
content: notificationContent,
trigger: nil
)
// Make sure we clear any existing notifications so that they don't start stacking up
// if the user receives multiple pushes.
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
UIApplication.shared.applicationIconBadgeNumber = 0
UNUserNotificationCenter.current().add(notificationRequest, withCompletionHandler: nil)
UIApplication.shared.applicationIconBadgeNumber = 1
DDLog.flushLog()
exit(0)
}
private func enableBackgroundRefreshIfNecessary() {
AppReadiness.runNowOrWhenAppDidBecomeReady {
UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
}
}
private func handleActivation() {
/// There is a _fun_ behaviour here where if the user launches the app, sends it to the background at the right time and then
/// opens it again the `AppReadiness` closures can be triggered before `applicationDidBecomeActive` has been
/// called again - this can result in odd behaviours so hold off on running this logic until it's properly called again
guard
Identity.userExists() &&
UserDefaults.sharedLokiProject?[.isMainAppActive] == true
else { return }
enableBackgroundRefreshIfNecessary()
JobRunner.appDidBecomeActive()
startPollersIfNeeded()
if CurrentAppContext().isMainApp {
handleAppActivatedWithOngoingCallIfNeeded()
}
}
private func ensureRootViewController(
calledFrom lifecycleMethod: LifecycleMethod,
onComplete: @escaping ((Bool) -> ()) = { _ in }
) {
let hasInitialRootViewController: Bool = self.hasInitialRootViewController
// Always call the completion block and indicate whether we successfully created the UI
guard
Storage.shared.isValid &&
(
AppReadiness.isAppReady() ||
lifecycleMethod == .finishLaunching ||
lifecycleMethod == .enterForeground(initialLaunchFailed: true)
) &&
!hasInitialRootViewController
else { return DispatchQueue.main.async { onComplete(hasInitialRootViewController) } }
/// Start a timeout for the creation of the rootViewController setup process (if it takes too long then we want to give the user
/// the option to export their logs)
let populateHomeScreenTimer: Timer = Timer.scheduledTimerOnMainThread(
withTimeInterval: AppDelegate.maxRootViewControllerInitialQueryDuration,
repeats: false
) { [weak self] timer in
timer.invalidate()
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout)
}
// All logic which needs to run after the 'rootViewController' is created
let rootViewControllerSetupComplete: (UIViewController) -> () = { [weak self] rootViewController in
let presentedViewController: UIViewController? = self?.window?.rootViewController?.presentedViewController
let targetRootViewController: UIViewController = TopBannerController(
child: StyledNavigationController(rootViewController: rootViewController),
cachedWarning: UserDefaults.sharedLokiProject?[.topBannerWarningToShow]
.map { rawValue in TopBannerController.Warning(rawValue: rawValue) }
)
/// Insert the `targetRootViewController` below the current view and trigger a layout without animation before properly
/// swapping the `rootViewController` over so we can avoid any weird initial layout behaviours
UIView.performWithoutAnimation {
self?.window?.rootViewController = targetRootViewController
}
self?.hasInitialRootViewController = true
UIViewController.attemptRotationToDeviceOrientation()
/// **Note:** There is an annoying case when starting the app by interacting with a push notification where
/// the `HomeVC` won't have completed loading it's view which means the `SessionApp.homeViewController`
/// won't have been set - we set the value directly here to resolve this edge case
if let homeViewController: HomeVC = rootViewController as? HomeVC {
SessionApp.homeViewController.mutate { $0 = homeViewController }
}
/// If we were previously presenting a viewController but are no longer preseting it then present it again
///
/// **Note:** Looks like the OS will throw an exception if we try to present a screen which is already (or
/// was previously?) presented, even if it's not attached to the screen it seems...
switch presentedViewController {
case is UIAlertController, is ConfirmationModal:
/// If the viewController we were presenting happened to be the "failed startup" modal then we can dismiss it
/// automatically (while this seems redundant it's less jarring for the user than just instantly having it disappear)
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout, animated: false) {
self?.window?.rootViewController?.dismiss(animated: true)
}
case is UIActivityViewController: HelpViewModel.shareLogs(animated: false)
default: break
}
// Setup is completed so run any post-setup tasks
onComplete(true)
}
// Navigate to the approriate screen depending on the onboarding state
switch Onboarding.State.current {
case .newUser:
DispatchQueue.main.async {
let viewController: LandingVC = LandingVC()
populateHomeScreenTimer.invalidate()
rootViewControllerSetupComplete(viewController)
}
case .missingName:
DispatchQueue.main.async {
let viewController: DisplayNameVC = DisplayNameVC(flow: .register)
populateHomeScreenTimer.invalidate()
rootViewControllerSetupComplete(viewController)
}
case .completed:
DispatchQueue.main.async {
let viewController: HomeVC = HomeVC()
/// We want to start observing the changes for the 'HomeVC' and want to wait until we actually get data back before we
/// continue as we don't want to show a blank home screen
DispatchQueue.global(qos: .userInitiated).async {
viewController.startObservingChanges() {
populateHomeScreenTimer.invalidate()
DispatchQueue.main.async {
rootViewControllerSetupComplete(viewController)
}
}
}
}
}
}
// MARK: - Notifications
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
PushRegistrationManager.shared.didReceiveVanillaPushToken(deviceToken)
Logger.info("Registering for push notifications with token: \(deviceToken).")
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
Logger.error("Failed to register push token with error: \(error).")
#if DEBUG
Logger.warn("We're in debug mode. Faking success for remote registration with a fake push identifier.")
PushRegistrationManager.shared.didReceiveVanillaPushToken(Data(count: 32))
#else
PushRegistrationManager.shared.didFailToReceiveVanillaPushToken(error: error)
#endif
}
private func clearAllNotificationsAndRestoreBadgeCount() {
AppReadiness.runNowOrWhenAppDidBecomeReady {
AppEnvironment.shared.notificationPresenter.clearAllNotifications()
guard CurrentAppContext().isMainApp else { return }
/// On application startup the `Storage.read` can be slightly slow while GRDB spins up it's database
/// read pools (up to a few seconds), since this read is blocking we want to dispatch it to run async to ensure
/// we don't block user interaction while it's running
DispatchQueue.global(qos: .default).async {
let unreadCount: Int = Storage.shared
.read { db in try Interaction.fetchUnreadCount(db) }
.defaulting(to: 0)
DispatchQueue.main.async {
UIApplication.shared.applicationIconBadgeNumber = unreadCount
}
}
}
}
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
AppReadiness.runNowOrWhenAppDidBecomeReady {
guard Identity.userCompletedRequiredOnboarding() else { return }
SessionApp.homeViewController.wrappedValue?.createNewConversation()
completionHandler(true)
}
}
/// The method will be called on the delegate only if the application is in the foreground. If the method is not implemented or the
/// handler is not called in a timely manner then the notification will not be presented. The application can choose to have the
/// notification presented as a sound, badge, alert and/or in the notification list.
///
/// This decision should be based on whether the information in the notification is otherwise visible to the user.
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
if notification.request.content.userInfo["remote"] != nil {
Logger.info("[Loki] Ignoring remote notifications while the app is in the foreground.")
return
}
AppReadiness.runNowOrWhenAppDidBecomeReady {
// We need to respect the in-app notification sound preference. This method, which is called
// for modern UNUserNotification users, could be a place to do that, but since we'd still
// need to handle this behavior for legacy UINotification users anyway, we "allow" all
// notification options here, and rely on the shared logic in NotificationPresenter to
// honor notification sound preferences for both modern and legacy users.
completionHandler([.alert, .badge, .sound])
}
}
/// The method will be called on the delegate when the user responded to the notification by opening the application, dismissing
/// the notification or choosing a UNNotificationAction. The delegate must be set before the application returns from
/// application:didFinishLaunchingWithOptions:.
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
AppReadiness.runNowOrWhenAppDidBecomeReady {
AppEnvironment.shared.userNotificationActionHandler.handleNotificationResponse(response, completionHandler: completionHandler)
}
}
/// The method will be called on the delegate when the application is launched in response to the user's request to view in-app
/// notification settings. Add UNAuthorizationOptionProvidesAppNotificationSettings as an option in
/// requestAuthorizationWithOptions:completionHandler: to add a button to inline notification settings view and the notification
/// settings view in Settings. The notification will be nil when opened from Settings.
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
}
// MARK: - Notification Handling
@objc private func registrationStateDidChange() {
handleActivation()
}
@objc public func showMissedCallTipsIfNeeded(_ notification: Notification) {
guard !UserDefaults.standard[.hasSeenCallMissedTips] else { return }
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.showMissedCallTipsIfNeeded(notification)
}
return
}
guard let callerId: String = notification.userInfo?[Notification.Key.senderId.rawValue] as? String else {
return
}
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() }
let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(
caller: Profile.displayName(id: callerId)
)
presentingVC.present(callMissedTipsModal, animated: true, completion: nil)
UserDefaults.standard[.hasSeenCallMissedTips] = true
}
// MARK: - Polling
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
guard Identity.userExists() else { return }
/// There is a fun issue where if you launch without any valid paths then the pollers are guaranteed to fail their first poll due to
/// trying and failing to build paths without having the `SnodeAPI.snodePool` populated, by waiting for the
/// `JobRunner.blockingQueue` to complete we can have more confidence that paths won't fail to build incorrectly
JobRunner.afterBlockingQueue { [weak self] in
self?.poller.start()
guard shouldStartGroupPollers else { return }
ClosedGroupPoller.shared.start()
OpenGroupManager.shared.startPolling()
}
}
public func stopPollers(shouldStopUserPoller: Bool = true) {
if shouldStopUserPoller {
poller.stopAllPollers()
}
ClosedGroupPoller.shared.stopAllPollers()
OpenGroupManager.shared.stopPolling()
}
// MARK: - App Link
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
guard let components: URLComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return false
}
// URL Scheme is sessionmessenger://DM?sessionID=1234
// We can later add more parameters like message etc.
if components.host == "DM" {
let matches: [URLQueryItem] = (components.queryItems ?? [])
.filter { item in item.name == "sessionID" }
if let sessionId: String = matches.first?.value {
createNewDMFromDeepLink(sessionId: sessionId)
return true
}
}
return false
}
private func createNewDMFromDeepLink(sessionId: String) {
guard let homeViewController: HomeVC = (window?.rootViewController as? UINavigationController)?.visibleViewController as? HomeVC else {
return
}
homeViewController.createNewDMFromDeepLink(sessionId: sessionId)
}
// MARK: - Call handling
func hasIncomingCallWaiting() -> Bool {
guard let call = AppEnvironment.shared.callManager.currentCall else { return false }
return !call.hasStartedConnecting
}
func hasCallOngoing() -> Bool {
guard let call = AppEnvironment.shared.callManager.currentCall else { return false }
return !call.hasEnded
}
func handleAppActivatedWithOngoingCallIfNeeded() {
guard
let call: SessionCall = (AppEnvironment.shared.callManager.currentCall as? SessionCall),
MiniCallView.current == nil
else { return }
if let callVC = CurrentAppContext().frontmostViewController() as? CallVC, callVC.call.uuid == call.uuid {
return
}
// FIXME: Handle more gracefully
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() }
let callVC: CallVC = CallVC(for: call)
if let conversationVC: ConversationVC = presentingVC as? ConversationVC, conversationVC.viewModel.threadData.threadId == call.sessionId {
callVC.conversationVC = conversationVC
conversationVC.inputAccessoryView?.isHidden = true
conversationVC.inputAccessoryView?.alpha = 0
}
presentingVC.present(callVC, animated: true, completion: nil)
}
}
// MARK: - LifecycleMethod
private enum LifecycleMethod: Equatable {
case finishLaunching
case enterForeground(initialLaunchFailed: Bool)
case didBecomeActive
var timingName: String {
switch self {
case .finishLaunching: return "Launch"
case .enterForeground: return "EnterForeground"
case .didBecomeActive: return "BecomeActive"
}
}
static func == (lhs: LifecycleMethod, rhs: LifecycleMethod) -> Bool {
switch (lhs, rhs) {
case (.finishLaunching, .finishLaunching): return true
case (.enterForeground(let lhsFailed), .enterForeground(let rhsFailed)): return (lhsFailed == rhsFailed)
case (.didBecomeActive, .didBecomeActive): return true
default: return false
}
}
}
// MARK: - StartupError
private enum StartupError: Error {
case databaseError(Error)
case failedToRestore
case startupTimeout
var name: String {
switch self {
case .databaseError(StorageError.startupFailed), .databaseError(DatabaseError.SQLITE_LOCKED):
return "Database startup failed"
case .failedToRestore: return "Failed to restore"
case .databaseError: return "Database error"
case .startupTimeout: return "Startup timeout"
}
}
var message: String {
switch self {
case .databaseError(StorageError.startupFailed), .databaseError(DatabaseError.SQLITE_LOCKED):
return "DATABASE_STARTUP_FAILED".localized()
case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized()
case .databaseError: return "DATABASE_MIGRATION_FAILED".localized()
case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized()
}
}
}