From 5ab00022ac685de338f37820b193ffbfcc438460 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 15:37:28 +0000 Subject: [PATCH 1/2] Add persistent notification with lock control actions Implemented a persistent notification feature that allows users to control their lock via notification actions (Open, Close, Pull Spring) with automatic connection handling. Features: - NotificationManager: Handles persistent notifications with action buttons - Auto-connect: Automatically connects to lock when action is triggered - Background modes: Added bluetooth-central capability for BLE in background - UI controls: Added buttons to show/hide persistent notification Changes: - Created NotificationManager.swift for notification handling - Extended ContentViewModel with handleNotificationAction method - Updated tedee_exampleApp.swift to initialize notification system - Added persistent notification controls to ContentView - Configured background modes in project settings --- tedee example.xcodeproj/project.pbxproj | 6 ++ tedee example/ContentView.swift | 35 ++++++- tedee example/ContentViewModel.swift | 37 ++++++- tedee example/NotificationManager.swift | 123 ++++++++++++++++++++++++ tedee example/tedee_exampleApp.swift | 30 +++++- 5 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 tedee example/NotificationManager.swift diff --git a/tedee example.xcodeproj/project.pbxproj b/tedee example.xcodeproj/project.pbxproj index 7750572..ebbc7af 100644 --- a/tedee example.xcodeproj/project.pbxproj +++ b/tedee example.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ D3B374722B8F5E1B00EDA7A5 /* ContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B3746C2B8F5E1B00EDA7A5 /* ContentViewModel.swift */; }; D3B374732B8F5E1B00EDA7A5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B3746D2B8F5E1B00EDA7A5 /* ContentView.swift */; }; D3B374942B8F700A00EDA7A5 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B374932B8F700A00EDA7A5 /* Configuration.swift */; }; + D3B374952B8F800B00EDA7A5 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B374952B8F800A00EDA7A5 /* NotificationManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -37,6 +38,7 @@ D3B3746C2B8F5E1B00EDA7A5 /* ContentViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentViewModel.swift; sourceTree = ""; }; D3B3746D2B8F5E1B00EDA7A5 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; D3B374932B8F700A00EDA7A5 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; + D3B374952B8F800A00EDA7A5 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -76,6 +78,7 @@ D3B3746C2B8F5E1B00EDA7A5 /* ContentViewModel.swift */, D3B3746D2B8F5E1B00EDA7A5 /* ContentView.swift */, D3B374932B8F700A00EDA7A5 /* Configuration.swift */, + D3B374952B8F800A00EDA7A5 /* NotificationManager.swift */, ); path = "tedee example"; sourceTree = ""; @@ -169,6 +172,7 @@ D3B3746F2B8F5E1B00EDA7A5 /* tedee_exampleApp.swift in Sources */, D3B374942B8F700A00EDA7A5 /* Configuration.swift in Sources */, D3B374732B8F5E1B00EDA7A5 /* ContentView.swift in Sources */, + D3B374952B8F800B00EDA7A5 /* NotificationManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -308,6 +312,7 @@ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth is required to make connection to tedee Lock"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -338,6 +343,7 @@ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth is required to make connection to tedee Lock"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/tedee example/ContentView.swift b/tedee example/ContentView.swift index a4f6f4a..6f7b48a 100644 --- a/tedee example/ContentView.swift +++ b/tedee example/ContentView.swift @@ -89,18 +89,18 @@ struct ContentView: View { Text("Pull") } .buttonStyle(.bordered) - + Spacer() - + Button { viewModel.openLock() } label: { Text("Open") } .buttonStyle(.bordered) - + Spacer() - + Button { viewModel.closeLock() } label: { @@ -119,6 +119,33 @@ struct ContentView: View { Spacer() } } + + Section { + VStack { + Text("Persistent Notification") + .font(.headline) + HStack { + Button { + Task { + try? await NotificationManager.shared.showPersistentNotification() + } + } label: { + Text("Show Notification") + } + .buttonStyle(.bordered) + + Spacer() + + Button { + NotificationManager.shared.removePersistentNotification() + } label: { + Text("Hide Notification") + .foregroundStyle(.red) + } + .buttonStyle(.bordered) + } + } + } Section { VStack(alignment: .leading) { diff --git a/tedee example/ContentViewModel.swift b/tedee example/ContentViewModel.swift index c578643..7053b7b 100644 --- a/tedee example/ContentViewModel.swift +++ b/tedee example/ContentViewModel.swift @@ -153,7 +153,7 @@ final class ContentViewModel { } } } - + Task { @MainActor in for await notification in TedeeLockManager.shared.notificationsStream { if self.serialNumber.serialNumber == notification.serialNumber.serialNumber { @@ -169,4 +169,39 @@ final class ContentViewModel { } } } + + @MainActor + func handleNotificationAction(_ actionIdentifier: String) async { + comunicationList.append(ComunicationListItem("notification action: \(actionIdentifier)")) + + // Auto-connect if not connected + if connectionStatus != .connected { + comunicationList.append(ComunicationListItem("auto-connecting to lock...")) + await connect() + + // Wait for connection to establish + try? await Task.sleep(for: .seconds(2)) + + // Check if connected + if connectionStatus != .connected { + comunicationList.append(ComunicationListItem("failed to auto-connect")) + return + } + } + + // Execute action based on identifier + switch actionIdentifier { + case NotificationManager.openActionIdentifier: + comunicationList.append(ComunicationListItem("executing open from notification")) + await openLock() + case NotificationManager.closeActionIdentifier: + comunicationList.append(ComunicationListItem("executing close from notification")) + await closeLock() + case NotificationManager.pullSpringActionIdentifier: + comunicationList.append(ComunicationListItem("executing pull spring from notification")) + await pullLock() + default: + break + } + } } diff --git a/tedee example/NotificationManager.swift b/tedee example/NotificationManager.swift new file mode 100644 index 0000000..6942339 --- /dev/null +++ b/tedee example/NotificationManager.swift @@ -0,0 +1,123 @@ +// +// NotificationManager.swift +// tedee example +// +// Created for persistent lock control notification +// + +import Foundation +import UserNotifications +import UIKit + +@MainActor +class NotificationManager: NSObject, ObservableObject, UNUserNotificationCenterDelegate { + static let shared = NotificationManager() + + // Action identifiers + static let openActionIdentifier = "OPEN_LOCK" + static let closeActionIdentifier = "CLOSE_LOCK" + static let pullSpringActionIdentifier = "PULL_SPRING" + + // Notification identifier + private let persistentNotificationIdentifier = "tedee_lock_control" + + // Callback for handling actions + var onActionReceived: ((String) async -> Void)? + + private override init() { + super.init() + UNUserNotificationCenter.current().delegate = self + } + + // Request notification permissions + func requestAuthorization() async throws { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) + if !granted { + throw NSError(domain: "NotificationManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Notification permission denied"]) + } + } + + // Setup notification categories with actions + func setupNotificationCategories() { + let openAction = UNNotificationAction( + identifier: Self.openActionIdentifier, + title: "🔓 Apri", + options: [.foreground] + ) + + let closeAction = UNNotificationAction( + identifier: Self.closeActionIdentifier, + title: "🔒 Chiudi", + options: [.foreground] + ) + + let pullSpringAction = UNNotificationAction( + identifier: Self.pullSpringActionIdentifier, + title: "🔄 Pull Spring", + options: [.foreground] + ) + + let category = UNNotificationCategory( + identifier: "LOCK_CONTROL", + actions: [openAction, closeAction, pullSpringAction], + intentIdentifiers: [], + options: [] + ) + + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + // Show persistent notification + func showPersistentNotification() async throws { + let content = UNMutableNotificationContent() + content.title = "Tedee Lock Control" + content.body = "Controllo rapido del tuo lock" + content.categoryIdentifier = "LOCK_CONTROL" + content.sound = nil + + // Create a trigger that fires immediately + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + + let request = UNNotificationRequest( + identifier: persistentNotificationIdentifier, + content: content, + trigger: trigger + ) + + try await UNUserNotificationCenter.current().add(request) + } + + // Remove persistent notification + func removePersistentNotification() { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [persistentNotificationIdentifier]) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [persistentNotificationIdentifier]) + } + + // MARK: - UNUserNotificationCenterDelegate + + // Handle notification when app is in foreground + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + return [.banner, .list] + } + + // Handle notification action response + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let actionIdentifier = response.actionIdentifier + + // Call the action handler on main actor + await MainActor.run { + Task { + await self.onActionReceived?(actionIdentifier) + + // Re-show the notification after action + try? await self.showPersistentNotification() + } + } + } +} diff --git a/tedee example/tedee_exampleApp.swift b/tedee example/tedee_exampleApp.swift index 4750680..3d0e276 100644 --- a/tedee example/tedee_exampleApp.swift +++ b/tedee example/tedee_exampleApp.swift @@ -9,9 +9,37 @@ import SwiftUI @main struct tedee_exampleApp: App { + @StateObject private var viewModel = ContentViewModel() + + init() { + // Initialize notification manager + Task { @MainActor in + let notificationManager = NotificationManager.shared + + // Setup notification categories + notificationManager.setupNotificationCategories() + + // Request authorization + do { + try await notificationManager.requestAuthorization() + + // Show persistent notification + try await notificationManager.showPersistentNotification() + } catch { + print("Notification setup error: \(error)") + } + } + } + var body: some Scene { WindowGroup { - ContentView(viewModel: ContentViewModel()) + ContentView(viewModel: viewModel) + .onAppear { + // Setup notification action handler + NotificationManager.shared.onActionReceived = { actionIdentifier in + await self.viewModel.handleNotificationAction(actionIdentifier) + } + } } } } From bf8caf4cfcce4daacf57541342f96cc6dbc243cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 16:43:38 +0000 Subject: [PATCH 2/2] Fix: Move NotificationManager initialization to .task modifier to prevent main thread crash --- tedee example/tedee_exampleApp.swift | 39 +++++++++++++--------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/tedee example/tedee_exampleApp.swift b/tedee example/tedee_exampleApp.swift index 3d0e276..5a2cd23 100644 --- a/tedee example/tedee_exampleApp.swift +++ b/tedee example/tedee_exampleApp.swift @@ -11,30 +11,26 @@ import SwiftUI struct tedee_exampleApp: App { @StateObject private var viewModel = ContentViewModel() - init() { - // Initialize notification manager - Task { @MainActor in - let notificationManager = NotificationManager.shared - - // Setup notification categories - notificationManager.setupNotificationCategories() - - // Request authorization - do { - try await notificationManager.requestAuthorization() - - // Show persistent notification - try await notificationManager.showPersistentNotification() - } catch { - print("Notification setup error: \(error)") - } - } - } - var body: some Scene { WindowGroup { ContentView(viewModel: viewModel) - .onAppear { + .task { + // Initialize notification manager on main thread + let notificationManager = NotificationManager.shared + + // Setup notification categories + notificationManager.setupNotificationCategories() + + // Request authorization + do { + try await notificationManager.requestAuthorization() + + // Show persistent notification + try await notificationManager.showPersistentNotification() + } catch { + print("Notification setup error: \(error)") + } + // Setup notification action handler NotificationManager.shared.onActionReceived = { actionIdentifier in await self.viewModel.handleNotificationAction(actionIdentifier) @@ -43,3 +39,4 @@ struct tedee_exampleApp: App { } } } +