Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

66 changes: 52 additions & 14 deletions Sources/App/Frontend/IncomingURLHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions Sources/App/Frontend/TagApprovalBottomSheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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)
Comment thread
bgoncal marked this conversation as resolved.
}
.buttonStyle(.primaryButton)

Button {
onAllowAlways()
bottomSheetState = .dismiss
} label: {
Text(L10n.Nfc.TagApproval.allowAlways)
}
.buttonStyle(.secondaryButton)
Comment thread
bgoncal marked this conversation as resolved.
}
}
.padding(.top)
},
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: {}
)
}
17 changes: 16 additions & 1 deletion Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -648,10 +648,23 @@ 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";
"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";
"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";
Expand Down Expand Up @@ -988,6 +1001,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";
Expand Down
14 changes: 14 additions & 0 deletions Sources/App/Settings/DebugView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions Sources/App/Settings/NFC/AllowedTagsView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
21 changes: 21 additions & 0 deletions Sources/App/Settings/NFC/TagsView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
41 changes: 32 additions & 9 deletions Sources/App/Settings/NFC/iOSTagManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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: "/")
Expand All @@ -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<String> {
firstly {
.value(message.records)
Expand Down
Loading
Loading