diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index c7befb8b..b5515355 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -7,6 +7,29 @@ objects = { /* Begin PBXBuildFile section */ + 7E1000063000000000000001 /* TelegramEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000213000000000000001 /* TelegramEvent.swift */; }; + 7E1000073000000000000001 /* TelegramSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000223000000000000001 /* TelegramSettings.swift */; }; + 7E1000013000000000000001 /* TelegramSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000113000000000000001 /* TelegramSettingsTests.swift */; }; + 7E1000023000000000000001 /* TestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000123000000000000001 /* TestDoubles.swift */; }; + 7E1000093000000000000001 /* KeychainStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000233000000000000001 /* KeychainStoreTests.swift */; }; + 7E1000083000000000000001 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000243000000000000001 /* KeychainStore.swift */; }; + 7E10000A3000000000000001 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E1000253000000000000001 /* Security.framework */; }; + 7E10000C3000000000000001 /* TelegramNotifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000273000000000000001 /* TelegramNotifierTests.swift */; }; + 7E10000D3000000000000001 /* CameraCaptureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000283000000000000001 /* CameraCaptureTests.swift */; }; + 7E10000E3000000000000001 /* CameraCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000293000000000000001 /* CameraCapture.swift */; }; + 7E10000F3000000000000001 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E10002A3000000000000001 /* AVFoundation.framework */; }; + 7E10000B3000000000000001 /* TelegramNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000263000000000000001 /* TelegramNotifier.swift */; }; + 7E10002B3000000000000001 /* TelegramNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10002C3000000000000001 /* TelegramNotificationService.swift */; }; + 7E10002D3000000000000001 /* TelegramNotificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10002E3000000000000001 /* TelegramNotificationServiceTests.swift */; }; + 7E1000303000000000000001 /* TelegramMenuControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10002F3000000000000001 /* TelegramMenuControllerTests.swift */; }; + 7E1000323000000000000001 /* TelegramMenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000313000000000000001 /* TelegramMenuController.swift */; }; + 7E1000333000000000000001 /* LocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000343000000000000001 /* LocalizationTests.swift */; }; + 7E1000353000000000000001 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7E1000363000000000000001 /* InfoPlist.strings */; }; + 7E1000443000000000000001 /* MacLocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000453000000000000001 /* MacLocationProvider.swift */; }; + 7E1000463000000000000001 /* MacLocationProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000473000000000000001 /* MacLocationProviderTests.swift */; }; + 7E1000483000000000000001 /* PhotoLocationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10004A3000000000000001 /* PhotoLocationCoordinator.swift */; }; + 7E1000493000000000000001 /* PhotoLocationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10004B3000000000000001 /* PhotoLocationCoordinatorTests.swift */; }; + 7E1000033000000000000001 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E1000133000000000000001 /* XCTest.framework */; }; 3D2CF85B255102B300157996 /* MediaRemote.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D2CF85A255102B300157996 /* MediaRemote.framework */; }; 3D2FCF08226C99CB007A06E7 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3D2FCF07226C99CB007A06E7 /* Images.xcassets */; }; 3D4EE4112279E55A00AF9E93 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 3D4EE4102279E55A00AF9E93 /* AppDelegate.m */; }; @@ -26,6 +49,16 @@ 3DD4B669226C1E3700451B7B /* login.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DD4B668226C1E3700451B7B /* login.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 7E1000043000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3DD4B648226C1C3200451B7B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3DD4B64F226C1C3200451B7B; + remoteInfo = BLEUnlock; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 3D600A5922701E5D0068FB7B /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; @@ -40,6 +73,37 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 7E1000213000000000000001 /* TelegramEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramEvent.swift; sourceTree = ""; }; + 7E1000223000000000000001 /* TelegramSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramSettings.swift; sourceTree = ""; }; + 7E1000103000000000000001 /* BLEUnlockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BLEUnlockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7E1000113000000000000001 /* TelegramSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramSettingsTests.swift; sourceTree = ""; }; + 7E1000123000000000000001 /* TestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDoubles.swift; sourceTree = ""; }; + 7E1000233000000000000001 /* KeychainStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStoreTests.swift; sourceTree = ""; }; + 7E1000243000000000000001 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; + 7E1000253000000000000001 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 7E1000273000000000000001 /* TelegramNotifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramNotifierTests.swift; sourceTree = ""; }; + 7E1000283000000000000001 /* CameraCaptureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureTests.swift; sourceTree = ""; }; + 7E1000293000000000000001 /* CameraCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCapture.swift; sourceTree = ""; }; + 7E10002A3000000000000001 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; + 7E1000263000000000000001 /* TelegramNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramNotifier.swift; sourceTree = ""; }; + 7E10002C3000000000000001 /* TelegramNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramNotificationService.swift; sourceTree = ""; }; + 7E10002E3000000000000001 /* TelegramNotificationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramNotificationServiceTests.swift; sourceTree = ""; }; + 7E10002F3000000000000001 /* TelegramMenuControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramMenuControllerTests.swift; sourceTree = ""; }; + 7E1000313000000000000001 /* TelegramMenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramMenuController.swift; sourceTree = ""; }; + 7E1000343000000000000001 /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; + 7E1000373000000000000001 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; + 7E1000383000000000000001 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; + 7E1000393000000000000001 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; + 7E10003A3000000000000001 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; + 7E10003B3000000000000001 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; + 7E10003C3000000000000001 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; + 7E10003D3000000000000001 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + 7E10003E3000000000000001 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; + 7E1000453000000000000001 /* MacLocationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLocationProvider.swift; sourceTree = ""; }; + 7E1000473000000000000001 /* MacLocationProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLocationProviderTests.swift; sourceTree = ""; }; + 7E10004A3000000000000001 /* PhotoLocationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLocationCoordinator.swift; sourceTree = ""; }; + 7E10004B3000000000000001 /* PhotoLocationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLocationCoordinatorTests.swift; sourceTree = ""; }; + 7E1000133000000000000001 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; 3D2CF85A255102B300157996 /* MediaRemote.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemote.framework; path = ../../../../System/Library/PrivateFrameworks/MediaRemote.framework; sourceTree = ""; }; 3D2FCF07226C99CB007A06E7 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 3D43D0112765FCCB00D15991 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/AboutBox.strings; sourceTree = ""; }; @@ -93,11 +157,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7E10000F3000000000000001 /* AVFoundation.framework in Frameworks */, + 7E10000A3000000000000001 /* Security.framework in Frameworks */, 3D2CF85B255102B300157996 /* MediaRemote.framework in Frameworks */, 3DD4B669226C1E3700451B7B /* login.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + 7E1000153000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E1000033000000000000001 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -119,6 +193,7 @@ isa = PBXGroup; children = ( 3DD4B652226C1C3200451B7B /* BLEUnlock */, + 7E1000143000000000000001 /* BLEUnlockTests */, 3D600A4122701A5B0068FB7B /* Launcher */, 3DD4B651226C1C3200451B7B /* Products */, 3DD4B667226C1E3700451B7B /* Frameworks */, @@ -130,6 +205,7 @@ children = ( 3DD4B650226C1C3200451B7B /* BLEUnlock.app */, 3D600A4022701A5A0068FB7B /* Launcher.app */, + 7E1000103000000000000001 /* BLEUnlockTests.xctest */, ); name = Products; sourceTree = ""; @@ -137,9 +213,19 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E10004A3000000000000001 /* PhotoLocationCoordinator.swift */, + 7E1000453000000000000001 /* MacLocationProvider.swift */, + 7E1000313000000000000001 /* TelegramMenuController.swift */, + 7E10002C3000000000000001 /* TelegramNotificationService.swift */, + 7E1000293000000000000001 /* CameraCapture.swift */, + 7E1000263000000000000001 /* TelegramNotifier.swift */, + 7E1000213000000000000001 /* TelegramEvent.swift */, + 7E1000223000000000000001 /* TelegramSettings.swift */, + 7E1000243000000000000001 /* KeychainStore.swift */, 3D95379322784D550017D8B9 /* AboutBox.swift */, 3D953799227853E20017D8B9 /* AboutBox.xib */, 3D600A35226CC1A40068FB7B /* Localizable.strings */, + 7E1000363000000000000001 /* InfoPlist.strings */, 3DD4B665226C1DF200451B7B /* BLE.swift */, 3D6070E126DBC68E00EE4E48 /* LEDeviceInfo.swift */, 3DD4B653226C1C3200451B7B /* AppDelegate.swift */, @@ -156,9 +242,28 @@ path = BLEUnlock; sourceTree = ""; }; + 7E1000143000000000000001 /* BLEUnlockTests */ = { + isa = PBXGroup; + children = ( + 7E10004B3000000000000001 /* PhotoLocationCoordinatorTests.swift */, + 7E1000473000000000000001 /* MacLocationProviderTests.swift */, + 7E1000343000000000000001 /* LocalizationTests.swift */, + 7E10002F3000000000000001 /* TelegramMenuControllerTests.swift */, + 7E10002E3000000000000001 /* TelegramNotificationServiceTests.swift */, + 7E1000283000000000000001 /* CameraCaptureTests.swift */, + 7E1000233000000000000001 /* KeychainStoreTests.swift */, + 7E1000273000000000000001 /* TelegramNotifierTests.swift */, + 7E1000113000000000000001 /* TelegramSettingsTests.swift */, + 7E1000123000000000000001 /* TestDoubles.swift */, + ); + path = BLEUnlockTests; + sourceTree = ""; + }; 3DD4B667226C1E3700451B7B /* Frameworks */ = { isa = PBXGroup; children = ( + 7E10002A3000000000000001 /* AVFoundation.framework */, + 7E1000253000000000000001 /* Security.framework */, 3D2CF85A255102B300157996 /* MediaRemote.framework */, 3DD4B668226C1E3700451B7B /* login.framework */, ); @@ -204,6 +309,23 @@ productReference = 3DD4B650226C1C3200451B7B /* BLEUnlock.app */; productType = "com.apple.product-type.application"; }; + 7E1000163000000000000001 /* BLEUnlockTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7E1000173000000000000001 /* Build configuration list for PBXNativeTarget "BLEUnlockTests" */; + buildPhases = ( + 7E1000183000000000000001 /* Sources */, + 7E1000153000000000000001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 7E1000053000000000000001 /* PBXTargetDependency */, + ); + name = BLEUnlockTests; + productName = BLEUnlockTests; + productReference = 7E1000103000000000000001 /* BLEUnlockTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -265,6 +387,7 @@ targets = ( 3DD4B64F226C1C3200451B7B /* BLEUnlock */, 3D600A3F22701A5A0068FB7B /* Launcher */, + 7E1000163000000000000001 /* BLEUnlockTests */, ); }; /* End PBXProject section */ @@ -284,6 +407,7 @@ files = ( 3D2FCF08226C99CB007A06E7 /* Images.xcassets in Resources */, 3D600A37226CC1A40068FB7B /* Localizable.strings in Resources */, + 7E1000353000000000000001 /* InfoPlist.strings in Resources */, 3D953797227853E20017D8B9 /* AboutBox.xib in Resources */, 3DD4B659226C1C3400451B7B /* MainMenu.xib in Resources */, ); @@ -312,6 +436,23 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 7E1000183000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E1000493000000000000001 /* PhotoLocationCoordinatorTests.swift in Sources */, + 7E1000463000000000000001 /* MacLocationProviderTests.swift in Sources */, + 7E1000333000000000000001 /* LocalizationTests.swift in Sources */, + 7E1000303000000000000001 /* TelegramMenuControllerTests.swift in Sources */, + 7E10002D3000000000000001 /* TelegramNotificationServiceTests.swift in Sources */, + 7E10000D3000000000000001 /* CameraCaptureTests.swift in Sources */, + 7E1000093000000000000001 /* KeychainStoreTests.swift in Sources */, + 7E10000C3000000000000001 /* TelegramNotifierTests.swift in Sources */, + 7E1000013000000000000001 /* TelegramSettingsTests.swift in Sources */, + 7E1000023000000000000001 /* TestDoubles.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3D600A3C22701A5A0068FB7B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -325,6 +466,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E1000483000000000000001 /* PhotoLocationCoordinator.swift in Sources */, + 7E1000443000000000000001 /* MacLocationProvider.swift in Sources */, + 7E1000323000000000000001 /* TelegramMenuController.swift in Sources */, + 7E10002B3000000000000001 /* TelegramNotificationService.swift in Sources */, + 7E10000E3000000000000001 /* CameraCapture.swift in Sources */, + 7E10000B3000000000000001 /* TelegramNotifier.swift in Sources */, + 7E1000063000000000000001 /* TelegramEvent.swift in Sources */, + 7E1000073000000000000001 /* TelegramSettings.swift in Sources */, + 7E1000083000000000000001 /* KeychainStore.swift in Sources */, 3DD4B654226C1C3200451B7B /* AppDelegate.swift in Sources */, 3DD4B666226C1DF200451B7B /* BLE.swift in Sources */, 3DD4B664226C1CF000451B7B /* lowlevel.c in Sources */, @@ -337,7 +487,30 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 7E1000053000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3DD4B64F226C1C3200451B7B /* BLEUnlock */; + targetProxy = 7E1000043000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ + 7E1000363000000000000001 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 7E1000373000000000000001 /* Base */, + 7E1000383000000000000001 /* da */, + 7E1000393000000000000001 /* de */, + 7E10003A3000000000000001 /* ja */, + 7E10003B3000000000000001 /* nb */, + 7E10003C3000000000000001 /* sv */, + 7E10003D3000000000000001 /* tr */, + 7E10003E3000000000000001 /* zh-Hans */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; 3D600A35226CC1A40068FB7B /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( @@ -394,6 +567,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 11.0; PRODUCT_BUNDLE_IDENTIFIER = jp.sone.BLEUnlock.Launcher; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -418,6 +592,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 11.0; PRODUCT_BUNDLE_IDENTIFIER = jp.sone.BLEUnlock.Launcher; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -478,7 +653,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -535,7 +710,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -555,6 +730,7 @@ COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 42LGPQYC7M; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_LOCATION = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = BLEUnlock/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -586,6 +762,7 @@ COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 42LGPQYC7M; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_LOCATION = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = BLEUnlock/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -605,6 +782,34 @@ }; name = Release; }; + 7E1000193000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + PRODUCT_BUNDLE_IDENTIFIER = jp.sone.BLEUnlockTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BLEUnlock.app/Contents/MacOS/BLEUnlock"; + }; + name = Debug; + }; + 7E1000203000000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + PRODUCT_BUNDLE_IDENTIFIER = jp.sone.BLEUnlockTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BLEUnlock.app/Contents/MacOS/BLEUnlock"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -635,6 +840,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7E1000173000000000000001 /* Build configuration list for PBXNativeTarget "BLEUnlockTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7E1000193000000000000001 /* Debug */, + 7E1000203000000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 3DD4B648226C1C3200451B7B /* Project object */; diff --git a/BLEUnlock.xcodeproj/xcshareddata/xcschemes/BLEUnlock.xcscheme b/BLEUnlock.xcodeproj/xcshareddata/xcschemes/BLEUnlock.xcscheme index 0227bea0..05f3c20d 100644 --- a/BLEUnlock.xcodeproj/xcshareddata/xcschemes/BLEUnlock.xcscheme +++ b/BLEUnlock.xcodeproj/xcshareddata/xcschemes/BLEUnlock.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + = self.unlockedAt + 10 { if self.ble.unlockRSSI != self.ble.UNLOCK_DISABLED { - self.runScript("intruded") + self.dispatchEvent("intruded") } self.playNowPlaying() } @@ -642,6 +677,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa if prefs.bool(forKey: "sleepDisplay") { item.state = .on } + + item = mainMenu.addItem(withTitle: t("telegram"), action: nil, keyEquivalent: "") + item.submenu = telegramMenuController.menu mainMenu.addItem(withTitle: t("set_password"), action: #selector(askPassword), keyEquivalent: "") diff --git a/BLEUnlock/BLEUnlock.entitlements b/BLEUnlock/BLEUnlock.entitlements index 49ad0bb0..5fa89308 100644 --- a/BLEUnlock/BLEUnlock.entitlements +++ b/BLEUnlock/BLEUnlock.entitlements @@ -4,5 +4,7 @@ com.apple.security.automation.apple-events + com.apple.security.personal-information.location + diff --git a/BLEUnlock/Base.lproj/InfoPlist.strings b/BLEUnlock/Base.lproj/InfoPlist.strings new file mode 100644 index 00000000..17230c17 --- /dev/null +++ b/BLEUnlock/Base.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "BLEUnlock uses the system default camera to photograph a manual unlock and upload the alert to Telegram."; +"NSLocationUsageDescription" = "BLEUnlock gets this Mac's location at capture time and attaches the coordinates and map to the Telegram security notification."; diff --git a/BLEUnlock/Base.lproj/Localizable.strings b/BLEUnlock/Base.lproj/Localizable.strings index 4e75488c..f7daba83 100644 --- a/BLEUnlock/Base.lproj/Localizable.strings +++ b/BLEUnlock/Base.lproj/Localizable.strings @@ -36,3 +36,51 @@ "use_screensaver_to_lock" = "Use Screensaver to Lock"; "wake_on_proximity" = "Wake on Proximity"; "wake_without_unlocking" = "Wake without Unlocking"; + +"telegram" = "Telegram Notifications"; +"telegram_enable" = "Enable Telegram"; +"telegram_configure" = "Configure…"; +"telegram_test" = "Send Test Notification"; +"telegram_events" = "Events"; +"telegram_event_away" = "Device is away"; +"telegram_event_lost" = "Signal is lost"; +"telegram_event_unlocked" = "Unlocked by BLEUnlock"; +"telegram_event_intruded" = "Unlocked manually"; +"telegram_take_photo" = "Attach a photo for manual unlock"; +"telegram_attach_mac_location" = "Attach Mac Location"; +"telegram_status_not_configured" = "Not configured"; +"telegram_status_enabled" = "Enabled"; +"telegram_status_disabled" = "Disabled"; +"telegram_bot_token" = "Bot token"; +"telegram_chat_id" = "Chat ID"; +"telegram_save" = "Save"; +"telegram_setup_help" = "Create a bot with @BotFather and copy its token. Send the bot a message, then open getUpdates in the Telegram Bot API and copy the numeric Chat ID from the response."; +"telegram_test_success" = "Test notification sent."; +"telegram_test_failed" = "Could not send the test notification."; +"telegram_camera_privacy" = "BLEUnlock uses the system default camera and uploads one photo to Telegram. The temporary photo is deleted after the attempt; if capture fails, BLEUnlock sends text instead."; +"telegram_error_not_configured" = "Configure a bot token and Chat ID first."; +"telegram_camera_error_denied" = "Camera access was denied."; +"telegram_camera_error_restricted" = "Camera access is restricted."; +"telegram_camera_error_no_camera" = "No camera is available."; +"telegram_camera_error_setup_failed" = "The camera could not be configured."; +"telegram_camera_error_capture_failed" = "The camera could not capture a photo."; +"telegram_camera_error_timeout" = "The camera capture timed out."; +"telegram_camera_error_file_write_failed" = "The captured photo could not be saved."; +"telegram_error_invalid_request" = "The Telegram request could not be created."; +"telegram_error_unreadable_photo" = "The captured photo could not be read."; +"telegram_error_transport" = "Telegram could not be reached."; +"telegram_error_http_status" = "Telegram returned HTTP status %d."; +"telegram_error_rejected" = "Telegram rejected the request."; +"telegram_error_invalid_response" = "Telegram returned an invalid response."; +"telegram_error_settings_unavailable" = "Telegram settings could not be read."; +"telegram_error_file_cleanup" = "The captured photo could not be deleted."; +"telegram_error_keychain_status" = "Keychain operation failed (%d)."; +"telegram_failure_notification_subtitle" = "Telegram notification failed"; +"telegram_message_time" = "Time"; +"telegram_message_rssi" = "RSSI"; +"telegram_message_coordinates" = "GPS Coordinates"; +"telegram_message_accuracy" = "Location Accuracy"; +"telegram_message_map" = "Map"; +"telegram_location_unavailable" = "The location at capture time is unavailable."; +"telegram_location_error" = "The Mac location could not be obtained."; +"telegram_location_send_error" = "The photo was sent, but the Telegram map location could not be sent."; diff --git a/BLEUnlock/CameraCapture.swift b/BLEUnlock/CameraCapture.swift new file mode 100644 index 00000000..156d80d4 --- /dev/null +++ b/BLEUnlock/CameraCapture.swift @@ -0,0 +1,567 @@ +import AVFoundation +import Foundation + +protocol PhotoCapturing { + func capture(completion: @escaping (Result) -> Void) +} + +enum CameraCaptureError: LocalizedError, Equatable { + case denied + case restricted + case noCamera + case setupFailed + case captureFailed + case timeout + case fileWriteFailed + + var errorDescription: String? { + switch self { + case .denied: + return t("telegram_camera_error_denied") + case .restricted: + return t("telegram_camera_error_restricted") + case .noCamera: + return t("telegram_camera_error_no_camera") + case .setupFailed: + return t("telegram_camera_error_setup_failed") + case .captureFailed: + return t("telegram_camera_error_capture_failed") + case .timeout: + return t("telegram_camera_error_timeout") + case .fileWriteFailed: + return t("telegram_camera_error_file_write_failed") + } + } +} + +protocol CameraAuthorizationProviding { + var authorizationStatus: AVAuthorizationStatus { get } + func requestAccess(completion: @escaping (Bool) -> Void) +} + +protocol PhotoSessionProviding { + func captureJPEG(completion: @escaping (Result) -> Void) + func stop() +} + +protocol CaptureSessionRunning: AnyObject { + var isRunning: Bool { get } + func startRunning() + func stopRunning() +} + +protocol PhotoOutputCapturing: AnyObject { + func capturePhoto(with settings: AVCapturePhotoSettings, + delegate: AVCapturePhotoCaptureDelegate) +} + +extension AVCaptureSession: CaptureSessionRunning {} +extension AVCapturePhotoOutput: PhotoOutputCapturing {} + +protocol ScheduledCancellation { + func cancel() +} + +protocol CameraScheduling { + @discardableResult + func schedule(after interval: TimeInterval, + _ block: @escaping () -> Void) -> ScheduledCancellation +} + +protocol CameraTeardownScheduling { + func schedule(_ block: @escaping () -> Void) +} + +protocol AbandonablePhotoCaptureDelegate: AnyObject { + func abandon() +} + +final class PhotoSessionLifecycle { + private let stateLock = NSLock() + private let retirementScheduler: CameraScheduling + private let retirementTimeout: TimeInterval + private var cancelled = false + private var pendingCompletion: ((Result) -> Void)? + private var activeDelegates: [ObjectIdentifier: AbandonablePhotoCaptureDelegate] = [:] + private var retiredDelegates: [ObjectIdentifier: AbandonablePhotoCaptureDelegate] = [:] + private var retirementCancellation: ScheduledCancellation? + private var retirementGeneration = 0 + + init(retirementScheduler: CameraScheduling = DispatchCameraScheduler(), + retirementTimeout: TimeInterval = 30) { + self.retirementScheduler = retirementScheduler + self.retirementTimeout = retirementTimeout + } + + func prepare(completion: @escaping (Result) -> Void) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + guard !cancelled else { return false } + pendingCompletion = completion + return true + } + + func takePreparedCompletion() -> ((Result) -> Void)? { + stateLock.lock() + defer { stateLock.unlock() } + guard !cancelled else { return nil } + let completion = pendingCompletion + pendingCompletion = nil + return completion + } + + func install(delegate: AbandonablePhotoCaptureDelegate) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + guard !cancelled, + activeDelegates.isEmpty, + retiredDelegates.isEmpty else { return false } + activeDelegates[ObjectIdentifier(delegate)] = delegate + return true + } + + @discardableResult + func performIfActive(_ action: () -> Void) -> Bool { + stateLock.lock() + let isActive = !cancelled + stateLock.unlock() + guard isActive else { return false } + + action() + return true + } + + func cancel() { + stateLock.lock() + guard !cancelled else { + stateLock.unlock() + return + } + cancelled = true + pendingCompletion = nil + let delegates = Array(activeDelegates.values) + retiredDelegates = activeDelegates + activeDelegates.removeAll() + retirementGeneration += 1 + let generation = retirementGeneration + let shouldScheduleRetirement = !retiredDelegates.isEmpty + stateLock.unlock() + + delegates.forEach { $0.abandon() } + if shouldScheduleRetirement { + scheduleRetiredDelegateRelease(generation: generation) + } + } + + func didFinishCallback(for delegate: AbandonablePhotoCaptureDelegate) { + stateLock.lock() + let identifier = ObjectIdentifier(delegate) + activeDelegates[identifier] = nil + retiredDelegates[identifier] = nil + let cancellation: ScheduledCancellation? + if retiredDelegates.isEmpty { + retirementGeneration += 1 + cancellation = retirementCancellation + retirementCancellation = nil + } else { + cancellation = nil + } + stateLock.unlock() + cancellation?.cancel() + } + + func teardownDidFinish() { + releaseAllRetiredDelegates() + } + + private func scheduleRetiredDelegateRelease(generation: Int) { + let cancellation = retirementScheduler.schedule(after: retirementTimeout) { [weak self] in + self?.releaseRetiredDelegates(generation: generation) + } + + stateLock.lock() + let shouldKeepCancellation = retirementGeneration == generation + && !retiredDelegates.isEmpty + && retirementCancellation == nil + if shouldKeepCancellation { + retirementCancellation = cancellation + } + stateLock.unlock() + + if !shouldKeepCancellation { + cancellation.cancel() + } + } + + private func releaseRetiredDelegates(generation: Int) { + stateLock.lock() + guard retirementGeneration == generation else { + stateLock.unlock() + return + } + retiredDelegates.removeAll() + retirementGeneration += 1 + let cancellation = retirementCancellation + retirementCancellation = nil + stateLock.unlock() + cancellation?.cancel() + } + + private func releaseAllRetiredDelegates() { + stateLock.lock() + retiredDelegates.removeAll() + retirementGeneration += 1 + let cancellation = retirementCancellation + retirementCancellation = nil + stateLock.unlock() + cancellation?.cancel() + } +} + +struct CameraWarmup { + let scheduler: CameraScheduling + let interval: TimeInterval + + @discardableResult + func schedule(lifecycle: PhotoSessionLifecycle, + _ action: @escaping () -> Void) -> ScheduledCancellation { + scheduler.schedule(after: interval) { + _ = lifecycle.performIfActive(action) + } + } +} + +final class CameraCapture: PhotoCapturing { + private let authorization: CameraAuthorizationProviding + private let sessionFactory: () -> Result + private let temporaryDirectory: URL + private let scheduler: CameraScheduling + private let teardownScheduler: CameraTeardownScheduling + private let timeout: TimeInterval + + init(authorization: CameraAuthorizationProviding = AVCameraAuthorization(), + sessionFactory: @escaping () -> Result = AVPhotoSession.make, + temporaryDirectory: URL = FileManager.default.temporaryDirectory, + scheduler: CameraScheduling = DispatchCameraScheduler(), + teardownScheduler: CameraTeardownScheduling = DispatchCameraTeardownScheduler(), + timeout: TimeInterval = 10) { + self.authorization = authorization + self.sessionFactory = sessionFactory + self.temporaryDirectory = temporaryDirectory + self.scheduler = scheduler + self.teardownScheduler = teardownScheduler + self.timeout = timeout + } + + func capture(completion: @escaping (Result) -> Void) { + switch authorization.authorizationStatus { + case .authorized: + captureAuthorized(completion: completion) + case .notDetermined: + authorization.requestAccess { granted in + guard granted else { + completion(.failure(.denied)) + return + } + self.captureAuthorized(completion: completion) + } + case .denied: + completion(.failure(.denied)) + case .restricted: + completion(.failure(.restricted)) + @unknown default: + completion(.failure(.restricted)) + } + } + + private func captureAuthorized( + completion: @escaping (Result) -> Void + ) { + let session: PhotoSessionProviding + switch sessionFactory() { + case .success(let createdSession): + session = createdSession + case .failure(let error): + completion(.failure(error)) + return + } + + CameraCaptureAttempt(session: session, + temporaryDirectory: temporaryDirectory, + teardownScheduler: teardownScheduler, + completion: completion) + .start(scheduler: scheduler, timeout: timeout) + } +} + +private final class CameraCaptureAttempt { + private let lock = NSLock() + private let temporaryDirectory: URL + private let teardownScheduler: CameraTeardownScheduling + private var session: PhotoSessionProviding? + private var completion: ((Result) -> Void)? + private var timeoutCancellation: ScheduledCancellation? + private var didFinish = false + + init(session: PhotoSessionProviding, + temporaryDirectory: URL, + teardownScheduler: CameraTeardownScheduling, + completion: @escaping (Result) -> Void) { + self.session = session + self.temporaryDirectory = temporaryDirectory + self.teardownScheduler = teardownScheduler + self.completion = completion + } + + func start(scheduler: CameraScheduling, timeout: TimeInterval) { + let cancellation = scheduler.schedule(after: timeout) { [self] in + finish(.failure(.timeout)) + } + + lock.lock() + let activeSession: PhotoSessionProviding? + if didFinish { + activeSession = nil + } else { + timeoutCancellation = cancellation + activeSession = session + } + lock.unlock() + + guard let activeSession = activeSession else { + cancellation.cancel() + return + } + activeSession.captureJPEG { [weak self] result in + self?.finish(result) + } + } + + private func finish(_ result: Result) { + lock.lock() + guard !didFinish, + let session = session, + let completion = completion else { + lock.unlock() + return + } + didFinish = true + self.session = nil + self.completion = nil + let cancellation = timeoutCancellation + timeoutCancellation = nil + lock.unlock() + + cancellation?.cancel() + + let finalResult: Result + switch result { + case .success(let jpeg): + let url = temporaryDirectory + .appendingPathComponent("BLEUnlock-intruded-\(UUID().uuidString)") + .appendingPathExtension("jpg") + do { + try jpeg.write(to: url, options: .atomic) + finalResult = .success(url) + } catch { + finalResult = .failure(.fileWriteFailed) + } + case .failure(let error): + finalResult = .failure(error) + } + + completion(finalResult) + teardownScheduler.schedule { + session.stop() + } + } +} + +final class AVCameraAuthorization: CameraAuthorizationProviding { + var authorizationStatus: AVAuthorizationStatus { + AVCaptureDevice.authorizationStatus(for: .video) + } + + func requestAccess(completion: @escaping (Bool) -> Void) { + AVCaptureDevice.requestAccess(for: .video, completionHandler: completion) + } +} + +private final class DispatchScheduledCancellation: ScheduledCancellation { + private let lock = NSLock() + private var workItem: DispatchWorkItem? + + init(workItem: DispatchWorkItem) { + self.workItem = workItem + } + + func cancel() { + lock.lock() + let item = workItem + workItem = nil + lock.unlock() + item?.cancel() + } +} + +final class DispatchCameraScheduler: CameraScheduling { + private let queue: DispatchQueue + + init(queue: DispatchQueue = DispatchQueue.global(qos: .utility)) { + self.queue = queue + } + + @discardableResult + func schedule(after interval: TimeInterval, + _ block: @escaping () -> Void) -> ScheduledCancellation { + let workItem = DispatchWorkItem(block: block) + queue.asyncAfter(deadline: .now() + interval, execute: workItem) + return DispatchScheduledCancellation(workItem: workItem) + } +} + +final class DispatchCameraTeardownScheduler: CameraTeardownScheduling { + private let queue: DispatchQueue + + init(queue: DispatchQueue = DispatchQueue.global(qos: .utility)) { + self.queue = queue + } + + func schedule(_ block: @escaping () -> Void) { + queue.async(execute: block) + } +} + +final class AVPhotoSession: PhotoSessionProviding { + private let session: CaptureSessionRunning + private let output: PhotoOutputCapturing + private let queue: DispatchQueue + private let warmup: CameraWarmup + private let lifecycle = PhotoSessionLifecycle() + + init(session: CaptureSessionRunning, + output: PhotoOutputCapturing, + queue: DispatchQueue = DispatchQueue( + label: "jp.sone.BLEUnlock.camera-capture" + ), + warmupScheduler: CameraScheduling? = nil, + warmupInterval: TimeInterval = 1) { + self.session = session + self.output = output + self.queue = queue + self.warmup = CameraWarmup( + scheduler: warmupScheduler ?? DispatchCameraScheduler(queue: queue), + interval: warmupInterval + ) + } + + static func make() -> Result { + guard let camera = AVCaptureDevice.default(for: .video) else { + return .failure(.noCamera) + } + + let input: AVCaptureDeviceInput + do { + input = try AVCaptureDeviceInput(device: camera) + } catch { + return .failure(.setupFailed) + } + + let session = AVCaptureSession() + let output = AVCapturePhotoOutput() + session.beginConfiguration() + defer { session.commitConfiguration() } + + guard session.canAddInput(input), session.canAddOutput(output) else { + return .failure(.setupFailed) + } + session.addInput(input) + session.addOutput(output) + return .success(AVPhotoSession(session: session, output: output)) + } + + func captureJPEG(completion: @escaping (Result) -> Void) { + guard lifecycle.prepare(completion: completion) else { return } + queue.async { [session, output, lifecycle] in + guard lifecycle.performIfActive({ session.startRunning() }) else { + return + } + self.warmup.schedule(lifecycle: lifecycle) { [output, lifecycle] in + guard let completion = lifecycle.takePreparedCompletion() else { return } + let proxy = AVPhotoCaptureDelegateProxy(completion: completion) { [weak lifecycle] proxy in + lifecycle?.didFinishCallback(for: proxy) + } + guard lifecycle.install(delegate: proxy) else { + proxy.abandon() + return + } + guard lifecycle.performIfActive({ + output.capturePhoto(with: AVCapturePhotoSettings(), delegate: proxy) + }) else { + proxy.abandon() + lifecycle.didFinishCallback(for: proxy) + return + } + } + } + } + + func stop() { + lifecycle.cancel() + queue.async { [session, lifecycle] in + defer { lifecycle.teardownDidFinish() } + if session.isRunning { + session.stopRunning() + } + } + } +} + +private final class AVPhotoCaptureDelegateProxy: NSObject, + AVCapturePhotoCaptureDelegate, + AbandonablePhotoCaptureDelegate { + private let lock = NSLock() + private var didComplete = false + private var completion: ((Result) -> Void)? + private let callbackFinished: (AVPhotoCaptureDelegateProxy) -> Void + + init(completion: @escaping (Result) -> Void, + callbackFinished: @escaping (AVPhotoCaptureDelegateProxy) -> Void) { + self.completion = completion + self.callbackFinished = callbackFinished + } + + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + let result: Result + if error != nil { + result = .failure(.captureFailed) + } else if let data = photo.fileDataRepresentation() { + result = .success(data) + } else { + result = .failure(.captureFailed) + } + finish(result) + } + + private func finish(_ result: Result) { + lock.lock() + guard !didComplete else { + lock.unlock() + return + } + didComplete = true + let completion = self.completion + self.completion = nil + lock.unlock() + completion?(result) + callbackFinished(self) + } + + func abandon() { + lock.lock() + completion = nil + lock.unlock() + } +} diff --git a/BLEUnlock/Info.plist b/BLEUnlock/Info.plist index a8713454..e43f7f3d 100644 --- a/BLEUnlock/Info.plist +++ b/BLEUnlock/Info.plist @@ -26,8 +26,12 @@ $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription BLEUnlock uses Bluetooth to detect devices. + NSCameraUsageDescription + BLEUnlock uses the system default camera to photograph a manual unlock and upload the alert to Telegram. NSHumanReadableCopyright Copyright © 2019-2022 Takeshi Sone. MIT Licensed. + NSLocationUsageDescription + BLEUnlock gets this Mac's location at capture time and attaches the coordinates and map to the Telegram security notification. NSMainNibFile MainMenu NSPrincipalClass diff --git a/BLEUnlock/KeychainStore.swift b/BLEUnlock/KeychainStore.swift new file mode 100644 index 00000000..08ffeab0 --- /dev/null +++ b/BLEUnlock/KeychainStore.swift @@ -0,0 +1,72 @@ +import Foundation +import Security + +enum KeychainStoreError: LocalizedError { + case status(OSStatus) + + var errorDescription: String? { + String(format: t("telegram_error_keychain_status"), statusCode) + } + + private var statusCode: OSStatus { + switch self { + case .status(let status): return status + } + } +} + +final class KeychainStore: SecretStoring { + let service: String + let accessGroup: String? + + init(service: String, accessGroup: String? = nil) { + self.service = service + self.accessGroup = accessGroup + } + + private func query(for account: String) -> [CFString: Any] { + var result: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account + ] + if let accessGroup = accessGroup { + result[kSecAttrAccessGroup] = accessGroup + } + return result + } + + func string(for account: String) throws -> String? { + var query = query(for: account) + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8) else { + throw KeychainStoreError.status(status) + } + return value + } + + func set(_ value: String, for account: String) throws { + let data = Data(value.utf8) + let status = SecItemUpdate(query(for: account) as CFDictionary, + [kSecValueData: data] as CFDictionary) + if status == errSecSuccess { return } + guard status == errSecItemNotFound else { throw KeychainStoreError.status(status) } + var attributes = query(for: account) + attributes[kSecValueData] = data + let addStatus = SecItemAdd(attributes as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw KeychainStoreError.status(addStatus) } + } + + func removeValue(for account: String) throws { + let status = SecItemDelete(query(for: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainStoreError.status(status) + } + } +} diff --git a/BLEUnlock/MacLocationProvider.swift b/BLEUnlock/MacLocationProvider.swift new file mode 100644 index 00000000..2c5c5bcd --- /dev/null +++ b/BLEUnlock/MacLocationProvider.swift @@ -0,0 +1,255 @@ +import CoreLocation +import Foundation + +struct TelegramLocation: Equatable { + let latitude: Double + let longitude: Double + let horizontalAccuracy: CLLocationAccuracy + let timestamp: Date +} + +enum MacLocationError: Error, Equatable { + case servicesDisabled + case denied + case restricted + case unavailable + case invalidLocation + case timeout + case cancelled +} + +protocol LocationRequestCancelling: AnyObject { + func cancel() +} + +protocol LocationAuthorizationRequesting { + func requestAuthorization() +} + +protocol MacLocationProviding { + @discardableResult + func requestLocation( + capturedAt: Date, + completion: @escaping (Result) -> Void + ) -> LocationRequestCancelling +} + +protocol CoreLocationClient: AnyObject { + var authorizationStatus: CLAuthorizationStatus { get } + var onAuthorizationChange: ((CLAuthorizationStatus) -> Void)? { get set } + var onLocations: (([CLLocation]) -> Void)? { get set } + var onError: ((Error) -> Void)? { get set } + func requestWhenInUseAuthorization() + func requestLocation() + func stop() +} + +final class CLLocationClient: NSObject, CoreLocationClient, CLLocationManagerDelegate { + private let manager = CLLocationManager() + var onAuthorizationChange: ((CLAuthorizationStatus) -> Void)? + var onLocations: (([CLLocation]) -> Void)? + var onError: ((Error) -> Void)? + var authorizationStatus: CLAuthorizationStatus { manager.authorizationStatus } + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + func requestWhenInUseAuthorization() { manager.requestWhenInUseAuthorization() } + func requestLocation() { manager.requestLocation() } + func stop() { manager.stopUpdatingLocation() } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + onAuthorizationChange?(manager.authorizationStatus) + } + + func locationManager(_ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation]) { + onLocations?(locations) + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + onError?(error) + } +} + +final class CoreMacLocationProvider: MacLocationProviding, LocationAuthorizationRequesting { + typealias ClientFactory = () -> CoreLocationClient + typealias TimeoutScheduler = (TimeInterval, @escaping () -> Void) -> () -> Void + + private let makeClient: ClientFactory + private let servicesEnabled: () -> Bool + private let scheduleTimeout: TimeoutScheduler + private var authorizationClient: CoreLocationClient? + private var activeRequests: [CoreMacLocationRequest] = [] + + init(makeClient: @escaping ClientFactory = CLLocationClient.init, + servicesEnabled: @escaping () -> Bool = CLLocationManager.locationServicesEnabled, + scheduleTimeout: @escaping TimeoutScheduler = CoreMacLocationProvider.dispatchTimeout) { + self.makeClient = makeClient + self.servicesEnabled = servicesEnabled + self.scheduleTimeout = scheduleTimeout + } + + func requestAuthorization() { + DispatchQueue.main.async { + guard self.servicesEnabled() else { return } + let client = self.makeClient() + self.authorizationClient = client + if client.authorizationStatus == .notDetermined { + client.requestWhenInUseAuthorization() + } + } + } + + @discardableResult + func requestLocation( + capturedAt: Date, + completion: @escaping (Result) -> Void + ) -> LocationRequestCancelling { + let request = CoreMacLocationRequest(makeClient: makeClient, + servicesEnabled: servicesEnabled, + scheduleTimeout: scheduleTimeout, + capturedAt: capturedAt, + completion: completion) + request.onFinish = { [weak self, weak request] in + guard let request = request else { return } + self?.release(request) + } + if Thread.isMainThread { + retainAndStart(request) + } else { + DispatchQueue.main.async { + self.retainAndStart(request) + } + } + return request + } + + private func retainAndStart(_ request: CoreMacLocationRequest) { + dispatchPrecondition(condition: .onQueue(.main)) + activeRequests.append(request) + request.start() + } + + private func release(_ request: CoreMacLocationRequest) { + dispatchPrecondition(condition: .onQueue(.main)) + activeRequests.removeAll { $0 === request } + } + + static func dispatchTimeout(after interval: TimeInterval, + action: @escaping () -> Void) -> () -> Void { + let item = DispatchWorkItem(block: action) + DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: item) + return { item.cancel() } + } +} + +private final class CoreMacLocationRequest: LocationRequestCancelling { + private let makeClient: () -> CoreLocationClient + private let servicesEnabled: () -> Bool + private let scheduleTimeout: CoreMacLocationProvider.TimeoutScheduler + private let capturedAt: Date + private var completion: ((Result) -> Void)? + private var client: CoreLocationClient? + private var cancelTimeout: (() -> Void)? + private var didRequestAuthorization = false + private var didRequestLocation = false + private var didFinish = false + var onFinish: (() -> Void)? + + init(makeClient: @escaping () -> CoreLocationClient, + servicesEnabled: @escaping () -> Bool, + scheduleTimeout: @escaping CoreMacLocationProvider.TimeoutScheduler, + capturedAt: Date, + completion: @escaping (Result) -> Void) { + self.makeClient = makeClient + self.servicesEnabled = servicesEnabled + self.scheduleTimeout = scheduleTimeout + self.capturedAt = capturedAt + self.completion = completion + } + + func start() { + if Thread.isMainThread { startOnMain() } + else { DispatchQueue.main.async { self.startOnMain() } } + } + + func cancel() { + if Thread.isMainThread { finish(.failure(.cancelled)) } + else { DispatchQueue.main.async { self.finish(.failure(.cancelled)) } } + } + + private func startOnMain() { + dispatchPrecondition(condition: .onQueue(.main)) + guard !didFinish else { return } + guard servicesEnabled() else { + finish(.failure(.servicesDisabled)) + return + } + let client = makeClient() + self.client = client + client.onAuthorizationChange = { [weak self] in self?.handleAuthorization($0) } + client.onLocations = { [weak self] in self?.accept($0) } + client.onError = { [weak self] _ in self?.finish(.failure(.unavailable)) } + cancelTimeout = scheduleTimeout(5) { [weak self] in + self?.finish(.failure(.timeout)) + } + handleAuthorization(client.authorizationStatus) + } + + private func handleAuthorization(_ status: CLAuthorizationStatus) { + guard !didFinish, let client = client else { return } + switch status { + case .authorizedAlways: + guard !didRequestLocation else { return } + didRequestLocation = true + client.requestLocation() + case .denied: + finish(.failure(.denied)) + case .restricted: + finish(.failure(.restricted)) + case .notDetermined: + guard !didRequestAuthorization else { return } + didRequestAuthorization = true + client.requestWhenInUseAuthorization() + @unknown default: + finish(.failure(.unavailable)) + } + } + + private func accept(_ locations: [CLLocation]) { + guard !didFinish else { return } + guard let value = locations.reversed().first(where: { location in + CLLocationCoordinate2DIsValid(location.coordinate) && + location.horizontalAccuracy >= 0 && + abs(location.timestamp.timeIntervalSince(capturedAt)) <= 60 + }) else { + finish(.failure(.invalidLocation)) + return + } + finish(.success(.init(latitude: value.coordinate.latitude, + longitude: value.coordinate.longitude, + horizontalAccuracy: value.horizontalAccuracy, + timestamp: value.timestamp))) + } + + private func finish(_ result: Result) { + dispatchPrecondition(condition: .onQueue(.main)) + guard !didFinish, let completion = completion else { return } + didFinish = true + self.completion = nil + cancelTimeout?() + cancelTimeout = nil + client?.stop() + client?.onAuthorizationChange = nil + client?.onLocations = nil + client?.onError = nil + client = nil + onFinish?() + onFinish = nil + completion(result) + } +} diff --git a/BLEUnlock/PhotoLocationCoordinator.swift b/BLEUnlock/PhotoLocationCoordinator.swift new file mode 100644 index 00000000..3de6ba5a --- /dev/null +++ b/BLEUnlock/PhotoLocationCoordinator.swift @@ -0,0 +1,84 @@ +import Foundation + +enum PhotoLocationOutcome: Equatable { + case photo(URL, Result) + case cameraFailure(CameraCaptureError) +} + +final class PhotoLocationCoordinator { + private let camera: PhotoCapturing + private let location: MacLocationProviding + private let lock = NSLock() + private var photo: Result? + private var position: Result? + private var locationToken: LocationRequestCancelling? + private var didComplete = false + + init(camera: PhotoCapturing, location: MacLocationProviding) { + self.camera = camera + self.location = location + } + + func capture(capturedAt: Date, + completion: @escaping (PhotoLocationOutcome) -> Void) { + let token = location.requestLocation(capturedAt: capturedAt) { [weak self] result in + self?.record(location: result, completion: completion) + } + lock.lock() + locationToken = token + let cancelImmediately = didComplete + lock.unlock() + if cancelImmediately { + token.cancel() + } + + camera.capture { [weak self] result in + self?.record(photo: result, completion: completion) + } + } + + private func record(photo result: Result, + completion: @escaping (PhotoLocationOutcome) -> Void) { + var action: (() -> Void)? + lock.lock() + if !didComplete, photo == nil { + photo = result + switch result { + case .failure(let error): + didComplete = true + let token = locationToken + locationToken = nil + action = { + token?.cancel() + completion(.cameraFailure(error)) + } + case .success: + action = finishPhotoIfReady(completion: completion) + } + } + lock.unlock() + action?() + } + + private func record(location result: Result, + completion: @escaping (PhotoLocationOutcome) -> Void) { + var action: (() -> Void)? + lock.lock() + if !didComplete, position == nil { + position = result + action = finishPhotoIfReady(completion: completion) + } + lock.unlock() + action?() + } + + private func finishPhotoIfReady( + completion: @escaping (PhotoLocationOutcome) -> Void + ) -> (() -> Void)? { + guard case .success(let url)? = photo, + let position = position else { return nil } + didComplete = true + locationToken = nil + return { completion(.photo(url, position)) } + } +} diff --git a/BLEUnlock/TelegramEvent.swift b/BLEUnlock/TelegramEvent.swift new file mode 100644 index 00000000..3cc09c1f --- /dev/null +++ b/BLEUnlock/TelegramEvent.swift @@ -0,0 +1,20 @@ +import Foundation + +enum TelegramEvent: String, CaseIterable { + case away, lost, unlocked, intruded + + var defaultEnabled: Bool { self != .unlocked } + var defaultsKey: String { "telegram.event.\(rawValue)" } +} + +struct TelegramEventContext: Equatable { + let event: TelegramEvent + let hostName: String + let timestamp: Date + let rssi: Int? +} + +struct TelegramCredentials: Equatable { + let token: String + let chatID: String +} diff --git a/BLEUnlock/TelegramMenuController.swift b/BLEUnlock/TelegramMenuController.swift new file mode 100644 index 00000000..4454f70f --- /dev/null +++ b/BLEUnlock/TelegramMenuController.swift @@ -0,0 +1,284 @@ +import AppKit +import Foundation + +protocol TelegramDialogPresenting { + func requestCredentials(hasStoredToken: Bool, + completion: (TelegramCredentialInput?) -> Void) + func showResult(title: String, message: String) +} + +struct TelegramCredentialInput { + let replacementToken: String? + let chatID: String +} + +final class TelegramMenuController: NSObject, NSMenuDelegate { + let menu = NSMenu() + let enableItem: NSMenuItem + let testItem: NSMenuItem + let statusItem: NSMenuItem + let eventItems: [TelegramEvent: NSMenuItem] + let photoItem: NSMenuItem + let privacyItem: NSMenuItem + let locationItem: NSMenuItem + + private let settings: TelegramSettings + private let service: TelegramNotificationHandling + private let dialogs: TelegramDialogPresenting + private let locationAuthorization: LocationAuthorizationRequesting + private let hostName: () -> String + private let serviceQueue = DispatchQueue(label: "jp.sone.BLEUnlock.telegram.menu", + qos: .utility) + + init(settings: TelegramSettings, + service: TelegramNotificationHandling, + dialogs: TelegramDialogPresenting, + locationAuthorization: LocationAuthorizationRequesting = CoreMacLocationProvider(), + hostName: @escaping () -> String = { Host.current().localizedName ?? "Mac" }) { + self.settings = settings + self.service = service + self.dialogs = dialogs + self.locationAuthorization = locationAuthorization + self.hostName = hostName + + enableItem = NSMenuItem(title: t("telegram_enable"), + action: #selector(toggleEnabled(_:)), + keyEquivalent: "") + let configureItem = NSMenuItem(title: t("telegram_configure"), + action: #selector(configure), + keyEquivalent: "") + testItem = NSMenuItem(title: t("telegram_test"), + action: #selector(sendTest), + keyEquivalent: "") + + let eventsMenu = NSMenu() + eventsMenu.autoenablesItems = false + var items: [TelegramEvent: NSMenuItem] = [:] + for event in TelegramEvent.allCases { + let item = NSMenuItem(title: t("telegram_event_\(event.rawValue)"), + action: #selector(toggleEvent(_:)), + keyEquivalent: "") + items[event] = item + eventsMenu.addItem(item) + } + eventItems = items + + let eventsItem = NSMenuItem(title: t("telegram_events"), + action: nil, + keyEquivalent: "") + eventsItem.submenu = eventsMenu + photoItem = NSMenuItem(title: t("telegram_take_photo"), + action: #selector(togglePhoto(_:)), + keyEquivalent: "") + privacyItem = NSMenuItem(title: t("telegram_camera_privacy"), + action: nil, + keyEquivalent: "") + privacyItem.isEnabled = false + locationItem = NSMenuItem(title: t("telegram_attach_mac_location"), + action: #selector(toggleLocation(_:)), + keyEquivalent: "") + statusItem = NSMenuItem(title: t("telegram_status_not_configured"), + action: nil, + keyEquivalent: "") + statusItem.isEnabled = false + + super.init() + + enableItem.target = self + configureItem.target = self + testItem.target = self + eventItems.values.forEach { $0.target = self } + photoItem.target = self + locationItem.target = self + + menu.autoenablesItems = false + menu.delegate = self + menu.addItem(enableItem) + menu.addItem(configureItem) + menu.addItem(testItem) + menu.addItem(.separator()) + menu.addItem(eventsItem) + menu.addItem(photoItem) + menu.addItem(privacyItem) + menu.addItem(locationItem) + menu.addItem(.separator()) + menu.addItem(statusItem) + } + + func menuWillOpen(_ menu: NSMenu) { + let configured = (try? settings.isConfigured()) == true + enableItem.isEnabled = configured + testItem.isEnabled = configured + enableItem.state = configured && settings.isEnabled ? .on : .off + for (event, item) in eventItems { + item.state = settings.isEventEnabled(event) ? .on : .off + } + photoItem.state = settings.takePhotoOnIntruded ? .on : .off + locationItem.state = settings.attachMacLocation ? .on : .off + locationItem.isEnabled = settings.takePhotoOnIntruded + + if !configured { + statusItem.title = t("telegram_status_not_configured") + } else if settings.isEnabled { + statusItem.title = t("telegram_status_enabled") + } else { + statusItem.title = t("telegram_status_disabled") + } + } + + @objc internal func toggleEnabled(_ item: NSMenuItem) { + guard (try? settings.isConfigured()) == true else { + menuWillOpen(menu) + return + } + settings.isEnabled.toggle() + menuWillOpen(menu) + } + + @objc internal func toggleEvent(_ item: NSMenuItem) { + guard let event = eventItems.first(where: { $0.value === item })?.key else { return } + settings.setEvent(event, enabled: !settings.isEventEnabled(event)) + item.state = settings.isEventEnabled(event) ? .on : .off + } + + @objc internal func togglePhoto(_ item: NSMenuItem) { + settings.takePhotoOnIntruded.toggle() + menuWillOpen(menu) + } + + @objc internal func toggleLocation(_ item: NSMenuItem) { + guard settings.takePhotoOnIntruded else { + menuWillOpen(menu) + return + } + settings.attachMacLocation.toggle() + if settings.attachMacLocation { + locationAuthorization.requestAuthorization() + } + menuWillOpen(menu) + } + + @objc internal func configure() { + let configured: Bool + do { + configured = try settings.isConfigured() + } catch { + dialogs.showResult(title: t("telegram_configure"), + message: t("telegram_error_settings_unavailable")) + return + } + + dialogs.requestCredentials(hasStoredToken: configured) { input in + guard let input = input else { return } + + do { + try self.settings.saveCredentials(replacementToken: input.replacementToken, + chatID: input.chatID) + guard try self.settings.isConfigured() else { + self.dialogs.showResult( + title: t("telegram_configure"), + message: t("telegram_error_not_configured") + ) + return + } + self.menuWillOpen(self.menu) + } catch { + self.dialogs.showResult(title: t("telegram_configure"), + message: error.localizedDescription) + } + } + } + + @objc internal func sendTest() { + let service = self.service + let resolvedHostName = hostName() + serviceQueue.async { [weak self] in + service.sendTest(hostName: resolvedHostName) { result in + self?.showTestResult(result) + } + } + } + + private func showTestResult(_ result: Result) { + onMain { [weak self] in + guard let self = self else { return } + switch result { + case .success: + self.dialogs.showResult(title: t("telegram_test_success"), message: "") + case .failure(let error): + self.dialogs.showResult(title: t("telegram_test_failed"), + message: error.localizedDescription) + } + } + } + + private func onMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } + } +} + +final class AppKitTelegramDialogPresenter: TelegramDialogPresenting { + func requestCredentials(hasStoredToken: Bool, + completion: (TelegramCredentialInput?) -> Void) { + precondition(Thread.isMainThread) + + let alert = NSAlert() + alert.window.title = "BLEUnlock" + alert.messageText = t("telegram_configure") + alert.addButton(withTitle: t("telegram_save")) + alert.addButton(withTitle: t("cancel")) + + let explanation = NSTextField(wrappingLabelWithString: t("telegram_setup_help")) + explanation.preferredMaxLayoutWidth = 360 + let privacy = NSTextField(wrappingLabelWithString: t("telegram_camera_privacy")) + privacy.preferredMaxLayoutWidth = 360 + + let tokenLabel = NSTextField(labelWithString: t("telegram_bot_token")) + let tokenField = NSSecureTextField() + tokenField.stringValue = "" + tokenField.placeholderString = hasStoredToken ? nil : t("telegram_bot_token") + + let chatIDLabel = NSTextField(labelWithString: t("telegram_chat_id")) + let chatIDField = NSTextField() + + let stack = NSStackView(views: [explanation, + privacy, + tokenLabel, + tokenField, + chatIDLabel, + chatIDField]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + stack.edgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + stack.frame = NSRect(x: 0, y: 0, width: 360, height: 190) + tokenField.widthAnchor.constraint(equalToConstant: 360).isActive = true + chatIDField.widthAnchor.constraint(equalToConstant: 360).isActive = true + alert.accessoryView = stack + + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { + completion(nil) + return + } + + let token = tokenField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + completion(.init(replacementToken: token.isEmpty ? nil : token, + chatID: chatIDField.stringValue)) + } + + func showResult(title: String, message: String) { + precondition(Thread.isMainThread) + let alert = NSAlert() + alert.window.title = "BLEUnlock" + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: t("ok")) + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } +} diff --git a/BLEUnlock/TelegramNotificationService.swift b/BLEUnlock/TelegramNotificationService.swift new file mode 100644 index 00000000..36ee4846 --- /dev/null +++ b/BLEUnlock/TelegramNotificationService.swift @@ -0,0 +1,391 @@ +import AppKit +import Foundation + +protocol TelegramNotificationHandling { + func handle(_ context: TelegramEventContext) + func sendTest(hostName: String, + completion: @escaping (Result) -> Void) +} + +protocol TelegramMessageFormatting { + func message(for context: TelegramEventContext) -> String + func photoCaption(for context: TelegramEventContext, + location: TelegramLocation?) -> String +} + +protocol FailureReporting { + func report(category: String, message: String) +} + +protocol FailureNotificationDelivering { + func deliver(message: String) +} + +protocol UserNotificationCenterDelivering { + func deliver(_ notification: NSUserNotification) +} + +extension NSUserNotificationCenter: UserNotificationCenterDelivering {} + +protocol MainThreadScheduling { + func perform(_ action: @escaping () -> Void) +} + +final class DispatchMainThreadScheduler: MainThreadScheduling { + func perform(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } + } +} + +final class UserNotificationFailureDelivery: FailureNotificationDelivering { + private let notificationCenter: UserNotificationCenterDelivering + private let scheduler: MainThreadScheduling + private let notificationFactory: () -> NSUserNotification + + init(notificationCenter: UserNotificationCenterDelivering = NSUserNotificationCenter.default, + scheduler: MainThreadScheduling = DispatchMainThreadScheduler(), + notificationFactory: @escaping () -> NSUserNotification = NSUserNotification.init) { + self.notificationCenter = notificationCenter + self.scheduler = scheduler + self.notificationFactory = notificationFactory + } + + func deliver(message: String) { + scheduler.perform { [notificationCenter, notificationFactory] in + let notification = notificationFactory() + notification.title = "BLEUnlock" + notification.subtitle = t("telegram_failure_notification_subtitle") + notification.informativeText = message + notificationCenter.deliver(notification) + } + } +} + +final class TelegramMessageFormatter: TelegramMessageFormatting { + func message(for context: TelegramEventContext) -> String { + let dateFormatter = DateFormatter() + dateFormatter.locale = .current + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .medium + + var lines = [ + "\(context.hostName) — \(localizedDescription(for: context.event))", + "\(t("telegram_message_time")): \(dateFormatter.string(from: context.timestamp))" + ] + if let rssi = context.rssi { + lines.append("\(t("telegram_message_rssi")): \(rssi) dBm") + } + return lines.joined(separator: "\n") + } + + func photoCaption(for context: TelegramEventContext, + location: TelegramLocation?) -> String { + var lines = [message(for: context)] + guard let location = location else { + lines.append(t("telegram_location_unavailable")) + return lines.joined(separator: "\n") + } + + let latitude = posixDecimal(location.latitude) + let longitude = posixDecimal(location.longitude) + lines.append("\(t("telegram_message_coordinates")): \(latitude), \(longitude)") + lines.append(String(format: "%@: ±%.0f m", + t("telegram_message_accuracy"), + location.horizontalAccuracy)) + lines.append("\(t("telegram_message_map")): https://maps.apple.com/?ll=\(latitude),\(longitude)") + return lines.joined(separator: "\n") + } + + private func posixDecimal(_ value: Double) -> String { + String(format: "%.6f", locale: Locale(identifier: "en_US_POSIX"), value) + } + + private func localizedDescription(for event: TelegramEvent) -> String { + let key: String + let fallback: String + switch event { + case .away: + key = "telegram_event_away" + fallback = "Device Away" + case .lost: + key = "telegram_event_lost" + fallback = "Signal Lost" + case .unlocked: + key = "telegram_event_unlocked" + fallback = "Unlocked by BLEUnlock" + case .intruded: + key = "telegram_event_intruded" + fallback = "Manually Unlocked" + } + return NSLocalizedString(key, value: fallback, comment: "Telegram event description") + } +} + +final class RateLimitedFailureReporter: FailureReporting { + private let now: () -> Date + private let interval: TimeInterval + private let notificationDelivery: FailureNotificationDelivering + private let lock = NSLock() + private var lastShown: [String: Date] = [:] + + convenience init(now: @escaping () -> Date = Date.init, + interval: TimeInterval = 300, + notificationCenter: NSUserNotificationCenter = .default) { + self.init(now: now, + interval: interval, + notificationDelivery: UserNotificationFailureDelivery( + notificationCenter: notificationCenter + )) + } + + init(now: @escaping () -> Date, + interval: TimeInterval, + notificationDelivery: FailureNotificationDelivering) { + self.now = now + self.interval = interval + self.notificationDelivery = notificationDelivery + } + + func report(category: String, message: String) { + let category = sanitizedCategory(category) + NSLog("BLEUnlock Telegram notification failure category: %@", category) + + let current = now() + lock.lock() + if let previous = lastShown[category], + current.timeIntervalSince(previous) < interval { + lock.unlock() + return + } + lastShown[category] = current + lock.unlock() + + notificationDelivery.deliver(message: message) + } + + private func sanitizedCategory(_ category: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + let sanitized = category.unicodeScalars + .map { allowed.contains($0) ? String($0) : "_" } + .joined() + return String((sanitized.isEmpty ? "unknown" : sanitized).prefix(64)) + } +} + +private enum TelegramNotificationServiceError: LocalizedError { + case notConfigured + case settingsUnavailable + + var errorDescription: String? { + switch self { + case .notConfigured: + return t("telegram_error_not_configured") + case .settingsUnavailable: + return t("telegram_error_settings_unavailable") + } + } +} + +final class TelegramNotificationService: TelegramNotificationHandling { + private let settings: TelegramSettings + private let sender: TelegramSending + private let camera: PhotoCapturing + private let location: MacLocationProviding + private let removeFile: (URL) throws -> Void + private let reporter: FailureReporting + private let formatter: TelegramMessageFormatting + + init(settings: TelegramSettings, + sender: TelegramSending, + camera: PhotoCapturing, + location: MacLocationProviding = CoreMacLocationProvider(), + removeFile: @escaping (URL) throws -> Void = { + try FileManager.default.removeItem(at: $0) + }, + reporter: FailureReporting, + formatter: TelegramMessageFormatting = TelegramMessageFormatter()) { + self.settings = settings + self.sender = sender + self.camera = camera + self.location = location + self.removeFile = removeFile + self.reporter = reporter + self.formatter = formatter + } + + func handle(_ context: TelegramEventContext) { + guard settings.isEnabled, settings.isEventEnabled(context.event) else { + return + } + + let credentials: TelegramCredentials + do { + guard let storedCredentials = try settings.credentials() else { return } + credentials = storedCredentials + } catch { + reporter.report(category: "settings", + message: t("telegram_error_settings_unavailable")) + return + } + + if context.event == .intruded && settings.takePhotoOnIntruded { + if settings.attachMacLocation { + sendLocatedPhotoOrFallback(credentials: credentials, + context: context, + completion: nil) + } else { + sendPhotoOrFallback(credentials: credentials, + message: formatter.message(for: context), + completion: nil) + } + } else { + sendText(credentials: credentials, + message: formatter.message(for: context), + completion: nil) + } + } + + func sendTest(hostName: String, + completion: @escaping (Result) -> Void) { + let credentials: TelegramCredentials + do { + guard let storedCredentials = try settings.credentials() else { + completion(.failure(TelegramNotificationServiceError.notConfigured)) + return + } + credentials = storedCredentials + } catch { + completion(.failure(TelegramNotificationServiceError.settingsUnavailable)) + return + } + + let context = TelegramEventContext(event: .intruded, + hostName: hostName, + timestamp: Date(), + rssi: nil) + if settings.takePhotoOnIntruded { + if settings.attachMacLocation { + sendLocatedPhotoOrFallback(credentials: credentials, + context: context, + completion: completion) + } else { + sendPhotoOrFallback(credentials: credentials, + message: formatter.message(for: context), + completion: completion) + } + } else { + sendText(credentials: credentials, + message: formatter.message(for: context), + completion: completion) + } + } + + private func sendLocatedPhotoOrFallback( + credentials: TelegramCredentials, + context: TelegramEventContext, + completion: ((Result) -> Void)? + ) { + let coordinator = PhotoLocationCoordinator(camera: camera, location: location) + coordinator.capture(capturedAt: context.timestamp) { [coordinator] outcome in + _ = coordinator + self.deliver(outcome, + credentials: credentials, + context: context, + completion: completion) + } + } + + private func deliver( + _ outcome: PhotoLocationOutcome, + credentials: TelegramCredentials, + context: TelegramEventContext, + completion: ((Result) -> Void)? + ) { + switch outcome { + case .cameraFailure(let error): + reporter.report(category: "camera", message: error.localizedDescription) + completion?(.failure(error)) + sendText(credentials: credentials, + message: formatter.message(for: context), + completion: nil) + case .photo(let photoURL, let positionResult): + let position = try? positionResult.get() + if case .failure = positionResult { + reporter.report(category: "location", message: t("telegram_location_error")) + } + let caption = formatter.photoCaption(for: context, location: position) + sender.sendPhoto(credentials: credentials, + photoURL: photoURL, + caption: caption) { [sender, removeFile, reporter] result in + do { + try removeFile(photoURL) + } catch { + reporter.report(category: "file", + message: t("telegram_error_file_cleanup")) + } + switch (result, position) { + case (.success, .some(let position)): + sender.sendLocation(credentials: credentials, + location: position) { mapResult in + if case .failure = mapResult { + reporter.report(category: "telegram-location", + message: t("telegram_location_send_error")) + } + completion?(mapResult.mapError { $0 as Error }) + } + default: + if case .failure(let error) = result { + reporter.report(category: "telegram", + message: error.localizedDescription) + } + completion?(result.mapError { $0 as Error }) + } + } + } + } + + private func sendPhotoOrFallback(credentials: TelegramCredentials, + message: String, + completion: ((Result) -> Void)?) { + camera.capture { [sender, removeFile, reporter] captureResult in + switch captureResult { + case .failure(let error): + reporter.report(category: "camera", message: error.localizedDescription) + completion?(.failure(error)) + self.sendText(credentials: credentials, + message: message, + completion: nil) + case .success(let photoURL): + sender.sendPhoto(credentials: credentials, + photoURL: photoURL, + caption: message) { result in + do { + try removeFile(photoURL) + } catch { + reporter.report(category: "file", + message: t("telegram_error_file_cleanup")) + } + if case .failure(let error) = result { + reporter.report(category: "telegram", message: error.localizedDescription) + } + completion?(result.mapError { $0 as Error }) + } + } + } + } + + private func sendText(credentials: TelegramCredentials, + message: String, + completion: ((Result) -> Void)?) { + sender.sendText(credentials: credentials, text: message) { [reporter] result in + if case .failure(let error) = result { + reporter.report(category: "telegram", message: error.localizedDescription) + } + completion?(result.mapError { $0 as Error }) + } + } +} diff --git a/BLEUnlock/TelegramNotifier.swift b/BLEUnlock/TelegramNotifier.swift new file mode 100644 index 00000000..9a5c96a4 --- /dev/null +++ b/BLEUnlock/TelegramNotifier.swift @@ -0,0 +1,243 @@ +import Foundation + +protocol HTTPTransport { + func perform(_ request: URLRequest, + completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) +} + +protocol TelegramSending { + func sendText(credentials: TelegramCredentials, + text: String, + completion: @escaping (Result) -> Void) + func sendPhoto(credentials: TelegramCredentials, + photoURL: URL, + caption: String, + completion: @escaping (Result) -> Void) + func sendLocation(credentials: TelegramCredentials, + location: TelegramLocation, + completion: @escaping (Result) -> Void) +} + +enum TelegramError: LocalizedError, Equatable { + case invalidRequest + case unreadablePhoto + case transport + case httpStatus(Int) + case rejected(String) + case invalidResponse + + var errorDescription: String? { + switch self { + case .invalidRequest: + return t("telegram_error_invalid_request") + case .unreadablePhoto: + return t("telegram_error_unreadable_photo") + case .transport: + return t("telegram_error_transport") + case .httpStatus(let code): + return String(format: t("telegram_error_http_status"), code) + case .rejected(let description): + return description + case .invalidResponse: + return t("telegram_error_invalid_response") + } + } +} + +final class TelegramNotifier: TelegramSending { + private let transport: HTTPTransport + + init(transport: HTTPTransport) { + self.transport = transport + } + + func sendText(credentials: TelegramCredentials, + text: String, + completion: @escaping (Result) -> Void) { + guard let request = makeTextRequest(credentials: credentials, text: text) else { + completion(.failure(.invalidRequest)) + return + } + perform(request, credentials: credentials, completion: completion) + } + + func sendPhoto(credentials: TelegramCredentials, + photoURL: URL, + caption: String, + completion: @escaping (Result) -> Void) { + guard let url = endpointURL(token: credentials.token, method: "sendPhoto") else { + completion(.failure(.invalidRequest)) + return + } + guard let data = try? Data(contentsOf: photoURL) else { + completion(.failure(.unreadablePhoto)) + return + } + let request = makePhotoRequest(credentials: credentials, + url: url, + photoURL: photoURL, + caption: caption, + data: data) + perform(request, credentials: credentials, completion: completion) + } + + func sendLocation(credentials: TelegramCredentials, + location: TelegramLocation, + completion: @escaping (Result) -> Void) { + guard let url = endpointURL(token: credentials.token, method: "sendLocation") else { + completion(.failure(.invalidRequest)) + return + } + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = formEncoded([ + ("chat_id", credentials.chatID), + ("latitude", String(location.latitude)), + ("longitude", String(location.longitude)) + ]) + perform(request, credentials: credentials, completion: completion) + } + + private func makeTextRequest(credentials: TelegramCredentials, text: String) -> URLRequest? { + guard let url = endpointURL(token: credentials.token, method: "sendMessage") else { + return nil + } + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = formEncoded([ + ("chat_id", credentials.chatID), + ("text", text) + ]) + return request + } + + private func makePhotoRequest(credentials: TelegramCredentials, + url: URL, + photoURL: URL, + caption: String, + data: Data) -> URLRequest { + let boundary = "BLEUnlock-\(UUID().uuidString)" + var body = Data() + body.appendMultipartField(name: "chat_id", value: credentials.chatID, boundary: boundary) + body.appendMultipartField(name: "caption", value: caption, boundary: boundary) + body.appendMultipartFile(name: "photo", + filename: photoURL.lastPathComponent, + mimeType: "image/jpeg", + bytes: data, + boundary: boundary) + body.append(Data("--\(boundary)--\r\n".utf8)) + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", + forHTTPHeaderField: "Content-Type") + request.httpBody = body + return request + } + + private func perform(_ request: URLRequest, + credentials: TelegramCredentials, + completion: @escaping (Result) -> Void) { + transport.perform(request) { result in + switch result { + case .failure: + completion(.failure(.transport)) + case .success(let (data, response)): + guard (200..<300).contains(response.statusCode) else { + completion(.failure(.httpStatus(response.statusCode))) + return + } + guard let decoded = try? JSONDecoder().decode(TelegramResponse.self, from: data) else { + completion(.failure(.invalidResponse)) + return + } + decoded.ok + ? completion(.success(())) + : completion(.failure(.rejected( + self.sanitized(decoded.description ?? t("telegram_error_rejected"), + credentials: credentials) + ))) + } + } + } + + private func endpointURL(token: String, method: String) -> URL? { + guard !token.isEmpty else { return nil } + return URL(string: "https://api.telegram.org/bot\(token)/\(method)") + } + + private func formEncoded(_ fields: [(String, String)]) -> Data { + let body = fields + .map { "\(formPercentEncoded($0.0))=\(formPercentEncoded($0.1))" } + .joined(separator: "&") + return Data(body.utf8) + } + + private func formPercentEncoded(_ value: String) -> String { + value.utf8.map { byte in + switch byte { + case 0x41...0x5A, 0x61...0x7A, 0x30...0x39, 0x2D, 0x2E, 0x5F, 0x7E: + return String(UnicodeScalar(byte)) + default: + return String(format: "%%%02X", byte) + } + }.joined() + } + + private func sanitized(_ description: String, + credentials: TelegramCredentials) -> String { + [credentials.token, credentials.chatID].reduce(description) { result, credential in + guard !credential.isEmpty else { return result } + return result.replacingOccurrences(of: credential, with: "[redacted]") + } + } +} + +private struct TelegramResponse: Decodable { + let ok: Bool + let description: String? +} + +private extension Data { + mutating func appendMultipartField(name: String, value: String, boundary: String) { + append(Data("--\(boundary)\r\n".utf8)) + append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) + append(Data("\(value)\r\n".utf8)) + } + + mutating func appendMultipartFile(name: String, + filename: String, + mimeType: String, + bytes: Data, + boundary: String) { + append(Data("--\(boundary)\r\n".utf8)) + append(Data("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".utf8)) + append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8)) + append(bytes) + append(Data("\r\n".utf8)) + } +} + +final class URLSessionTransport: HTTPTransport { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func perform(_ request: URLRequest, + completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) { + session.dataTask(with: request) { data, response, error in + if error != nil { + completion(.failure(TelegramError.transport)) + return + } + guard let data = data, let response = response as? HTTPURLResponse else { + completion(.failure(TelegramError.invalidResponse)) + return + } + completion(.success((data, response))) + }.resume() + } +} diff --git a/BLEUnlock/TelegramSettings.swift b/BLEUnlock/TelegramSettings.swift new file mode 100644 index 00000000..0b47ea42 --- /dev/null +++ b/BLEUnlock/TelegramSettings.swift @@ -0,0 +1,65 @@ +import Foundation + +protocol SecretStoring { + func string(for account: String) throws -> String? + func set(_ value: String, for account: String) throws + func removeValue(for account: String) throws +} + +final class TelegramSettings { + private enum Key { + static let enabled = "telegram.enabled" + static let takePhoto = "telegram.takePhotoOnIntruded" + static let attachMacLocation = "telegram.attachMacLocation" + static let token = "botToken" + static let chatID = "chatID" + } + + private let defaults: UserDefaults + private let secrets: SecretStoring + + init(defaults: UserDefaults = .standard, secrets: SecretStoring) { + self.defaults = defaults + self.secrets = secrets + } + + var isEnabled: Bool { + get { defaults.bool(forKey: Key.enabled) } + set { defaults.set(newValue, forKey: Key.enabled) } + } + + var takePhotoOnIntruded: Bool { + get { defaults.object(forKey: Key.takePhoto) as? Bool ?? true } + set { defaults.set(newValue, forKey: Key.takePhoto) } + } + + var attachMacLocation: Bool { + get { defaults.object(forKey: Key.attachMacLocation) as? Bool ?? false } + set { defaults.set(newValue, forKey: Key.attachMacLocation) } + } + + func isEventEnabled(_ event: TelegramEvent) -> Bool { + defaults.object(forKey: event.defaultsKey) as? Bool ?? event.defaultEnabled + } + + func setEvent(_ event: TelegramEvent, enabled: Bool) { + defaults.set(enabled, forKey: event.defaultsKey) + } + + func credentials() throws -> TelegramCredentials? { + guard let token = try secrets.string(for: Key.token), !token.isEmpty, + let chatID = try secrets.string(for: Key.chatID), !chatID.isEmpty else { return nil } + return TelegramCredentials(token: token, chatID: chatID) + } + + func isConfigured() throws -> Bool { try credentials() != nil } + + func saveCredentials(replacementToken: String?, chatID: String) throws { + let replacement = replacementToken? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let replacement = replacement, !replacement.isEmpty { + try secrets.set(replacement, for: Key.token) + } + try secrets.set(chatID.trimmingCharacters(in: .whitespacesAndNewlines), for: Key.chatID) + } +} diff --git a/BLEUnlock/da.lproj/InfoPlist.strings b/BLEUnlock/da.lproj/InfoPlist.strings new file mode 100644 index 00000000..ad729354 --- /dev/null +++ b/BLEUnlock/da.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "BLEUnlock bruger systemets standardkamera til at fotografere en manuel oplåsning og uploade advarslen til Telegram."; +"NSLocationUsageDescription" = "BLEUnlock henter denne Macs placering på optagelsestidspunktet og vedhæfter koordinater og kort til Telegram-sikkerhedsmeddelelsen."; diff --git a/BLEUnlock/da.lproj/Localizable.strings b/BLEUnlock/da.lproj/Localizable.strings index ccc259e5..7409be89 100644 --- a/BLEUnlock/da.lproj/Localizable.strings +++ b/BLEUnlock/da.lproj/Localizable.strings @@ -36,3 +36,51 @@ "use_screensaver_to_lock" = "Brug screensaver til at låse"; "wake_on_proximity" = "Vågn op på nærhed"; "wake_without_unlocking" = "Vågn op uden at låse op"; + +"telegram" = "Telegram-notifikationer"; +"telegram_enable" = "Aktivér Telegram"; +"telegram_configure" = "Konfigurer…"; +"telegram_test" = "Send testnotifikation"; +"telegram_events" = "Hændelser"; +"telegram_event_away" = "Enheden er væk"; +"telegram_event_lost" = "Signalet er mistet"; +"telegram_event_unlocked" = "Låst op af BLEUnlock"; +"telegram_event_intruded" = "Låst op manuelt"; +"telegram_take_photo" = "Vedhæft et foto ved manuel oplåsning"; +"telegram_attach_mac_location" = "Vedhæft Mac-placering"; +"telegram_status_not_configured" = "Ikke konfigureret"; +"telegram_status_enabled" = "Aktiveret"; +"telegram_status_disabled" = "Deaktiveret"; +"telegram_bot_token" = "Bot-token"; +"telegram_chat_id" = "Chat-id"; +"telegram_save" = "Gem"; +"telegram_setup_help" = "Opret en bot med @BotFather, og kopiér dens token. Send botten en besked, åbn derefter getUpdates i Telegram Bot API, og kopiér det numeriske chat-id fra svaret."; +"telegram_test_success" = "Testnotifikationen blev sendt."; +"telegram_test_failed" = "Testnotifikationen kunne ikke sendes."; +"telegram_camera_privacy" = "BLEUnlock bruger systemets standardkamera og uploader ét foto til Telegram. Det midlertidige foto slettes efter forsøget; hvis optagelsen mislykkes, sender BLEUnlock tekst i stedet."; +"telegram_error_not_configured" = "Konfigurer først et bot-token og chat-id."; +"telegram_camera_error_denied" = "Adgang til kameraet blev afvist."; +"telegram_camera_error_restricted" = "Adgang til kameraet er begrænset."; +"telegram_camera_error_no_camera" = "Der er intet kamera tilgængeligt."; +"telegram_camera_error_setup_failed" = "Kameraet kunne ikke konfigureres."; +"telegram_camera_error_capture_failed" = "Kameraet kunne ikke tage et foto."; +"telegram_camera_error_timeout" = "Tidsgrænsen for kameraoptagelsen blev overskredet."; +"telegram_camera_error_file_write_failed" = "Det optagne foto kunne ikke gemmes."; +"telegram_error_invalid_request" = "Telegram-anmodningen kunne ikke oprettes."; +"telegram_error_unreadable_photo" = "Det optagne foto kunne ikke læses."; +"telegram_error_transport" = "Telegram kunne ikke kontaktes."; +"telegram_error_http_status" = "Telegram returnerede HTTP-status %d."; +"telegram_error_rejected" = "Telegram afviste anmodningen."; +"telegram_error_invalid_response" = "Telegram returnerede et ugyldigt svar."; +"telegram_error_settings_unavailable" = "Telegram-indstillingerne kunne ikke læses."; +"telegram_error_file_cleanup" = "Det optagne foto kunne ikke slettes."; +"telegram_error_keychain_status" = "Nøgleringshandlingen mislykkedes (%d)."; +"telegram_failure_notification_subtitle" = "Telegram-notifikationen mislykkedes"; +"telegram_message_time" = "Tid"; +"telegram_message_rssi" = "RSSI"; +"telegram_message_coordinates" = "GPS-koordinater"; +"telegram_message_accuracy" = "Placeringens nøjagtighed"; +"telegram_message_map" = "Kort"; +"telegram_location_unavailable" = "Placeringen på optagelsestidspunktet kunne ikke hentes."; +"telegram_location_error" = "Mac-computerens placering kunne ikke hentes."; +"telegram_location_send_error" = "Billedet blev sendt, men Telegram-kortplaceringen kunne ikke sendes."; diff --git a/BLEUnlock/de.lproj/InfoPlist.strings b/BLEUnlock/de.lproj/InfoPlist.strings new file mode 100644 index 00000000..56434e8f --- /dev/null +++ b/BLEUnlock/de.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "BLEUnlock verwendet die Standardkamera des Systems, um eine manuelle Entsperrung zu fotografieren und die Warnung zu Telegram hochzuladen."; +"NSLocationUsageDescription" = "BLEUnlock ermittelt den Standort dieses Macs zum Aufnahmezeitpunkt und fügt Koordinaten und Karte der Telegram-Sicherheitsmeldung hinzu."; diff --git a/BLEUnlock/de.lproj/Localizable.strings b/BLEUnlock/de.lproj/Localizable.strings index c9d978ef..1e8d3a93 100644 --- a/BLEUnlock/de.lproj/Localizable.strings +++ b/BLEUnlock/de.lproj/Localizable.strings @@ -36,3 +36,51 @@ "use_screensaver_to_lock" = "Den Bildschirmschoner verwenden zum Sperren"; "wake_on_proximity" = "Aufwachen bei Annäherung"; "wake_without_unlocking" = "Aufwachen ohne Entsperren"; + +"telegram" = "Telegram-Benachrichtigungen"; +"telegram_enable" = "Telegram aktivieren"; +"telegram_configure" = "Konfigurieren…"; +"telegram_test" = "Testbenachrichtigung senden"; +"telegram_events" = "Ereignisse"; +"telegram_event_away" = "Gerät ist außer Reichweite"; +"telegram_event_lost" = "Signal verloren"; +"telegram_event_unlocked" = "Von BLEUnlock entsperrt"; +"telegram_event_intruded" = "Manuell entsperrt"; +"telegram_take_photo" = "Bei manuellem Entsperren Foto anhängen"; +"telegram_attach_mac_location" = "Mac-Standort anhängen"; +"telegram_status_not_configured" = "Nicht konfiguriert"; +"telegram_status_enabled" = "Aktiviert"; +"telegram_status_disabled" = "Deaktiviert"; +"telegram_bot_token" = "Bot-Token"; +"telegram_chat_id" = "Chat-ID"; +"telegram_save" = "Speichern"; +"telegram_setup_help" = "Erstellen Sie mit @BotFather einen Bot und kopieren Sie sein Token. Senden Sie dem Bot eine Nachricht, öffnen Sie anschließend getUpdates in der Telegram Bot API und kopieren Sie die numerische Chat-ID aus der Antwort."; +"telegram_test_success" = "Testbenachrichtigung wurde gesendet."; +"telegram_test_failed" = "Testbenachrichtigung konnte nicht gesendet werden."; +"telegram_camera_privacy" = "BLEUnlock verwendet die Standardkamera des Systems und lädt ein Foto zu Telegram hoch. Das temporäre Foto wird nach dem Versuch gelöscht; schlägt die Aufnahme fehl, sendet BLEUnlock stattdessen Text."; +"telegram_error_not_configured" = "Konfigurieren Sie zuerst Bot-Token und Chat-ID."; +"telegram_camera_error_denied" = "Der Kamerazugriff wurde verweigert."; +"telegram_camera_error_restricted" = "Der Kamerazugriff ist eingeschränkt."; +"telegram_camera_error_no_camera" = "Es ist keine Kamera verfügbar."; +"telegram_camera_error_setup_failed" = "Die Kamera konnte nicht konfiguriert werden."; +"telegram_camera_error_capture_failed" = "Die Kamera konnte kein Foto aufnehmen."; +"telegram_camera_error_timeout" = "Zeitüberschreitung bei der Kameraaufnahme."; +"telegram_camera_error_file_write_failed" = "Das aufgenommene Foto konnte nicht gespeichert werden."; +"telegram_error_invalid_request" = "Die Telegram-Anfrage konnte nicht erstellt werden."; +"telegram_error_unreadable_photo" = "Das aufgenommene Foto konnte nicht gelesen werden."; +"telegram_error_transport" = "Telegram konnte nicht erreicht werden."; +"telegram_error_http_status" = "Telegram hat den HTTP-Status %d zurückgegeben."; +"telegram_error_rejected" = "Telegram hat die Anfrage abgelehnt."; +"telegram_error_invalid_response" = "Telegram hat eine ungültige Antwort zurückgegeben."; +"telegram_error_settings_unavailable" = "Die Telegram-Einstellungen konnten nicht gelesen werden."; +"telegram_error_file_cleanup" = "Das aufgenommene Foto konnte nicht gelöscht werden."; +"telegram_error_keychain_status" = "Der Schlüsselbundvorgang ist fehlgeschlagen (%d)."; +"telegram_failure_notification_subtitle" = "Telegram-Benachrichtigung fehlgeschlagen"; +"telegram_message_time" = "Zeit"; +"telegram_message_rssi" = "RSSI"; +"telegram_message_coordinates" = "GPS-Koordinaten"; +"telegram_message_accuracy" = "Standortgenauigkeit"; +"telegram_message_map" = "Karte"; +"telegram_location_unavailable" = "Der Standort zum Aufnahmezeitpunkt ist nicht verfügbar."; +"telegram_location_error" = "Der Mac-Standort konnte nicht ermittelt werden."; +"telegram_location_send_error" = "Das Foto wurde gesendet, aber der Telegram-Kartenstandort konnte nicht gesendet werden."; diff --git a/BLEUnlock/ja.lproj/InfoPlist.strings b/BLEUnlock/ja.lproj/InfoPlist.strings new file mode 100644 index 00000000..3f2d462f --- /dev/null +++ b/BLEUnlock/ja.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "BLEUnlockはシステムのデフォルトカメラで手動ロック解除を撮影し、その警告をTelegramへアップロードします。"; +"NSLocationUsageDescription" = "BLEUnlockは撮影時のこのMacの位置情報を取得し、座標と地図をTelegramのセキュリティ通知に添付します。"; diff --git a/BLEUnlock/ja.lproj/Localizable.strings b/BLEUnlock/ja.lproj/Localizable.strings index 39910a7e..3c40592d 100644 --- a/BLEUnlock/ja.lproj/Localizable.strings +++ b/BLEUnlock/ja.lproj/Localizable.strings @@ -36,3 +36,51 @@ "use_screensaver_to_lock" = "スクリーンセーバーでロック"; "wake_on_proximity" = "画面スリープから復帰"; "wake_without_unlocking" = "アンロックせずに画面復帰"; + +"telegram" = "Telegram通知"; +"telegram_enable" = "Telegram通知を有効にする"; +"telegram_configure" = "設定…"; +"telegram_test" = "テスト通知を送信"; +"telegram_events" = "通知するイベント"; +"telegram_event_away" = "デバイスが離れた"; +"telegram_event_lost" = "信号を失った"; +"telegram_event_unlocked" = "BLEUnlockでロック解除"; +"telegram_event_intruded" = "手動でロック解除"; +"telegram_take_photo" = "手動ロック解除時に写真を添付"; +"telegram_attach_mac_location" = "Macの位置情報を添付"; +"telegram_status_not_configured" = "未設定"; +"telegram_status_enabled" = "有効"; +"telegram_status_disabled" = "無効"; +"telegram_bot_token" = "Botトークン"; +"telegram_chat_id" = "Chat ID"; +"telegram_save" = "保存"; +"telegram_setup_help" = "@BotFatherでボットを作成し、トークンをコピーします。ボットにメッセージを送信してから、Telegram Bot APIのgetUpdatesを開き、応答内の数値のChat IDをコピーしてください。"; +"telegram_test_success" = "テスト通知を送信しました。"; +"telegram_test_failed" = "テスト通知を送信できませんでした。"; +"telegram_camera_privacy" = "BLEUnlockはシステムのデフォルトカメラで写真を1枚撮影してTelegramへアップロードします。一時写真は送信処理後に削除され、撮影に失敗した場合は代わりにテキストを送信します。"; +"telegram_error_not_configured" = "先にBotトークンとChat IDを設定してください。"; +"telegram_camera_error_denied" = "カメラへのアクセスが拒否されました。"; +"telegram_camera_error_restricted" = "カメラへのアクセスが制限されています。"; +"telegram_camera_error_no_camera" = "利用できるカメラがありません。"; +"telegram_camera_error_setup_failed" = "カメラを設定できませんでした。"; +"telegram_camera_error_capture_failed" = "カメラで写真を撮影できませんでした。"; +"telegram_camera_error_timeout" = "カメラ撮影がタイムアウトしました。"; +"telegram_camera_error_file_write_failed" = "撮影した写真を保存できませんでした。"; +"telegram_error_invalid_request" = "Telegramリクエストを作成できませんでした。"; +"telegram_error_unreadable_photo" = "撮影した写真を読み込めませんでした。"; +"telegram_error_transport" = "Telegramに接続できませんでした。"; +"telegram_error_http_status" = "TelegramがHTTPステータス%dを返しました。"; +"telegram_error_rejected" = "Telegramがリクエストを拒否しました。"; +"telegram_error_invalid_response" = "Telegramから無効な応答が返されました。"; +"telegram_error_settings_unavailable" = "Telegram設定を読み込めませんでした。"; +"telegram_error_file_cleanup" = "撮影した写真を削除できませんでした。"; +"telegram_error_keychain_status" = "キーチェーン操作に失敗しました(%d)。"; +"telegram_failure_notification_subtitle" = "Telegram通知に失敗しました"; +"telegram_message_time" = "時刻"; +"telegram_message_rssi" = "RSSI"; +"telegram_message_coordinates" = "GPS座標"; +"telegram_message_accuracy" = "位置情報の精度"; +"telegram_message_map" = "地図"; +"telegram_location_unavailable" = "撮影時の位置情報を取得できませんでした。"; +"telegram_location_error" = "Macの位置情報を取得できませんでした。"; +"telegram_location_send_error" = "写真は送信されましたが、Telegramの地図位置情報を送信できませんでした。"; diff --git a/BLEUnlock/nb.lproj/InfoPlist.strings b/BLEUnlock/nb.lproj/InfoPlist.strings new file mode 100644 index 00000000..c4cc4c1f --- /dev/null +++ b/BLEUnlock/nb.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "BLEUnlock bruker systemets standardkamera til å fotografere en manuell opplåsing og laste opp varselet til Telegram."; +"NSLocationUsageDescription" = "BLEUnlock henter posisjonen til denne Macen på opptakstidspunktet og legger ved koordinater og kart i Telegram-sikkerhetsvarslet."; diff --git a/BLEUnlock/nb.lproj/Localizable.strings b/BLEUnlock/nb.lproj/Localizable.strings index 37bc754a..47524399 100644 --- a/BLEUnlock/nb.lproj/Localizable.strings +++ b/BLEUnlock/nb.lproj/Localizable.strings @@ -36,3 +36,51 @@ "use_screensaver_to_lock" = "Bruke skjermsparer til å låse"; "wake_on_proximity" = "Vekk ved nærhet"; "wake_without_unlocking" = "Våkne uten å låse opp"; + +"telegram" = "Telegram-varslinger"; +"telegram_enable" = "Aktiver Telegram"; +"telegram_configure" = "Konfigurer…"; +"telegram_test" = "Send testvarsling"; +"telegram_events" = "Hendelser"; +"telegram_event_away" = "Enheten er borte"; +"telegram_event_lost" = "Signalet er mistet"; +"telegram_event_unlocked" = "Låst opp av BLEUnlock"; +"telegram_event_intruded" = "Låst opp manuelt"; +"telegram_take_photo" = "Legg ved et bilde ved manuell opplåsing"; +"telegram_attach_mac_location" = "Legg ved Mac-posisjon"; +"telegram_status_not_configured" = "Ikke konfigurert"; +"telegram_status_enabled" = "Aktivert"; +"telegram_status_disabled" = "Deaktivert"; +"telegram_bot_token" = "Bot-token"; +"telegram_chat_id" = "Chat-ID"; +"telegram_save" = "Lagre"; +"telegram_setup_help" = "Opprett en bot med @BotFather og kopier tokenet. Send boten en melding, åpne deretter getUpdates i Telegram Bot API og kopier den numeriske Chat-ID-en fra svaret."; +"telegram_test_success" = "Testvarslingen ble sendt."; +"telegram_test_failed" = "Kunne ikke sende testvarslingen."; +"telegram_camera_privacy" = "BLEUnlock bruker systemets standardkamera og laster opp ett bilde til Telegram. Det midlertidige bildet slettes etter forsøket; hvis opptaket mislykkes, sender BLEUnlock tekst i stedet."; +"telegram_error_not_configured" = "Konfigurer Bot-token og Chat-ID først."; +"telegram_camera_error_denied" = "Tilgang til kameraet ble nektet."; +"telegram_camera_error_restricted" = "Tilgang til kameraet er begrenset."; +"telegram_camera_error_no_camera" = "Ingen kamera er tilgjengelig."; +"telegram_camera_error_setup_failed" = "Kameraet kunne ikke konfigureres."; +"telegram_camera_error_capture_failed" = "Kameraet kunne ikke ta et bilde."; +"telegram_camera_error_timeout" = "Tidsgrensen for kameraopptaket ble overskredet."; +"telegram_camera_error_file_write_failed" = "Det tatt bildet kunne ikke lagres."; +"telegram_error_invalid_request" = "Telegram-forespørselen kunne ikke opprettes."; +"telegram_error_unreadable_photo" = "Det tatt bildet kunne ikke leses."; +"telegram_error_transport" = "Telegram kunne ikke nås."; +"telegram_error_http_status" = "Telegram returnerte HTTP-status %d."; +"telegram_error_rejected" = "Telegram avviste forespørselen."; +"telegram_error_invalid_response" = "Telegram returnerte et ugyldig svar."; +"telegram_error_settings_unavailable" = "Telegram-innstillingene kunne ikke leses."; +"telegram_error_file_cleanup" = "Det tatt bildet kunne ikke slettes."; +"telegram_error_keychain_status" = "Nøkkelringoperasjonen mislyktes (%d)."; +"telegram_failure_notification_subtitle" = "Telegram-varslingen mislyktes"; +"telegram_message_time" = "Tid"; +"telegram_message_rssi" = "RSSI"; +"telegram_message_coordinates" = "GPS-koordinater"; +"telegram_message_accuracy" = "Posisjonsnøyaktighet"; +"telegram_message_map" = "Kart"; +"telegram_location_unavailable" = "Posisjonen på opptakstidspunktet er ikke tilgjengelig."; +"telegram_location_error" = "Kunne ikke hente Mac-posisjonen."; +"telegram_location_send_error" = "Bildet ble sendt, men kartposisjonen kunne ikke sendes til Telegram."; diff --git a/BLEUnlock/sv.lproj/InfoPlist.strings b/BLEUnlock/sv.lproj/InfoPlist.strings new file mode 100644 index 00000000..04e1a078 --- /dev/null +++ b/BLEUnlock/sv.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "BLEUnlock använder systemets standardkamera för att fotografera en manuell upplåsning och ladda upp varningen till Telegram."; +"NSLocationUsageDescription" = "BLEUnlock hämtar den här Mac-datorns plats när fotot tas och bifogar koordinater och karta till Telegram-säkerhetsnotisen."; diff --git a/BLEUnlock/sv.lproj/Localizable.strings b/BLEUnlock/sv.lproj/Localizable.strings index 4ca0820a..92c63bb0 100644 --- a/BLEUnlock/sv.lproj/Localizable.strings +++ b/BLEUnlock/sv.lproj/Localizable.strings @@ -36,3 +36,51 @@ "use_screensaver_to_lock" = "Använd skärmsläckare för att låsa"; "wake_on_proximity" = "Väck om i närheten"; "wake_without_unlocking" = "Vakna utan att låsa upp"; + +"telegram" = "Telegram-notiser"; +"telegram_enable" = "Aktivera Telegram"; +"telegram_configure" = "Konfigurera…"; +"telegram_test" = "Skicka testnotis"; +"telegram_events" = "Händelser"; +"telegram_event_away" = "Enheten är utom räckhåll"; +"telegram_event_lost" = "Signalen har förlorats"; +"telegram_event_unlocked" = "Upplåst av BLEUnlock"; +"telegram_event_intruded" = "Upplåst manuellt"; +"telegram_take_photo" = "Bifoga ett foto vid manuell upplåsning"; +"telegram_attach_mac_location" = "Bifoga Mac-plats"; +"telegram_status_not_configured" = "Inte konfigurerat"; +"telegram_status_enabled" = "Aktiverat"; +"telegram_status_disabled" = "Inaktiverat"; +"telegram_bot_token" = "Bot-token"; +"telegram_chat_id" = "Chatt-ID"; +"telegram_save" = "Spara"; +"telegram_setup_help" = "Skapa en bot med @BotFather och kopiera dess token. Skicka ett meddelande till boten, öppna sedan getUpdates i Telegram Bot API och kopiera det numeriska chatt-ID:t från svaret."; +"telegram_test_success" = "Testnotisen skickades."; +"telegram_test_failed" = "Testnotisen kunde inte skickas."; +"telegram_camera_privacy" = "BLEUnlock använder systemets standardkamera och laddar upp ett foto till Telegram. Det tillfälliga fotot raderas efter försöket; om fotograferingen misslyckas skickar BLEUnlock text i stället."; +"telegram_error_not_configured" = "Konfigurera Bot-token och chatt-ID först."; +"telegram_camera_error_denied" = "Åtkomst till kameran nekades."; +"telegram_camera_error_restricted" = "Åtkomst till kameran är begränsad."; +"telegram_camera_error_no_camera" = "Ingen kamera är tillgänglig."; +"telegram_camera_error_setup_failed" = "Kameran kunde inte konfigureras."; +"telegram_camera_error_capture_failed" = "Kameran kunde inte ta ett foto."; +"telegram_camera_error_timeout" = "Kamerafotograferingen nådde tidsgränsen."; +"telegram_camera_error_file_write_failed" = "Det tagna fotot kunde inte sparas."; +"telegram_error_invalid_request" = "Telegram-begäran kunde inte skapas."; +"telegram_error_unreadable_photo" = "Det tagna fotot kunde inte läsas."; +"telegram_error_transport" = "Telegram kunde inte nås."; +"telegram_error_http_status" = "Telegram returnerade HTTP-status %d."; +"telegram_error_rejected" = "Telegram avvisade begäran."; +"telegram_error_invalid_response" = "Telegram returnerade ett ogiltigt svar."; +"telegram_error_settings_unavailable" = "Telegram-inställningarna kunde inte läsas."; +"telegram_error_file_cleanup" = "Det tagna fotot kunde inte raderas."; +"telegram_error_keychain_status" = "Nyckelringsåtgärden misslyckades (%d)."; +"telegram_failure_notification_subtitle" = "Telegram-notisen misslyckades"; +"telegram_message_time" = "Tid"; +"telegram_message_rssi" = "RSSI"; +"telegram_message_coordinates" = "GPS-koordinater"; +"telegram_message_accuracy" = "Platsnoggrannhet"; +"telegram_message_map" = "Karta"; +"telegram_location_unavailable" = "Platsen vid fotograferingstillfället är inte tillgänglig."; +"telegram_location_error" = "Det gick inte att hämta Mac-datorns plats."; +"telegram_location_send_error" = "Fotot skickades, men kartplatsen kunde inte skickas till Telegram."; diff --git a/BLEUnlock/tr.lproj/InfoPlist.strings b/BLEUnlock/tr.lproj/InfoPlist.strings new file mode 100644 index 00000000..d9f290f5 --- /dev/null +++ b/BLEUnlock/tr.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "BLEUnlock, elle kilit açmayı fotoğraflamak ve uyarıyı Telegram'a yüklemek için sistemin varsayılan kamerasını kullanır."; +"NSLocationUsageDescription" = "BLEUnlock fotoğraf çekildiğinde bu Mac'in konumunu alır ve koordinatlarla haritayı Telegram güvenlik bildirimine ekler."; diff --git a/BLEUnlock/tr.lproj/Localizable.strings b/BLEUnlock/tr.lproj/Localizable.strings index 1376de24..2b0a3296 100644 --- a/BLEUnlock/tr.lproj/Localizable.strings +++ b/BLEUnlock/tr.lproj/Localizable.strings @@ -36,3 +36,51 @@ "use_screensaver_to_lock" = "Kilit ekranı için ekran kuruyucu kullan"; "wake_on_proximity" = "Uyandırma yakınlığı"; "wake_without_unlocking" = "Kilidi açmadan uyanın"; + +"telegram" = "Telegram Bildirimleri"; +"telegram_enable" = "Telegram'ı Etkinleştir"; +"telegram_configure" = "Yapılandır…"; +"telegram_test" = "Test Bildirimi Gönder"; +"telegram_events" = "Olaylar"; +"telegram_event_away" = "Cihaz uzakta"; +"telegram_event_lost" = "Sinyal kayboldu"; +"telegram_event_unlocked" = "BLEUnlock tarafından kilit açıldı"; +"telegram_event_intruded" = "Elle kilit açıldı"; +"telegram_take_photo" = "Elle kilit açıldığında fotoğraf ekle"; +"telegram_attach_mac_location" = "Mac Konumunu Ekle"; +"telegram_status_not_configured" = "Yapılandırılmadı"; +"telegram_status_enabled" = "Etkin"; +"telegram_status_disabled" = "Devre dışı"; +"telegram_bot_token" = "Bot jetonu"; +"telegram_chat_id" = "Sohbet kimliği"; +"telegram_save" = "Kaydet"; +"telegram_setup_help" = "@BotFather ile bir bot oluşturun ve jetonunu kopyalayın. Bota bir mesaj gönderin, ardından Telegram Bot API'deki getUpdates'ı açın ve yanıttaki sayısal sohbet kimliğini kopyalayın."; +"telegram_test_success" = "Test bildirimi gönderildi."; +"telegram_test_failed" = "Test bildirimi gönderilemedi."; +"telegram_camera_privacy" = "BLEUnlock sistemin varsayılan kamerasını kullanır ve Telegram'a bir fotoğraf yükler. Geçici fotoğraf denemeden sonra silinir; çekim başarısız olursa BLEUnlock bunun yerine metin gönderir."; +"telegram_error_not_configured" = "Önce bot jetonunu ve sohbet kimliğini yapılandırın."; +"telegram_camera_error_denied" = "Kamera erişimi reddedildi."; +"telegram_camera_error_restricted" = "Kamera erişimi kısıtlı."; +"telegram_camera_error_no_camera" = "Kullanılabilir kamera yok."; +"telegram_camera_error_setup_failed" = "Kamera yapılandırılamadı."; +"telegram_camera_error_capture_failed" = "Kamera fotoğraf çekemedi."; +"telegram_camera_error_timeout" = "Kamera çekimi zaman aşımına uğradı."; +"telegram_camera_error_file_write_failed" = "Çekilen fotoğraf kaydedilemedi."; +"telegram_error_invalid_request" = "Telegram isteği oluşturulamadı."; +"telegram_error_unreadable_photo" = "Çekilen fotoğraf okunamadı."; +"telegram_error_transport" = "Telegram'a ulaşılamadı."; +"telegram_error_http_status" = "Telegram HTTP durum kodu %d döndürdü."; +"telegram_error_rejected" = "Telegram isteği reddetti."; +"telegram_error_invalid_response" = "Telegram geçersiz bir yanıt döndürdü."; +"telegram_error_settings_unavailable" = "Telegram ayarları okunamadı."; +"telegram_error_file_cleanup" = "Çekilen fotoğraf silinemedi."; +"telegram_error_keychain_status" = "Anahtar Zinciri işlemi başarısız oldu (%d)."; +"telegram_failure_notification_subtitle" = "Telegram bildirimi başarısız oldu"; +"telegram_message_time" = "Saat"; +"telegram_message_rssi" = "RSSI"; +"telegram_message_coordinates" = "GPS Koordinatları"; +"telegram_message_accuracy" = "Konum Doğruluğu"; +"telegram_message_map" = "Harita"; +"telegram_location_unavailable" = "Fotoğraf çekildiği andaki konum alınamadı."; +"telegram_location_error" = "Mac konumu alınamadı."; +"telegram_location_send_error" = "Fotoğraf gönderildi ancak Telegram harita konumu gönderilemedi."; diff --git a/BLEUnlock/zh-Hans.lproj/InfoPlist.strings b/BLEUnlock/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 00000000..4376b7e9 --- /dev/null +++ b/BLEUnlock/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "BLEUnlock 使用系统默认摄像头拍摄手动解锁,并将警报上传到 Telegram。"; +"NSLocationUsageDescription" = "BLEUnlock 获取这台 Mac 拍照时的位置,并将坐标和地图附加到 Telegram 安全通知。"; diff --git a/BLEUnlock/zh-Hans.lproj/Localizable.strings b/BLEUnlock/zh-Hans.lproj/Localizable.strings index c8a0303f..e5749656 100644 --- a/BLEUnlock/zh-Hans.lproj/Localizable.strings +++ b/BLEUnlock/zh-Hans.lproj/Localizable.strings @@ -36,3 +36,51 @@ "use_screensaver_to_lock" = "用屏保来锁定它"; "wake_on_proximity" = "靠近唤醒"; "wake_without_unlocking" = "唤醒时不需解锁"; + +"telegram" = "Telegram 通知"; +"telegram_enable" = "启用 Telegram"; +"telegram_configure" = "配置…"; +"telegram_test" = "发送测试通知"; +"telegram_events" = "通知事件"; +"telegram_event_away" = "设备已远离"; +"telegram_event_lost" = "信号已丢失"; +"telegram_event_unlocked" = "BLEUnlock 已解锁"; +"telegram_event_intruded" = "已手动解锁"; +"telegram_take_photo" = "手动解锁时附加照片"; +"telegram_attach_mac_location" = "附加 Mac 位置"; +"telegram_status_not_configured" = "未配置"; +"telegram_status_enabled" = "已启用"; +"telegram_status_disabled" = "已禁用"; +"telegram_bot_token" = "机器人令牌"; +"telegram_chat_id" = "聊天 ID"; +"telegram_save" = "保存"; +"telegram_setup_help" = "使用 @BotFather 创建机器人并复制其令牌。向机器人发送一条消息,然后打开 Telegram Bot API 的 getUpdates,复制响应中的数字聊天 ID。"; +"telegram_test_success" = "测试通知已发送。"; +"telegram_test_failed" = "无法发送测试通知。"; +"telegram_camera_privacy" = "BLEUnlock 使用系统默认摄像头拍摄一张照片并上传到 Telegram。尝试结束后会删除临时照片;如果拍摄失败,BLEUnlock 将改为发送文本。"; +"telegram_error_not_configured" = "请先配置机器人令牌和聊天 ID。"; +"telegram_camera_error_denied" = "摄像头访问权限已被拒绝。"; +"telegram_camera_error_restricted" = "摄像头访问受限。"; +"telegram_camera_error_no_camera" = "没有可用的摄像头。"; +"telegram_camera_error_setup_failed" = "无法配置摄像头。"; +"telegram_camera_error_capture_failed" = "摄像头无法拍摄照片。"; +"telegram_camera_error_timeout" = "摄像头拍摄超时。"; +"telegram_camera_error_file_write_failed" = "无法保存拍摄的照片。"; +"telegram_error_invalid_request" = "无法创建 Telegram 请求。"; +"telegram_error_unreadable_photo" = "无法读取拍摄的照片。"; +"telegram_error_transport" = "无法连接 Telegram。"; +"telegram_error_http_status" = "Telegram 返回了 HTTP 状态码 %d。"; +"telegram_error_rejected" = "Telegram 拒绝了该请求。"; +"telegram_error_invalid_response" = "Telegram 返回了无效响应。"; +"telegram_error_settings_unavailable" = "无法读取 Telegram 设置。"; +"telegram_error_file_cleanup" = "无法删除拍摄的照片。"; +"telegram_error_keychain_status" = "钥匙串操作失败(%d)。"; +"telegram_failure_notification_subtitle" = "Telegram 通知失败"; +"telegram_message_time" = "时间"; +"telegram_message_rssi" = "RSSI"; +"telegram_message_coordinates" = "GPS 坐标"; +"telegram_message_accuracy" = "定位精度"; +"telegram_message_map" = "地图"; +"telegram_location_unavailable" = "无法获取拍照时的位置。"; +"telegram_location_error" = "无法获取 Mac 位置。"; +"telegram_location_send_error" = "照片已发送,但无法发送 Telegram 地图位置。"; diff --git a/BLEUnlockTests/CameraCaptureTests.swift b/BLEUnlockTests/CameraCaptureTests.swift new file mode 100644 index 00000000..158b28ad --- /dev/null +++ b/BLEUnlockTests/CameraCaptureTests.swift @@ -0,0 +1,676 @@ +import AVFoundation +import Foundation +import XCTest +@testable import BLEUnlock + +final class StubCameraAuthorization: CameraAuthorizationProviding { + var authorizationStatus: AVAuthorizationStatus = .authorized + var requestResult = true + private(set) var requestAccessCalls = 0 + + func requestAccess(completion: @escaping (Bool) -> Void) { + requestAccessCalls += 1 + completion(requestResult) + } +} + +final class StubPhotoSession: PhotoSessionProviding { + var result: Result? + private(set) var stopped = false + private(set) var captureCalls = 0 + private var completion: ((Result) -> Void)? + + func captureJPEG(completion: @escaping (Result) -> Void) { + captureCalls += 1 + self.completion = completion + if let result = result { + completion(result) + } + } + + func complete(with result: Result) { + completion?(result) + } + + func stop() { + stopped = true + } +} + +final class StubScheduledCancellation: ScheduledCancellation { + private(set) var cancelCalls = 0 + + func cancel() { + cancelCalls += 1 + } +} + +final class StubCameraScheduler: CameraScheduling { + private(set) var scheduledIntervals: [TimeInterval] = [] + private(set) var blocks: [() -> Void] = [] + private(set) var cancellations: [StubScheduledCancellation] = [] + + @discardableResult + func schedule(after interval: TimeInterval, + _ block: @escaping () -> Void) -> ScheduledCancellation { + let cancellation = StubScheduledCancellation() + scheduledIntervals.append(interval) + blocks.append(block) + cancellations.append(cancellation) + return cancellation + } +} + +final class StubAVCaptureSession: CaptureSessionRunning { + private(set) var startRunningCalls = 0 + private(set) var stopRunningCalls = 0 + var isRunning = false + + func startRunning() { + startRunningCalls += 1 + isRunning = true + } + + func stopRunning() { + stopRunningCalls += 1 + isRunning = false + } +} + +final class StubAVCapturePhotoOutput: PhotoOutputCapturing { + private(set) var capturePhotoCalls = 0 + + func capturePhoto(with settings: AVCapturePhotoSettings, + delegate: AVCapturePhotoCaptureDelegate) { + capturePhotoCalls += 1 + } +} + +struct ImmediateCameraTeardownScheduler: CameraTeardownScheduling { + func schedule(_ block: @escaping () -> Void) { + block() + } +} + +final class StubCameraTeardownScheduler: CameraTeardownScheduling { + private(set) var blocks: [() -> Void] = [] + + func schedule(_ block: @escaping () -> Void) { + blocks.append(block) + } +} + +final class LifetimeTrackingPhotoSession: PhotoSessionProviding { + var result: Result? + var onDeinit: (() -> Void)? + + func captureJPEG(completion: @escaping (Result) -> Void) { + if let result = result { + completion(result) + } + } + + func stop() {} + + deinit { + onDeinit?() + } +} + +final class RetainingCompletionPhotoSession: PhotoSessionProviding { + var onDeinit: (() -> Void)? + private var completion: ((Result) -> Void)? + + func captureJPEG(completion: @escaping (Result) -> Void) { + self.completion = completion + } + + func stop() {} + + deinit { + onDeinit?() + } +} + +final class BlockingStopPhotoSession: PhotoSessionProviding { + let stopStarted = DispatchSemaphore(value: 0) + let allowStopToReturn = DispatchSemaphore(value: 0) + + func captureJPEG(completion: @escaping (Result) -> Void) {} + + func stop() { + stopStarted.signal() + allowStopToReturn.wait() + } +} + +final class LifecycleBackedPhotoSession: PhotoSessionProviding { + private let lifecycle: PhotoSessionLifecycle + private(set) weak var captureDelegate: StubAbandonablePhotoDelegate? + + init(retirementScheduler: CameraScheduling, + retirementTimeout: TimeInterval = 30) { + lifecycle = PhotoSessionLifecycle(retirementScheduler: retirementScheduler, + retirementTimeout: retirementTimeout) + } + + func captureJPEG(completion: @escaping (Result) -> Void) { + let delegate = StubAbandonablePhotoDelegate() + delegate.completion = { + completion(.success(Data([0xFF, 0xD8, 0xFF, 0xD9]))) + } + guard lifecycle.install(delegate: delegate) else { return } + captureDelegate = delegate + } + + func stop() { + lifecycle.cancel() + } + + func deliverLateCallback() { + guard let delegate = captureDelegate else { return } + delegate.deliverCallback() + lifecycle.didFinishCallback(for: delegate) + } + + func finishTeardown() { + lifecycle.teardownDidFinish() + } +} + +final class LifetimeToken {} + +final class WeakBox { + weak var value: Value? + + init(_ value: Value?) { + self.value = value + } +} + +final class StubAbandonablePhotoDelegate: AbandonablePhotoCaptureDelegate { + var completion: (() -> Void)? + private(set) var abandonCalls = 0 + + func deliverCallback() { + completion?() + } + + func abandon() { + abandonCalls += 1 + completion = nil + } +} + +final class CameraCaptureTests: XCTestCase { + private var temporaryDirectory: URL! + private var scheduler: StubCameraScheduler! + + override func setUpWithError() throws { + try super.setUpWithError() + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("BLEUnlock-CameraCaptureTests-\(UUID().uuidString)", + isDirectory: true) + try FileManager.default.createDirectory(at: temporaryDirectory, + withIntermediateDirectories: true) + scheduler = StubCameraScheduler() + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: temporaryDirectory) + scheduler = nil + temporaryDirectory = nil + try super.tearDownWithError() + } + + func testAVPhotoSessionStartsSessionAndWaitsOneSecondBeforeRequestingPhoto() throws { + let captureSession = StubAVCaptureSession() + let photoOutput = StubAVCapturePhotoOutput() + let queue = DispatchQueue(label: "CameraCaptureTests.AVPhotoSession") + let photoSession = AVPhotoSession(session: captureSession, + output: photoOutput, + queue: queue, + warmupScheduler: scheduler) + + photoSession.captureJPEG { _ in } + queue.sync {} + + XCTAssertEqual(captureSession.startRunningCalls, 1) + XCTAssertEqual(photoOutput.capturePhotoCalls, 0) + XCTAssertEqual(scheduler.scheduledIntervals, [1]) + + try XCTUnwrap(scheduler.blocks.first)() + + XCTAssertEqual(photoOutput.capturePhotoCalls, 1) + } + + func testAVPhotoSessionCancellationDuringWarmupDoesNotRequestPhoto() throws { + let captureSession = StubAVCaptureSession() + let photoOutput = StubAVCapturePhotoOutput() + let queue = DispatchQueue(label: "CameraCaptureTests.AVPhotoSession.cancel") + let photoSession = AVPhotoSession(session: captureSession, + output: photoOutput, + queue: queue, + warmupScheduler: scheduler) + + photoSession.captureJPEG { _ in } + queue.sync {} + photoSession.stop() + + try XCTUnwrap(scheduler.blocks.first)() + + XCTAssertEqual(photoOutput.capturePhotoCalls, 0) + } + + func testAuthorizedCaptureWritesReturnedJPEGToUniqueTemporaryURL() throws { + let session = StubPhotoSession() + let jpeg = Data([0xFF, 0xD8, 0xFF, 0xD9]) + session.result = .success(jpeg) + let done = expectation(description: "capture") + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in + do { + let url = try result.get() + XCTAssertEqual(try Data(contentsOf: url), jpeg) + XCTAssertEqual(url.deletingLastPathComponent(), self.temporaryDirectory) + XCTAssertTrue(url.lastPathComponent.hasPrefix("BLEUnlock-intruded-")) + XCTAssertEqual(url.pathExtension, "jpg") + try FileManager.default.removeItem(at: url) + } catch { + XCTFail("\(error)") + } + done.fulfill() + } + + wait(for: [done], timeout: 1) + XCTAssertTrue(session.stopped) + XCTAssertEqual(scheduler.scheduledIntervals, [10]) + XCTAssertEqual(scheduler.cancellations.first?.cancelCalls, 1) + } + + func testNotDeterminedRequestsAccessBeforeCapture() { + let authorization = StubCameraAuthorization() + authorization.authorizationStatus = .notDetermined + authorization.requestResult = true + let session = StubPhotoSession() + session.result = .success(Data([0xFF, 0xD8, 0xFF, 0xD9])) + var factoryObservedAccessRequest = false + let done = expectation(description: "capture") + + CameraCapture(authorization: authorization, + sessionFactory: { + factoryObservedAccessRequest = authorization.requestAccessCalls == 1 + return .success(session) + }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in + if case .success(let url) = result { + try? FileManager.default.removeItem(at: url) + } else { + XCTFail("Expected successful capture, got \(result)") + } + done.fulfill() + } + + wait(for: [done], timeout: 1) + XCTAssertEqual(authorization.requestAccessCalls, 1) + XCTAssertTrue(factoryObservedAccessRequest) + XCTAssertEqual(session.captureCalls, 1) + XCTAssertTrue(session.stopped) + } + + func testDeniedPermissionReturnsDeniedWithoutStartingSession() { + let authorization = StubCameraAuthorization() + authorization.authorizationStatus = .denied + var factoryCalls = 0 + let done = expectation(description: "capture") + + CameraCapture(authorization: authorization, + sessionFactory: { + factoryCalls += 1 + return .failure(.setupFailed) + }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in + XCTAssertEqual(try? result.get(), nil) + if case .failure(let error) = result { + XCTAssertEqual(error, .denied) + } + done.fulfill() + } + + wait(for: [done], timeout: 1) + XCTAssertEqual(factoryCalls, 0) + XCTAssertTrue(scheduler.blocks.isEmpty) + } + + func testMissingCameraReturnsNoCamera() { + let done = expectation(description: "capture") + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .failure(.noCamera) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in + guard case .failure(let error) = result else { + return XCTFail("Expected failure, got \(result)") + } + XCTAssertEqual(error, .noCamera) + done.fulfill() + } + + wait(for: [done], timeout: 1) + XCTAssertTrue(scheduler.blocks.isEmpty) + } + + func testCaptureTimeoutStopsSessionAndReturnsTimeout() throws { + let session = StubPhotoSession() + var results: [Result] = [] + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { + results.append($0) + } + + XCTAssertEqual(session.captureCalls, 1) + XCTAssertFalse(session.stopped) + let timeout = try XCTUnwrap(scheduler.blocks.first) + timeout() + session.complete(with: .success(Data([0xFF, 0xD8, 0xFF, 0xD9]))) + + XCTAssertEqual(results.count, 1) + guard case .failure(let error) = results[0] else { + return XCTFail("Expected timeout, got \(results[0])") + } + XCTAssertEqual(error, .timeout) + XCTAssertTrue(session.stopped) + XCTAssertEqual(scheduler.cancellations.first?.cancelCalls, 1) + } + + func testCaptureFailureStopsSessionAndReturnsCaptureFailed() { + let session = StubPhotoSession() + session.result = .failure(.captureFailed) + let done = expectation(description: "capture") + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in + guard case .failure(let error) = result else { + return XCTFail("Expected failure, got \(result)") + } + XCTAssertEqual(error, .captureFailed) + done.fulfill() + } + + wait(for: [done], timeout: 1) + XCTAssertTrue(session.stopped) + XCTAssertEqual(scheduler.cancellations.first?.cancelCalls, 1) + } + + func testSynchronousSuccessReleasesSessionStateWithDispatchScheduler() { + let released = expectation(description: "session released") + var session: LifetimeTrackingPhotoSession? = LifetimeTrackingPhotoSession() + session?.result = .success(Data([0xFF, 0xD8, 0xFF, 0xD9])) + session?.onDeinit = { released.fulfill() } + let weakSession = WeakBox(session) + + let capture = CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session!) }, + temporaryDirectory: temporaryDirectory, + scheduler: DispatchCameraScheduler(), + teardownScheduler: ImmediateCameraTeardownScheduler(), + timeout: 0.01) + capture.capture { result in + if case .success(let url) = result { + try? FileManager.default.removeItem(at: url) + } else { + XCTFail("Expected successful capture, got \(result)") + } + } + session = nil + + wait(for: [released], timeout: 1) + XCTAssertNil(weakSession.value) + } + + func testTimeoutReleasesSessionStateWithDispatchScheduler() { + let timedOut = expectation(description: "capture timed out") + let released = expectation(description: "session released") + var session: LifetimeTrackingPhotoSession? = LifetimeTrackingPhotoSession() + session?.onDeinit = { released.fulfill() } + let weakSession = WeakBox(session) + + let capture = CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session!) }, + temporaryDirectory: temporaryDirectory, + scheduler: DispatchCameraScheduler(), + teardownScheduler: ImmediateCameraTeardownScheduler(), + timeout: 0) + capture.capture { result in + guard case .failure(let error) = result else { + return XCTFail("Expected timeout, got \(result)") + } + XCTAssertEqual(error, .timeout) + timedOut.fulfill() + } + session = nil + + wait(for: [timedOut, released], timeout: 1) + XCTAssertNil(weakSession.value) + } + + func testCancellingLifecycleDoesNotWaitForBlockedActionAndReleasesPendingCompletion() { + let lifecycle = PhotoSessionLifecycle() + var deliveredCompletions = 0 + var completionOwner: LifetimeToken? = LifetimeToken() + let weakCompletionOwner = WeakBox(completionOwner) + XCTAssertTrue(lifecycle.prepare(completion: retainedResultCompletion( + owner: completionOwner! + ) { + deliveredCompletions += 1 + })) + completionOwner = nil + XCTAssertNotNil(weakCompletionOwner.value) + + let actionStarted = DispatchSemaphore(value: 0) + let allowActionToReturn = DispatchSemaphore(value: 0) + let actionReturned = expectation(description: "action returned") + DispatchQueue.global(qos: .utility).async { + _ = lifecycle.performIfActive { + actionStarted.signal() + allowActionToReturn.wait() + } + actionReturned.fulfill() + } + XCTAssertEqual(actionStarted.wait(timeout: .now() + 1), .success) + + let cancelReturned = expectation(description: "cancel returned") + DispatchQueue.global(qos: .utility).async { + lifecycle.cancel() + cancelReturned.fulfill() + } + let cancelResult = XCTWaiter.wait(for: [cancelReturned], timeout: 0.2) + allowActionToReturn.signal() + wait(for: [actionReturned], timeout: 1) + + XCTAssertEqual(cancelResult, .completed, + "Cancellation must not wait for startRunning to return") + XCTAssertNil(weakCompletionOwner.value) + XCTAssertEqual(deliveredCompletions, 0) + } + + func testLateCallbackAfterTimeoutRetainsDelegateSuppressesClientAndThenReleases() throws { + let retirementScheduler = StubCameraScheduler() + let session = LifecycleBackedPhotoSession(retirementScheduler: retirementScheduler) + var results: [Result] = [] + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { + results.append($0) + } + let weakDelegate = WeakBox(session.captureDelegate) + + try XCTUnwrap(scheduler.blocks.first)() + + XCTAssertEqual(results.count, 1) + guard case .failure(let error) = results[0] else { + return XCTFail("Expected timeout, got \(results[0])") + } + XCTAssertEqual(error, .timeout) + XCTAssertNotNil(weakDelegate.value, + "AVFoundation's in-flight delegate must survive cancellation") + + session.deliverLateCallback() + + XCTAssertEqual(results.count, 1, + "A callback after timeout must not complete the client again") + XCTAssertNil(weakDelegate.value, + "The final capture callback is a safe delegate release point") + XCTAssertEqual(retirementScheduler.cancellations.first?.cancelCalls, 1) + } + + func testNoCallbackReleasesRetiredDelegateAfterTeardownCompletes() throws { + let retirementScheduler = StubCameraScheduler() + let session = LifecycleBackedPhotoSession(retirementScheduler: retirementScheduler) + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { _ in } + let weakDelegate = WeakBox(session.captureDelegate) + + try XCTUnwrap(scheduler.blocks.first)() + XCTAssertNotNil(weakDelegate.value) + + session.finishTeardown() + + XCTAssertNil(weakDelegate.value, + "Completed session teardown must release a delegate with no callback") + XCTAssertEqual(retirementScheduler.cancellations.first?.cancelCalls, 1) + } + + func testBlockedTeardownUsesBoundedFallbackToReleaseRetiredDelegate() throws { + let retirementScheduler = StubCameraScheduler() + let session = LifecycleBackedPhotoSession(retirementScheduler: retirementScheduler, + retirementTimeout: 3) + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { _ in } + let weakDelegate = WeakBox(session.captureDelegate) + + try XCTUnwrap(scheduler.blocks.first)() + XCTAssertNotNil(weakDelegate.value) + XCTAssertEqual(retirementScheduler.scheduledIntervals, [3]) + + try XCTUnwrap(retirementScheduler.blocks.first)() + + XCTAssertNil(weakDelegate.value, + "The independent fallback bounds retention if teardown never returns") + } + + func testCancelledLifecycleSkipsQueuedStartAndPhotoCapture() { + let cancelledBeforeStart = PhotoSessionLifecycle() + var starts = 0 + var photoCaptures = 0 + cancelledBeforeStart.cancel() + + XCTAssertFalse(cancelledBeforeStart.performIfActive { starts += 1 }) + XCTAssertFalse(cancelledBeforeStart.performIfActive { photoCaptures += 1 }) + XCTAssertEqual(starts, 0) + XCTAssertEqual(photoCaptures, 0) + + let cancelledAfterStart = PhotoSessionLifecycle() + XCTAssertTrue(cancelledAfterStart.performIfActive { starts += 1 }) + cancelledAfterStart.cancel() + XCTAssertFalse(cancelledAfterStart.performIfActive { photoCaptures += 1 }) + XCTAssertEqual(starts, 1) + XCTAssertEqual(photoCaptures, 0) + } + + func testTimeoutCompletesBeforePotentiallyBlockedTeardown() throws { + let session = BlockingStopPhotoSession() + let teardownScheduler = StubCameraTeardownScheduler() + var results: [Result] = [] + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: teardownScheduler).capture { + results.append($0) + } + + try XCTUnwrap(scheduler.blocks.first)() + + XCTAssertEqual(results.count, 1, + "Timeout completion and text fallback must precede teardown") + guard case .failure(let error) = results[0] else { + return XCTFail("Expected timeout, got \(results[0])") + } + XCTAssertEqual(error, .timeout) + XCTAssertEqual(teardownScheduler.blocks.count, 1) + + let teardownReturned = expectation(description: "teardown returned") + DispatchQueue.global(qos: .utility).async { + teardownScheduler.blocks[0]() + teardownReturned.fulfill() + } + XCTAssertEqual(session.stopStarted.wait(timeout: .now() + 1), .success) + XCTAssertEqual(results.count, 1) + session.allowStopToReturn.signal() + wait(for: [teardownReturned], timeout: 1) + } + + func testTimeoutReleasesNeverCompletingSessionAfterTeardown() throws { + let released = expectation(description: "session released") + var session: RetainingCompletionPhotoSession? = RetainingCompletionPhotoSession() + session?.onDeinit = { released.fulfill() } + let weakSession = WeakBox(session) + + CameraCapture(authorization: StubCameraAuthorization(), + sessionFactory: { .success(session!) }, + temporaryDirectory: temporaryDirectory, + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in + guard case .failure(let error) = result else { + return XCTFail("Expected timeout, got \(result)") + } + XCTAssertEqual(error, .timeout) + } + session = nil + + try XCTUnwrap(scheduler.blocks.first)() + + wait(for: [released], timeout: 1) + XCTAssertNil(weakSession.value) + } + + private func retainedResultCompletion( + owner: LifetimeToken, + completion: @escaping () -> Void + ) -> (Result) -> Void { + return { _ in + _ = owner + completion() + } + } + +} diff --git a/BLEUnlockTests/KeychainStoreTests.swift b/BLEUnlockTests/KeychainStoreTests.swift new file mode 100644 index 00000000..fbeaf437 --- /dev/null +++ b/BLEUnlockTests/KeychainStoreTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import BLEUnlock + +final class KeychainStoreTests: XCTestCase { + private let account = "test-credential" + private lazy var store = KeychainStore(service: "jp.sone.BLEUnlockTests.\(UUID().uuidString)") + + override func tearDown() { + try? store.removeValue(for: account) + super.tearDown() + } + + func testSaveReadReplaceAndDelete() throws { + try store.set("first", for: account) + XCTAssertEqual(try store.string(for: account), "first") + try store.set("second", for: account) + XCTAssertEqual(try store.string(for: account), "second") + try store.removeValue(for: account) + XCTAssertNil(try store.string(for: account)) + } +} diff --git a/BLEUnlockTests/LocalizationTests.swift b/BLEUnlockTests/LocalizationTests.swift new file mode 100644 index 00000000..3dda6009 --- /dev/null +++ b/BLEUnlockTests/LocalizationTests.swift @@ -0,0 +1,224 @@ +import Foundation +import XCTest +@testable import BLEUnlock + +final class LocalizationTests: XCTestCase { + private let telegramKeys: Set = [ + "telegram", "telegram_enable", "telegram_configure", "telegram_test", + "telegram_events", "telegram_event_away", "telegram_event_lost", + "telegram_event_unlocked", "telegram_event_intruded", + "telegram_take_photo", "telegram_attach_mac_location", "telegram_status_not_configured", + "telegram_status_enabled", "telegram_status_disabled", + "telegram_bot_token", "telegram_chat_id", "telegram_save", + "telegram_setup_help", "telegram_test_success", "telegram_test_failed", + "telegram_camera_privacy", "telegram_error_not_configured", + "telegram_camera_error_denied", "telegram_camera_error_restricted", + "telegram_camera_error_no_camera", "telegram_camera_error_setup_failed", + "telegram_camera_error_capture_failed", "telegram_camera_error_timeout", + "telegram_camera_error_file_write_failed", + "telegram_error_invalid_request", "telegram_error_unreadable_photo", + "telegram_error_transport", "telegram_error_http_status", + "telegram_error_rejected", "telegram_error_invalid_response", + "telegram_error_settings_unavailable", "telegram_error_file_cleanup", + "telegram_error_keychain_status", "telegram_failure_notification_subtitle", + "telegram_message_time", "telegram_message_rssi", + "telegram_message_coordinates", "telegram_message_accuracy", + "telegram_message_map", "telegram_location_unavailable", + "telegram_location_error", "telegram_location_send_error" + ] + + private let localizationDirectories = ["Base", "da", "de", "ja", "nb", "sv", "tr", "zh-Hans"] + private let concreteTelegramEventKeys: Set = [ + "telegram_event_away", "telegram_event_lost", + "telegram_event_unlocked", "telegram_event_intruded" + ] + private let dynamicTelegramEventReference = #"telegram_event_\(event.rawValue)"# + + private var repository: URL { + URL(fileURLWithPath: #file) + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + func testEveryLocalizationContainsAllTelegramKeys() throws { + for name in localizationDirectories { + let url = repository.appendingPathComponent("BLEUnlock/\(name).lproj/Localizable.strings") + let values = try strings(at: url) + XCTAssertTrue(telegramKeys.subtracting(values.keys).isEmpty, "Missing keys in \(name)") + for key in telegramKeys { + let value = try XCTUnwrap(values[key], "Missing \(key) in \(name)") + XCTAssertFalse(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "Empty \(key) in \(name)") + XCTAssertNotEqual(value, key, "Raw localization key in \(name)") + } + } + } + + func testCameraPrivacyTextStatesTheCompleteDataFlow() throws { + let values = try strings(at: repository.appendingPathComponent( + "BLEUnlock/Base.lproj/Localizable.strings" + )) + let privacy = try XCTUnwrap(values["telegram_camera_privacy"]).lowercased() + + for phrase in ["system default camera", "upload", "telegram", "deleted", "text"] { + XCTAssertTrue(privacy.contains(phrase), + "Camera privacy text must mention \(phrase)") + } + } + + func testEveryLocalizationContainsCameraUsageDescription() throws { + for name in localizationDirectories { + let url = repository.appendingPathComponent("BLEUnlock/\(name).lproj/InfoPlist.strings") + let values = try strings(at: url) + let description = try XCTUnwrap(values["NSCameraUsageDescription"], + "Missing NSCameraUsageDescription in \(name)") + XCTAssertFalse(description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + XCTAssertNotEqual(description, "NSCameraUsageDescription") + } + } + + func testEveryLocalizationContainsExactLocationUsageDescription() throws { + let expectedDescriptions = [ + "Base": "BLEUnlock gets this Mac's location at capture time and attaches the coordinates and map to the Telegram security notification.", + "da": "BLEUnlock henter denne Macs placering på optagelsestidspunktet og vedhæfter koordinater og kort til Telegram-sikkerhedsmeddelelsen.", + "de": "BLEUnlock ermittelt den Standort dieses Macs zum Aufnahmezeitpunkt und fügt Koordinaten und Karte der Telegram-Sicherheitsmeldung hinzu.", + "ja": "BLEUnlockは撮影時のこのMacの位置情報を取得し、座標と地図をTelegramのセキュリティ通知に添付します。", + "nb": "BLEUnlock henter posisjonen til denne Macen på opptakstidspunktet og legger ved koordinater og kart i Telegram-sikkerhetsvarslet.", + "sv": "BLEUnlock hämtar den här Mac-datorns plats när fotot tas och bifogar koordinater och karta till Telegram-säkerhetsnotisen.", + "tr": "BLEUnlock fotoğraf çekildiğinde bu Mac'in konumunu alır ve koordinatlarla haritayı Telegram güvenlik bildirimine ekler.", + "zh-Hans": "BLEUnlock 获取这台 Mac 拍照时的位置,并将坐标和地图附加到 Telegram 安全通知。" + ] + + for name in localizationDirectories { + let url = repository.appendingPathComponent("BLEUnlock/\(name).lproj/InfoPlist.strings") + let values = try strings(at: url) + let description = try XCTUnwrap(values["NSLocationUsageDescription"], + "Missing NSLocationUsageDescription in \(name)") + XCTAssertFalse(description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + XCTAssertEqual(description, expectedDescriptions[name]) + } + } + + func testBaseInfoPlistContainsMacLocationUsageDescriptionOnly() throws { + let url = repository.appendingPathComponent("BLEUnlock/Info.plist") + let values = try XCTUnwrap( + PropertyListSerialization.propertyList(from: Data(contentsOf: url), + options: [], + format: nil) as? [String: Any] + ) + + XCTAssertEqual(values["NSLocationUsageDescription"] as? String, + "BLEUnlock gets this Mac's location at capture time and attaches the coordinates and map to the Telegram security notification.") + XCTAssertNil(values["NSLocationAlwaysUsageDescription"]) + XCTAssertNil(values["NSLocationAlwaysAndWhenInUseUsageDescription"]) + XCTAssertNil(values["NSLocationWhenInUseUsageDescription"]) + } + + func testProductionEntitlementsAllowMacLocation() throws { + let url = repository.appendingPathComponent("BLEUnlock/BLEUnlock.entitlements") + let values = try XCTUnwrap( + PropertyListSerialization.propertyList(from: Data(contentsOf: url), + options: [], + format: nil) as? [String: Any] + ) + XCTAssertEqual(values["com.apple.security.personal-information.location"] as? Bool, + true) + } + + func testOnlyAppBuildConfigurationsEnableLocationResourceAccess() throws { + let source = try String(contentsOf: repository.appendingPathComponent( + "BLEUnlock.xcodeproj/project.pbxproj" + )) + let appConfigurations = ["3DD4B65F226C1C3400451B7B", "3DD4B660226C1C3400451B7B"] + let otherTargetConfigurations = [ + "3D600A4C22701A5C0068FB7B", "3D600A4D22701A5C0068FB7B", + "7E1000193000000000000001", "7E1000203000000000000001" + ] + + XCTAssertEqual(source.components(separatedBy: "ENABLE_RESOURCE_ACCESS_LOCATION = YES;").count - 1, + 2) + for identifier in appConfigurations { + XCTAssertTrue(try buildConfiguration(identifier, in: source) + .contains("ENABLE_RESOURCE_ACCESS_LOCATION = YES;")) + } + for identifier in otherTargetConfigurations { + XCTAssertFalse(try buildConfiguration(identifier, in: source) + .contains("ENABLE_RESOURCE_ACCESS_LOCATION")) + } + } + + func testProductionTelegramLocalizationReferencesAreCovered() throws { + let generatedEventKeys = Set(TelegramEvent.allCases.map { + "telegram_event_\($0.rawValue)" + }) + XCTAssertEqual(generatedEventKeys, concreteTelegramEventKeys, + "Every finite TelegramEvent value must have an explicit localization key") + XCTAssertTrue(generatedEventKeys.isSubset(of: telegramKeys)) + + let sourceFiles = [ + "CameraCapture.swift", "KeychainStore.swift", "TelegramMenuController.swift", + "TelegramNotificationService.swift", "TelegramNotifier.swift" + ] + let expression = try NSRegularExpression( + pattern: #"(?:t|NSLocalizedString)\(\"(telegram_[^\"]+)\""# + ) + var referencedKeys: Set = [] + + for name in sourceFiles { + let source = try String(contentsOf: repository.appendingPathComponent("BLEUnlock/\(name)")) + let range = NSRange(source.startIndex..., in: source) + for match in expression.matches(in: source, range: range) { + guard let keyRange = Range(match.range(at: 1), in: source) else { continue } + let key = String(source[keyRange]) + if key == dynamicTelegramEventReference { + referencedKeys.formUnion(generatedEventKeys) + } else { + XCTAssertFalse(key.contains(#"\("#), + "Unrecognized dynamic localization reference: \(key)") + referencedKeys.insert(key) + } + } + } + + XCTAssertTrue(referencedKeys.isSubset(of: telegramKeys), + "Production keys missing from completeness set: \(referencedKeys.subtracting(telegramKeys))") + } + + func testTelegramSourcesDoNotContainHardCodedEnglishErrorsOrLabels() throws { + let files = ["CameraCapture.swift", "KeychainStore.swift", + "TelegramNotificationService.swift", "TelegramNotifier.swift"] + let source = try files.map { + try String(contentsOf: repository.appendingPathComponent("BLEUnlock/\($0)")) + }.joined(separator: "\n") + let forbidden = [ + "Camera access was denied.", "Camera access is restricted.", + "No camera is available.", "The camera could not be configured.", + "The camera could not capture a photo.", "The camera capture timed out.", + "The captured photo could not be saved.", + "The Telegram request could not be created.", + "The captured photo could not be read.", "Telegram could not be reached.", + "Telegram returned HTTP status", "Telegram returned an invalid response.", + "Telegram notification failed", "Telegram settings could not be read.", + "The captured photo could not be deleted.", "Keychain operation failed" + ] + + for text in forbidden { + XCTAssertFalse(source.contains(text), "Hard-coded user-visible text: \(text)") + } + } + + private func strings(at url: URL) throws -> [String: String] { + let data = try Data(contentsOf: url) + let plist = try PropertyListSerialization.propertyList(from: data, + options: [], + format: nil) + return try XCTUnwrap(plist as? [String: String]) + } + + private func buildConfiguration(_ identifier: String, in source: String) throws -> Substring { + let start = try XCTUnwrap(source.range(of: "\(identifier) /*")) + let suffix = source[start.lowerBound...] + let end = try XCTUnwrap(suffix.range(of: "\n\t\t};")) + return suffix[..] = [] + + let token = provider.requestLocation(capturedAt: capturedAt) { results.append($0) } + client.sendAuthorization(.authorizedAlways) + client.sendLocations([ + CLLocation(coordinate: .init(latitude: 25.0330, longitude: 121.5654), + altitude: 0, + horizontalAccuracy: 18, + verticalAccuracy: -1, + timestamp: capturedAt.addingTimeInterval(2)) + ]) + client.sendLocations([]) + + XCTAssertNotNil(token) + XCTAssertEqual(results, [.success(.init(latitude: 25.0330, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: capturedAt.addingTimeInterval(2)))]) + XCTAssertEqual(client.requestLocationCalls, 1) + XCTAssertEqual(client.stopCalls, 1) + } + + func testRejectsStaleAndInvalidLocations() { + let capturedAt = Date(timeIntervalSince1970: 1_000) + let provider = makeProvider() + var result: Result? + + _ = provider.requestLocation(capturedAt: capturedAt) { result = $0 } + client.sendAuthorization(.authorizedAlways) + client.sendLocations([ + CLLocation(coordinate: .init(latitude: 25, longitude: 121), + altitude: 0, + horizontalAccuracy: 10, + verticalAccuracy: -1, + timestamp: capturedAt.addingTimeInterval(-61)) + ]) + + XCTAssertEqual(result, .failure(.invalidLocation)) + } + + func testRejectsInvalidCoordinates() { + let capturedAt = Date(timeIntervalSince1970: 1_000) + let provider = makeProvider() + var result: Result? + + _ = provider.requestLocation(capturedAt: capturedAt) { result = $0 } + client.sendAuthorization(.authorizedAlways) + client.sendLocations([location(latitude: 100, + longitude: 121, + horizontalAccuracy: 10, + timestamp: capturedAt)]) + + XCTAssertEqual(result, .failure(.invalidLocation)) + } + + func testRejectsNegativeHorizontalAccuracy() { + let capturedAt = Date(timeIntervalSince1970: 1_000) + let provider = makeProvider() + var result: Result? + + _ = provider.requestLocation(capturedAt: capturedAt) { result = $0 } + client.sendAuthorization(.authorizedAlways) + client.sendLocations([location(horizontalAccuracy: -1, timestamp: capturedAt)]) + + XCTAssertEqual(result, .failure(.invalidLocation)) + } + + func testRejectsLocationMoreThanSixtySecondsAfterCapture() { + let capturedAt = Date(timeIntervalSince1970: 1_000) + let provider = makeProvider() + var result: Result? + + _ = provider.requestLocation(capturedAt: capturedAt) { result = $0 } + client.sendAuthorization(.authorizedAlways) + client.sendLocations([location(timestamp: capturedAt.addingTimeInterval(61))]) + + XCTAssertEqual(result, .failure(.invalidLocation)) + } + + func testAcceptsLocationsExactlySixtySecondsFromCapture() { + let capturedAt = Date(timeIntervalSince1970: 1_000) + + for offset in [-60.0, 60.0] { + client = RecordingCoreLocationClient() + scheduler = ControlledLocationTimeoutScheduler() + let provider = makeProvider() + var result: Result? + let timestamp = capturedAt.addingTimeInterval(offset) + + _ = provider.requestLocation(capturedAt: capturedAt) { result = $0 } + client.sendAuthorization(.authorizedAlways) + client.sendLocations([location(timestamp: timestamp)]) + + XCTAssertEqual(result, .success(.init(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: timestamp))) + } + } + + func testTimesOutAfterFiveSecondsAndIgnoresLateCallback() { + let provider = makeProvider() + var results: [Result] = [] + + _ = provider.requestLocation(capturedAt: Date()) { results.append($0) } + XCTAssertEqual(scheduler.intervals, [5]) + scheduler.fireFirst() + client.sendLocations([freshLocation]) + + XCTAssertEqual(results, [.failure(.timeout)]) + XCTAssertEqual(client.stopCalls, 1) + } + + func testServicesDisabledCompletesOnceWithoutCreatingAClient() { + var makeClientCalls = 0 + let provider = CoreMacLocationProvider(makeClient: { + makeClientCalls += 1 + return self.client + }, + servicesEnabled: { false }, + scheduleTimeout: scheduler.schedule) + var results: [Result] = [] + + _ = provider.requestLocation(capturedAt: Date()) { results.append($0) } + + XCTAssertEqual(results, [.failure(.servicesDisabled)]) + XCTAssertEqual(makeClientCalls, 0) + XCTAssertEqual(scheduler.intervals, []) + } + + func testClientErrorCompletesOnceAndStopsTheRequest() { + client.authorizationStatus = .authorizedAlways + let provider = makeProvider() + var results: [Result] = [] + + _ = provider.requestLocation(capturedAt: Date()) { results.append($0) } + client.send(error: NSError(domain: "test", code: 1)) + client.send(error: NSError(domain: "test", code: 2)) + client.sendLocations([freshLocation]) + + XCTAssertEqual(results, [.failure(.unavailable)]) + XCTAssertEqual(client.stopCalls, 1) + } + + func testNotDeterminedRequestsAuthorizationOnlyOnceForRepeatedCallbacks() { + let provider = makeProvider() + + let token = provider.requestLocation(capturedAt: Date()) { _ in } + client.sendAuthorization(.notDetermined) + client.sendAuthorization(.notDetermined) + + XCTAssertEqual(client.authorizationCalls, 1) + XCTAssertEqual(client.requestLocationCalls, 0) + token.cancel() + } + + func testClientConstructionMovesToMainThreadWhenRequestedOffMain() { + client.authorizationStatus = .authorizedAlways + let clientCreated = expectation(description: "Client created") + let completed = expectation(description: "Request completed") + var madeClientOnMainThread = false + let provider = CoreMacLocationProvider(makeClient: { + madeClientOnMainThread = Thread.isMainThread + clientCreated.fulfill() + return self.client + }, + servicesEnabled: { true }, + scheduleTimeout: scheduler.schedule) + + DispatchQueue.global().async { + _ = provider.requestLocation(capturedAt: Date()) { _ in completed.fulfill() } + } + + wait(for: [clientCreated], timeout: 1) + XCTAssertTrue(madeClientOnMainThread) + client.send(error: NSError(domain: "test", code: 1)) + wait(for: [completed], timeout: 1) + } + + func testDeniedRestrictedAndCancelledRequestsCompleteWithoutCoordinates() { + assertAuthorization(.denied, produces: .denied) + assertAuthorization(.restricted, produces: .restricted) + + client.authorizationStatus = .notDetermined + let provider = makeProvider() + var result: Result? + let token = provider.requestLocation(capturedAt: Date()) { result = $0 } + token.cancel() + XCTAssertEqual(result, .failure(.cancelled)) + } + + private func makeProvider() -> CoreMacLocationProvider { + CoreMacLocationProvider(makeClient: { self.client }, + servicesEnabled: { true }, + scheduleTimeout: self.scheduler.schedule) + } + + private var freshLocation: CLLocation { + location(timestamp: Date()) + } + + private func location(latitude: CLLocationDegrees = 25.033, + longitude: CLLocationDegrees = 121.5654, + horizontalAccuracy: CLLocationAccuracy = 18, + timestamp: Date) -> CLLocation { + CLLocation(coordinate: .init(latitude: latitude, longitude: longitude), + altitude: 0, + horizontalAccuracy: horizontalAccuracy, + verticalAccuracy: -1, + timestamp: timestamp) + } + + private func assertAuthorization(_ status: CLAuthorizationStatus, + produces expected: MacLocationError) { + client.authorizationStatus = .notDetermined + let provider = makeProvider() + var result: Result? + _ = provider.requestLocation(capturedAt: Date()) { result = $0 } + client.sendAuthorization(status) + XCTAssertEqual(result, .failure(expected)) + } +} + +private final class RecordingCoreLocationClient: CoreLocationClient { + var authorizationStatus: CLAuthorizationStatus = .notDetermined + var onAuthorizationChange: ((CLAuthorizationStatus) -> Void)? + var onLocations: (([CLLocation]) -> Void)? + var onError: ((Error) -> Void)? + private(set) var authorizationCalls = 0 + private(set) var requestLocationCalls = 0 + private(set) var stopCalls = 0 + + func requestWhenInUseAuthorization() { authorizationCalls += 1 } + func requestLocation() { requestLocationCalls += 1 } + func stop() { stopCalls += 1 } + + func sendAuthorization(_ status: CLAuthorizationStatus) { + authorizationStatus = status + onAuthorizationChange?(status) + } + + func sendLocations(_ locations: [CLLocation]) { onLocations?(locations) } + func send(error: Error) { onError?(error) } +} + +private final class ControlledLocationTimeoutScheduler { + private(set) var intervals: [TimeInterval] = [] + private var actions: [() -> Void] = [] + + func schedule(after interval: TimeInterval, + action: @escaping () -> Void) -> () -> Void { + intervals.append(interval) + actions.append(action) + let index = actions.count - 1 + return { [weak self] in self?.actions[index] = {} } + } + + func fireFirst() { actions[0]() } +} diff --git a/BLEUnlockTests/PhotoLocationCoordinatorTests.swift b/BLEUnlockTests/PhotoLocationCoordinatorTests.swift new file mode 100644 index 00000000..62809755 --- /dev/null +++ b/BLEUnlockTests/PhotoLocationCoordinatorTests.swift @@ -0,0 +1,237 @@ +import Foundation +import XCTest +@testable import BLEUnlock + +final class PhotoLocationCoordinatorTests: XCTestCase { + private var camera: ControlledPhotoCapturer! + private var location: ControlledMacLocationProvider! + private var coordinator: PhotoLocationCoordinator! + private let capturedAt = Date(timeIntervalSince1970: 100) + private let photoURL = URL(fileURLWithPath: "/private/tmp/location-test.jpg") + private let validLocation = TelegramLocation(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: Date(timeIntervalSince1970: 100)) + + override func setUp() { + super.setUp() + camera = ControlledPhotoCapturer() + location = ControlledMacLocationProvider() + coordinator = PhotoLocationCoordinator(camera: camera, location: location) + } + + override func tearDown() { + coordinator = nil + location = nil + camera = nil + super.tearDown() + } + + func testStartsPhotoAndLocationImmediatelyAndWaitsForBoth() { + var outputs: [PhotoLocationOutcome] = [] + coordinator.capture(capturedAt: capturedAt) { outputs.append($0) } + + XCTAssertEqual(camera.captureCalls, 1) + XCTAssertEqual(location.requestedDates, [capturedAt]) + camera.complete(.success(photoURL)) + XCTAssertTrue(outputs.isEmpty) + location.complete(.success(validLocation)) + XCTAssertEqual(outputs, [.photo(photoURL, .success(validLocation))]) + } + + func testLocationFailureStillReturnsPhotoWithoutLocation() { + var outcome: PhotoLocationOutcome? + coordinator.capture(capturedAt: capturedAt) { outcome = $0 } + + location.complete(.failure(.timeout)) + camera.complete(.success(photoURL)) + + XCTAssertEqual(outcome, .photo(photoURL, .failure(.timeout))) + } + + func testCameraFailureCancelsLocationAndCompletesImmediatelyOnce() { + var outcomes: [PhotoLocationOutcome] = [] + coordinator.capture(capturedAt: capturedAt) { outcomes.append($0) } + + camera.complete(.failure(.captureFailed)) + camera.complete(.failure(.denied)) + location.complete(.success(validLocation)) + + XCTAssertEqual(location.token.cancelCalls, 1) + XCTAssertEqual(outcomes, [.cameraFailure(.captureFailed)]) + } + + func testDuplicatePhotoFailureDoesNotReplaceFirstPhotoSuccessBeforeLocationArrives() { + var outcomes: [PhotoLocationOutcome] = [] + coordinator.capture(capturedAt: capturedAt) { outcomes.append($0) } + + camera.complete(.success(photoURL)) + camera.complete(.failure(.captureFailed)) + + XCTAssertTrue(outcomes.isEmpty) + XCTAssertEqual(location.token.cancelCalls, 0) + + location.complete(.success(validLocation)) + + XCTAssertEqual(outcomes, [.photo(photoURL, .success(validLocation))]) + XCTAssertEqual(location.token.cancelCalls, 0) + } + + func testDuplicateLocationSuccessDoesNotReplaceFirstFailureBeforePhotoArrives() { + var outcomes: [PhotoLocationOutcome] = [] + coordinator.capture(capturedAt: capturedAt) { outcomes.append($0) } + + location.complete(.failure(.timeout)) + location.complete(.success(validLocation)) + camera.complete(.success(photoURL)) + + XCTAssertEqual(outcomes, [.photo(photoURL, .failure(.timeout))]) + XCTAssertEqual(location.token.cancelCalls, 0) + } + + func testSynchronousCallbacksCompleteExactlyOnce() { + let immediateLocation = ImmediateMacLocationProvider(result: .success(validLocation)) + let immediateCamera = ImmediatePhotoCapturer(result: .success(photoURL)) + let coordinator = PhotoLocationCoordinator(camera: immediateCamera, + location: immediateLocation) + var outcomes: [PhotoLocationOutcome] = [] + + coordinator.capture(capturedAt: capturedAt) { outcomes.append($0) } + + XCTAssertEqual(immediateLocation.requestedDates, [capturedAt]) + XCTAssertEqual(immediateCamera.captureCalls, 1) + XCTAssertEqual(outcomes, [.photo(photoURL, .success(validLocation))]) + } + + func testConcurrentPhotoAndLocationCallbacksCompleteExactlyOnce() { + let lock = NSLock() + var outcomes: [PhotoLocationOutcome] = [] + coordinator.capture(capturedAt: capturedAt) { outcome in + lock.lock() + outcomes.append(outcome) + lock.unlock() + } + + DispatchQueue.concurrentPerform(iterations: 2) { iteration in + if iteration == 0 { + camera.complete(.success(photoURL)) + } else { + location.complete(.success(validLocation)) + } + } + + lock.lock() + let recordedOutcomes = outcomes + lock.unlock() + XCTAssertEqual(recordedOutcomes, [.photo(photoURL, .success(validLocation))]) + } +} + +private final class ControlledPhotoCapturer: PhotoCapturing { + private let lock = NSLock() + private var completion: ((Result) -> Void)? + private var storedCaptureCalls = 0 + + var captureCalls: Int { + lock.lock() + defer { lock.unlock() } + return storedCaptureCalls + } + + func capture(completion: @escaping (Result) -> Void) { + lock.lock() + storedCaptureCalls += 1 + self.completion = completion + lock.unlock() + } + + func complete(_ result: Result) { + lock.lock() + let completion = completion + lock.unlock() + completion?(result) + } +} + +private final class RecordingLocationToken: LocationRequestCancelling { + private let lock = NSLock() + private var storedCancelCalls = 0 + + var cancelCalls: Int { + lock.lock() + defer { lock.unlock() } + return storedCancelCalls + } + + func cancel() { + lock.lock() + storedCancelCalls += 1 + lock.unlock() + } +} + +private final class ControlledMacLocationProvider: MacLocationProviding { + let token = RecordingLocationToken() + private let lock = NSLock() + private var storedRequestedDates: [Date] = [] + private var completion: ((Result) -> Void)? + + var requestedDates: [Date] { + lock.lock() + defer { lock.unlock() } + return storedRequestedDates + } + + @discardableResult + func requestLocation( + capturedAt: Date, + completion: @escaping (Result) -> Void + ) -> LocationRequestCancelling { + lock.lock() + storedRequestedDates.append(capturedAt) + self.completion = completion + lock.unlock() + return token + } + + func complete(_ result: Result) { + lock.lock() + let completion = completion + lock.unlock() + completion?(result) + } +} + +private final class ImmediatePhotoCapturer: PhotoCapturing { + let result: Result + private(set) var captureCalls = 0 + + init(result: Result) { + self.result = result + } + + func capture(completion: @escaping (Result) -> Void) { + captureCalls += 1 + completion(result) + } +} + +private final class ImmediateMacLocationProvider: MacLocationProviding { + let result: Result + let token = RecordingLocationToken() + private(set) var requestedDates: [Date] = [] + + init(result: Result) { + self.result = result + } + + @discardableResult + func requestLocation( + capturedAt: Date, + completion: @escaping (Result) -> Void + ) -> LocationRequestCancelling { + requestedDates.append(capturedAt) + completion(result) + return token + } +} diff --git a/BLEUnlockTests/TelegramMenuControllerTests.swift b/BLEUnlockTests/TelegramMenuControllerTests.swift new file mode 100644 index 00000000..f39172c8 --- /dev/null +++ b/BLEUnlockTests/TelegramMenuControllerTests.swift @@ -0,0 +1,225 @@ +import AppKit +import XCTest +@testable import BLEUnlock + +final class TelegramMenuControllerTests: XCTestCase { + private var defaultsSuiteName: String! + private var defaults: UserDefaults! + private var settings: TelegramSettings! + private var service: RecordingTelegramNotificationService! + private var dialogs: RecordingTelegramDialogPresenter! + private var locationAuthorization: RecordingLocationAuthorizationRequester! + private var controller: TelegramMenuController! + + override func setUp() { + super.setUp() + defaultsSuiteName = "jp.sone.BLEUnlockTests.TelegramMenuController.\(UUID())" + defaults = UserDefaults(suiteName: defaultsSuiteName)! + defaults.removePersistentDomain(forName: defaultsSuiteName) + settings = TelegramSettings(defaults: defaults, secrets: MemorySecretStore()) + service = RecordingTelegramNotificationService() + dialogs = RecordingTelegramDialogPresenter() + locationAuthorization = RecordingLocationAuthorizationRequester() + controller = TelegramMenuController(settings: settings, + service: service, + dialogs: dialogs, + locationAuthorization: locationAuthorization, + hostName: { "Fred-Mac" }) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: defaultsSuiteName) + defaultsSuiteName = nil + defaults = nil + settings = nil + service = nil + dialogs = nil + locationAuthorization = nil + controller = nil + super.tearDown() + } + + func testUnconfiguredMenuDisablesEnableAndTestAndShowsNotConfigured() { + controller.menuWillOpen(controller.menu) + + XCTAssertFalse(controller.enableItem.isEnabled) + XCTAssertFalse(controller.testItem.isEnabled) + XCTAssertEqual(controller.statusItem.title, t("telegram_status_not_configured")) + } + + func testConfiguredMenuCanEnableTelegram() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + controller.menuWillOpen(controller.menu) + + XCTAssertTrue(controller.enableItem.isEnabled) + XCTAssertEqual(controller.enableItem.state, .off) + + controller.toggleEnabled(controller.enableItem) + + XCTAssertTrue(settings.isEnabled) + XCTAssertEqual(controller.enableItem.state, .on) + } + + func testEventAndPhotoItemsReflectAndPersistSettings() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + controller.menuWillOpen(controller.menu) + + XCTAssertEqual(controller.eventItems[.away]?.state, .on) + XCTAssertEqual(controller.eventItems[.unlocked]?.state, .off) + XCTAssertEqual(controller.photoItem.state, .on) + + controller.toggleEvent(controller.eventItems[.unlocked]!) + controller.togglePhoto(controller.photoItem) + + XCTAssertTrue(settings.isEventEnabled(.unlocked)) + XCTAssertFalse(settings.takePhotoOnIntruded) + } + + func testConfigureLeavesExistingTokenWhenTokenFieldIsBlank() throws { + try settings.saveCredentials(replacementToken: "original", chatID: "old-chat") + dialogs.credentialInput = .init(replacementToken: nil, chatID: "new-chat") + + controller.configure() + + XCTAssertTrue(dialogs.requestWasOnMainThread) + XCTAssertEqual(dialogs.hasStoredTokenValues, [true]) + XCTAssertEqual(try settings.credentials(), + .init(token: "original", chatID: "new-chat")) + } + + func testConfigureReplacesTokenWhenNewValueIsEntered() throws { + try settings.saveCredentials(replacementToken: "original", chatID: "old-chat") + dialogs.credentialInput = .init(replacementToken: " replacement ", + chatID: "new-chat") + + controller.configure() + + XCTAssertEqual(try settings.credentials(), + .init(token: "replacement", chatID: "new-chat")) + } + + func testSendTestCallsServiceAndPresentsResult() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + let presented = expectation(description: "Result presented") + dialogs.onShowResult = { presented.fulfill() } + + controller.sendTest() + + wait(for: [presented], timeout: 2) + XCTAssertEqual(service.hostNames, ["Fred-Mac"]) + XCTAssertEqual(dialogs.results.count, 1) + XCTAssertEqual(dialogs.results.first?.title, t("telegram_test_success")) + XCTAssertTrue(dialogs.showResultWasOnMainThread) + } + + func testCameraPrivacyExplanationIsVisibleAdjacentToPhotoToggle() { + let photoIndex = controller.menu.index(of: controller.photoItem) + let privacyIndex = controller.menu.index(of: controller.privacyItem) + + XCTAssertEqual(privacyIndex, photoIndex + 1) + XCTAssertEqual(controller.privacyItem.title, t("telegram_camera_privacy")) + XCTAssertFalse(controller.privacyItem.isEnabled) + } + + func testLocationItemIsBelowPrivacyTextAndDisabledWhenPhotoIsOff() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + settings.takePhotoOnIntruded = false + controller.menuWillOpen(controller.menu) + + XCTAssertEqual(controller.menu.index(of: controller.locationItem), + controller.menu.index(of: controller.privacyItem) + 1) + XCTAssertFalse(controller.locationItem.isEnabled) + XCTAssertEqual(controller.locationItem.state, .off) + } + + func testTurningPhotoOffDisablesLocationItem() { + controller.menuWillOpen(controller.menu) + + controller.togglePhoto(controller.photoItem) + + XCTAssertFalse(controller.locationItem.isEnabled) + } + + func testEnablingLocationPersistsAndRequestsAuthorizationOnce() { + controller.toggleLocation(controller.locationItem) + + XCTAssertTrue(settings.attachMacLocation) + XCTAssertEqual(locationAuthorization.requestCalls, 1) + XCTAssertEqual(controller.locationItem.state, .on) + + controller.toggleLocation(controller.locationItem) + + XCTAssertFalse(settings.attachMacLocation) + XCTAssertEqual(locationAuthorization.requestCalls, 1) + } + + func testControllerDoesNotReadOrRetainTelegramCredentials() throws { + let repository = URL(fileURLWithPath: #file) + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String(contentsOf: repository.appendingPathComponent( + "BLEUnlock/TelegramMenuController.swift" + )) + + XCTAssertFalse(source.contains("settings.credentials()")) + XCTAssertFalse(source.contains("TelegramCredentials")) + XCTAssertTrue(source.contains("saveCredentials(replacementToken:")) + } +} + +private final class RecordingLocationAuthorizationRequester: LocationAuthorizationRequesting { + private(set) var requestCalls = 0 + + func requestAuthorization() { + requestCalls += 1 + } +} + +private final class RecordingTelegramNotificationService: TelegramNotificationHandling { + private let lock = NSLock() + private var recordedHostNames: [String] = [] + var result: Result = .success(()) + + var hostNames: [String] { + lock.lock() + defer { lock.unlock() } + return recordedHostNames + } + + func handle(_ context: TelegramEventContext) {} + + func sendTest(hostName: String, + completion: @escaping (Result) -> Void) { + lock.lock() + recordedHostNames.append(hostName) + lock.unlock() + completion(result) + } +} + +private final class RecordingTelegramDialogPresenter: TelegramDialogPresenting { + struct PresentedResult { + let title: String + let message: String + } + + var credentialInput: TelegramCredentialInput? + private(set) var hasStoredTokenValues: [Bool] = [] + private(set) var results: [PresentedResult] = [] + private(set) var requestWasOnMainThread = false + private(set) var showResultWasOnMainThread = false + var onShowResult: (() -> Void)? + + func requestCredentials(hasStoredToken: Bool, + completion: (TelegramCredentialInput?) -> Void) { + requestWasOnMainThread = Thread.isMainThread + hasStoredTokenValues.append(hasStoredToken) + completion(credentialInput) + } + + func showResult(title: String, message: String) { + showResultWasOnMainThread = Thread.isMainThread + results.append(.init(title: title, message: message)) + onShowResult?() + } +} diff --git a/BLEUnlockTests/TelegramNotificationServiceTests.swift b/BLEUnlockTests/TelegramNotificationServiceTests.swift new file mode 100644 index 00000000..e948e460 --- /dev/null +++ b/BLEUnlockTests/TelegramNotificationServiceTests.swift @@ -0,0 +1,502 @@ +import AppKit +import Foundation +import XCTest +@testable import BLEUnlock + +final class TelegramNotificationServiceTests: XCTestCase { + private var defaultsSuiteName: String! + private var defaults: UserDefaults! + private var settings: TelegramSettings! + private var sender: RecordingTelegramSender! + private var camera: StubPhotoCapturer! + private var location: StubMacLocationProvider! + private var reporter: RecordingFailureReporter! + private var remover: RecordingFileRemover! + private var service: TelegramNotificationService! + private let photoURL = URL(fileURLWithPath: "/private/tmp/BLEUnlock-test-photo.jpg") + + override func setUp() { + super.setUp() + defaultsSuiteName = "jp.sone.BLEUnlockTests.TelegramNotificationService.\(UUID())" + defaults = UserDefaults(suiteName: defaultsSuiteName) + defaults.removePersistentDomain(forName: defaultsSuiteName) + settings = TelegramSettings(defaults: defaults, secrets: MemorySecretStore()) + sender = RecordingTelegramSender() + camera = StubPhotoCapturer() + location = StubMacLocationProvider() + reporter = RecordingFailureReporter() + remover = RecordingFileRemover() + service = TelegramNotificationService( + settings: settings, + sender: sender, + camera: camera, + location: location, + removeFile: remover.remove, + reporter: reporter + ) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: defaultsSuiteName) + defaultsSuiteName = nil + defaults = nil + settings = nil + sender = nil + camera = nil + location = nil + reporter = nil + remover = nil + service = nil + super.tearDown() + } + + func testDisabledTelegramDoesNothing() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + + service.handle(context(event: .intruded)) + + assertNoCameraOrNetworkCalls() + } + + func testUnconfiguredTelegramDoesNothing() { + settings.isEnabled = true + + service.handle(context(event: .intruded)) + + assertNoCameraOrNetworkCalls() + } + + func testKeychainReadFailureIsReportedWithoutLeakingUnderlyingError() { + let secret = "token-SECRET" + let failure = NSError(domain: secret, + code: 17, + userInfo: [NSLocalizedDescriptionKey: "Could not read \(secret)"]) + settings = TelegramSettings(defaults: defaults, + secrets: ThrowingSecretStore(error: failure)) + settings.isEnabled = true + service = TelegramNotificationService( + settings: settings, + sender: sender, + camera: camera, + location: location, + removeFile: remover.remove, + reporter: reporter + ) + + service.handle(context(event: .intruded)) + + assertNoCameraOrNetworkCalls() + XCTAssertEqual(reporter.categories, ["settings"]) + XCTAssertEqual(reporter.messages, [t("telegram_error_settings_unavailable")]) + XCTAssertFalse(reporter.messages.joined().contains(secret)) + } + + func testDisabledEventDoesNothing() throws { + try configure() + settings.setEvent(.intruded, enabled: false) + + service.handle(context(event: .intruded)) + + assertNoCameraOrNetworkCalls() + } + + func testAwaySendsHostTimeEventAndRSSIAsText() throws { + try configure() + + service.handle(context(event: .away, rssi: -47)) + + XCTAssertEqual(camera.captureCalls, 0) + XCTAssertEqual(sender.photoCalls.count, 0) + XCTAssertEqual(sender.textCalls.count, 1) + let call = try XCTUnwrap(sender.textCalls.first) + XCTAssertEqual(call.credentials, .init(token: "token", chatID: "chat")) + let lines = call.text.components(separatedBy: "\n") + XCTAssertEqual(lines.count, 3) + let eventDescription = NSLocalizedString("telegram_event_away", + value: "Device Away", + comment: "") + XCTAssertEqual(lines[0], "Fred-Mac — \(eventDescription)") + XCTAssertTrue(lines[1].hasPrefix("\(t("telegram_message_time")): ")) + XCTAssertEqual(lines[2], "\(t("telegram_message_rssi")): -47 dBm") + } + + func testIntrudedWithPhotoSendsPhotoAndDeletesFileOnSuccess() throws { + try configure() + camera.result = .success(photoURL) + + service.handle(context(event: .intruded)) + + XCTAssertEqual(camera.captureCalls, 1) + XCTAssertEqual(sender.photoCalls.count, 1) + XCTAssertEqual(sender.photoCalls.first?.photoURL, photoURL) + XCTAssertEqual(sender.textCalls.count, 0) + XCTAssertEqual(remover.calls, [photoURL]) + XCTAssertTrue(reporter.categories.isEmpty) + } + + func testIntrudedDeletesPhotoWhenUploadFailsWithoutTextRetry() throws { + try configure() + camera.result = .success(photoURL) + sender.photoResult = .failure(.transport) + + service.handle(context(event: .intruded)) + + XCTAssertEqual(sender.photoCalls.count, 1) + XCTAssertEqual(sender.textCalls.count, 0) + XCTAssertEqual(remover.calls, [photoURL]) + XCTAssertEqual(reporter.categories, ["telegram"]) + } + + func testCancelledPhotoUploadDeletesPhotoWithoutTextRetry() throws { + try configure() + camera.result = .success(photoURL) + sender.photoResult = .failure(.transport) + + service.handle(context(event: .intruded)) + + XCTAssertEqual(sender.photoCalls.count, 1) + XCTAssertEqual(sender.textCalls.count, 0) + XCTAssertEqual(remover.calls, [photoURL]) + XCTAssertEqual(reporter.categories, ["telegram"]) + } + + func testCaptureFailureFallsBackToTextAndReportsFailure() throws { + try configure() + camera.result = .failure(.denied) + + service.handle(context(event: .intruded, rssi: -47)) + + XCTAssertEqual(sender.photoCalls.count, 0) + XCTAssertEqual(sender.textCalls.count, 1) + XCTAssertEqual(reporter.categories, ["camera"]) + } + + func testRequestConstructionFailureDeletesPhoto() throws { + try configure() + camera.result = .success(photoURL) + sender.photoResult = .failure(.invalidRequest) + + service.handle(context(event: .intruded)) + + XCTAssertEqual(sender.photoCalls.count, 1) + XCTAssertEqual(sender.textCalls.count, 0) + XCTAssertEqual(remover.calls, [photoURL]) + XCTAssertEqual(reporter.categories, ["telegram"]) + } + + func testLocationEnabledSendsCaptionedPhotoThenNativeMap() throws { + try configure() + settings.attachMacLocation = true + camera.result = .success(photoURL) + let validLocation = TelegramLocation(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: Date(timeIntervalSince1970: 100)) + location.result = .success(validLocation) + + service.handle(context(event: .intruded)) + + XCTAssertEqual(sender.photoCalls.count, 1) + XCTAssertTrue(sender.photoCalls[0].caption.contains("25.033000, 121.565400")) + XCTAssertEqual(sender.locationCalls.map(\.location), [validLocation]) + XCTAssertEqual(sender.callOrder, [.photo, .location]) + } + + func testLocationDisabledNeverCallsProviderOrSendsMap() throws { + try configure() + settings.attachMacLocation = false + camera.result = .success(photoURL) + + service.handle(context(event: .intruded)) + + XCTAssertTrue(location.requestedDates.isEmpty) + XCTAssertTrue(sender.locationCalls.isEmpty) + XCTAssertEqual(sender.callOrder, [.photo]) + } + + func testLocationFailureSendsPhotoWithUnavailableCaptionAndNoMap() throws { + try configure() + settings.attachMacLocation = true + camera.result = .success(photoURL) + location.result = .failure(.timeout) + + service.handle(context(event: .intruded)) + + XCTAssertTrue(sender.photoCalls[0].caption.contains(t("telegram_location_unavailable"))) + XCTAssertTrue(sender.locationCalls.isEmpty) + XCTAssertEqual(reporter.categories, ["location"]) + XCTAssertEqual(reporter.messages, [t("telegram_location_error")]) + } + + func testLocatedCameraFailureCancelsLocationAndUsesTextFallbackWithoutCoordinates() throws { + try configure() + settings.attachMacLocation = true + camera.result = .failure(.denied) + location.result = .success(.init(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: Date(timeIntervalSince1970: 100))) + + service.handle(context(event: .intruded)) + + XCTAssertEqual(location.token.cancelCalls, 1) + XCTAssertEqual(sender.textCalls.count, 1) + XCTAssertFalse(sender.textCalls[0].text.contains("25.033000")) + XCTAssertTrue(sender.photoCalls.isEmpty) + XCTAssertTrue(sender.locationCalls.isEmpty) + XCTAssertEqual(sender.callOrder, [.text]) + } + + func testPhotoUploadFailureDoesNotSendNativeMapAndCleansFile() throws { + try configure() + settings.attachMacLocation = true + camera.result = .success(photoURL) + location.result = .success(.init(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: Date(timeIntervalSince1970: 100))) + sender.photoResult = .failure(.transport) + + service.handle(context(event: .intruded)) + + XCTAssertTrue(sender.locationCalls.isEmpty) + XCTAssertEqual(sender.callOrder, [.photo]) + XCTAssertEqual(remover.calls, [photoURL]) + } + + func testNativeMapFailureReportsWithoutResendingPhoto() throws { + try configure() + settings.attachMacLocation = true + camera.result = .success(photoURL) + location.result = .success(.init(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: Date(timeIntervalSince1970: 100))) + sender.locationResult = .failure(.transport) + + service.handle(context(event: .intruded)) + + XCTAssertEqual(sender.photoCalls.count, 1) + XCTAssertEqual(sender.locationCalls.count, 1) + XCTAssertEqual(sender.callOrder, [.photo, .location]) + XCTAssertEqual(reporter.categories.last, "telegram-location") + XCTAssertEqual(reporter.messages.last, t("telegram_location_send_error")) + } + + func testLocatedTestNotificationUsesOneTimestampForCaptionAndLocation() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + settings.attachMacLocation = true + camera.result = .success(photoURL) + location.result = .success(.init(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: Date())) + let formatter = RecordingTelegramMessageFormatter() + service = TelegramNotificationService( + settings: settings, + sender: sender, + camera: camera, + location: location, + removeFile: remover.remove, + reporter: reporter, + formatter: formatter + ) + var result: Result? + + service.sendTest(hostName: "Fred-Mac") { result = $0 } + + assertSuccess(result) + XCTAssertEqual(formatter.photoCaptionContexts.count, 1) + XCTAssertEqual(location.requestedDates, [formatter.photoCaptionContexts[0].timestamp]) + } + + func testTestNotificationUsesPhotoSetting() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + camera.result = .success(photoURL) + var photoResult: Result? + + service.sendTest(hostName: "Fred-Mac") { photoResult = $0 } + + assertSuccess(photoResult) + XCTAssertEqual(camera.captureCalls, 1) + XCTAssertEqual(sender.photoCalls.count, 1) + XCTAssertEqual(sender.textCalls.count, 0) + XCTAssertEqual(remover.calls, [photoURL]) + + settings.takePhotoOnIntruded = false + var textResult: Result? + service.sendTest(hostName: "Fred-Mac") { textResult = $0 } + + assertSuccess(textResult) + XCTAssertEqual(camera.captureCalls, 1) + XCTAssertEqual(sender.photoCalls.count, 1) + XCTAssertEqual(sender.textCalls.count, 1) + } + + func testPhotoEnabledTestSurfacesCaptureFailureAfterTextFallback() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + camera.result = .failure(.denied) + var result: Result? + + service.sendTest(hostName: "Fred-Mac") { result = $0 } + + guard case .failure(let error)? = result else { + return XCTFail("Expected camera failure, got \(String(describing: result))") + } + XCTAssertEqual(error as? CameraCaptureError, .denied) + XCTAssertEqual(sender.textCalls.count, 1, + "The test alert may still fall back to text") + XCTAssertEqual(sender.photoCalls.count, 0) + XCTAssertEqual(reporter.categories, ["camera"]) + } + + func testUserNotificationDeliveryCreatesAndDeliversOnMainThread() { + let delivered = expectation(description: "notification delivered") + let center = RecordingUserNotificationCenter() + center.onDeliver = { delivered.fulfill() } + var factoryWasOnMainThread = false + let delivery = UserNotificationFailureDelivery( + notificationCenter: center, + notificationFactory: { + factoryWasOnMainThread = Thread.isMainThread + return NSUserNotification() + } + ) + + DispatchQueue.global(qos: .utility).async { + delivery.deliver(message: "Offline") + } + + wait(for: [delivered], timeout: 1) + XCTAssertTrue(factoryWasOnMainThread) + XCTAssertTrue(center.deliveryWasOnMainThread) + XCTAssertEqual(center.notifications.first?.subtitle, + t("telegram_failure_notification_subtitle")) + XCTAssertEqual(center.notifications.first?.informativeText, "Offline") + } + + func testFailureReporterRateLimitsSameFailureForFiveMinutes() { + var now = Date(timeIntervalSince1970: 100) + let delivery = RecordingFailureNotificationDelivery() + let rateLimitedReporter = RateLimitedFailureReporter( + now: { now }, + interval: 300, + notificationDelivery: delivery + ) + + rateLimitedReporter.report(category: "camera", message: "Denied") + rateLimitedReporter.report(category: "camera", message: "Denied again") + rateLimitedReporter.report(category: "telegram", message: "Offline") + now.addTimeInterval(299) + rateLimitedReporter.report(category: "camera", message: "Still denied") + + XCTAssertEqual(delivery.messages, ["Denied", "Offline"]) + + now.addTimeInterval(1) + rateLimitedReporter.report(category: "camera", message: "Denied after interval") + + XCTAssertEqual(delivery.messages, ["Denied", "Offline", "Denied after interval"]) + } + + func testProductionAppWiresCoreLocationIntoTelegramMenuAndService() throws { + let repository = URL(fileURLWithPath: #file) + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String(contentsOf: repository.appendingPathComponent( + "BLEUnlock/AppDelegate.swift" + )) + XCTAssertTrue(source.contains("CoreMacLocationProvider")) + XCTAssertTrue(source.contains("location: macLocationProvider")) + XCTAssertTrue(source.contains("locationAuthorization: macLocationProvider")) + } + + func testPhotoCaptionIncludesCoordinatesAccuracyAndEscapedAppleMapsLink() { + let formatter = TelegramMessageFormatter() + let context = TelegramEventContext(event: .intruded, + hostName: "Fred-Mac", + timestamp: Date(timeIntervalSince1970: 1_000), + rssi: nil) + let location = TelegramLocation(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18.4, + timestamp: context.timestamp) + + let caption = formatter.photoCaption(for: context, location: location) + + XCTAssertTrue(caption.contains("25.033000, 121.565400")) + XCTAssertTrue(caption.contains("±18 m")) + XCTAssertTrue(caption.contains("https://maps.apple.com/?ll=25.033000,121.565400")) + } + + func testPhotoCaptionMarksLocationUnavailableWithoutCoordinates() { + let formatter = TelegramMessageFormatter() + let context = TelegramEventContext(event: .intruded, + hostName: "Fred-Mac", + timestamp: Date(timeIntervalSince1970: 1_000), + rssi: nil) + + let caption = formatter.photoCaption(for: context, location: nil) + + XCTAssertTrue(caption.contains(t("telegram_location_unavailable"))) + XCTAssertFalse(caption.contains("maps.apple.com")) + } + + private func configure() throws { + try settings.saveCredentials(replacementToken: "token", chatID: "chat") + settings.isEnabled = true + } + + private func context(event: TelegramEvent, rssi: Int? = nil) -> TelegramEventContext { + .init(event: event, + hostName: "Fred-Mac", + timestamp: Date(timeIntervalSince1970: 100), + rssi: rssi) + } + + private func assertNoCameraOrNetworkCalls(file: StaticString = #filePath, + line: UInt = #line) { + XCTAssertEqual(camera.captureCalls, 0, file: file, line: line) + XCTAssertTrue(location.requestedDates.isEmpty, file: file, line: line) + XCTAssertEqual(sender.textCalls.count, 0, file: file, line: line) + XCTAssertEqual(sender.photoCalls.count, 0, file: file, line: line) + XCTAssertEqual(sender.locationCalls.count, 0, file: file, line: line) + } + + private func assertSuccess(_ result: Result?, + file: StaticString = #filePath, + line: UInt = #line) { + guard case .success? = result else { + return XCTFail("Expected success, got \(String(describing: result))", + file: file, + line: line) + } + } +} + +private final class RecordingTelegramMessageFormatter: TelegramMessageFormatting { + private(set) var messageContexts: [TelegramEventContext] = [] + private(set) var photoCaptionContexts: [TelegramEventContext] = [] + + func message(for context: TelegramEventContext) -> String { + messageContexts.append(context) + return "message" + } + + func photoCaption(for context: TelegramEventContext, + location: TelegramLocation?) -> String { + photoCaptionContexts.append(context) + return "caption" + } +} + +private final class RecordingUserNotificationCenter: UserNotificationCenterDelivering { + private(set) var notifications: [NSUserNotification] = [] + private(set) var deliveryWasOnMainThread = false + var onDeliver: (() -> Void)? + + func deliver(_ notification: NSUserNotification) { + deliveryWasOnMainThread = Thread.isMainThread + notifications.append(notification) + onDeliver?() + } +} diff --git a/BLEUnlockTests/TelegramNotifierTests.swift b/BLEUnlockTests/TelegramNotifierTests.swift new file mode 100644 index 00000000..c730c830 --- /dev/null +++ b/BLEUnlockTests/TelegramNotifierTests.swift @@ -0,0 +1,299 @@ +import Foundation +import XCTest +@testable import BLEUnlock + +final class TelegramNotifierTests: XCTestCase { + private var transport: RecordingHTTPTransport! + private var notifier: TelegramNotifier! + + override func setUp() { + super.setUp() + transport = RecordingHTTPTransport() + notifier = TelegramNotifier(transport: transport) + } + + func testSendTextBuildsFormRequestAndAcceptsOKResponse() throws { + transport.result = .success((Data(#"{"ok":true}"#.utf8), response(status: 200))) + let done = expectation(description: "completion") + + notifier.sendText(credentials: .init(token: "token-SECRET", chatID: "987654"), + text: "Fred & Mac") { result in + guard case .success = result else { + return XCTFail("Expected success, got \(result)") + } + done.fulfill() + } + + wait(for: [done], timeout: 1) + let request = try XCTUnwrap(transport.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/bottoken-SECRET/sendMessage") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), + "application/x-www-form-urlencoded") + let body = String(decoding: try XCTUnwrap(request.httpBody), as: UTF8.self) + XCTAssertTrue(body.contains("chat_id=987654")) + XCTAssertTrue(body.contains("text=Fred%20%26%20Mac")) + } + + func testSendTextPercentEncodesLiteralPlusForFormBody() throws { + transport.result = .success((Data(#"{"ok":true}"#.utf8), response(status: 200))) + let done = expectation(description: "completion") + + notifier.sendText(credentials: .init(token: "token-SECRET", chatID: "987654"), + text: "A+B") { result in + guard case .success = result else { + return XCTFail("Expected success, got \(result)") + } + done.fulfill() + } + + wait(for: [done], timeout: 1) + let request = try XCTUnwrap(transport.requests.first) + let body = String(decoding: try XCTUnwrap(request.httpBody), as: UTF8.self) + XCTAssertTrue(body.contains("text=A%2BB")) + } + + func testSendLocationUsesTelegramEndpointAndFormFields() throws { + transport.result = .success((Data(#"{"ok":true}"#.utf8), response(status: 200))) + let credentials = TelegramCredentials(token: "token-SECRET", chatID: "987654") + let location = TelegramLocation(latitude: 25.0330, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: Date()) + let done = expectation(description: "completion") + + notifier.sendLocation(credentials: credentials, location: location) { result in + guard case .success = result else { + return XCTFail("Expected success, got \(result)") + } + done.fulfill() + } + + wait(for: [done], timeout: 1) + let request = try XCTUnwrap(transport.requests.first) + XCTAssertEqual(request.url?.path, "/bottoken-SECRET/sendLocation") + XCTAssertEqual(request.httpMethod, "POST") + let body = String(decoding: try XCTUnwrap(request.httpBody), as: UTF8.self) + XCTAssertTrue(body.contains("chat_id=987654")) + XCTAssertTrue(body.contains("latitude=25.033")) + XCTAssertTrue(body.contains("longitude=121.5654")) + } + + func testSendLocationRedactsCredentialsFromRejectedResponse() { + let payload = #"{"ok":false,"description":"token-SECRET 987654 rejected"}"# + transport.result = .success((Data(payload.utf8), response(status: 200))) + let done = expectation(description: "completion") + let location = TelegramLocation(latitude: 25.033, + longitude: 121.5654, + horizontalAccuracy: 18, + timestamp: Date()) + + notifier.sendLocation(credentials: .init(token: "token-SECRET", chatID: "987654"), + location: location) { result in + guard case .failure(let error) = result else { + return XCTFail("Expected rejection, got \(result)") + } + XCTAssertEqual(error, .rejected("[redacted] [redacted] rejected")) + done.fulfill() + } + + wait(for: [done], timeout: 1) + } + + func testSendPhotoBuildsMultipartWithJPEGAndCaption() throws { + let photoBytes = Data([0xFF, 0xD8, 0x00, 0x7F, 0xD9]) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, + withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let photoURL = directory.appendingPathComponent("capture.jpg") + try photoBytes.write(to: photoURL) + transport.result = .success((Data(#"{"ok":true}"#.utf8), response(status: 200))) + let done = expectation(description: "completion") + + notifier.sendPhoto(credentials: .init(token: "token-SECRET", chatID: "987654"), + photoURL: photoURL, + caption: "Door opened") { result in + guard case .success = result else { + return XCTFail("Expected success, got \(result)") + } + done.fulfill() + } + + wait(for: [done], timeout: 1) + let request = try XCTUnwrap(transport.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/bottoken-SECRET/sendPhoto") + let contentType = try XCTUnwrap(request.value(forHTTPHeaderField: "Content-Type")) + XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary=")) + let boundary = String(contentType.dropFirst("multipart/form-data; boundary=".count)) + XCTAssertFalse(boundary.isEmpty) + var expectedBody = Data() + expectedBody.append(Data("--\(boundary)\r\n".utf8)) + expectedBody.append(Data("Content-Disposition: form-data; name=\"chat_id\"\r\n\r\n".utf8)) + expectedBody.append(Data("987654\r\n".utf8)) + expectedBody.append(Data("--\(boundary)\r\n".utf8)) + expectedBody.append(Data("Content-Disposition: form-data; name=\"caption\"\r\n\r\n".utf8)) + expectedBody.append(Data("Door opened\r\n".utf8)) + expectedBody.append(Data("--\(boundary)\r\n".utf8)) + expectedBody.append(Data("Content-Disposition: form-data; name=\"photo\"; filename=\"capture.jpg\"\r\n".utf8)) + expectedBody.append(Data("Content-Type: image/jpeg\r\n\r\n".utf8)) + expectedBody.append(photoBytes) + expectedBody.append(Data("\r\n--\(boundary)--\r\n".utf8)) + XCTAssertEqual(request.httpBody, expectedBody) + } + + func testSendPhotoInvalidCredentialsReturnInvalidRequest() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, + withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let photoURL = directory.appendingPathComponent("capture.jpg") + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: photoURL) + transport.result = .success((Data(#"{"ok":true}"#.utf8), response(status: 200))) + let done = expectation(description: "completion") + + notifier.sendPhoto(credentials: .init(token: "", chatID: "987654"), + photoURL: photoURL, + caption: "Door opened") { result in + defer { done.fulfill() } + guard case .failure(let error) = result else { + return XCTFail("Expected failure") + } + XCTAssertEqual(error, .invalidRequest) + } + + wait(for: [done], timeout: 1) + XCTAssertTrue(transport.requests.isEmpty) + } + + func testTelegramOKFalseReturnsSanitizedDescription() { + transport.result = .success((Data(#"{"ok":false,"description":"Forbidden"}"#.utf8), + response(status: 200))) + let done = expectation(description: "completion") + + notifier.sendText(credentials: .init(token: "token-SECRET", chatID: "987654"), + text: "x") { result in + guard case .failure(let error) = result else { + return XCTFail("Expected failure") + } + XCTAssertEqual(error, .rejected("Forbidden")) + self.assertSanitized(error) + done.fulfill() + } + + wait(for: [done], timeout: 1) + } + + func testTelegramOKFalseRedactsCredentialsFromDescription() { + let serverDescription = "Forbidden token-SECRET chat 987654 path /bottoken-SECRET/" + let payload = #"{"ok":false,"description":"\#(serverDescription)"}"# + transport.result = .success((Data(payload.utf8), response(status: 200))) + let done = expectation(description: "completion") + + notifier.sendText(credentials: .init(token: "token-SECRET", chatID: "987654"), + text: "x") { result in + defer { done.fulfill() } + guard case .failure(let error) = result, + case .rejected(let description) = error else { + return XCTFail("Expected rejected failure") + } + XCTAssertTrue(description.contains("Forbidden")) + for secret in ["token-SECRET", "987654", "/bottoken-SECRET/"] { + XCTAssertFalse(description.contains(secret)) + XCTAssertFalse(String(describing: error).contains(secret)) + } + } + + wait(for: [done], timeout: 1) + } + + func testTransportErrorDoesNotExposeTokenOrRequestURL() { + let secretURL = "https://api.telegram.org/bottoken-SECRET/sendMessage" + transport.result = .failure(NSError(domain: secretURL, + code: -1, + userInfo: [NSLocalizedDescriptionKey: secretURL])) + let done = expectation(description: "completion") + + notifier.sendText(credentials: .init(token: "token-SECRET", chatID: "987654"), + text: "x") { result in + guard case .failure(let error) = result else { + return XCTFail("Expected failure") + } + XCTAssertEqual(error, .transport) + self.assertSanitized(error) + XCTAssertFalse(String(describing: error).contains(secretURL)) + done.fulfill() + } + + wait(for: [done], timeout: 1) + } + + func testCancelledPhotoTransportMapsToSanitizedTransportError() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, + withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let photoURL = directory.appendingPathComponent("capture.jpg") + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: photoURL) + let requestURL = "https://api.telegram.org/bottoken-SECRET/sendPhoto" + transport.result = .failure(URLError( + .cancelled, + userInfo: [NSLocalizedDescriptionKey: requestURL] + )) + let done = expectation(description: "completion") + + notifier.sendPhoto(credentials: .init(token: "token-SECRET", chatID: "987654"), + photoURL: photoURL, + caption: "Door opened") { result in + defer { done.fulfill() } + guard case .failure(let error) = result else { + return XCTFail("Expected failure") + } + XCTAssertEqual(error, .transport) + let errorOutput = "\(String(describing: error)) \(error.localizedDescription)" + for secret in ["token-SECRET", "987654", requestURL] { + XCTAssertFalse(errorOutput.contains(secret)) + } + } + + wait(for: [done], timeout: 1) + XCTAssertEqual(transport.requests.count, 1) + XCTAssertEqual(transport.requests.first?.url?.absoluteString, requestURL) + } + + func testMalformedResponseFails() { + transport.result = .success((Data(#"{"unexpected":true}"#.utf8), response(status: 200))) + let done = expectation(description: "completion") + + notifier.sendText(credentials: .init(token: "token-SECRET", chatID: "987654"), + text: "x") { result in + guard case .failure(let error) = result else { + return XCTFail("Expected failure") + } + XCTAssertEqual(error, .invalidResponse) + self.assertSanitized(error) + done.fulfill() + } + + wait(for: [done], timeout: 1) + } + + private func response(status: Int) -> HTTPURLResponse { + HTTPURLResponse(url: URL(string: "https://api.telegram.org")!, + statusCode: status, + httpVersion: nil, + headerFields: nil)! + } + + private func assertSanitized(_ error: TelegramError, + file: StaticString = #filePath, + line: UInt = #line) { + let description = String(describing: error) + XCTAssertFalse(description.contains("token-SECRET"), file: file, line: line) + XCTAssertFalse(description.contains("bot token-SECRET"), file: file, line: line) + } +} diff --git a/BLEUnlockTests/TelegramSettingsTests.swift b/BLEUnlockTests/TelegramSettingsTests.swift new file mode 100644 index 00000000..2b5711a1 --- /dev/null +++ b/BLEUnlockTests/TelegramSettingsTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import BLEUnlock + +final class TelegramSettingsTests: XCTestCase { + private var defaultsSuiteName: String! + private var defaults: UserDefaults! + private var secrets: MemorySecretStore! + + override func setUp() { + super.setUp() + defaultsSuiteName = "jp.sone.BLEUnlockTests.TelegramSettings.\(UUID())" + defaults = UserDefaults(suiteName: defaultsSuiteName)! + defaults.removePersistentDomain(forName: defaultsSuiteName) + secrets = MemorySecretStore() + } + + override func tearDown() { + defaults.removePersistentDomain(forName: defaultsSuiteName) + defaultsSuiteName = nil + defaults = nil + secrets = nil + super.tearDown() + } + + func testDefaultsAreDisabledWithApprovedEventChoices() throws { + let settings = TelegramSettings(defaults: defaults, secrets: secrets) + + XCTAssertFalse(settings.isEnabled) + XCTAssertTrue(settings.isEventEnabled(.away)) + XCTAssertTrue(settings.isEventEnabled(.lost)) + XCTAssertFalse(settings.isEventEnabled(.unlocked)) + XCTAssertTrue(settings.isEventEnabled(.intruded)) + XCTAssertTrue(settings.takePhotoOnIntruded) + XCTAssertFalse(try settings.isConfigured()) + } + + func testPersistsSwitchesAndCredentials() throws { + let settings = TelegramSettings(defaults: defaults, secrets: secrets) + settings.isEnabled = true + settings.setEvent(.away, enabled: false) + settings.takePhotoOnIntruded = false + try settings.saveCredentials(replacementToken: "token-123", chatID: "987654") + + let reloaded = TelegramSettings(defaults: defaults, secrets: secrets) + XCTAssertTrue(reloaded.isEnabled) + XCTAssertFalse(reloaded.isEventEnabled(.away)) + XCTAssertFalse(reloaded.takePhotoOnIntruded) + XCTAssertEqual(try reloaded.credentials(), + TelegramCredentials(token: "token-123", chatID: "987654")) + } + + func testAttachMacLocationDefaultsOffAndPersists() { + let settings = TelegramSettings(defaults: defaults, secrets: secrets) + + XCTAssertFalse(settings.attachMacLocation) + settings.attachMacLocation = true + XCTAssertTrue(settings.attachMacLocation) + } + + func testBlankOrNilReplacementPreservesStoredToken() throws { + let settings = TelegramSettings(defaults: defaults, secrets: secrets) + try settings.saveCredentials(replacementToken: "original", chatID: "old-chat") + + try settings.saveCredentials(replacementToken: " \n", chatID: "new-chat") + XCTAssertEqual(try settings.credentials(), + TelegramCredentials(token: "original", chatID: "new-chat")) + + try settings.saveCredentials(replacementToken: nil, chatID: "newest-chat") + XCTAssertEqual(try settings.credentials(), + TelegramCredentials(token: "original", chatID: "newest-chat")) + } +} diff --git a/BLEUnlockTests/TestDoubles.swift b/BLEUnlockTests/TestDoubles.swift new file mode 100644 index 00000000..6c5af216 --- /dev/null +++ b/BLEUnlockTests/TestDoubles.swift @@ -0,0 +1,155 @@ +import Foundation +@testable import BLEUnlock + +enum TelegramCallKind: Equatable { + case text + case photo + case location +} + +final class MemorySecretStore: SecretStoring { + var values: [String: String] = [:] + func string(for account: String) throws -> String? { values[account] } + func set(_ value: String, for account: String) throws { values[account] = value } + func removeValue(for account: String) throws { values.removeValue(forKey: account) } +} + +final class ThrowingSecretStore: SecretStoring { + let error: Error + + init(error: Error) { + self.error = error + } + + func string(for account: String) throws -> String? { throw error } + func set(_ value: String, for account: String) throws { throw error } + func removeValue(for account: String) throws { throw error } +} + +final class RecordingHTTPTransport: HTTPTransport { + var requests: [URLRequest] = [] + var result: Result<(Data, HTTPURLResponse), Error>! + + func perform(_ request: URLRequest, + completion: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) { + requests.append(request) + completion(result) + } +} + +final class RecordingTelegramSender: TelegramSending { + struct TextCall { + let credentials: TelegramCredentials + let text: String + } + + struct PhotoCall { + let credentials: TelegramCredentials + let photoURL: URL + let caption: String + } + + struct LocationCall { + let credentials: TelegramCredentials + let location: TelegramLocation + } + + var textResult: Result = .success(()) + var photoResult: Result = .success(()) + var locationResult: Result = .success(()) + private(set) var textCalls: [TextCall] = [] + private(set) var photoCalls: [PhotoCall] = [] + private(set) var locationCalls: [LocationCall] = [] + private(set) var callOrder: [TelegramCallKind] = [] + + func sendText(credentials: TelegramCredentials, + text: String, + completion: @escaping (Result) -> Void) { + callOrder.append(.text) + textCalls.append(.init(credentials: credentials, text: text)) + completion(textResult) + } + + func sendPhoto(credentials: TelegramCredentials, + photoURL: URL, + caption: String, + completion: @escaping (Result) -> Void) { + callOrder.append(.photo) + photoCalls.append(.init(credentials: credentials, + photoURL: photoURL, + caption: caption)) + completion(photoResult) + } + + func sendLocation(credentials: TelegramCredentials, + location: TelegramLocation, + completion: @escaping (Result) -> Void) { + callOrder.append(.location) + locationCalls.append(.init(credentials: credentials, location: location)) + completion(locationResult) + } +} + +final class StubPhotoCapturer: PhotoCapturing { + var result: Result = .failure(.captureFailed) + private(set) var captureCalls = 0 + + func capture(completion: @escaping (Result) -> Void) { + captureCalls += 1 + completion(result) + } +} + +final class StubLocationRequestToken: LocationRequestCancelling { + private(set) var cancelCalls = 0 + + func cancel() { + cancelCalls += 1 + } +} + +final class StubMacLocationProvider: MacLocationProviding { + var result: Result = .failure(.unavailable) + private(set) var requestedDates: [Date] = [] + let token = StubLocationRequestToken() + + @discardableResult + func requestLocation( + capturedAt: Date, + completion: @escaping (Result) -> Void + ) -> LocationRequestCancelling { + requestedDates.append(capturedAt) + completion(result) + return token + } +} + +final class RecordingFailureReporter: FailureReporting { + private(set) var categories: [String] = [] + private(set) var messages: [String] = [] + + func report(category: String, message: String) { + categories.append(category) + messages.append(message) + } +} + +final class RecordingFileRemover { + private(set) var calls: [URL] = [] + var error: Error? + + func remove(_ url: URL) throws { + calls.append(url) + if let error = error { + throw error + } + } +} + +final class RecordingFailureNotificationDelivery: FailureNotificationDelivering { + private(set) var messages: [String] = [] + + func deliver(message: String) { + messages.append(message) + } +} diff --git a/README.ja.md b/README.ja.md index 314da5df..b266b18c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -52,6 +52,20 @@ Bluetooth | 当然ながら、Bluetoothへのアクセスが必要です。 最後に、メニューバーアイコンから*デバイス*を選択してください。近くにあるBLEデバイスのスキャンが始まります。使いたいデバイスを選べば完了です。 +## Telegram通知 + +Telegram通知は任意の機能で、設定して有効にするまでは無効です。 + +1. [@BotFather](https://t.me/BotFather)でボットを作成し、そのトークンをコピーします。 +2. ボットにメッセージを送信してから、`https://api.telegram.org/bot/getUpdates` を開き、応答内の数値のChat IDをコピーします。 +3. *BLEUnlock > Telegram通知 > 設定…* を開き、トークンとChat IDを保存します。 +4. テスト通知を送信し、通知するイベントを選んでからTelegram通知を有効にします。 +5. 写真付き通知を有効にしている場合は、macOSから求められたときにカメラへのアクセスを許可します。 + +承認済みの初期設定では、`away`、`lost`、`intruded`がオン、`unlocked`がオフです。Telegram通知自体は無効で開始します。`intruded`の写真撮影は初期状態でオンですが、写真を添付できるイベントは`intruded`だけで、ほかのイベントは常にテキストのみです。カメラへのアクセスが拒否された場合や撮影に失敗した場合、`intruded`通知もテキストのみで送信されます。一時的に作成された侵入検知写真は、送信の成否にかかわらず、送信を試みるたびに削除されます。 + +従来の`~/Library/Application Scripts/jp.sone.BLEUnlock/event`スクリプトも引き続き利用でき、Telegramとは独立して4種類すべてのイベント引数を受け取ります。 + ## オプション ### 今すぐロック @@ -173,58 +187,9 @@ BLEUnlockはロック・アンロック時に以下のスクリプトを実行 > 注意: `intruded` イベントが正常に働くには、システム環境設定の *セキュリティとプライバシー* で *スリープとスクリーンセーバの解除にパスワードを要求* を **すぐに** に設定してください。 -### サンプル - -例としてLINE Notifyにメッセージを送るスクリプトを示します。 -手動でアンロックされた場合Macの前にいる人の写真を添付します。 - -```sh -#!/bin/bash - -set -eo pipefail - -LINE_TOKEN=xxxxx - -notify() { - local message=$1 - local image=$2 - if [ "$image" ]; then - img_arg="-F imageFile=@$image" - else - img_arg="" - fi - curl -X POST -H "Authorization: Bearer $LINE_TOKEN" -F "message=$message" \ - $img_arg https://notify-api.line.me/api/notify -} - -capture() { - open -Wa SnapshotUnlocker - ls -t /tmp/unlock-*.jpg | head -1 -} - -case $1 in - away) - notify "$(hostname -s) is locked by BLEUnlock because iPhone is away." - ;; - lost) - notify "$(hostname -s) is locked by BLEUnlock because signal is lost." - ;; - unlocked) - #notify "$(hostname -s) is unlocked by BLEUnlock." - ;; - intruded) - notify "$(hostname -s) is manually unlocked." $(capture) - ;; -esac -``` - -`SnapshotUnlocker` はスクリプトエディタで作った .app で、内容は以下のとおりです。 - -``` -do shell script "/usr/local/bin/ffmpeg -f avfoundation -r 30 -i 0 -frames:v 1 -y /tmp/unlock-$(date +%Y%m%d_%H%M%S).jpg" -``` +### LINE Notifyの過去のサンプル(非対応) -このappはBLEUnlockにカメラのパーミッションがないため必要となります。このappにパーミッションを与えることによりパーミッションの問題を回避できます。 +以前のREADMEでは、ここにLINE NotifyとSnapshotUnlockerを使用するスクリプト例を掲載していました。LINE Notifyは終了したため、その過去のエンドポイントとサンプルは非対応であり、現在は動作しません。BLEUnlock内蔵のTelegram通知を使用するか、従来の`event`スクリプトから現在利用できる別のサービスに接続してください。 ## FUNDING diff --git a/README.md b/README.md index cedbed62..bd2e6c4b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,20 @@ Finally, from the menu bar icon, select *Device*. It starts scanning nearby BLE devices. Select your device, and you're done! +## Telegram notifications + +Telegram notifications are optional and disabled until you configure and enable them: + +1. Create a bot with [@BotFather](https://t.me/BotFather) and copy its token. +2. Send the bot a message, then open `https://api.telegram.org/bot/getUpdates` and copy the numeric Chat ID from the response. +3. Open *BLEUnlock > Telegram Notifications > Configure…* and save both values. +4. Send a test notification, choose the event switches, and enable Telegram. +5. If photo alerts are enabled, approve Camera access when macOS asks. + +The approved event defaults are `away`, `lost`, and `intruded` on, with `unlocked` off. Telegram itself starts disabled. Photo capture for `intruded` is on by default; `intruded` is the only event that can include a photo, and all other event notifications are text-only. If Camera access is denied or capture fails, the `intruded` alert is sent as text only. Each temporary intrusion photo is deleted after its send attempt, whether the attempt succeeds or fails. + +The legacy `~/Library/Application Scripts/jp.sone.BLEUnlock/event` script remains available and continues to receive all four event arguments independently of Telegram. + ## Options Option | Description @@ -150,58 +164,9 @@ An argument is passed depending on the type of event: > NOTE: for `intruded` event works properly, you have to set *Require password **immediately** after sleep* in *Security & Privacy* preference pane. -### Example - -Here is an example script which sends a LINE Notify message, with a photo of the person in front of the Mac when it is unlocked manually. - -```sh -#!/bin/bash - -set -eo pipefail - -LINE_TOKEN=xxxxx - -notify() { - local message=$1 - local image=$2 - if [ "$image" ]; then - img_arg="-F imageFile=@$image" - else - img_arg="" - fi - curl -X POST -H "Authorization: Bearer $LINE_TOKEN" -F "message=$message" \ - $img_arg https://notify-api.line.me/api/notify -} - -capture() { - open -Wa SnapshotUnlocker - ls -t /tmp/unlock-*.jpg | head -1 -} - -case $1 in - away) - notify "$(hostname -s) is locked by BLEUnlock because iPhone is away." - ;; - lost) - notify "$(hostname -s) is locked by BLEUnlock because signal is lost." - ;; - unlocked) - #notify "$(hostname -s) is unlocked by BLEUnlock." - ;; - intruded) - notify "$(hostname -s) is manually unlocked." $(capture) - ;; -esac -``` - -`SnapshotUnlocker` is an .app created with Script Editor with this script: - -``` -do shell script "/usr/local/bin/ffmpeg -f avfoundation -r 30 -i 0 -frames:v 1 -y /tmp/unlock-$(date +%Y%m%d_%H%M%S).jpg" -``` +### Historical LINE Notify example (unsupported) -This app is required because BLEUnlock does not have Camera permission. -Giving permission to this app resolves the problem. +Older versions of this README showed a LINE Notify and SnapshotUnlocker script here. LINE Notify has been discontinued, so that historical endpoint and example are unsupported and no longer functional. Use BLEUnlock's built-in Telegram notifications or connect another current service through the legacy `event` script. ## Funding