407 lines
15 KiB
Swift
407 lines
15 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
public import UIKit
|
|
|
|
enum LaunchInterface {
|
|
case registration(RegistrationCoordinatorLoader, RegistrationMode)
|
|
case secondaryProvisioning
|
|
case chatList
|
|
}
|
|
|
|
public class SignalApp {
|
|
public static let shared = SignalApp()
|
|
private(set) weak var conversationSplitViewController: ConversationSplitViewController?
|
|
|
|
private init() {}
|
|
|
|
var hasSelectedThread: Bool {
|
|
return conversationSplitViewController?.selectedThread != nil
|
|
}
|
|
|
|
func showConversationSplitView(appReadiness: AppReadinessSetter) {
|
|
let splitViewController = ConversationSplitViewController(appReadiness: appReadiness)
|
|
UIApplication.shared.delegate?.window??.rootViewController = splitViewController
|
|
self.conversationSplitViewController = splitViewController
|
|
}
|
|
|
|
func dismissAllModals(animated: Bool, completion: (() -> Void)?) {
|
|
guard let window = CurrentAppContext().mainWindow else {
|
|
owsFailDebug("Missing window.")
|
|
return
|
|
}
|
|
guard let rootViewController = window.rootViewController else {
|
|
owsFailDebug("Missing rootViewController.")
|
|
return
|
|
}
|
|
let hasModal = rootViewController.presentedViewController != nil
|
|
if hasModal {
|
|
rootViewController.dismiss(animated: animated, completion: completion)
|
|
} else {
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func showLaunchInterface(_ launchInterface: LaunchInterface, appReadiness: AppReadinessSetter, launchStartedAt: TimeInterval) {
|
|
owsPrecondition(appReadiness.isAppReady)
|
|
|
|
let startupDuration = CACurrentMediaTime() - launchStartedAt
|
|
let formattedStartupDuration = String(format: "%.3f", startupDuration)
|
|
Logger.info("Presenting app \(formattedStartupDuration) seconds after launch started.")
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(spamChallenge),
|
|
name: SpamChallengeResolver.NeedsCaptchaNotification,
|
|
object: nil,
|
|
)
|
|
|
|
switch launchInterface {
|
|
case .registration(let registrationLoader, let desiredMode):
|
|
showRegistration(loader: registrationLoader, desiredMode: desiredMode, appReadiness: appReadiness)
|
|
appReadiness.setUIIsReady()
|
|
case .secondaryProvisioning:
|
|
showSecondaryProvisioning(appReadiness: appReadiness)
|
|
appReadiness.setUIIsReady()
|
|
case .chatList:
|
|
showConversationSplitView(appReadiness: appReadiness)
|
|
}
|
|
|
|
UIViewController.attemptRotationToDeviceOrientation()
|
|
}
|
|
|
|
@objc
|
|
private func spamChallenge() {
|
|
let db = DependenciesBridge.shared.db
|
|
let windowManager = AppEnvironment.shared.windowManagerRef
|
|
|
|
let frontmostViewController = windowManager.captchaWindow.findFrontmostViewController(ignoringAlerts: true)!
|
|
SpamCaptchaViewController.presentActionSheet(from: frontmostViewController)
|
|
|
|
db.write { tx in
|
|
SupportKeyValueStore().setLastChallengeDate(value: Date(), transaction: tx)
|
|
}
|
|
}
|
|
|
|
func showRegistration(
|
|
loader: RegistrationCoordinatorLoader,
|
|
desiredMode: RegistrationMode,
|
|
appReadiness: AppReadinessSetter,
|
|
) {
|
|
let logger: PrefixedLogger
|
|
switch desiredMode {
|
|
case .registering:
|
|
logger = PrefixedLogger(prefix: "[Reg]")
|
|
logger.info("Attempting initial registration on app launch")
|
|
case .reRegistering:
|
|
logger = PrefixedLogger(prefix: "[ReReg]")
|
|
logger.info("Attempting reregistration on app launch")
|
|
case .changingNumber:
|
|
logger = PrefixedLogger(prefix: "[ChgNum]")
|
|
logger.info("Attempting change number registration on app launch")
|
|
}
|
|
let coordinator = SSKEnvironment.shared.databaseStorageRef.write { tx in
|
|
return loader.coordinator(forDesiredMode: desiredMode, transaction: tx, logger: logger)
|
|
}
|
|
let navController = RegistrationNavigationController.withCoordinator(coordinator, appReadiness: appReadiness)
|
|
|
|
UIApplication.shared.delegate?.window??.rootViewController = navController
|
|
|
|
conversationSplitViewController = nil
|
|
}
|
|
|
|
@MainActor
|
|
func showSecondaryProvisioning(appReadiness: AppReadinessSetter) {
|
|
ProvisioningController.presentProvisioningFlow(appReadiness: appReadiness)
|
|
conversationSplitViewController = nil
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func showAppSettings(mode: ChatListViewController.ShowAppSettingsMode, completion: (() -> Void)? = nil) {
|
|
guard let conversationSplitViewController else {
|
|
owsFailDebug("Missing conversationSplitViewController.")
|
|
return
|
|
}
|
|
conversationSplitViewController.showAppSettingsWithMode(mode, completion: completion)
|
|
}
|
|
|
|
func showCameraCaptureView(completion: ((UINavigationController) -> Void)? = nil) {
|
|
guard let conversationSplitViewController else {
|
|
owsFailDebug("Missing conversationSplitViewController.")
|
|
return
|
|
}
|
|
conversationSplitViewController.showCameraView(completion: completion)
|
|
}
|
|
|
|
func showNewConversationView() {
|
|
AssertIsOnMainThread()
|
|
guard let conversationSplitViewController else {
|
|
owsFailDebug("No conversationSplitViewController")
|
|
return
|
|
}
|
|
conversationSplitViewController.showNewConversationView()
|
|
}
|
|
|
|
func showMyStories(animated: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let conversationSplitViewController else {
|
|
owsFailDebug("No conversationSplitViewController")
|
|
return
|
|
}
|
|
|
|
Logger.info("")
|
|
conversationSplitViewController.showMyStoriesController(animated: animated)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func presentConversationForAddress(
|
|
_ address: SignalServiceAddress,
|
|
action: ConversationViewAction = .none,
|
|
animated: Bool,
|
|
) {
|
|
let thread = SSKEnvironment.shared.databaseStorageRef.write { transaction in
|
|
return TSContactThread.getOrCreateThread(withContactAddress: address, transaction: transaction)
|
|
}
|
|
presentConversationForThread(
|
|
threadUniqueId: thread.uniqueId,
|
|
action: action,
|
|
animated: animated,
|
|
)
|
|
}
|
|
|
|
func presentConversationForThread(
|
|
threadUniqueId: String,
|
|
action: ConversationViewAction = .none,
|
|
focusMessageId: String? = nil,
|
|
animated: Bool,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let conversationSplitViewController else {
|
|
owsFailDebug("No conversationSplitViewController")
|
|
return
|
|
}
|
|
|
|
Logger.info("")
|
|
|
|
DispatchMainThreadSafe {
|
|
if
|
|
focusMessageId == nil,
|
|
let visibleThread = conversationSplitViewController.visibleThread,
|
|
visibleThread.uniqueId == threadUniqueId,
|
|
let conversationViewController = conversationSplitViewController.selectedConversationViewController
|
|
{
|
|
conversationViewController.popKeyBoard()
|
|
if case .updateDraft = action {
|
|
conversationViewController.reloadDraft()
|
|
}
|
|
return
|
|
}
|
|
conversationSplitViewController.presentThread(
|
|
threadUniqueId: threadUniqueId,
|
|
action: action,
|
|
focusMessageId: focusMessageId,
|
|
animated: animated,
|
|
)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func presentConversationAndScrollToFirstUnreadMessage(threadUniqueId: String, animated: Bool) {
|
|
guard let conversationSplitViewController else {
|
|
owsFailDebug("No conversationSplitViewController")
|
|
return
|
|
}
|
|
|
|
Logger.info("")
|
|
|
|
// If there's a presented blocking splash, but the user is trying to open a
|
|
// thread, dismiss it. We'll try again next time they open the app. We
|
|
// don't want to block them from accessing their conversations.
|
|
ExperienceUpgradeManager.dismissSplashWithoutCompletingIfNecessary()
|
|
|
|
if let visibleThread = conversationSplitViewController.visibleThread, visibleThread.uniqueId == threadUniqueId {
|
|
AppEnvironment.shared.windowManagerRef.minimizeCallIfNeeded()
|
|
conversationSplitViewController.selectedConversationViewController?.scrollToInitialPosition(animated: animated)
|
|
return
|
|
}
|
|
|
|
if let sendMediaNavigationController = conversationSplitViewController.selectedConversationViewController?.presentedViewController as? SendMediaNavigationController {
|
|
if sendMediaNavigationController.hasUnsavedChanges {
|
|
return
|
|
}
|
|
|
|
AppEnvironment.shared.windowManagerRef.minimizeCallIfNeeded()
|
|
conversationSplitViewController.presentThread(
|
|
threadUniqueId: threadUniqueId,
|
|
action: .none,
|
|
focusMessageId: nil,
|
|
animated: false,
|
|
)
|
|
sendMediaNavigationController.dismiss(animated: animated)
|
|
return
|
|
}
|
|
|
|
AppEnvironment.shared.windowManagerRef.minimizeCallIfNeeded()
|
|
conversationSplitViewController.presentThread(
|
|
threadUniqueId: threadUniqueId,
|
|
action: .none,
|
|
focusMessageId: nil,
|
|
animated: animated,
|
|
)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func snapshotSplitViewController(afterScreenUpdates: Bool) -> UIView? {
|
|
return conversationSplitViewController?.view?.snapshotView(afterScreenUpdates: afterScreenUpdates)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@MainActor
|
|
func resetLinkedAppDataAndExit(
|
|
localDeviceId: LocalDeviceId,
|
|
keyFetcher: GRDBKeyFetcher,
|
|
registrationStateChangeManager: RegistrationStateChangeManager,
|
|
) async -> Never {
|
|
// Best effort to unlink ourselves from the server.
|
|
try? await registrationStateChangeManager.unlinkLocalDevice(localDeviceId: localDeviceId, auth: .implicit())
|
|
resetAppDataAndExit(keyFetcher: keyFetcher)
|
|
}
|
|
|
|
/// Wipe all app data, and exit the app.
|
|
///
|
|
/// - Warning
|
|
/// Extremely destructive. Call with great caution.
|
|
///
|
|
/// - Important
|
|
/// This is used in launch flows, before global singletons are available.
|
|
@MainActor
|
|
func resetAppDataAndExit(keyFetcher: GRDBKeyFetcher) -> Never {
|
|
resetAppData(keyFetcher: keyFetcher)
|
|
exit(0)
|
|
}
|
|
|
|
/// Wipe all app data.
|
|
///
|
|
/// - Warning
|
|
/// Extremely destructive. Call with great caution.
|
|
///
|
|
/// - Important
|
|
/// This is used in launch flows, before global singletons are available.
|
|
@MainActor
|
|
func resetAppData(keyFetcher: GRDBKeyFetcher) {
|
|
do {
|
|
try keyFetcher.clear()
|
|
} catch {
|
|
owsFailDebug("Could not clear keychain: \(error)")
|
|
}
|
|
|
|
func wipeUserDefaults(_ userDefaults: UserDefaults) {
|
|
for (key, _) in userDefaults.dictionaryRepresentation() {
|
|
userDefaults.removeObject(forKey: key)
|
|
}
|
|
userDefaults.synchronize()
|
|
}
|
|
|
|
wipeUserDefaults(UserDefaults.standard)
|
|
wipeUserDefaults(CurrentAppContext().appUserDefaults())
|
|
|
|
OWSFileSystem.deleteContents(ofDirectory: OWSFileSystem.appSharedDataDirectoryPath())
|
|
OWSFileSystem.deleteContents(ofDirectory: OWSFileSystem.appDocumentDirectoryPath())
|
|
OWSFileSystem.deleteContents(ofDirectory: OWSFileSystem.cachesDirectoryPath())
|
|
OWSFileSystem.deleteContents(ofDirectory: NSTemporaryDirectory())
|
|
|
|
UserNotificationPresenter().clearAllNotifications()
|
|
UIApplication.shared.applicationIconBadgeNumber = 0
|
|
AppDelegate.updateApplicationShortcutItems(isRegistered: false)
|
|
|
|
DebugLogger.shared.wipeLogsAlways(appContext: CurrentAppContext() as! MainAppContext)
|
|
}
|
|
|
|
@MainActor
|
|
func showTransferCompleteAndExit() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"OUTGOING_TRANSFER_COMPLETE_TITLE",
|
|
comment: "Title for action sheet shown when device transfer completes",
|
|
),
|
|
message: OWSLocalizedString(
|
|
"OUTGOING_TRANSFER_COMPLETE_MESSAGE",
|
|
comment: "Message for action sheet shown when device transfer completes",
|
|
),
|
|
)
|
|
actionSheet.addAction(.init(
|
|
title: OWSLocalizedString(
|
|
"OUTGOING_TRANSFER_COMPLETE_EXIT_ACTION",
|
|
comment: "Button for action sheet shown when device transfer completes; quits the Signal app immediately (does not automatically relaunch, but the user may choose to relaunch).",
|
|
),
|
|
style: .destructive,
|
|
handler: { _ in
|
|
exit(0)
|
|
},
|
|
))
|
|
actionSheet.isCancelable = false
|
|
CurrentAppContext().frontmostViewController()?.present(actionSheet, animated: true)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public func showExportDatabaseUI(from parentVC: UIViewController, completion: @escaping () -> Void = {}) {
|
|
guard DebugFlags.internalSettings else {
|
|
// This should NEVER be exposed outside of internal settings.
|
|
// We do not want to expose users to phishing scams. This should only be used for debugging purposes.
|
|
Logger.warn("cannot export database in a public build")
|
|
completion()
|
|
return
|
|
}
|
|
|
|
let alert = UIAlertController(
|
|
title: "⚠️⚠️⚠️ Warning!!! ⚠️⚠️⚠️",
|
|
message: "This contains all your contacts, groups, and messages. "
|
|
+ "The database file will remain encrypted and the password provided after export, "
|
|
+ "but it is still much less secure because it's now out of the app's control.\n\n"
|
|
+ "NO ONE AT SIGNAL CAN MAKE YOU DO THIS! Don't do it if you're not comfortable.",
|
|
preferredStyle: .alert,
|
|
)
|
|
alert.addAction(.init(title: "Export", style: .destructive) { _ in
|
|
if SSKEnvironment.hasShared {
|
|
// Try to sync the database first, since we don't export the WAL.
|
|
_ = try? SSKEnvironment.shared.databaseStorageRef.grdbStorage.syncTruncatingCheckpoint()
|
|
}
|
|
let databaseFileUrl = GRDBDatabaseStorageAdapter.databaseFileUrl()
|
|
let shareSheet = UIActivityViewController(activityItems: [databaseFileUrl], applicationActivities: nil)
|
|
shareSheet.completionWithItemsHandler = { _, completed, _, error in
|
|
guard completed, error == nil, let password = SSKEnvironment.shared.databaseStorageRef.keyFetcher.debugOnly_keyData()?.hexadecimalString else {
|
|
completion()
|
|
return
|
|
}
|
|
UIPasteboard.general.string = password
|
|
let passwordAlert = UIAlertController(
|
|
title: "Your database password has been copied to the clipboard",
|
|
message: nil,
|
|
preferredStyle: .alert,
|
|
)
|
|
passwordAlert.addAction(.init(title: "OK", style: .default) { _ in
|
|
completion()
|
|
})
|
|
parentVC.present(passwordAlert, animated: true)
|
|
}
|
|
parentVC.present(shareSheet, animated: true)
|
|
})
|
|
alert.addAction(.init(title: "Cancel", style: .cancel) { _ in
|
|
completion()
|
|
})
|
|
parentVC.present(alert, animated: true)
|
|
}
|
|
}
|