From f0b5944fcecfb7a3a2f443fef7ed8c3fe74f5861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Thu, 7 May 2026 15:34:03 +0200 Subject: [PATCH 1/3] Add confirmation dialog before executing tag --- HomeAssistant.xcodeproj/project.pbxproj | 16 ++++ Sources/App/Frontend/IncomingURLHandler.swift | 66 ++++++++++--- .../App/Frontend/TagApprovalBottomSheet.swift | 92 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 8 ++ Sources/App/Settings/DebugView.swift | 14 +++ Sources/App/Settings/NFC/iOSTagManager.swift | 41 +++++++-- Sources/Shared/Database/AllowedTag.swift | 45 +++++++++ Sources/Shared/Database/DatabaseTables.swift | 5 + .../Shared/Database/GRDB+Initialization.swift | 1 + .../Database/Tables/AllowedTagTable.swift | 39 ++++++++ .../Environment/TagManagerProtocol.swift | 1 + .../Shared/Resources/Swiftgen/Strings.swift | 24 +++++ 12 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 Sources/App/Frontend/TagApprovalBottomSheet.swift create mode 100644 Sources/Shared/Database/AllowedTag.swift create mode 100644 Sources/Shared/Database/Tables/AllowedTagTable.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 11299da7e6..900f45ca37 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -380,6 +380,7 @@ 11DC6BAB24E23780002D9FDA /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = B63CCDCF2164714900123C50 /* Intents.intentdefinition */; settings = {ATTRIBUTES = (no_codegen, ); }; }; 11DE822E24FAC51100E636B8 /* IncomingURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DE822D24FAC51000E636B8 /* IncomingURLHandler.swift */; }; 11DE823024FAE66F00E636B8 /* UIWindow+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DE822F24FAE66F00E636B8 /* UIWindow+Additions.swift */; }; + 42A9C0022FBB000100D0C0DE /* TagApprovalBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A9C0012FBB000100D0C0DE /* TagApprovalBottomSheet.swift */; }; 11DE9D8625B6103C0081C0ED /* LauncherAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DE9D8525B6103C0081C0ED /* LauncherAppDelegate.swift */; }; 11DE9FBE25B6186E0081C0ED /* Home Assistant Launcher.app in Embed Mac Launcher */ = {isa = PBXBuildFile; fileRef = 11DE9D8325B6103C0081C0ED /* Home Assistant Launcher.app */; platformFilter = maccatalyst; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 11E1639A250B1B760076D612 /* OnboardingStateObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E16399250B1B760076D612 /* OnboardingStateObservation.swift */; }; @@ -560,6 +561,10 @@ 420CFC652D3F9C2C009A94F3 /* HAppEntityTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420CFC632D3F9C2C009A94F3 /* HAppEntityTable.swift */; }; 420CFC682D3F9C40009A94F3 /* WatchConfigTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420CFC672D3F9C40009A94F3 /* WatchConfigTable.swift */; }; 420CFC6A2D3F9C40009A94F3 /* WatchConfigTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420CFC672D3F9C40009A94F3 /* WatchConfigTable.swift */; }; + 42A9C1022FBB000100D0C0DE /* AllowedTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A9C1012FBB000100D0C0DE /* AllowedTag.swift */; }; + 42A9C1032FBB000100D0C0DE /* AllowedTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A9C1012FBB000100D0C0DE /* AllowedTag.swift */; }; + 42A9C1052FBB000100D0C0DE /* AllowedTagTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A9C1042FBB000100D0C0DE /* AllowedTagTable.swift */; }; + 42A9C1062FBB000100D0C0DE /* AllowedTagTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A9C1042FBB000100D0C0DE /* AllowedTagTable.swift */; }; 420CFC6C2D3F9C6E009A94F3 /* CarPlayConfigTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420CFC6B2D3F9C6E009A94F3 /* CarPlayConfigTable.swift */; }; 420CFC6D2D3F9C6E009A94F3 /* CarPlayConfigTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420CFC6B2D3F9C6E009A94F3 /* CarPlayConfigTable.swift */; }; 420CFC702D3F9C86009A94F3 /* AssistPipelinesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420CFC6F2D3F9C86009A94F3 /* AssistPipelinesTable.swift */; }; @@ -2176,6 +2181,7 @@ 11D826F024E39F2D005B8A86 /* CoreNFC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreNFC.framework; path = System/Library/Frameworks/CoreNFC.framework; sourceTree = SDKROOT; }; 11DE822D24FAC51000E636B8 /* IncomingURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingURLHandler.swift; sourceTree = ""; }; 11DE822F24FAE66F00E636B8 /* UIWindow+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+Additions.swift"; sourceTree = ""; }; + 42A9C0012FBB000100D0C0DE /* TagApprovalBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagApprovalBottomSheet.swift; sourceTree = ""; }; 11DE9D8325B6103C0081C0ED /* Home Assistant Launcher.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Home Assistant Launcher.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 11DE9D8525B6103C0081C0ED /* LauncherAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherAppDelegate.swift; sourceTree = ""; }; 11DE9D8E25B6103D0081C0ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2334,6 +2340,8 @@ 420C91542F0C7AB4005D04A6 /* EntityRegistry.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityRegistry.test.swift; sourceTree = ""; }; 420CFC632D3F9C2C009A94F3 /* HAppEntityTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAppEntityTable.swift; sourceTree = ""; }; 420CFC672D3F9C40009A94F3 /* WatchConfigTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigTable.swift; sourceTree = ""; }; + 42A9C1012FBB000100D0C0DE /* AllowedTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllowedTag.swift; sourceTree = ""; }; + 42A9C1042FBB000100D0C0DE /* AllowedTagTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllowedTagTable.swift; sourceTree = ""; }; 420CFC6B2D3F9C6E009A94F3 /* CarPlayConfigTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayConfigTable.swift; sourceTree = ""; }; 420CFC6F2D3F9C86009A94F3 /* AssistPipelinesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistPipelinesTable.swift; sourceTree = ""; }; 420CFC772D3F9CAB009A94F3 /* AppEntityRegistryListForDisplayTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntityRegistryListForDisplayTable.swift; sourceTree = ""; }; @@ -4074,6 +4082,7 @@ isa = PBXGroup; children = ( 11DE822D24FAC51000E636B8 /* IncomingURLHandler.swift */, + 42A9C0012FBB000100D0C0DE /* TagApprovalBottomSheet.swift */, 42B18FD42F38C11F00A1537A /* WebView */, 42955C392F20E2E800E398E8 /* ConnectivityCheck */, 42D6EABC2F0FF98F00FA249B /* ExternalMessageBus */, @@ -4743,6 +4752,7 @@ 420CFC7F2D3F9D89009A94F3 /* DatabaseTables.swift */, 420CFC622D3F9C1F009A94F3 /* Tables */, 4297ADA62C89C74A00790812 /* GRDB+Initialization.swift */, + 42A9C1012FBB000100D0C0DE /* AllowedTag.swift */, 42FDCA292F0C88A100C92958 /* AppEntityRegistryTable.swift */, 428DBFFA2F0C9141003B08D5 /* AppDeviceRegistryTable.swift */, ); @@ -4764,6 +4774,7 @@ 4201FD9A2F0E938E00C3EF1C /* CameraListConfigurationTable.swift */, 4210CCFE2F155B7900B71FB9 /* AssistConfigurationTable.swift */, 14444A34DA125693568C7035 /* KioskSettingsTable.swift */, + 42A9C1042FBB000100D0C0DE /* AllowedTagTable.swift */, ); path = Tables; sourceTree = ""; @@ -9526,6 +9537,7 @@ 4273C48E2C8859530065A5B4 /* PageAppEntity.swift in Sources */, 11ADB13E24C29E6900FF5EB2 /* ZoneManagerRegionFilter.swift in Sources */, 11DE822E24FAC51100E636B8 /* IncomingURLHandler.swift in Sources */, + 42A9C0022FBB000100D0C0DE /* TagApprovalBottomSheet.swift in Sources */, B657A8EA1CA646EB00121384 /* AppDelegate.swift in Sources */, 420E2AE72C474718004921D8 /* WidgetBasicViewModel.swift in Sources */, 1178C4E524D5CEB200FDEC3E /* ConnectionURLView.swift in Sources */, @@ -9865,6 +9877,8 @@ 4298587F2EB1025E00E33710 /* LocationManager.swift in Sources */, 420CFC6A2D3F9C40009A94F3 /* WatchConfigTable.swift in Sources */, 072BACCC5B2509E4AF06BFED /* KioskSettingsTable.swift in Sources */, + 42A9C1032FBB000100D0C0DE /* AllowedTag.swift in Sources */, + 42A9C1062FBB000100D0C0DE /* AllowedTagTable.swift in Sources */, 1164D9DF25FB1B9800515E8A /* UIBarButtonItem+Additions.swift in Sources */, 11B38EF6275C54A300205C7B /* PickAServerError.swift in Sources */, 426D9C752C9C60B000F278AF /* ControlEntityProvider.swift in Sources */, @@ -10349,6 +10363,8 @@ B6221F6622266FA000502A30 /* WebhookRequest.swift in Sources */, 1104FD05253292CD00B8BE34 /* Guarantee+Additions.swift in Sources */, 420CFC682D3F9C40009A94F3 /* WatchConfigTable.swift in Sources */, + 42A9C1022FBB000100D0C0DE /* AllowedTag.swift in Sources */, + 42A9C1052FBB000100D0C0DE /* AllowedTagTable.swift in Sources */, 42DC8B792E169FA300D9999E /* Color+Hex.swift in Sources */, 11B38EE7275C54A200205C7B /* PerformActionIntentHandler.swift in Sources */, 11FA53F2251071D2008D9506 /* NSItemProvider+Additions.swift in Sources */, diff --git a/Sources/App/Frontend/IncomingURLHandler.swift b/Sources/App/Frontend/IncomingURLHandler.swift index b550d6b732..8d4837f506 100644 --- a/Sources/App/Frontend/IncomingURLHandler.swift +++ b/Sources/App/Frontend/IncomingURLHandler.swift @@ -263,20 +263,10 @@ class IncomingURLHandler { switch Current.tags.handle(userActivity: userActivity) { case let .handled(type): - let (icon, text) = { () -> (MaterialDesignIcons, String) in - switch type { - case .nfc: - return (.nfcVariantIcon, L10n.Nfc.tagRead) - case .generic: - return (.qrcodeIcon, L10n.Nfc.genericTagRead) - } - }() - - Current.sceneManager.showFullScreenConfirm( - icon: icon, - text: text, - onto: .value(windowController.window) - ) + showTagReadConfirmation(type: type) + return true + case let .requiresApproval(tag, type): + showTagApproval(tag: tag, type: type) return true case let .open(url): // NFC-based URL @@ -559,6 +549,54 @@ class IncomingURLHandler { } } + private func showTagApproval(tag: String, type: TagManagerHandleResult.HandledType) { + windowController?.webViewControllerPromise.done { webViewController in + let view = TagApprovalBottomSheet( + tag: tag, + onAllowOnce: { [weak self] in + self?.fireApprovedTag(tag, type: type) + }, + onAllowAlways: { [weak self] in + AllowedTag.add(tag) + self?.fireApprovedTag(tag, type: type) + }, + onDismiss: { [weak webViewController] in + webViewController?.dismiss(animated: false) + } + ) + + let controller = UIHostingController(rootView: view) + controller.modalPresentationStyle = .overFullScreen + controller.view.backgroundColor = .clear + webViewController.present(controller, animated: false) + } + } + + private func fireApprovedTag(_ tag: String, type: TagManagerHandleResult.HandledType) { + Current.tags.fireEvent(tag: tag).cauterize() + showTagReadConfirmation(type: type) + } + + private func showTagReadConfirmation(type: TagManagerHandleResult.HandledType) { + let (icon, text) = tagConfirmationContent(type: type) + Current.sceneManager.showFullScreenConfirm( + icon: icon, + text: text, + onto: .value(windowController.window) + ) + } + + private func tagConfirmationContent( + type: TagManagerHandleResult.HandledType + ) -> (icon: MaterialDesignIcons, text: String) { + switch type { + case .nfc: + return (.nfcVariantIcon, L10n.Nfc.tagRead) + case .generic: + return (.qrcodeIcon, L10n.Nfc.genericTagRead) + } + } + private func showAlert(title: String, message: String) { let alert = UIAlertController( title: title, diff --git a/Sources/App/Frontend/TagApprovalBottomSheet.swift b/Sources/App/Frontend/TagApprovalBottomSheet.swift new file mode 100644 index 0000000000..a02684e717 --- /dev/null +++ b/Sources/App/Frontend/TagApprovalBottomSheet.swift @@ -0,0 +1,92 @@ +import Shared +import SwiftUI +import UIKit + +struct TagApprovalBottomSheet: View { + @State private var bottomSheetState: AppleLikeBottomSheetViewState? + + let tag: String + let onAllowOnce: () -> Void + let onAllowAlways: () -> Void + let onDismiss: () -> Void + + var body: some View { + AppleLikeBottomSheet( + title: L10n.Nfc.TagApproval.title, + content: { + VStack(spacing: DesignSystem.Spaces.three) { + Text(L10n.Nfc.TagApproval.description) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button { + UIPasteboard.general.string = tag + UINotificationFeedbackGenerator().notificationOccurred(.success) + } label: { + VStack(alignment: .leading) { + Text(L10n.Nfc.TagApproval.TagId.title) + .font(DesignSystem.Font.caption2) + .padding(.leading) + .foregroundStyle(.secondary) + HStack(spacing: DesignSystem.Spaces.one) { + Text(tag) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemSymbol: .docOnDoc) + .font(.callout.weight(.semibold)) + .foregroundStyle(.secondary) + } + .foregroundStyle(.primary) + .padding(.vertical, DesignSystem.Spaces.one) + .padding(.horizontal, DesignSystem.Spaces.two) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemBackground), in: Capsule()) + } + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Nfc.TagApproval.copyTag) + + VStack(spacing: DesignSystem.Spaces.one) { + Button { + onAllowOnce() + bottomSheetState = .dismiss + } label: { + Text(L10n.Nfc.TagApproval.allowOnce) + } + .buttonStyle(.primaryButton) + + Button { + onAllowAlways() + bottomSheetState = .dismiss + } label: { + Text(L10n.Nfc.TagApproval.allowAlways) + } + .buttonStyle(.secondaryButton) + } + } + }, + contentInsets: .init( + top: .zero, + leading: DesignSystem.Spaces.two, + bottom: DesignSystem.Spaces.three, + trailing: DesignSystem.Spaces.two + ), + bottomSheetMinHeight: 320, + state: $bottomSheetState, + customDismiss: onDismiss + ) + } +} + +#Preview { + TagApprovalBottomSheet( + tag: "1234-5678-9012", + onAllowOnce: {}, + onAllowAlways: {}, + onDismiss: {} + ) +} diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index cc13ec7c31..e916231480 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -651,6 +651,12 @@ Tags will work on any device with Home Assistant installed which has hardware su "nfc.list.title" = "NFC Tags"; "nfc.list.write_tag" = "Write Tag"; "nfc.not_available" = "NFC is not available on this device"; +"nfc.tag_approval.allow_always" = "Allow Always"; +"nfc.tag_approval.allow_once" = "Allow Once"; +"nfc.tag_approval.copy_tag" = "Copy tag"; +"nfc.tag_approval.tag_id.title" = "Tag identifier"; +"nfc.tag_approval.description" = "Send this tag to Home Assistant to perform the automation attached to it."; +"nfc.tag_approval.title" = "Allow Sending?"; "nfc.read.error.generic_failure" = "Failed to read tag"; "nfc.read.error.not_home_assistant" = "NFC tag is not a Home Assistant tag"; "nfc.read.error.tag_invalid" = "NFC tag is invalid"; @@ -988,6 +994,8 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "settings.developer.annoying_background_notifications.title" = "Annoying Background Info"; "settings.developer.camera_notification.notification.body" = "Expand this to show the camera content extension"; "settings.developer.camera_notification.title" = "Show camera notification content extension"; +"settings.developer.clear_allowed_tags.complete.title" = "Approved tags cleared"; +"settings.developer.clear_allowed_tags.title" = "Clear approved tags"; "settings.developer.copy_realm.alert.message" = "Copied Realm from %@ to %@"; "settings.developer.copy_realm.alert.title" = "Copied Realm"; "settings.developer.copy_realm.title" = "Copy Realm from app group to Documents"; diff --git a/Sources/App/Settings/DebugView.swift b/Sources/App/Settings/DebugView.swift index 8dd329387c..8defed9979 100644 --- a/Sources/App/Settings/DebugView.swift +++ b/Sources/App/Settings/DebugView.swift @@ -26,6 +26,7 @@ struct DebugView: View { // Alerts @State private var showDeleteEntitiesAlert = false @State private var showResetAppAlert = false + @State private var showClearAllowedTagsAlert = false @State private var watchSyncErrorMessage: String? @State private var showWatchSyncError = false @@ -195,6 +196,9 @@ struct DebugView: View { } message: { Text(L10n.Settings.Debugging.KeychainRestartRequired.message) } + .alert(L10n.Settings.Developer.ClearAllowedTags.Complete.title, isPresented: $showClearAllowedTagsAlert) { + Button(L10n.okLabel, role: .cancel) {} + } } private func forceAppRestartAfterKeychainDeletion() { @@ -397,6 +401,16 @@ struct DebugView: View { ) } + Button { + AllowedTag.clearAll() + showClearAllowedTagsAlert = true + } label: { + linkContent( + image: .init(systemSymbol: .trash), + title: L10n.Settings.Developer.ClearAllowedTags.title + ) + } + Button { deleteKeychainConfirmationText = "" showDeleteKeychainAlert = true diff --git a/Sources/App/Settings/NFC/iOSTagManager.swift b/Sources/App/Settings/NFC/iOSTagManager.swift index 34c43fc96e..f67d1b50eb 100644 --- a/Sources/App/Settings/NFC/iOSTagManager.swift +++ b/Sources/App/Settings/NFC/iOSTagManager.swift @@ -58,16 +58,12 @@ class iOSTagManager: TagManager { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) if let tag = Self.identifier(from: url) { - fireEvent(tag: tag).cauterize() - let ndefRecord = userActivity.ndefMessagePayload.records.first - if ndefRecord == nil || ndefRecord?.typeNameFormat == .empty { - /* - For user activities not generated by background tag reading, ndefMessagePayload returns a message - that contains only one NFCNDEFPayload record. That record has a typeNameFormat of NFCTypeNameFormat - */ - return .handled(.generic) + let type = Self.handledType(from: userActivity) + if AllowedTag.contains(tag) { + fireEvent(tag: tag).cauterize() + return .handled(type) } else { - return .handled(.nfc) + return .requiresApproval(tag: tag, type: type) } } @@ -88,6 +84,10 @@ class iOSTagManager: TagManager { } private static func identifier(from url: URL) -> String? { + guard isSupportedTagHost(url.host?.lowercased()) else { + return nil + } + if url.pathComponents.starts(with: ["/", "tag"]) { // ["/", "tag", "5f0ba733-172f-430d-a7f8-e4ad940c88d7"] for example let value = url.pathComponents.dropFirst(2).joined(separator: "/") @@ -101,6 +101,29 @@ class iOSTagManager: TagManager { } } + private static func isSupportedTagHost(_ host: String?) -> Bool { + guard let host else { return false } + + var hosts = ["www.home-assistant.io"] + if Current.appConfiguration == .debug { + hosts.append("next.home-assistant.io") + } + return hosts.contains(host) + } + + private static func handledType(from userActivity: NSUserActivity) -> TagManagerHandleResult.HandledType { + let ndefRecord = userActivity.ndefMessagePayload.records.first + if ndefRecord == nil || ndefRecord?.typeNameFormat == .empty { + /* + For user activities not generated by background tag reading, ndefMessagePayload returns a message + that contains only one NFCNDEFPayload record. That record has a typeNameFormat of NFCTypeNameFormat + */ + return .generic + } else { + return .nfc + } + } + private static func identifier(from message: NFCNDEFMessage) -> Promise { firstly { .value(message.records) diff --git a/Sources/Shared/Database/AllowedTag.swift b/Sources/Shared/Database/AllowedTag.swift new file mode 100644 index 0000000000..90df94c822 --- /dev/null +++ b/Sources/Shared/Database/AllowedTag.swift @@ -0,0 +1,45 @@ +import Foundation +import GRDB + +public struct AllowedTag: Codable, FetchableRecord, PersistableRecord { + public static let databaseTableName = GRDBDatabaseTable.allowedTags.rawValue + + public var tag: String + + public init(tag: String) { + self.tag = tag + } + + public static func contains(_ tag: String) -> Bool { + do { + return try Current.database().read { db in + try AllowedTag.fetchOne(db, key: tag) != nil + } + } catch { + Current.Log.error("Failed to fetch allowed tag \(tag), error: \(error.localizedDescription)") + return false + } + } + + public static func add(_ tag: String) { + guard !tag.isEmpty else { return } + + do { + try Current.database().write { db in + try AllowedTag(tag: tag).insert(db, onConflict: .replace) + } + } catch { + Current.Log.error("Failed to save allowed tag \(tag), error: \(error.localizedDescription)") + } + } + + public static func clearAll() { + do { + try Current.database().write { db in + _ = try AllowedTag.deleteAll(db) + } + } catch { + Current.Log.error("Failed to clear allowed tags, error: \(error.localizedDescription)") + } + } +} diff --git a/Sources/Shared/Database/DatabaseTables.swift b/Sources/Shared/Database/DatabaseTables.swift index 1e9a4814dd..24f1ce139c 100644 --- a/Sources/Shared/Database/DatabaseTables.swift +++ b/Sources/Shared/Database/DatabaseTables.swift @@ -17,6 +17,7 @@ public enum GRDBDatabaseTable: String { case cameraListConfiguration case assistConfiguration case kioskSettings + case allowedTags // Dropped since 2025.2, now saved as json file // Context: https://github.com/groue/GRDB.swift/issues/1626#issuecomment-2623927815 @@ -196,4 +197,8 @@ public enum DatabaseTables { case id case settingsJSON } + + public enum AllowedTag: String, CaseIterable { + case tag + } } diff --git a/Sources/Shared/Database/GRDB+Initialization.swift b/Sources/Shared/Database/GRDB+Initialization.swift index 00cb22ced1..aae71fa821 100644 --- a/Sources/Shared/Database/GRDB+Initialization.swift +++ b/Sources/Shared/Database/GRDB+Initialization.swift @@ -68,6 +68,7 @@ public extension DatabaseQueue { CameraListConfigurationTable(), AssistConfigurationTable(), KioskSettingsTable(), + AllowedTagTable(), ] } diff --git a/Sources/Shared/Database/Tables/AllowedTagTable.swift b/Sources/Shared/Database/Tables/AllowedTagTable.swift new file mode 100644 index 0000000000..a1378193e3 --- /dev/null +++ b/Sources/Shared/Database/Tables/AllowedTagTable.swift @@ -0,0 +1,39 @@ +import Foundation +import GRDB + +final class AllowedTagTable: DatabaseTableProtocol { + private let legacyAllowedTagsKey = "allowedTags" + + var tableName: String { GRDBDatabaseTable.allowedTags.rawValue } + + var definedColumns: [String] { DatabaseTables.AllowedTag.allCases.map(\.rawValue) } + + func createIfNeeded(database: DatabaseQueue) throws { + let shouldCreateTable = try database.read { db in + try !db.tableExists(tableName) + } + if shouldCreateTable { + try database.write { db in + try db.create(table: tableName) { t in + t.primaryKey(DatabaseTables.AllowedTag.tag.rawValue, .text).notNull() + } + } + } else { + try migrateColumns(database: database) + } + + try migrateLegacyUserDefaultsTags(database: database) + } + + private func migrateLegacyUserDefaultsTags(database: DatabaseQueue) throws { + let legacyTags = Set(Current.settingsStore.prefs.stringArray(forKey: legacyAllowedTagsKey) ?? []) + guard !legacyTags.isEmpty else { return } + + try database.write { db in + for tag in legacyTags where !tag.isEmpty { + try AllowedTag(tag: tag).insert(db, onConflict: .replace) + } + } + Current.settingsStore.prefs.removeObject(forKey: legacyAllowedTagsKey) + } +} diff --git a/Sources/Shared/Environment/TagManagerProtocol.swift b/Sources/Shared/Environment/TagManagerProtocol.swift index 94adb9dfdb..be1ead1c27 100644 --- a/Sources/Shared/Environment/TagManagerProtocol.swift +++ b/Sources/Shared/Environment/TagManagerProtocol.swift @@ -9,6 +9,7 @@ public enum TagManagerHandleResult { case unhandled case handled(HandledType) + case requiresApproval(tag: String, type: HandledType) case open(URL) } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index a9053018f3..833ae6b91d 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -2391,6 +2391,22 @@ public enum L10n { public static var tagInvalid: String { return L10n.tr("Localizable", "nfc.read.error.tag_invalid") } } } + public enum TagApproval { + /// Allow Always + public static var allowAlways: String { return L10n.tr("Localizable", "nfc.tag_approval.allow_always") } + /// Allow Once + public static var allowOnce: String { return L10n.tr("Localizable", "nfc.tag_approval.allow_once") } + /// Copy tag + public static var copyTag: String { return L10n.tr("Localizable", "nfc.tag_approval.copy_tag") } + /// Send this tag to Home Assistant to perform the automation attached to it. + public static var description: String { return L10n.tr("Localizable", "nfc.tag_approval.description") } + /// Allow Sending? + public static var title: String { return L10n.tr("Localizable", "nfc.tag_approval.title") } + public enum TagId { + /// Tag identifier + public static var title: String { return L10n.tr("Localizable", "nfc.tag_approval.tag_id.title") } + } + } public enum Write { /// Hold your %@ near a writable NFC tag public static func startMessage(_ p1: Any) -> String { @@ -3446,6 +3462,14 @@ public enum L10n { public static var body: String { return L10n.tr("Localizable", "settings.developer.camera_notification.notification.body") } } } + public enum ClearAllowedTags { + /// Clear approved tags + public static var title: String { return L10n.tr("Localizable", "settings.developer.clear_allowed_tags.title") } + public enum Complete { + /// Approved tags cleared + public static var title: String { return L10n.tr("Localizable", "settings.developer.clear_allowed_tags.complete.title") } + } + } public enum CopyRealm { /// Copy Realm from app group to Documents public static var title: String { return L10n.tr("Localizable", "settings.developer.copy_realm.title") } From dac43d8369c2855c8d4b7159fc10c5561c9a29e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Thu, 7 May 2026 15:49:19 +0200 Subject: [PATCH 2/3] Add screen to manage allowed tags --- HomeAssistant.xcodeproj/project.pbxproj | 8 +++ .../Resources/en.lproj/Localizable.strings | 9 ++- .../App/Settings/NFC/AllowedTagsView.swift | 58 +++++++++++++++++++ Sources/App/Settings/NFC/TagsView.swift | 21 +++++++ .../App/Settings/Settings/SettingsItem.swift | 4 +- Sources/Shared/Database/AllowedTag.swift | 23 ++++++++ .../Shared/Resources/Swiftgen/Strings.swift | 25 +++++++- 7 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 Sources/App/Settings/NFC/AllowedTagsView.swift create mode 100644 Sources/App/Settings/NFC/TagsView.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 900f45ca37..5b9991e4b8 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -662,6 +662,8 @@ 422F951F2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422F951E2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift */; }; 423178442F28C29100814230 /* NFCListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423178422F28C29100814230 /* NFCListView.swift */; }; 423178452F28C29100814230 /* NFCTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423178432F28C29100814230 /* NFCTagView.swift */; }; + 423178462FBB000100814230 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423178472FBB000100814230 /* TagsView.swift */; }; + 423178482FBB000100814230 /* AllowedTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423178492FBB000100814230 /* AllowedTagsView.swift */; }; 423179802D54FADD0037A8A4 /* AppIntentHaptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4231797F2D54FADD0037A8A4 /* AppIntentHaptics.swift */; }; 423179812D54FADD0037A8A4 /* AppIntentHaptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4231797F2D54FADD0037A8A4 /* AppIntentHaptics.swift */; }; 42333ADB2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */; }; @@ -2411,6 +2413,8 @@ 422F951E2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAApplicationShortcutItem.swift; sourceTree = ""; }; 423178422F28C29100814230 /* NFCListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCListView.swift; sourceTree = ""; }; 423178432F28C29100814230 /* NFCTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCTagView.swift; sourceTree = ""; }; + 423178472FBB000100814230 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = ""; }; + 423178492FBB000100814230 /* AllowedTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllowedTagsView.swift; sourceTree = ""; }; 4231797F2D54FADD0037A8A4 /* AppIntentHaptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentHaptics.swift; sourceTree = ""; }; 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityRegistryListForDisplay.swift; sourceTree = ""; }; 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityIconColorProvider.swift; sourceTree = ""; }; @@ -3858,6 +3862,8 @@ 1161C01924D7633700A0E3C4 /* NFC */ = { isa = PBXGroup; children = ( + 423178472FBB000100814230 /* TagsView.swift */, + 423178492FBB000100814230 /* AllowedTagsView.swift */, 423178422F28C29100814230 /* NFCListView.swift */, 423178432F28C29100814230 /* NFCTagView.swift */, 1161C01624D75BD500A0E3C4 /* iOSTagManager.swift */, @@ -9323,6 +9329,8 @@ B6DA3C7322691A5000DE811C /* AKConverter.swift in Sources */, 423178442F28C29100814230 /* NFCListView.swift in Sources */, 423178452F28C29100814230 /* NFCTagView.swift in Sources */, + 423178462FBB000100814230 /* TagsView.swift in Sources */, + 423178482FBB000100814230 /* AllowedTagsView.swift in Sources */, 42FCCFFE2B9B1C310057783F /* ThreadCredentialsSharing+build.swift in Sources */, 423B5E0D2D677BB90000CB95 /* WidgetContentMargin.swift in Sources */, 42A32F382EEA393700B323F1 /* CarPlayLockConfirmation.swift in Sources */, diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index e916231480..07043ab88d 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -648,7 +648,7 @@ Currently mTLS is only supported on iOS 17+, it may present issues related to di Tags will work on any device with Home Assistant installed which has hardware support to read them."; "nfc.list.learn_more" = "Learn More"; "nfc.list.read_tag" = "Read Tag"; -"nfc.list.title" = "NFC Tags"; +"nfc.list.title" = "NFC Read/Write"; "nfc.list.write_tag" = "Write Tag"; "nfc.not_available" = "NFC is not available on this device"; "nfc.tag_approval.allow_always" = "Allow Always"; @@ -658,6 +658,13 @@ Tags will work on any device with Home Assistant installed which has hardware su "nfc.tag_approval.description" = "Send this tag to Home Assistant to perform the automation attached to it."; "nfc.tag_approval.title" = "Allow Sending?"; "nfc.read.error.generic_failure" = "Failed to read tag"; +"tags.allowed.delete_all" = "Delete all"; +"tags.allowed.delete_all.confirm.button" = "Delete all"; +"tags.allowed.delete_all.confirm.title" = "Delete all allowed tags?"; +"tags.allowed.empty" = "No allowed tags"; +"tags.allowed.footer" = "Allowed tags can be sent to Home Assistant without asking for approval each time. Swipe left on a tag to delete it."; +"tags.allowed.title" = "Allowed tags"; +"tags.title" = "Tags"; "nfc.read.error.not_home_assistant" = "NFC tag is not a Home Assistant tag"; "nfc.read.error.tag_invalid" = "NFC tag is invalid"; "nfc.read.start_message" = "Hold your %@ near an NFC tag"; diff --git a/Sources/App/Settings/NFC/AllowedTagsView.swift b/Sources/App/Settings/NFC/AllowedTagsView.swift new file mode 100644 index 0000000000..4ed954b9e0 --- /dev/null +++ b/Sources/App/Settings/NFC/AllowedTagsView.swift @@ -0,0 +1,58 @@ +import Shared +import SwiftUI + +struct AllowedTagsView: View { + @State private var allowedTags: [AllowedTag] = [] + @State private var showDeleteAllConfirmation = false + + var body: some View { + List { + Section { + if allowedTags.isEmpty { + Text(L10n.Tags.Allowed.empty) + .foregroundStyle(.secondary) + } else { + ForEach(allowedTags, id: \.tag) { allowedTag in + Text(allowedTag.tag) + .font(.system(.body, design: .monospaced)) + } + .onDelete(perform: deleteTags) + } + } footer: { + Text(L10n.Tags.Allowed.footer) + } + } + .navigationTitle(L10n.Tags.Allowed.title) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Tags.Allowed.deleteAll) { + showDeleteAllConfirmation = true + } + .disabled(allowedTags.isEmpty) + .confirmationDialog( + L10n.Tags.Allowed.DeleteAll.Confirm.title, + isPresented: $showDeleteAllConfirmation, + titleVisibility: .visible + ) { + Button(L10n.cancelLabel, role: .cancel) {} + Button(L10n.Tags.Allowed.DeleteAll.Confirm.button, role: .destructive) { + AllowedTag.clearAll() + loadAllowedTags() + } + } + } + } + .onAppear(perform: loadAllowedTags) + } + + private func loadAllowedTags() { + allowedTags = AllowedTag.all() + } + + private func deleteTags(at offsets: IndexSet) { + offsets + .map { allowedTags[$0].tag } + .forEach(AllowedTag.delete) + loadAllowedTags() + } +} diff --git a/Sources/App/Settings/NFC/TagsView.swift b/Sources/App/Settings/NFC/TagsView.swift new file mode 100644 index 0000000000..b08f3a939d --- /dev/null +++ b/Sources/App/Settings/NFC/TagsView.swift @@ -0,0 +1,21 @@ +import Shared +import SwiftUI + +struct TagsView: View { + var body: some View { + List { + NavigationLink { + NFCListView() + } label: { + Text(L10n.Nfc.List.title) + } + + NavigationLink { + AllowedTagsView() + } label: { + Text(L10n.Tags.Allowed.title) + } + } + .navigationTitle(L10n.Tags.title) + } +} diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index 3f86d69038..e26e3fffac 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -32,7 +32,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case .notifications: return L10n.Settings.DetailsSection.NotificationSettingsRow.title case .liveActivities: return L10n.LiveActivity.title case .sensors: return L10n.SettingsSensors.title - case .nfc: return L10n.Nfc.List.title + case .nfc: return L10n.Tags.title case .widgets: return L10n.Settings.Widgets.title case .appIconShortcuts: return L10n.Settings.AppIconShortcuts.title case .watch: return L10n.Settings.DetailsSection.WatchRowConfiguration.title @@ -126,7 +126,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case .sensors: SensorListView() case .nfc: - NFCListView() + TagsView() case .widgets: CustomWidgetsListView() case .appIconShortcuts: diff --git a/Sources/Shared/Database/AllowedTag.swift b/Sources/Shared/Database/AllowedTag.swift index 90df94c822..34abaebd73 100644 --- a/Sources/Shared/Database/AllowedTag.swift +++ b/Sources/Shared/Database/AllowedTag.swift @@ -21,6 +21,19 @@ public struct AllowedTag: Codable, FetchableRecord, PersistableRecord { } } + public static func all() -> [AllowedTag] { + do { + return try Current.database().read { db in + try AllowedTag + .order(Column(DatabaseTables.AllowedTag.tag.rawValue)) + .fetchAll(db) + } + } catch { + Current.Log.error("Failed to fetch allowed tags, error: \(error.localizedDescription)") + return [] + } + } + public static func add(_ tag: String) { guard !tag.isEmpty else { return } @@ -33,6 +46,16 @@ public struct AllowedTag: Codable, FetchableRecord, PersistableRecord { } } + public static func delete(_ tag: String) { + do { + try Current.database().write { db in + _ = try AllowedTag.deleteOne(db, key: tag) + } + } catch { + Current.Log.error("Failed to delete allowed tag \(tag), error: \(error.localizedDescription)") + } + } + public static func clearAll() { do { try Current.database().write { db in diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 833ae6b91d..42a70c2003 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -2372,7 +2372,7 @@ public enum L10n { public static var learnMore: String { return L10n.tr("Localizable", "nfc.list.learn_more") } /// Read Tag public static var readTag: String { return L10n.tr("Localizable", "nfc.list.read_tag") } - /// NFC Tags + /// NFC Read/Write public static var title: String { return L10n.tr("Localizable", "nfc.list.title") } /// Write Tag public static var writeTag: String { return L10n.tr("Localizable", "nfc.list.write_tag") } @@ -4339,6 +4339,29 @@ public enum L10n { } } + public enum Tags { + /// Tags + public static var title: String { return L10n.tr("Localizable", "tags.title") } + public enum Allowed { + /// Delete all + public static var deleteAll: String { return L10n.tr("Localizable", "tags.allowed.delete_all") } + /// No allowed tags + public static var empty: String { return L10n.tr("Localizable", "tags.allowed.empty") } + /// Allowed tags can be sent to Home Assistant without asking for approval each time. Swipe left on a tag to delete it. + public static var footer: String { return L10n.tr("Localizable", "tags.allowed.footer") } + /// Allowed tags + public static var title: String { return L10n.tr("Localizable", "tags.allowed.title") } + public enum DeleteAll { + public enum Confirm { + /// Delete all + public static var button: String { return L10n.tr("Localizable", "tags.allowed.delete_all.confirm.button") } + /// Delete all allowed tags? + public static var title: String { return L10n.tr("Localizable", "tags.allowed.delete_all.confirm.title") } + } + } + } + } + public enum Thread { public enum ActiveOperationalDataSet { /// Active operational data set From 5c9520d17a6d846af5dc1a3a24229125aa8b6d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Thu, 7 May 2026 16:06:40 +0200 Subject: [PATCH 3/3] Add tests --- HomeAssistant.xcodeproj/project.pbxproj | 8 ++ .../App/Frontend/TagApprovalBottomSheet.swift | 1 + Tests/App/Settings/NFCTagApproval.test.swift | 75 +++++++++++++++++++ Tests/Shared/Database/AllowedTag.test.swift | 63 ++++++++++++++++ .../Database/DatabaseTableProtocol.test.swift | 14 +++- .../Database/GRDB+Initialization.test.swift | 7 +- .../Database/TableSchemaTests.test.swift | 17 ++++- 7 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 Tests/App/Settings/NFCTagApproval.test.swift create mode 100644 Tests/Shared/Database/AllowedTag.test.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 5b9991e4b8..efac044e10 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1166,6 +1166,8 @@ 42F73F572E259A0900B704A9 /* BaseSensorUpdateSignaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F73F552E259A0900B704A9 /* BaseSensorUpdateSignaler.swift */; }; 42F73F5A2E264A9D00B704A9 /* WebViewControllerButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F73F592E264A9D00B704A9 /* WebViewControllerButtons.swift */; }; 42F8D8682DC3B0500022DE43 /* GesturesSetupView.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F8D8672DC3B0500022DE43 /* GesturesSetupView.test.swift */; }; + 42A7F1012FBC000100BEEF01 /* AllowedTag.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */; }; + 42A7F1212FBC000100BEEF01 /* NFCTagApproval.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1202FBC000100BEEF01 /* NFCTagApproval.test.swift */; }; 42F958992BB4684700497981 /* WidgetAssist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F958972BB4681E00497981 /* WidgetAssist.swift */; }; 42F9589C2BB4691D00497981 /* WidgetAssistProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F9589A2BB468F400497981 /* WidgetAssistProvider.swift */; }; 42F9589F2BB4707F00497981 /* WidgetAssistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F9589D2BB4705E00497981 /* WidgetAssistView.swift */; }; @@ -3017,6 +3019,8 @@ 42F73F552E259A0900B704A9 /* BaseSensorUpdateSignaler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseSensorUpdateSignaler.swift; sourceTree = ""; }; 42F73F592E264A9D00B704A9 /* WebViewControllerButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewControllerButtons.swift; sourceTree = ""; }; 42F8D8672DC3B0500022DE43 /* GesturesSetupView.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GesturesSetupView.test.swift; sourceTree = ""; }; + 42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/AllowedTag.test.swift; sourceTree = ""; }; + 42A7F1202FBC000100BEEF01 /* NFCTagApproval.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCTagApproval.test.swift; sourceTree = ""; }; 42F958972BB4681E00497981 /* WidgetAssist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAssist.swift; sourceTree = ""; }; 42F9589A2BB468F400497981 /* WidgetAssistProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAssistProvider.swift; sourceTree = ""; }; 42F9589D2BB4705E00497981 /* WidgetAssistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAssistView.swift; sourceTree = ""; }; @@ -6046,6 +6050,7 @@ 42A42A692DC37311000ABBAF /* PrivacyView.test.swift */, 42A42A6B2DC37F09000ABBAF /* AboutView.test.swift */, 42F8D8672DC3B0500022DE43 /* GesturesSetupView.test.swift */, + 42A7F1202FBC000100BEEF01 /* NFCTagApproval.test.swift */, ); path = Settings; sourceTree = ""; @@ -7311,6 +7316,7 @@ 11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */, 11883CC624C131EE0036A6C6 /* RealmZone.test.swift */, 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */, + 42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */, BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */, 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */, 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */, @@ -9771,6 +9777,7 @@ 11EF62DA24C3687D00BABB64 /* ZoneManagerRegionFilter.test.swift in Sources */, 11A71C8724A5074E00D9565F /* ZoneManager.test.swift in Sources */, 42A818E02BBEA8150083D045 /* AssistViewModel.test.swift in Sources */, + 42A7F1212FBC000100BEEF01 /* NFCTagApproval.test.swift in Sources */, 42A42A6A2DC37311000ABBAF /* PrivacyView.test.swift in Sources */, 42FCD0082B9B1ECE0057783F /* SimulatorThreadClientService.swift in Sources */, 42A47A872C452D5400C9B43D /* WebViewExternalMessageHandlerTests.swift in Sources */, @@ -10463,6 +10470,7 @@ 11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */, 11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */, DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */, + 42A7F1012FBC000100BEEF01 /* AllowedTag.test.swift in Sources */, A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */, 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */, BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */, diff --git a/Sources/App/Frontend/TagApprovalBottomSheet.swift b/Sources/App/Frontend/TagApprovalBottomSheet.swift index a02684e717..2a1aa96ee2 100644 --- a/Sources/App/Frontend/TagApprovalBottomSheet.swift +++ b/Sources/App/Frontend/TagApprovalBottomSheet.swift @@ -68,6 +68,7 @@ struct TagApprovalBottomSheet: View { .buttonStyle(.secondaryButton) } } + .padding(.top) }, contentInsets: .init( top: .zero, diff --git a/Tests/App/Settings/NFCTagApproval.test.swift b/Tests/App/Settings/NFCTagApproval.test.swift new file mode 100644 index 0000000000..d85e3c3f39 --- /dev/null +++ b/Tests/App/Settings/NFCTagApproval.test.swift @@ -0,0 +1,75 @@ +import Foundation +import GRDB +@testable import HomeAssistant +@testable import Shared +import Testing + +@Suite(.serialized) +struct NFCTagApprovalTests { + private let legacyAllowedTagsKey = "allowedTags" + + @Test("Unapproved Home Assistant tags require approval") + func unapprovedTagsRequireApproval() throws { + try withAllowedTagDatabase { + let result = iOSTagManager().handle(userActivity: userActivity(tag: "front-door")) + + guard case let .requiresApproval(tag, type) = result else { + Issue.record("Expected tag to require approval") + return + } + + #expect(tag == "front-door") + #expect(isGeneric(type)) + } + } + + @Test("Allowed Home Assistant tags are handled immediately") + func allowedTagsAreHandledImmediately() throws { + try withAllowedTagDatabase { + let previousServers = Current.servers + Current.servers = FakeServerManager(initial: 0) + defer { Current.servers = previousServers } + + AllowedTag.add("front-door") + + let result = iOSTagManager().handle(userActivity: userActivity(tag: "front-door")) + + guard case let .handled(type) = result else { + Issue.record("Expected allowed tag to be handled") + return + } + + #expect(isGeneric(type)) + } + } + + private func withAllowedTagDatabase(perform work: () throws -> Void) throws { + let previousDatabase = Current.database + let database = try DatabaseQueue(path: ":memory:") + + Current.settingsStore.prefs.removeObject(forKey: legacyAllowedTagsKey) + try AllowedTagTable().createIfNeeded(database: database) + Current.database = { database } + + defer { + Current.database = previousDatabase + Current.settingsStore.prefs.removeObject(forKey: legacyAllowedTagsKey) + } + + try work() + } + + private func userActivity(tag: String) -> NSUserActivity { + let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) + activity.webpageURL = URL(string: "https://www.home-assistant.io/tag/\(tag)")! + return activity + } + + private func isGeneric(_ type: TagManagerHandleResult.HandledType) -> Bool { + if case .generic = type { + return true + } else { + return false + } + } +} diff --git a/Tests/Shared/Database/AllowedTag.test.swift b/Tests/Shared/Database/AllowedTag.test.swift new file mode 100644 index 0000000000..f98bbc6d06 --- /dev/null +++ b/Tests/Shared/Database/AllowedTag.test.swift @@ -0,0 +1,63 @@ +import GRDB +@testable import Shared +import Testing + +@Suite(.serialized) +struct AllowedTagTests { + private let legacyAllowedTagsKey = "allowedTags" + + @Test("Allowed tags can be added, listed, deleted, and cleared") + func allowedTagsCRUD() throws { + try withAllowedTagDatabase { + AllowedTag.add("garage") + AllowedTag.add("front-door") + AllowedTag.add("garage") + AllowedTag.add("") + + #expect(AllowedTag.contains("garage")) + #expect(AllowedTag.contains("front-door")) + #expect(!AllowedTag.contains("missing")) + #expect(AllowedTag.all().map(\.tag) == ["front-door", "garage"]) + + AllowedTag.delete("front-door") + + #expect(!AllowedTag.contains("front-door")) + #expect(AllowedTag.all().map(\.tag) == ["garage"]) + + AllowedTag.clearAll() + + #expect(AllowedTag.all().isEmpty) + } + } + + @Test("Allowed tags migrate from legacy UserDefaults storage") + func migratesLegacyUserDefaultsTags() throws { + try withAllowedTagDatabase(legacyTags: ["garage", "front-door", "garage", ""]) { + #expect(AllowedTag.all().map(\.tag) == ["front-door", "garage"]) + #expect(Current.settingsStore.prefs.stringArray(forKey: legacyAllowedTagsKey) == nil) + } + } + + private func withAllowedTagDatabase( + legacyTags: [String]? = nil, + perform work: () throws -> Void + ) throws { + let previousDatabase = Current.database + let database = try DatabaseQueue(path: ":memory:") + + Current.settingsStore.prefs.removeObject(forKey: legacyAllowedTagsKey) + if let legacyTags { + Current.settingsStore.prefs.set(legacyTags, forKey: legacyAllowedTagsKey) + } + + try AllowedTagTable().createIfNeeded(database: database) + Current.database = { database } + + defer { + Current.database = previousDatabase + Current.settingsStore.prefs.removeObject(forKey: legacyAllowedTagsKey) + } + + try work() + } +} diff --git a/Tests/Shared/Database/DatabaseTableProtocol.test.swift b/Tests/Shared/Database/DatabaseTableProtocol.test.swift index 6e1505e0ca..0ebcf9b331 100644 --- a/Tests/Shared/Database/DatabaseTableProtocol.test.swift +++ b/Tests/Shared/Database/DatabaseTableProtocol.test.swift @@ -163,10 +163,20 @@ struct DatabaseTableProtocolTests { #expect(Set(table.definedColumns) == Set(expectedColumns)) } - @Test("All 16 tables conform to DatabaseTableProtocol") + @Test("AllowedTagTable conforms to DatabaseTableProtocol") + func allowedTagTableConformance() throws { + let table = AllowedTagTable() + #expect(table.tableName == GRDBDatabaseTable.allowedTags.rawValue) + #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") + + let expectedColumns = DatabaseTables.AllowedTag.allCases.map(\.rawValue) + #expect(Set(table.definedColumns) == Set(expectedColumns)) + } + + @Test("All 17 tables conform to DatabaseTableProtocol") func allTablesConformToProtocol() throws { let tables = DatabaseQueue.tables() - #expect(tables.count == 16, "Should have exactly 16 tables") + #expect(tables.count == 17, "Should have exactly 17 tables") for table in tables { // Verify each table has a non-empty tableName diff --git a/Tests/Shared/Database/GRDB+Initialization.test.swift b/Tests/Shared/Database/GRDB+Initialization.test.swift index 67b0865bbb..fbc10885a5 100644 --- a/Tests/Shared/Database/GRDB+Initialization.test.swift +++ b/Tests/Shared/Database/GRDB+Initialization.test.swift @@ -27,10 +27,10 @@ struct GRDBInitializationTests { ) } - @Test("Tables returns exactly 16 tables") - func tablesReturns16Tables() throws { + @Test("Tables returns exactly 17 tables") + func tablesReturns17Tables() throws { let tables = DatabaseQueue.tables() - #expect(tables.count == 16, "DatabaseQueue.tables() should return exactly 16 tables") + #expect(tables.count == 17, "DatabaseQueue.tables() should return exactly 17 tables") } @Test("Tables contains all expected table names") @@ -56,6 +56,7 @@ struct GRDBInitializationTests { GRDBDatabaseTable.cameraListConfiguration.rawValue, GRDBDatabaseTable.assistConfiguration.rawValue, GRDBDatabaseTable.kioskSettings.rawValue, + GRDBDatabaseTable.allowedTags.rawValue, ] for expectedName in expectedTableNames { diff --git a/Tests/Shared/Database/TableSchemaTests.test.swift b/Tests/Shared/Database/TableSchemaTests.test.swift index 697037dec0..37e352f16e 100644 --- a/Tests/Shared/Database/TableSchemaTests.test.swift +++ b/Tests/Shared/Database/TableSchemaTests.test.swift @@ -220,13 +220,24 @@ struct TableSchemaTests { ) } - @Test("All 16 tables create successfully together") + @Test("AllowedTagTable schema validation") + func allowedTagTableSchema() throws { + let table = AllowedTagTable() + let expectedColumns = DatabaseTables.AllowedTag.allCases.map(\.rawValue) + try verifyTableSchema( + table: table, + expectedTableName: GRDBDatabaseTable.allowedTags.rawValue, + expectedColumns: expectedColumns + ) + } + + @Test("All 17 tables create successfully together") func allTablesCreateTogether() throws { let database = try DatabaseQueue(path: ":memory:") let tables = DatabaseQueue.tables() - // Verify we have exactly 16 tables - #expect(tables.count == 16, "Should have exactly 16 tables, but found \(tables.count)") + // Verify we have exactly 17 tables + #expect(tables.count == 17, "Should have exactly 17 tables, but found \(tables.count)") // Create all tables for table in tables {