Device linking redesign

This commit is contained in:
Elaine 2024-11-13 19:03:28 -07:00 committed by GitHub
parent b62e7da708
commit aefb384f57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 877 additions and 8 deletions

View File

@ -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 */,

View 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
}
}

View File

@ -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)
}
))

View File

@ -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

View File

@ -587,7 +587,7 @@ extension LinkedDevicesTableViewController: LinkDeviceViewControllerDelegate {
}
}
func expectMoreDevices() {
private func expectMoreDevices() {
isExpectingMoreDevices = true

View File

@ -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()
}

View File

@ -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";

View File

@ -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
}

View File

@ -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))
}
}

View File

@ -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