Device linking redesign
This commit is contained in:
parent
b62e7da708
commit
aefb384f57
@ -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 = "<group>"; };
|
||||
05EA61432CC943D200B16D4E /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = "<group>"; };
|
||||
05EA61442CC943DD00B16D4E /* Project.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Project.xcconfig; sourceTree = "<group>"; };
|
||||
05EA61472CC9491400B16D4E /* User.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = User.xcconfig; sourceTree = "<group>"; };
|
||||
05FDBC282CD91B31000C87BC /* ChatListContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListContainerView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@ -5421,6 +5421,7 @@
|
||||
B95A765D2B76E93500AA7E97 /* FindByUsernameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindByUsernameViewController.swift; sourceTree = "<group>"; };
|
||||
B95BBAC12BB36025009EFB4A /* ProfileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileName.swift; sourceTree = "<group>"; };
|
||||
B96D6D782B9F83270039EB99 /* SignalSymbols-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Regular.otf"; sourceTree = "<group>"; };
|
||||
B96FEE2E2CDC297500836191 /* User.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = User.xcconfig; sourceTree = "<group>"; };
|
||||
B9754F532C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationAvatarView+SwiftUI.swift"; sourceTree = "<group>"; };
|
||||
B982ACFE2BA8FD2A00AD7E81 /* SignalSymbols-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Bold.otf"; sourceTree = "<group>"; };
|
||||
B982AD002BA8FD3100AD7E81 /* SignalSymbols-Light.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Light.otf"; sourceTree = "<group>"; };
|
||||
@ -5439,6 +5440,7 @@
|
||||
B9E322D62CD024A2006DAF3B /* linking-device-dark.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "linking-device-dark.json"; sourceTree = "<group>"; };
|
||||
B9E322D72CD024A2006DAF3B /* linking-device-light.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "linking-device-light.json"; sourceTree = "<group>"; };
|
||||
B9E322E62CD170ED006DAF3B /* SignalList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalList.swift; sourceTree = "<group>"; };
|
||||
B9E322E82CD191CF006DAF3B /* LinkedDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedDevicesView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
B9F2155E2A940596002DCAE0 /* ImageEditorStickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEditorStickerItem.swift; sourceTree = "<group>"; };
|
||||
@ -7409,7 +7411,7 @@
|
||||
05EA61422CC9437F00B16D4E /* Project-Debug.xcconfig */,
|
||||
05EA61432CC943D200B16D4E /* Project-Release.xcconfig */,
|
||||
05EA61442CC943DD00B16D4E /* Project.xcconfig */,
|
||||
05EA61472CC9491400B16D4E /* User.xcconfig */,
|
||||
B96FEE2E2CDC297500836191 /* User.xcconfig */,
|
||||
);
|
||||
path = Config;
|
||||
sourceTree = "<group>";
|
||||
@ -10647,6 +10649,7 @@
|
||||
children = (
|
||||
505C2ED32997015800C23FB2 /* LinkDeviceViewController.swift */,
|
||||
3437F6392512835200AC1767 /* LinkedDevicesTableViewController.swift */,
|
||||
B9E322E82CD191CF006DAF3B /* LinkedDevicesView.swift */,
|
||||
);
|
||||
path = "Linked Devices";
|
||||
sourceTree = "<group>";
|
||||
@ -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 */,
|
||||
|
||||
25
Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/Contents.json
vendored
Normal file
25
Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/linked-device-intro-dark.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/device-linking/linked-device-intro-dark.imageset/linked-device-intro-dark.pdf
vendored
Normal file
Binary file not shown.
Binary file not shown.
@ -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)
|
||||
}
|
||||
))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -587,7 +587,7 @@ extension LinkedDevicesTableViewController: LinkDeviceViewControllerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func expectMoreDevices() {
|
||||
private func expectMoreDevices() {
|
||||
|
||||
isExpectingMoreDevices = true
|
||||
|
||||
|
||||
@ -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<Presentation, Never>()
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
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<Void, Error>?) {
|
||||
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<LinkedDevicesView> {
|
||||
private let viewModel = LinkedDevicesViewModel()
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
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()
|
||||
}
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Wrapped: View>: UIViewController {
|
||||
private let hostingController: HostingController<Wrapped>
|
||||
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user