From c59cd48a6da900f4e5a8ce3192309b1173c4268a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:55:21 +0200 Subject: [PATCH] Migrate Notification settings + leaf screens to SwiftUI (#4562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Migrate the notification settings screen and its three leaf screens to SwiftUI: - `NotificationSettingsView`: permission status, learn-more link, sounds / categories / rate-limit / debug navigation, badge reset + auto-clear, push ID share, push ID reset. - `NotificationSoundsView`: imported / bundled / system segmented lists, audio playback, swipe delete, `.fileImporter`, file-sharing + system import, AKConverter progress HUD, alert handling. - `NotificationRateLimitView`: pull-to-refresh on iOS, toolbar refresh on Catalyst, 1-second reset countdown, retry state, parent remaining-count callback. - `NotificationDebugNotificationsView`: `UserDefaults`-backed toggles. Removes the Eureka `row(for:)` extensions in `NotificationRateLimitsAPI.swift`, deletes the four old `*ViewController.swift` files, and rewires `SettingsItem.notifications` and `NotificationManager.openSettingsFor` to the SwiftUI view. ## Screenshots _Pending — to be added before merge._ ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes Part of a five-PR Eureka → SwiftUI migration tracked in `UIKitToSwiftUIMigration.md` (siblings: #4560, #4561, #4563, #4564). The `Eureka`, `ColorPickerRow`, and `ViewRow` pods stay until all slices land. **Reconciliation with #4563:** the categories PR temporarily embeds `NotificationCategoryListView` inside the old `NotificationSettingsViewController` via `UIHostingController`. This PR deletes that controller entirely; after both merge, the `categoriesDestination` in `NotificationSettingsView` should link directly to `NotificationCategoryListView` from the categories PR. `bundle exec fastlane lint` passes. Not build-verified locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 --- HomeAssistant.xcodeproj/project.pbxproj | 32 +- .../Notifications/NotificationManager.swift | 10 +- .../NotificationDebugNotificationsView.swift | 73 +++ ...tionDebugNotificationsViewController.swift | 86 --- .../NotificationRateLimitView.swift | 245 ++++++++ .../NotificationRateLimitViewController.swift | 164 ----- .../NotificationRateLimitsAPI.swift | 46 -- .../NotificationSettingsView.swift | 321 ++++++++++ .../NotificationSettingsViewController.swift | 311 ---------- .../NotificationSoundsView.swift | 579 ++++++++++++++++++ .../NotificationSoundsViewController.swift | 512 ---------------- .../App/Settings/Settings/SettingsItem.swift | 3 +- .../App/Settings/SettingsRootDataSource.swift | 6 +- 13 files changed, 1246 insertions(+), 1142 deletions(-) create mode 100644 Sources/App/Settings/Notifications/NotificationDebugNotificationsView.swift delete mode 100644 Sources/App/Settings/Notifications/NotificationDebugNotificationsViewController.swift create mode 100644 Sources/App/Settings/Notifications/NotificationRateLimitView.swift delete mode 100644 Sources/App/Settings/Notifications/NotificationRateLimitViewController.swift create mode 100644 Sources/App/Settings/Notifications/NotificationSettingsView.swift delete mode 100644 Sources/App/Settings/Notifications/NotificationSettingsViewController.swift create mode 100644 Sources/App/Settings/Notifications/NotificationSoundsView.swift delete mode 100644 Sources/App/Settings/Notifications/NotificationSoundsViewController.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index f209f689b..946bc9653 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -453,8 +453,8 @@ 11F3847C24FB27FC00CB0D74 /* DeviceWrapperBatteryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F3847A24FB27FB00CB0D74 /* DeviceWrapperBatteryObserver.swift */; }; 11F3D74C2495377B00C05BBA /* SensorListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F3D74B2495377B00C05BBA /* SensorListView.swift */; }; 11F55EBC25D3A2A3003977AC /* NotificationCategoryListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55EBB25D3A2A3003977AC /* NotificationCategoryListViewController.swift */; }; - 11F55ECD25D3A364003977AC /* NotificationRateLimitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55ECC25D3A364003977AC /* NotificationRateLimitViewController.swift */; }; - 11F55EED25D3B088003977AC /* NotificationDebugNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55EEC25D3B088003977AC /* NotificationDebugNotificationsViewController.swift */; }; + 11F55ECD25D3A364003977AC /* NotificationRateLimitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55ECC25D3A364003977AC /* NotificationRateLimitView.swift */; }; + 11F55EED25D3B088003977AC /* NotificationDebugNotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F55EEC25D3B088003977AC /* NotificationDebugNotificationsView.swift */; }; 11F855D624DF6C7A0018013E /* MaterialDesignIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 11F855D224DF6C7A0018013E /* MaterialDesignIcons.ttf */; }; 11F855D724DF6C7A0018013E /* MaterialDesignIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 11F855D224DF6C7A0018013E /* MaterialDesignIcons.ttf */; }; 11F855D824DF6C7A0018013E /* MaterialDesignIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F855D324DF6C7A0018013E /* MaterialDesignIcons.swift */; }; @@ -1438,7 +1438,7 @@ B657A90C1CA646EB00121384 /* HomeAssistantUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657A90B1CA646EB00121384 /* HomeAssistantUITests.swift */; }; B658AA7E2250B2A000C9BFE3 /* MobileAppUpdateRegistrationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B658AA7C2250B25D00C9BFE3 /* MobileAppUpdateRegistrationRequest.swift */; }; B658AA7F2250B2A100C9BFE3 /* MobileAppUpdateRegistrationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B658AA7C2250B25D00C9BFE3 /* MobileAppUpdateRegistrationRequest.swift */; }; - B65C0B522282BA13007E057B /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65C0B512282BA13007E057B /* NotificationSettingsViewController.swift */; }; + B65C0B522282BA13007E057B /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65C0B512282BA13007E057B /* NotificationSettingsView.swift */; }; B6617EED1CFE79AD004DEE6D /* NSURL+QueryDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6617EEC1CFE79AD004DEE6D /* NSURL+QueryDictionary.swift */; }; B661FB68226B961400E541DD /* WebSocketBridge.js in Resources */ = {isa = PBXBuildFile; fileRef = B661FB67226B961400E541DD /* WebSocketBridge.js */; }; B661FC7E226C87BB00E541DD /* home.json in Resources */ = {isa = PBXBuildFile; fileRef = B661FC7D226C87BB00E541DD /* home.json */; }; @@ -1524,7 +1524,7 @@ B6D3B4ED225B26900082BB4F /* SensorContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D3B4EB225B26300082BB4F /* SensorContainer.swift */; }; B6D3B4EE225B26910082BB4F /* SensorContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D3B4EB225B26300082BB4F /* SensorContainer.swift */; }; B6D8A3282271448E00FA765D /* error.json in Resources */ = {isa = PBXBuildFile; fileRef = B6D8A3272271448D00FA765D /* error.json */; }; - B6DA3C7122690B1F00DE811C /* NotificationSoundsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DA3C7022690B1F00DE811C /* NotificationSoundsViewController.swift */; }; + B6DA3C7122690B1F00DE811C /* NotificationSoundsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DA3C7022690B1F00DE811C /* NotificationSoundsView.swift */; }; B6DA3C7322691A5000DE811C /* AKConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DA3C7222691A5000DE811C /* AKConverter.swift */; }; B6DD5E6A24940F6F003A0154 /* OpenInFirefoxControllerSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DD5E6924940F6F003A0154 /* OpenInFirefoxControllerSwift.swift */; }; B6DDF8534A4176416CFAC79A /* KioskSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49767602CA2066683EC638F /* KioskSettingsView.swift */; }; @@ -2251,8 +2251,8 @@ 11F3847A24FB27FB00CB0D74 /* DeviceWrapperBatteryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceWrapperBatteryObserver.swift; sourceTree = ""; }; 11F3D74B2495377B00C05BBA /* SensorListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorListView.swift; sourceTree = ""; }; 11F55EBB25D3A2A3003977AC /* NotificationCategoryListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategoryListViewController.swift; sourceTree = ""; }; - 11F55ECC25D3A364003977AC /* NotificationRateLimitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRateLimitViewController.swift; sourceTree = ""; }; - 11F55EEC25D3B088003977AC /* NotificationDebugNotificationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugNotificationsViewController.swift; sourceTree = ""; }; + 11F55ECC25D3A364003977AC /* NotificationRateLimitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRateLimitView.swift; sourceTree = ""; }; + 11F55EEC25D3B088003977AC /* NotificationDebugNotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugNotificationsView.swift; sourceTree = ""; }; 11F855D224DF6C7A0018013E /* MaterialDesignIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = MaterialDesignIcons.ttf; path = Tools/MaterialDesignIcons.ttf; sourceTree = SOURCE_ROOT; }; 11F855D324DF6C7A0018013E /* MaterialDesignIcons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MaterialDesignIcons.swift; sourceTree = ""; }; 11F855D424DF6C7A0018013E /* IconDrawable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IconDrawable.swift; sourceTree = ""; }; @@ -3363,7 +3363,7 @@ B658AA7622506DAF00C9BFE3 /* GoogleService-Info-Beta.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Beta.plist"; sourceTree = ""; }; B658AA7C2250B25D00C9BFE3 /* MobileAppUpdateRegistrationRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MobileAppUpdateRegistrationRequest.swift; sourceTree = ""; }; B65B15042273188300635D5C /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; - B65C0B512282BA13007E057B /* NotificationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewController.swift; sourceTree = ""; }; + B65C0B512282BA13007E057B /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; B6617EEC1CFE79AD004DEE6D /* NSURL+QueryDictionary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSURL+QueryDictionary.swift"; sourceTree = ""; }; B661FB67226B961400E541DD /* WebSocketBridge.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = WebSocketBridge.js; sourceTree = ""; }; B661FC7D226C87BB00E541DD /* home.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = home.json; sourceTree = ""; }; @@ -3426,7 +3426,7 @@ B6CC5D9B2159D10F00833E5D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B6D3B4EB225B26300082BB4F /* SensorContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorContainer.swift; sourceTree = ""; }; B6D8A3272271448D00FA765D /* error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = error.json; sourceTree = ""; }; - B6DA3C7022690B1F00DE811C /* NotificationSoundsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundsViewController.swift; sourceTree = ""; }; + B6DA3C7022690B1F00DE811C /* NotificationSoundsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundsView.swift; sourceTree = ""; }; B6DA3C7222691A5000DE811C /* AKConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AKConverter.swift; sourceTree = ""; }; B6DAC734215F069300727D2A /* NotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategory.swift; sourceTree = ""; }; B6DAC736215F06B100727D2A /* NotificationAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAction.swift; sourceTree = ""; }; @@ -4148,12 +4148,12 @@ children = ( B68EDD02215F0E2900DD6B28 /* NotificationCategoryConfigurator.swift */, B68EDD04215F12C900DD6B28 /* NotificationActionConfigurator.swift */, - B6DA3C7022690B1F00DE811C /* NotificationSoundsViewController.swift */, - B65C0B512282BA13007E057B /* NotificationSettingsViewController.swift */, + B6DA3C7022690B1F00DE811C /* NotificationSoundsView.swift */, + B65C0B512282BA13007E057B /* NotificationSettingsView.swift */, 11C4629324B189B100031902 /* NotificationRateLimitsAPI.swift */, 11F55EBB25D3A2A3003977AC /* NotificationCategoryListViewController.swift */, - 11F55ECC25D3A364003977AC /* NotificationRateLimitViewController.swift */, - 11F55EEC25D3B088003977AC /* NotificationDebugNotificationsViewController.swift */, + 11F55ECC25D3A364003977AC /* NotificationRateLimitView.swift */, + 11F55EEC25D3B088003977AC /* NotificationDebugNotificationsView.swift */, ); path = Notifications; sourceTree = ""; @@ -9382,7 +9382,7 @@ 1112AEBB25F717E9007A541A /* LocationHistoryDetailViewController.swift in Sources */, 42FA83392F4722A30050095A /* FrontEndConnectionState.swift in Sources */, 11BD7B4D25B53D7F001826F0 /* AppMacBridgeStatusItemConfiguration.swift in Sources */, - 11F55EED25D3B088003977AC /* NotificationDebugNotificationsViewController.swift in Sources */, + 11F55EED25D3B088003977AC /* NotificationDebugNotificationsView.swift in Sources */, 42DFE8742EFAE33D0058DADB /* AssistWavesAnimation.swift in Sources */, 42462E732DA4114800ECC8A7 /* Sizes.swift in Sources */, 42175EC12F436811001073AF /* ClientCertificateOnboardingView.swift in Sources */, @@ -9524,7 +9524,7 @@ 4238E8572EB0EA4A00BDF010 /* ConnectionSecurityLevelBlockViewModel.swift in Sources */, 42AAEE882EC4B9880049E1F3 /* URLOpener.swift in Sources */, 42DF7DE22EC53DBC003F3C4A /* HomeAssistantAccountRowView.swift in Sources */, - B6DA3C7122690B1F00DE811C /* NotificationSoundsViewController.swift in Sources */, + B6DA3C7122690B1F00DE811C /* NotificationSoundsView.swift in Sources */, 424DD05A2B3509170057E456 /* CarPlayQuickAccessTemplate.swift in Sources */, 451D63FF25213F1E2AF7C73A /* CarPlayAssistSession.swift in Sources */, 429AFE5E2DB7DED300AF0836 /* GeneralSettingsTemplateEditor.swift in Sources */, @@ -9571,7 +9571,7 @@ 42790C442C48077200E31B38 /* ImprovSuccessView.swift in Sources */, 429AFE5C2DB7BE4000AF0836 /* GeneralSettingsView.swift in Sources */, 425573ED2B58904000145217 /* CarPlayEntityListItem.swift in Sources */, - 11F55ECD25D3A364003977AC /* NotificationRateLimitViewController.swift in Sources */, + 11F55ECD25D3A364003977AC /* NotificationRateLimitView.swift in Sources */, 4210CCEB2F152AD000B71FB9 /* CameraPlayerView.swift in Sources */, 3E4087F02CEC7F210085DF29 /* WidgetBasicSensorView.swift in Sources */, 42F73F5A2E264A9D00B704A9 /* WebViewControllerButtons.swift in Sources */, @@ -9642,7 +9642,7 @@ 1185DFB1271FF53800ED7D9A /* OnboardingAuthStepNotify.swift in Sources */, 4278CB852D01F0B200CFAAC9 /* GesturesSetupViewModel.swift in Sources */, 42AF759B2DDB5AA900ACDF45 /* WebViewSettingsUpdateReason.swift in Sources */, - B65C0B522282BA13007E057B /* NotificationSettingsViewController.swift in Sources */, + B65C0B522282BA13007E057B /* NotificationSettingsView.swift in Sources */, 42C1012B2CD3DB8A0012BA78 /* CoverIntent.swift in Sources */, 429997BF2DDB59E400CC6C12 /* WebViewRestorationType.swift in Sources */, 11A183B32511BCF300CA326A /* LifecycleManager.swift in Sources */, diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index c5a010127..9872e979c 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -336,8 +336,11 @@ extension NotificationManager: UNUserNotificationCenterDelegate { _ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification? ) { - let view = NotificationSettingsViewController() - view.doneButton = true + let rootView = NavigationView { + NotificationSettingsView(showsDoneButton: true) + } + .navigationViewStyle(.stack) + let hostingController = rootView.embeddedInHostingController() Current.sceneManager.webViewWindowControllerPromise.done { var rootViewController = $0.window.rootViewController @@ -345,8 +348,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate { rootViewController = navigationController.viewControllers.first } rootViewController?.dismiss(animated: false, completion: { - let navController = UINavigationController(rootViewController: view) - rootViewController?.present(navController, animated: true, completion: nil) + rootViewController?.present(hostingController, animated: true, completion: nil) }) } } diff --git a/Sources/App/Settings/Notifications/NotificationDebugNotificationsView.swift b/Sources/App/Settings/Notifications/NotificationDebugNotificationsView.swift new file mode 100644 index 000000000..63cd67053 --- /dev/null +++ b/Sources/App/Settings/Notifications/NotificationDebugNotificationsView.swift @@ -0,0 +1,73 @@ +import Shared +import SwiftUI + +struct NotificationDebugNotificationsView: View { + private struct Toggle: Identifiable { + let id: String + let title: String + } + + private let toggles: [Toggle] = [ + .init(id: "enterNotifications", title: L10n.SettingsDetails.Location.Notifications.Enter.title), + .init(id: "exitNotifications", title: L10n.SettingsDetails.Location.Notifications.Exit.title), + .init(id: "beaconEnterNotifications", title: L10n.SettingsDetails.Location.Notifications.BeaconEnter.title), + .init(id: "beaconExitNotifications", title: L10n.SettingsDetails.Location.Notifications.BeaconExit.title), + .init( + id: "significantLocationChangeNotifications", + title: L10n.SettingsDetails.Location.Notifications.LocationChange.title + ), + .init( + id: "backgroundFetchLocationChangeNotifications", + title: L10n.SettingsDetails.Location.Notifications.BackgroundFetch.title + ), + .init( + id: "pushLocationRequestNotifications", + title: L10n.SettingsDetails.Location.Notifications.PushNotification.title + ), + .init( + id: "urlSchemeLocationRequestNotifications", + title: L10n.SettingsDetails.Location.Notifications.UrlScheme.title + ), + .init( + id: "xCallbackURLLocationRequestNotifications", + title: L10n.SettingsDetails.Location.Notifications.XCallbackUrl.title + ), + ] + + var body: some View { + List { + Section { + ForEach(toggles) { toggle in + PrefsToggleRow(key: toggle.id, title: toggle.title) + } + } + } + .navigationTitle(L10n.SettingsDetails.Location.Notifications.header) + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct PrefsToggleRow: View { + let key: String + let title: String + + @State private var value: Bool + + init(key: String, title: String) { + self.key = key + self.title = title + _value = State(initialValue: prefs.bool(forKey: key)) + } + + var body: some View { + SwiftUI.Toggle(isOn: Binding( + get: { value }, + set: { newValue in + value = newValue + prefs.set(newValue, forKey: key) + } + )) { + Text(title) + } + } +} diff --git a/Sources/App/Settings/Notifications/NotificationDebugNotificationsViewController.swift b/Sources/App/Settings/Notifications/NotificationDebugNotificationsViewController.swift deleted file mode 100644 index 9b7ab2ccd..000000000 --- a/Sources/App/Settings/Notifications/NotificationDebugNotificationsViewController.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Eureka -import RealmSwift -import Shared - -class NotificationDebugNotificationsViewController: HAFormViewController { - override func viewDidLoad() { - super.viewDidLoad() - - title = L10n.SettingsDetails.Location.Notifications.header - - form +++ Section() - - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.Enter.title - $0.value = prefs.bool(forKey: "enterNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "enterNotifications") - } - }) - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.Exit.title - $0.value = prefs.bool(forKey: "exitNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "exitNotifications") - } - }) - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.BeaconEnter.title - $0.value = prefs.bool(forKey: "beaconEnterNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "beaconEnterNotifications") - } - }) - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.BeaconExit.title - $0.value = prefs.bool(forKey: "beaconExitNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "beaconExitNotifications") - } - }) - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.LocationChange.title - $0.value = prefs.bool(forKey: "significantLocationChangeNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "significantLocationChangeNotifications") - } - }) - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.BackgroundFetch.title - $0.value = prefs.bool(forKey: "backgroundFetchLocationChangeNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "backgroundFetchLocationChangeNotifications") - } - }) - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.PushNotification.title - $0.value = prefs.bool(forKey: "pushLocationRequestNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "pushLocationRequestNotifications") - } - }) - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.UrlScheme.title - $0.value = prefs.bool(forKey: "urlSchemeLocationRequestNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "urlSchemeLocationRequestNotifications") - } - }) - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Location.Notifications.XCallbackUrl.title - $0.value = prefs.bool(forKey: "xCallbackURLLocationRequestNotifications") - }.onChange({ row in - if let val = row.value { - prefs.set(val, forKey: "xCallbackURLLocationRequestNotifications") - } - }) - } -} diff --git a/Sources/App/Settings/Notifications/NotificationRateLimitView.swift b/Sources/App/Settings/Notifications/NotificationRateLimitView.swift new file mode 100644 index 000000000..9893f42c8 --- /dev/null +++ b/Sources/App/Settings/Notifications/NotificationRateLimitView.swift @@ -0,0 +1,245 @@ +import PromiseKit +import Shared +import SwiftUI + +struct NotificationRateLimitView: View { + @StateObject private var viewModel: NotificationRateLimitViewModel + var onChange: (RateLimitResponse) -> Void + + init( + initialPromise: Promise? = nil, + onChange: @escaping (RateLimitResponse) -> Void = { _ in } + ) { + _viewModel = StateObject(wrappedValue: NotificationRateLimitViewModel(initialPromise: initialPromise)) + self.onChange = onChange + } + + var body: some View { + List { + content + } + .navigationTitle(L10n.SettingsDetails.Notifications.RateLimits.header) + .navigationBarTitleDisplayMode(.inline) + .modifier(ConditionalRefreshableModifier(enabled: !Current.isCatalyst) { + await viewModel.refresh() + }) + .toolbar { + // `if` directly inside `.toolbar` requires iOS 16+ ToolbarContentBuilder. + // Move the conditional inside the item so it works on iOS 15 too. + ToolbarItem(placement: .primaryAction) { + if Current.isCatalyst { + Button { + Task { await viewModel.refresh() } + } label: { + Image(systemSymbol: .arrowClockwise) + } + .disabled(viewModel.isRefreshing) + } + } + } + .onAppear { + viewModel.onChange = onChange + Task { await viewModel.refreshIfNeeded() } + viewModel.startTimer() + } + .onDisappear { + viewModel.stopTimer() + } + } + + @ViewBuilder + private var content: some View { + switch viewModel.state { + case .loading: + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + case let .loaded(response): + Section { + row( + title: L10n.SettingsDetails.Notifications.RateLimits.attempts, + value: format(response.rateLimits.attempts) + ) + row( + title: L10n.SettingsDetails.Notifications.RateLimits.delivered, + value: format(response.rateLimits.successful) + ) + row( + title: L10n.SettingsDetails.Notifications.RateLimits.errors, + value: format(response.rateLimits.errors) + ) + row( + title: L10n.SettingsDetails.Notifications.RateLimits.total, + value: format(response.rateLimits.total) + ) + row( + title: L10n.SettingsDetails.Notifications.RateLimits.resetsIn, + value: viewModel.resetsInText ?? resetsAtAbsolute(response.rateLimits.resetsAt) + ) + } footer: { + Text(L10n.SettingsDetails.Notifications.RateLimits.footerWithParam(response.rateLimits.maximum)) + } + case let .error(message): + Section { + Text(message) + .foregroundColor(.secondary) + Button(L10n.retryLabel) { + Task { await viewModel.refresh() } + } + } + } + } + + private func row(title: String, value: String) -> some View { + HStack { + Text(title) + Spacer() + Text(value) + .foregroundColor(.secondary) + } + } + + private func format(_ value: Int) -> String { + NumberFormatter.localizedString(from: NSNumber(value: value), number: .none) + } + + private func resetsAtAbsolute(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium) + } +} + +// MARK: - View Model + +@MainActor +final class NotificationRateLimitViewModel: ObservableObject { + enum State { + case loading + case loaded(RateLimitResponse) + case error(String) + } + + enum RateLimitError: Error { + case noPushId + } + + @Published private(set) var state: State = .loading + @Published private(set) var resetsInText: String? + @Published private(set) var isRefreshing = false + + var onChange: (RateLimitResponse) -> Void = { _ in } + + private var initialPromise: Promise? + private var timer: Timer? + private let utc = TimeZone(identifier: "UTC") ?? .current + + init(initialPromise: Promise?) { + self.initialPromise = initialPromise + } + + static func newPromise() -> Promise { + if let pushID = Current.settingsStore.pushID { + return NotificationRateLimitsAPI.rateLimits(pushID: pushID) + } else { + return .init(error: RateLimitError.noPushId) + } + } + + func refreshIfNeeded() async { + if case .loading = state { + await refresh() + } + } + + func refresh() async { + isRefreshing = true + defer { isRefreshing = false } + + do { + let response: RateLimitResponse + if let initialPromise { + self.initialPromise = nil + response = try await initialPromise.asyncValue + } else { + response = try await Self.newPromise().asyncValue + } + state = .loaded(response) + onChange(response) + updateResetsIn() + } catch { + Current.Log.error("couldn't load rate limit: \(error)") + state = .error(error.localizedDescription) + } + } + + func startTimer() { + stopTimer() + let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateResetsIn() + } + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func updateResetsIn() { + var calendar = Calendar.current + calendar.timeZone = utc + + guard let startOfNextDay = calendar.nextDate( + after: Date(), + matching: DateComponents(hour: 0, minute: 0), + matchingPolicy: .nextTimePreservingSmallerComponents + ) else { + resetsInText = nil + return + } + + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = [.hour, .minute, .second] + + resetsInText = formatter.string(from: Date(), to: startOfNextDay) + } +} + +// MARK: - Promise async helper + +private extension Promise { + var asyncValue: T { + get async throws { + try await withCheckedThrowingContinuation { continuation in + self.done { value in + continuation.resume(returning: value) + }.catch { error in + continuation.resume(throwing: error) + } + } + } + } +} + +// MARK: - Conditional refreshable modifier + +private struct ConditionalRefreshableModifier: ViewModifier { + let enabled: Bool + let action: () async -> Void + + @ViewBuilder + func body(content: Content) -> some View { + if enabled { + content.refreshable { await action() } + } else { + content + } + } +} diff --git a/Sources/App/Settings/Notifications/NotificationRateLimitViewController.swift b/Sources/App/Settings/Notifications/NotificationRateLimitViewController.swift deleted file mode 100644 index 209dbecc7..000000000 --- a/Sources/App/Settings/Notifications/NotificationRateLimitViewController.swift +++ /dev/null @@ -1,164 +0,0 @@ -import Eureka -import PromiseKit -import RealmSwift -import Shared - -class NotificationRateLimitListViewController: HAFormViewController { - let utc = TimeZone(identifier: "UTC")! - let refreshControl = UIRefreshControl() - - private var initialPromise: Promise? - - static func newPromise() -> Promise { - if let pushID = Current.settingsStore.pushID { - return NotificationRateLimitsAPI.rateLimits(pushID: pushID) - } else { - return .init(error: RateLimitError.noPushId) - } - } - - init(initialPromise: Promise?) { - self.initialPromise = initialPromise - super.init() - } - - var rateLimitDidChange: (RateLimitResponse) -> Void = { _ in } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - setupTimer() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - teardownTimer() - } - - override func viewDidLoad() { - super.viewDidLoad() - - title = L10n.SettingsDetails.Notifications.RateLimits.header - - if Current.isCatalyst { - navigationItem.rightBarButtonItems = [ - UIBarButtonItem( - barButtonSystemItem: .refresh, - target: self, - action: #selector(refresh) - ), - ] - } else { - tableView.refreshControl = refreshControl - refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) - } - - refresh() - - form +++ Section { - $0.tag = "rateLimits" - } - } - - private var timer: Timer? { - willSet { - timer?.invalidate() - } - } - - private func setupTimer() { - timer = Timer.scheduledTimer( - timeInterval: 1, - target: self, - selector: #selector(updateTimer), - userInfo: nil, - repeats: true - ) - } - - private func teardownTimer() { - timer = nil - } - - private enum RateLimitError: Error { - case noPushId - } - - @objc private func refresh() { - if initialPromise == nil { - refreshControl.beginRefreshing() - } - - firstly { () -> Promise in - if let initialPromise { - self.initialPromise = nil - return initialPromise - } else { - return Self.newPromise() - } - }.done { [form, rateLimitDidChange] response in - guard let section = form.sectionBy(tag: "rateLimits") else { - return - } - - Current.Log.debug("updated rate limits: \(response)") - - section.footer = HeaderFooterView( - title: L10n.SettingsDetails.Notifications.RateLimits.footerWithParam(response.rateLimits.maximum) - ) - - UIView.performWithoutAnimation { - section.removeAll() - - section - <<< response.rateLimits.row(for: \.attempts) - <<< response.rateLimits.row(for: \.successful) - <<< response.rateLimits.row(for: \.errors) - <<< response.rateLimits.row(for: \.total) - <<< response.rateLimits.row(for: \.resetsAt) - } - - rateLimitDidChange(response) - }.done { [weak self] _ in - self?.updateTimer() - }.ensure { [refreshControl] in - refreshControl.endRefreshing() - }.catch { [form] error in - Current.Log.error("couldn't load rate limit: \(error)") - guard let section = form.sectionBy(tag: "rateLimits") else { - return - } - - section.removeAll() - - section <<< ButtonRow { - $0.title = L10n.retryLabel - $0.onCellSelection { [weak self] _, _ in - self?.refresh() - } - } - } - } - - @objc func updateTimer() { - var calendar = Calendar.current - calendar.timeZone = utc - - guard let startOfNextDay = calendar.nextDate( - after: Date(), - matching: DateComponents(hour: 0, minute: 0), - matchingPolicy: .nextTimePreservingSmallerComponents - ) else { - return - } - guard let row = form.rowBy(tag: "resetsIn") as? LabelRow else { return } - - let formatter = DateComponentsFormatter() - formatter.zeroFormattingBehavior = .pad - formatter.allowedUnits = [.hour, .minute, .second] - - row.value = formatter.string(from: Date(), to: startOfNextDay) - row.updateCell() - } -} diff --git a/Sources/App/Settings/Notifications/NotificationRateLimitsAPI.swift b/Sources/App/Settings/Notifications/NotificationRateLimitsAPI.swift index df0770998..47996e8ac 100644 --- a/Sources/App/Settings/Notifications/NotificationRateLimitsAPI.swift +++ b/Sources/App/Settings/Notifications/NotificationRateLimitsAPI.swift @@ -1,4 +1,3 @@ -import Eureka import Foundation import PromiseKit import Shared @@ -49,48 +48,3 @@ class NotificationRateLimitsAPI { } } } - -extension RateLimitResponse.RateLimits { - func row(for keyPath: KeyPath) -> BaseRow { - LabelRow { - $0.value = NumberFormatter.localizedString( - from: NSNumber(value: self[keyPath: keyPath]), - number: .none - ) - $0.title = { () -> String in - switch keyPath { - case \.attempts: - return L10n.SettingsDetails.Notifications.RateLimits.attempts - case \.successful: - return L10n.SettingsDetails.Notifications.RateLimits.delivered - case \.errors: - return L10n.SettingsDetails.Notifications.RateLimits.errors - case \.total: - return L10n.SettingsDetails.Notifications.RateLimits.total - case \.maximum: - return "" - default: - fatalError("missing key: \(keyPath)") - } - }() - } - } - - func row(for keyPath: KeyPath) -> BaseRow { - LabelRow { row in - row.value = DateFormatter.localizedString( - from: self[keyPath: keyPath], - dateStyle: .none, - timeStyle: .medium - ) - - switch keyPath { - case \.resetsAt: - row.title = L10n.SettingsDetails.Notifications.RateLimits.resetsIn - row.tag = "resetsIn" - default: - fatalError("missing key: \(keyPath)") - } - } - } -} diff --git a/Sources/App/Settings/Notifications/NotificationSettingsView.swift b/Sources/App/Settings/Notifications/NotificationSettingsView.swift new file mode 100644 index 000000000..5507571bc --- /dev/null +++ b/Sources/App/Settings/Notifications/NotificationSettingsView.swift @@ -0,0 +1,321 @@ +import PromiseKit +import Shared +import SwiftUI +import UserNotifications + +struct NotificationSettingsView: View { + /// Set to `true` when presented from an in-notification "open settings" flow so we show a Done button. + var showsDoneButton: Bool = false + + @Environment(\.dismiss) private var dismiss + + @StateObject private var viewModel = NotificationSettingsViewModel() + + @State private var showShareSheet = false + @State private var shareItems: [Any] = [] + @State private var resetAlert: ResetAlertInfo? + @State private var ratePromise: Promise? + @State private var rateLimitRemaining: Int? + + private struct ResetAlertInfo: Identifiable { + let id = UUID() + let title: String + let message: String + } + + var body: some View { + List { + overviewSection + soundsSection + badgeSection + categoriesSection + debugSection + } + .navigationTitle(L10n.SettingsDetails.Notifications.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + // `if` directly inside `.toolbar` requires iOS 16+ ToolbarContentBuilder. + // Move the conditional inside the item so it works on iOS 15 too. + ToolbarItem(placement: .confirmationAction) { + if showsDoneButton { + Button(L10n.doneLabel) { + dismiss() + } + } + } + } + .onAppear { + viewModel.refreshPermissionStatus() + if ratePromise == nil { + let promise = NotificationRateLimitViewModel.newPromise() + promise.done { response in + rateLimitRemaining = response.rateLimits.remaining + }.cauterize() + ratePromise = promise + } + } + .onReceive( + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + ) { _ in + viewModel.refreshPermissionStatus() + viewModel.refreshBadgeCount() + } + .sheet(isPresented: $showShareSheet) { + NotificationsShareSheet(activityItems: shareItems) + } + .alert(item: $resetAlert) { info in + Alert( + title: Text(info.title), + message: Text(info.message), + dismissButton: .default(Text(L10n.okLabel)) + ) + } + } + + // MARK: - Sections + + private var overviewSection: some View { + Section { + Text(L10n.SettingsDetails.Notifications.info) + .foregroundColor(.primary) + + Button { + handlePermissionTap() + } label: { + HStack { + Text(L10n.SettingsDetails.Notifications.Permission.title) + .foregroundColor(.primary) + Spacer() + Text(viewModel.permissionText) + .foregroundColor(.secondary) + Image(systemSymbol: .chevronRight) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Link(destination: URL(string: "https://companion.home-assistant.io/app/ios/notifications")!) { + HStack { + Text(L10n.SettingsDetails.learnMore) + Spacer() + Image(systemSymbol: .arrowUpForwardSquare) + .font(.caption) + } + } + } + } + + private var soundsSection: some View { + Section { + NavigationLink { + NotificationSoundsView() + } label: { + Text(L10n.SettingsDetails.Notifications.Sounds.title) + } + } footer: { + Text(L10n.SettingsDetails.Notifications.Sounds.footer) + } + } + + private var badgeSection: some View { + Section { + Button { + UIApplication.shared.applicationIconBadgeNumber = 0 + viewModel.refreshBadgeCount() + } label: { + HStack { + Text(L10n.SettingsDetails.Notifications.BadgeSection.Button.title) + .foregroundColor(.primary) + Spacer() + Text(viewModel.badgeCountText) + .foregroundColor(.secondary) + } + } + + SwiftUI.Toggle( + L10n.SettingsDetails.Notifications.BadgeSection.AutomaticSetting.title, + isOn: $viewModel.clearBadgeAutomatically + ) + } footer: { + Text(L10n.SettingsDetails.Notifications.BadgeSection.AutomaticSetting.description) + } + } + + private var categoriesSection: some View { + Section { + NavigationLink { + categoriesDestination + } label: { + Text(L10n.SettingsDetails.Notifications.Categories.header) + } + Text(L10n.SettingsDetails.Notifications.Categories.deprecatedNote) + .font(.footnote) + .foregroundColor(.secondary) + } + } + + private var categoriesDestination: some View { + // Category list migration is handled by the notification-categories slice. + // Keep the existing Eureka controller until that migration lands. + embed(NotificationCategoryListViewController()) + .navigationTitle(L10n.SettingsDetails.Notifications.Categories.header) + } + + private var debugSection: some View { + Section { + NavigationLink { + NotificationRateLimitView(initialPromise: ratePromise) { response in + rateLimitRemaining = response.rateLimits.remaining + } + } label: { + HStack { + Text(L10n.SettingsDetails.Notifications.RateLimits.header) + Spacer() + if let remaining = rateLimitRemaining { + Text(NumberFormatter.localizedString(from: NSNumber(value: remaining), number: .decimal)) + .foregroundColor(.secondary) + } + } + } + + NavigationLink { + NotificationDebugNotificationsView() + } label: { + Text(L10n.SettingsDetails.Location.Notifications.header) + } + + Button { + guard let id = viewModel.pushID else { return } + shareItems = [id] + showShareSheet = true + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.SettingsDetails.Notifications.PushIdSection.header) + .foregroundColor(.primary) + Text(viewModel.pushIDDisplay) + .foregroundColor(.secondary) + .font(.footnote) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Button { + viewModel.resetPushID { result in + switch result { + case .success: + break + case let .failure(error): + resetAlert = ResetAlertInfo( + title: L10n.errorLabel, + message: error.localizedDescription + ) + } + } + } label: { + Text(L10n.Settings.ResetSection.ResetRow.title) + .foregroundColor(.red) + } + } header: { + Text(L10n.debugSectionLabel) + } + } + + // MARK: - Actions + + private func handlePermissionTap() { + let wasDetermined = viewModel.lastPermissionSeen != nil && viewModel.lastPermissionSeen != .notDetermined + UNUserNotificationCenter.current().requestAuthorization(options: .defaultOptions) { _, _ in + Task { @MainActor in + viewModel.refreshPermissionStatus() + if wasDetermined { + URLOpener.shared.openSettings(destination: .notification, completionHandler: nil) + } + } + } + } +} + +// MARK: - Share Sheet + +struct NotificationsShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} + +// MARK: - View Model + +@MainActor +final class NotificationSettingsViewModel: ObservableObject { + @Published var permissionText: String = "" + @Published var lastPermissionSeen: UNAuthorizationStatus? + @Published var badgeCountText: String = "" + // `Self` can't be referenced from a stored-property initializer in a class; use the + // type name explicitly. + @Published var pushIDDisplay: String = NotificationSettingsViewModel + .displayForPushID(Current.settingsStore.pushID) + @Published var clearBadgeAutomatically: Bool = Current.settingsStore.clearBadgeAutomatically { + didSet { + Current.settingsStore.clearBadgeAutomatically = clearBadgeAutomatically + } + } + + var pushID: String? { Current.settingsStore.pushID } + + private static func displayForPushID(_ id: String?) -> String { + id ?? L10n.SettingsDetails.Notifications.PushIdSection.notRegistered + } + + init() { + refreshBadgeCount() + } + + func refreshBadgeCount() { + let value = UIApplication.shared.applicationIconBadgeNumber + badgeCountText = NumberFormatter.localizedString(from: NSNumber(value: value), number: .decimal) + } + + func refreshPermissionStatus() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + self.lastPermissionSeen = settings.authorizationStatus + self.permissionText = Self.permissionText(for: settings.authorizationStatus) + } + } + } + + private static func permissionText(for status: UNAuthorizationStatus) -> String { + switch status { + case .ephemeral, .authorized, .provisional: + return L10n.SettingsDetails.Notifications.Permission.enabled + case .denied: + return L10n.SettingsDetails.Notifications.Permission.disabled + case .notDetermined: + return L10n.SettingsDetails.Notifications.Permission.needsRequest + @unknown default: + return L10n.SettingsDetails.Notifications.Permission.disabled + } + } + + // PromiseKit also exports a single-parameter `Result`, so qualify with `Swift.Result`. + func resetPushID(completion: @escaping (Swift.Result) -> Void) { + Current.Log.verbose("Resetting push token!") + firstly { + Current.notificationManager.resetPushID() + }.done { [weak self] newToken in + self?.pushIDDisplay = Self.displayForPushID(newToken) + }.then { _ in + when(fulfilled: Current.apis.map { $0.updateRegistration() }) + }.done { _ in + completion(.success(())) + }.catch { error in + Current.Log.error("Error resetting push token: \(error)") + completion(.failure(error)) + } + } +} diff --git a/Sources/App/Settings/Notifications/NotificationSettingsViewController.swift b/Sources/App/Settings/Notifications/NotificationSettingsViewController.swift deleted file mode 100644 index 57076d9df..000000000 --- a/Sources/App/Settings/Notifications/NotificationSettingsViewController.swift +++ /dev/null @@ -1,311 +0,0 @@ -import Eureka -import FirebaseInstallations -import FirebaseMessaging -import PromiseKit -import RealmSwift -import Shared -import UIKit - -class NotificationSettingsViewController: HAFormViewController { - public var doneButton: Bool = false - - private var observerTokens: [Any] = [] - - deinit { - for token in observerTokens { - NotificationCenter.default.removeObserver(token) - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - if doneButton { - navigationItem.rightBarButtonItem = nil - doneButton = false - } - } - - override func viewDidLoad() { - super.viewDidLoad() - - if doneButton { - let doneButton = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(closeSettingsDetailView(_:)) - ) - navigationItem.setRightBarButton(doneButton, animated: true) - } - - title = L10n.SettingsDetails.Notifications.title - - form - +++ Section() - <<< InfoLabelRow { - $0.title = L10n.SettingsDetails.Notifications.info - $0.displayType = .primary - } - - <<< notificationPermissionRow() - - <<< LearnMoreButtonRow { - $0.value = URL(string: "https://companion.home-assistant.io/app/ios/notifications")! - } - - +++ Section( - footer: L10n.SettingsDetails.Notifications.Sounds.footer - ) - - <<< ButtonRow { - $0.title = L10n.SettingsDetails.Notifications.Sounds.title - $0.presentationMode = .show(controllerProvider: ControllerProvider.callback { - NotificationSoundsViewController() - }, onDismiss: nil) - } - - +++ Section(footer: L10n.SettingsDetails.Notifications.BadgeSection.AutomaticSetting.description) - - <<< ButtonRow { - $0.title = L10n.SettingsDetails.Notifications.BadgeSection.Button.title - $0.cellStyle = .value1 - - var lastValue: Int? - let update = { [weak row = $0] in - guard let row else { return } - - let value = UIApplication.shared.applicationIconBadgeNumber - guard value != lastValue else { return } - - row.value = NumberFormatter.localizedString( - from: NSNumber(value: value), - number: .decimal - ) - row.updateCell() - - lastValue = value - } - - // timer because kvo only works on manually changing it, and this is easiest/cheap - let timer = Timer.scheduledTimer( - withTimeInterval: 1.0, - repeats: true, - block: { _ in update() } - ) - // kvo so internally setting it updates instantly - let token = UIApplication.shared.observe( - \.applicationIconBadgeNumber, - changeHandler: { _, _ in update() } - ) - - update() - - after(life: self).done { - token.invalidate() - timer.invalidate() - } - - $0.cellUpdate { cell, row in - cell.textLabel?.textAlignment = .natural - cell.detailTextLabel?.text = row.value - } - $0.onCellSelection { _, _ in - UIApplication.shared.applicationIconBadgeNumber = 0 - } - } - - <<< SwitchRow { - $0.title = L10n.SettingsDetails.Notifications.BadgeSection.AutomaticSetting.title - $0.value = Current.settingsStore.clearBadgeAutomatically - $0.onChange { row in - Current.settingsStore.clearBadgeAutomatically = row.value ?? true - } - } - - +++ Section() - - <<< ButtonRow { - $0.title = L10n.SettingsDetails.Notifications.Categories.header - $0.presentationMode = .show(controllerProvider: ControllerProvider.callback { - NotificationCategoryListViewController() - }, onDismiss: { vc in - _ = vc.navigationController?.popViewController(animated: true) - }) - } - - <<< InfoLabelRow { - $0.title = L10n.SettingsDetails.Notifications.Categories.deprecatedNote - } - - +++ Section( - header: L10n.debugSectionLabel, - footer: nil - ) - - <<< ButtonRowOf { row in - let value = NotificationRateLimitListViewController.newPromise() - - row.cellStyle = .value1 - - func update(for response: RateLimitResponse) { - row.value = response.rateLimits.remaining - row.updateCell() - } - - value.done { response in - update(for: response) - }.cauterize() - - row.title = L10n.SettingsDetails.Notifications.RateLimits.header - row.presentationMode = .show(controllerProvider: ControllerProvider.callback { - let controller = NotificationRateLimitListViewController(initialPromise: value) - controller.rateLimitDidChange = { rateLimit in - update(for: rateLimit) - } - return controller - }, onDismiss: nil) - - row.displayValueFor = { value in - value.map { - NumberFormatter.localizedString( - from: NSNumber(value: $0), - number: .decimal - ) - } - } - } - - <<< ButtonRow { - $0.title = L10n.SettingsDetails.Location.Notifications.header - $0.presentationMode = .show(controllerProvider: ControllerProvider.callback { - NotificationDebugNotificationsViewController() - }, onDismiss: nil) - } - - <<< LabelRow("pushID") { - $0.title = L10n.SettingsDetails.Notifications.PushIdSection.header - - if let pushID = Current.settingsStore.pushID { - $0.value = pushID - } else { - $0.value = L10n.SettingsDetails.Notifications.PushIdSection.notRegistered - } - - $0.cellSetup { cell, _ in - cell.detailTextLabel?.lineBreakMode = .byTruncatingMiddle - } - - $0.cellUpdate { cell, _ in - cell.selectionStyle = .default - } - - $0.onCellSelection { [weak self] cell, row in - guard let id = Current.settingsStore.pushID else { return } - - let vc = UIActivityViewController(activityItems: [id], applicationActivities: nil) - with(vc.popoverPresentationController) { - $0?.sourceView = cell - $0?.sourceRect = cell.bounds - } - self?.present(vc, animated: true, completion: nil) - row.deselect(animated: true) - } - } - - <<< ButtonRow { - $0.tag = "resetPushID" - $0.title = L10n.Settings.ResetSection.ResetRow.title - }.cellUpdate { cell, _ in - cell.textLabel?.textColor = .red - }.onCellSelection { [weak self] cell, _ in - Current.Log.verbose("Resetting push token!") - - firstly { - Current.notificationManager.resetPushID() - }.done { token in - guard let idRow = self?.form.rowBy(tag: "pushID") as? LabelRow else { return } - idRow.value = token - idRow.updateCell() - }.then { _ in - when(fulfilled: Current.apis.map { $0.updateRegistration() }) - }.catch { error in - Current.Log.error("Error resetting push token: \(error)") - let alert = UIAlertController( - title: L10n.errorLabel, - message: error.localizedDescription, - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: L10n.okLabel, style: .default, handler: nil)) - - self?.present(alert, animated: true, completion: nil) - alert.popoverPresentationController?.sourceView = cell.formViewController()?.view - } - } - } - - @objc func closeSettingsDetailView(_ sender: UIButton) { - dismiss(animated: true, completion: nil) - } - - private func notificationPermissionRow() -> BaseRow { - var lastPermissionSeen: UNAuthorizationStatus? - - func update(_ row: LabelRow) { - UNUserNotificationCenter.current().getNotificationSettings { settings in - DispatchQueue.main.async { - lastPermissionSeen = settings.authorizationStatus - - row.value = { - switch settings.authorizationStatus { - case .ephemeral: - return L10n.SettingsDetails.Notifications.Permission.enabled - case .authorized, .provisional: - return L10n.SettingsDetails.Notifications.Permission.enabled - case .denied: - return L10n.SettingsDetails.Notifications.Permission.disabled - case .notDetermined: - return L10n.SettingsDetails.Notifications.Permission.needsRequest - @unknown default: - return L10n.SettingsDetails.Notifications.Permission.disabled - } - }() - row.updateCell() - } - } - } - - return LabelRow { row in - row.title = L10n.SettingsDetails.Notifications.Permission.title - update(row) - - observerTokens.append(NotificationCenter.default.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { _ in - // in case the user jumps to settings and changes while we're open, update the value - update(row) - }) - - row.cellUpdate { cell, _ in - cell.accessoryType = .disclosureIndicator - cell.selectionStyle = .default - } - - row.onCellSelection { _, row in - UNUserNotificationCenter.current().requestAuthorization(options: .defaultOptions) { _, _ in - DispatchQueue.main.async { - update(row) - row.deselect(animated: true) - - if lastPermissionSeen != .notDetermined { - // if we weren't prompting for permission with this request, open settings - // we can't avoid the request code-path since getting settings is async - URLOpener.shared.openSettings(destination: .notification, completionHandler: nil) - } - } - } - } - } - } -} diff --git a/Sources/App/Settings/Notifications/NotificationSoundsView.swift b/Sources/App/Settings/Notifications/NotificationSoundsView.swift new file mode 100644 index 000000000..3eb601546 --- /dev/null +++ b/Sources/App/Settings/Notifications/NotificationSoundsView.swift @@ -0,0 +1,579 @@ +import AVFoundation +import PromiseKit +import Shared +import SwiftUI +import UniformTypeIdentifiers + +struct NotificationSoundsView: View { + enum SoundCategory: Int, CaseIterable, Identifiable { + case imported + case bundled + case system + + var id: Int { rawValue } + + var title: String { + switch self { + case .imported: return L10n.SettingsDetails.Notifications.Sounds.imported + case .bundled: return L10n.SettingsDetails.Notifications.Sounds.bundled + case .system: return L10n.SettingsDetails.Notifications.Sounds.system + } + } + } + + @StateObject private var viewModel = NotificationSoundsViewModel() + @EnvironmentObject private var viewControllerProvider: ViewControllerProvider + + @State private var selected: SoundCategory = .imported + @State private var showImporter = false + @State private var alert: AlertInfo? + + private struct AlertInfo: Identifiable { + let id = UUID() + let title: String + let message: String + } + + private var categories: [SoundCategory] { + if Current.isCatalyst { + return [.imported, .bundled] + } + return SoundCategory.allCases + } + + var body: some View { + List { + Section { + Picker("", selection: $selected) { + ForEach(categories) { category in + Text(category.title).tag(category) + } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + + switch selected { + case .imported: + importedSection + case .bundled: + bundledSection + case .system: + systemSection + } + } + .navigationTitle(L10n.SettingsDetails.Notifications.Sounds.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + if let url = URL(string: "https://companion.home-assistant.io/app/ios/notifications-sounds") { + openURLInBrowser(url, viewControllerProvider.viewController) + } + } label: { + Image(systemSymbol: .questionmarkCircle) + } + } + } + .overlay { + if viewModel.isBusy { + ProgressOverlay() + } + } + .fileImporter( + isPresented: $showImporter, + allowedContentTypes: [.audio, .data], + allowsMultipleSelection: true + ) { result in + handleImport(result: result) + } + .alert(item: $alert) { info in + Alert( + title: Text(info.title), + message: Text(info.message), + dismissButton: .default(Text(L10n.okLabel)) + ) + } + .onAppear { + viewModel.loadSounds() + } + .onDisappear { + viewModel.stopPlayback() + } + } + + // MARK: - Sections + + private var importedSection: some View { + Section { + ForEach(viewModel.imported, id: \.self) { url in + soundRow(url: url) + } + .onDelete { indexSet in + // Iterate descending so each removal doesn't shift the indices we still + // need to read out of the source array. + for index in indexSet.sorted(by: >) { + guard index < viewModel.imported.count else { continue } + let url = viewModel.imported[index] + do { + try viewModel.deleteSound(url) + } catch { + presentError(error) + } + } + } + + if Current.isCatalyst { + Text(L10n.SettingsDetails.Notifications.Sounds.importMacInstructions) + .foregroundColor(.secondary) + Button(L10n.SettingsDetails.Notifications.Sounds.importMacOpenFolder) { + viewModel.openLibrarySoundsFolder() + } + } else { + Button(L10n.SettingsDetails.Notifications.Sounds.importCustom) { + showImporter = true + } + Button(L10n.SettingsDetails.Notifications.Sounds.importFileSharing) { + Task { + do { + let count = try await viewModel.importFromFileSharing() + presentImported(count: count) + } catch { + presentError(error) + } + } + } + } + } footer: { + Text(L10n.SettingsDetails.Notifications.Sounds.footer) + } + } + + private var bundledSection: some View { + Section { + ForEach(viewModel.bundled, id: \.self) { url in + soundRow(url: url) + } + } + } + + private var systemSection: some View { + Section { + ForEach(viewModel.system, id: \.self) { url in + soundRow(url: url) + } + .onDelete { indexSet in + // Iterate descending so each removal doesn't shift the indices we still + // need to read out of the source array. + for index in indexSet.sorted(by: >) { + guard index < viewModel.system.count else { continue } + let url = viewModel.system[index] + do { + try viewModel.deleteSound(url) + } catch { + presentError(error) + } + } + } + + Button(L10n.SettingsDetails.Notifications.Sounds.importSystem) { + Task { + do { + let count = try await viewModel.importSystemSounds() + presentImported(count: count) + } catch { + presentError(error) + } + } + } + } + } + + private func soundRow(url: URL) -> some View { + Button { + viewModel.play(url: url) { error in + presentError(error) + } + } label: { + HStack { + Text(url.lastPathComponent) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + UIPasteboard.general.string = url.lastPathComponent + } label: { + Text(L10n.copyLabel) + } + .buttonStyle(.borderless) + } + } + } + + // MARK: - Helpers + + // PromiseKit also exports a single-parameter `Result`, so qualify with `Swift.Result`. + private func handleImport(result: Swift.Result<[URL], Error>) { + switch result { + case let .success(urls): + Task { await viewModel.importPickedFiles(urls) { error in + await MainActor.run { presentError(error) } + } } + case let .failure(error): + presentError(error) + } + } + + private func presentError(_ error: Error) { + alert = AlertInfo(title: L10n.errorLabel, message: error.localizedDescription) + } + + private func presentImported(count: Int) { + alert = AlertInfo( + title: L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.title, + message: L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.message(count) + ) + } +} + +// MARK: - Progress Overlay + +private struct ProgressOverlay: View { + var body: some View { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView() + .progressViewStyle(.circular) + .padding(24) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } +} + +// MARK: - View Model + +@MainActor +final class NotificationSoundsViewModel: ObservableObject { + @Published var imported: [URL] = [] + @Published var bundled: [URL] = [] + @Published var system: [URL] = [] + @Published var isBusy = false + + private var audioPlayer: AVAudioPlayer? + + func loadSounds() { + imported = (try? importedFilesWithSuffix(".wav")) ?? [] + if Current.isCatalyst { + imported = [] + } + imported.sort(by: { $0.lastPathComponent < $1.lastPathComponent }) + + bundled = (Bundle.main.urls(forResourcesWithExtension: "wav", subdirectory: nil) ?? []) + .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + + system = ((try? importedFilesWithSuffix(".caf")) ?? []) + .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + } + + func play(url: URL, onError: (Error) -> Void) { + do { + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.play() + } catch { + Current.Log.error("Error when playing sound \(url.lastPathComponent): \(error)") + onError(error) + } + } + + func stopPlayback() { + audioPlayer?.stop() + audioPlayer = nil + } + + func deleteSound(_ url: URL) throws { + Current.Log.verbose("Deleting sound at \(url)") + do { + try FileManager.default.removeItem(at: url) + } catch { + throw SoundError(soundURL: nil, kind: .deleteError, underlying: error) + } + imported.removeAll { $0 == url } + system.removeAll { $0 == url } + } + + func openLibrarySoundsFolder() { + do { + let url = try librarySoundsURL() + URLOpener.shared.open(url, options: [:], completionHandler: nil) + } catch { + Current.Log.error("couldn't open folder: \(error)") + } + } + + func importFromFileSharing() async throws -> Int { + isBusy = true + defer { isBusy = false } + + let sharingURL = try fileSharingPath() + let sounds = soundsInDirectory(sharingURL) ?? [] + let copied = try await copySounds(sounds, category: .imported) + return copied.count + } + + func importSystemSounds() async throws -> Int { + isBusy = true + defer { isBusy = false } + + let soundsPath = URL(fileURLWithPath: "/System/Library/Audio/UISounds", isDirectory: true) + let systemSounds = await Task.detached(priority: .userInitiated) { () -> [URL] in + Self.enumerateSounds(path: soundsPath) ?? [] + }.value + let copied = try await copySounds(systemSounds, category: .system) + return copied.count + } + + // Pure file-system work — opt out of the surrounding `@MainActor` isolation so + // it can be called from `Task.detached` for off-main enumeration. + private nonisolated static func enumerateSounds(path: URL) -> [URL]? { + guard let enu = FileManager.default.enumerator(at: path, includingPropertiesForKeys: [.isDirectoryKey]) else { + Current.Log.error("Unable to get enumerator!") + return nil + } + + var foundURLs: [URL] = [] + + while let fileURL = enu.nextObject() as? URL { + if FileManager.default.isDirectory(fileURL) == false, ensureDurationStatic(fileURL) { + foundURLs.append(fileURL) + } + } + + return foundURLs + } + + private nonisolated static func ensureDurationStatic(_ soundURL: URL) -> Bool { + let duration = Double(CMTimeGetSeconds(AVURLAsset(url: soundURL).duration)) + return duration > 0.0 && duration <= 30.0 + } + + func importPickedFiles(_ urls: [URL], onError: @escaping (Error) async -> Void) async { + isBusy = true + defer { isBusy = false } + + let destinationURL: URL + do { + destinationURL = try librarySoundsURL() + } catch { + await onError(error) + return + } + + for pickedURL in urls { + var options = AKConverter.Options() + options.format = "wav" + options.sampleRate = 48000 + options.bitDepth = 32 + options.eraseFile = true + + let fileName = pickedURL.deletingPathExtension().lastPathComponent + let newSoundPath = destinationURL.appendingPathComponent("\(fileName).wav") + + let didStart = pickedURL.startAccessingSecurityScopedResource() + defer { + if didStart { pickedURL.stopAccessingSecurityScopedResource() } + } + + await withCheckedContinuation { continuation in + AKConverter(inputURL: pickedURL, outputURL: newSoundPath, options: options).start { error in + Task { @MainActor in + if let error { + let sError = SoundError( + soundURL: newSoundPath, + kind: .conversionFailed, + underlying: error + ) + Current.Log.error("Experienced error during convert \(sError) (\(error))") + await onError(sError) + } else { + if !self.imported.contains(newSoundPath) { + self.imported.append(newSoundPath) + self.imported.sort(by: { $0.lastPathComponent < $1.lastPathComponent }) + } + } + continuation.resume() + } + } + } + } + } + + // MARK: - File helpers + + func librarySoundsURL() throws -> URL { + do { + let librarySoundsPath = try FileManager.default.url( + for: .libraryDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ).appendingPathComponent("Sounds") + + if !Current.isCatalyst { + Current.Log.verbose("Creating sounds directory at \(librarySoundsPath)") + try FileManager.default.createDirectory( + at: librarySoundsPath, + withIntermediateDirectories: true, + attributes: nil + ) + } + + return librarySoundsPath + } catch { + throw SoundError(soundURL: nil, kind: .cantBuildLibrarySoundsPath, underlying: error) + } + } + + private func importedFilesWithSuffix(_ suffix: String) throws -> [URL] { + do { + let files = try FileManager.default.contentsOfDirectory( + at: librarySoundsURL(), + includingPropertiesForKeys: nil + ) + return files.filter { $0.lastPathComponent.hasSuffix(suffix) } + } catch { + throw SoundError(soundURL: nil, kind: .cantGetDirectoryContents, underlying: error) + } + } + + private func fileSharingPath() throws -> URL { + do { + return try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) + } catch { + throw SoundError(soundURL: nil, kind: .cantGetFileSharingPath, underlying: error) + } + } + + private func soundsInDirectory(_ path: URL) -> [URL]? { + guard let enu = FileManager.default.enumerator(at: path, includingPropertiesForKeys: [.isDirectoryKey]) else { + Current.Log.error("Unable to get enumerator!") + return nil + } + + var foundURLs: [URL] = [] + + while let fileURL = enu.nextObject() as? URL { + if FileManager.default.isDirectory(fileURL) == false, ensureDuration(fileURL) { + foundURLs.append(fileURL) + } + } + + return foundURLs + } + + private func ensureDuration(_ soundURL: URL) -> Bool { + let duration = Double(CMTimeGetSeconds(AVURLAsset(url: soundURL).duration)) + return duration > 0.0 && duration <= 30.0 + } + + private func copySounds(_ soundURLs: [URL], category: NotificationSoundsView.SoundCategory) async throws -> [URL] { + guard !soundURLs.isEmpty else { return [] } + + let destination = try librarySoundsURL() + var copied: [URL] = [] + + for soundURL in soundURLs { + let soundName = soundURL.lastPathComponent + let newURL = destination.appendingPathComponent(soundName) + + Current.Log.verbose("Copying sound \(soundName) from \(soundURL) to \(newURL)") + + if FileManager.default.fileExists(atPath: newURL.path) { + Current.Log.verbose("Sound \(soundName) already exists in ~/Library/Sounds, removing") + do { + try FileManager.default.removeItem(at: newURL) + } catch { + throw SoundError(soundURL: nil, kind: .deleteError, underlying: error) + } + } + + do { + try FileManager.default.copyItem(at: soundURL, to: newURL) + } catch { + throw SoundError(soundURL: nil, kind: .copyError, underlying: error) + } + + copied.append(newURL) + + switch category { + case .imported: + if !imported.contains(newURL) { + imported.append(newURL) + } + case .system: + if !system.contains(newURL) { + system.append(newURL) + } + case .bundled: + break + } + } + + imported.sort(by: { $0.lastPathComponent < $1.lastPathComponent }) + system.sort(by: { $0.lastPathComponent < $1.lastPathComponent }) + + return copied + } +} + +// MARK: - File Manager helper + +private extension FileManager { + func isDirectory(_ url: URL) -> Bool? { + var isDir = ObjCBool(false) + if fileExists(atPath: url.path, isDirectory: &isDir) { + return isDir.boolValue + } + return nil + } +} + +// MARK: - Error type + +private struct SoundError: LocalizedError { + enum ErrorKind { + case cantBuildLibrarySoundsPath + case cantGetFileSharingPath + case cantGetDirectoryContents + case conversionFailed + case copyError + case deleteError + } + + let soundURL: URL? + let kind: ErrorKind + let underlying: Error + + var errorDescription: String? { + let description = underlying.localizedDescription + switch kind { + case .cantBuildLibrarySoundsPath: + return L10n.SettingsDetails.Notifications.Sounds.Error.cantBuildLibrarySoundsPath(description) + case .cantGetFileSharingPath: + return L10n.SettingsDetails.Notifications.Sounds.Error.cantGetFileSharingPath(description) + case .cantGetDirectoryContents: + return L10n.SettingsDetails.Notifications.Sounds.Error.cantGetDirectoryContents(description) + case .conversionFailed: + return L10n.SettingsDetails.Notifications.Sounds.Error.conversionFailed(description) + case .copyError: + return L10n.SettingsDetails.Notifications.Sounds.Error.copyError(description) + case .deleteError: + return L10n.SettingsDetails.Notifications.Sounds.Error.deleteError(description) + } + } +} diff --git a/Sources/App/Settings/Notifications/NotificationSoundsViewController.swift b/Sources/App/Settings/Notifications/NotificationSoundsViewController.swift deleted file mode 100644 index a4c1c03e8..000000000 --- a/Sources/App/Settings/Notifications/NotificationSoundsViewController.swift +++ /dev/null @@ -1,512 +0,0 @@ -import AVFoundation -import Eureka -import MBProgressHUD -import MobileCoreServices -import PromiseKit -import Shared -import UIKit - -private var buttonAssociatedString: String = "" - -class NotificationSoundsViewController: HAFormViewController, UIDocumentPickerDelegate { - public var onDismissCallback: ((UIViewController) -> Void)? - - var audioPlayer: AVAudioPlayer? - - override func viewDidLoad() { - super.viewDidLoad() - - title = L10n.SettingsDetails.Notifications.Sounds.title - - navigationItem.rightBarButtonItems = [ - with(AppConstants.helpBarButtonItem) { - $0.action = #selector(help) - $0.target = self - }, - ] - - var importedFileSharingSounds: [URL] = [] - - do { - if Current.isCatalyst { - importedFileSharingSounds = [] - } else { - importedFileSharingSounds = try importedFilesWithSuffix(".wav") - } - } catch { - Current.Log.error("Error while getting imported file sharing sounds \(error)") - } - - var importedSystemSounds: [URL] = [] - - do { - importedSystemSounds = try importedFilesWithSuffix(".caf") - } catch { - Current.Log.error("Error while getting imported system sounds \(error)") - } - - form +++ SegmentedRow("soundListChooser") { - var options = [String]() - - options.append(L10n.SettingsDetails.Notifications.Sounds.imported) - options.append(L10n.SettingsDetails.Notifications.Sounds.bundled) - - if !Current.isCatalyst { - options.append(L10n.SettingsDetails.Notifications.Sounds.system) - } - - $0.options = options - $0.value = L10n.SettingsDetails.Notifications.Sounds.imported - } - - form.append(getSoundsSection( - "imported", - L10n.SettingsDetails.Notifications.Sounds.imported, - fileURLs: importedFileSharingSounds - )) - - if let urls = Bundle.main.urls(forResourcesWithExtension: "wav", subdirectory: nil) { - form.append(getSoundsSection( - "bundled", - L10n.SettingsDetails.Notifications.Sounds.bundled, - fileURLs: urls - )) - } - - form.append(getSoundsSection( - "system", - L10n.SettingsDetails.Notifications.Sounds.system, - fileURLs: importedSystemSounds - )) - } - - func getSoundsSection(_ tag: String, _ header: String, fileURLs: [URL]) -> Section { - let sortedURLs = fileURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) - let section = Section() - section.tag = tag - section.hidden = Condition.predicate(NSPredicate(format: "$soundListChooser != %@", header)) - - let isImportedSection = header == L10n.SettingsDetails.Notifications.Sounds.imported - - for sound in sortedURLs { - section.append(getSoundRow(sound, isImportedSection)) - } - - if isImportedSection, Current.isCatalyst { - section <<< InfoLabelRow { - $0.title = L10n.SettingsDetails.Notifications.Sounds.importMacInstructions - } - <<< ButtonRow { - $0.title = L10n.SettingsDetails.Notifications.Sounds.importMacOpenFolder - $0.onCellSelection { [weak self] _, _ in - guard let self else { return } - do { - try URLOpener.shared.open(librarySoundsURL(), options: [:], completionHandler: nil) - } catch { - Current.Log.error("couldn't open folder: \(error)") - } - } - } - } else if isImportedSection { - section - <<< ButtonRow { - $0.tag = "import_custom_sound" - $0.title = L10n.SettingsDetails.Notifications.Sounds.importCustom - }.onCellSelection { _, _ in - self.importTapped() - } - <<< ButtonRow { - $0.title = L10n.SettingsDetails.Notifications.Sounds.importFileSharing - }.onCellSelection { cell, _ in - MBProgressHUD.showAdded(to: self.view, animated: true) - - firstly { - self.fileSharingPath() - }.then { path -> Promise<[URL]> in - let sounds: [URL] = self.soundsInDirectory(path) ?? [] - return self.copySounds(sounds, "imported") - }.done { copied in - let title = L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.title - let message = L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.message(copied.count) - self.showAlert(message, title, popoverView: cell.contentView) - }.catch { error in - self.showAlert(error.localizedDescription, nil, popoverView: cell.contentView) - }.finally { - MBProgressHUD.hide(for: self.view, animated: true) - } - } - } - - if header == L10n.SettingsDetails.Notifications.Sounds.system { - section - <<< ButtonRow { - $0.title = L10n.SettingsDetails.Notifications.Sounds.importSystem - }.onCellSelection { cell, _ in - MBProgressHUD.showAdded(to: self.view, animated: true) - - DispatchQueue.global(qos: .userInitiated).async { - let soundsPath = URL(fileURLWithPath: "/System/Library/Audio/UISounds", isDirectory: true) - let systemSounds: [URL] = self.soundsInDirectory(soundsPath) ?? [] - self.copySounds(systemSounds, "system").done { copied in - let title = L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.title - let message = L10n.SettingsDetails.Notifications.Sounds.ImportedAlert.message(copied.count) - self.showAlert(message, title, popoverView: cell.contentView) - }.catch { error in - self.showAlert(error.localizedDescription, nil, popoverView: cell.contentView) - }.finally { - MBProgressHUD.hide(for: self.view, animated: true) - } - } - } - } - - return section - } - - @objc private func copyButtonTapped(_ button: UIButton) { - guard let string = objc_getAssociatedObject(button, &buttonAssociatedString) as? String else { - Current.Log.info("failed to copy from button \(button)") - return - } - - UIPasteboard.general.string = string - } - - func getSoundRow(_ fileURL: URL, _ enableDelete: Bool = false) -> ButtonRowOf { - let copyButton = with(UIButton(type: .system)) { - $0.setTitle(L10n.copyLabel, for: .normal) - $0.sizeToFit() - $0.addTarget(self, action: #selector(copyButtonTapped(_:)), for: .touchUpInside) - objc_setAssociatedObject($0, &buttonAssociatedString, fileURL.lastPathComponent, .OBJC_ASSOCIATION_COPY) - } - - return ButtonRowOf { - $0.value = fileURL - $0.tag = fileURL.lastPathComponent - $0.title = fileURL.lastPathComponent - if enableDelete { - $0.trailingSwipe.actions = [SwipeAction( - style: .destructive, - title: L10n.delete, - handler: self.handleSwipeDelete - )] - $0.trailingSwipe.performsFirstActionWithFullSwipe = true - } - }.cellSetup { cell, _ in - cell.accessoryView = copyButton - }.cellUpdate { cell, _ in - cell.textLabel?.numberOfLines = 0 - cell.textLabel?.textAlignment = .natural - cell.textLabel?.textColor = nil - }.onCellSelection { cell, _ in - do { - self.audioPlayer = try AVAudioPlayer(contentsOf: fileURL) - self.audioPlayer?.play() - } catch { - Current.Log.error("Error when playing sound \(fileURL.lastPathComponent): \(error)") - self.showAlert(error.localizedDescription, nil, popoverView: cell.contentView) - } - } - } - - func handleSwipeDelete(action: SwipeAction, row: BaseRow, completionHandler: ((Bool) -> Void)?) { - guard let urlRow = row as? ButtonRowOf, let url = urlRow.value else { completionHandler?(false); return } - - do { - try deleteSound(url) - } catch { - Current.Log.error("Error when deleting sound \(url): \(error)") - showAlert(error.localizedDescription, nil, popoverView: row.baseCell.contentView) - completionHandler?(false) - return - } - - if let section = row.section, let indexPath = row.indexPath { - section.remove(at: indexPath.row) - } - - completionHandler?(true) - } - - @objc func importTapped() { - let picker = UIDocumentPickerViewController(documentTypes: [ - String(kUTTypeAudio), - String(kUTTypeData), - ], in: .import) - picker.delegate = self - picker.modalPresentationStyle = .fullScreen - picker.allowsMultipleSelection = true - present(picker, animated: true, completion: nil) - } - - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - Current.Log.verbose("Did pick sounds at \(urls)") - for pickedURL in urls { - Current.Log.verbose("Processing picked sound at \(pickedURL)") - var options = AKConverter.Options() - options.format = "wav" - options.sampleRate = 48000 - options.bitDepth = 32 - options.eraseFile = true - - let librarySoundsURL: URL - do { - librarySoundsURL = try self.librarySoundsURL() - } catch { - Current.Log.error("Error when getting library sounds URL \(error)") - showAlert(error.localizedDescription) - return - } - - let fileName = pickedURL.deletingPathExtension().lastPathComponent - let newSoundPath = librarySoundsURL.appendingPathComponent("\(fileName).wav") - - Current.Log.verbose("New sound path is \(newSoundPath)") - - AKConverter(inputURL: pickedURL, outputURL: newSoundPath, options: options).start { error in - if let error { - let sError = SoundError(soundURL: newSoundPath, kind: .conversionFailed, underlying: error) - Current.Log.error("Experienced error during convert \(sError) (\(error))") - self.showAlert(sError.localizedDescription) - return - } - - if self.form.rowBy(tag: newSoundPath.lastPathComponent) == nil, - var section = self.form.sectionBy(tag: "imported") { - section.insert(self.getSoundRow(newSoundPath, true), at: section.count - 1) - } - } - } - } - - func librarySoundsURL() throws -> URL { - do { - let librarySoundsPath = try FileManager.default.url( - for: .libraryDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ).appendingPathComponent("Sounds") - - if !Current.isCatalyst { - // on Catalyst the sounds folder is a symlink to the global one, we don't want to mess with it - Current.Log.verbose("Creating sounds directory at \(librarySoundsPath)") - try FileManager.default.createDirectory( - at: librarySoundsPath, - withIntermediateDirectories: true, - attributes: nil - ) - } - - return librarySoundsPath - } catch { - throw SoundError(soundURL: nil, kind: .cantBuildLibrarySoundsPath, underlying: error) - } - } - - func importedFilesWithSuffix(_ suffix: String) throws -> [URL] { - do { - let files = try FileManager.default.contentsOfDirectory( - at: librarySoundsURL(), - includingPropertiesForKeys: nil - ) - return files.filter({ $0.lastPathComponent.hasSuffix(suffix) }) - } catch { - throw SoundError(soundURL: nil, kind: .cantGetDirectoryContents, underlying: error) - } - } - - func soundsInDirectory(_ path: URL) -> [URL]? { - guard let enu = FileManager.default.enumerator(at: path, includingPropertiesForKeys: [.isDirectoryKey]) else { - Current.Log.error("Unable to get enumerator!") - return nil - } - - var foundURLs: [URL] = [] - - while let fileURL = enu.nextObject() as? URL { - if FileManager.default.isDirectory(fileURL) == false, ensureDuration(fileURL) { - foundURLs.append(fileURL) - } - } - - return foundURLs - } - - func fileSharingPath() -> Promise { - Promise { seal in - do { - try seal.fulfill(FileManager.default.url( - for: .documentDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - )) - } catch { - seal.reject(SoundError(soundURL: nil, kind: .cantGetFileSharingPath, underlying: error)) - } - } - } - - // Thanks to http://stackoverflow.com/a/35624018/486182 - // Must reboot device after installing new push sounds (http://stackoverflow.com/q/34998278/486182) - func copySounds(_ soundURLs: [URL], _ formSectionTag: String) throws -> [URL] { - guard !soundURLs.isEmpty else { return [URL]() } - - let librarySoundsURL = try librarySoundsURL() - - var copiedSounds: [URL] = [] - - for soundURL in soundURLs { - let soundName = soundURL.lastPathComponent - - let newURL = librarySoundsURL.appendingPathComponent(soundName) - - Current.Log.verbose("Copying sound \(soundName) from \(soundURL) to \(newURL)") - - if FileManager.default.fileExists(atPath: newURL.path) { - Current.Log.verbose("Sound \(soundName) already exists in ~/Library/Sounds, removing") - try deleteSound(newURL) - } - - do { - try FileManager.default.copyItem(at: soundURL, to: newURL) - } catch { - throw SoundError(soundURL: nil, kind: .copyError, underlying: error) - } - - copiedSounds.append(newURL) - - if form.rowBy(tag: newURL.lastPathComponent) == nil, - var section = form.sectionBy(tag: formSectionTag) { - section.insert( - getSoundRow(newURL), - at: formSectionTag == "system" ? section.count - 1 : section.count - ) - } - } - - return copiedSounds - } - - func deleteSound(_ soundURL: URL) throws { - Current.Log.verbose("Deleting sound at \(soundURL)") - do { - try FileManager.default.removeItem(at: soundURL) - } catch { - throw SoundError(soundURL: nil, kind: .deleteError, underlying: error) - } - } - - func copySounds(_ soundURLs: [URL], _ formSectionTag: String) -> Promise<[URL]> { - guard !soundURLs.isEmpty else { return Promise.value([URL]()) } - - do { - let librarySoundsURL = try librarySoundsURL() - - let promises: [Promise] = soundURLs.map { self.copySound(librarySoundsURL, $0, formSectionTag) } - - return when(fulfilled: promises) - } catch { - return Promise(error: error) - } - } - - func copySound(_ librarySoundsURL: URL, _ soundURL: URL, _ formSectionTag: String) -> Promise { - Promise { seal in - let soundName = soundURL.lastPathComponent - - let newURL = librarySoundsURL.appendingPathComponent(soundName) - - Current.Log.verbose("Copying sound \(soundName) from \(soundURL) to \(newURL)") - - if FileManager.default.fileExists(atPath: newURL.path) { - Current.Log.verbose("Sound \(soundName) already exists in ~/Library/Sounds, removing") - do { - try FileManager.default.removeItem(at: newURL) - } catch { - seal.reject(SoundError(soundURL: nil, kind: .deleteError, underlying: error)) - } - } - - do { - try FileManager.default.copyItem(at: soundURL, to: newURL) - } catch { - seal.reject(SoundError(soundURL: nil, kind: .copyError, underlying: error)) - } - - DispatchQueue.main.async { - if self.form.rowBy(tag: newURL.lastPathComponent) === nil, - let section = self.form.sectionBy(tag: formSectionTag) { - section.append(self.getSoundRow(newURL)) - } - } - - seal.fulfill(newURL) - } - } - - func ensureDuration(_ soundURL: URL) -> Bool { - let duration = Double(CMTimeGetSeconds(AVURLAsset(url: soundURL).duration)) - return duration > 0.0 && duration <= 30.0 - } - - func showAlert(_ message: String, _ title: String? = L10n.errorLabel, popoverView: UIView? = nil) { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: L10n.okLabel, style: .default, handler: nil)) - present(alert, animated: true, completion: nil) - if let view = popoverView { - alert.popoverPresentationController?.sourceView = view - } - } - - @objc private func help() { - openURLInBrowser( - URL(string: "https://companion.home-assistant.io/app/ios/notifications-sounds")!, - self - ) - } -} - -extension FileManager { - func isDirectory(_ url: URL) -> Bool? { - var isDir = ObjCBool(false) - if fileExists(atPath: url.path, isDirectory: &isDir) { - return isDir.boolValue - } - return nil - } -} - -private struct SoundError: LocalizedError { - enum ErrorKind { - case cantBuildLibrarySoundsPath - case cantGetFileSharingPath - case cantGetDirectoryContents - case conversionFailed - case copyError - case deleteError - } - - let soundURL: URL? - let kind: ErrorKind - let underlying: Error - - public var errorDescription: String? { - let description = underlying.localizedDescription - switch kind { - case .cantBuildLibrarySoundsPath: - return L10n.SettingsDetails.Notifications.Sounds.Error.cantBuildLibrarySoundsPath(description) - case .cantGetFileSharingPath: - return L10n.SettingsDetails.Notifications.Sounds.Error.cantGetFileSharingPath(description) - case .cantGetDirectoryContents: - return L10n.SettingsDetails.Notifications.Sounds.Error.cantGetDirectoryContents(description) - case .conversionFailed: - return L10n.SettingsDetails.Notifications.Sounds.Error.conversionFailed(description) - case .copyError: - return L10n.SettingsDetails.Notifications.Sounds.Error.copyError(description) - case .deleteError: - return L10n.SettingsDetails.Notifications.Sounds.Error.deleteError(description) - } - } -} diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index 918e97355..6c2580d0b 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -218,8 +218,7 @@ struct SettingsServersView: View { struct SettingsNotificationsView: View { var body: some View { - embed(NotificationSettingsViewController()) - .navigationTitle(L10n.Settings.DetailsSection.NotificationSettingsRow.title) + NotificationSettingsView() } } diff --git a/Sources/App/Settings/SettingsRootDataSource.swift b/Sources/App/Settings/SettingsRootDataSource.swift index 29302add8..1bf7e3ab6 100644 --- a/Sources/App/Settings/SettingsRootDataSource.swift +++ b/Sources/App/Settings/SettingsRootDataSource.swift @@ -46,7 +46,11 @@ enum SettingsRootDataSource { $0.title = L10n.Settings.DetailsSection.NotificationSettingsRow.title $0.icon = .bellOutlineIcon $0.presentationMode = .show(controllerProvider: ControllerProvider.callback { - NotificationSettingsViewController() + let view = NavigationView { + NotificationSettingsView() + } + .navigationViewStyle(.stack) + return view.embeddedInHostingController() }, onDismiss: nil) } }