diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 218ee65cca..76e40c70a0 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1694,6 +1694,7 @@ B9E322D82CD024A2006DAF3B /* linking-device-light.json in Resources */ = {isa = PBXBuildFile; fileRef = B9E322D72CD024A2006DAF3B /* linking-device-light.json */; }; B9E322D92CD024A2006DAF3B /* linking-device-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = B9E322D62CD024A2006DAF3B /* linking-device-dark.json */; }; B9E322E72CD170ED006DAF3B /* SignalList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9E322E62CD170ED006DAF3B /* SignalList.swift */; }; + B9E322E92CD191CF006DAF3B /* LinkedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9E322E82CD191CF006DAF3B /* LinkedDevicesView.swift */; }; B9EB5ABD1884C002007CBB57 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9EB5ABC1884C002007CBB57 /* MessageUI.framework */; }; B9F2155D2A93C9E8002DCAE0 /* StickerPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F2155C2A93C9E8002DCAE0 /* StickerPickerSheet.swift */; }; B9F2155F2A940596002DCAE0 /* ImageEditorStickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F2155E2A940596002DCAE0 /* ImageEditorStickerItem.swift */; }; @@ -3660,7 +3661,6 @@ 05EA61422CC9437F00B16D4E /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; 05EA61432CC943D200B16D4E /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; 05EA61442CC943DD00B16D4E /* Project.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Project.xcconfig; sourceTree = ""; }; - 05EA61472CC9491400B16D4E /* User.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = User.xcconfig; sourceTree = ""; }; 05FDBC282CD91B31000C87BC /* ChatListContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListContainerView.swift; sourceTree = ""; }; 0BADD293DAFC82BF3274F0F6 /* Pods_SignalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1404D8B2276A353A0068E2F6 /* ChatListViewController+Multiselect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatListViewController+Multiselect.swift"; sourceTree = ""; }; @@ -5421,6 +5421,7 @@ B95A765D2B76E93500AA7E97 /* FindByUsernameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindByUsernameViewController.swift; sourceTree = ""; }; B95BBAC12BB36025009EFB4A /* ProfileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileName.swift; sourceTree = ""; }; B96D6D782B9F83270039EB99 /* SignalSymbols-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Regular.otf"; sourceTree = ""; }; + B96FEE2E2CDC297500836191 /* User.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = User.xcconfig; sourceTree = ""; }; B9754F532C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationAvatarView+SwiftUI.swift"; sourceTree = ""; }; B982ACFE2BA8FD2A00AD7E81 /* SignalSymbols-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Bold.otf"; sourceTree = ""; }; B982AD002BA8FD3100AD7E81 /* SignalSymbols-Light.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Light.otf"; sourceTree = ""; }; @@ -5439,6 +5440,7 @@ B9E322D62CD024A2006DAF3B /* linking-device-dark.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "linking-device-dark.json"; sourceTree = ""; }; B9E322D72CD024A2006DAF3B /* linking-device-light.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "linking-device-light.json"; sourceTree = ""; }; B9E322E62CD170ED006DAF3B /* SignalList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalList.swift; sourceTree = ""; }; + B9E322E82CD191CF006DAF3B /* LinkedDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedDevicesView.swift; sourceTree = ""; }; B9EB5ABC1884C002007CBB57 /* MessageUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; }; B9F2155C2A93C9E8002DCAE0 /* StickerPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerPickerSheet.swift; sourceTree = ""; }; B9F2155E2A940596002DCAE0 /* ImageEditorStickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEditorStickerItem.swift; sourceTree = ""; }; @@ -7409,7 +7411,7 @@ 05EA61422CC9437F00B16D4E /* Project-Debug.xcconfig */, 05EA61432CC943D200B16D4E /* Project-Release.xcconfig */, 05EA61442CC943DD00B16D4E /* Project.xcconfig */, - 05EA61472CC9491400B16D4E /* User.xcconfig */, + B96FEE2E2CDC297500836191 /* User.xcconfig */, ); path = Config; sourceTree = ""; @@ -10647,6 +10649,7 @@ children = ( 505C2ED32997015800C23FB2 /* LinkDeviceViewController.swift */, 3437F6392512835200AC1767 /* LinkedDevicesTableViewController.swift */, + B9E322E82CD191CF006DAF3B /* LinkedDevicesView.swift */, ); path = "Linked Devices"; sourceTree = ""; @@ -16474,6 +16477,7 @@ D9C30DDB2A20072F009C2184 /* LegacyGroupLearnMoreViewController.swift in Sources */, 505C2ED42997015800C23FB2 /* LinkDeviceViewController.swift in Sources */, 3437F63A2512835300AC1767 /* LinkedDevicesTableViewController.swift in Sources */, + B9E322E92CD191CF006DAF3B /* LinkedDevicesView.swift in Sources */, 76C87FE128BE8E2400BD8709 /* LinkPreviewAttachmentViewController.swift in Sources */, 507B69122C5044F800F1C6D7 /* LinkPreviewGroupLink.swift in Sources */, 4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */, diff --git a/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/Contents.json b/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/Contents.json new file mode 100644 index 0000000000..a04f19c466 --- /dev/null +++ b/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "linked-device-intro-light.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "linked-device-intro-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/linked-device-intro-dark.pdf b/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/linked-device-intro-dark.pdf new file mode 100644 index 0000000000..714aa74da9 Binary files /dev/null and b/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/linked-device-intro-dark.pdf differ diff --git a/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/linked-device-intro-light.pdf b/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/linked-device-intro-light.pdf new file mode 100644 index 0000000000..ea253c5eb9 Binary files /dev/null and b/Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/linked-device-intro-light.pdf differ diff --git a/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.swift b/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.swift index ce7454a6cc..39ca2248e3 100644 --- a/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.swift @@ -152,7 +152,11 @@ class AppSettingsViewController: OWSTableViewController2 { icon: .settingsLinkedDevices, withText: OWSLocalizedString("LINKED_DEVICES_TITLE", comment: "Menu item and navbar title for the device manager"), actionBlock: { [weak self] in - let vc = LinkedDevicesTableViewController() + let vc = if FeatureFlags.linkedDevicesRedesign { + LinkedDevicesHostingController() + } else { + LinkedDevicesTableViewController() + } self?.navigationController?.pushViewController(vc, animated: true) } )) diff --git a/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkDeviceViewController.swift b/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkDeviceViewController.swift index 0ba15d3a3b..971b1f33fb 100644 --- a/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkDeviceViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkDeviceViewController.swift @@ -345,10 +345,13 @@ class LinkDeviceViewController: OWSViewController { } private func popToLinkedDeviceList(_ completion: (() -> Void)? = nil) { - navigationController?.popViewController(animated: true, completion: { + navigationController?.popViewController(animated: true) + // The method for adding a completion handler to popViewController in + // UIViewController+SignalUI doesn't play well with UIHostingController + navigationController?.transitionCoordinator?.animate(alongsideTransition: nil) { _ in UIViewController.attemptRotationToDeviceOrientation() completion?() - }) + } } #if TESTABLE_BUILD diff --git a/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesTableViewController.swift b/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesTableViewController.swift index a10a17c3e7..c12cfb0a58 100644 --- a/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesTableViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesTableViewController.swift @@ -587,7 +587,7 @@ extension LinkedDevicesTableViewController: LinkDeviceViewControllerDelegate { } } - func expectMoreDevices() { + private func expectMoreDevices() { isExpectingMoreDevices = true diff --git a/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesView.swift b/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesView.swift new file mode 100644 index 0000000000..664b27bd6f --- /dev/null +++ b/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesView.swift @@ -0,0 +1,762 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Combine +import SwiftUI +import SignalUI +import SignalServiceKit +import LocalAuthentication + +// This has a long and awful name so that if the condition is ever changed, +// the text shown to internal users about it can be changed too. +private var shouldShowDeviceIdsBecauseUserIsInternal: Bool { DebugFlags.internalSettings } + +// MARK: - LinkedDevicesViewModel + +class LinkedDevicesViewModel: ObservableObject { + + @Published fileprivate var editMode: EditMode = .inactive + @Published fileprivate var displayableDevices: [DisplayableDevice] = [] + @Published fileprivate var isLoading: Bool = false + + fileprivate enum Presentation { + case newDeviceToast(deviceName: String) + case linkDeviceAuthentication + case unlinkDeviceConfirmation(displayableDevice: DisplayableDevice) + case updateFailureAlert(Error) + case unlinkFailureAlert(device: OWSDevice, error: Error) + case activityIndicator(ModalActivityIndicatorViewController) + } + + fileprivate var present = PassthroughSubject() + + private var subscriptions = Set() + private var pollingRefreshTimer: Timer? + private var oldDeviceList: [DisplayableDevice] = [] + private var isExpectingMoreDevices = false { + didSet { + shouldShowFinishLinkingSheet = isExpectingMoreDevices + } + } + fileprivate var shouldShowFinishLinkingSheet = false + + init() { + DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self) + + NotificationCenter.default.publisher( + for: OWSDevicesService.deviceListUpdateFailed + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let error = notification.object as? Error else { + owsFailDebug("Missing error.") + return + } + if error.isNetworkFailureOrTimeout { + return + } + + self?.present.send(.updateFailureAlert(error)) + } + .store(in: &subscriptions) + + NotificationCenter.default.publisher( + for: OWSDevicesService.deviceListUpdateModifiedDeviceList + ) + .sink { [weak self] _ in + self?.pollingRefreshTimer?.invalidate() + self?.pollingRefreshTimer = nil + } + .store(in: &subscriptions) + } + + @MainActor + func refreshDevices() async { + if displayableDevices.isEmpty { + self.isLoading = true + } + + try? await OWSDevicesService.refreshDevices().awaitable() + + if !isExpectingMoreDevices { + self.isLoading = false + } + } + + private func updateDeviceList() { + var displayableDevices = SSKEnvironment.shared.databaseStorageRef.read { transaction -> [DisplayableDevice] in + let justDevices = OWSDevice.anyFetchAll(transaction: transaction).filter { + !$0.isPrimaryDevice + } + + let identityManager = DependenciesBridge.shared.identityManager + return justDevices.map { device -> DisplayableDevice in + return .init( + device: device, + displayName: device.displayName( + identityManager: identityManager, + tx: transaction.asV2Read + ) + ) + } + } + + displayableDevices.sort { (lhs, rhs) in + lhs.device.createdAt < rhs.device.createdAt + } + + if oldDeviceList != displayableDevices { + if + isExpectingMoreDevices, + let newDevice = displayableDevices.last, + newDevice != oldDeviceList.last + { + present.send(.newDeviceToast(deviceName: newDevice.displayName)) + isExpectingMoreDevices = false + withAnimation { + self.isLoading = false + } + } + + oldDeviceList = displayableDevices + } + + withAnimation { + self.displayableDevices = displayableDevices + + if displayableDevices.isEmpty { + self.editMode = .inactive + } + } + } + + func unlinkDevice(_ device: OWSDevice) { + OWSDevicesService.unlinkDevice( + device, + success: { [weak self] in + Logger.info("Removing unlinked device with deviceId: \(device.deviceId)") + SSKEnvironment.shared.databaseStorageRef.write { transaction in + device.anyRemove(transaction: transaction) + } + DispatchQueue.main.async { + self?.updateDeviceList() + } + }, + failure: { [weak self] (error) in + DispatchQueue.main.async { + self?.present.send(.unlinkFailureAlert(device: device, error: error)) + } + } + ) + } + + struct DisplayableDevice: Hashable, Identifiable { + var id: Int { device.deviceId } + + let device: OWSDevice + let displayName: String + + var createdAt: Date { device.createdAt } + + static func == (lhs: DisplayableDevice, rhs: DisplayableDevice) -> Bool { + lhs.id == rhs.id + && lhs.displayName == rhs.displayName + && lhs.createdAt == rhs.createdAt + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(displayName) + hasher.combine(createdAt) + } + } +} + +// MARK: DatabaseChangeDelegate + +extension LinkedDevicesViewModel: DatabaseChangeDelegate { + func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) { + AssertIsOnMainThread() + + guard databaseChanges.didUpdate(tableName: OWSDevice.databaseTableName) else { + return + } + + updateDeviceList() + } + + func databaseChangesDidUpdateExternally() { + AssertIsOnMainThread() + + updateDeviceList() + } + + func databaseChangesDidReset() { + AssertIsOnMainThread() + + updateDeviceList() + } +} + +// MARK: LinkDeviceViewControllerDelegate + +extension LinkedDevicesViewModel: LinkDeviceViewControllerDelegate { + func didFinishLinking(linkNSyncTask: Task?) { + guard let linkNSyncTask else { + expectMoreDevices() + return + } + Task { @MainActor in + // TODO: use the appropriate UX for loading, and show percent progress + let loadingViewController = ModalActivityIndicatorViewController(canCancel: false, presentationDelay: 0) + loadingViewController.modalPresentationStyle = .overFullScreen + self.present.send(.activityIndicator(loadingViewController)) + do { + try await linkNSyncTask.value + } catch { + loadingViewController.dismiss(animated: false) { + DependenciesBridge.shared.messageBackupErrorPresenter.presentOverTopmostViewController(completion: {}) + } + self.expectMoreDevices() + return + } + await self.refreshDevices() + loadingViewController.dismiss(animated: false) + } + } + + private func expectMoreDevices() { + AssertIsOnMainThread() + + isExpectingMoreDevices = true + editMode = .inactive + + pollingRefreshTimer?.invalidate() + pollingRefreshTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] timer in + guard let self, self.isExpectingMoreDevices else { + timer.invalidate() + return + } + + Task { + await self.refreshDevices() + } + } + } +} + +// MARK: - LinkedDevicesHostingController + +class LinkedDevicesHostingController: HostingContainer { + private let viewModel = LinkedDevicesViewModel() + + private var subscriptions = Set() + + private weak var finishLinkingSheet: HeroSheetViewController? + + init() { + super.init(wrappedView: LinkedDevicesView(viewModel: viewModel)) + + viewModel.present.sink { [weak self] presentation in + guard let self else { return } + switch presentation { + case let .newDeviceToast(deviceName): + if let finishLinkingSheet { + finishLinkingSheet.dismiss(animated: true) { + self.showNewDeviceToast(deviceName: deviceName) + } + } else { + self.showNewDeviceToast(deviceName: deviceName) + } + case let .updateFailureAlert(error): + self.showUpdateFailureAlert(error: error) + case .linkDeviceAuthentication: + self.didTapLinkDeviceButton() + case let .unlinkDeviceConfirmation(displayableDevice): + self.showUnlinkDeviceConfirmAlert(displayableDevice: displayableDevice) + case let .unlinkFailureAlert(device, error): + self.showUnlinkFailedAlert(device: device, error: error) + case let .activityIndicator(modal): + self.present(modal, animated: false) + } + }.store(in: &subscriptions) + + viewModel.$editMode + .sink { [weak self] editMode in + self?.updateNavigationItems(editMode: editMode) + } + .store(in: &subscriptions) + + self.title = OWSLocalizedString( + "LINKED_DEVICES_TITLE", + comment: "Menu item and navbar title for the device manager" + ) + } + + private func updateNavigationItems(editMode: EditMode? = nil) { + // @Published sends the new value before the view model + // itself updates, so its value needs to be passed in. + let editMode = editMode ?? viewModel.editMode + + navigationItem.rightBarButtonItem = .systemItem( + editMode.isEditing ? .done : .edit + ) { [weak viewModel] in + withAnimation { + viewModel?.editMode = editMode.isEditing ? .inactive : .active + } + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if viewModel.shouldShowFinishLinkingSheet { + // Only show the sheet once even if viewDidAppear is + // called multiple times while waiting for the link. + viewModel.shouldShowFinishLinkingSheet = false + + let sheet = HeroSheetViewController( + heroImage: UIImage(named: "linked-devices")!, + title: OWSLocalizedString( + "LINK_NEW_DEVICE_FINISH_ON_OTHER_DEVICE_SHEET_TITLE", + comment: "Title for a sheet when a user has started linking a device informing them to finish the process on that other device" + ), + body: OWSLocalizedString( + "LINK_NEW_DEVICE_FINISH_ON_OTHER_DEVICE_SHEET_BODY", + comment: "Body text for a sheet when a user has started linking a device informing them to finish the process on that other device" + ), + buttonTitle: CommonStrings.continueButton + ) + self.finishLinkingSheet = sheet + + // Presenting it in viewDidAppear prevents the background dimming + DispatchQueue.main.async { + self.present(sheet, animated: true) + } + } + } + + // MARK: Device linking + + private func showNewDeviceToast(deviceName: String) { + guard FeatureFlags.biometricLinkedDeviceFlow else { return } + presentToast(text: String( + format: OWSLocalizedString( + "DEVICE_LIST_UPDATE_NEW_DEVICE_TOAST", + comment: "Message appearing on a toast indicating a new device was successfully linked. Embeds {{ device name }}" + ), + deviceName + )) + } + + private func showUpdateFailureAlert(error: Error) { + AssertIsOnMainThread() + + let alertTitle = OWSLocalizedString( + "DEVICE_LIST_UPDATE_FAILED_TITLE", + comment: "Alert title that can occur when viewing device manager." + ) + let alert = ActionSheetController(title: alertTitle, + message: error.userErrorDescription) + alert.addAction(ActionSheetAction( + title: CommonStrings.retryButton, + style: .default) { [weak self] _ in + Task { + await self?.viewModel.refreshDevices() + } + } + ) + alert.addAction(OWSActionSheets.dismissAction) + + presentActionSheet(alert) + } + + private func showLinkNewDeviceView() { + AssertIsOnMainThread() + + let linkView = LinkDeviceViewController() + linkView.delegate = viewModel + navigationController?.pushViewController(linkView, animated: true) + } + + private func getCameraPermissionsThenShowLinkNewDeviceView() { + self.ows_askForCameraPermissions { granted in + guard granted else { + return + } + self.showLinkNewDeviceView() + } + } + + private func didTapLinkDeviceButton() { + let context = DeviceOwnerAuthenticationType.localAuthenticationContext() + + var error: NSError? + let canEvaluatePolicy = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) + + guard canEvaluatePolicy && error == nil else { + let result = self.handleAuthenticationError(error as Error?) + switch result { + case .failed(let error): + self.showError(error) + case .canceled: + break + case .continueWithoutAuthentication: + self.getCameraPermissionsThenShowLinkNewDeviceView() + } + return + } + + let sheet = HeroSheetViewController( + heroImage: UIImage(named: "phone-lock")!, + title: OWSLocalizedString( + "LINK_NEW_DEVICE_AUTHENTICATION_INFO_SHEET_TITLE", + comment: "Title for a sheet when a user tries to link a device informing them that they will need to authenticate their device" + ), + body: OWSLocalizedString( + "LINK_NEW_DEVICE_AUTHENTICATION_INFO_SHEET_BODY", + comment: "Body text for a sheet when a user tries to link a device informing them that they will need to authenticate their device" + ), + buttonTitle: CommonStrings.continueButton + ) { [weak self, context] in + self?.dismiss(animated: true) + Task { + await self?.authenticateThenShowLinkNewDeviceView(context: context) + } + } + + self.present(sheet, animated: true) + } + + // MARK: Authentication + + private func authenticateThenShowLinkNewDeviceView(context: LAContext) async { + do { + try await context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: OWSLocalizedString( + "LINK_NEW_DEVICE_AUTHENTICATION_REASON", + comment: "Description of how and why Signal iOS uses Touch ID/Face ID/Phone Passcode to unlock device linking." + ) + ) + self.getCameraPermissionsThenShowLinkNewDeviceView() + } catch { + let result = self.handleAuthenticationError(error) + switch result { + case .failed(let error): + self.showError(error) + case .canceled: + break + case .continueWithoutAuthentication: + self.getCameraPermissionsThenShowLinkNewDeviceView() + } + } + } + + private enum AuthenticationErrorResult { + case failed(OWSError) + case canceled + case continueWithoutAuthentication + } + + private func showError(_ error: OWSError) { + Logger.error(error.userErrorDescription) + OWSActionSheets.showActionSheet( + title: DeviceAuthenticationErrorMessage.errorSheetTitle, + message: error.userErrorDescription, + fromViewController: self + ) + } + + private func handleAuthenticationError(_ error: Error?) -> AuthenticationErrorResult { + let errorMessage: String + switch (error as? LAError)?.code { + case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet, .touchIDNotAvailable, .touchIDNotEnrolled: + Logger.info("local authentication not enrolled") + return .continueWithoutAuthentication + case .userCancel, .userFallback, .systemCancel, .appCancel: + Logger.info("local authentication cancelled.") + return .canceled + case .biometryLockout, .touchIDLockout: + Logger.error("local authentication error: lockout.") + errorMessage = DeviceAuthenticationErrorMessage.lockout + case .authenticationFailed: + Logger.error("local authentication error: authenticationFailed.") + errorMessage = DeviceAuthenticationErrorMessage.authenticationFailed + case .invalidContext: + owsFailDebug("context not valid.") + errorMessage = DeviceAuthenticationErrorMessage.unknownError + case .notInteractive: + // Example: app was backgrounded + owsFailDebug("context not interactive.") + errorMessage = DeviceAuthenticationErrorMessage.unknownError + case .none: + owsFailDebug("Unexpected error: \(String(describing: error))") + errorMessage = DeviceAuthenticationErrorMessage.unknownError + @unknown default: + owsFailDebug("Unexpected enum value.") + errorMessage = DeviceAuthenticationErrorMessage.unknownError + } + + let owsError = OWSError( + error: .localAuthenticationError, + description: errorMessage, + isRetryable: false + ) + + return .failed(owsError) + } + + // MARK: Unlinking + + private func showUnlinkDeviceConfirmAlert(displayableDevice: LinkedDevicesViewModel.DisplayableDevice) { + AssertIsOnMainThread() + + let titleFormat = OWSLocalizedString("UNLINK_CONFIRMATION_ALERT_TITLE", + comment: "Alert title for confirming device deletion") + let title = String(format: titleFormat, displayableDevice.displayName) + let message = OWSLocalizedString("UNLINK_CONFIRMATION_ALERT_BODY", + comment: "Alert message to confirm unlinking a device") + let alert = ActionSheetController(title: title, message: message) + alert.addAction( + ActionSheetAction( + title: OWSLocalizedString( + "UNLINK_ACTION", + comment: "button title for unlinking a device" + ), + accessibilityIdentifier: "confirm_unlink_device", + style: .destructive, + handler: { [weak viewModel] _ in + viewModel?.unlinkDevice(displayableDevice.device) + } + ) + ) + alert.addAction(OWSActionSheets.cancelAction) + presentActionSheet(alert) + } + + func showUnlinkFailedAlert(device: OWSDevice, error: Error) { + AssertIsOnMainThread() + + let title = OWSLocalizedString( + "UNLINKING_FAILED_ALERT_TITLE", + comment: "Alert title when unlinking device fails" + ) + let alert = ActionSheetController(title: title, message: error.userErrorDescription) + alert.addAction( + ActionSheetAction( + title: CommonStrings.retryButton, + style: .default + ) { [weak self] _ in + self?.viewModel.unlinkDevice(device) + } + ) + alert.addAction(OWSActionSheets.cancelAction) + presentActionSheet(alert) + } +} + +// MARK: - LinkedDevicesView + +struct LinkedDevicesView: View { + @ObservedObject var viewModel: LinkedDevicesViewModel + + private var isEditing: Bool { + viewModel.editMode.isEditing + } + + var body: some View { + SignalList { + SignalSection { + VStack(spacing: 20) { + Image("linked-device-intro-dark") + + Text(OWSLocalizedString( + "LINKED_DEVICES_HEADER_DESCRIPTION", + comment: "Description for header of the linked devices list" + )) + .foregroundStyle(Color.Signal.secondaryLabel) + .multilineTextAlignment(.center) + + Button { + viewModel.present.send(.linkDeviceAuthentication) + } label: { + Text(OWSLocalizedString( + "LINK_NEW_DEVICE_TITLE", + comment: "Navigation title when scanning QR code to add new device." + )) + .padding(.vertical, 14) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + } + .font(.headline) + .buttonStyle(.borderless) + .foregroundStyle(isEditing ? Color.Signal.tertiaryLabel : .white) + .background( + isEditing ? Color.Signal.tertiaryFill : Color.Signal.ultramarine, + in: .rect(cornerRadius: 12) + ) + .disabled(isEditing) + } + .padding(.horizontal, 8) + .padding(.vertical, 12) + } + + SignalSection { + if viewModel.displayableDevices.isEmpty { + ZStack(alignment: .center) { + // For height calculation + DeviceView(device: nil) + .opacity(0) + + if viewModel.isLoading { + // [Device Linking] TODO: Signal spinner + ProgressView() + } else { + Text(OWSLocalizedString( + "LINKED_DEVICES_EMPTY_STATE", + comment: "Text that appears where the linked device list would be indicating that there are no linked devices." + )) + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + .frame(maxWidth: .infinity) + } + + ForEach(viewModel.displayableDevices, id: \.self) { device in + DeviceView(device: device) + .swipeActions { + Button(OWSLocalizedString( + "UNLINK_ACTION", + comment: "button title for unlinking a device" + )) { + viewModel.present.send(.unlinkDeviceConfirmation(displayableDevice: device)) + } + .tint(.red) + } + } + .onDelete { _ in + // This exists for adding the (-) buttons in edit mode, + // but the actual swipe action is defined above. + } + } header: { + Text(OWSLocalizedString( + "LINKED_DEVICES_LIST_TITLE", + comment: "Title above the list of currently-linked devices" + )) + } footer: { + ( + SignalSymbol.lock.text(dynamicTypeBaseSize: 16).baselineOffset(-1) + + Text(" ") + + Text(OWSLocalizedString( + "LINKED_DEVICES_LIST_FOOTER", + comment: "Footer text below the list of currently-linked devices" + )) + ) + .multilineTextAlignment(.center) + .foregroundStyle(Color.Signal.secondaryLabel) + .font(.caption) + .padding(.top) + } + + if shouldShowDeviceIdsBecauseUserIsInternal { + SignalSection { } footer: { + Text(LocalizationNotNeeded( + "Device IDs (and this message) are only shown to internal users." + )) + .foregroundStyle(Color.Signal.tertiaryLabel) + } + } + } + .task { + await viewModel.refreshDevices() + } + .refreshable { + await viewModel.refreshDevices() + } + .environment(\.editMode, self.$viewModel.editMode) + } + + // MARK: DeviceView + + private struct DeviceView: View { + var device: LinkedDevicesViewModel.DisplayableDevice? + + private var deviceName: String { + if let device { + if shouldShowDeviceIdsBecauseUserIsInternal { + LocalizationNotNeeded( + "#\(device.device.deviceId): \(device.displayName)" + ) + } else { + device.displayName + } + } else { + " " + } + } + + private func dateFormattedString(format: String, date: Date) -> String { + String( + format: format, + DateUtil.dateFormatter.string(from: date) + ) + } + + private var linkedDateString: String { + guard let device else { return " " } + return dateFormattedString( + format: OWSLocalizedString( + "DEVICE_LINKED_AT_LABEL", + comment: "{{Short Date}} when device was linked." + ), + date: device.device.createdAt + ) + } + + private var lastSeenDateString: String { + guard let device else { return " " } + return dateFormattedString( + format: OWSLocalizedString( + "DEVICE_LAST_ACTIVE_AT_LABEL", + comment: "{{Short Date}} when device last communicated with Signal Server." + ), + // lastSeenAt is stored at day granularity. At midnight UTC. + // Making it likely that when you first link a device it will + // be "last seen" the day before it was created, which looks broken. + date: max( + device.device.createdAt, + device.device.lastSeenAt + ) + ) + } + + var body: some View { + HStack(spacing: 12) { + Image("devices") + .padding(6) + .background(Color(.systemFill), in: .circle) + + VStack(alignment: .leading) { + Text(self.deviceName) + Group { + Text(linkedDateString) + Text(lastSeenDateString) + } + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + } +} + +// MARK: - Previews + +@available(iOS 17, *) +#Preview { + LinkedDevicesHostingController() +} diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index e94565ab65..2919b91247 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -4072,6 +4072,18 @@ /* A button shown in a banner when you tap the 'New Chat' icon at the top of the list of chats. Tapping it opens a website with more details. */ "LINKED_DEVICE_MANAGE_CONTACTS_LEARN_MORE" = "Learn more"; +/* Text that appears where the linked device list would be indicating that there are no linked devices. */ +"LINKED_DEVICES_EMPTY_STATE" = "No linked devices"; + +/* Description for header of the linked devices list */ +"LINKED_DEVICES_HEADER_DESCRIPTION" = "Use Signal on desktop or iPad. Your messages will sync to your linked devices."; + +/* Footer text below the list of currently-linked devices */ +"LINKED_DEVICES_LIST_FOOTER" = "Messages and chat info are protected by end-to-end encryption on all devices"; + +/* Title above the list of currently-linked devices */ +"LINKED_DEVICES_LIST_TITLE" = "Linked Devices"; + /* Menu item and navbar title for the device manager */ "LINKED_DEVICES_TITLE" = "Linked Devices"; diff --git a/SignalServiceKit/Util/FeatureFlags.swift b/SignalServiceKit/Util/FeatureFlags.swift index e9750efd5e..607b78d4fb 100644 --- a/SignalServiceKit/Util/FeatureFlags.swift +++ b/SignalServiceKit/Util/FeatureFlags.swift @@ -57,7 +57,8 @@ public enum FeatureFlags { public static let versionedExpireTimer = true - public static let biometricLinkedDeviceFlow = build.includes(.dev) + public static let linkedDevicesRedesign = build.includes(.internal) + public static let biometricLinkedDeviceFlow = build.includes(.internal) public static let storageServiceRecordIkmMigration = false } diff --git a/SignalUI/Appearance/SignalSymbols.swift b/SignalUI/Appearance/SignalSymbols.swift index 07c0cdcc56..6a9dfd8efa 100644 --- a/SignalUI/Appearance/SignalSymbols.swift +++ b/SignalUI/Appearance/SignalSymbols.swift @@ -4,6 +4,7 @@ // import SignalServiceKit +public import SwiftUI public enum SignalSymbol: Character { @@ -117,7 +118,7 @@ public enum SignalSymbol: Character { case regular case bold - private var fontName: String { + fileprivate var fontName: String { switch self { case .light: return "SignalSymbols-Light" @@ -242,4 +243,28 @@ public enum SignalSymbol: Character { attributes: attributes ) } + + // MARK: - SwiftUI + + /// Creates a SwiftUI `Text` view with the specified dynamic type size and weight. + /// + /// Can be combined with other `Text` views with the `+` operator. + /// For example: + /// + /// ```swift + /// SignalSymbol.arrowUp.text(dynamicTypeBaseSize: 16) + + /// Text(" Share") + /// ``` + /// - Parameters: + /// - dynamicTypeBaseSize: The base size of the font at 100% Dynamic Type + /// scale which will then be scaled based on the current device scale. + /// - weight: The font weight. + /// - Returns: A SwiftUI `Text` view with this symbol and the given font. + public func text( + dynamicTypeBaseSize: CGFloat, + weight: Weight = .regular + ) -> Text { + Text("\(self.rawValue)") + .font(Font.custom(weight.fontName, size: dynamicTypeBaseSize)) + } } diff --git a/SignalUI/SwiftUIExtensions/HostingController.swift b/SignalUI/SwiftUIExtensions/HostingController.swift index 34fef57fbf..5342e98eff 100644 --- a/SignalUI/SwiftUIExtensions/HostingController.swift +++ b/SignalUI/SwiftUIExtensions/HostingController.swift @@ -26,6 +26,39 @@ extension EnvironmentValues { } } +// MARK: - HostingContainer + +/// Container view controller around ``HostingController``. +/// Useful when you want to manually set navigation item bar buttons from a +/// UIKit context, to avoid `UIHostingController`'s behavior of only displaying +/// bar buttons once fully appeared. +open class HostingContainer: UIViewController { + private let hostingController: HostingController + + public init(wrappedView: Wrapped) { + self.hostingController = .init(wrappedView: wrappedView) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("unimplemented") + } + + open override func viewDidLoad() { + addChild(hostingController) + view.addSubview(hostingController.view) + hostingController.view.autoPinEdgesToSuperviewEdges() + hostingController.didMove(toParent: self) + } +} + +extension HostingContainer: OWSNavigationChildController { + public var childForOWSNavigationConfiguration: (any OWSNavigationChildController)? { + hostingController + } +} + // MARK: - HostingController /// Extends UIHostingController by wrapping its `rootView` and adding additional