From 42d68afdc940d23b019fb51ad6f3f185c8e50334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= Date: Tue, 12 Dec 2023 23:37:06 -0300 Subject: [PATCH] Redesign thread credentials sharing (#2478) --- HomeAssistant.xcodeproj/project.pbxproj | 4 + .../Resources/en.lproj/Localizable.strings | 8 +- .../DebugSettingsViewController.swift | 17 -- .../ThreadCredentialsSharingView.swift | 151 +++++++----------- .../ThreadCredentialsSharingViewModel.swift | 43 +++-- Sources/App/WebView/WebViewController.swift | 7 +- .../Shared/Resources/Swiftgen/Strings.swift | 20 +-- Sources/Shared/Utilities/Haptics.swift | 16 ++ 8 files changed, 128 insertions(+), 138 deletions(-) create mode 100644 Sources/Shared/Utilities/Haptics.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index c9af10381..18f8e14aa 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -519,6 +519,7 @@ 424A7F462B188946008C8DF3 /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F452B188946008C8DF3 /* WidgetBackground.swift */; }; 424A7F482B188BF3008C8DF3 /* WidgetContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */; }; 426740A92B17391000C1DD73 /* Data+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426740A72B17390A00C1DD73 /* Data+Hexadecimal.swift */; }; + 429C72202B28D0EC00BCD558 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C721F2B28D0EC00BCD558 /* Haptics.swift */; }; 42CA28BB2B1028330093B31A /* SimulatorThreadClientService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28BA2B1028330093B31A /* SimulatorThreadClientService.swift */; }; 42CFCD6A2B1F958A00CCEF4A /* DropSupportMessageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CFCD692B1F958A00CCEF4A /* DropSupportMessageViewController.swift */; }; 42DD84132B14ACAB00936F16 /* Color+ColorAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */; }; @@ -1594,6 +1595,7 @@ 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetContentMargin.swift; sourceTree = ""; }; 426740A72B17390A00C1DD73 /* Data+Hexadecimal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Hexadecimal.swift"; sourceTree = ""; }; 42805A132B0226050095414C /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Base; path = Base.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; + 429C721F2B28D0EC00BCD558 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 42CA28A62B1012DE0093B31A /* ThreadCredentialsSharingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadCredentialsSharingView.swift; sourceTree = ""; }; 42CA28AD2B101D4D0093B31A /* HACornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HACornerRadius.swift; sourceTree = ""; }; 42CA28AF2B101D6B0093B31A /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; @@ -3035,6 +3037,7 @@ children = ( 42CA28AD2B101D4D0093B31A /* HACornerRadius.swift */, 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */, + 429C721F2B28D0EC00BCD558 /* Haptics.swift */, ); path = Utilities; sourceTree = ""; @@ -5988,6 +5991,7 @@ D014EEA92128E192008EA6F5 /* ConnectionInfo.swift in Sources */, 11169BC8262BE460005EF90A /* UNNotificationContent+Additions.swift in Sources */, 11AF4D14249C7E09006C74C0 /* ActivitySensor.swift in Sources */, + 429C72202B28D0EC00BCD558 /* Haptics.swift in Sources */, 11B38EE9275C54A200205C7B /* GetCameraImageIntentHandler.swift in Sources */, 426740A92B17391000C1DD73 /* Data+Hexadecimal.swift in Sources */, D0EEF306214DD3CF00D1D360 /* ObjectMapperTransformers.swift in Sources */, diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 2ebc6d063..4dced79b9 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -335,7 +335,6 @@ Home Assistant is free and open source home automation software with a focus on "settings.developer.header" = "Developer"; "settings.developer.map_notification.notification.body" = "Expand this to show the map content extension"; "settings.developer.map_notification.title" = "Show map notification content extension"; -"settings.developer.mock_thread_credentials_sharing.title" = "Simulator Thread Credentials Sharing"; "settings.developer.show_log_files.title" = "Show log files in Finder"; "settings.developer.sync_watch_context.title" = "Sync Watch Context"; "settings.event_log.title" = "Event Log"; @@ -544,12 +543,9 @@ Home Assistant is free and open source home automation software with a focus on "share_extension.entered_placeholder" = "'entered' in event"; "share_extension.error.title" = "Couldn't Send"; "success_label" = "Success"; -"thread.credentials.border_agent_id_title" = "Border Agent ID"; -"thread.credentials.network_key_title" = "Network Key"; -"thread.credentials.network_name_title" = "Network Name"; "thread.credentials.no_credential_available" = "You don't have credentials available on your iCloud Keychain."; -"thread.credentials.screen_title" = "Thread Credentials"; -"thread.credentials.share_credentials_button_title" = "Share credential with Home Assistant"; +"thread.credentials.share_credentials.no_credentials_title" = "You don't have credentials to share"; +"thread.credentials.share_credentials.no_credentials_message" = "Make sure your are logged in with your iCloud account which is owner of a Home in Apple Home."; "token_error.connection_failed" = "Connection failed."; "token_error.expired" = "Token is expired."; "token_error.token_unavailable" = "Token is unavailable."; diff --git a/Sources/App/Settings/DebugSettingsViewController.swift b/Sources/App/Settings/DebugSettingsViewController.swift index 92d372344..8a4904b05 100644 --- a/Sources/App/Settings/DebugSettingsViewController.swift +++ b/Sources/App/Settings/DebugSettingsViewController.swift @@ -382,23 +382,6 @@ class DebugSettingsViewController: HAFormViewController { alert.popoverPresentationController?.sourceView = cell.formViewController()?.view } - if #available(iOS 16.4, *) { - section <<< ButtonRow { - $0.title = L10n.Settings.Developer.MockThreadCredentialsSharing.title - }.onCellSelection { [weak self] _, _ in - guard let server = Current.servers.all.first else { return } - let viewController = UIHostingController( - rootView: ThreadCredentialsSharingView( - viewModel: .init( - server: server, - threadClient: SimulatorThreadClientService() - ) - ) - ) - self?.present(viewController, animated: true, completion: nil) - } - } - section <<< SwitchRow { $0.title = L10n.Settings.Developer.AnnoyingBackgroundNotifications.title $0.value = prefs.bool(forKey: XCGLogger.shouldNotifyUserDefaultsKey) diff --git a/Sources/App/ThreadCredentialsSharing/ThreadCredentialsSharingView.swift b/Sources/App/ThreadCredentialsSharing/ThreadCredentialsSharingView.swift index c760f275f..836ab310d 100644 --- a/Sources/App/ThreadCredentialsSharing/ThreadCredentialsSharingView.swift +++ b/Sources/App/ThreadCredentialsSharing/ThreadCredentialsSharingView.swift @@ -11,34 +11,26 @@ struct ThreadCredentialsSharingView: View { } var body: some View { - NavigationView { - Group { - if viewModel.showLoader { - progressView - } else { - credentialsList - } - } - .navigationTitle(L10n.Thread.Credentials.screenTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar(content: { - ToolbarItem(placement: .topBarTrailing) { - Button { - dismiss() - } label: { - Text(L10n.doneLabel) + VStack { + if viewModel.showImportSuccess { + successView + .onAppear { + Haptics.shared.play(.medium) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + dismiss() + } } - } - }) - .alert(alertTitle, isPresented: $viewModel.showAlert) { - errorAlertActions - } message: { - if case let .error(_, message) = viewModel.alertType { - Text(message) - } + } else { + progressView } } - .navigationViewStyle(.stack) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.5)) + .alert(alertTitle, isPresented: $viewModel.showAlert) { + alertActions + } message: { + Text(alertMessage) + } .onAppear { Task { await viewModel.retrieveAllCredentials() @@ -46,90 +38,69 @@ struct ThreadCredentialsSharingView: View { } } + private var successView: some View { + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 65, height: 65) + .aspectRatio(contentMode: .fit) + .foregroundColor(.white) + } + private var alertTitle: String { - if case let .error(title, _) = viewModel.alertType { + switch viewModel.alertType { + case let .empty(title, _): return title - } else if case let .success(title) = viewModel.alertType { + case let .error(title, _): return title - } else { + case .none: return "" } } - @ViewBuilder - private var errorAlertActions: some View { + private var alertMessage: String { + switch viewModel.alertType { + case let .empty(_, message): + return message + case let .error(_, message): + return message + default: + return "" + } + } + + private var doneButton: some View { Button { - /* no-op */ + dismiss() } label: { Text(L10n.doneLabel) } - - if case .error = viewModel.alertType { - Button { - Task { - await viewModel.retrieveAllCredentials() - } - } label: { - Text(L10n.retryLabel) - } - } } - private var progressView: some View { - ProgressView() - .progressViewStyle(.circular) - .scaleEffect(CGSize(width: 2, height: 2)) - } - - @ViewBuilder - private var credentialsList: some View { - if viewModel.credentials.isEmpty { - Text(L10n.Thread.Credentials.noCredentialAvailable) - .multilineTextAlignment(.center) - } else { - List(viewModel.credentials, id: \.borderAgentID) { credential in - makeCredentialCard(credential: credential) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) + private var retryButton: some View { + Button { + Task { + await viewModel.retrieveAllCredentials() } - .listStyle(.plain) - .background(Color(uiColor: .secondarySystemBackground)) + } label: { + Text(L10n.retryLabel) } } - private func makeCredentialCard(credential: ThreadCredential) -> some View { - CardView(backgroundColor: Color(uiColor: .systemBackground)) { - makeCardPropertyView( - title: L10n.Thread.Credentials.networkNameTitle, - value: credential.networkName - ) - makeCardPropertyView( - title: L10n.Thread.Credentials.borderAgentIdTitle, - value: credential.borderAgentID - ) - makeCardPropertyView( - title: L10n.Thread.Credentials.networkKeyTitle, - value: credential.networkKey - ) - Button { - viewModel.shareCredentialWithHomeAssistant(credential: credential) - } label: { - Text(L10n.Thread.Credentials.shareCredentialsButtonTitle) - } - .buttonStyle(.textButton) + @ViewBuilder + private var alertActions: some View { + switch viewModel.alertType { + case .error, .empty: + doneButton + retryButton + default: + EmptyView() } } - private func makeCardPropertyView(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: .zero) { - Group { - Text(title) - .font(.footnote) - Text(value) - .textSelection(.enabled) - .font(.body.bold()) - } - .frame(maxWidth: .infinity, alignment: .leading) - } + private var progressView: some View { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(CGSize(width: 2, height: 2)) + .tint(.white) } } diff --git a/Sources/App/ThreadCredentialsSharing/ThreadCredentialsSharingViewModel.swift b/Sources/App/ThreadCredentialsSharing/ThreadCredentialsSharingViewModel.swift index 43c254172..3bbc60e65 100644 --- a/Sources/App/ThreadCredentialsSharing/ThreadCredentialsSharingViewModel.swift +++ b/Sources/App/ThreadCredentialsSharing/ThreadCredentialsSharingViewModel.swift @@ -5,17 +5,18 @@ import Shared @available(iOS 13, *) final class ThreadCredentialsSharingViewModel: ObservableObject { enum AlertType { - case success(title: String) + case empty(title: String, message: String) case error(title: String, message: String) } @Published var credentials: [ThreadCredential] = [] @Published var showAlert = false - @Published var showLoader = false @Published var alertType: AlertType? + @Published var showImportSuccess = false private let threadClient: THClientProtocol private let connection: HAConnection + private var credentialsToImport: [String] = [] init(server: Server, threadClient: THClientProtocol) { self.threadClient = threadClient @@ -24,32 +25,56 @@ final class ThreadCredentialsSharingViewModel: ObservableObject { @MainActor func retrieveAllCredentials() async { - showLoader = true do { credentials = try await threadClient.retrieveAllCredentials() + + if credentials.isEmpty { + showAlert(type: .empty( + title: L10n.Thread.Credentials.ShareCredentials.noCredentialsTitle, + message: L10n.Thread.Credentials.ShareCredentials.noCredentialsMessage + )) + } else { + credentialsToImport = credentials.map(\.activeOperationalDataSet) + processImport() + } } catch { - showAlert(type: .error(title: L10n.errorLabel, message: "Error message: \(error.localizedDescription)")) + showAlert(type: .error(title: L10n.errorLabel, message: error.localizedDescription)) + } + } + + @MainActor + private func processImport() { + guard let first = credentialsToImport.first else { + showImportSuccess = true + return + } + + shareCredentialWithHomeAssistant(credential: first) { [weak self] success in + if success { + self?.credentialsToImport.removeFirst() + self?.processImport() + } } - showLoader = false } @MainActor - func shareCredentialWithHomeAssistant(credential: ThreadCredential) { + private func shareCredentialWithHomeAssistant(credential: String, completion: @escaping (Bool) -> Void) { let request = HARequest(type: .webSocket("thread/add_dataset_tlv"), data: [ - "tlv": credential.activeOperationalDataSet, + "tlv": credential, "source": "iOS-app", ]) connection.send(request).promise.pipe { [weak self] result in guard let self else { return } switch result { case .fulfilled: - self.showAlert(type: .success(title: L10n.successLabel)) + completion(true) case let .rejected(error): self .showAlert(type: .error( title: L10n.errorLabel, - message: "Error message: \(error.localizedDescription)" + message: error.localizedDescription )) + completion(false) } } } diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index 7a1c974a8..18c99ab8d 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -1111,11 +1111,14 @@ extension WebViewController: WKScriptMessageHandler { private func threadCredentialsRequested() { if #available(iOS 16.4, *) { - let threadDebugView = UIHostingController(rootView: ThreadCredentialsSharingView(viewModel: .init( + let threadManagementView = UIHostingController(rootView: ThreadCredentialsSharingView(viewModel: .init( server: server, threadClient: ThreadClientService() ))) - present(threadDebugView, animated: true) + threadManagementView.view.backgroundColor = .clear + threadManagementView.modalPresentationStyle = .overFullScreen + threadManagementView.modalTransitionStyle = .crossDissolve + present(threadManagementView, animated: true) } } } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index af87bd736..85ec2412c 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1138,10 +1138,6 @@ public enum L10n { public static var body: String { return L10n.tr("Localizable", "settings.developer.map_notification.notification.body") } } } - public enum MockThreadCredentialsSharing { - /// Simulator Thread Credentials Sharing - public static var title: String { return L10n.tr("Localizable", "settings.developer.mock_thread_credentials_sharing.title") } - } public enum ShowLogFiles { /// Show log files in Finder public static var title: String { return L10n.tr("Localizable", "settings.developer.show_log_files.title") } @@ -1784,18 +1780,14 @@ public enum L10n { public enum Thread { public enum Credentials { - /// Border Agent ID - public static var borderAgentIdTitle: String { return L10n.tr("Localizable", "thread.credentials.border_agent_id_title") } - /// Network Key - public static var networkKeyTitle: String { return L10n.tr("Localizable", "thread.credentials.network_key_title") } - /// Network Name - public static var networkNameTitle: String { return L10n.tr("Localizable", "thread.credentials.network_name_title") } /// You don't have credentials available on your iCloud Keychain. public static var noCredentialAvailable: String { return L10n.tr("Localizable", "thread.credentials.no_credential_available") } - /// Thread Credentials - public static var screenTitle: String { return L10n.tr("Localizable", "thread.credentials.screen_title") } - /// Share credential with Home Assistant - public static var shareCredentialsButtonTitle: String { return L10n.tr("Localizable", "thread.credentials.share_credentials_button_title") } + public enum ShareCredentials { + /// Make sure your are logged in with your iCloud account which is owner of a Home in Apple Home. + public static var noCredentialsMessage: String { return L10n.tr("Localizable", "thread.credentials.share_credentials.no_credentials_message") } + /// You don't have credentials to share + public static var noCredentialsTitle: String { return L10n.tr("Localizable", "thread.credentials.share_credentials.no_credentials_title") } + } } } diff --git a/Sources/Shared/Utilities/Haptics.swift b/Sources/Shared/Utilities/Haptics.swift new file mode 100644 index 000000000..78c952f6a --- /dev/null +++ b/Sources/Shared/Utilities/Haptics.swift @@ -0,0 +1,16 @@ +import Foundation +import UIKit + +public class Haptics { + public static let shared = Haptics() + + private init() {} + + public func play(_ feedbackStyle: UIImpactFeedbackGenerator.FeedbackStyle) { + UIImpactFeedbackGenerator(style: feedbackStyle).impactOccurred() + } + + public func notify(_ feedbackType: UINotificationFeedbackGenerator.FeedbackType) { + UINotificationFeedbackGenerator().notificationOccurred(feedbackType) + } +}