diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 095cf09f80..3bd61f828c 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -477,6 +477,7 @@ 20226C5AB77E1229852ADDC8 /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D27653D385E4CEB58E52A350 /* Pods_iOS_Extensions_Widgets.framework */; }; 237993F7E11DC585E29EDC7C /* Pods-iOS-Extensions-NotificationService-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */; }; 2F50FC61669812D485E608EC /* Pods-iOS-Extensions-PushProvider-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */; }; + 31A98CB0EDAA970B5B6B409C /* AppZone+Queries.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD0F913C9C21E0A7450CBBD /* AppZone+Queries.swift */; }; 368048FC64829A4E4B82B631 /* Pods_watchOS_WatchExtension_Watch.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */; }; 38A4EBA18ADEEE555AD14F52 /* Pods-iOS-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */; }; 3997926A2B7F904A00231B54 /* MobileAppConfigPushCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792692B7F904A00231B54 /* MobileAppConfigPushCategory.swift */; }; @@ -486,6 +487,7 @@ 399792712B7F909900231B54 /* MobileAppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792702B7F909900231B54 /* MobileAppConfig.swift */; }; 399792722B7F909900231B54 /* MobileAppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792702B7F909900231B54 /* MobileAppConfig.swift */; }; 39A32EE22C0E384E00985722 /* UIImage+scaledToSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A32EE12C0E384E00985722 /* UIImage+scaledToSize.swift */; }; + 3B405DA3013E2657D38DFB9B /* AppZoneMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0988947803F4FC498D6C6FCA /* AppZoneMigration.swift */; }; 3D6C7748CE8FF6A74860E511 /* Pods_iOS_SharedTesting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 202360CC8C2C1193658F9359 /* Pods_iOS_SharedTesting.framework */; }; 3E02C0E22CA7FCBF00102131 /* IntentSensorsAppEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E02C0E02CA7FCBF00102131 /* IntentSensorsAppEntity.swift */; }; 3E02C0E32CA7FCBF00102131 /* IntentSensorsAppEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E02C0E02CA7FCBF00102131 /* IntentSensorsAppEntity.swift */; }; @@ -1075,14 +1077,21 @@ 46F103262D721516002BC586 /* LocationHistoryListViewSnapshot.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F103252D721504002BC586 /* LocationHistoryListViewSnapshot.test.swift */; }; 491E98FF25D543560077BBE3 /* LogbookEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491E98FE25D543560077BBE3 /* LogbookEntry.swift */; }; 491E990025D543560077BBE3 /* LogbookEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491E98FE25D543560077BBE3 /* LogbookEntry.swift */; }; + 5202E900F2118BD2AB22A744 /* AppZoneTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5BAB76FB395B3562F264DE /* AppZoneTable.swift */; }; 539AA1653F4BCDB61FE7C696 /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 213EF66D14F92AF8BF2E9E98 /* Pods_iOS_Shared_iOS.framework */; }; + 584BBBE97ECDB5C2BE969CCD /* AppZoneTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5BAB76FB395B3562F264DE /* AppZoneTable.swift */; }; 5B715903CB3450FE351399BC /* Pods-iOS-Extensions-Share-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 207E35C8F1554A9AD616FFA2 /* Pods-iOS-Extensions-Share-metadata.plist */; }; + 5C8320F0577A06A28708CD29 /* AppZoneTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5BAB76FB395B3562F264DE /* AppZoneTable.swift */; }; 5FFBC80F835393915C4748CF /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A370326321B07E5ACE0BCB65 /* Pods_iOS_Extensions_PushProvider.framework */; }; + 610E99AF2B2DD593EF556ACE /* AppZoneMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0988947803F4FC498D6C6FCA /* AppZoneMigration.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; + 6E29D8AF59F5E50E837E455F /* AppZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BE374EFA8C16DBDD8F27B6B /* AppZone.swift */; }; + 76C667EC941491415470D041 /* AppZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BE374EFA8C16DBDD8F27B6B /* AppZone.swift */; }; 78BE7D5D003D9F8C7486DD69 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F4DFB087A3A43F9A526B851 /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; }; 81A0C1BBDEFF4F8C5FC314BE /* Pods_iOS_Extensions_NotificationContent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1F356D0219C7F8A24234511B /* Pods_iOS_Extensions_NotificationContent.framework */; }; 84F7755EFB03C3F463292ABF /* Pods-watchOS-Shared-watchOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */; }; 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; + A58042E9C1C655FAD725E38C /* AppZone+Queries.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD0F913C9C21E0A7450CBBD /* AppZone+Queries.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022212226DAC9D00E8DBFE /* ScaledFont.swift */; }; B60248001FBD343000998205 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B60247FE1FBD343000998205 /* InfoPlist.strings */; }; @@ -1238,6 +1247,7 @@ B641BC231E209CA9002CCBC1 /* HomeAssistantLogoView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B641BC221E209CA9002CCBC1 /* HomeAssistantLogoView.xib */; }; B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B641BC241E20A17B002CCBC1 /* OpenInChromeController.swift */; }; B64BB3A81E9C6551001E8B46 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64BB3A71E9C6551001E8B46 /* WebViewController.swift */; }; + B64D13EEB0C40E5B75A6BA8E /* AppZoneTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5BAB76FB395B3562F264DE /* AppZoneTable.swift */; }; B655E915227FE88A00CFDC94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B60247FE1FBD343000998205 /* InfoPlist.strings */; }; B657A8EA1CA646EB00121384 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657A8E91CA646EB00121384 /* AppDelegate.swift */; }; B657A8F61CA646EB00121384 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B657A8F41CA646EB00121384 /* LaunchScreen.storyboard */; }; @@ -1337,11 +1347,15 @@ B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; B9820AF29664869FD0B25CDF /* Pods_iOS_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD90A8F251D0671EFAC931ED /* Pods_iOS_App.framework */; }; C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; + C14308290638EEC37B3733E2 /* AppZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BE374EFA8C16DBDD8F27B6B /* AppZone.swift */; }; + C48233A6CB1B8A2ED78B8C28 /* AppZone+Queries.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD0F913C9C21E0A7450CBBD /* AppZone+Queries.swift */; }; C6478E5ADCB3EB7EC959EB53 /* Pods_iOS_Extensions_Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29FC93E25AB875716E2F35D4 /* Pods_iOS_Extensions_Intents.framework */; }; + C8DC8AFAE0E234DCC0D3A8A6 /* AppZoneMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0988947803F4FC498D6C6FCA /* AppZoneMigration.swift */; }; CA6886D02384DA18A91F37DD /* Pods-iOS-Extensions-Intents-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */; }; CB1983AFBFED0A03533DBE85 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C851CA22DDEEA359D12221C3 /* Pods_iOS_Extensions_Share.framework */; }; CF58E969432B36CC112701AC /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F1D92E4B7A5CD1007EB0782 /* Pods_watchOS_Shared_watchOS.framework */; }; D014EEA92128E192008EA6F5 /* ConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D014EEA82128E192008EA6F5 /* ConnectionInfo.swift */; }; + D01E0161CCBD3BC6669D923E /* AppZone+Queries.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD0F913C9C21E0A7450CBBD /* AppZone+Queries.swift */; }; D03D892920E0A85300D4F28D /* Shared.h in Headers */ = {isa = PBXBuildFile; fileRef = D03D891920E0A85300D4F28D /* Shared.h */; settings = {ATTRIBUTES = (Public, ); }; }; D03D893420E0A8FE00D4F28D /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; D03D893520E0AEF100D4F28D /* Realm+Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A6367420DBE93400E5C49B /* Realm+Initialization.swift */; }; @@ -1377,6 +1391,8 @@ D0EEF322214DE56B00D1D360 /* LocationTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EEF321214DE56B00D1D360 /* LocationTrigger.swift */; }; D0EEF324214DF2B700D1D360 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E857A11CB1CCCC00F96925 /* Utils.swift */; }; D0EEF335214EB77100D1D360 /* CLLocation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C17E20D1F64D00BD810B /* CLLocation+Extensions.swift */; }; + DAE7E836C2C493B6B1F83371 /* AppZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BE374EFA8C16DBDD8F27B6B /* AppZone.swift */; }; + E73095E2EE4286D7328F7336 /* AppZoneMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0988947803F4FC498D6C6FCA /* AppZoneMigration.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; FD3BC66C29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift */; }; @@ -1670,6 +1686,7 @@ 0194775556E59C6E64735937 /* Pods-watchOS-Shared-watchOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.release.xcconfig"; sourceTree = ""; }; 05C398FF0F9BA764B69CA36B /* Pods-iOS-Extensions-NotificationService.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.beta.xcconfig"; sourceTree = ""; }; 05E6CF2BD91E8443547F3026 /* Pods-iOS-Extensions-Today.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Today.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Today/Pods-iOS-Extensions-Today.release.xcconfig"; sourceTree = ""; }; + 0988947803F4FC498D6C6FCA /* AppZoneMigration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppZoneMigration.swift; path = Sources/Shared/API/Models/AppZoneMigration.swift; sourceTree = ""; }; 0AC45831AE5C9F83C5B6269D /* Pods-iOS-Extensions-Share.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Share.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Share/Pods-iOS-Extensions-Share.debug.xcconfig"; sourceTree = ""; }; 1100D51C2496AECE00B1073C /* PermissionStatusRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionStatusRow.swift; sourceTree = ""; }; 1100D51E2496F63400B1073C /* ThemeColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeColors.swift; sourceTree = ""; }; @@ -2752,6 +2769,7 @@ 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-WatchExtension-Watch-metadata.plist"; path = "Pods/Pods-watchOS-WatchExtension-Watch-metadata.plist"; sourceTree = ""; }; 943E024774CF54EADF771379 /* Pods_iOS_Extensions_Matter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Matter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 97F089744D425CAB2755F843 /* Pods-iOS-Shared-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.debug.xcconfig"; sourceTree = ""; }; + 9BE374EFA8C16DBDD8F27B6B /* AppZone.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppZone.swift; path = Sources/Shared/API/Models/AppZone.swift; sourceTree = ""; }; 9C4E5E21229D98220044C8EC /* HomeAssistant.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = HomeAssistant.debug.xcconfig; sourceTree = ""; }; 9C4E5E22229D98530044C8EC /* HomeAssistant.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.release.xcconfig; sourceTree = ""; }; 9C4E5E25229D986B0044C8EC /* HomeAssistant.beta.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.beta.xcconfig; sourceTree = ""; }; @@ -2760,6 +2778,7 @@ A0CE1C12B4ACF0A6876B6F7F /* Pods-iOS-Extensions-Today.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Today.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Today/Pods-iOS-Extensions-Today.beta.xcconfig"; sourceTree = ""; }; A370326321B07E5ACE0BCB65 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_watchOS_WatchExtension_Watch.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + ABD0F913C9C21E0A7450CBBD /* AppZone+Queries.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "AppZone+Queries.swift"; path = "Sources/Shared/API/Models/AppZone+Queries.swift"; sourceTree = ""; }; ADC769271BB34C474C2D1E24 /* Pods-iOS-Shared-iOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-metadata.plist"; sourceTree = ""; }; AF744211EE471EE671F7C928 /* Pods-iOS-Extensions-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.debug.xcconfig"; sourceTree = ""; }; B086E41966E89AE531E3C1A5 /* Pods-iOS-Extensions-Widgets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.debug.xcconfig"; sourceTree = ""; }; @@ -3079,6 +3098,7 @@ B6FD0573228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; B9B49F9D3E32AD45659A0A41 /* Pods-iOS-Extensions-Matter.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Matter.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Matter/Pods-iOS-Extensions-Matter.beta.xcconfig"; sourceTree = ""; }; + BA5BAB76FB395B3562F264DE /* AppZoneTable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppZoneTable.swift; path = Sources/Shared/Database/Tables/AppZoneTable.swift; sourceTree = ""; }; BED1F3255FAD612BC4670B45 /* Pods-iOS-Extensions-Share.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Share.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Share/Pods-iOS-Extensions-Share.beta.xcconfig"; sourceTree = ""; }; BEE6D44D86AC3F2F3E43950D /* Pods-watchOS-Shared-watchOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.debug.xcconfig"; sourceTree = ""; }; C2563441A5A149C269C5F320 /* Pods-iOS-Shared-iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.release.xcconfig"; sourceTree = ""; }; @@ -3326,6 +3346,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 04630E3B4F1D6CD3E88181BC /* Tables */ = { + isa = PBXGroup; + children = ( + BA5BAB76FB395B3562F264DE /* AppZoneTable.swift */, + ); + name = Tables; + path = Tables; + sourceTree = ""; + }; 1100D5222497578800B1073C /* TestNotifications */ = { isa = PBXGroup; children = ( @@ -4055,6 +4084,26 @@ path = Notifications; sourceTree = ""; }; + 161E8A43B97B8F8ADEAF43C8 /* Database */ = { + isa = PBXGroup; + children = ( + 04630E3B4F1D6CD3E88181BC /* Tables */, + ); + name = Database; + path = Database; + sourceTree = ""; + }; + 1C1CC6A7CFF49986C9C49553 /* Models */ = { + isa = PBXGroup; + children = ( + 9BE374EFA8C16DBDD8F27B6B /* AppZone.swift */, + ABD0F913C9C21E0A7450CBBD /* AppZone+Queries.swift */, + 0988947803F4FC498D6C6FCA /* AppZoneMigration.swift */, + ); + name = Models; + path = Models; + sourceTree = ""; + }; 29278BB24639BA945D3D86B4 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -5783,6 +5832,16 @@ path = ClientEvents; sourceTree = ""; }; + 6663654131CA4B41D376861A /* Shared */ = { + isa = PBXGroup; + children = ( + F7433BDDC183698AA2CC2C34 /* API */, + 161E8A43B97B8F8ADEAF43C8 /* Database */, + ); + name = Shared; + path = Shared; + sourceTree = ""; + }; 9C4E5E20229D97FA0044C8EC /* Configuration */ = { isa = PBXGroup; children = ( @@ -6054,6 +6113,7 @@ 29278BB24639BA945D3D86B4 /* Frameworks */, 42A746A22E832FB8005E0332 /* .github */, 42A7474C2E832FB8005E0332 /* fastlane */, + 6663654131CA4B41D376861A /* Shared */, ); sourceTree = ""; }; @@ -6658,6 +6718,15 @@ path = Common; sourceTree = ""; }; + F7433BDDC183698AA2CC2C34 /* API */ = { + isa = PBXGroup; + children = ( + 1C1CC6A7CFF49986C9C49553 /* Models */, + ); + name = API; + path = API; + sourceTree = ""; + }; FD3BC66429BA000A00B19FBE /* CarPlay */ = { isa = PBXGroup; children = ( @@ -7313,7 +7382,7 @@ packageReferences = ( 420E64BB2D676B2400A31E86 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 42B89EA62E05CC54000224A2 /* XCRemoteSwiftPackageReference "WebRTC" */, - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */, + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */, 4237E6372E5333370023B673 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); productRefGroup = B657A8E71CA646EB00121384 /* Products */; @@ -8781,6 +8850,10 @@ 11A71C7324A4FC8A00D9565F /* ZoneManagerEquatableRegion.test.swift in Sources */, 429481EB2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift in Sources */, 119C786725CF845800D41734 /* LocalizedStrings.test.swift in Sources */, + 76C667EC941491415470D041 /* AppZone.swift in Sources */, + A58042E9C1C655FAD725E38C /* AppZone+Queries.swift in Sources */, + 610E99AF2B2DD593EF556ACE /* AppZoneMigration.swift in Sources */, + B64D13EEB0C40E5B75A6BA8E /* AppZoneTable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9049,6 +9122,10 @@ 1104FC9225322C1800B8BE34 /* Dictionary+Additions.swift in Sources */, 118261F824F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */, 11C8E8AD24F36535003E7F89 /* DeviceWrapper.swift in Sources */, + DAE7E836C2C493B6B1F83371 /* AppZone.swift in Sources */, + D01E0161CCBD3BC6669D923E /* AppZone+Queries.swift in Sources */, + E73095E2EE4286D7328F7336 /* AppZoneMigration.swift in Sources */, + 584BBBE97ECDB5C2BE969CCD /* AppZoneTable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9369,6 +9446,10 @@ D0B25BD62133128800678C2C /* UNNotificationContent+ClientEvent.swift in Sources */, 118261F724F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */, 11C8E8AE24F3778E003E7F89 /* DeviceWrapper.swift in Sources */, + 6E29D8AF59F5E50E837E455F /* AppZone.swift in Sources */, + 31A98CB0EDAA970B5B6B409C /* AppZone+Queries.swift in Sources */, + C8DC8AFAE0E234DCC0D3A8A6 /* AppZoneMigration.swift in Sources */, + 5C8320F0577A06A28708CD29 /* AppZoneTable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9426,6 +9507,10 @@ 11764A6C26817FC3007D47F3 /* UserDefaultsValueSync.test.swift in Sources */, 114CBAED283AB92D00A9BAFF /* SecTrust+TestAdditions.swift in Sources */, 110ED58025A570F100489AF7 /* DisplaySensor.test.swift in Sources */, + C14308290638EEC37B3733E2 /* AppZone.swift in Sources */, + C48233A6CB1B8A2ED78B8C28 /* AppZone+Queries.swift in Sources */, + 3B405DA3013E2657D38DFB9B /* AppZoneMigration.swift in Sources */, + 5202E900F2118BD2AB22A744 /* AppZoneTable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -11265,7 +11350,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -11321,7 +11406,7 @@ }; 4273F7DF2E258827000629F7 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; - package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */; + package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */; productName = SharedPush; }; 427692E22B98B82500F24321 /* SharedPush */ = { diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index e1d3292c88..3ebd7afb61 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -391,6 +391,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = Realm.live() Action.setupObserver() NotificationCategory.setupObserver() + + // Migrate zones from Realm to GRDB (one-time migration) + AppZoneMigration.migrateFromRealm() } private func setupMenus() { diff --git a/Sources/App/ZoneManager/ZoneManager.swift b/Sources/App/ZoneManager/ZoneManager.swift index b3ee39fa39..ff197aa372 100644 --- a/Sources/App/ZoneManager/ZoneManager.swift +++ b/Sources/App/ZoneManager/ZoneManager.swift @@ -10,7 +10,6 @@ class ZoneManager { let collector: ZoneManagerCollector let processor: ZoneManagerProcessor let regionFilter: ZoneManagerRegionFilter - let zones: AnyRealmCollection private var notificationTokens = [NotificationToken]() @@ -24,11 +23,6 @@ class ZoneManager { self.collector = collector self.processor = processor self.regionFilter = regionFilter - self.zones = AnyRealmCollection( - Current.realm() - .objects(RLMZone.self) - .filter("TrackingEnabled == true") - ) self.collector.delegate = self self.processor.delegate = self @@ -36,7 +30,6 @@ class ZoneManager { log(state: .initialize) updateLocationManager(isInitial: true) - zones.realm?.refresh() NotificationCenter.default.addObserver( self, @@ -44,6 +37,18 @@ class ZoneManager { name: SettingsStore.locationRelatedSettingDidChange, object: nil ) + + // Observe zone updates from LegacyModelManager + NotificationCenter.default.addObserver( + self, + selector: #selector(zonesDidUpdate), + name: NSNotification.Name("ZonesDidUpdate"), + object: nil + ) + } + + @objc private func zonesDidUpdate() { + updateLocationManager(isInitial: false) } deinit { @@ -69,17 +74,12 @@ class ZoneManager { } } - if isInitial { - notificationTokens.append(zones.observe { [weak self] change in - switch change { - case let .initial(collection), .update(let collection, deletions: _, insertions: _, modifications: _): - self?.sync(zones: AnyCollection(collection)) - case let .error(error): - Current.Log.error("couldn't sync zones: \(error)") - } - }) - } else { + // Load zones from GRDB and sync + do { + let zones = try AppZone.fetchAllTrackableZones() sync(zones: AnyCollection(zones)) + } catch { + Current.Log.error("Failed to fetch zones from GRDB: \(error)") } } @@ -101,7 +101,12 @@ class ZoneManager { // a location change means we should consider changing our monitored regions // ^ not tap for this side effect because we don't want to do this on failure guard let self else { return } - sync(zones: AnyCollection(zones)) + do { + let zones = try AppZone.fetchAllTrackableZones() + sync(zones: AnyCollection(zones)) + } catch { + Current.Log.error("Failed to fetch zones for sync: \(error)") + } }.then { Current.clientEventStore.addEvent(ClientEvent( text: "Updated location", @@ -160,7 +165,7 @@ class ZoneManager { } } - private func sync(zones: AnyCollection) { + private func sync(zones: AnyCollection) { let currentRegions = locationManager.monitoredRegions let desiredRegions = regionFilter.regions( from: zones, diff --git a/Sources/App/ZoneManager/ZoneManagerAccuracyFuzzer.swift b/Sources/App/ZoneManager/ZoneManagerAccuracyFuzzer.swift index 7e774d4280..bcbc11d90f 100644 --- a/Sources/App/ZoneManager/ZoneManagerAccuracyFuzzer.swift +++ b/Sources/App/ZoneManager/ZoneManagerAccuracyFuzzer.swift @@ -86,7 +86,7 @@ struct ZoneManagerAccuracyFuzzerMultiZone: ZoneManagerAccuracyFuzzer { } let coordinate = location.coordinate - let distance = zone.location.distance(from: location) - zone.Radius + let distance = zone.location.distance(from: location) - zone.radius guard !zone.circularRegion.contains(coordinate), distance > 0 else { // this fuzzing is only necessary if the region doesn't contain without accuracy @@ -94,13 +94,19 @@ struct ZoneManagerAccuracyFuzzerMultiZone: ZoneManagerAccuracyFuzzer { return nil } - let containedZones = Current.realm() - .objects(RLMZone.self) - .filter { - // ignoring accuracy because that is not what matters for this case - // allowing the zone we're entering since we know we're not in it but we should be - $0.circularRegion.contains(coordinate) || $0 == zone + let containedZones: [AppZone] = { + do { + let zones = try AppZone.fetchAllTrackableZones() + return zones.filter { + // ignoring accuracy because that is not what matters for this case + // allowing the zone we're entering since we know we're not in it but we should be + $0.circularRegion.contains(coordinate) || $0.id == zone.id + } + } catch { + Current.Log.error("Failed to fetch zones in fuzzer: \(error)") + return [] } + }() guard containedZones.count > 1 else { // no overlapping zones for this location, no change is necessary diff --git a/Sources/App/ZoneManager/ZoneManagerCollector.swift b/Sources/App/ZoneManager/ZoneManagerCollector.swift index 97eb0e0f27..7fedc23ff8 100644 --- a/Sources/App/ZoneManager/ZoneManagerCollector.swift +++ b/Sources/App/ZoneManager/ZoneManagerCollector.swift @@ -53,12 +53,18 @@ class ZoneManagerCollectorImpl: NSObject, ZoneManagerCollector { return } - let zone = Current.realm() - .objects(RLMZone.self) - .first(where: { - $0.identifier == region.identifier || - $0.identifier == region.identifier.components(separatedBy: "@").first - }) + let zone: AppZone? = { + do { + let zones = try AppZone.fetchAllTrackableZones() + return zones.first(where: { + $0.id == region.identifier || + $0.id == region.identifier.components(separatedBy: "@").first + }) + } catch { + Current.Log.error("Failed to fetch zones in collector: \(error)") + return nil + } + }() let event = ZoneManagerEvent( eventType: .region(region, state), diff --git a/Sources/App/ZoneManager/ZoneManagerEvent.swift b/Sources/App/ZoneManager/ZoneManagerEvent.swift index f0c8170148..7c33552931 100644 --- a/Sources/App/ZoneManager/ZoneManagerEvent.swift +++ b/Sources/App/ZoneManager/ZoneManagerEvent.swift @@ -28,11 +28,11 @@ struct ZoneManagerEvent: Equatable, CustomStringConvertible { } var eventType: EventType - var associatedZone: RLMZone? + var associatedZone: AppZone? init( eventType: ZoneManagerEvent.EventType, - associatedZone: RLMZone? = nil + associatedZone: AppZone? = nil ) { self.eventType = eventType self.associatedZone = associatedZone @@ -40,7 +40,7 @@ struct ZoneManagerEvent: Equatable, CustomStringConvertible { static func == (lhs: ZoneManagerEvent, rhs: ZoneManagerEvent) -> Bool { lhs.eventType == rhs.eventType && - lhs.associatedZone?.identifier == rhs.associatedZone?.identifier + lhs.associatedZone?.id == rhs.associatedZone?.id } var description: String { @@ -49,11 +49,7 @@ struct ZoneManagerEvent: Equatable, CustomStringConvertible { attributes.append(String(describing: eventType)) if let zone = associatedZone { - if zone.isInvalidated { - attributes.append("zone deleted") - } else { - attributes.append(zone.identifier) - } + attributes.append(zone.id) } return "ZoneManagerEvent(\(attributes.joined(separator: ", ")))" diff --git a/Sources/App/ZoneManager/ZoneManagerProcessor.swift b/Sources/App/ZoneManager/ZoneManagerProcessor.swift index 6474eeded7..a2ac5f7243 100644 --- a/Sources/App/ZoneManager/ZoneManagerProcessor.swift +++ b/Sources/App/ZoneManager/ZoneManagerProcessor.swift @@ -149,29 +149,31 @@ class ZoneManagerProcessorImpl: ZoneManagerProcessor { return .value(()) } - private static func evaluateRegionEvent(region: CLRegion, state: CLRegionState, zone: RLMZone?) -> Promise { + private static func evaluateRegionEvent(region: CLRegion, state: CLRegionState, zone: AppZone?) -> Promise { guard state != .unknown else { return ignore(.unknownRegionState) } - guard let zone else { + guard var zone else { return ignore(.unknownRegion) } - guard zone.TrackingEnabled else { + guard zone.trackingEnabled else { // Do nothing in case we don't want to trigger an enter event return ignore(.zoneDisabled) } - if let current = Current.connectivity.currentWiFiSSID(), zone.SSIDFilter.contains(current) { + if let current = Current.connectivity.currentWiFiSSID(), zone.ssidFilter.contains(current) { // If current SSID is in the filter list stop processing region event. // This is to cut down on false exits. // https://github.com/home-assistant/iOS/issues/32 return ignore(.ignoredSSID(current)) } - zone.realm?.reentrantWrite { - zone.inRegion = state == .inside + do { + try zone.updateInRegion(state == .inside) + } catch { + Current.Log.error("Failed to update zone inRegion status: \(error)") } if region is CLBeaconRegion, state == .outside { diff --git a/Sources/App/ZoneManager/ZoneManagerRegionFilter.swift b/Sources/App/ZoneManager/ZoneManagerRegionFilter.swift index 015dfe97f5..df39ac2d98 100644 --- a/Sources/App/ZoneManager/ZoneManagerRegionFilter.swift +++ b/Sources/App/ZoneManager/ZoneManagerRegionFilter.swift @@ -4,7 +4,7 @@ import Shared protocol ZoneManagerRegionFilter { func regions( - from zones: AnyCollection, + from zones: AnyCollection, currentRegions: AnyCollection, lastLocation: CLLocation? ) -> AnyCollection @@ -65,7 +65,7 @@ class ZoneManagerRegionFilterImpl: ZoneManagerRegionFilter { } func regions( - from zones: AnyCollection, + from zones: AnyCollection, currentRegions: AnyCollection, lastLocation: CLLocation? ) -> AnyCollection { @@ -101,12 +101,12 @@ class ZoneManagerRegionFilterImpl: ZoneManagerRegionFilter { return lhs.key.location.distance(from: sourceLocation) < rhs.key.location.distance(from: sourceLocation) } else { // We have neither a location nor a home zone, so just like...strip the bigger ones? - return lhs.key.Radius < rhs.key.Radius + return lhs.key.radius < rhs.key.radius } } // just used for logging - var strippedZones = [RLMZone]() + var strippedZones = [AppZone]() for option in sorted.reversed() { let currentCount = Counts(segmented.values.flatMap { $0 }) @@ -139,8 +139,8 @@ class ZoneManagerRegionFilterImpl: ZoneManagerRegionFilter { private func logError( counts: Counts, - allZones: AnyCollection, - strippedZones: AnyCollection, + allZones: AnyCollection, + strippedZones: AnyCollection, decisionSource: String ) { Current.clientEventStore.addEvent(ClientEvent( @@ -149,7 +149,7 @@ class ZoneManagerRegionFilterImpl: ZoneManagerRegionFilter { "counts": counts.eventPayload, "limits": limits.eventPayload, "total_zones": allZones.count, - "stripped_zones": strippedZones.map(\.identifier), + "stripped_zones": strippedZones.map(\.id), "stripped_decision": decisionSource, ] )) diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 6d5f12efaa..def7b3e791 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -511,7 +511,7 @@ public class HomeAssistantAPI { public func SubmitLocation( updateType: LocationUpdateTrigger, location rawLocation: CLLocation?, - zone: RLMZone? + zone: AppZone? ) -> Promise { let update: WebhookUpdateLocation let location: CLLocation? @@ -529,7 +529,8 @@ public class HomeAssistantAPI { update = .init(trigger: updateType, usingNameOf: zone) } else if let rawLocation { // note this is a different zone than the event - e.g. the zone may be the one we are exiting - update = .init(trigger: updateType, usingNameOf: RLMZone.zone(of: rawLocation, in: server)) + let foundZone = try? AppZone.zone(of: rawLocation, in: server) + update = .init(trigger: updateType, usingNameOf: foundZone) } else { update = .init(trigger: updateType) } @@ -660,7 +661,7 @@ public class HomeAssistantAPI { public func zoneStateEvent( region: CLRegion, state: CLRegionState, - zone: RLMZone + zone: AppZone ) -> (eventType: String, eventData: [String: Any]) { var eventData: [String: Any] = sharedEventDeviceInfo eventData["zone"] = zone.entityId diff --git a/Sources/Shared/API/Models/AppZone+Queries.swift b/Sources/Shared/API/Models/AppZone+Queries.swift new file mode 100644 index 0000000000..6ef01a8d02 --- /dev/null +++ b/Sources/Shared/API/Models/AppZone+Queries.swift @@ -0,0 +1,109 @@ +import CoreLocation +import Foundation +import GRDB + +public extension AppZone { + /// Fetch all zones for a specific server + static func fetchZones(for serverId: String) throws -> [AppZone] { + try Current.database().read { db in + try AppZone + .filter(Column(DatabaseTables.AppZone.serverId.rawValue) == serverId) + .order(Column(DatabaseTables.AppZone.entityId.rawValue)) + .fetchAll(db) + } + } + + /// Fetch zones that are trackable (tracking enabled and not passive) + static func fetchTrackableZones(for serverId: String) throws -> [AppZone] { + try Current.database().read { db in + try AppZone + .filter(Column(DatabaseTables.AppZone.serverId.rawValue) == serverId) + .filter(Column(DatabaseTables.AppZone.trackingEnabled.rawValue) == true) + .filter(Column(DatabaseTables.AppZone.isPassive.rawValue) == false) + .order(Column(DatabaseTables.AppZone.entityId.rawValue)) + .fetchAll(db) + } + } + + /// Fetch all trackable zones across all servers + static func fetchAllTrackableZones() throws -> [AppZone] { + try Current.database().read { db in + try AppZone + .filter(Column(DatabaseTables.AppZone.trackingEnabled.rawValue) == true) + .filter(Column(DatabaseTables.AppZone.isPassive.rawValue) == false) + .order(Column(DatabaseTables.AppZone.entityId.rawValue)) + .fetchAll(db) + } + } + + /// Fetch a specific zone by ID + static func fetchZone(id: String) throws -> AppZone? { + try Current.database().read { db in + try AppZone + .filter(Column(DatabaseTables.AppZone.id.rawValue) == id) + .fetchOne(db) + } + } + + /// Fetch zone by entityId and serverId + static func fetchZone(entityId: String, serverId: String) throws -> AppZone? { + let id = AppZone.primaryKey(sourceIdentifier: entityId, serverIdentifier: serverId) + return try fetchZone(id: id) + } + + /// Find zone that contains the given location for a specific server + static func zone(of location: CLLocation, in server: Server) throws -> AppZone? { + let zones = try fetchTrackableZones(for: server.identifier.rawValue) + return zones + .filter { $0.circularRegion.containsWithAccuracy(location) } + .sorted { zoneA, zoneB in + // match the smaller zone over the larger + zoneA.radius < zoneB.radius + } + .first + } + + /// Save or update a zone + static func save(_ zone: AppZone) throws { + try Current.database().write { db in + try zone.save(db) + } + } + + /// Save or update multiple zones + static func save(_ zones: [AppZone]) throws { + try Current.database().write { db in + for zone in zones { + try zone.save(db) + } + } + } + + /// Delete zones for a specific server + static func deleteZones(for serverId: String) throws { + try Current.database().write { db in + try AppZone + .filter(Column(DatabaseTables.AppZone.serverId.rawValue) == serverId) + .deleteAll(db) + } + } + + /// Delete a specific zone + static func deleteZone(id: String) throws { + try Current.database().write { db in + try AppZone + .filter(Column(DatabaseTables.AppZone.id.rawValue) == id) + .deleteAll(db) + } + } + + /// Update inRegion status for a zone + mutating func updateInRegion(_ inRegion: Bool) throws { + var updatedZone = self + updatedZone.inRegion = inRegion + try Current.database().write { db in + try updatedZone.update(db) + } + self = updatedZone + } +} diff --git a/Sources/Shared/API/Models/AppZone.swift b/Sources/Shared/API/Models/AppZone.swift new file mode 100644 index 0000000000..2c55da1e1f --- /dev/null +++ b/Sources/Shared/API/Models/AppZone.swift @@ -0,0 +1,252 @@ +import CoreLocation +import Foundation +import GRDB +import HAKit + +public struct AppZone: Codable, FetchableRecord, PersistableRecord { + public static let databaseTableName = GRDBDatabaseTable.appZone.rawValue + + /// serverId/entityId (e.g., "server1/zone.home") + public let id: String + public let serverId: String + public let entityId: String + public var friendlyName: String? + public var latitude: Double + public var longitude: Double + public var radius: Double + public var trackingEnabled: Bool + public var enterNotification: Bool + public var exitNotification: Bool + public var inRegion: Bool + public var isPassive: Bool + + // Beacons + public var beaconUUID: String? + public var beaconMajor: Int? + public var beaconMinor: Int? + + // SSID + public var ssidTrigger: [String] + public var ssidFilter: [String] + + public init( + id: String, + serverId: String, + entityId: String, + friendlyName: String?, + latitude: Double, + longitude: Double, + radius: Double, + trackingEnabled: Bool, + enterNotification: Bool, + exitNotification: Bool, + inRegion: Bool, + isPassive: Bool, + beaconUUID: String?, + beaconMajor: Int?, + beaconMinor: Int?, + ssidTrigger: [String], + ssidFilter: [String] + ) { + self.id = id + self.serverId = serverId + self.entityId = entityId + self.friendlyName = friendlyName + self.latitude = latitude + self.longitude = longitude + self.radius = radius + self.trackingEnabled = trackingEnabled + self.enterNotification = enterNotification + self.exitNotification = exitNotification + self.inRegion = inRegion + self.isPassive = isPassive + self.beaconUUID = beaconUUID + self.beaconMajor = beaconMajor + self.beaconMinor = beaconMinor + self.ssidTrigger = ssidTrigger + self.ssidFilter = ssidFilter + } + + public static func primaryKey(sourceIdentifier: String, serverIdentifier: String) -> String { + serverIdentifier + "/" + sourceIdentifier + } + + public var isHome: Bool { + entityId == "zone.home" + } + + public var center: CLLocationCoordinate2D { + .init( + latitude: latitude, + longitude: longitude + ) + } + + public var location: CLLocation { + CLLocation( + coordinate: center, + altitude: 0, + horizontalAccuracy: radius, + verticalAccuracy: -1, + timestamp: Date() + ) + } + + public var regionsForMonitoring: [CLRegion] { + #if os(iOS) + if let beaconRegion { + return [beaconRegion] + } else { + return circularRegionsForMonitoring + } + #else + return circularRegionsForMonitoring + #endif + } + + public var circularRegion: CLCircularRegion { + let region = CLCircularRegion(center: center, radius: radius, identifier: id) + region.notifyOnEntry = true + region.notifyOnExit = true + return region + } + + #if os(iOS) + public var beaconRegion: CLBeaconRegion? { + guard let uuidString = beaconUUID else { + return nil + } + + guard let uuid = UUID(uuidString: uuidString) else { + let event = + ClientEvent( + text: "Unable to create beacon region due to invalid UUID: \(uuidString)", + type: .locationUpdate + ) + Current.clientEventStore.addEvent(event) + return nil + } + + let beaconRegion: CLBeaconRegion + + if let major = beaconMajor, let minor = beaconMinor { + beaconRegion = CLBeaconRegion( + uuid: uuid, + major: CLBeaconMajorValue(major), + minor: CLBeaconMinorValue(minor), + identifier: id + ) + } else if let major = beaconMajor { + beaconRegion = CLBeaconRegion( + uuid: uuid, + major: CLBeaconMajorValue(major), + identifier: id + ) + } else { + beaconRegion = CLBeaconRegion(uuid: uuid, identifier: id) + } + + beaconRegion.notifyEntryStateOnDisplay = true + beaconRegion.notifyOnEntry = true + beaconRegion.notifyOnExit = true + return beaconRegion + } + #endif + + public func containsInRegions(_ location: CLLocation) -> Bool { + circularRegionsForMonitoring.allSatisfy { $0.containsWithAccuracy(location) } + } + + public var circularRegionsForMonitoring: [CLCircularRegion] { + if radius >= 100 { + // zone is big enough to not have false-enters + let region = CLCircularRegion(center: center, radius: radius, identifier: id) + region.notifyOnEntry = true + region.notifyOnExit = true + return [region] + } else { + // zone is too small for region monitoring without false-enters + // see https://github.com/home-assistant/iOS/issues/784 + + // given we're a circle centered at (lat, long) with radius R + // and we want to be a series of circles with radius 100m that overlap our circle as best as possible + let numberOfCircles = 3 + let minimumRadius: Double = 100.0 + let centerOffset = Measurement(value: minimumRadius - radius, unit: .meters) + let sliceAngle = ((2.0 * Double.pi) / Double(numberOfCircles)) + + let angles: [Measurement] = (0 ..< numberOfCircles).map { amount in + .init(value: sliceAngle * Double(amount), unit: .radians) + } + + return angles.map { angle in + CLCircularRegion( + center: center.moving(distance: centerOffset, direction: angle), + radius: minimumRadius, + identifier: String(format: "%@@%03.0f", id, angle.converted(to: .degrees).value) + ) + } + } + } + + public var name: String { + if let fName = friendlyName { return fName } + return entityId.replacingOccurrences( + of: "zone.", + with: "" + ).replacingOccurrences( + of: "_", + with: " " + ).capitalized + } + + public var deviceTrackerName: String { + entityId.replacingOccurrences(of: "zone.", with: "") + } + + public var isBeaconRegion: Bool { + beaconUUID != nil + } +} + +public extension AppZone { + init(from zone: HAEntity, server: Server) { + guard let zoneAttributes = zone.attributes.zone else { + fatalError("Invalid zone entity") + } + + let identifier = Self.primaryKey(sourceIdentifier: zone.entityId, serverIdentifier: server.identifier.rawValue) + + self.init( + id: identifier, + serverId: server.identifier.rawValue, + entityId: zone.entityId, + friendlyName: zone.attributes.friendlyName, + latitude: zoneAttributes.latitude, + longitude: zoneAttributes.longitude, + radius: zoneAttributes.radius.converted(to: .meters).value, + trackingEnabled: zone.attributes.isTrackingEnabled, + enterNotification: true, + exitNotification: true, + inRegion: false, + isPassive: zoneAttributes.isPassive, + beaconUUID: zone.attributes.beaconUUID, + beaconMajor: zone.attributes.beaconMajor, + beaconMinor: zone.attributes.beaconMinor, + ssidTrigger: zone.attributes.ssidTrigger, + ssidFilter: zone.attributes.ssidFilter + ) + } +} + +extension HAEntityAttributes { + // app-specific attributes for zones, always optional + var isTrackingEnabled: Bool { self["track_ios"] as? Bool ?? true } + var beaconUUID: String? { beacon["uuid"] as? String } + var beaconMajor: Int? { beacon["major"] as? Int } + var beaconMinor: Int? { beacon["minor"] as? Int } + var ssidTrigger: [String] { self["ssid_trigger"] as? [String] ?? [] } + var ssidFilter: [String] { self["ssid_filter"] as? [String] ?? [] } + + private var beacon: [String: Any] { self["beacon"] as? [String: Any] ?? [:] } +} diff --git a/Sources/Shared/API/Models/AppZoneMigration.swift b/Sources/Shared/API/Models/AppZoneMigration.swift new file mode 100644 index 0000000000..32ca7305f2 --- /dev/null +++ b/Sources/Shared/API/Models/AppZoneMigration.swift @@ -0,0 +1,72 @@ +import Foundation +import GRDB +import RealmSwift + +public enum AppZoneMigration { + /// Migrate zones from Realm to GRDB + /// This is a one-time migration that copies all existing zones from Realm to GRDB + public static func migrateFromRealm() { + let userDefaultsKey = "AppZoneMigration.hasCompleted" + + guard !UserDefaults.standard.bool(forKey: userDefaultsKey) else { + Current.Log.verbose("Zone migration from Realm to GRDB already completed") + return + } + + Current.Log.info("Starting zone migration from Realm to GRDB") + + do { + let realm = Current.realm() + let realmZones = Array(realm.objects(RLMZone.self)) + + guard !realmZones.isEmpty else { + Current.Log.info("No zones found in Realm to migrate") + UserDefaults.standard.set(true, forKey: userDefaultsKey) + return + } + + var migratedZones: [AppZone] = [] + for realmZone in realmZones { + let appZone = AppZone( + id: realmZone.identifier, + serverId: realmZone.serverIdentifier, + entityId: realmZone.entityId, + friendlyName: realmZone.FriendlyName, + latitude: realmZone.Latitude, + longitude: realmZone.Longitude, + radius: realmZone.Radius, + trackingEnabled: realmZone.TrackingEnabled, + enterNotification: realmZone.enterNotification, + exitNotification: realmZone.exitNotification, + inRegion: realmZone.inRegion, + isPassive: realmZone.isPassive, + beaconUUID: realmZone.BeaconUUID, + beaconMajor: realmZone.BeaconMajor.value, + beaconMinor: realmZone.BeaconMinor.value, + ssidTrigger: Array(realmZone.SSIDTrigger), + ssidFilter: Array(realmZone.SSIDFilter) + ) + migratedZones.append(appZone) + } + + try AppZone.save(migratedZones) + + UserDefaults.standard.set(true, forKey: userDefaultsKey) + + Current.Log.info("Successfully migrated \(migratedZones.count) zones from Realm to GRDB") + Current.clientEventStore.addEvent(.init( + text: "Migrated \(migratedZones.count) zones from Realm to GRDB", + type: .database + )) + } catch { + Current.Log.error("Failed to migrate zones from Realm to GRDB: \(error)") + Current.clientEventStore.addEvent(.init( + text: "Failed to migrate zones from Realm to GRDB", + type: .database, + payload: [ + "error": error.localizedDescription, + ] + )) + } + } +} diff --git a/Sources/Shared/API/Models/LegacyModelManager.swift b/Sources/Shared/API/Models/LegacyModelManager.swift index 803a776417..31083f6258 100644 --- a/Sources/Shared/API/Models/LegacyModelManager.swift +++ b/Sources/Shared/API/Models/LegacyModelManager.swift @@ -386,6 +386,58 @@ public class LegacyModelManager: ServerObserver { UM.didUpdate(objects: updatedModels, server: server, realm: realm) UM.willDelete(objects: Array(deleteObjects), server: server, realm: realm) realm.delete(deleteObjects) + + // Also save zones to GRDB for migration + if UM.self == RLMZone.self, let zones = updatedModels as? [RLMZone] { + syncZonesToGRDB(zones: zones, deletedIDs: deletedIDs, server: server) + } + } + } + + private func syncZonesToGRDB(zones: [RLMZone], deletedIDs: Set, server: Server) { + do { + // Convert Realm zones to GRDB zones + let appZones = zones.map { realmZone in + AppZone( + id: realmZone.identifier, + serverId: realmZone.serverIdentifier, + entityId: realmZone.entityId, + friendlyName: realmZone.FriendlyName, + latitude: realmZone.Latitude, + longitude: realmZone.Longitude, + radius: realmZone.Radius, + trackingEnabled: realmZone.TrackingEnabled, + enterNotification: realmZone.enterNotification, + exitNotification: realmZone.exitNotification, + inRegion: realmZone.inRegion, + isPassive: realmZone.isPassive, + beaconUUID: realmZone.BeaconUUID, + beaconMajor: realmZone.BeaconMajor.value, + beaconMinor: realmZone.BeaconMinor.value, + ssidTrigger: Array(realmZone.SSIDTrigger), + ssidFilter: Array(realmZone.SSIDFilter) + ) + } + + // Save zones to GRDB + try AppZone.save(appZones) + + // Delete removed zones from GRDB + for deletedID in deletedIDs { + try AppZone.deleteZone(id: deletedID) + } + + // Notify ZoneManager about zone updates + NotificationCenter.default.post(name: NSNotification.Name("ZonesDidUpdate"), object: nil) + } catch { + Current.Log.error("Failed to sync zones to GRDB: \(error)") + Current.clientEventStore.addEvent(.init( + text: "Failed to sync zones to GRDB", + type: .database, + payload: [ + "error": error.localizedDescription, + ] + )) } } diff --git a/Sources/Shared/API/Models/LocationHistory.swift b/Sources/Shared/API/Models/LocationHistory.swift index 4b20ac22bd..e6e67a173f 100644 --- a/Sources/Shared/API/Models/LocationHistory.swift +++ b/Sources/Shared/API/Models/LocationHistory.swift @@ -45,6 +45,32 @@ public class LocationHistoryEntry: Object { self.accuracyAuthorization = accuracyAuthorization } + public convenience init( + updateType: LocationUpdateTrigger, + location: CLLocation?, + zone: AppZone?, + accuracyAuthorization: CLAccuracyAuthorization, + payload: String + ) { + self.init() + + var loc = CLLocation() + if let location { + loc = location + } else if let zone { + loc = zone.location + } + + self.Accuracy = loc.horizontalAccuracy + self.Latitude = loc.coordinate.latitude + self.Longitude = loc.coordinate.longitude + self.Trigger = updateType.rawValue + // Don't link to Realm zone for AppZone + self.Zone = nil + self.Payload = payload + self.accuracyAuthorization = accuracyAuthorization + } + public var clLocation: CLLocation { CLLocation( coordinate: .init(latitude: Latitude, longitude: Longitude), diff --git a/Sources/Shared/API/Models/WebhookUpdateLocation.swift b/Sources/Shared/API/Models/WebhookUpdateLocation.swift index 5a73032f26..71a5ff1ea4 100644 --- a/Sources/Shared/API/Models/WebhookUpdateLocation.swift +++ b/Sources/Shared/API/Models/WebhookUpdateLocation.swift @@ -29,12 +29,12 @@ public struct WebhookUpdateLocation: ImmutableMappable { } } - public init(trigger: LocationUpdateTrigger, usingNameOf zone: RLMZone?) { + public init(trigger: LocationUpdateTrigger, usingNameOf zone: AppZone?) { self.init(trigger: trigger) self.locationName = zone?.deviceTrackerName ?? LocationNames.NotHome.rawValue } - public init(trigger: LocationUpdateTrigger, location: CLLocation?, zone: RLMZone?) { + public init(trigger: LocationUpdateTrigger, location: CLLocation?, zone: AppZone?) { self.init(trigger: trigger) let useLocation: Bool diff --git a/Sources/Shared/API/Webhook/Networking/WebhookResponseLocation.swift b/Sources/Shared/API/Webhook/Networking/WebhookResponseLocation.swift index 4f0e2ccbcd..345c64704f 100644 --- a/Sources/Shared/API/Webhook/Networking/WebhookResponseLocation.swift +++ b/Sources/Shared/API/Webhook/Networking/WebhookResponseLocation.swift @@ -11,9 +11,9 @@ struct WebhookResponseLocationLocalMetadata: ImmutableMappable { let trigger: LocationUpdateTrigger let zoneName: String - init(trigger: LocationUpdateTrigger, zone: RLMZone?) { + init(trigger: LocationUpdateTrigger, zone: AppZone?) { self.trigger = trigger - self.zoneName = zone?.Name ?? "(unknown)" + self.zoneName = zone?.name ?? "(unknown)" } init(map: Map) throws { @@ -35,7 +35,7 @@ struct WebhookResponseLocation: WebhookResponseHandler { static func localMetdata( trigger: LocationUpdateTrigger, - zone: RLMZone? + zone: AppZone? ) -> [String: Any] { WebhookResponseLocationLocalMetadata( trigger: trigger, diff --git a/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift b/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift index 30566cf592..a2b645eada 100644 --- a/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift @@ -65,12 +65,18 @@ public class GeocoderSensor: SensorProvider { var attributes = Self.attributes(for: placemarks) if let location = request.location { - let insideZones = Current.realm().objects(RLMZone.self) - .filter(RLMZone.trackablePredicate) - .sorted(byKeyPath: "Radius") - .filter { $0.circularRegion.contains(location.coordinate) } - .map { $0.FriendlyName ?? $0.Name } - .filter { $0 != "" } + let insideZones: [String] = { + do { + return try AppZone.fetchAllTrackableZones() + .sorted { $0.radius < $1.radius } + .filter { $0.circularRegion.contains(location.coordinate) } + .map { $0.friendlyName ?? $0.name } + .filter { $0 != "" } + } catch { + Current.Log.error("Failed to fetch zones in GeocoderSensor: \(error)") + return [] + } + }() if let zone = insideZones.first, Current.settingsStore.prefs.bool(forKey: UserDefaultsKeys.geocodeUseZone.rawValue) { diff --git a/Sources/Shared/Database/DatabaseTables.swift b/Sources/Shared/Database/DatabaseTables.swift index 7c323ba11d..07afec503e 100644 --- a/Sources/Shared/Database/DatabaseTables.swift +++ b/Sources/Shared/Database/DatabaseTables.swift @@ -9,6 +9,7 @@ public enum GRDBDatabaseTable: String { case appPanel case customWidget case appArea + case appZone // Dropped since 2025.2, now saved as json file // Context: https://github.com/groue/GRDB.swift/issues/1626#issuecomment-2623927815 @@ -84,4 +85,25 @@ public enum DatabaseTables { case icon case entities } + + // Zones from Home Assistant + public enum AppZone: String { + case id + case serverId + case entityId + case friendlyName + case latitude + case longitude + case radius + case trackingEnabled + case enterNotification + case exitNotification + case inRegion + case isPassive + case beaconUUID + case beaconMajor + case beaconMinor + case ssidTrigger + case ssidFilter + } } diff --git a/Sources/Shared/Database/GRDB+Initialization.swift b/Sources/Shared/Database/GRDB+Initialization.swift index 74a6f419a5..6a1ec7f2a2 100644 --- a/Sources/Shared/Database/GRDB+Initialization.swift +++ b/Sources/Shared/Database/GRDB+Initialization.swift @@ -52,6 +52,7 @@ public extension DatabaseQueue { AppPanelTable(), CustomWidgetTable(), AppAreaTable(), + AppZoneTable(), ] } diff --git a/Sources/Shared/Database/Tables/AppZoneTable.swift b/Sources/Shared/Database/Tables/AppZoneTable.swift new file mode 100644 index 0000000000..3921d96945 --- /dev/null +++ b/Sources/Shared/Database/Tables/AppZoneTable.swift @@ -0,0 +1,39 @@ +import Foundation +import GRDB + +final class AppZoneTable: DatabaseTableProtocol { + func createIfNeeded(database: DatabaseQueue) throws { + let shouldCreateTable = try database.read { db in + try !db.tableExists(GRDBDatabaseTable.appZone.rawValue) + } + if shouldCreateTable { + try database.write { db in + try db.create(table: GRDBDatabaseTable.appZone.rawValue) { t in + t.column(DatabaseTables.AppZone.id.rawValue, .text).notNull().primaryKey() + t.column(DatabaseTables.AppZone.serverId.rawValue, .text).notNull() + t.column(DatabaseTables.AppZone.entityId.rawValue, .text).notNull() + t.column(DatabaseTables.AppZone.friendlyName.rawValue, .text) + t.column(DatabaseTables.AppZone.latitude.rawValue, .double).notNull() + t.column(DatabaseTables.AppZone.longitude.rawValue, .double).notNull() + t.column(DatabaseTables.AppZone.radius.rawValue, .double).notNull() + t.column(DatabaseTables.AppZone.trackingEnabled.rawValue, .boolean).notNull() + t.column(DatabaseTables.AppZone.enterNotification.rawValue, .boolean).notNull() + t.column(DatabaseTables.AppZone.exitNotification.rawValue, .boolean).notNull() + t.column(DatabaseTables.AppZone.inRegion.rawValue, .boolean).notNull() + t.column(DatabaseTables.AppZone.isPassive.rawValue, .boolean).notNull() + t.column(DatabaseTables.AppZone.beaconUUID.rawValue, .text) + t.column(DatabaseTables.AppZone.beaconMajor.rawValue, .integer) + t.column(DatabaseTables.AppZone.beaconMinor.rawValue, .integer) + t.column(DatabaseTables.AppZone.ssidTrigger.rawValue, .jsonText).notNull() + t.column(DatabaseTables.AppZone.ssidFilter.rawValue, .jsonText).notNull() + + // Ensure unique combination of serverId and entityId + t.uniqueKey([ + DatabaseTables.AppZone.serverId.rawValue, + DatabaseTables.AppZone.entityId.rawValue, + ]) + } + } + } + } +} diff --git a/Tests/App/ZoneManager/ZoneManager.test.swift b/Tests/App/ZoneManager/ZoneManager.test.swift index 4cfe052e85..5305397ab3 100644 --- a/Tests/App/ZoneManager/ZoneManager.test.swift +++ b/Tests/App/ZoneManager/ZoneManager.test.swift @@ -96,7 +96,41 @@ class ZoneManagerTests: XCTestCase { private func addedZones(_ toAdd: [RLMZone]) throws -> [RLMZone] { try realm.write { realm.add(toAdd) - return toAdd + } + // Also sync to GRDB for the new zone manager + syncZonesToGRDB(toAdd) + return toAdd + } + + private func syncZonesToGRDB(_ zones: [RLMZone]) { + let appZones = zones.map { realmZone in + AppZone( + id: realmZone.identifier, + serverId: realmZone.serverIdentifier, + entityId: realmZone.entityId, + friendlyName: realmZone.FriendlyName, + latitude: realmZone.Latitude, + longitude: realmZone.Longitude, + radius: realmZone.Radius, + trackingEnabled: realmZone.TrackingEnabled, + enterNotification: realmZone.enterNotification, + exitNotification: realmZone.exitNotification, + inRegion: realmZone.inRegion, + isPassive: realmZone.isPassive, + beaconUUID: realmZone.BeaconUUID, + beaconMajor: realmZone.BeaconMajor.value, + beaconMinor: realmZone.BeaconMinor.value, + ssidTrigger: Array(realmZone.SSIDTrigger), + ssidFilter: Array(realmZone.SSIDFilter) + ) + } + + do { + try AppZone.save(appZones) + // Trigger zone update notification + NotificationCenter.default.post(name: NSNotification.Name("ZonesDidUpdate"), object: nil) + } catch { + XCTFail("Failed to sync zones to GRDB: \(error)") } } @@ -142,6 +176,7 @@ class ZoneManagerTests: XCTestCase { zones[1].Latitude += 0.02 addedRegions.append(contentsOf: zones[1].regionsForMonitoring) } + syncZonesToGRDB(zones) realm.refresh() @@ -151,11 +186,15 @@ class ZoneManagerTests: XCTestCase { XCTAssertEqual(collector.ignoringNextStates, Set(addedRegions)) // remove a zone + let toRemoveId: String try realm.write { let toRemove = zones.popLast()! + toRemoveId = toRemove.identifier removedRegions.append(contentsOf: toRemove.regionsForMonitoring) realm.delete(toRemove) } + try AppZone.deleteZone(id: toRemoveId) + NotificationCenter.default.post(name: NSNotification.Name("ZonesDidUpdate"), object: nil) realm.refresh()