From 427fb1e71376f52b0a0cc821402521be587068e3 Mon Sep 17 00:00:00 2001 From: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> Date: Tue, 7 Jan 2025 15:04:51 -0500 Subject: [PATCH] account: implement export-from-device https://git.jami.net/savoirfairelinux/jami-client-qt/-/issues/1695 Change-Id: Ia8d71c8351620247b1695fb4a3cce6798f55100b --- Ring/Ring.xcodeproj/project.pbxproj | 8 + .../AccountCreation/AccountAdapter.mm | 2 - .../NameRegistrationAdapter.mm | 1 - Ring/Ring/Constants/Generated/Strings.swift | 77 ++-- Ring/Ring/Extensions/Video+Helpers.swift | 38 ++ .../Settings/Me/AccountSummaryView.swift | 23 +- .../Features/Settings/Me/LinkDeviceVM.swift | 168 +++++++++ .../Features/Settings/Me/LinkDeviceView.swift | 350 ++++++++++++------ .../Settings/Me/LinkedDevicesVM.swift | 123 +----- .../Settings/Me/LinkedDevicesView.swift | 21 +- .../Settings/Me/ManageAccountView.swift | 2 - .../Features/Settings/Me/UtilitiesViews.swift | 8 +- .../Walkthrough/Models/LinkToAccountVM.swift | 41 +- .../Walkthrough/Views/LinkToAccountView.swift | 189 ++++------ .../Features/Walkthrough/Views/Views.swift | 214 ++++++----- Ring/Ring/Helpers/SwiftUIViews.swift | 114 ++++++ Ring/Ring/QRCode/ScanViewController.swift | 14 +- .../Resources/en.lproj/Localizable.strings | 36 +- 18 files changed, 880 insertions(+), 549 deletions(-) create mode 100644 Ring/Ring/Extensions/Video+Helpers.swift create mode 100644 Ring/Ring/Features/Settings/Me/LinkDeviceVM.swift diff --git a/Ring/Ring.xcodeproj/project.pbxproj b/Ring/Ring.xcodeproj/project.pbxproj index 57f2a6b35..95f727600 100644 --- a/Ring/Ring.xcodeproj/project.pbxproj +++ b/Ring/Ring.xcodeproj/project.pbxproj @@ -149,6 +149,7 @@ 1DF75AC6296E0C2A0055EA87 /* AddMoreParticipantsInSwarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF75AC5296E0C2A0055EA87 /* AddMoreParticipantsInSwarm.swift */; }; 26001BD82D6E5D1A009A8E23 /* libnatpmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 26001BD72D6E5C8F009A8E23 /* libnatpmp.a */; }; 26001BD92D6E5E40009A8E23 /* libnatpmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 26001BD72D6E5C8F009A8E23 /* libnatpmp.a */; }; + 26001BDB2D6FF5F6009A8E23 /* Video+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26001BDA2D6FF5F6009A8E23 /* Video+Helpers.swift */; }; 26069B6724C9FCE8002361A3 /* ObjCHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 26069B6624C9FCE8002361A3 /* ObjCHandler.m */; }; 2607176E2CAC454E00494875 /* ComposeNewMessageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2607176D2CAC454E00494875 /* ComposeNewMessageCoordinator.swift */; }; 260717702CAD901D00494875 /* ConversationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2607176F2CAD901D00494875 /* ConversationDataSource.swift */; }; @@ -402,6 +403,7 @@ 26D08AB9269628F400E37574 /* RequestsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D08AB8269628F400E37574 /* RequestsService.swift */; }; 26D08ABB2696293100E37574 /* RequestsAdapterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D08ABA2696293100E37574 /* RequestsAdapterDelegate.swift */; }; 26D08ABE2696296300E37574 /* RequestsAdapter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 26D08ABD2696296300E37574 /* RequestsAdapter.mm */; }; + 26D3B08C2D3075F800975914 /* LinkDeviceVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D3B08B2D3075F800975914 /* LinkDeviceVM.swift */; }; 26D87CEB2BBB4A5F0086E4AA /* AccountsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D87CEA2BBB4A5F0086E4AA /* AccountsViewModel.swift */; }; 26D8F54A2A6C08D20044398A /* TopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D8F5492A6C08D20044398A /* TopView.swift */; }; 26DA813224B641A5006C6E23 /* ProfilesAdapter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 26DA813124B641A5006C6E23 /* ProfilesAdapter.mm */; }; @@ -790,6 +792,7 @@ 1DF75AC3296E0A940055EA87 /* View+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Helpers.swift"; sourceTree = "<group>"; }; 1DF75AC5296E0C2A0055EA87 /* AddMoreParticipantsInSwarm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMoreParticipantsInSwarm.swift; sourceTree = "<group>"; }; 26001BD72D6E5C8F009A8E23 /* libnatpmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libnatpmp.a; path = ../fat/lib/libnatpmp.a; sourceTree = "<group>"; }; + 26001BDA2D6FF5F6009A8E23 /* Video+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Video+Helpers.swift"; sourceTree = "<group>"; }; 26069B6524C9FCE8002361A3 /* ObjCHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ObjCHandler.h; sourceTree = "<group>"; }; 26069B6624C9FCE8002361A3 /* ObjCHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjCHandler.m; sourceTree = "<group>"; }; 2607176D2CAC454E00494875 /* ComposeNewMessageCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ComposeNewMessageCoordinator.swift; path = Ring/Features/Conversations/ComposeNewMessageCoordinator.swift; sourceTree = SOURCE_ROOT; }; @@ -1006,6 +1009,7 @@ 26D08ABA2696293100E37574 /* RequestsAdapterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestsAdapterDelegate.swift; sourceTree = "<group>"; }; 26D08ABC2696296300E37574 /* RequestsAdapter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RequestsAdapter.h; sourceTree = "<group>"; }; 26D08ABD2696296300E37574 /* RequestsAdapter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RequestsAdapter.mm; sourceTree = "<group>"; }; + 26D3B08B2D3075F800975914 /* LinkDeviceVM.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkDeviceVM.swift; sourceTree = "<group>"; }; 26D87CEA2BBB4A5F0086E4AA /* AccountsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountsViewModel.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Models/AccountsViewModel.swift; sourceTree = SOURCE_ROOT; }; 26D8F5492A6C08D20044398A /* TopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopView.swift; sourceTree = "<group>"; }; 26DA813024B641A5006C6E23 /* ProfilesAdapter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ProfilesAdapter.h; sourceTree = "<group>"; }; @@ -1615,6 +1619,7 @@ 621231F81F880EDF009B86F0 /* UILabel+Ring.swift */, 0EDCC85F1F98150500B121D7 /* UIView+Rx.swift */, 62006E03203F4DD6003C3197 /* UITextField+Helpers.swift */, + 26001BDA2D6FF5F6009A8E23 /* Video+Helpers.swift */, 0E13A91B22B844B100A12A54 /* NSUserActivity+Call.swift */, 648AF76C24ED7CA90004D727 /* UITextView+Helpers.swift */, BB4C6E2529229131001C901A /* ColorExtension.swift */, @@ -2418,6 +2423,7 @@ 26FDF43E2C1353BA009D036B /* AccountSummaryVM.swift */, 268C87E32C1793E300593D7C /* NameRegistrationVM.swift */, 268C87E72C1B6E2000593D7C /* BlockedContactsVM.swift */, + 26D3B08B2D3075F800975914 /* LinkDeviceVM.swift */, 268C87F12C1CA74A00593D7C /* EncryptAccountVM.swift */, 268C87F52C1CC78100593D7C /* LinkedDevicesVM.swift */, 267B95692C20C99A00353B01 /* EditProfileVM.swift */, @@ -2922,6 +2928,7 @@ 26A072FD295A7145006D8163 /* ContextMenuVM.swift in Sources */, 1A2D18C71F29180700B2C785 /* DeviceModel.swift in Sources */, 26BCBBD52A965EFC0001EE38 /* PictureInPictureManager.swift in Sources */, + 26001BDB2D6FF5F6009A8E23 /* Video+Helpers.swift in Sources */, 1A20418F1F1EAC0E00C08435 /* Coordinator.swift in Sources */, 268C87E62C17945C00593D7C /* NameRegistrationView.swift in Sources */, 0E49097C1FEACA4B005CAA50 /* CallViewModel.swift in Sources */, @@ -3060,6 +3067,7 @@ 268C87E82C1B6E2000593D7C /* BlockedContactsVM.swift in Sources */, 642AD48424EC64CE00521127 /* CopyableLabel.swift in Sources */, 269255782C5B18DB00F85A1E /* LinkToAccountView.swift in Sources */, + 26D3B08C2D3075F800975914 /* LinkDeviceVM.swift in Sources */, 261190B42A2928D000CAE7F8 /* ParticipantViewModel.swift in Sources */, 26A34EDC2C6EBCD600A41DD4 /* BackupAccountModel.swift in Sources */, 264EA8802977082A00B6FB6F /* LogUIViewModel.swift in Sources */, diff --git a/Ring/Ring/Bridging/AccountCreation/AccountAdapter.mm b/Ring/Ring/Bridging/AccountCreation/AccountAdapter.mm index 40eb42d4e..52b6abec1 100644 --- a/Ring/Ring/Bridging/AccountCreation/AccountAdapter.mm +++ b/Ring/Ring/Bridging/AccountCreation/AccountAdapter.mm @@ -106,8 +106,6 @@ static id <AccountAdapterDelegate> _delegate; } })); - - confHandlers.insert(exportable_callback<ConfigurationSignal::KnownDevicesChanged>([&](const std::string& account_id, const std::map<std::string, std::string>& devices) { if (AccountAdapter.delegate) { NSString* accountId = [NSString stringWithUTF8String:account_id.c_str()]; diff --git a/Ring/Ring/Bridging/NameRegistration/NameRegistrationAdapter.mm b/Ring/Ring/Bridging/NameRegistration/NameRegistrationAdapter.mm index d86c265df..a94843811 100644 --- a/Ring/Ring/Bridging/NameRegistration/NameRegistrationAdapter.mm +++ b/Ring/Ring/Bridging/NameRegistration/NameRegistrationAdapter.mm @@ -49,7 +49,6 @@ static id <NameRegistrationAdapterDelegate> _delegate; confHandlers .insert(exportable_callback<ConfigurationSignal::RegisteredNameFound>([&](const std::string& account_id, const std::string& requested_name, - int state, const std::string address, const std::string& name) { diff --git a/Ring/Ring/Constants/Generated/Strings.swift b/Ring/Ring/Constants/Generated/Strings.swift index 9e4cf23ce..d85795ff2 100644 --- a/Ring/Ring/Constants/Generated/Strings.swift +++ b/Ring/Ring/Constants/Generated/Strings.swift @@ -846,6 +846,8 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "global.confirm", fallback: "Confirm") /// Confirm password internal static let confirmPassword = L10n.tr("Localizable", "global.confirmPassword", fallback: "Confirm password") + /// Connect + internal static let connect = L10n.tr("Localizable", "global.connect", fallback: "Connect") /// Copy internal static let copy = L10n.tr("Localizable", "global.copy", fallback: "Copy") /// Create @@ -932,8 +934,13 @@ internal enum L10n { internal static let noInvitations = L10n.tr("Localizable", "invitations.noInvitations", fallback: "No invitations") } internal enum LinkDevice { - /// An error occurred while exporting the account. - internal static let defaultError = L10n.tr("Localizable", "linkDevice.defaultError", fallback: "An error occurred while exporting the account.") + /// New device found at IP address below. Is that you? + /// To continue to transfer the account, click `Confirm`. + internal static let authenticationInfo = L10n.tr("Localizable", "linkDevice.authenticationInfo", fallback: "New device found at IP address below. Is that you?\nTo continue to transfer the account, click `Confirm`.") + /// Account imported successfully on the new device. + internal static let completed = L10n.tr("Localizable", "linkDevice.completed", fallback: "Account imported successfully on the new device.") + /// Connecting to your new device… + internal static let connecting = L10n.tr("Localizable", "linkDevice.connecting", fallback: "Connecting to your new device…") /// A network error occurred. /// Please verify your connection. internal static let errorNetwork = L10n.tr("Localizable", "linkDevice.errorNetwork", fallback: "A network error occurred.\nPlease verify your connection.") @@ -949,16 +956,32 @@ internal enum L10n { /// An authentication error occurred. /// Please verify your password. internal static let errorWrongPassword = L10n.tr("Localizable", "linkDevice.errorWrongPassword", fallback: "An authentication error occurred.\nPlease verify your password.") - /// Open Jami on the new device and choose “Link this device to an account” to complete the process. The PIN code will expire in 10 minutes. - internal static let explanationMessage = L10n.tr("Localizable", "linkDevice.explanationMessage", fallback: "Open Jami on the new device and choose “Link this device to an account” to complete the process. The PIN code will expire in 10 minutes.") - /// Verifying - internal static let hudMessage = L10n.tr("Localizable", "linkDevice.hudMessage", fallback: "Verifying") + /// The export account operation to the new device is in progress. + /// Please confirm import on the new device. + internal static let exportInProgress = L10n.tr("Localizable", "linkDevice.exportInProgress", fallback: "The export account operation to the new device is in progress.\nPlease confirm import on the new device.") + /// Select Add Account > Connect from another device. + /// + internal static let infoAddAccount = L10n.tr("Localizable", "linkDevice.infoAddAccount", fallback: "Select Add Account > Connect from another device.\n") + /// When ready, enter the code and press `Connect`. + internal static let infoCode = L10n.tr("Localizable", "linkDevice.infoCode", fallback: "When ready, enter the code and press `Connect`.") + /// When ready, scan the QR code + internal static let infoQRCode = L10n.tr("Localizable", "linkDevice.infoQRCode", fallback: "When ready, scan the QR code") + /// On the new device, initiate a new account. + /// + internal static let initialInfo = L10n.tr("Localizable", "linkDevice.initialInfo", fallback: "On the new device, initiate a new account.\n") /// A network error occurred while exporting the account. internal static let networkError = L10n.tr("Localizable", "linkDevice.networkError", fallback: "A network error occurred while exporting the account.") - /// Incorrect password. Please try again with the correct password. - internal static let passwordError = L10n.tr("Localizable", "linkDevice.passwordError", fallback: "Incorrect password. Please try again with the correct password.") + /// New device IP address: %1@ + internal static func newDeviceIP(_ p1: Any) -> String { + return L10n.tr("Localizable", "linkDevice.newDeviceIP", String(describing: p1), fallback: "New device IP address: %1@") + } /// Link new device internal static let title = L10n.tr("Localizable", "linkDevice.title", fallback: "Link new device") + /// token + internal static let token = L10n.tr("Localizable", "linkDevice.token", fallback: "token") + /// New device identifier is unrecognized. + /// Please follow above instruction. + internal static let wrongEntry = L10n.tr("Localizable", "linkDevice.wrongEntry", fallback: "New device identifier is unrecognized.\nPlease follow above instruction.") } internal enum LinkToAccount { /// The account is protected with a password. @@ -967,42 +990,26 @@ internal enum L10n { /// Action required. /// Please confirm account on the source device. internal static let actionRequired = L10n.tr("Localizable", "linkToAccount.actionRequired", fallback: "Action required.\nPlease confirm account on the source device.") - /// Exiting now will cancel the account importation process. - internal static let alertMessage = L10n.tr("Localizable", "linkToAccount.alertMessage", fallback: "Exiting now will cancel the account importation process.") - /// Are you sure you want to exit? - internal static let alertTile = L10n.tr("Localizable", "linkToAccount.alertTile", fallback: "Are you sure you want to exit?") - /// Account imported successfully on the new device. - internal static let allSet = L10n.tr("Localizable", "linkToAccount.allSet", fallback: "Account imported successfully on the new device.") + /// Exiting will cancel the import account operation. + internal static let alertMessage = L10n.tr("Localizable", "linkToAccount.alertMessage", fallback: "Exiting will cancel the import account operation.") + /// Do you want to exit? + internal static let alertTile = L10n.tr("Localizable", "linkToAccount.alertTile", fallback: "Do you want to exit?") + /// Account imported successfully. + internal static let allSet = L10n.tr("Localizable", "linkToAccount.allSet", fallback: "Account imported successfully.") /// When ready, enter the authentication code. internal static let enterProvidedCode = L10n.tr("Localizable", "linkToAccount.enterProvidedCode", fallback: "When ready, enter the authentication code.") /// Exit internal static let exit = L10n.tr("Localizable", "linkToAccount.exit", fallback: "Exit") - /// A PIN code is required to use an existing Jami account on this device. - internal static let explanationMessage = L10n.tr("Localizable", "linkToAccount.explanationMessage", fallback: "A PIN code is required to use an existing Jami account on this device.") - /// To generate the PIN code, go to the account management settings on the device containing the account you want to link to. Select “Link new device”. You will receive the necessary PIN code to complete this form. The PIN code will expire in 10 minutes. - internal static let explanationPinMessage = L10n.tr("Localizable", "linkToAccount.explanationPinMessage", fallback: "To generate the PIN code, go to the account management settings on the device containing the account you want to link to. Select “Link new device”. You will receive the necessary PIN code to complete this form. The PIN code will expire in 10 minutes.") /// On the source device, initiate the exportation. internal static let exportInstructions = L10n.tr("Localizable", "linkToAccount.exportInstructions", fallback: "On the source device, initiate the exportation.") /// Select Account > Account Settings > Link new device. internal static let exportInstructionsPath = L10n.tr("Localizable", "linkToAccount.exportInstructionsPath", fallback: "Select Account > Account Settings > Link new device.") - /// Go to my new account - internal static let goToAccounts = L10n.tr("Localizable", "linkToAccount.goToAccounts", fallback: "Go to my new account") + /// Go to imported account + internal static let goToAccounts = L10n.tr("Localizable", "linkToAccount.goToAccounts", fallback: "Go to imported account") /// Import account internal static let importAccount = L10n.tr("Localizable", "linkToAccount.importAccount", fallback: "Import account") - /// Link - internal static let linkButtonTitle = L10n.tr("Localizable", "linkToAccount.linkButtonTitle", fallback: "Link") - /// Choose “Link new device” from another Jami app to show the QR code or generate a PIN code. - internal static let linkDeviceMessage = L10n.tr("Localizable", "linkToAccount.linkDeviceMessage", fallback: "Choose “Link new device” from another Jami app to show the QR code or generate a PIN code.") - /// Link device - internal static let linkDeviceTitle = L10n.tr("Localizable", "linkToAccount.linkDeviceTitle", fallback: "Link device") - /// Enter PIN code - internal static let pinLabel = L10n.tr("Localizable", "linkToAccount.pinLabel", fallback: "Enter PIN code") - /// PIN code - internal static let pinPlaceholder = L10n.tr("Localizable", "linkToAccount.pinPlaceholder", fallback: "PIN code") - /// When ready, scan the QR Code. - internal static let scanQrCode = L10n.tr("Localizable", "linkToAccount.scanQrCode", fallback: "When ready, scan the QR Code.") - /// Scan QR code - internal static let scanQRCode = L10n.tr("Localizable", "linkToAccount.scanQRCode", fallback: "Scan QR code") + /// When ready, scan the QR code. + internal static let scanQrCode = L10n.tr("Localizable", "linkToAccount.scanQrCode", fallback: "When ready, scan the QR code.") /// Your code is: %@ internal static func shareMessage(_ p1: Any) -> String { return L10n.tr("Localizable", "linkToAccount.shareMessage", String(describing: p1), fallback: "Your code is: %@") @@ -1011,8 +1018,6 @@ internal enum L10n { internal static let showPinCode = L10n.tr("Localizable", "linkToAccount.showPinCode", fallback: "Authentication code") /// QR code internal static let showQrCode = L10n.tr("Localizable", "linkToAccount.showQrCode", fallback: "QR code") - /// Account linking - internal static let waitLinkToAccountTitle = L10n.tr("Localizable", "linkToAccount.waitLinkToAccountTitle", fallback: "Account linking") } internal enum LinkToAccountManager { /// Enter JAMS URL diff --git a/Ring/Ring/Extensions/Video+Helpers.swift b/Ring/Ring/Extensions/Video+Helpers.swift new file mode 100644 index 000000000..3feed6dca --- /dev/null +++ b/Ring/Ring/Extensions/Video+Helpers.swift @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import Foundation + +extension AVCaptureVideoOrientation { + init(_ orientation: UIInterfaceOrientation) { + switch orientation { + case .portrait: + self = .portrait + case .portraitUpsideDown: + self = .portraitUpsideDown + case .landscapeLeft: + self = .landscapeLeft + case .landscapeRight: + self = .landscapeRight + case .unknown: + self = .portrait + @unknown default: + self = .portrait + } + } +} diff --git a/Ring/Ring/Features/Settings/Me/AccountSummaryView.swift b/Ring/Ring/Features/Settings/Me/AccountSummaryView.swift index 660a5fade..79e442111 100644 --- a/Ring/Ring/Features/Settings/Me/AccountSummaryView.swift +++ b/Ring/Ring/Features/Settings/Me/AccountSummaryView.swift @@ -30,9 +30,6 @@ struct AccountSummaryView: View { @SwiftUI.State private var showAccountRegistration = false @SwiftUI.State private var showQRcode = false - @Environment(\.presentationMode) - var presentation - let avatarSize: CGFloat = 60 init(injectionBag: InjectionBag, account: AccountModel) { @@ -78,15 +75,19 @@ struct AccountSummaryView: View { SettingsRow(iconName: "link", title: L10n.AccountPage.linkedDevices) } ShareButtonView(infoToShare: model.accountInfoToShare) { - Group { - Image(systemName: "envelope") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - .padding(.trailing, 5) - Text(L10n.Smartlist.inviteFriends) + HStack { + Spacer() + Group { + Image(systemName: "envelope") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .padding(.trailing, 5) + Text(L10n.Smartlist.inviteFriends) + } + .foregroundColor(.jamiColor) + Spacer() } - .foregroundColor(.jamiColor) } } } diff --git a/Ring/Ring/Features/Settings/Me/LinkDeviceVM.swift b/Ring/Ring/Features/Settings/Me/LinkDeviceVM.swift new file mode 100644 index 000000000..155a32b10 --- /dev/null +++ b/Ring/Ring/Features/Settings/Me/LinkDeviceVM.swift @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import Foundation +import RxSwift +import SwiftUI + +enum AddDeviceExportState { + case initial(error: AuthError? = nil) + case connecting + case authenticating(peerAddress: String?) + case inProgress + case success + case error(error: String) + + func isCancelableState() -> Bool { + switch self { + case .initial, .success, .error: + return true + default: + return false + } + } +} + +class LinkDeviceVM: ObservableObject { + static let schema = "jami-auth://" + @Published var exportState: AddDeviceExportState = .initial() + @Published var exportToken: String = "jami-auth://" + @Published var entryError: String? + let account: AccountModel + let accountService: AccountsService + + let disposeBag = DisposeBag() + var codeProvided = false + + private var operationId: UInt32 = 0 + + init(account: AccountModel, accountService: AccountsService) { + self.account = account + self.accountService = accountService + } + + func cleanState() { + entryError = nil + exportToken = LinkDeviceVM.schema + } + + func handleAuthenticationUri(_ jamiAuthentication: String) { + entryError = nil + if codeProvided { + return + } + guard !jamiAuthentication.isEmpty, + jamiAuthentication.hasPrefix(LinkDeviceVM.schema), + jamiAuthentication.count == 59 else { + entryError = L10n.LinkDevice.wrongEntry + return + } + + codeProvided = true + + self.accountService.authStateSubject + .filter { [weak self] authResult in + guard let self = self else { return false } + return authResult.accountId == self.account.id && + authResult.operationId == self.operationId + } + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] authResult in + self?.handleAuthResult(authResult) + }) + .disposed(by: disposeBag) + + operationId = self.accountService.addDevice(accountId: account.id, token: jamiAuthentication) + } + + private func handleAuthResult(_ result: AuthResult) { + guard checkNewStateValidity(newState: result.state) else { + print("Invalid state transition: \(exportState) -> \(result.state)") + return + } + + print("Processing signal: \(result.accountId):\(result.operationId):\(result.state) \(result.details)") + + switch result.state { + case .initializing: + break + case .connecting: + handleConnectingSignal() + case .authenticating: + handleAuthenticatingSignal(result.details) + case .inProgress: + handleInProgressSignal() + case .done: + handleDoneSignal(result.details) + case .tokenAvailable: + break + } + } + + private func checkNewStateValidity(newState: AuthState) -> Bool { + let validStates: [AuthState] + + switch exportState { + case .initial: validStates = [.connecting, .done] + case .connecting: validStates = [.authenticating, .done] + case .authenticating: validStates = [.inProgress, .done] + case .inProgress: validStates = [.inProgress, .done] + case .error, .success: validStates = [.done] + } + + return validStates.contains(newState) + } + + private func handleConnectingSignal() { + withAnimation { self.exportState = .connecting } + } + + private func handleAuthenticatingSignal(_ details: [String: String]) { + let peerAddress = details[LinkDeviceConstants.Keys.peerAddress] + withAnimation { self.exportState = .authenticating(peerAddress: peerAddress) } + } + + private func handleInProgressSignal() { + withAnimation { self.exportState = .inProgress } + } + + private func handleDoneSignal(_ details: [String: String]) { + if let errorString = details[LinkDeviceConstants.Keys.error], + !errorString.isEmpty, + errorString != "none", + let error = AuthError(rawValue: errorString) { + withAnimation { self.exportState = .error(error: error.message()) } + } else { + withAnimation { self.exportState = .success } + } + } + + func confirmAddDevice() { + self.handleInProgressSignal() + accountService.confirmAddDevice(accountId: account.id, operationId: operationId) + } + + func cancelAddDevice() { + self.handleInProgressSignal() + accountService.cancelAddDevice(accountId: account.id, operationId: operationId) + } + + func shouldShowAlert() -> Bool { + return !self.exportState.isCancelableState() + } +} diff --git a/Ring/Ring/Features/Settings/Me/LinkDeviceView.swift b/Ring/Ring/Features/Settings/Me/LinkDeviceView.swift index 62fd519ba..01dbda0b3 100644 --- a/Ring/Ring/Features/Settings/Me/LinkDeviceView.swift +++ b/Ring/Ring/Features/Settings/Me/LinkDeviceView.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Savoir-faire Linux Inc. + * Copyright (C) 2024-2025 Savoir-faire Linux Inc. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,138 +18,274 @@ import SwiftUI +typealias EntryMode = DeviceLinkingMode + struct LinkDeviceView: View { - @ObservedObject var model: LinkedDevicesVM - @SwiftUI.State var askForPassword: Bool - @SwiftUI.State var password: String = "" + @StateObject var model: LinkDeviceVM + @SwiftUI.State var entryMode: DeviceLinkingMode = .qrCode + @Environment(\.verticalSizeClass) + var verticalSizeClass + @Environment(\.colorScheme) + var colorScheme + @SwiftUI.State private var showAlert = false + @Environment(\.presentationMode) + var presentation + + init(account: AccountModel, accountService: AccountsService) { + _model = StateObject(wrappedValue: + LinkDeviceVM(account: account, + accountService: accountService)) + } var body: some View { - CustomAlert(content: { createLinkDeviceView() }) + createLinkDeviceView() + .padding() + .frame(maxWidth: 500) + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(L10n.LinkDevice.title) + .toolbar { toolbarContent } + .alert(isPresented: $showAlert, content: alertContent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(UIColor.systemGroupedBackground) + .ignoresSafeArea()) } + @ViewBuilder func createLinkDeviceView() -> some View { - VStack(spacing: 20) { - if askForPassword { - passwordView() - } else { - switch model.generatingState { - case .initial, .generatingPin: - loadingView() - case .success(let pin): - successView(pin: pin) - case .error(let error): - errorView(error: error.description) - } - } + switch model.exportState { + case .initial: + initialView + case .connecting: + connectingView() + case .authenticating(peerAddress: let peerAddress): + authenticatedView(address: peerAddress ?? "") + case .inProgress: + loadingView() + case .error(let error): + errorView(error) + case .success: + successView() } } - func loadingView() -> some View { - VStack { - SwiftUI.ProgressView(L10n.AccountPage.generatingPin) - .padding() + @ViewBuilder private var initialView: some View { + if verticalSizeClass == .regular { + portraitView + } else { + landscapeView } - .frame(minWidth: 280, minHeight: 150) - } - - func passwordView() -> some View { - VStack(spacing: 20) { - Text(L10n.LinkDevice.title) - .font(.headline) - Text(L10n.AccountPage.passwordForPin) - .font(.subheadline) - PasswordFieldView(text: $password, placeholder: L10n.Global.enterPassword) - .textFieldStyleInAlert() - HStack { - Button(action: { - withAnimation { - model.showLinkDeviceAlert = false - } - }, label: { - Text(L10n.Global.cancel) - .foregroundColor(.jamiColor) - }) + } + + private var portraitView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 30) { + info + tokenView + .frame(maxWidth: 500) Spacer() - Button(action: { - withAnimation { - askForPassword = false - } - model.linkDevice(with: password) - }, label: { - Text(L10n.LinkToAccount.linkButtonTitle) - .foregroundColor(.jamiColor) - }) - .disabled(password.isEmpty) - .opacity(password.isEmpty ? 0.5 : 1) } } } - func successView(pin: String) -> some View { - VStack(spacing: 20) { - Text("\(pin)") - .foregroundColor(.jamiColor) - .font(.headline) - .conditionalTextSelection() - if let image = model.PINImage { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 140, height: 140) - .accessibilityHidden(true) + private var landscapeView: some View { + HStack(spacing: 30) { + VStack { + Spacer().frame(height: 30) + info + Spacer() } - HStack(spacing: 15) { - Image(systemName: "info.circle") - .resizable() - .foregroundColor(.jamiColor) - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - VStack(spacing: 5) { - Text(L10n.AccountPage.pinExplanationTitle) - Text(L10n.AccountPage.pinExplanationMessage) - .font(.footnote) + ScrollView(showsIndicators: false) { + VStack(spacing: 30) { + tokenView + .frame(maxWidth: 500) } } - .padding() - .background(Color.jamiTertiaryControl) - .cornerRadius(12) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.AccountPage.pinExplanationTitle + " " + L10n.AccountPage.pinExplanationMessage) - .accessibilityAutoFocusOnAppear() + Spacer() + } + } - HStack { - Spacer() - Button(action: { - withAnimation { - model.showLinkDeviceAlert = false - } - }, label: { - Text(L10n.Global.close) - .foregroundColor(.jamiColor) - .padding(.horizontal) - }) + private var info: some View { + ( + Text(L10n.LinkDevice.initialInfo) + + Text(L10n.LinkDevice.infoAddAccount).bold() + + Text(entryMode == .qrCode ? L10n.LinkDevice.infoQRCode : L10n.LinkDevice.infoCode) + ) + .multilineTextAlignment(.center) + .font(.callout) + .lineSpacing(4) + .frame(maxWidth: 500) + } + + @ViewBuilder private var tokenView: some View { + ModeSelectorView(selectedMode: $entryMode, isLinkToAccount: false) + .onChange(of: entryMode) { _ in + model.cleanState() + } + tokenContent + } + + @ViewBuilder private var tokenContent: some View { + Group { + if entryMode == .pin { + tokenEntry + } else { + qrCodeView + .frame(maxWidth: .infinity) } } + .padding() + .background(colorScheme == .light ? Color(UIColor.secondarySystemGroupedBackground) : Color(UIColor.systemGray2)) + .cornerRadius(10) + .padding(.horizontal) } - func errorView(error: String) -> some View { + private func connectingView() -> some View { VStack { - Text(L10n.AccountPage.pinError + ": \(error.description)") - .foregroundColor(Color(UIColor.jamiFailure)) - .font(.subheadline) + Text(L10n.LinkDevice.connecting) + .multilineTextAlignment(.center) + .font(.callout) + .lineSpacing(4) + SwiftUI.ProgressView() .padding() - HStack { - Spacer() - Button(action: { - withAnimation { - model.showLinkDeviceAlert = false - } - }, label: { - Text(L10n.Global.close) - .foregroundColor(.jamiColor) - .padding(.horizontal) - }) + Spacer() + } + } + + func authenticatedView(address: String) -> some View { + VStack { + VStack { + Text(L10n.LinkDevice.authenticationInfo) + .multilineTextAlignment(.center) + .font(.callout) + .lineSpacing(4) + Text(L10n.LinkDevice.newDeviceIP("\(address)")) + .bold().padding(.vertical) + .multilineTextAlignment(.center) + HStack { + Spacer() + Button(action: { + model.confirmAddDevice() + }, label: { + Text(L10n.Global.confirm) + .foregroundColor(Color(UIColor.label)) + .padding(.horizontal, 10) + .padding(.vertical, 12) + .background(Color.jamiTertiaryControl) + .cornerRadius(10) + }) + .padding(.horizontal) + Button(action: { + model.cancelAddDevice() + }, label: { + Text(L10n.Global.cancel) + .foregroundColor(Color(UIColor.label)) + .padding(.horizontal, 10) + .padding(.vertical, 12) + .background(Color.jamiTertiaryControl) + .cornerRadius(10) + }) + Spacer() + } + } + .padding() + .background(colorScheme == .light ? Color(UIColor.secondarySystemGroupedBackground) : Color(UIColor.systemGray2)) + .cornerRadius(10) + .padding(.horizontal) + .frame(maxWidth: 500) + Spacer() + } + } + + func loadingView() -> some View { + VStack { + Text(L10n.LinkDevice.exportInProgress) + .multilineTextAlignment(.center) + .font(.callout) + .lineSpacing(4) + SwiftUI.ProgressView() + .padding() + Spacer() + } + } + + private func successView() -> some View { + SuccessStateView( + message: L10n.LinkDevice.completed, + buttonTitle: L10n.Global.ok + ) { + presentation.wrappedValue.dismiss() + } + } + + private func errorView(_ message: String) -> some View { + ErrorStateView( + message: message, + buttonTitle: L10n.Global.ok + ) { + presentation.wrappedValue.dismiss() + } + } + + private var qrCodeView: some View { + ScanQRCodeView(width: 250, height: 200) { jamiAuthentication in + model.handleAuthenticationUri(jamiAuthentication) + } + } + + func connectButton() -> some View { + Button(action: { + withAnimation { + model.handleAuthenticationUri(model.exportToken) } + }, label: { + Text(L10n.Global.connect) + .commonButtonStyle() + }) + } + + private var tokenEntry: some View { + VStack { + TextField(L10n.LinkDevice.token, text: $model.exportToken) + .padding(.horizontal, 10) + .padding(.vertical, 12) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .background(Color(UIColor.systemGroupedBackground)) + .cornerRadius(10) + Text(model.entryError ?? "") + .font(.footnote) + .foregroundColor(Color(UIColor.jamiFailure)) + .multilineTextAlignment(.center) + connectButton() + .padding(.top) + } + } + + func cancelRequested() { + if model.shouldShowAlert() { + self.showAlert = true + } else { + cancel() + } + } + + func cancel() { + presentation.wrappedValue.dismiss() + } + + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + BackButton(action: cancelRequested) } } + + private func alertContent() -> Alert { + Alert( + title: Text(L10n.LinkToAccount.alertTile), + message: Text(L10n.LinkToAccount.alertMessage), + primaryButton: .destructive(Text(L10n.Global.confirm), action: cancel), + secondaryButton: .cancel() + ) + } } diff --git a/Ring/Ring/Features/Settings/Me/LinkedDevicesVM.swift b/Ring/Ring/Features/Settings/Me/LinkedDevicesVM.swift index 0a3df65c8..3c0c9c828 100644 --- a/Ring/Ring/Features/Settings/Me/LinkedDevicesVM.swift +++ b/Ring/Ring/Features/Settings/Me/LinkedDevicesVM.swift @@ -21,55 +21,6 @@ import SwiftUI import RxSwift -enum ExportAccountResponse: Int { - case success = 0 - case wrongPassword = 1 - case networkProblem = 2 -} - -enum PinError { - case passwordError - case networkError - case defaultError - - var description: String { - switch self { - case .passwordError: - return L10n.LinkDevice.passwordError - case .networkError: - return L10n.LinkDevice.networkError - case .defaultError: - return L10n.LinkDevice.defaultError - } - } -} - -enum GeneratingPinState { - - case initial - case generatingPin - case success(pin: String) - case error(error: PinError) - - var rawValue: String { - switch self { - case .initial: - return "INITIAL" - case .generatingPin: - return "GENERATING_PIN" - case .success: - return "SUCCESS" - case .error: - return "ERROR" - } - } - - func isStateOfType(type: String) -> Bool { - - return self.rawValue == type - } -} - enum ActionsState { case deviceRevokedWithSuccess(deviceId: String) case deviceRevocationError(deviceId: String, errorMessage: String) @@ -85,9 +36,7 @@ class LinkedDevicesVM: ObservableObject { @Published var devices = [DeviceModel]() @Published var revocationError: String? @Published var revocationSuccess: String? - @Published var generatingState = GeneratingPinState.initial - @Published var PINImage: UIImage? - @Published var showLinkDeviceAlert: Bool = false + @Published var showLinkDevice: Bool = false let account: AccountModel let accountService: AccountsService @@ -127,51 +76,13 @@ class LinkedDevicesVM: ObservableObject { } } - func showLinkDevice() { - showLinkDeviceAlert = true - if !self.hasPassword() { - self.linkDevice(with: "") - } - } - - func linkDevice(with password: String) { - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } - self.updateStateOnMainThread(GeneratingPinState.generatingPin) - - if self.hasPassword() && password.isEmpty { - updateStateOnMainThread(.error(error: PinError.passwordError)) - return - } - - self.accountService.sharedResponseStream - .filter({ [weak self] exportCompletedEvent in - guard let self = self else { return false } - return exportCompletedEvent.eventType == ServiceEventType.exportOnRingEnded && - exportCompletedEvent.getEventInput(.id) == self.accountService.currentAccount?.id - }) - .subscribe(onNext: { [weak self] exportCompletedEvent in - guard let self = self else { return } - if let state: Int = exportCompletedEvent.getEventInput(.state) { - self.handleExportCompletedEvent(with: state, event: exportCompletedEvent) - } - }) - .disposed(by: self.disposeBag) - - self.accountService.exportOnRing(withPassword: password) - .subscribe(onCompleted: { - }, onError: { [weak self] _ in - guard let self = self else { return } - self.updateStateOnMainThread(.error(error: PinError.passwordError)) - }) - .disposed(by: self.disposeBag) - } + func linkDevice() { + showLinkDevice = true } func cleanInfoMessages() { revocationError = nil revocationSuccess = nil - updateStateOnMainThread(.initial) } private func subscribeToDeviceRevocationEvents(for deviceId: String) { @@ -212,32 +123,4 @@ class LinkedDevicesVM: ObservableObject { self.revocationError = L10n.AccountPage.deviceRevocationError } } - - private func updateStateOnMainThread(_ state: GeneratingPinState) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.generatingState = state - } - } - - private func handleExportCompletedEvent(with state: Int, event: ServiceEvent) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - switch state { - case ExportAccountResponse.success.rawValue: - if let pin: String = event.getEventInput(.pin) { - self.PINImage = pin.generateQRCode() - updateStateOnMainThread(.success(pin: pin)) - } else { - updateStateOnMainThread(.error(error: PinError.defaultError)) - } - case ExportAccountResponse.wrongPassword.rawValue: - updateStateOnMainThread(.error(error: PinError.passwordError)) - case ExportAccountResponse.networkProblem.rawValue: - updateStateOnMainThread(.error(error: PinError.networkError)) - default: - updateStateOnMainThread(.error(error: PinError.defaultError)) - } - } - } } diff --git a/Ring/Ring/Features/Settings/Me/LinkedDevicesView.swift b/Ring/Ring/Features/Settings/Me/LinkedDevicesView.swift index 1b9bab2c1..f5fdefa56 100644 --- a/Ring/Ring/Features/Settings/Me/LinkedDevicesView.swift +++ b/Ring/Ring/Features/Settings/Me/LinkedDevicesView.swift @@ -44,6 +44,15 @@ struct LinkedDevicesView: View { } } } + linkDeviceButton() + .background( + NavigationLink( + destination: LinkDeviceView(account: model.account, accountService: model.accountService), + isActive: $model.showLinkDevice, + label: { EmptyView() } + ) + ) + if !model.devices.filter({ device in !device.isCurrent }).isEmpty { @@ -55,15 +64,10 @@ struct LinkedDevicesView: View { } } } - linkDeviceButton() } if showRevocationAlert { RevocationView(model: model, showRevocationAlert: $showRevocationAlert, deviceToRevoke: $deviceToRevoke) } - - if model.showLinkDeviceAlert { - LinkDeviceView(model: model, askForPassword: model.hasPassword()) - } } .onChange(of: showRevocationAlert) { _ in if !showRevocationAlert { @@ -71,11 +75,6 @@ struct LinkedDevicesView: View { model.cleanInfoMessages() } } - .onChange(of: model.showLinkDeviceAlert ) { _ in - if !model.showLinkDeviceAlert { - model.cleanInfoMessages() - } - } .navigationTitle( L10n.AccountPage.linkedDevices) .navigationBarTitleDisplayMode(.inline) } @@ -123,7 +122,7 @@ struct LinkedDevicesView: View { func linkDeviceButton() -> some View { Button(action: { withAnimation { - model.showLinkDevice() + model.linkDevice() } }, label: { Text(L10n.LinkDevice.title) diff --git a/Ring/Ring/Features/Settings/Me/ManageAccountView.swift b/Ring/Ring/Features/Settings/Me/ManageAccountView.swift index feb027876..ef784432f 100644 --- a/Ring/Ring/Features/Settings/Me/ManageAccountView.swift +++ b/Ring/Ring/Features/Settings/Me/ManageAccountView.swift @@ -23,8 +23,6 @@ import SwiftUI struct ManageAccountView: View { @ObservedObject var model: AccountSummaryVM let state: AccountStatePublisher - @Environment(\.presentationMode) - var presentation @SwiftUI.State private var showRemovalAlert = false var body: some View { ZStack { diff --git a/Ring/Ring/Features/Settings/Me/UtilitiesViews.swift b/Ring/Ring/Features/Settings/Me/UtilitiesViews.swift index f8f0017df..af9545294 100644 --- a/Ring/Ring/Features/Settings/Me/UtilitiesViews.swift +++ b/Ring/Ring/Features/Settings/Me/UtilitiesViews.swift @@ -142,12 +142,8 @@ extension ShareButtonView where ButtonContent == AnyView { init(infoToShare: String) { self.init(infoToShare: infoToShare) { AnyView( - Label("Share", systemImage: "square.and.arrow.up.fill") - .foregroundColor(.white) - .padding(.horizontal) - .padding(.vertical, 10) - .background(Color.jamiColor) - .cornerRadius(10) + Label(L10n.Global.share, systemImage: "square.and.arrow.up.fill") + .commonButtonStyle() ) } } diff --git a/Ring/Ring/Features/Walkthrough/Models/LinkToAccountVM.swift b/Ring/Ring/Features/Walkthrough/Models/LinkToAccountVM.swift index e56ff38ce..ae336b1f5 100644 --- a/Ring/Ring/Features/Walkthrough/Models/LinkToAccountVM.swift +++ b/Ring/Ring/Features/Walkthrough/Models/LinkToAccountVM.swift @@ -23,7 +23,7 @@ import SwiftUI enum LinkDeviceError { static let wrongPassword = L10n.LinkDevice.errorWrongPassword static let networkError = L10n.LinkDevice.errorNetwork - static let failedToGeneratePin = L10n.LinkDevice.errorToken + static let failedToGenerateToken = L10n.LinkDevice.errorToken static let jamiIdNotFound = L10n.LinkDevice.errorWrongData } @@ -36,6 +36,7 @@ enum LinkDeviceConstants { static let importAuthScheme = "auth_scheme" static let importAuthError = "auth_error" static let importPeerId = "peer_id" + static let peerAddress = "peer_address" static let token = "token" static let error = "error" } @@ -43,9 +44,11 @@ enum LinkDeviceConstants { enum AuthError: String { case wrongPassword = "auth_error" + case credentials = "invalid_credentials" case network = "network" case timeout = "timeout" case state = "state" + case canceled = "canceled" static func fromString(_ string: String) -> AuthError? { return AuthError(rawValue: string) @@ -53,10 +56,11 @@ enum AuthError: String { func message() -> String { switch self { - case .wrongPassword: return L10n.LinkDevice.errorWrongPassword + case .wrongPassword, .credentials: return L10n.LinkDevice.errorWrongPassword case .network: return L10n.LinkDevice.errorNetwork case .timeout: return L10n.LinkDevice.errorTimeout case .state: return L10n.LinkDevice.errorWrongData + case .canceled: return "canceled" } } } @@ -68,7 +72,8 @@ class LinkToAccountVM: ObservableObject, AvatarViewDataModel { @Published var username: String? @Published var token: String = "" @Published var password: String = "" - @Published var hasPassword: Bool = false + @Published var hasPassword: Bool = true + @Published var authError: String? @Published private(set) var uiState: LinkDeviceUIState = .initial var jamiId: String = "" @@ -98,13 +103,13 @@ class LinkToAccountVM: ObservableObject, AvatarViewDataModel { } func getShareInfo() -> String { - let info: String = self.token ?? "" + let info: String = self.token return L10n.LinkToAccount.shareMessage(info) } private func setupDeviceAuthObserver() { accountsService.authStateSubject - .filter { $0.accountId == self.tempAccount } + .filter { [weak self] in $0.accountId == self?.tempAccount } .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] result in self?.updateDeviceAuthState(result: result) @@ -119,8 +124,8 @@ class LinkToAccountVM: ObservableObject, AvatarViewDataModel { case .initial: validStates = [.tokenAvailable, .done] case .displayingToken: validStates = [.tokenAvailable, .connecting, .done] case .connecting: validStates = [.authenticating, .done] - case .authenticating, .inProgress: validStates = [.inProgress, .done] - case .error, .success: validStates = [.done] + case .authenticating, .inProgress: validStates = [.inProgress, .done, .authenticating] + case .error, .success: validStates = [.done, .authenticating, .inProgress] } return validStates.contains(newState) @@ -144,7 +149,7 @@ class LinkToAccountVM: ObservableObject, AvatarViewDataModel { self.token = token withAnimation { uiState = .displayingToken(pin: token) } } else { - withAnimation { uiState = .error(message: LinkDeviceError.failedToGeneratePin) } + withAnimation { uiState = .error(message: LinkDeviceError.failedToGenerateToken) } } } @@ -155,17 +160,17 @@ class LinkToAccountVM: ObservableObject, AvatarViewDataModel { private func handleAuthenticating(details: [String: String]) { hasPassword = details[LinkDeviceConstants.Keys.importAuthScheme] == LinkDeviceConstants.AuthScheme.password let authError = details[LinkDeviceConstants.Keys.importAuthError].flatMap { AuthError.fromString($0) } - if let errorMessage = authError?.rawValue { - withAnimation { uiState = .error(message: errorMessage) } - return + if let errorMessage = authError?.message() { + self.authError = errorMessage } guard let jamiId = details[LinkDeviceConstants.Keys.importPeerId] else { - withAnimation { uiState = .error(message: LinkDeviceError.jamiIdNotFound) } return } self.jamiId = jamiId - self.lookupUserName(jamiId: jamiId) + if self.username == nil { + self.lookupUserName(jamiId: jamiId) + } withAnimation { uiState = .authenticating } } @@ -199,15 +204,17 @@ class LinkToAccountVM: ObservableObject, AvatarViewDataModel { } private func handleDone(details: [String: String]) { - if let error = details[LinkDeviceConstants.Keys.error].flatMap(AuthError.fromString) { - withAnimation { uiState = .error(message: error.rawValue) } + if let errorString = details[LinkDeviceConstants.Keys.error], + !errorString.isEmpty, + errorString != "none", + let error = AuthError(rawValue: errorString) { + withAnimation { self.uiState = .error(message: error.message()) } } else { - withAnimation { uiState = .success } + withAnimation { self.uiState = .success } } } func onCancel() { - // Remove temporary account if it exists if let tempAccountId = tempAccount { accountsService.removeAccount(id: tempAccountId) } diff --git a/Ring/Ring/Features/Walkthrough/Views/LinkToAccountView.swift b/Ring/Ring/Features/Walkthrough/Views/LinkToAccountView.swift index e17caa0f7..6874eb2e9 100644 --- a/Ring/Ring/Features/Walkthrough/Views/LinkToAccountView.swift +++ b/Ring/Ring/Features/Walkthrough/Views/LinkToAccountView.swift @@ -18,27 +18,15 @@ import SwiftUI -enum DisplayMode: String, CaseIterable, Identifiable { - case qrCode = "qrCode" - case label = "pinCode" - - var id: String { rawValue } - - var title: String { - switch self { - case .qrCode: - return L10n.LinkToAccount.showQrCode - case .label: - return L10n.LinkToAccount.showPinCode - } - } -} +typealias DisplayMode = DeviceLinkingMode struct LinkToAccountView: View { @StateObject var viewModel: LinkToAccountVM let dismissHandler = DismissHandler() @Environment(\.verticalSizeClass) var verticalSizeClass + @Environment(\.colorScheme) + var colorScheme @SwiftUI.State private var displayMode: DisplayMode = .qrCode @SwiftUI.State private var showAlert = false @@ -51,6 +39,8 @@ struct LinkToAccountView: View { var body: some View { mainContent + .padding() + .frame(maxWidth: 500) .navigationBarTitleDisplayMode(.inline) .navigationTitle(L10n.LinkToAccount.importAccount) .navigationBarBackButtonHidden(true) @@ -71,11 +61,7 @@ struct LinkToAccountView: View { case .authenticating: autenticatingView case .inProgress: - VStack { - SwiftUI.ProgressView() - Spacer() - } - .padding() + inProgressView case .success: successView() case .error(let message): @@ -89,26 +75,24 @@ struct LinkToAccountView: View { SwiftUI.ProgressView() Spacer() } - .padding(.horizontal) } - private var tokenView: some View { - Group { - if verticalSizeClass == .regular { - portraitView - } else { - landscapeView - } + @ViewBuilder private var tokenView: some View { + if verticalSizeClass == .regular { + portraitView + } else { + landscapeView } } private var connectingView: some View { - VStack(spacing: 15) { + VStack { Text(L10n.LinkToAccount.actionRequired) .multilineTextAlignment(.center) + .font(.callout) + .lineSpacing(4) Spacer() } - .padding(.horizontal) } private var autenticatingView: some View { @@ -118,46 +102,52 @@ struct LinkToAccountView: View { if viewModel.hasPassword { passwordView.padding(.top) } - StyleImportAccountButton(title: L10n.LinkToAccount.importAccount, - action: { [weak viewModel] in - viewModel?.connect() - }) + Button(action: { [weak viewModel] in + viewModel?.connect() + }, label: { + Text(L10n.LinkToAccount.importAccount) + .commonButtonStyle() + }) + .disabled(viewModel.hasPassword && viewModel.password.isEmpty) + .opacity((viewModel.hasPassword && viewModel.password.isEmpty) ? 0.5 : 1) Spacer() } .padding(.horizontal) } } - private func successView() -> some View { - VStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(UIColor.jamiSuccess)) - .font(.system(size: 50)) - .padding() - Text(L10n.LinkToAccount.allSet) - .multilineTextAlignment(.center) - StyleImportAccountButton(title: L10n.LinkToAccount.goToAccounts, - action: { [weak dismissHandler, weak viewModel] in - dismissHandler?.dismissView() - viewModel?.linkCompleted() - }) + private var inProgressView: some View { + HStack { + Spacer() + VStack { + SwiftUI.ProgressView() + Spacer() + } Spacer() } } + private func successView() -> some View { + SuccessStateView( + message: L10n.LinkToAccount.allSet, + buttonTitle: L10n.LinkToAccount.goToAccounts + ) { [weak dismissHandler, weak viewModel] in + guard let dismissHandler = dismissHandler, + let viewModel = viewModel else { return } + dismissHandler.dismissView() + viewModel.linkCompleted() + } + } + private func errorView(_ message: String) -> some View { - VStack { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(Color(UIColor.jamiFailure)) - .font(.system(size: 50)) - .padding() - Text(message) - StyleImportAccountButton(title: L10n.LinkToAccount.exit, - action: { [weak dismissHandler, weak viewModel] in - dismissHandler?.dismissView() - viewModel?.onCancel() - }) - Spacer() + ErrorStateView( + message: message, + buttonTitle: L10n.LinkToAccount.exit + ) { [weak dismissHandler, weak viewModel] in + guard let dismissHandler = dismissHandler, + let viewModel = viewModel else { return } + dismissHandler.dismissView() + viewModel.onCancel() } } @@ -166,9 +156,9 @@ struct LinkToAccountView: View { VStack(spacing: 30) { info tokenDisplay + .frame(maxWidth: 500) Spacer() } - .padding(.horizontal) } } @@ -186,7 +176,6 @@ struct LinkToAccountView: View { } Spacer() } - .padding(.horizontal) } private var info: some View { @@ -197,28 +186,33 @@ struct LinkToAccountView: View { L10n.LinkToAccount.scanQrCode : L10n.LinkToAccount.enterProvidedCode) ) .multilineTextAlignment(.center) + .font(.callout) + .lineSpacing(4) .frame(maxWidth: 500) } @ViewBuilder private var tokenDisplay: some View { - Group { - Picker("Display Mode", selection: $displayMode) { - ForEach(DisplayMode.allCases) { mode in - Text(mode.title).tag(mode) - } - } - .pickerStyle(SegmentedPickerStyle()) + ModeSelectorView(selectedMode: $displayMode, isLinkToAccount: true) + tokenContent + } + + private var qrCodeView: some View { + QRCodeView(jamiId: viewModel.token, accessibilityLabel: L10n.Accessibility.Account.tokenQRcode, size: 200) + } - if displayMode == .label { + @ViewBuilder private var tokenContent: some View { + Group { + if displayMode == .pin { tokenLabel } else { qrCodeView + .frame(maxWidth: 500) } } - } - - private var qrCodeView: some View { - QRCodeView(jamiId: viewModel.token, accessibilityLabel: L10n.Accessibility.Account.tokenQRcode, size: 200) + .padding() + .background(colorScheme == .light ? Color(UIColor.secondarySystemGroupedBackground) : Color(UIColor.systemGray2)) + .cornerRadius(10) + .padding(.horizontal) } private var tokenLabel: some View { @@ -240,24 +234,31 @@ struct LinkToAccountView: View { private var passwordView: some View { VStack { Text(L10n.LinkToAccount.accountLockedWithPassword) + .font(.callout) .multilineTextAlignment(.center) + .lineSpacing(4) WalkthroughPasswordView(text: $viewModel.password, placeholder: L10n.Global.password) + if let error = viewModel.authError { + Text(error) + .multilineTextAlignment(.center) + .foregroundColor(Color(UIColor.jamiFailure)) + .font(.footnote) + } } } private var userInfoView: some View { VStack(spacing: 15) { - AvatarImageView(model: viewModel, width: 80, height: 80) + AvatarImageView(model: viewModel, width: 60, height: 60) if let username = viewModel.username { Text(username) } Text(viewModel.jamiId).font(.callout) } .padding() - .background(Color(UIColor.secondarySystemBackground)) + .frame(maxWidth: .infinity) + .background(colorScheme == .light ? Color(UIColor.secondarySystemGroupedBackground) : Color(UIColor.systemGray2)) .cornerRadius(10) - .shadow(color: Color(UIColor.quaternaryLabel), radius: 1) - .padding() } func cancelRequested() { @@ -275,20 +276,7 @@ struct LinkToAccountView: View { private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .navigationBarLeading) { - backButton - } - } - - @ViewBuilder - private var backButton: some View { - Button(action: cancelRequested) { - HStack { - Group { - Image(systemName: "chevron.left") - Text(L10n.Actions.backAction) - } - .foregroundColor(Color(UIColor.jamiButtonDark)) - } + BackButton(action: cancelRequested) } } @@ -306,22 +294,3 @@ struct LinkToAccountView: View { .ignoresSafeArea() } } - -struct StyleImportAccountButton: View { - let title: String - let action: () -> Void - var backgroundColor: Color = Color.jamiColor - var textColor: Color = .white - - var body: some View { - Button(action: action) { - Text(title) - .foregroundColor(textColor) - .padding(.horizontal) - .padding(.vertical, 10) - .background(backgroundColor) - .cornerRadius(10) - } - .padding() - } -} diff --git a/Ring/Ring/Features/Walkthrough/Views/Views.swift b/Ring/Ring/Features/Walkthrough/Views/Views.swift index a4cfd135a..81cf3c251 100644 --- a/Ring/Ring/Features/Walkthrough/Views/Views.swift +++ b/Ring/Ring/Features/Walkthrough/Views/Views.swift @@ -133,101 +133,6 @@ struct QRCodeScannerOverlayView: View { } } -struct QRCodeScannerView: UIViewControllerRepresentable { - let width: CGFloat - let height: CGFloat - var didFindCode: (String) -> Void - - class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { - var parent: QRCodeScannerView - - init(parent: QRCodeScannerView) { - self.parent = parent - } - - func metadataOutput(_ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection) { - if let metadataObject = metadataObjects.first { - guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } - guard let stringValue = readableObject.stringValue else { return } - AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) - parent.didFindCode(stringValue) - } - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator(parent: self) - } - - func makeUIViewController(context: Context) -> UIViewController { - let viewController = UIViewController() - let session = AVCaptureSession() - - guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { - showCameraDisabledMessage(in: viewController) - return viewController - } - let videoInput: AVCaptureDeviceInput - - do { - videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) - } catch { - showCameraDisabledMessage(in: viewController) - return viewController - } - - if session.canAddInput(videoInput) { - session.addInput(videoInput) - } else { - showCameraDisabledMessage(in: viewController) - return viewController - } - - let metadataOutput = AVCaptureMetadataOutput() - - if session.canAddOutput(metadataOutput) { - session.addOutput(metadataOutput) - - metadataOutput.setMetadataObjectsDelegate(context.coordinator, - queue: DispatchQueue.main) - metadataOutput.metadataObjectTypes = [.qr] - metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, - width: width, - height: height) - } else { - showCameraDisabledMessage(in: viewController) - return viewController - } - - let previewLayer = AVCaptureVideoPreviewLayer(session: session) - previewLayer.frame = CGRect(x: 0, y: 0, width: width, height: width) - previewLayer.videoGravity = .resizeAspectFill - viewController.view.layer.addSublayer(previewLayer) - - DispatchQueue.global(qos: .userInitiated).async { - session.startRunning() - } - - return viewController - } - - func updateUIViewController(_ uiViewController: UIViewController, - context: Context) {} - - private func showCameraDisabledMessage(in viewController: UIViewController) { - viewController.view.backgroundColor = .black - let label = UILabel() - label.text = L10n.Global.cameraDisabled - label.numberOfLines = 0 - label.textColor = .white - label.textAlignment = .center - label.frame = CGRect(x: 20, y: 0, width: width - 40, height: height) - viewController.view.addSubview(label) - } -} - struct AlertFactory { static func alertWithOkButton(title: String, message: String, @@ -260,3 +165,122 @@ struct AlertFactory { .padding() } } + +// Common enum for QR/PIN modes +enum DeviceLinkingMode: String, CaseIterable, Identifiable { + case qrCode = "qrCode" + case pin = "pinCode" + + var id: String { rawValue } + + func getTitle(isLinkToAccount: Bool) -> String { + switch self { + case .qrCode: + return L10n.LinkToAccount.showQrCode + case .pin: + return L10n.LinkToAccount.showPinCode + } + } +} + +struct ModeSelectorView: View { + @Binding var selectedMode: DeviceLinkingMode + let isLinkToAccount: Bool + + var body: some View { + Picker("Display Mode", selection: $selectedMode) { + ForEach(DeviceLinkingMode.allCases) { mode in + Text(mode.getTitle(isLinkToAccount: isLinkToAccount)).tag(mode) + } + } + .pickerStyle(SegmentedPickerStyle()) + } +} + +struct SuccessStateView: View { + let message: String + let buttonTitle: String + let action: () -> Void + + var body: some View { + VStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(UIColor.jamiSuccess)) + .font(.system(size: 50)) + Text(message) + .multilineTextAlignment(.center) + .padding(.vertical) + ActionButton(title: buttonTitle, action: action) + Spacer() + } + .padding(.horizontal) + } +} + +struct ErrorStateView: View { + let message: String + let buttonTitle: String + let action: () -> Void + + var body: some View { + VStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color(UIColor.jamiFailure)) + .font(.system(size: 50)) + Text(message) + .multilineTextAlignment(.center) + .padding(.vertical) + ActionButton(title: buttonTitle, action: action) + Spacer() + } + .padding(.horizontal) + } +} + +// Reusable action button +struct ActionButton: View { + let title: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .foregroundColor(Color(UIColor.label)) + .padding(.horizontal, 10) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(Color.jamiTertiaryControl) + .cornerRadius(10) + } + } +} + +// Reusable back button +struct BackButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: "chevron.left") + Text(L10n.Actions.backAction) + } + .foregroundColor(Color(UIColor.jamiButtonDark)) + } + } +} + +// Reusable loading view +struct LoadingStateView: View { + let message: String + + var body: some View { + VStack { + Text(message) + .multilineTextAlignment(.center) + SwiftUI.ProgressView() + .padding() + Spacer() + } + } +} diff --git a/Ring/Ring/Helpers/SwiftUIViews.swift b/Ring/Ring/Helpers/SwiftUIViews.swift index 27723e370..bd9bad641 100644 --- a/Ring/Ring/Helpers/SwiftUIViews.swift +++ b/Ring/Ring/Helpers/SwiftUIViews.swift @@ -111,3 +111,117 @@ struct UITextViewWrapper: UIViewRepresentable { } } } + +struct QRCodeScannerView: UIViewControllerRepresentable { + let width: CGFloat + let height: CGFloat + var didFindCode: (String) -> Void + + var previewLayer = AVCaptureVideoPreviewLayer() + + class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + var parent: QRCodeScannerView + + init(parent: QRCodeScannerView) { + self.parent = parent + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + if let metadataObject = metadataObjects.first, + let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject, + let stringValue = readableObject.stringValue { + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + parent.didFindCode(stringValue) + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UIViewController { + let viewController = UIViewController() + let session = AVCaptureSession() + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { + showCameraDisabledMessage(in: viewController) + return viewController + } + + do { + let videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) + if session.canAddInput(videoInput) { + session.addInput(videoInput) + } else { + showCameraDisabledMessage(in: viewController) + return viewController + } + } catch { + showCameraDisabledMessage(in: viewController) + return viewController + } + + let metadataOutput = AVCaptureMetadataOutput() + if session.canAddOutput(metadataOutput) { + session.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(context.coordinator, queue: .main) + metadataOutput.metadataObjectTypes = [.qr] + metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, width: width, height: height) + } else { + showCameraDisabledMessage(in: viewController) + return viewController + } + + previewLayer.session = session + previewLayer.videoGravity = .resizeAspectFill + + previewLayer.frame = viewController.view.bounds + viewController.view.layer.addSublayer(previewLayer) + + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + + return viewController + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + previewLayer.frame = uiViewController.view.bounds + + if let connection = previewLayer.connection, connection.isVideoOrientationSupported { + connection.videoOrientation = AVCaptureVideoOrientation(ScreenHelper.currentOrientation()) + } + } + + private func showCameraDisabledMessage(in viewController: UIViewController) { + viewController.view.backgroundColor = .black + let label = UILabel() + label.text = L10n.Global.cameraDisabled + label.numberOfLines = 0 + label.textColor = .white + label.textAlignment = .center + label.frame = CGRect(x: 20, y: 0, width: width - 40, height: height) + viewController.view.addSubview(label) + } +} + +struct CommonButtonStyle: ViewModifier { + func body(content: Content) -> some View { + content + .foregroundColor(Color(UIColor.label)) + .padding(.horizontal, 10) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(Color.jamiTertiaryControl) + .cornerRadius(10) + } +} + +extension View { + func commonButtonStyle() -> some View { + self.modifier(CommonButtonStyle()) + } +} diff --git a/Ring/Ring/QRCode/ScanViewController.swift b/Ring/Ring/QRCode/ScanViewController.swift index af3799a82..aa4d96dbf 100644 --- a/Ring/Ring/QRCode/ScanViewController.swift +++ b/Ring/Ring/QRCode/ScanViewController.swift @@ -132,19 +132,7 @@ class ScanViewController: UIViewController, StoryboardBased, AVCaptureMetadataOu func updateOrientation() { if self.videoPreviewLayer?.connection!.isVideoOrientationSupported ?? false { - let orientation: UIDeviceOrientation = UIDevice.current.orientation - var cameraOrientation = AVCaptureVideoOrientation.portrait - switch orientation { - case .landscapeRight: - cameraOrientation = AVCaptureVideoOrientation.landscapeLeft - case .landscapeLeft: - cameraOrientation = AVCaptureVideoOrientation.landscapeRight - case .portraitUpsideDown: - cameraOrientation = AVCaptureVideoOrientation.portraitUpsideDown - default: - cameraOrientation = AVCaptureVideoOrientation.portrait - } - self.videoPreviewLayer?.connection?.videoOrientation = cameraOrientation + self.videoPreviewLayer?.connection?.videoOrientation = AVCaptureVideoOrientation(ScreenHelper.currentOrientation()) } } diff --git a/Ring/Ring/Resources/en.lproj/Localizable.strings b/Ring/Ring/Resources/en.lproj/Localizable.strings index 4d6841f1e..544887ecd 100644 --- a/Ring/Ring/Resources/en.lproj/Localizable.strings +++ b/Ring/Ring/Resources/en.lproj/Localizable.strings @@ -59,6 +59,8 @@ "global.confirmPassword" = "Confirm password"; "global.confirm" = "Confirm"; "global.cameraDisabled" = "Camera access is disabled. Enable it in device settings in order to use this feature."; +"global.confirm" = "Confirm"; +"global.connect" = "Connect"; // Scan "scan.badQrCode" = "Bad QR code"; @@ -193,30 +195,21 @@ "createAccount.encryptExplanation" = "A Jami account is created and stored locally only on this device as an archive containing its account keys. Access to the archive can optionally be protected with a password."; // Link To Account form -"linkToAccount.waitLinkToAccountTitle" = "Account linking"; -"linkToAccount.linkButtonTitle" = "Link"; -"linkToAccount.linkDeviceTitle" = "Link device"; -"linkToAccount.linkDeviceMessage" = "Choose “Link new device” from another Jami app to show the QR code or generate a PIN code."; -"linkToAccount.explanationMessage" = "A PIN code is required to use an existing Jami account on this device."; -"linkToAccount.pinPlaceholder" = "PIN code"; -"linkToAccount.pinLabel" = "Enter PIN code"; -"linkToAccount.scanQRCode" = "Scan QR code"; -"linkToAccount.explanationPinMessage" = "To generate the PIN code, go to the account management settings on the device containing the account you want to link to. Select “Link new device”. You will receive the necessary PIN code to complete this form. The PIN code will expire in 10 minutes."; "linkToAccount.importAccount" = "Import account"; "linkToAccount.showQrCode" = "QR code"; "linkToAccount.showPinCode" = "Authentication code"; "linkToAccount.actionRequired" = "Action required.\nPlease confirm account on the source device."; -"linkToAccount.allSet" = "Account imported successfully on the new device."; -"linkToAccount.goToAccounts" = "Go to my new account"; +"linkToAccount.allSet" = "Account imported successfully."; +"linkToAccount.goToAccounts" = "Go to imported account"; "linkToAccount.exportInstructions" = "On the source device, initiate the exportation."; "linkToAccount.exportInstructionsPath" = "Select Account > Account Settings > Link new device."; -"linkToAccount.scanQrCode" = "When ready, scan the QR Code."; +"linkToAccount.scanQrCode" = "When ready, scan the QR code."; "linkToAccount.enterProvidedCode" = "When ready, enter the authentication code."; "linkToAccount.accountLockedWithPassword" = "The account is protected with a password.\nTo continue, enter the account password."; "linkToAccount.exit" = "Exit"; "linkToAccount.shareMessage" = "Your code is: %@"; -"linkToAccount.alertTile" = "Are you sure you want to exit?"; -"linkToAccount.alertMessage" = "Exiting now will cancel the account importation process."; +"linkToAccount.alertTile" = "Do you want to exit?"; +"linkToAccount.alertMessage" = "Exiting will cancel the import account operation."; // Import from Archive "importFromArchive.explanation" = "Import Jami account from local archive file."; @@ -432,16 +425,23 @@ // Link New Device "linkDevice.title" = "Link new device"; -"linkDevice.passwordError" = "Incorrect password. Please try again with the correct password."; "linkDevice.networkError" = "A network error occurred while exporting the account."; -"linkDevice.defaultError" = "An error occurred while exporting the account."; -"linkDevice.explanationMessage" = "Open Jami on the new device and choose “Link this device to an account” to complete the process. The PIN code will expire in 10 minutes."; -"linkDevice.hudMessage" = "Verifying"; "linkDevice.errorWrongPassword" = "An authentication error occurred.\nPlease verify your password."; "linkDevice.errorNetwork" = "A network error occurred.\nPlease verify your connection."; "linkDevice.errorTimeout" = "The operation has timed out.\nPlease try again."; "linkDevice.errorToken" = "An error occurred while generating the token.\nPlease try again."; "linkDevice.errorWrongData" = "An error occurred while exporting the account.n\nPlease try again."; +"linkDevice.initialInfo" = "On the new device, initiate a new account.\n"; +"linkDevice.infoAddAccount" = "Select Add Account > Connect from another device.\n"; +"linkDevice.infoQRCode" = "When ready, scan the QR code"; +"linkDevice.infoCode" = "When ready, enter the code and press `Connect`."; +"linkDevice.connecting" = "Connecting to your new device…"; +"linkDevice.authenticationInfo" = "New device found at IP address below. Is that you?\nTo continue to transfer the account, click `Confirm`."; +"linkDevice.newDeviceIP" = "New device IP address: %1@"; +"linkDevice.token" = "token"; +"linkDevice.wrongEntry" = "New device identifier is unrecognized.\nPlease follow above instruction."; +"linkDevice.exportInProgress" = "The export account operation to the new device is in progress.\nPlease confirm import on the new device."; +"linkDevice.completed" = "Account imported successfully on the new device."; // Contact Page "contactPage.startAudioCall" = "Start audio call"; -- GitLab