From acf4adddfd88694ff1caa90dfb936482a42f678b Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 05:28:31 -0600 Subject: [PATCH 01/23] Add Telegram settings model and test target --- BLEUnlock.xcodeproj/project.pbxproj | 122 +++++++++++++++++- .../xcshareddata/xcschemes/BLEUnlock.xcscheme | 10 ++ BLEUnlock/TelegramEvent.swift | 20 +++ BLEUnlock/TelegramSettings.swift | 55 ++++++++ BLEUnlockTests/TelegramSettingsTests.swift | 41 ++++++ BLEUnlockTests/TestDoubles.swift | 8 ++ 6 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 BLEUnlock/TelegramEvent.swift create mode 100644 BLEUnlock/TelegramSettings.swift create mode 100644 BLEUnlockTests/TelegramSettingsTests.swift create mode 100644 BLEUnlockTests/TestDoubles.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index c7befb8b..888a2702 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ 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 */; }; + 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 +31,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 +55,12 @@ /* 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 = ""; }; + 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 = ""; }; @@ -98,6 +119,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7E1000153000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E1000033000000000000001 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -119,6 +148,7 @@ isa = PBXGroup; children = ( 3DD4B652226C1C3200451B7B /* BLEUnlock */, + 7E1000143000000000000001 /* BLEUnlockTests */, 3D600A4122701A5B0068FB7B /* Launcher */, 3DD4B651226C1C3200451B7B /* Products */, 3DD4B667226C1E3700451B7B /* Frameworks */, @@ -130,6 +160,7 @@ children = ( 3DD4B650226C1C3200451B7B /* BLEUnlock.app */, 3D600A4022701A5A0068FB7B /* Launcher.app */, + 7E1000103000000000000001 /* BLEUnlockTests.xctest */, ); name = Products; sourceTree = ""; @@ -137,6 +168,8 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E1000213000000000000001 /* TelegramEvent.swift */, + 7E1000223000000000000001 /* TelegramSettings.swift */, 3D95379322784D550017D8B9 /* AboutBox.swift */, 3D953799227853E20017D8B9 /* AboutBox.xib */, 3D600A35226CC1A40068FB7B /* Localizable.strings */, @@ -156,6 +189,15 @@ path = BLEUnlock; sourceTree = ""; }; + 7E1000143000000000000001 /* BLEUnlockTests */ = { + isa = PBXGroup; + children = ( + 7E1000113000000000000001 /* TelegramSettingsTests.swift */, + 7E1000123000000000000001 /* TestDoubles.swift */, + ); + path = BLEUnlockTests; + sourceTree = ""; + }; 3DD4B667226C1E3700451B7B /* Frameworks */ = { isa = PBXGroup; children = ( @@ -204,6 +246,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 +324,7 @@ targets = ( 3DD4B64F226C1C3200451B7B /* BLEUnlock */, 3D600A3F22701A5A0068FB7B /* Launcher */, + 7E1000163000000000000001 /* BLEUnlockTests */, ); }; /* End PBXProject section */ @@ -312,6 +372,15 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 7E1000183000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E1000013000000000000001 /* TelegramSettingsTests.swift in Sources */, + 7E1000023000000000000001 /* TestDoubles.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3D600A3C22701A5A0068FB7B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -325,6 +394,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E1000063000000000000001 /* TelegramEvent.swift in Sources */, + 7E1000073000000000000001 /* TelegramSettings.swift in Sources */, 3DD4B654226C1C3200451B7B /* AppDelegate.swift in Sources */, 3DD4B666226C1DF200451B7B /* BLE.swift in Sources */, 3DD4B664226C1CF000451B7B /* lowlevel.c in Sources */, @@ -337,6 +408,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 7E1000053000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3DD4B64F226C1C3200451B7B /* BLEUnlock */; + targetProxy = 7E1000043000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 3D600A35226CC1A40068FB7B /* Localizable.strings */ = { isa = PBXVariantGroup; @@ -394,6 +473,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 +498,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 +559,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 +616,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; @@ -605,6 +686,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 +744,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"> + + + + 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 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) } + } + + 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(token: String, chatID: String) throws { + try secrets.set(token.trimmingCharacters(in: .whitespacesAndNewlines), for: Key.token) + try secrets.set(chatID.trimmingCharacters(in: .whitespacesAndNewlines), for: Key.chatID) + } +} diff --git a/BLEUnlockTests/TelegramSettingsTests.swift b/BLEUnlockTests/TelegramSettingsTests.swift new file mode 100644 index 00000000..6cb036c9 --- /dev/null +++ b/BLEUnlockTests/TelegramSettingsTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import BLEUnlock + +final class TelegramSettingsTests: XCTestCase { + private var defaults: UserDefaults! + private var secrets: MemorySecretStore! + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: #file)! + defaults.removePersistentDomain(forName: #file) + secrets = MemorySecretStore() + } + + 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(token: "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")) + } +} diff --git a/BLEUnlockTests/TestDoubles.swift b/BLEUnlockTests/TestDoubles.swift new file mode 100644 index 00000000..d0274e8a --- /dev/null +++ b/BLEUnlockTests/TestDoubles.swift @@ -0,0 +1,8 @@ +@testable import BLEUnlock + +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) } +} From f9c1ec32f798b7d29cc838e1cf29bda9e87fa5c2 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 05:41:48 -0600 Subject: [PATCH 02/23] Store Telegram credentials in Keychain --- BLEUnlock.xcodeproj/project.pbxproj | 12 +++++ BLEUnlock/KeychainStore.swift | 72 +++++++++++++++++++++++++ BLEUnlockTests/KeychainStoreTests.swift | 21 ++++++++ 3 files changed, 105 insertions(+) create mode 100644 BLEUnlock/KeychainStore.swift create mode 100644 BLEUnlockTests/KeychainStoreTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 888a2702..5fd627a5 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 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 */; }; 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 */; }; @@ -60,6 +63,9 @@ 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; }; 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 = ""; }; @@ -114,6 +120,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7E10000A3000000000000001 /* Security.framework in Frameworks */, 3D2CF85B255102B300157996 /* MediaRemote.framework in Frameworks */, 3DD4B669226C1E3700451B7B /* login.framework in Frameworks */, ); @@ -170,6 +177,7 @@ children = ( 7E1000213000000000000001 /* TelegramEvent.swift */, 7E1000223000000000000001 /* TelegramSettings.swift */, + 7E1000243000000000000001 /* KeychainStore.swift */, 3D95379322784D550017D8B9 /* AboutBox.swift */, 3D953799227853E20017D8B9 /* AboutBox.xib */, 3D600A35226CC1A40068FB7B /* Localizable.strings */, @@ -192,6 +200,7 @@ 7E1000143000000000000001 /* BLEUnlockTests */ = { isa = PBXGroup; children = ( + 7E1000233000000000000001 /* KeychainStoreTests.swift */, 7E1000113000000000000001 /* TelegramSettingsTests.swift */, 7E1000123000000000000001 /* TestDoubles.swift */, ); @@ -201,6 +210,7 @@ 3DD4B667226C1E3700451B7B /* Frameworks */ = { isa = PBXGroup; children = ( + 7E1000253000000000000001 /* Security.framework */, 3D2CF85A255102B300157996 /* MediaRemote.framework */, 3DD4B668226C1E3700451B7B /* login.framework */, ); @@ -376,6 +386,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E1000093000000000000001 /* KeychainStoreTests.swift in Sources */, 7E1000013000000000000001 /* TelegramSettingsTests.swift in Sources */, 7E1000023000000000000001 /* TestDoubles.swift in Sources */, ); @@ -396,6 +407,7 @@ files = ( 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 */, diff --git a/BLEUnlock/KeychainStore.swift b/BLEUnlock/KeychainStore.swift new file mode 100644 index 00000000..69c3ec4c --- /dev/null +++ b/BLEUnlock/KeychainStore.swift @@ -0,0 +1,72 @@ +import Foundation +import Security + +enum KeychainStoreError: LocalizedError { + case status(OSStatus) + + var errorDescription: String? { + "Keychain operation failed (\(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/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)) + } +} From 6590db19a470b92a99dacacc34faa01f283f6087 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 05:49:47 -0600 Subject: [PATCH 03/23] Add Telegram Bot API client --- BLEUnlock.xcodeproj/project.pbxproj | 8 + BLEUnlock/TelegramNotifier.swift | 186 +++++++++++++++++++++ BLEUnlockTests/TelegramNotifierTests.swift | 152 +++++++++++++++++ BLEUnlockTests/TestDoubles.swift | 12 ++ 4 files changed, 358 insertions(+) create mode 100644 BLEUnlock/TelegramNotifier.swift create mode 100644 BLEUnlockTests/TelegramNotifierTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 5fd627a5..8cee11aa 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 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 */; }; + 7E10000B3000000000000001 /* TelegramNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000263000000000000001 /* TelegramNotifier.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 */; }; @@ -66,6 +68,8 @@ 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 = ""; }; + 7E1000263000000000000001 /* TelegramNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelegramNotifier.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 = ""; }; @@ -175,6 +179,7 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E1000263000000000000001 /* TelegramNotifier.swift */, 7E1000213000000000000001 /* TelegramEvent.swift */, 7E1000223000000000000001 /* TelegramSettings.swift */, 7E1000243000000000000001 /* KeychainStore.swift */, @@ -201,6 +206,7 @@ isa = PBXGroup; children = ( 7E1000233000000000000001 /* KeychainStoreTests.swift */, + 7E1000273000000000000001 /* TelegramNotifierTests.swift */, 7E1000113000000000000001 /* TelegramSettingsTests.swift */, 7E1000123000000000000001 /* TestDoubles.swift */, ); @@ -387,6 +393,7 @@ buildActionMask = 2147483647; files = ( 7E1000093000000000000001 /* KeychainStoreTests.swift in Sources */, + 7E10000C3000000000000001 /* TelegramNotifierTests.swift in Sources */, 7E1000013000000000000001 /* TelegramSettingsTests.swift in Sources */, 7E1000023000000000000001 /* TestDoubles.swift in Sources */, ); @@ -405,6 +412,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E10000B3000000000000001 /* TelegramNotifier.swift in Sources */, 7E1000063000000000000001 /* TelegramEvent.swift in Sources */, 7E1000073000000000000001 /* TelegramSettings.swift in Sources */, 7E1000083000000000000001 /* KeychainStore.swift in Sources */, diff --git a/BLEUnlock/TelegramNotifier.swift b/BLEUnlock/TelegramNotifier.swift new file mode 100644 index 00000000..56cdc1c6 --- /dev/null +++ b/BLEUnlock/TelegramNotifier.swift @@ -0,0 +1,186 @@ +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) +} + +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 "The Telegram request could not be created." + case .unreadablePhoto: + return "The captured photo could not be read." + case .transport: + return "Telegram could not be reached." + case .httpStatus(let code): + return "Telegram returned HTTP status \(code)." + case .rejected(let description): + return description + case .invalidResponse: + return "Telegram returned an 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, completion: completion) + } + + func sendPhoto(credentials: TelegramCredentials, + photoURL: URL, + caption: String, + completion: @escaping (Result) -> Void) { + guard let request = try? makePhotoRequest(credentials: credentials, + photoURL: photoURL, + caption: caption) else { + completion(.failure(.unreadablePhoto)) + return + } + perform(request, completion: completion) + } + + private func makeTextRequest(credentials: TelegramCredentials, text: String) -> URLRequest? { + guard let url = URL(string: "https://api.telegram.org/bot\(credentials.token)/sendMessage") else { + return nil + } + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "chat_id", value: credentials.chatID), + URLQueryItem(name: "text", value: text) + ] + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + return request + } + + private func makePhotoRequest(credentials: TelegramCredentials, + photoURL: URL, + caption: String) throws -> URLRequest { + let data = try Data(contentsOf: photoURL) + guard let url = URL(string: "https://api.telegram.org/bot\(credentials.token)/sendPhoto") else { + throw TelegramError.invalidRequest + } + 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, + 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( + decoded.description ?? "Telegram rejected the request." + ))) + } + } + } +} + +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/BLEUnlockTests/TelegramNotifierTests.swift b/BLEUnlockTests/TelegramNotifierTests.swift new file mode 100644 index 00000000..1210899c --- /dev/null +++ b/BLEUnlockTests/TelegramNotifierTests.swift @@ -0,0 +1,152 @@ +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 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 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 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 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/TestDoubles.swift b/BLEUnlockTests/TestDoubles.swift index d0274e8a..c555e921 100644 --- a/BLEUnlockTests/TestDoubles.swift +++ b/BLEUnlockTests/TestDoubles.swift @@ -1,3 +1,4 @@ +import Foundation @testable import BLEUnlock final class MemorySecretStore: SecretStoring { @@ -6,3 +7,14 @@ final class MemorySecretStore: SecretStoring { func set(_ value: String, for account: String) throws { values[account] = value } func removeValue(for account: String) throws { values.removeValue(forKey: account) } } + +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) + } +} From 37c3f73ea1f0eb2e01a03fe77ec2c10ef5a94a7f Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 06:18:38 -0600 Subject: [PATCH 04/23] Harden Telegram request handling --- BLEUnlock/TelegramNotifier.swift | 72 ++++++++++++++++------ BLEUnlockTests/TelegramNotifierTests.swift | 66 ++++++++++++++++++++ 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/BLEUnlock/TelegramNotifier.swift b/BLEUnlock/TelegramNotifier.swift index 56cdc1c6..dd6f122f 100644 --- a/BLEUnlock/TelegramNotifier.swift +++ b/BLEUnlock/TelegramNotifier.swift @@ -55,45 +55,48 @@ final class TelegramNotifier: TelegramSending { completion(.failure(.invalidRequest)) return } - perform(request, completion: completion) + perform(request, credentials: credentials, completion: completion) } func sendPhoto(credentials: TelegramCredentials, photoURL: URL, caption: String, completion: @escaping (Result) -> Void) { - guard let request = try? makePhotoRequest(credentials: credentials, - photoURL: photoURL, - caption: caption) else { + 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 } - perform(request, completion: completion) + let request = makePhotoRequest(credentials: credentials, + url: url, + photoURL: photoURL, + caption: caption, + data: data) + perform(request, credentials: credentials, completion: completion) } private func makeTextRequest(credentials: TelegramCredentials, text: String) -> URLRequest? { - guard let url = URL(string: "https://api.telegram.org/bot\(credentials.token)/sendMessage") else { + guard let url = endpointURL(token: credentials.token, method: "sendMessage") else { return nil } - var components = URLComponents() - components.queryItems = [ - URLQueryItem(name: "chat_id", value: credentials.chatID), - URLQueryItem(name: "text", value: text) - ] var request = URLRequest(url: url, timeoutInterval: 15) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + request.httpBody = formEncoded([ + ("chat_id", credentials.chatID), + ("text", text) + ]) return request } private func makePhotoRequest(credentials: TelegramCredentials, + url: URL, photoURL: URL, - caption: String) throws -> URLRequest { - let data = try Data(contentsOf: photoURL) - guard let url = URL(string: "https://api.telegram.org/bot\(credentials.token)/sendPhoto") else { - throw TelegramError.invalidRequest - } + caption: String, + data: Data) -> URLRequest { let boundary = "BLEUnlock-\(UUID().uuidString)" var body = Data() body.appendMultipartField(name: "chat_id", value: credentials.chatID, boundary: boundary) @@ -113,6 +116,7 @@ final class TelegramNotifier: TelegramSending { } private func perform(_ request: URLRequest, + credentials: TelegramCredentials, completion: @escaping (Result) -> Void) { transport.perform(request) { result in switch result { @@ -130,11 +134,43 @@ final class TelegramNotifier: TelegramSending { decoded.ok ? completion(.success(())) : completion(.failure(.rejected( - decoded.description ?? "Telegram rejected the request." + self.sanitized(decoded.description ?? "Telegram rejected the request.", + 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 { diff --git a/BLEUnlockTests/TelegramNotifierTests.swift b/BLEUnlockTests/TelegramNotifierTests.swift index 1210899c..3eddb2d9 100644 --- a/BLEUnlockTests/TelegramNotifierTests.swift +++ b/BLEUnlockTests/TelegramNotifierTests.swift @@ -35,6 +35,24 @@ final class TelegramNotifierTests: XCTestCase { 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 testSendPhotoBuildsMultipartWithJPEGAndCaption() throws { let photoBytes = Data([0xFF, 0xD8, 0x00, 0x7F, 0xD9]) let directory = FileManager.default.temporaryDirectory @@ -79,6 +97,31 @@ final class TelegramNotifierTests: XCTestCase { 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))) @@ -97,6 +140,29 @@ final class TelegramNotifierTests: XCTestCase { 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, From 1ae6d1cdb405654d94c7fafa4ec39a9aeddfb558 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 06:33:44 -0600 Subject: [PATCH 05/23] Add one-shot camera capture --- BLEUnlock.xcodeproj/project.pbxproj | 12 + BLEUnlock/CameraCapture.swift | 287 ++++++++++++++++++++++++ BLEUnlock/Info.plist | 2 + BLEUnlockTests/CameraCaptureTests.swift | 232 +++++++++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 BLEUnlock/CameraCapture.swift create mode 100644 BLEUnlockTests/CameraCaptureTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 8cee11aa..8a579a6f 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 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 */; }; 7E1000033000000000000001 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E1000133000000000000001 /* XCTest.framework */; }; 3D2CF85B255102B300157996 /* MediaRemote.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D2CF85A255102B300157996 /* MediaRemote.framework */; }; @@ -69,6 +72,9 @@ 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 = ""; }; 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 = ""; }; @@ -124,6 +130,7 @@ 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 */, @@ -179,6 +186,7 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E1000293000000000000001 /* CameraCapture.swift */, 7E1000263000000000000001 /* TelegramNotifier.swift */, 7E1000213000000000000001 /* TelegramEvent.swift */, 7E1000223000000000000001 /* TelegramSettings.swift */, @@ -205,6 +213,7 @@ 7E1000143000000000000001 /* BLEUnlockTests */ = { isa = PBXGroup; children = ( + 7E1000283000000000000001 /* CameraCaptureTests.swift */, 7E1000233000000000000001 /* KeychainStoreTests.swift */, 7E1000273000000000000001 /* TelegramNotifierTests.swift */, 7E1000113000000000000001 /* TelegramSettingsTests.swift */, @@ -216,6 +225,7 @@ 3DD4B667226C1E3700451B7B /* Frameworks */ = { isa = PBXGroup; children = ( + 7E10002A3000000000000001 /* AVFoundation.framework */, 7E1000253000000000000001 /* Security.framework */, 3D2CF85A255102B300157996 /* MediaRemote.framework */, 3DD4B668226C1E3700451B7B /* login.framework */, @@ -392,6 +402,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E10000D3000000000000001 /* CameraCaptureTests.swift in Sources */, 7E1000093000000000000001 /* KeychainStoreTests.swift in Sources */, 7E10000C3000000000000001 /* TelegramNotifierTests.swift in Sources */, 7E1000013000000000000001 /* TelegramSettingsTests.swift in Sources */, @@ -412,6 +423,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E10000E3000000000000001 /* CameraCapture.swift in Sources */, 7E10000B3000000000000001 /* TelegramNotifier.swift in Sources */, 7E1000063000000000000001 /* TelegramEvent.swift in Sources */, 7E1000073000000000000001 /* TelegramSettings.swift in Sources */, diff --git a/BLEUnlock/CameraCapture.swift b/BLEUnlock/CameraCapture.swift new file mode 100644 index 00000000..40bd8ed7 --- /dev/null +++ b/BLEUnlock/CameraCapture.swift @@ -0,0 +1,287 @@ +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 "Camera access was denied." + case .restricted: + return "Camera access is restricted." + case .noCamera: + return "No camera is available." + case .setupFailed: + return "The camera could not be configured." + case .captureFailed: + return "The camera could not capture a photo." + case .timeout: + return "The camera capture timed out." + case .fileWriteFailed: + return "The captured photo could not be saved." + } + } +} + +protocol CameraAuthorizationProviding { + var authorizationStatus: AVAuthorizationStatus { get } + func requestAccess(completion: @escaping (Bool) -> Void) +} + +protocol PhotoSessionProviding { + func captureJPEG(completion: @escaping (Result) -> Void) + func stop() +} + +protocol ScheduledCancellation { + func cancel() +} + +protocol CameraScheduling { + @discardableResult + func schedule(after interval: TimeInterval, + _ block: @escaping () -> Void) -> ScheduledCancellation +} + +final class CameraCapture: PhotoCapturing { + private let authorization: CameraAuthorizationProviding + private let sessionFactory: () -> Result + private let temporaryDirectory: URL + private let scheduler: CameraScheduling + private let timeout: TimeInterval + + init(authorization: CameraAuthorizationProviding = AVCameraAuthorization(), + sessionFactory: @escaping () -> Result = AVPhotoSession.make, + temporaryDirectory: URL = FileManager.default.temporaryDirectory, + scheduler: CameraScheduling = DispatchCameraScheduler(), + timeout: TimeInterval = 10) { + self.authorization = authorization + self.sessionFactory = sessionFactory + self.temporaryDirectory = temporaryDirectory + self.scheduler = scheduler + 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 + } + + let stateLock = NSLock() + var didFinish = false + var timeoutCancellation: ScheduledCancellation? + + let finish: (Result) -> Void = { [temporaryDirectory] result in + stateLock.lock() + guard !didFinish else { + stateLock.unlock() + return + } + didFinish = true + let cancellation = timeoutCancellation + stateLock.unlock() + + cancellation?.cancel() + session.stop() + + switch result { + case .success(let jpeg): + let url = temporaryDirectory + .appendingPathComponent("BLEUnlock-intruded-\(UUID().uuidString)") + .appendingPathExtension("jpg") + do { + try jpeg.write(to: url, options: .atomic) + completion(.success(url)) + } catch { + completion(.failure(.fileWriteFailed)) + } + case .failure(let error): + completion(.failure(error)) + } + } + + session.captureJPEG(completion: finish) + + let cancellation = scheduler.schedule(after: timeout) { + finish(.failure(.timeout)) + } + stateLock.lock() + timeoutCancellation = cancellation + let captureAlreadyFinished = didFinish + stateLock.unlock() + if captureAlreadyFinished { + cancellation.cancel() + } + } +} + +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 workItem: DispatchWorkItem + + init(workItem: DispatchWorkItem) { + self.workItem = workItem + } + + func cancel() { + workItem.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 AVPhotoSession: PhotoSessionProviding { + private let session: AVCaptureSession + private let output: AVCapturePhotoOutput + private let queue = DispatchQueue(label: "jp.sone.BLEUnlock.camera-capture") + private var delegateProxy: AVPhotoCaptureDelegateProxy? + + private init(session: AVCaptureSession, output: AVCapturePhotoOutput) { + self.session = session + self.output = output + } + + 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) { + queue.async { [self] in + let proxy = AVPhotoCaptureDelegateProxy { [weak self] result in + completion(result) + self?.queue.async { + self?.delegateProxy = nil + } + } + delegateProxy = proxy + session.startRunning() + output.capturePhoto(with: AVCapturePhotoSettings(), delegate: proxy) + } + } + + func stop() { + queue.async { [session] in + if session.isRunning { + session.stopRunning() + } + } + } +} + +private final class AVPhotoCaptureDelegateProxy: NSObject, AVCapturePhotoCaptureDelegate { + private let lock = NSLock() + private var didComplete = false + private let completion: (Result) -> Void + + init(completion: @escaping (Result) -> Void) { + self.completion = completion + } + + 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 + lock.unlock() + completion(result) + } +} diff --git a/BLEUnlock/Info.plist b/BLEUnlock/Info.plist index a8713454..9bc2b6a5 100644 --- a/BLEUnlock/Info.plist +++ b/BLEUnlock/Info.plist @@ -26,6 +26,8 @@ $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription BLEUnlock uses Bluetooth to detect devices. + NSCameraUsageDescription + BLEUnlock uses the camera to photograph a manual unlock and send the alert through Telegram. NSHumanReadableCopyright Copyright © 2019-2022 Takeshi Sone. MIT Licensed. NSMainNibFile diff --git a/BLEUnlockTests/CameraCaptureTests.swift b/BLEUnlockTests/CameraCaptureTests.swift new file mode 100644 index 00000000..d7bdf479 --- /dev/null +++ b/BLEUnlockTests/CameraCaptureTests.swift @@ -0,0 +1,232 @@ +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 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 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).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).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).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).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).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).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) + } +} From d589690cbf156c94a551a26b1fc71668d5787f9e Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 07:06:27 -0600 Subject: [PATCH 06/23] Fix camera capture lifecycle --- BLEUnlock/CameraCapture.swift | 128 +++++++++++++++++--- BLEUnlockTests/CameraCaptureTests.swift | 153 ++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 16 deletions(-) diff --git a/BLEUnlock/CameraCapture.swift b/BLEUnlock/CameraCapture.swift index 40bd8ed7..37df8a2c 100644 --- a/BLEUnlock/CameraCapture.swift +++ b/BLEUnlock/CameraCapture.swift @@ -54,6 +54,69 @@ protocol CameraScheduling { _ block: @escaping () -> Void) -> ScheduledCancellation } +protocol AbandonablePhotoCaptureDelegate: AnyObject { + func abandon() +} + +final class PhotoSessionLifecycle { + private let stateLock = NSLock() + private let actionGate = NSRecursiveLock() + private var cancelled = false + private var activeDelegates: [ObjectIdentifier: AbandonablePhotoCaptureDelegate] = [:] + private var abandonedDelegates: [ObjectIdentifier: AbandonablePhotoCaptureDelegate] = [:] + + func install(delegate: AbandonablePhotoCaptureDelegate) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + guard !cancelled else { return false } + activeDelegates[ObjectIdentifier(delegate)] = delegate + return true + } + + @discardableResult + func performIfActive(_ action: () -> Void) -> Bool { + actionGate.lock() + defer { actionGate.unlock() } + + 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 + let delegates = Array(activeDelegates.values) + for delegate in delegates { + let identifier = ObjectIdentifier(delegate) + abandonedDelegates[identifier] = delegate + activeDelegates[identifier] = nil + } + stateLock.unlock() + + delegates.forEach { $0.abandon() } + + actionGate.lock() + actionGate.unlock() + } + + func didFinishCallback(for delegate: AbandonablePhotoCaptureDelegate) { + stateLock.lock() + let identifier = ObjectIdentifier(delegate) + activeDelegates[identifier] = nil + abandonedDelegates[identifier] = nil + stateLock.unlock() + } +} + final class CameraCapture: PhotoCapturing { private let authorization: CameraAuthorizationProviding private let sessionFactory: () -> Result @@ -118,6 +181,7 @@ final class CameraCapture: PhotoCapturing { } didFinish = true let cancellation = timeoutCancellation + timeoutCancellation = nil stateLock.unlock() cancellation?.cancel() @@ -145,8 +209,10 @@ final class CameraCapture: PhotoCapturing { finish(.failure(.timeout)) } stateLock.lock() - timeoutCancellation = cancellation let captureAlreadyFinished = didFinish + if !captureAlreadyFinished { + timeoutCancellation = cancellation + } stateLock.unlock() if captureAlreadyFinished { cancellation.cancel() @@ -165,14 +231,19 @@ final class AVCameraAuthorization: CameraAuthorizationProviding { } private final class DispatchScheduledCancellation: ScheduledCancellation { - private let workItem: DispatchWorkItem + private let lock = NSLock() + private var workItem: DispatchWorkItem? init(workItem: DispatchWorkItem) { self.workItem = workItem } func cancel() { - workItem.cancel() + lock.lock() + let item = workItem + workItem = nil + lock.unlock() + item?.cancel() } } @@ -196,7 +267,7 @@ final class AVPhotoSession: PhotoSessionProviding { private let session: AVCaptureSession private let output: AVCapturePhotoOutput private let queue = DispatchQueue(label: "jp.sone.BLEUnlock.camera-capture") - private var delegateProxy: AVPhotoCaptureDelegateProxy? + private let lifecycle = PhotoSessionLifecycle() private init(session: AVCaptureSession, output: AVCapturePhotoOutput) { self.session = session @@ -230,19 +301,30 @@ final class AVPhotoSession: PhotoSessionProviding { func captureJPEG(completion: @escaping (Result) -> Void) { queue.async { [self] in - let proxy = AVPhotoCaptureDelegateProxy { [weak self] result in - completion(result) - self?.queue.async { - self?.delegateProxy = nil - } + let proxy = AVPhotoCaptureDelegateProxy(completion: completion) { [lifecycle] proxy in + lifecycle.didFinishCallback(for: proxy) + } + guard lifecycle.install(delegate: proxy) else { + proxy.abandon() + return + } + guard lifecycle.performIfActive({ session.startRunning() }) else { + proxy.abandon() + lifecycle.didFinishCallback(for: proxy) + return + } + guard lifecycle.performIfActive({ + output.capturePhoto(with: AVCapturePhotoSettings(), delegate: proxy) + }) else { + proxy.abandon() + lifecycle.didFinishCallback(for: proxy) + return } - delegateProxy = proxy - session.startRunning() - output.capturePhoto(with: AVCapturePhotoSettings(), delegate: proxy) } } func stop() { + lifecycle.cancel() queue.async { [session] in if session.isRunning { session.stopRunning() @@ -251,13 +333,18 @@ final class AVPhotoSession: PhotoSessionProviding { } } -private final class AVPhotoCaptureDelegateProxy: NSObject, AVCapturePhotoCaptureDelegate { +private final class AVPhotoCaptureDelegateProxy: NSObject, + AVCapturePhotoCaptureDelegate, + AbandonablePhotoCaptureDelegate { private let lock = NSLock() private var didComplete = false - private let completion: (Result) -> Void + private var completion: ((Result) -> Void)? + private let callbackFinished: (AVPhotoCaptureDelegateProxy) -> Void - init(completion: @escaping (Result) -> Void) { + init(completion: @escaping (Result) -> Void, + callbackFinished: @escaping (AVPhotoCaptureDelegateProxy) -> Void) { self.completion = completion + self.callbackFinished = callbackFinished } func photoOutput(_ output: AVCapturePhotoOutput, @@ -281,7 +368,16 @@ private final class AVPhotoCaptureDelegateProxy: NSObject, AVCapturePhotoCapture return } didComplete = true + let completion = self.completion + self.completion = nil + lock.unlock() + completion?(result) + callbackFinished(self) + } + + func abandon() { + lock.lock() + completion = nil lock.unlock() - completion(result) } } diff --git a/BLEUnlockTests/CameraCaptureTests.swift b/BLEUnlockTests/CameraCaptureTests.swift index d7bdf479..9449d493 100644 --- a/BLEUnlockTests/CameraCaptureTests.swift +++ b/BLEUnlockTests/CameraCaptureTests.swift @@ -61,6 +61,47 @@ final class StubCameraScheduler: CameraScheduling { } } +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 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! @@ -229,4 +270,116 @@ final class CameraCaptureTests: XCTestCase { 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(), + 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(), + 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 testCancellingLifecycleReleasesProxyCompletionButRetainsDelegateUntilCallback() { + let lifecycle = PhotoSessionLifecycle() + var deliveredCompletions = 0 + var completionOwner: LifetimeToken? = LifetimeToken() + let weakCompletionOwner = WeakBox(completionOwner) + var delegate: StubAbandonablePhotoDelegate? = StubAbandonablePhotoDelegate() + let weakDelegate = WeakBox(delegate) + delegate?.completion = retainedCompletion(owner: completionOwner!) { + deliveredCompletions += 1 + } + + XCTAssertTrue(lifecycle.install(delegate: delegate!)) + completionOwner = nil + XCTAssertNotNil(weakCompletionOwner.value) + + lifecycle.cancel() + + XCTAssertEqual(delegate?.abandonCalls, 1) + XCTAssertNil(weakCompletionOwner.value) + delegate = nil + XCTAssertNotNil(weakDelegate.value, + "Cancelled lifecycle must retain the AVFoundation delegate") + + deliverAndFinishCallback(delegate: weakDelegate.value!, lifecycle: lifecycle) + + XCTAssertEqual(deliveredCompletions, 0) + XCTAssertNil(weakDelegate.value) + } + + 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) + } + + private func retainedCompletion(owner: LifetimeToken, + completion: @escaping () -> Void) -> () -> Void { + return { + _ = owner + completion() + } + } + + private func deliverAndFinishCallback(delegate: StubAbandonablePhotoDelegate, + lifecycle: PhotoSessionLifecycle) { + delegate.deliverCallback() + lifecycle.didFinishCallback(for: delegate) + } } From 25aacbd635820724b8590d66b2b09b6f5f5f9ea7 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 07:32:36 -0600 Subject: [PATCH 07/23] Orchestrate Telegram event notifications --- BLEUnlock.xcodeproj/project.pbxproj | 8 + BLEUnlock/TelegramNotificationService.swift | 244 ++++++++++++++++++ .../TelegramNotificationServiceTests.swift | 230 +++++++++++++++++ BLEUnlockTests/TelegramSettingsTests.swift | 14 +- BLEUnlockTests/TestDoubles.swift | 75 ++++++ 5 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 BLEUnlock/TelegramNotificationService.swift create mode 100644 BLEUnlockTests/TelegramNotificationServiceTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 8a579a6f..1ab4fa21 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ 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 */; }; 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 */; }; @@ -76,6 +78,8 @@ 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 = ""; }; 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 = ""; }; @@ -186,6 +190,7 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E10002C3000000000000001 /* TelegramNotificationService.swift */, 7E1000293000000000000001 /* CameraCapture.swift */, 7E1000263000000000000001 /* TelegramNotifier.swift */, 7E1000213000000000000001 /* TelegramEvent.swift */, @@ -213,6 +218,7 @@ 7E1000143000000000000001 /* BLEUnlockTests */ = { isa = PBXGroup; children = ( + 7E10002E3000000000000001 /* TelegramNotificationServiceTests.swift */, 7E1000283000000000000001 /* CameraCaptureTests.swift */, 7E1000233000000000000001 /* KeychainStoreTests.swift */, 7E1000273000000000000001 /* TelegramNotifierTests.swift */, @@ -402,6 +408,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E10002D3000000000000001 /* TelegramNotificationServiceTests.swift in Sources */, 7E10000D3000000000000001 /* CameraCaptureTests.swift in Sources */, 7E1000093000000000000001 /* KeychainStoreTests.swift in Sources */, 7E10000C3000000000000001 /* TelegramNotifierTests.swift in Sources */, @@ -423,6 +430,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E10002B3000000000000001 /* TelegramNotificationService.swift in Sources */, 7E10000E3000000000000001 /* CameraCapture.swift in Sources */, 7E10000B3000000000000001 /* TelegramNotifier.swift in Sources */, 7E1000063000000000000001 /* TelegramEvent.swift in Sources */, diff --git a/BLEUnlock/TelegramNotificationService.swift b/BLEUnlock/TelegramNotificationService.swift new file mode 100644 index 00000000..68b9c9d1 --- /dev/null +++ b/BLEUnlock/TelegramNotificationService.swift @@ -0,0 +1,244 @@ +import Foundation + +protocol TelegramNotificationHandling { + func handle(_ context: TelegramEventContext) + func sendTest(hostName: String, + completion: @escaping (Result) -> Void) +} + +protocol TelegramMessageFormatting { + func message(for context: TelegramEventContext) -> String +} + +protocol FailureReporting { + func report(category: String, message: String) +} + +protocol FailureNotificationDelivering { + func deliver(message: String) +} + +final class UserNotificationFailureDelivery: FailureNotificationDelivering { + private let notificationCenter: NSUserNotificationCenter + + init(notificationCenter: NSUserNotificationCenter = .default) { + self.notificationCenter = notificationCenter + } + + func deliver(message: String) { + let notification = NSUserNotification() + notification.title = "BLEUnlock" + notification.subtitle = "Telegram notification failed" + 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))", + "Time: \(dateFormatter.string(from: context.timestamp))" + ] + if let rssi = context.rssi { + lines.append("RSSI: \(rssi) dBm") + } + return lines.joined(separator: "\n") + } + + 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 "Telegram is not configured." + case .settingsUnavailable: + return "Telegram settings could not be read." + } + } +} + +final class TelegramNotificationService: TelegramNotificationHandling { + private let settings: TelegramSettings + private let sender: TelegramSending + private let camera: PhotoCapturing + private let removeFile: (URL) throws -> Void + private let reporter: FailureReporting + private let formatter: TelegramMessageFormatting + + init(settings: TelegramSettings, + sender: TelegramSending, + camera: PhotoCapturing, + 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.removeFile = removeFile + self.reporter = reporter + self.formatter = formatter + } + + func handle(_ context: TelegramEventContext) { + guard settings.isEnabled, settings.isEventEnabled(context.event), + let credentials = try? settings.credentials() else { + return + } + + let message = formatter.message(for: context) + if context.event == .intruded && settings.takePhotoOnIntruded { + sendPhotoOrFallback(credentials: credentials, message: message, completion: nil) + } else { + sendText(credentials: credentials, message: message, 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 message = formatter.message(for: .init(event: .intruded, + hostName: hostName, + timestamp: Date(), + rssi: nil)) + if settings.takePhotoOnIntruded { + sendPhotoOrFallback(credentials: credentials, + message: message, + completion: completion) + } else { + sendText(credentials: credentials, message: message, completion: completion) + } + } + + 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) + self.sendText(credentials: credentials, + message: message, + completion: completion) + case .success(let photoURL): + sender.sendPhoto(credentials: credentials, + photoURL: photoURL, + caption: message) { result in + do { + try removeFile(photoURL) + } catch { + reporter.report(category: "file", + message: "The captured photo could not be deleted.") + } + 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/BLEUnlockTests/TelegramNotificationServiceTests.swift b/BLEUnlockTests/TelegramNotificationServiceTests.swift new file mode 100644 index 00000000..29eb9ec0 --- /dev/null +++ b/BLEUnlockTests/TelegramNotificationServiceTests.swift @@ -0,0 +1,230 @@ +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 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() + reporter = RecordingFailureReporter() + remover = RecordingFileRemover() + service = TelegramNotificationService( + settings: settings, + sender: sender, + camera: camera, + removeFile: remover.remove, + reporter: reporter + ) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: defaultsSuiteName) + defaultsSuiteName = nil + defaults = nil + settings = nil + sender = nil + camera = nil + reporter = nil + remover = nil + service = nil + super.tearDown() + } + + func testDisabledTelegramDoesNothing() throws { + try settings.saveCredentials(token: "token", chatID: "chat") + + service.handle(context(event: .intruded)) + + assertNoCameraOrNetworkCalls() + } + + func testUnconfiguredTelegramDoesNothing() { + settings.isEnabled = true + + service.handle(context(event: .intruded)) + + assertNoCameraOrNetworkCalls() + } + + 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("Time: ")) + XCTAssertEqual(lines[2], "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]) + } + + 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 testTestNotificationUsesPhotoSetting() throws { + try settings.saveCredentials(token: "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 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"]) + } + + private func configure() throws { + try settings.saveCredentials(token: "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) + XCTAssertEqual(sender.textCalls.count, 0, file: file, line: line) + XCTAssertEqual(sender.photoCalls.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) + } + } +} diff --git a/BLEUnlockTests/TelegramSettingsTests.swift b/BLEUnlockTests/TelegramSettingsTests.swift index 6cb036c9..2d54a3fa 100644 --- a/BLEUnlockTests/TelegramSettingsTests.swift +++ b/BLEUnlockTests/TelegramSettingsTests.swift @@ -2,16 +2,26 @@ 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() - defaults = UserDefaults(suiteName: #file)! - defaults.removePersistentDomain(forName: #file) + 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) diff --git a/BLEUnlockTests/TestDoubles.swift b/BLEUnlockTests/TestDoubles.swift index c555e921..6f39d8d7 100644 --- a/BLEUnlockTests/TestDoubles.swift +++ b/BLEUnlockTests/TestDoubles.swift @@ -18,3 +18,78 @@ final class RecordingHTTPTransport: HTTPTransport { 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 + } + + var textResult: Result = .success(()) + var photoResult: Result = .success(()) + private(set) var textCalls: [TextCall] = [] + private(set) var photoCalls: [PhotoCall] = [] + + func sendText(credentials: TelegramCredentials, + text: String, + completion: @escaping (Result) -> Void) { + textCalls.append(.init(credentials: credentials, text: text)) + completion(textResult) + } + + func sendPhoto(credentials: TelegramCredentials, + photoURL: URL, + caption: String, + completion: @escaping (Result) -> Void) { + photoCalls.append(.init(credentials: credentials, + photoURL: photoURL, + caption: caption)) + completion(photoResult) + } +} + +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 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) + } +} From 748271c6f60f8c27ca9eedd484fc12c3b42c0ab9 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 08:30:31 -0600 Subject: [PATCH 08/23] Strengthen Telegram cancellation coverage --- .../TelegramNotificationServiceTests.swift | 1 + BLEUnlockTests/TelegramNotifierTests.swift | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/BLEUnlockTests/TelegramNotificationServiceTests.swift b/BLEUnlockTests/TelegramNotificationServiceTests.swift index 29eb9ec0..800fe1d9 100644 --- a/BLEUnlockTests/TelegramNotificationServiceTests.swift +++ b/BLEUnlockTests/TelegramNotificationServiceTests.swift @@ -127,6 +127,7 @@ final class TelegramNotificationServiceTests: XCTestCase { XCTAssertEqual(sender.photoCalls.count, 1) XCTAssertEqual(sender.textCalls.count, 0) XCTAssertEqual(remover.calls, [photoURL]) + XCTAssertEqual(reporter.categories, ["telegram"]) } func testCaptureFailureFallsBackToTextAndReportsFailure() throws { diff --git a/BLEUnlockTests/TelegramNotifierTests.swift b/BLEUnlockTests/TelegramNotifierTests.swift index 3eddb2d9..84456333 100644 --- a/BLEUnlockTests/TelegramNotifierTests.swift +++ b/BLEUnlockTests/TelegramNotifierTests.swift @@ -184,6 +184,40 @@ final class TelegramNotifierTests: XCTestCase { 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") From 501b7091e5450d087cb55af3e59aadd67e7fa157 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 08:48:29 -0600 Subject: [PATCH 09/23] Add Telegram notification menu --- BLEUnlock.xcodeproj/project.pbxproj | 8 + BLEUnlock/AppDelegate.swift | 41 ++- BLEUnlock/TelegramMenuController.swift | 254 ++++++++++++++++++ .../TelegramMenuControllerTests.swift | 159 +++++++++++ 4 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 BLEUnlock/TelegramMenuController.swift create mode 100644 BLEUnlockTests/TelegramMenuControllerTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 1ab4fa21..57db9c50 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ 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 */; }; 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 */; }; @@ -80,6 +82,8 @@ 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 = ""; }; 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 = ""; }; @@ -190,6 +194,7 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E1000313000000000000001 /* TelegramMenuController.swift */, 7E10002C3000000000000001 /* TelegramNotificationService.swift */, 7E1000293000000000000001 /* CameraCapture.swift */, 7E1000263000000000000001 /* TelegramNotifier.swift */, @@ -218,6 +223,7 @@ 7E1000143000000000000001 /* BLEUnlockTests */ = { isa = PBXGroup; children = ( + 7E10002F3000000000000001 /* TelegramMenuControllerTests.swift */, 7E10002E3000000000000001 /* TelegramNotificationServiceTests.swift */, 7E1000283000000000000001 /* CameraCaptureTests.swift */, 7E1000233000000000000001 /* KeychainStoreTests.swift */, @@ -408,6 +414,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E1000303000000000000001 /* TelegramMenuControllerTests.swift in Sources */, 7E10002D3000000000000001 /* TelegramNotificationServiceTests.swift in Sources */, 7E10000D3000000000000001 /* CameraCaptureTests.swift in Sources */, 7E1000093000000000000001 /* KeychainStoreTests.swift in Sources */, @@ -430,6 +437,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E1000323000000000000001 /* TelegramMenuController.swift in Sources */, 7E10002B3000000000000001 /* TelegramNotificationService.swift in Sources */, 7E10000E3000000000000001 /* CameraCapture.swift in Sources */, 7E10000B3000000000000001 /* TelegramNotifier.swift in Sources */, diff --git a/BLEUnlock/AppDelegate.swift b/BLEUnlock/AppDelegate.swift index 4ee9c918..497eba19 100644 --- a/BLEUnlock/AppDelegate.swift +++ b/BLEUnlock/AppDelegate.swift @@ -30,6 +30,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa var unlockedAt = 0.0 var inScreensaver = false var lastRSSI: Int? = nil + let telegramSettings = TelegramSettings( + secrets: KeychainStore(service: "jp.sone.BLEUnlock.telegram") + ) + lazy var telegramService: TelegramNotificationHandling = TelegramNotificationService( + settings: telegramSettings, + sender: TelegramNotifier(transport: URLSessionTransport()), + camera: CameraCapture(), + reporter: RateLimitedFailureReporter() + ) + lazy var telegramMenuController = TelegramMenuController( + settings: telegramSettings, + service: telegramService, + dialogs: AppKitTelegramDialogPresenter() + ) + private let telegramEventQueue = DispatchQueue( + label: "jp.sone.BLEUnlock.telegram.events", + qos: .utility + ) func menuWillOpen(_ menu: NSMenu) { if menu == deviceMenu { @@ -177,6 +195,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa try? process.run() } + func dispatchEvent(_ rawValue: String) { + runScript(rawValue) + guard let event = TelegramEvent(rawValue: rawValue) else { return } + let context = TelegramEventContext( + event: event, + hostName: Host.current().localizedName ?? "Mac", + timestamp: Date(), + rssi: lastRSSI + ) + telegramEventQueue.async { [weak self] in + self?.telegramService.handle(context) + } + } + func pauseNowPlaying() { guard prefs.bool(forKey: "pauseItunes") else { return } MRMediaRemoteGetNowPlayingApplicationIsPlaying( @@ -238,7 +270,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa pauseNowPlaying() lockOrSaveScreen() notifyUser(reason) - runScript(reason) + dispatchEvent(reason) } manualLock = false } @@ -302,7 +334,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa self.unlockedAt = Date().timeIntervalSince1970 self.fakeKeyStrokes(password) self.playNowPlaying() - self.runScript("unlocked") + self.dispatchEvent("unlocked") }) } @@ -344,7 +376,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa print("onUnlock") if Date().timeIntervalSince1970 >= self.unlockedAt + 10 { if self.ble.unlockRSSI != self.ble.UNLOCK_DISABLED { - self.runScript("intruded") + self.dispatchEvent("intruded") } self.playNowPlaying() } @@ -642,6 +674,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/TelegramMenuController.swift b/BLEUnlock/TelegramMenuController.swift new file mode 100644 index 00000000..c4c60659 --- /dev/null +++ b/BLEUnlock/TelegramMenuController.swift @@ -0,0 +1,254 @@ +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 + + private let settings: TelegramSettings + private let service: TelegramNotificationHandling + private let dialogs: TelegramDialogPresenting + private let hostName: () -> String + private let serviceQueue = DispatchQueue(label: "jp.sone.BLEUnlock.telegram.menu", + qos: .utility) + + init(settings: TelegramSettings, + service: TelegramNotificationHandling, + dialogs: TelegramDialogPresenting, + hostName: @escaping () -> String = { Host.current().localizedName ?? "Mac" }) { + self.settings = settings + self.service = service + self.dialogs = dialogs + 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: "") + 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 + + 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(.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 + + 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() + item.state = settings.takePhotoOnIntruded ? .on : .off + } + + @objc internal func configure() { + let existingCredentials: TelegramCredentials? + do { + existingCredentials = try settings.credentials() + } catch { + dialogs.showResult(title: t("telegram_configure"), + message: error.localizedDescription) + return + } + + dialogs.requestCredentials(hasStoredToken: existingCredentials != nil) { input in + guard let input = input else { return } + let replacement = input.replacementToken? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let token = replacement.flatMap({ $0.isEmpty ? nil : $0 }) + ?? existingCredentials?.token else { + self.dialogs.showResult( + title: t("telegram_configure"), + message: t("telegram_error_not_configured") + ) + return + } + + do { + try self.settings.saveCredentials(token: token, chatID: input.chatID) + 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 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, + 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: 132) + 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/BLEUnlockTests/TelegramMenuControllerTests.swift b/BLEUnlockTests/TelegramMenuControllerTests.swift new file mode 100644 index 00000000..828db21c --- /dev/null +++ b/BLEUnlockTests/TelegramMenuControllerTests.swift @@ -0,0 +1,159 @@ +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 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() + controller = TelegramMenuController(settings: settings, + service: service, + dialogs: dialogs, + hostName: { "Fred-Mac" }) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: defaultsSuiteName) + defaultsSuiteName = nil + defaults = nil + settings = nil + service = nil + dialogs = 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(token: "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(token: "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(token: "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(token: "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(token: "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) + } +} + +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?() + } +} From 0332798cf2e0e770335d5f746d610fdac6b80848 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 09:10:08 -0600 Subject: [PATCH 10/23] Document and localize Telegram notifications --- BLEUnlock.xcodeproj/project.pbxproj | 4 ++ BLEUnlock/Base.lproj/Localizable.strings | 22 +++++++ BLEUnlock/da.lproj/Localizable.strings | 22 +++++++ BLEUnlock/de.lproj/Localizable.strings | 22 +++++++ BLEUnlock/ja.lproj/Localizable.strings | 22 +++++++ BLEUnlock/nb.lproj/Localizable.strings | 22 +++++++ BLEUnlock/sv.lproj/Localizable.strings | 22 +++++++ BLEUnlock/tr.lproj/Localizable.strings | 22 +++++++ BLEUnlock/zh-Hans.lproj/Localizable.strings | 22 +++++++ BLEUnlockTests/LocalizationTests.swift | 29 +++++++++ README.ja.md | 67 +++++---------------- README.md | 67 +++++---------------- 12 files changed, 241 insertions(+), 102 deletions(-) create mode 100644 BLEUnlockTests/LocalizationTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 57db9c50..48d85cab 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 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 */; }; 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 */; }; @@ -84,6 +85,7 @@ 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 = ""; }; 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 = ""; }; @@ -223,6 +225,7 @@ 7E1000143000000000000001 /* BLEUnlockTests */ = { isa = PBXGroup; children = ( + 7E1000343000000000000001 /* LocalizationTests.swift */, 7E10002F3000000000000001 /* TelegramMenuControllerTests.swift */, 7E10002E3000000000000001 /* TelegramNotificationServiceTests.swift */, 7E1000283000000000000001 /* CameraCaptureTests.swift */, @@ -414,6 +417,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E1000333000000000000001 /* LocalizationTests.swift in Sources */, 7E1000303000000000000001 /* TelegramMenuControllerTests.swift in Sources */, 7E10002D3000000000000001 /* TelegramNotificationServiceTests.swift in Sources */, 7E10000D3000000000000001 /* CameraCaptureTests.swift in Sources */, diff --git a/BLEUnlock/Base.lproj/Localizable.strings b/BLEUnlock/Base.lproj/Localizable.strings index 4e75488c..b846d10b 100644 --- a/BLEUnlock/Base.lproj/Localizable.strings +++ b/BLEUnlock/Base.lproj/Localizable.strings @@ -36,3 +36,25 @@ "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_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" = "Photo alerts require Camera access. If access is denied, BLEUnlock sends text only."; +"telegram_error_not_configured" = "Configure a bot token and Chat ID first."; diff --git a/BLEUnlock/da.lproj/Localizable.strings b/BLEUnlock/da.lproj/Localizable.strings index ccc259e5..eb8f83f5 100644 --- a/BLEUnlock/da.lproj/Localizable.strings +++ b/BLEUnlock/da.lproj/Localizable.strings @@ -36,3 +36,25 @@ "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_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" = "Fotonotifikationer kræver adgang til kameraet. Hvis adgang afvises, sender BLEUnlock kun tekst."; +"telegram_error_not_configured" = "Konfigurer først et bot-token og chat-id."; diff --git a/BLEUnlock/de.lproj/Localizable.strings b/BLEUnlock/de.lproj/Localizable.strings index c9d978ef..26dafddb 100644 --- a/BLEUnlock/de.lproj/Localizable.strings +++ b/BLEUnlock/de.lproj/Localizable.strings @@ -36,3 +36,25 @@ "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_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" = "Fotobenachrichtigungen benötigen Kamerazugriff. Wenn der Zugriff verweigert wird, sendet BLEUnlock nur Text."; +"telegram_error_not_configured" = "Konfigurieren Sie zuerst Bot-Token und Chat-ID."; diff --git a/BLEUnlock/ja.lproj/Localizable.strings b/BLEUnlock/ja.lproj/Localizable.strings index 39910a7e..bf0c5315 100644 --- a/BLEUnlock/ja.lproj/Localizable.strings +++ b/BLEUnlock/ja.lproj/Localizable.strings @@ -36,3 +36,25 @@ "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_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" = "写真付き通知にはカメラへのアクセスが必要です。アクセスが拒否された場合はテキストのみ送信します。"; +"telegram_error_not_configured" = "先にBotトークンとChat IDを設定してください。"; diff --git a/BLEUnlock/nb.lproj/Localizable.strings b/BLEUnlock/nb.lproj/Localizable.strings index 37bc754a..8926ca59 100644 --- a/BLEUnlock/nb.lproj/Localizable.strings +++ b/BLEUnlock/nb.lproj/Localizable.strings @@ -36,3 +36,25 @@ "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_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" = "Bildevarsler krever kameratilgang. Hvis tilgang nektes, sender BLEUnlock bare tekst."; +"telegram_error_not_configured" = "Konfigurer Bot-token og Chat-ID først."; diff --git a/BLEUnlock/sv.lproj/Localizable.strings b/BLEUnlock/sv.lproj/Localizable.strings index 4ca0820a..c5260744 100644 --- a/BLEUnlock/sv.lproj/Localizable.strings +++ b/BLEUnlock/sv.lproj/Localizable.strings @@ -36,3 +36,25 @@ "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_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" = "Fotonotiser kräver kameraåtkomst. Om åtkomst nekas skickar BLEUnlock endast text."; +"telegram_error_not_configured" = "Konfigurera Bot-token och chatt-ID först."; diff --git a/BLEUnlock/tr.lproj/Localizable.strings b/BLEUnlock/tr.lproj/Localizable.strings index 1376de24..b8a16d17 100644 --- a/BLEUnlock/tr.lproj/Localizable.strings +++ b/BLEUnlock/tr.lproj/Localizable.strings @@ -36,3 +36,25 @@ "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_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" = "Fotoğraflı bildirimler Kamera erişimi gerektirir. Erişim reddedilirse BLEUnlock yalnızca metin gönderir."; +"telegram_error_not_configured" = "Önce bot jetonunu ve sohbet kimliğini yapılandırın."; diff --git a/BLEUnlock/zh-Hans.lproj/Localizable.strings b/BLEUnlock/zh-Hans.lproj/Localizable.strings index c8a0303f..41650c2b 100644 --- a/BLEUnlock/zh-Hans.lproj/Localizable.strings +++ b/BLEUnlock/zh-Hans.lproj/Localizable.strings @@ -36,3 +36,25 @@ "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_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_error_not_configured" = "请先配置机器人令牌和聊天 ID。"; diff --git a/BLEUnlockTests/LocalizationTests.swift b/BLEUnlockTests/LocalizationTests.swift new file mode 100644 index 00000000..2fb8b5a6 --- /dev/null +++ b/BLEUnlockTests/LocalizationTests.swift @@ -0,0 +1,29 @@ +import XCTest + +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_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" + ] + + func testEveryLocalizationContainsAllTelegramKeys() throws { + let repository = URL(fileURLWithPath: #file) + .deletingLastPathComponent() + .deletingLastPathComponent() + let localizationDirectories = ["Base", "da", "de", "ja", "nb", "sv", "tr", "zh-Hans"] + + for name in localizationDirectories { + let url = repository.appendingPathComponent("BLEUnlock/\(name).lproj/Localizable.strings") + let data = try Data(contentsOf: url) + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + let values = try XCTUnwrap(plist as? [String: String]) + XCTAssertTrue(telegramKeys.subtracting(values.keys).isEmpty, "Missing keys in \(name)") + } + } +} 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 From 7971b0b3139e0fb255c5705843e77fbfab786470 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 09:55:28 -0600 Subject: [PATCH 11/23] Resolve Telegram security review findings --- BLEUnlock.xcodeproj/project.pbxproj | 26 +++ BLEUnlock/Base.lproj/InfoPlist.strings | 1 + BLEUnlock/Base.lproj/Localizable.strings | 21 +- BLEUnlock/CameraCapture.swift | 201 ++++++++++++------ BLEUnlock/Info.plist | 2 +- BLEUnlock/KeychainStore.swift | 2 +- BLEUnlock/TelegramMenuController.swift | 39 ++-- BLEUnlock/TelegramNotificationService.swift | 69 ++++-- BLEUnlock/TelegramNotifier.swift | 12 +- BLEUnlock/TelegramSettings.swift | 8 +- BLEUnlock/da.lproj/InfoPlist.strings | 1 + BLEUnlock/da.lproj/Localizable.strings | 21 +- BLEUnlock/de.lproj/InfoPlist.strings | 1 + BLEUnlock/de.lproj/Localizable.strings | 21 +- BLEUnlock/ja.lproj/InfoPlist.strings | 1 + BLEUnlock/ja.lproj/Localizable.strings | 21 +- BLEUnlock/nb.lproj/InfoPlist.strings | 1 + BLEUnlock/nb.lproj/Localizable.strings | 21 +- BLEUnlock/sv.lproj/InfoPlist.strings | 1 + BLEUnlock/sv.lproj/Localizable.strings | 21 +- BLEUnlock/tr.lproj/InfoPlist.strings | 1 + BLEUnlock/tr.lproj/Localizable.strings | 21 +- BLEUnlock/zh-Hans.lproj/InfoPlist.strings | 1 + BLEUnlock/zh-Hans.lproj/Localizable.strings | 21 +- BLEUnlockTests/CameraCaptureTests.swift | 186 ++++++++++++++-- BLEUnlockTests/LocalizationTests.swift | 129 ++++++++++- .../TelegramMenuControllerTests.swift | 32 ++- .../TelegramNotificationServiceTests.swift | 89 +++++++- BLEUnlockTests/TelegramSettingsTests.swift | 15 +- BLEUnlockTests/TestDoubles.swift | 12 ++ 30 files changed, 844 insertions(+), 154 deletions(-) create mode 100644 BLEUnlock/Base.lproj/InfoPlist.strings create mode 100644 BLEUnlock/da.lproj/InfoPlist.strings create mode 100644 BLEUnlock/de.lproj/InfoPlist.strings create mode 100644 BLEUnlock/ja.lproj/InfoPlist.strings create mode 100644 BLEUnlock/nb.lproj/InfoPlist.strings create mode 100644 BLEUnlock/sv.lproj/InfoPlist.strings create mode 100644 BLEUnlock/tr.lproj/InfoPlist.strings create mode 100644 BLEUnlock/zh-Hans.lproj/InfoPlist.strings diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 48d85cab..9e709fbd 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 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 */; }; 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 */; }; @@ -86,6 +87,14 @@ 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 = ""; }; 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 = ""; }; @@ -206,6 +215,7 @@ 3D95379322784D550017D8B9 /* AboutBox.swift */, 3D953799227853E20017D8B9 /* AboutBox.xib */, 3D600A35226CC1A40068FB7B /* Localizable.strings */, + 7E1000363000000000000001 /* InfoPlist.strings */, 3DD4B665226C1DF200451B7B /* BLE.swift */, 3D6070E126DBC68E00EE4E48 /* LEDeviceInfo.swift */, 3DD4B653226C1C3200451B7B /* AppDelegate.swift */, @@ -385,6 +395,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 */, ); @@ -469,6 +480,21 @@ /* 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 = ( diff --git a/BLEUnlock/Base.lproj/InfoPlist.strings b/BLEUnlock/Base.lproj/InfoPlist.strings new file mode 100644 index 00000000..9423a1c3 --- /dev/null +++ b/BLEUnlock/Base.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "BLEUnlock uses the system default camera to photograph a manual unlock and upload the alert to Telegram."; diff --git a/BLEUnlock/Base.lproj/Localizable.strings b/BLEUnlock/Base.lproj/Localizable.strings index b846d10b..a29b509d 100644 --- a/BLEUnlock/Base.lproj/Localizable.strings +++ b/BLEUnlock/Base.lproj/Localizable.strings @@ -56,5 +56,24 @@ "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" = "Photo alerts require Camera access. If access is denied, BLEUnlock sends text only."; +"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"; diff --git a/BLEUnlock/CameraCapture.swift b/BLEUnlock/CameraCapture.swift index 37df8a2c..14868abb 100644 --- a/BLEUnlock/CameraCapture.swift +++ b/BLEUnlock/CameraCapture.swift @@ -17,19 +17,19 @@ enum CameraCaptureError: LocalizedError, Equatable { var errorDescription: String? { switch self { case .denied: - return "Camera access was denied." + return t("telegram_camera_error_denied") case .restricted: - return "Camera access is restricted." + return t("telegram_camera_error_restricted") case .noCamera: - return "No camera is available." + return t("telegram_camera_error_no_camera") case .setupFailed: - return "The camera could not be configured." + return t("telegram_camera_error_setup_failed") case .captureFailed: - return "The camera could not capture a photo." + return t("telegram_camera_error_capture_failed") case .timeout: - return "The camera capture timed out." + return t("telegram_camera_error_timeout") case .fileWriteFailed: - return "The captured photo could not be saved." + return t("telegram_camera_error_file_write_failed") } } } @@ -54,16 +54,36 @@ protocol CameraScheduling { _ 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 actionGate = NSRecursiveLock() private var cancelled = false + private var pendingCompletion: ((Result) -> Void)? private var activeDelegates: [ObjectIdentifier: AbandonablePhotoCaptureDelegate] = [:] - private var abandonedDelegates: [ObjectIdentifier: AbandonablePhotoCaptureDelegate] = [:] + + 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() @@ -75,9 +95,6 @@ final class PhotoSessionLifecycle { @discardableResult func performIfActive(_ action: () -> Void) -> Bool { - actionGate.lock() - defer { actionGate.unlock() } - stateLock.lock() let isActive = !cancelled stateLock.unlock() @@ -94,25 +111,18 @@ final class PhotoSessionLifecycle { return } cancelled = true + pendingCompletion = nil let delegates = Array(activeDelegates.values) - for delegate in delegates { - let identifier = ObjectIdentifier(delegate) - abandonedDelegates[identifier] = delegate - activeDelegates[identifier] = nil - } + activeDelegates.removeAll() stateLock.unlock() delegates.forEach { $0.abandon() } - - actionGate.lock() - actionGate.unlock() } func didFinishCallback(for delegate: AbandonablePhotoCaptureDelegate) { stateLock.lock() let identifier = ObjectIdentifier(delegate) activeDelegates[identifier] = nil - abandonedDelegates[identifier] = nil stateLock.unlock() } } @@ -122,17 +132,20 @@ final class CameraCapture: PhotoCapturing { 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 } @@ -169,53 +182,93 @@ final class CameraCapture: PhotoCapturing { return } - let stateLock = NSLock() - var didFinish = false - var timeoutCancellation: ScheduledCancellation? + CameraCaptureAttempt(session: session, + temporaryDirectory: temporaryDirectory, + teardownScheduler: teardownScheduler, + completion: completion) + .start(scheduler: scheduler, timeout: timeout) + } +} - let finish: (Result) -> Void = { [temporaryDirectory] result in - stateLock.lock() - guard !didFinish else { - stateLock.unlock() - return - } - didFinish = true - let cancellation = timeoutCancellation - timeoutCancellation = nil - stateLock.unlock() +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 + } - cancellation?.cancel() - session.stop() + func start(scheduler: CameraScheduling, timeout: TimeInterval) { + let cancellation = scheduler.schedule(after: timeout) { [self] in + finish(.failure(.timeout)) + } - switch result { - case .success(let jpeg): - let url = temporaryDirectory - .appendingPathComponent("BLEUnlock-intruded-\(UUID().uuidString)") - .appendingPathExtension("jpg") - do { - try jpeg.write(to: url, options: .atomic) - completion(.success(url)) - } catch { - completion(.failure(.fileWriteFailed)) - } - case .failure(let error): - completion(.failure(error)) - } + lock.lock() + let activeSession: PhotoSessionProviding? + if didFinish { + activeSession = nil + } else { + timeoutCancellation = cancellation + activeSession = session } + lock.unlock() - session.captureJPEG(completion: finish) + guard let activeSession = activeSession else { + cancellation.cancel() + return + } + activeSession.captureJPEG { [weak self] result in + self?.finish(result) + } + } - let cancellation = scheduler.schedule(after: timeout) { - finish(.failure(.timeout)) + private func finish(_ result: Result) { + lock.lock() + guard !didFinish, + let session = session, + let completion = completion else { + lock.unlock() + return } - stateLock.lock() - let captureAlreadyFinished = didFinish - if !captureAlreadyFinished { - timeoutCancellation = cancellation + 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) } - stateLock.unlock() - if captureAlreadyFinished { - cancellation.cancel() + + completion(finalResult) + teardownScheduler.schedule { + session.stop() } } } @@ -263,6 +316,18 @@ final class DispatchCameraScheduler: CameraScheduling { } } +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: AVCaptureSession private let output: AVCapturePhotoOutput @@ -300,17 +365,17 @@ final class AVPhotoSession: PhotoSessionProviding { } func captureJPEG(completion: @escaping (Result) -> Void) { - queue.async { [self] in - let proxy = AVPhotoCaptureDelegateProxy(completion: completion) { [lifecycle] proxy in - lifecycle.didFinishCallback(for: proxy) - } - guard lifecycle.install(delegate: proxy) else { - proxy.abandon() + guard lifecycle.prepare(completion: completion) else { return } + queue.async { [session, output, lifecycle] in + guard lifecycle.performIfActive({ session.startRunning() }) else { return } - guard lifecycle.performIfActive({ session.startRunning() }) else { + 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() - lifecycle.didFinishCallback(for: proxy) return } guard lifecycle.performIfActive({ diff --git a/BLEUnlock/Info.plist b/BLEUnlock/Info.plist index 9bc2b6a5..6c1ec943 100644 --- a/BLEUnlock/Info.plist +++ b/BLEUnlock/Info.plist @@ -27,7 +27,7 @@ NSBluetoothAlwaysUsageDescription BLEUnlock uses Bluetooth to detect devices. NSCameraUsageDescription - BLEUnlock uses the camera to photograph a manual unlock and send the alert through Telegram. + 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. NSMainNibFile diff --git a/BLEUnlock/KeychainStore.swift b/BLEUnlock/KeychainStore.swift index 69c3ec4c..08ffeab0 100644 --- a/BLEUnlock/KeychainStore.swift +++ b/BLEUnlock/KeychainStore.swift @@ -5,7 +5,7 @@ enum KeychainStoreError: LocalizedError { case status(OSStatus) var errorDescription: String? { - "Keychain operation failed (\(statusCode))." + String(format: t("telegram_error_keychain_status"), statusCode) } private var statusCode: OSStatus { diff --git a/BLEUnlock/TelegramMenuController.swift b/BLEUnlock/TelegramMenuController.swift index c4c60659..09305ac7 100644 --- a/BLEUnlock/TelegramMenuController.swift +++ b/BLEUnlock/TelegramMenuController.swift @@ -19,6 +19,7 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { let statusItem: NSMenuItem let eventItems: [TelegramEvent: NSMenuItem] let photoItem: NSMenuItem + let privacyItem: NSMenuItem private let settings: TelegramSettings private let service: TelegramNotificationHandling @@ -65,6 +66,10 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { photoItem = NSMenuItem(title: t("telegram_take_photo"), action: #selector(togglePhoto(_:)), keyEquivalent: "") + privacyItem = NSMenuItem(title: t("telegram_camera_privacy"), + action: nil, + keyEquivalent: "") + privacyItem.isEnabled = false statusItem = NSMenuItem(title: t("telegram_status_not_configured"), action: nil, keyEquivalent: "") @@ -86,6 +91,7 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { menu.addItem(.separator()) menu.addItem(eventsItem) menu.addItem(photoItem) + menu.addItem(privacyItem) menu.addItem(.separator()) menu.addItem(statusItem) } @@ -130,30 +136,28 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { } @objc internal func configure() { - let existingCredentials: TelegramCredentials? + let configured: Bool do { - existingCredentials = try settings.credentials() + configured = try settings.isConfigured() } catch { dialogs.showResult(title: t("telegram_configure"), - message: error.localizedDescription) + message: t("telegram_error_settings_unavailable")) return } - dialogs.requestCredentials(hasStoredToken: existingCredentials != nil) { input in + dialogs.requestCredentials(hasStoredToken: configured) { input in guard let input = input else { return } - let replacement = input.replacementToken? - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let token = replacement.flatMap({ $0.isEmpty ? nil : $0 }) - ?? existingCredentials?.token else { - self.dialogs.showResult( - title: t("telegram_configure"), - message: t("telegram_error_not_configured") - ) - return - } do { - try self.settings.saveCredentials(token: token, chatID: input.chatID) + 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"), @@ -207,6 +211,8 @@ final class AppKitTelegramDialogPresenter: TelegramDialogPresenting { 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() @@ -217,6 +223,7 @@ final class AppKitTelegramDialogPresenter: TelegramDialogPresenting { let chatIDField = NSTextField() let stack = NSStackView(views: [explanation, + privacy, tokenLabel, tokenField, chatIDLabel, @@ -225,7 +232,7 @@ final class AppKitTelegramDialogPresenter: TelegramDialogPresenting { 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: 132) + 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 diff --git a/BLEUnlock/TelegramNotificationService.swift b/BLEUnlock/TelegramNotificationService.swift index 68b9c9d1..9815b431 100644 --- a/BLEUnlock/TelegramNotificationService.swift +++ b/BLEUnlock/TelegramNotificationService.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation protocol TelegramNotificationHandling { @@ -18,19 +19,47 @@ 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: NSUserNotificationCenter + private let notificationCenter: UserNotificationCenterDelivering + private let scheduler: MainThreadScheduling + private let notificationFactory: () -> NSUserNotification - init(notificationCenter: NSUserNotificationCenter = .default) { + 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) { - let notification = NSUserNotification() - notification.title = "BLEUnlock" - notification.subtitle = "Telegram notification failed" - notification.informativeText = message - notificationCenter.deliver(notification) + scheduler.perform { [notificationCenter, notificationFactory] in + let notification = notificationFactory() + notification.title = "BLEUnlock" + notification.subtitle = t("telegram_failure_notification_subtitle") + notification.informativeText = message + notificationCenter.deliver(notification) + } } } @@ -43,10 +72,10 @@ final class TelegramMessageFormatter: TelegramMessageFormatting { var lines = [ "\(context.hostName) — \(localizedDescription(for: context.event))", - "Time: \(dateFormatter.string(from: context.timestamp))" + "\(t("telegram_message_time")): \(dateFormatter.string(from: context.timestamp))" ] if let rssi = context.rssi { - lines.append("RSSI: \(rssi) dBm") + lines.append("\(t("telegram_message_rssi")): \(rssi) dBm") } return lines.joined(separator: "\n") } @@ -130,9 +159,9 @@ private enum TelegramNotificationServiceError: LocalizedError { var errorDescription: String? { switch self { case .notConfigured: - return "Telegram is not configured." + return t("telegram_error_not_configured") case .settingsUnavailable: - return "Telegram settings could not be read." + return t("telegram_error_settings_unavailable") } } } @@ -162,8 +191,17 @@ final class TelegramNotificationService: TelegramNotificationHandling { } func handle(_ context: TelegramEventContext) { - guard settings.isEnabled, settings.isEventEnabled(context.event), - let credentials = try? settings.credentials() else { + 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 } @@ -209,9 +247,10 @@ final class TelegramNotificationService: TelegramNotificationHandling { switch captureResult { case .failure(let error): reporter.report(category: "camera", message: error.localizedDescription) + completion?(.failure(error)) self.sendText(credentials: credentials, message: message, - completion: completion) + completion: nil) case .success(let photoURL): sender.sendPhoto(credentials: credentials, photoURL: photoURL, @@ -220,7 +259,7 @@ final class TelegramNotificationService: TelegramNotificationHandling { try removeFile(photoURL) } catch { reporter.report(category: "file", - message: "The captured photo could not be deleted.") + message: t("telegram_error_file_cleanup")) } if case .failure(let error) = result { reporter.report(category: "telegram", message: error.localizedDescription) diff --git a/BLEUnlock/TelegramNotifier.swift b/BLEUnlock/TelegramNotifier.swift index dd6f122f..702a18a9 100644 --- a/BLEUnlock/TelegramNotifier.swift +++ b/BLEUnlock/TelegramNotifier.swift @@ -26,17 +26,17 @@ enum TelegramError: LocalizedError, Equatable { var errorDescription: String? { switch self { case .invalidRequest: - return "The Telegram request could not be created." + return t("telegram_error_invalid_request") case .unreadablePhoto: - return "The captured photo could not be read." + return t("telegram_error_unreadable_photo") case .transport: - return "Telegram could not be reached." + return t("telegram_error_transport") case .httpStatus(let code): - return "Telegram returned HTTP status \(code)." + return String(format: t("telegram_error_http_status"), code) case .rejected(let description): return description case .invalidResponse: - return "Telegram returned an invalid response." + return t("telegram_error_invalid_response") } } } @@ -134,7 +134,7 @@ final class TelegramNotifier: TelegramSending { decoded.ok ? completion(.success(())) : completion(.failure(.rejected( - self.sanitized(decoded.description ?? "Telegram rejected the request.", + self.sanitized(decoded.description ?? t("telegram_error_rejected"), credentials: credentials) ))) } diff --git a/BLEUnlock/TelegramSettings.swift b/BLEUnlock/TelegramSettings.swift index 3bf0ed1f..70590525 100644 --- a/BLEUnlock/TelegramSettings.swift +++ b/BLEUnlock/TelegramSettings.swift @@ -48,8 +48,12 @@ final class TelegramSettings { func isConfigured() throws -> Bool { try credentials() != nil } - func saveCredentials(token: String, chatID: String) throws { - try secrets.set(token.trimmingCharacters(in: .whitespacesAndNewlines), for: Key.token) + 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..b9963948 --- /dev/null +++ b/BLEUnlock/da.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "BLEUnlock bruger systemets standardkamera til at fotografere en manuel oplåsning og uploade advarslen til Telegram."; diff --git a/BLEUnlock/da.lproj/Localizable.strings b/BLEUnlock/da.lproj/Localizable.strings index eb8f83f5..7f8a44e0 100644 --- a/BLEUnlock/da.lproj/Localizable.strings +++ b/BLEUnlock/da.lproj/Localizable.strings @@ -56,5 +56,24 @@ "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" = "Fotonotifikationer kræver adgang til kameraet. Hvis adgang afvises, sender BLEUnlock kun tekst."; +"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"; diff --git a/BLEUnlock/de.lproj/InfoPlist.strings b/BLEUnlock/de.lproj/InfoPlist.strings new file mode 100644 index 00000000..e6fc917c --- /dev/null +++ b/BLEUnlock/de.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "BLEUnlock verwendet die Standardkamera des Systems, um eine manuelle Entsperrung zu fotografieren und die Warnung zu Telegram hochzuladen."; diff --git a/BLEUnlock/de.lproj/Localizable.strings b/BLEUnlock/de.lproj/Localizable.strings index 26dafddb..b8a117e9 100644 --- a/BLEUnlock/de.lproj/Localizable.strings +++ b/BLEUnlock/de.lproj/Localizable.strings @@ -56,5 +56,24 @@ "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" = "Fotobenachrichtigungen benötigen Kamerazugriff. Wenn der Zugriff verweigert wird, sendet BLEUnlock nur Text."; +"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"; diff --git a/BLEUnlock/ja.lproj/InfoPlist.strings b/BLEUnlock/ja.lproj/InfoPlist.strings new file mode 100644 index 00000000..a4e1dd94 --- /dev/null +++ b/BLEUnlock/ja.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "BLEUnlockはシステムのデフォルトカメラで手動ロック解除を撮影し、その警告をTelegramへアップロードします。"; diff --git a/BLEUnlock/ja.lproj/Localizable.strings b/BLEUnlock/ja.lproj/Localizable.strings index bf0c5315..72cc0a00 100644 --- a/BLEUnlock/ja.lproj/Localizable.strings +++ b/BLEUnlock/ja.lproj/Localizable.strings @@ -56,5 +56,24 @@ "telegram_setup_help" = "@BotFatherでボットを作成し、トークンをコピーします。ボットにメッセージを送信してから、Telegram Bot APIのgetUpdatesを開き、応答内の数値のChat IDをコピーしてください。"; "telegram_test_success" = "テスト通知を送信しました。"; "telegram_test_failed" = "テスト通知を送信できませんでした。"; -"telegram_camera_privacy" = "写真付き通知にはカメラへのアクセスが必要です。アクセスが拒否された場合はテキストのみ送信します。"; +"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"; diff --git a/BLEUnlock/nb.lproj/InfoPlist.strings b/BLEUnlock/nb.lproj/InfoPlist.strings new file mode 100644 index 00000000..aa3483ae --- /dev/null +++ b/BLEUnlock/nb.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "BLEUnlock bruker systemets standardkamera til å fotografere en manuell opplåsing og laste opp varselet til Telegram."; diff --git a/BLEUnlock/nb.lproj/Localizable.strings b/BLEUnlock/nb.lproj/Localizable.strings index 8926ca59..e891e2b7 100644 --- a/BLEUnlock/nb.lproj/Localizable.strings +++ b/BLEUnlock/nb.lproj/Localizable.strings @@ -56,5 +56,24 @@ "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" = "Bildevarsler krever kameratilgang. Hvis tilgang nektes, sender BLEUnlock bare tekst."; +"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"; diff --git a/BLEUnlock/sv.lproj/InfoPlist.strings b/BLEUnlock/sv.lproj/InfoPlist.strings new file mode 100644 index 00000000..45d53667 --- /dev/null +++ b/BLEUnlock/sv.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "BLEUnlock använder systemets standardkamera för att fotografera en manuell upplåsning och ladda upp varningen till Telegram."; diff --git a/BLEUnlock/sv.lproj/Localizable.strings b/BLEUnlock/sv.lproj/Localizable.strings index c5260744..2cdeaa32 100644 --- a/BLEUnlock/sv.lproj/Localizable.strings +++ b/BLEUnlock/sv.lproj/Localizable.strings @@ -56,5 +56,24 @@ "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" = "Fotonotiser kräver kameraåtkomst. Om åtkomst nekas skickar BLEUnlock endast text."; +"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"; diff --git a/BLEUnlock/tr.lproj/InfoPlist.strings b/BLEUnlock/tr.lproj/InfoPlist.strings new file mode 100644 index 00000000..3ccb33cd --- /dev/null +++ b/BLEUnlock/tr.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "BLEUnlock, elle kilit açmayı fotoğraflamak ve uyarıyı Telegram'a yüklemek için sistemin varsayılan kamerasını kullanır."; diff --git a/BLEUnlock/tr.lproj/Localizable.strings b/BLEUnlock/tr.lproj/Localizable.strings index b8a16d17..3f3bc3a4 100644 --- a/BLEUnlock/tr.lproj/Localizable.strings +++ b/BLEUnlock/tr.lproj/Localizable.strings @@ -56,5 +56,24 @@ "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" = "Fotoğraflı bildirimler Kamera erişimi gerektirir. Erişim reddedilirse BLEUnlock yalnızca metin gönderir."; +"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"; diff --git a/BLEUnlock/zh-Hans.lproj/InfoPlist.strings b/BLEUnlock/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 00000000..99a07037 --- /dev/null +++ b/BLEUnlock/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSCameraUsageDescription" = "BLEUnlock 使用系统默认摄像头拍摄手动解锁,并将警报上传到 Telegram。"; diff --git a/BLEUnlock/zh-Hans.lproj/Localizable.strings b/BLEUnlock/zh-Hans.lproj/Localizable.strings index 41650c2b..748b986b 100644 --- a/BLEUnlock/zh-Hans.lproj/Localizable.strings +++ b/BLEUnlock/zh-Hans.lproj/Localizable.strings @@ -56,5 +56,24 @@ "telegram_setup_help" = "使用 @BotFather 创建机器人并复制其令牌。向机器人发送一条消息,然后打开 Telegram Bot API 的 getUpdates,复制响应中的数字聊天 ID。"; "telegram_test_success" = "测试通知已发送。"; "telegram_test_failed" = "无法发送测试通知。"; -"telegram_camera_privacy" = "照片通知需要摄像头权限。如果拒绝访问,BLEUnlock 将仅发送文本。"; +"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"; diff --git a/BLEUnlockTests/CameraCaptureTests.swift b/BLEUnlockTests/CameraCaptureTests.swift index 9449d493..b4aa2d8c 100644 --- a/BLEUnlockTests/CameraCaptureTests.swift +++ b/BLEUnlockTests/CameraCaptureTests.swift @@ -61,6 +61,20 @@ final class StubCameraScheduler: CameraScheduling { } } +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)? @@ -78,6 +92,33 @@ final class LifetimeTrackingPhotoSession: PhotoSessionProviding { } } +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 LifetimeToken {} final class WeakBox { @@ -132,7 +173,8 @@ final class CameraCaptureTests: XCTestCase { CameraCapture(authorization: StubCameraAuthorization(), sessionFactory: { .success(session) }, temporaryDirectory: temporaryDirectory, - scheduler: scheduler).capture { result in + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in do { let url = try result.get() XCTAssertEqual(try Data(contentsOf: url), jpeg) @@ -167,7 +209,8 @@ final class CameraCaptureTests: XCTestCase { return .success(session) }, temporaryDirectory: temporaryDirectory, - scheduler: scheduler).capture { result in + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in if case .success(let url) = result { try? FileManager.default.removeItem(at: url) } else { @@ -195,7 +238,8 @@ final class CameraCaptureTests: XCTestCase { return .failure(.setupFailed) }, temporaryDirectory: temporaryDirectory, - scheduler: scheduler).capture { result in + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in XCTAssertEqual(try? result.get(), nil) if case .failure(let error) = result { XCTAssertEqual(error, .denied) @@ -214,7 +258,8 @@ final class CameraCaptureTests: XCTestCase { CameraCapture(authorization: StubCameraAuthorization(), sessionFactory: { .failure(.noCamera) }, temporaryDirectory: temporaryDirectory, - scheduler: scheduler).capture { result in + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in guard case .failure(let error) = result else { return XCTFail("Expected failure, got \(result)") } @@ -233,7 +278,10 @@ final class CameraCaptureTests: XCTestCase { CameraCapture(authorization: StubCameraAuthorization(), sessionFactory: { .success(session) }, temporaryDirectory: temporaryDirectory, - scheduler: scheduler).capture { results.append($0) } + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { + results.append($0) + } XCTAssertEqual(session.captureCalls, 1) XCTAssertFalse(session.stopped) @@ -258,7 +306,8 @@ final class CameraCaptureTests: XCTestCase { CameraCapture(authorization: StubCameraAuthorization(), sessionFactory: { .success(session) }, temporaryDirectory: temporaryDirectory, - scheduler: scheduler).capture { result in + scheduler: scheduler, + teardownScheduler: ImmediateCameraTeardownScheduler()).capture { result in guard case .failure(let error) = result else { return XCTFail("Expected failure, got \(result)") } @@ -282,6 +331,7 @@ final class CameraCaptureTests: XCTestCase { sessionFactory: { .success(session!) }, temporaryDirectory: temporaryDirectory, scheduler: DispatchCameraScheduler(), + teardownScheduler: ImmediateCameraTeardownScheduler(), timeout: 0.01) capture.capture { result in if case .success(let url) = result { @@ -307,6 +357,7 @@ final class CameraCaptureTests: XCTestCase { sessionFactory: { .success(session!) }, temporaryDirectory: temporaryDirectory, scheduler: DispatchCameraScheduler(), + teardownScheduler: ImmediateCameraTeardownScheduler(), timeout: 0) capture.capture { result in guard case .failure(let error) = result else { @@ -321,7 +372,47 @@ final class CameraCaptureTests: XCTestCase { XCTAssertNil(weakSession.value) } - func testCancellingLifecycleReleasesProxyCompletionButRetainsDelegateUntilCallback() { + 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 testCancellingLifecycleReleasesAbandonedDelegateWithoutCallback() { let lifecycle = PhotoSessionLifecycle() var deliveredCompletions = 0 var completionOwner: LifetimeToken? = LifetimeToken() @@ -334,20 +425,14 @@ final class CameraCaptureTests: XCTestCase { XCTAssertTrue(lifecycle.install(delegate: delegate!)) completionOwner = nil - XCTAssertNotNil(weakCompletionOwner.value) - lifecycle.cancel() XCTAssertEqual(delegate?.abandonCalls, 1) XCTAssertNil(weakCompletionOwner.value) delegate = nil - XCTAssertNotNil(weakDelegate.value, - "Cancelled lifecycle must retain the AVFoundation delegate") - - deliverAndFinishCallback(delegate: weakDelegate.value!, lifecycle: lifecycle) - XCTAssertEqual(deliveredCompletions, 0) - XCTAssertNil(weakDelegate.value) + XCTAssertNil(weakDelegate.value, + "An abandoned delegate must not be retained forever without a callback") } func testCancelledLifecycleSkipsQueuedStartAndPhotoCapture() { @@ -369,6 +454,64 @@ final class CameraCaptureTests: XCTestCase { 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 retainedCompletion(owner: LifetimeToken, completion: @escaping () -> Void) -> () -> Void { return { @@ -377,9 +520,14 @@ final class CameraCaptureTests: XCTestCase { } } - private func deliverAndFinishCallback(delegate: StubAbandonablePhotoDelegate, - lifecycle: PhotoSessionLifecycle) { - delegate.deliverCallback() - lifecycle.didFinishCallback(for: delegate) + private func retainedResultCompletion( + owner: LifetimeToken, + completion: @escaping () -> Void + ) -> (Result) -> Void { + return { _ in + _ = owner + completion() + } } + } diff --git a/BLEUnlockTests/LocalizationTests.swift b/BLEUnlockTests/LocalizationTests.swift index 2fb8b5a6..8c11cb3a 100644 --- a/BLEUnlockTests/LocalizationTests.swift +++ b/BLEUnlockTests/LocalizationTests.swift @@ -1,4 +1,6 @@ +import Foundation import XCTest +@testable import BLEUnlock final class LocalizationTests: XCTestCase { private let telegramKeys: Set = [ @@ -9,21 +11,134 @@ final class LocalizationTests: XCTestCase { "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_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" ] - func testEveryLocalizationContainsAllTelegramKeys() throws { - let repository = URL(fileURLWithPath: #file) + 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() - let localizationDirectories = ["Base", "da", "de", "ja", "nb", "sv", "tr", "zh-Hans"] + } + func testEveryLocalizationContainsAllTelegramKeys() throws { for name in localizationDirectories { let url = repository.appendingPathComponent("BLEUnlock/\(name).lproj/Localizable.strings") - let data = try Data(contentsOf: url) - let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) - let values = try XCTUnwrap(plist as? [String: String]) + 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 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]) + } } diff --git a/BLEUnlockTests/TelegramMenuControllerTests.swift b/BLEUnlockTests/TelegramMenuControllerTests.swift index 828db21c..6f4e25d4 100644 --- a/BLEUnlockTests/TelegramMenuControllerTests.swift +++ b/BLEUnlockTests/TelegramMenuControllerTests.swift @@ -44,7 +44,7 @@ final class TelegramMenuControllerTests: XCTestCase { } func testConfiguredMenuCanEnableTelegram() throws { - try settings.saveCredentials(token: "token", chatID: "chat") + try settings.saveCredentials(replacementToken: "token", chatID: "chat") controller.menuWillOpen(controller.menu) XCTAssertTrue(controller.enableItem.isEnabled) @@ -57,7 +57,7 @@ final class TelegramMenuControllerTests: XCTestCase { } func testEventAndPhotoItemsReflectAndPersistSettings() throws { - try settings.saveCredentials(token: "token", chatID: "chat") + try settings.saveCredentials(replacementToken: "token", chatID: "chat") controller.menuWillOpen(controller.menu) XCTAssertEqual(controller.eventItems[.away]?.state, .on) @@ -72,7 +72,7 @@ final class TelegramMenuControllerTests: XCTestCase { } func testConfigureLeavesExistingTokenWhenTokenFieldIsBlank() throws { - try settings.saveCredentials(token: "original", chatID: "old-chat") + try settings.saveCredentials(replacementToken: "original", chatID: "old-chat") dialogs.credentialInput = .init(replacementToken: nil, chatID: "new-chat") controller.configure() @@ -84,7 +84,7 @@ final class TelegramMenuControllerTests: XCTestCase { } func testConfigureReplacesTokenWhenNewValueIsEntered() throws { - try settings.saveCredentials(token: "original", chatID: "old-chat") + try settings.saveCredentials(replacementToken: "original", chatID: "old-chat") dialogs.credentialInput = .init(replacementToken: " replacement ", chatID: "new-chat") @@ -95,7 +95,7 @@ final class TelegramMenuControllerTests: XCTestCase { } func testSendTestCallsServiceAndPresentsResult() throws { - try settings.saveCredentials(token: "token", chatID: "chat") + try settings.saveCredentials(replacementToken: "token", chatID: "chat") let presented = expectation(description: "Result presented") dialogs.onShowResult = { presented.fulfill() } @@ -107,6 +107,28 @@ final class TelegramMenuControllerTests: XCTestCase { 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 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 RecordingTelegramNotificationService: TelegramNotificationHandling { diff --git a/BLEUnlockTests/TelegramNotificationServiceTests.swift b/BLEUnlockTests/TelegramNotificationServiceTests.swift index 800fe1d9..09ca071b 100644 --- a/BLEUnlockTests/TelegramNotificationServiceTests.swift +++ b/BLEUnlockTests/TelegramNotificationServiceTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import XCTest @testable import BLEUnlock @@ -46,7 +47,7 @@ final class TelegramNotificationServiceTests: XCTestCase { } func testDisabledTelegramDoesNothing() throws { - try settings.saveCredentials(token: "token", chatID: "chat") + try settings.saveCredentials(replacementToken: "token", chatID: "chat") service.handle(context(event: .intruded)) @@ -61,6 +62,30 @@ final class TelegramNotificationServiceTests: XCTestCase { 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, + 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) @@ -86,8 +111,8 @@ final class TelegramNotificationServiceTests: XCTestCase { value: "Device Away", comment: "") XCTAssertEqual(lines[0], "Fred-Mac — \(eventDescription)") - XCTAssertTrue(lines[1].hasPrefix("Time: ")) - XCTAssertEqual(lines[2], "RSSI: -47 dBm") + XCTAssertTrue(lines[1].hasPrefix("\(t("telegram_message_time")): ")) + XCTAssertEqual(lines[2], "\(t("telegram_message_rssi")): -47 dBm") } func testIntrudedWithPhotoSendsPhotoAndDeletesFileOnSuccess() throws { @@ -155,7 +180,7 @@ final class TelegramNotificationServiceTests: XCTestCase { } func testTestNotificationUsesPhotoSetting() throws { - try settings.saveCredentials(token: "token", chatID: "chat") + try settings.saveCredentials(replacementToken: "token", chatID: "chat") camera.result = .success(photoURL) var photoResult: Result? @@ -177,6 +202,48 @@ final class TelegramNotificationServiceTests: XCTestCase { 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() @@ -201,7 +268,7 @@ final class TelegramNotificationServiceTests: XCTestCase { } private func configure() throws { - try settings.saveCredentials(token: "token", chatID: "chat") + try settings.saveCredentials(replacementToken: "token", chatID: "chat") settings.isEnabled = true } @@ -229,3 +296,15 @@ final class TelegramNotificationServiceTests: XCTestCase { } } } + +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/TelegramSettingsTests.swift b/BLEUnlockTests/TelegramSettingsTests.swift index 2d54a3fa..23c8180a 100644 --- a/BLEUnlockTests/TelegramSettingsTests.swift +++ b/BLEUnlockTests/TelegramSettingsTests.swift @@ -39,7 +39,7 @@ final class TelegramSettingsTests: XCTestCase { settings.isEnabled = true settings.setEvent(.away, enabled: false) settings.takePhotoOnIntruded = false - try settings.saveCredentials(token: "token-123", chatID: "987654") + try settings.saveCredentials(replacementToken: "token-123", chatID: "987654") let reloaded = TelegramSettings(defaults: defaults, secrets: secrets) XCTAssertTrue(reloaded.isEnabled) @@ -48,4 +48,17 @@ final class TelegramSettingsTests: XCTestCase { XCTAssertEqual(try reloaded.credentials(), TelegramCredentials(token: "token-123", chatID: "987654")) } + + 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 index 6f39d8d7..eedcd1b7 100644 --- a/BLEUnlockTests/TestDoubles.swift +++ b/BLEUnlockTests/TestDoubles.swift @@ -8,6 +8,18 @@ final class MemorySecretStore: SecretStoring { 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>! From c365e033094d48aca79a07c6c26a1db4f7b100a4 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 10:08:13 -0600 Subject: [PATCH 12/23] Retain in-flight camera delegates safely --- BLEUnlock/CameraCapture.swift | 82 ++++++++++++++- BLEUnlockTests/CameraCaptureTests.swift | 129 +++++++++++++++++++----- 2 files changed, 184 insertions(+), 27 deletions(-) diff --git a/BLEUnlock/CameraCapture.swift b/BLEUnlock/CameraCapture.swift index 14868abb..1c01eccf 100644 --- a/BLEUnlock/CameraCapture.swift +++ b/BLEUnlock/CameraCapture.swift @@ -64,9 +64,20 @@ protocol AbandonablePhotoCaptureDelegate: AnyObject { 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() @@ -88,7 +99,9 @@ final class PhotoSessionLifecycle { func install(delegate: AbandonablePhotoCaptureDelegate) -> Bool { stateLock.lock() defer { stateLock.unlock() } - guard !cancelled else { return false } + guard !cancelled, + activeDelegates.isEmpty, + retiredDelegates.isEmpty else { return false } activeDelegates[ObjectIdentifier(delegate)] = delegate return true } @@ -113,17 +126,81 @@ final class PhotoSessionLifecycle { 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() } } @@ -390,7 +467,8 @@ final class AVPhotoSession: PhotoSessionProviding { func stop() { lifecycle.cancel() - queue.async { [session] in + queue.async { [session, lifecycle] in + defer { lifecycle.teardownDidFinish() } if session.isRunning { session.stopRunning() } diff --git a/BLEUnlockTests/CameraCaptureTests.swift b/BLEUnlockTests/CameraCaptureTests.swift index b4aa2d8c..250a3b06 100644 --- a/BLEUnlockTests/CameraCaptureTests.swift +++ b/BLEUnlockTests/CameraCaptureTests.swift @@ -119,6 +119,40 @@ final class BlockingStopPhotoSession: PhotoSessionProviding { } } +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 { @@ -412,27 +446,80 @@ final class CameraCaptureTests: XCTestCase { XCTAssertEqual(deliveredCompletions, 0) } - func testCancellingLifecycleReleasesAbandonedDelegateWithoutCallback() { - let lifecycle = PhotoSessionLifecycle() - var deliveredCompletions = 0 - var completionOwner: LifetimeToken? = LifetimeToken() - let weakCompletionOwner = WeakBox(completionOwner) - var delegate: StubAbandonablePhotoDelegate? = StubAbandonablePhotoDelegate() - let weakDelegate = WeakBox(delegate) - delegate?.completion = retainedCompletion(owner: completionOwner!) { - deliveredCompletions += 1 + 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) - XCTAssertTrue(lifecycle.install(delegate: delegate!)) - completionOwner = nil - lifecycle.cancel() + 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() - XCTAssertEqual(delegate?.abandonCalls, 1) - XCTAssertNil(weakCompletionOwner.value) - delegate = nil - XCTAssertEqual(deliveredCompletions, 0) XCTAssertNil(weakDelegate.value, - "An abandoned delegate must not be retained forever without a callback") + "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() { @@ -512,14 +599,6 @@ final class CameraCaptureTests: XCTestCase { XCTAssertNil(weakSession.value) } - private func retainedCompletion(owner: LifetimeToken, - completion: @escaping () -> Void) -> () -> Void { - return { - _ = owner - completion() - } - } - private func retainedResultCompletion( owner: LifetimeToken, completion: @escaping () -> Void From 43fe273180d1915f0d3004726516e9cba1b6926e Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 17:09:56 -0600 Subject: [PATCH 13/23] Warm up camera before Telegram snapshots --- BLEUnlock/CameraCapture.swift | 59 ++++++++++++++++++------- BLEUnlockTests/CameraCaptureTests.swift | 34 ++++++++++++++ 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/BLEUnlock/CameraCapture.swift b/BLEUnlock/CameraCapture.swift index 1c01eccf..bb355519 100644 --- a/BLEUnlock/CameraCapture.swift +++ b/BLEUnlock/CameraCapture.swift @@ -204,6 +204,19 @@ final class PhotoSessionLifecycle { } } +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 @@ -408,12 +421,24 @@ final class DispatchCameraTeardownScheduler: CameraTeardownScheduling { final class AVPhotoSession: PhotoSessionProviding { private let session: AVCaptureSession private let output: AVCapturePhotoOutput - private let queue = DispatchQueue(label: "jp.sone.BLEUnlock.camera-capture") + private let queue: DispatchQueue + private let warmup: CameraWarmup private let lifecycle = PhotoSessionLifecycle() - private init(session: AVCaptureSession, output: AVCapturePhotoOutput) { + private init(session: AVCaptureSession, + output: AVCapturePhotoOutput, + 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 { @@ -447,20 +472,22 @@ final class AVPhotoSession: PhotoSessionProviding { guard lifecycle.performIfActive({ session.startRunning() }) else { return } - 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 + 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 + } } } } diff --git a/BLEUnlockTests/CameraCaptureTests.swift b/BLEUnlockTests/CameraCaptureTests.swift index 250a3b06..15c72f5a 100644 --- a/BLEUnlockTests/CameraCaptureTests.swift +++ b/BLEUnlockTests/CameraCaptureTests.swift @@ -198,6 +198,40 @@ final class CameraCaptureTests: XCTestCase { try super.tearDownWithError() } + func testCameraWarmupWaitsOneSecondBeforeCaptureAction() throws { + let lifecycle = PhotoSessionLifecycle() + XCTAssertTrue(lifecycle.prepare { _ in }) + var captureActions = 0 + let warmup = CameraWarmup(scheduler: scheduler, interval: 1) + + warmup.schedule(lifecycle: lifecycle) { + captureActions += 1 + } + + XCTAssertEqual(captureActions, 0) + XCTAssertEqual(scheduler.scheduledIntervals, [1]) + + try XCTUnwrap(scheduler.blocks.first)() + + XCTAssertEqual(captureActions, 1) + } + + func testCameraWarmupSkipsCaptureActionAfterCancellation() throws { + let lifecycle = PhotoSessionLifecycle() + XCTAssertTrue(lifecycle.prepare { _ in }) + var captureActions = 0 + let warmup = CameraWarmup(scheduler: scheduler, interval: 1) + + warmup.schedule(lifecycle: lifecycle) { + captureActions += 1 + } + lifecycle.cancel() + + try XCTUnwrap(scheduler.blocks.first)() + + XCTAssertEqual(captureActions, 0) + } + func testAuthorizedCaptureWritesReturnedJPEGToUniqueTemporaryURL() throws { let session = StubPhotoSession() let jpeg = Data([0xFF, 0xD8, 0xFF, 0xD9]) From 2833d4cc560a104efc1e288b042bfc231e0a2318 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 17:22:33 -0600 Subject: [PATCH 14/23] Test production camera warm-up wiring --- BLEUnlock/CameraCapture.swift | 32 +++++++---- BLEUnlockTests/CameraCaptureTests.swift | 70 ++++++++++++++++++------- 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/BLEUnlock/CameraCapture.swift b/BLEUnlock/CameraCapture.swift index bb355519..156d80d4 100644 --- a/BLEUnlock/CameraCapture.swift +++ b/BLEUnlock/CameraCapture.swift @@ -44,6 +44,20 @@ protocol PhotoSessionProviding { 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() } @@ -419,19 +433,19 @@ final class DispatchCameraTeardownScheduler: CameraTeardownScheduling { } final class AVPhotoSession: PhotoSessionProviding { - private let session: AVCaptureSession - private let output: AVCapturePhotoOutput + private let session: CaptureSessionRunning + private let output: PhotoOutputCapturing private let queue: DispatchQueue private let warmup: CameraWarmup private let lifecycle = PhotoSessionLifecycle() - private init(session: AVCaptureSession, - output: AVCapturePhotoOutput, - queue: DispatchQueue = DispatchQueue( - label: "jp.sone.BLEUnlock.camera-capture" - ), - warmupScheduler: CameraScheduling? = nil, - warmupInterval: TimeInterval = 1) { + 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 diff --git a/BLEUnlockTests/CameraCaptureTests.swift b/BLEUnlockTests/CameraCaptureTests.swift index 15c72f5a..158b28ad 100644 --- a/BLEUnlockTests/CameraCaptureTests.swift +++ b/BLEUnlockTests/CameraCaptureTests.swift @@ -61,6 +61,31 @@ final class StubCameraScheduler: CameraScheduling { } } +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() @@ -198,38 +223,43 @@ final class CameraCaptureTests: XCTestCase { try super.tearDownWithError() } - func testCameraWarmupWaitsOneSecondBeforeCaptureAction() throws { - let lifecycle = PhotoSessionLifecycle() - XCTAssertTrue(lifecycle.prepare { _ in }) - var captureActions = 0 - let warmup = CameraWarmup(scheduler: scheduler, interval: 1) + 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) - warmup.schedule(lifecycle: lifecycle) { - captureActions += 1 - } + photoSession.captureJPEG { _ in } + queue.sync {} - XCTAssertEqual(captureActions, 0) + XCTAssertEqual(captureSession.startRunningCalls, 1) + XCTAssertEqual(photoOutput.capturePhotoCalls, 0) XCTAssertEqual(scheduler.scheduledIntervals, [1]) try XCTUnwrap(scheduler.blocks.first)() - XCTAssertEqual(captureActions, 1) + XCTAssertEqual(photoOutput.capturePhotoCalls, 1) } - func testCameraWarmupSkipsCaptureActionAfterCancellation() throws { - let lifecycle = PhotoSessionLifecycle() - XCTAssertTrue(lifecycle.prepare { _ in }) - var captureActions = 0 - let warmup = CameraWarmup(scheduler: scheduler, interval: 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) - warmup.schedule(lifecycle: lifecycle) { - captureActions += 1 - } - lifecycle.cancel() + photoSession.captureJPEG { _ in } + queue.sync {} + photoSession.stop() try XCTUnwrap(scheduler.blocks.first)() - XCTAssertEqual(captureActions, 0) + XCTAssertEqual(photoOutput.capturePhotoCalls, 0) } func testAuthorizedCaptureWritesReturnedJPEGToUniqueTemporaryURL() throws { From 265504706e28b2765d6b5c50f3cff7408a5113b6 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 18:35:51 -0600 Subject: [PATCH 15/23] Add one-shot Mac location provider --- BLEUnlock.xcodeproj/project.pbxproj | 8 + BLEUnlock/MacLocationProvider.swift | 255 ++++++++++++++++++ BLEUnlockTests/MacLocationProviderTests.swift | 143 ++++++++++ 3 files changed, 406 insertions(+) create mode 100644 BLEUnlock/MacLocationProvider.swift create mode 100644 BLEUnlockTests/MacLocationProviderTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 9e709fbd..ca2ee076 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ 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 */; }; 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 */; }; @@ -95,6 +97,8 @@ 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 = ""; }; 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 = ""; }; @@ -205,6 +209,7 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E1000453000000000000001 /* MacLocationProvider.swift */, 7E1000313000000000000001 /* TelegramMenuController.swift */, 7E10002C3000000000000001 /* TelegramNotificationService.swift */, 7E1000293000000000000001 /* CameraCapture.swift */, @@ -235,6 +240,7 @@ 7E1000143000000000000001 /* BLEUnlockTests */ = { isa = PBXGroup; children = ( + 7E1000473000000000000001 /* MacLocationProviderTests.swift */, 7E1000343000000000000001 /* LocalizationTests.swift */, 7E10002F3000000000000001 /* TelegramMenuControllerTests.swift */, 7E10002E3000000000000001 /* TelegramNotificationServiceTests.swift */, @@ -428,6 +434,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E1000463000000000000001 /* MacLocationProviderTests.swift in Sources */, 7E1000333000000000000001 /* LocalizationTests.swift in Sources */, 7E1000303000000000000001 /* TelegramMenuControllerTests.swift in Sources */, 7E10002D3000000000000001 /* TelegramNotificationServiceTests.swift in Sources */, @@ -452,6 +459,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E1000443000000000000001 /* MacLocationProvider.swift in Sources */, 7E1000323000000000000001 /* TelegramMenuController.swift in Sources */, 7E10002B3000000000000001 /* TelegramNotificationService.swift in Sources */, 7E10000E3000000000000001 /* CameraCapture.swift in Sources */, 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/BLEUnlockTests/MacLocationProviderTests.swift b/BLEUnlockTests/MacLocationProviderTests.swift new file mode 100644 index 00000000..104b5b59 --- /dev/null +++ b/BLEUnlockTests/MacLocationProviderTests.swift @@ -0,0 +1,143 @@ +import CoreLocation +import XCTest +@testable import BLEUnlock + +final class MacLocationProviderTests: XCTestCase { + private var client: RecordingCoreLocationClient! + private var scheduler: ControlledLocationTimeoutScheduler! + + override func setUp() { + super.setUp() + client = RecordingCoreLocationClient() + scheduler = ControlledLocationTimeoutScheduler() + } + + func testFreshValidLocationCompletesOnce() { + let capturedAt = Date(timeIntervalSince1970: 1_000) + let provider = makeProvider() + var results: [Result] = [] + + 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 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 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 { + CLLocation(coordinate: .init(latitude: 25.033, longitude: 121.5654), + altitude: 0, + horizontalAccuracy: 18, + verticalAccuracy: -1, + timestamp: Date()) + } + + 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]() } +} From 767fcb86eed6ea74dfe337620d94f4c4a451d18c Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 18:43:29 -0600 Subject: [PATCH 16/23] Cover Mac location failure boundaries --- BLEUnlockTests/MacLocationProviderTests.swift | 140 +++++++++++++++++- 1 file changed, 137 insertions(+), 3 deletions(-) diff --git a/BLEUnlockTests/MacLocationProviderTests.swift b/BLEUnlockTests/MacLocationProviderTests.swift index 104b5b59..81fc62d8 100644 --- a/BLEUnlockTests/MacLocationProviderTests.swift +++ b/BLEUnlockTests/MacLocationProviderTests.swift @@ -1,4 +1,5 @@ import CoreLocation +import Foundation import XCTest @testable import BLEUnlock @@ -55,6 +56,66 @@ final class MacLocationProviderTests: XCTestCase { 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] = [] @@ -68,6 +129,72 @@ final class MacLocationProviderTests: XCTestCase { 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) @@ -87,11 +214,18 @@ final class MacLocationProviderTests: XCTestCase { } private var freshLocation: CLLocation { - CLLocation(coordinate: .init(latitude: 25.033, longitude: 121.5654), + 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: 18, + horizontalAccuracy: horizontalAccuracy, verticalAccuracy: -1, - timestamp: Date()) + timestamp: timestamp) } private func assertAuthorization(_ status: CLAuthorizationStatus, From 38aa3a0f6459cd4903bd7fe45cff76405b426173 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 18:50:36 -0600 Subject: [PATCH 17/23] Add opt-in Telegram location setting --- BLEUnlock/Base.lproj/Localizable.strings | 1 + BLEUnlock/TelegramMenuController.swift | 25 ++++++++++- BLEUnlock/TelegramSettings.swift | 6 +++ BLEUnlock/da.lproj/Localizable.strings | 1 + BLEUnlock/de.lproj/Localizable.strings | 1 + BLEUnlock/ja.lproj/Localizable.strings | 1 + BLEUnlock/nb.lproj/Localizable.strings | 1 + BLEUnlock/sv.lproj/Localizable.strings | 1 + BLEUnlock/tr.lproj/Localizable.strings | 1 + BLEUnlock/zh-Hans.lproj/Localizable.strings | 1 + BLEUnlockTests/LocalizationTests.swift | 2 +- .../TelegramMenuControllerTests.swift | 44 +++++++++++++++++++ BLEUnlockTests/TelegramSettingsTests.swift | 8 ++++ 13 files changed, 91 insertions(+), 2 deletions(-) diff --git a/BLEUnlock/Base.lproj/Localizable.strings b/BLEUnlock/Base.lproj/Localizable.strings index a29b509d..2b196645 100644 --- a/BLEUnlock/Base.lproj/Localizable.strings +++ b/BLEUnlock/Base.lproj/Localizable.strings @@ -47,6 +47,7 @@ "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"; diff --git a/BLEUnlock/TelegramMenuController.swift b/BLEUnlock/TelegramMenuController.swift index 09305ac7..4454f70f 100644 --- a/BLEUnlock/TelegramMenuController.swift +++ b/BLEUnlock/TelegramMenuController.swift @@ -20,10 +20,12 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { 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) @@ -31,10 +33,12 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { 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"), @@ -70,6 +74,9 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { 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: "") @@ -82,6 +89,7 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { testItem.target = self eventItems.values.forEach { $0.target = self } photoItem.target = self + locationItem.target = self menu.autoenablesItems = false menu.delegate = self @@ -92,6 +100,7 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { menu.addItem(eventsItem) menu.addItem(photoItem) menu.addItem(privacyItem) + menu.addItem(locationItem) menu.addItem(.separator()) menu.addItem(statusItem) } @@ -105,6 +114,8 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { 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") @@ -132,7 +143,19 @@ final class TelegramMenuController: NSObject, NSMenuDelegate { @objc internal func togglePhoto(_ item: NSMenuItem) { settings.takePhotoOnIntruded.toggle() - item.state = settings.takePhotoOnIntruded ? .on : .off + 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() { diff --git a/BLEUnlock/TelegramSettings.swift b/BLEUnlock/TelegramSettings.swift index 70590525..0b47ea42 100644 --- a/BLEUnlock/TelegramSettings.swift +++ b/BLEUnlock/TelegramSettings.swift @@ -10,6 +10,7 @@ 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" } @@ -32,6 +33,11 @@ final class TelegramSettings { 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 } diff --git a/BLEUnlock/da.lproj/Localizable.strings b/BLEUnlock/da.lproj/Localizable.strings index 7f8a44e0..c5fbc41a 100644 --- a/BLEUnlock/da.lproj/Localizable.strings +++ b/BLEUnlock/da.lproj/Localizable.strings @@ -47,6 +47,7 @@ "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"; diff --git a/BLEUnlock/de.lproj/Localizable.strings b/BLEUnlock/de.lproj/Localizable.strings index b8a117e9..26bf2e9c 100644 --- a/BLEUnlock/de.lproj/Localizable.strings +++ b/BLEUnlock/de.lproj/Localizable.strings @@ -47,6 +47,7 @@ "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"; diff --git a/BLEUnlock/ja.lproj/Localizable.strings b/BLEUnlock/ja.lproj/Localizable.strings index 72cc0a00..654ddeb9 100644 --- a/BLEUnlock/ja.lproj/Localizable.strings +++ b/BLEUnlock/ja.lproj/Localizable.strings @@ -47,6 +47,7 @@ "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" = "無効"; diff --git a/BLEUnlock/nb.lproj/Localizable.strings b/BLEUnlock/nb.lproj/Localizable.strings index e891e2b7..46e7b781 100644 --- a/BLEUnlock/nb.lproj/Localizable.strings +++ b/BLEUnlock/nb.lproj/Localizable.strings @@ -47,6 +47,7 @@ "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"; diff --git a/BLEUnlock/sv.lproj/Localizable.strings b/BLEUnlock/sv.lproj/Localizable.strings index 2cdeaa32..fb7811d2 100644 --- a/BLEUnlock/sv.lproj/Localizable.strings +++ b/BLEUnlock/sv.lproj/Localizable.strings @@ -47,6 +47,7 @@ "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"; diff --git a/BLEUnlock/tr.lproj/Localizable.strings b/BLEUnlock/tr.lproj/Localizable.strings index 3f3bc3a4..88598462 100644 --- a/BLEUnlock/tr.lproj/Localizable.strings +++ b/BLEUnlock/tr.lproj/Localizable.strings @@ -47,6 +47,7 @@ "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ışı"; diff --git a/BLEUnlock/zh-Hans.lproj/Localizable.strings b/BLEUnlock/zh-Hans.lproj/Localizable.strings index 748b986b..36689ac1 100644 --- a/BLEUnlock/zh-Hans.lproj/Localizable.strings +++ b/BLEUnlock/zh-Hans.lproj/Localizable.strings @@ -47,6 +47,7 @@ "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" = "已禁用"; diff --git a/BLEUnlockTests/LocalizationTests.swift b/BLEUnlockTests/LocalizationTests.swift index 8c11cb3a..4805a64b 100644 --- a/BLEUnlockTests/LocalizationTests.swift +++ b/BLEUnlockTests/LocalizationTests.swift @@ -7,7 +7,7 @@ final class LocalizationTests: XCTestCase { "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_status_not_configured", + "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", diff --git a/BLEUnlockTests/TelegramMenuControllerTests.swift b/BLEUnlockTests/TelegramMenuControllerTests.swift index 6f4e25d4..f39172c8 100644 --- a/BLEUnlockTests/TelegramMenuControllerTests.swift +++ b/BLEUnlockTests/TelegramMenuControllerTests.swift @@ -8,6 +8,7 @@ final class TelegramMenuControllerTests: XCTestCase { private var settings: TelegramSettings! private var service: RecordingTelegramNotificationService! private var dialogs: RecordingTelegramDialogPresenter! + private var locationAuthorization: RecordingLocationAuthorizationRequester! private var controller: TelegramMenuController! override func setUp() { @@ -18,9 +19,11 @@ final class TelegramMenuControllerTests: XCTestCase { 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" }) } @@ -31,6 +34,7 @@ final class TelegramMenuControllerTests: XCTestCase { settings = nil service = nil dialogs = nil + locationAuthorization = nil controller = nil super.tearDown() } @@ -117,6 +121,38 @@ final class TelegramMenuControllerTests: XCTestCase { 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() @@ -131,6 +167,14 @@ final class TelegramMenuControllerTests: XCTestCase { } } +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] = [] diff --git a/BLEUnlockTests/TelegramSettingsTests.swift b/BLEUnlockTests/TelegramSettingsTests.swift index 23c8180a..2b5711a1 100644 --- a/BLEUnlockTests/TelegramSettingsTests.swift +++ b/BLEUnlockTests/TelegramSettingsTests.swift @@ -49,6 +49,14 @@ final class TelegramSettingsTests: XCTestCase { 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") From f7aef443d2c62fd353d263adcce20db6740c2395 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 18:53:51 -0600 Subject: [PATCH 18/23] Send Telegram native location messages --- BLEUnlock/TelegramNotifier.swift | 21 ++++++++++ BLEUnlockTests/TelegramNotifierTests.swift | 47 ++++++++++++++++++++++ BLEUnlockTests/TestDoubles.swift | 14 +++++++ 3 files changed, 82 insertions(+) diff --git a/BLEUnlock/TelegramNotifier.swift b/BLEUnlock/TelegramNotifier.swift index 702a18a9..9a5c96a4 100644 --- a/BLEUnlock/TelegramNotifier.swift +++ b/BLEUnlock/TelegramNotifier.swift @@ -13,6 +13,9 @@ protocol TelegramSending { photoURL: URL, caption: String, completion: @escaping (Result) -> Void) + func sendLocation(credentials: TelegramCredentials, + location: TelegramLocation, + completion: @escaping (Result) -> Void) } enum TelegramError: LocalizedError, Equatable { @@ -78,6 +81,24 @@ final class TelegramNotifier: TelegramSending { 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 diff --git a/BLEUnlockTests/TelegramNotifierTests.swift b/BLEUnlockTests/TelegramNotifierTests.swift index 84456333..c730c830 100644 --- a/BLEUnlockTests/TelegramNotifierTests.swift +++ b/BLEUnlockTests/TelegramNotifierTests.swift @@ -53,6 +53,53 @@ final class TelegramNotifierTests: XCTestCase { 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 diff --git a/BLEUnlockTests/TestDoubles.swift b/BLEUnlockTests/TestDoubles.swift index eedcd1b7..8ac4f4ee 100644 --- a/BLEUnlockTests/TestDoubles.swift +++ b/BLEUnlockTests/TestDoubles.swift @@ -43,10 +43,17 @@ final class RecordingTelegramSender: TelegramSending { 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] = [] func sendText(credentials: TelegramCredentials, text: String, @@ -64,6 +71,13 @@ final class RecordingTelegramSender: TelegramSending { caption: caption)) completion(photoResult) } + + func sendLocation(credentials: TelegramCredentials, + location: TelegramLocation, + completion: @escaping (Result) -> Void) { + locationCalls.append(.init(credentials: credentials, location: location)) + completion(locationResult) + } } final class StubPhotoCapturer: PhotoCapturing { From c9cce352bf5f5d14e8b890930cb8cedcefd8cc6d Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 19:00:29 -0600 Subject: [PATCH 19/23] Format Telegram photo location captions --- BLEUnlock/Base.lproj/Localizable.strings | 4 +++ BLEUnlock/TelegramNotificationService.swift | 24 ++++++++++++++ BLEUnlock/da.lproj/Localizable.strings | 4 +++ BLEUnlock/de.lproj/Localizable.strings | 4 +++ BLEUnlock/ja.lproj/Localizable.strings | 4 +++ BLEUnlock/nb.lproj/Localizable.strings | 4 +++ BLEUnlock/sv.lproj/Localizable.strings | 4 +++ BLEUnlock/tr.lproj/Localizable.strings | 4 +++ BLEUnlock/zh-Hans.lproj/Localizable.strings | 4 +++ BLEUnlockTests/LocalizationTests.swift | 4 ++- .../TelegramNotificationServiceTests.swift | 31 +++++++++++++++++++ 11 files changed, 90 insertions(+), 1 deletion(-) diff --git a/BLEUnlock/Base.lproj/Localizable.strings b/BLEUnlock/Base.lproj/Localizable.strings index 2b196645..d1e66e94 100644 --- a/BLEUnlock/Base.lproj/Localizable.strings +++ b/BLEUnlock/Base.lproj/Localizable.strings @@ -78,3 +78,7 @@ "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."; diff --git a/BLEUnlock/TelegramNotificationService.swift b/BLEUnlock/TelegramNotificationService.swift index 9815b431..1dd32d39 100644 --- a/BLEUnlock/TelegramNotificationService.swift +++ b/BLEUnlock/TelegramNotificationService.swift @@ -9,6 +9,8 @@ protocol TelegramNotificationHandling { protocol TelegramMessageFormatting { func message(for context: TelegramEventContext) -> String + func photoCaption(for context: TelegramEventContext, + location: TelegramLocation?) -> String } protocol FailureReporting { @@ -80,6 +82,28 @@ final class TelegramMessageFormatter: TelegramMessageFormatting { 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 diff --git a/BLEUnlock/da.lproj/Localizable.strings b/BLEUnlock/da.lproj/Localizable.strings index c5fbc41a..68d33d56 100644 --- a/BLEUnlock/da.lproj/Localizable.strings +++ b/BLEUnlock/da.lproj/Localizable.strings @@ -78,3 +78,7 @@ "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."; diff --git a/BLEUnlock/de.lproj/Localizable.strings b/BLEUnlock/de.lproj/Localizable.strings index 26bf2e9c..ff2ecb2b 100644 --- a/BLEUnlock/de.lproj/Localizable.strings +++ b/BLEUnlock/de.lproj/Localizable.strings @@ -78,3 +78,7 @@ "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."; diff --git a/BLEUnlock/ja.lproj/Localizable.strings b/BLEUnlock/ja.lproj/Localizable.strings index 654ddeb9..476731d7 100644 --- a/BLEUnlock/ja.lproj/Localizable.strings +++ b/BLEUnlock/ja.lproj/Localizable.strings @@ -78,3 +78,7 @@ "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" = "撮影時の位置情報を取得できませんでした。"; diff --git a/BLEUnlock/nb.lproj/Localizable.strings b/BLEUnlock/nb.lproj/Localizable.strings index 46e7b781..371f459c 100644 --- a/BLEUnlock/nb.lproj/Localizable.strings +++ b/BLEUnlock/nb.lproj/Localizable.strings @@ -78,3 +78,7 @@ "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."; diff --git a/BLEUnlock/sv.lproj/Localizable.strings b/BLEUnlock/sv.lproj/Localizable.strings index fb7811d2..42d2bc67 100644 --- a/BLEUnlock/sv.lproj/Localizable.strings +++ b/BLEUnlock/sv.lproj/Localizable.strings @@ -78,3 +78,7 @@ "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."; diff --git a/BLEUnlock/tr.lproj/Localizable.strings b/BLEUnlock/tr.lproj/Localizable.strings index 88598462..f1802535 100644 --- a/BLEUnlock/tr.lproj/Localizable.strings +++ b/BLEUnlock/tr.lproj/Localizable.strings @@ -78,3 +78,7 @@ "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ı."; diff --git a/BLEUnlock/zh-Hans.lproj/Localizable.strings b/BLEUnlock/zh-Hans.lproj/Localizable.strings index 36689ac1..1ecbd055 100644 --- a/BLEUnlock/zh-Hans.lproj/Localizable.strings +++ b/BLEUnlock/zh-Hans.lproj/Localizable.strings @@ -78,3 +78,7 @@ "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" = "无法获取拍照时的位置。"; diff --git a/BLEUnlockTests/LocalizationTests.swift b/BLEUnlockTests/LocalizationTests.swift index 4805a64b..720c405d 100644 --- a/BLEUnlockTests/LocalizationTests.swift +++ b/BLEUnlockTests/LocalizationTests.swift @@ -21,7 +21,9 @@ final class LocalizationTests: XCTestCase { "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_time", "telegram_message_rssi", + "telegram_message_coordinates", "telegram_message_accuracy", + "telegram_message_map", "telegram_location_unavailable" ] private let localizationDirectories = ["Base", "da", "de", "ja", "nb", "sv", "tr", "zh-Hans"] diff --git a/BLEUnlockTests/TelegramNotificationServiceTests.swift b/BLEUnlockTests/TelegramNotificationServiceTests.swift index 09ca071b..411762e7 100644 --- a/BLEUnlockTests/TelegramNotificationServiceTests.swift +++ b/BLEUnlockTests/TelegramNotificationServiceTests.swift @@ -267,6 +267,37 @@ final class TelegramNotificationServiceTests: XCTestCase { XCTAssertEqual(delivery.messages, ["Denied", "Offline", "Denied after interval"]) } + 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 From 5b8b26268e20efb2c7e82dcecce8bfab20327a01 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 19:17:39 -0600 Subject: [PATCH 20/23] Attach Mac location to Telegram photos --- BLEUnlock.xcodeproj/project.pbxproj | 8 + BLEUnlock/Base.lproj/Localizable.strings | 2 + BLEUnlock/PhotoLocationCoordinator.swift | 84 +++++++ BLEUnlock/TelegramNotificationService.swift | 106 ++++++++- BLEUnlock/da.lproj/Localizable.strings | 2 + BLEUnlock/de.lproj/Localizable.strings | 2 + BLEUnlock/ja.lproj/Localizable.strings | 2 + BLEUnlock/nb.lproj/Localizable.strings | 2 + BLEUnlock/sv.lproj/Localizable.strings | 2 + BLEUnlock/tr.lproj/Localizable.strings | 2 + BLEUnlock/zh-Hans.lproj/Localizable.strings | 2 + BLEUnlockTests/LocalizationTests.swift | 3 +- .../PhotoLocationCoordinatorTests.swift | 209 ++++++++++++++++++ .../TelegramNotificationServiceTests.swift | 149 +++++++++++++ BLEUnlockTests/TestDoubles.swift | 34 +++ 15 files changed, 597 insertions(+), 12 deletions(-) create mode 100644 BLEUnlock/PhotoLocationCoordinator.swift create mode 100644 BLEUnlockTests/PhotoLocationCoordinatorTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index ca2ee076..04d17d7a 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 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 */; }; @@ -99,6 +101,8 @@ 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 = ""; }; @@ -209,6 +213,7 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E10004A3000000000000001 /* PhotoLocationCoordinator.swift */, 7E1000453000000000000001 /* MacLocationProvider.swift */, 7E1000313000000000000001 /* TelegramMenuController.swift */, 7E10002C3000000000000001 /* TelegramNotificationService.swift */, @@ -240,6 +245,7 @@ 7E1000143000000000000001 /* BLEUnlockTests */ = { isa = PBXGroup; children = ( + 7E10004B3000000000000001 /* PhotoLocationCoordinatorTests.swift */, 7E1000473000000000000001 /* MacLocationProviderTests.swift */, 7E1000343000000000000001 /* LocalizationTests.swift */, 7E10002F3000000000000001 /* TelegramMenuControllerTests.swift */, @@ -434,6 +440,7 @@ 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 */, @@ -459,6 +466,7 @@ 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 */, diff --git a/BLEUnlock/Base.lproj/Localizable.strings b/BLEUnlock/Base.lproj/Localizable.strings index d1e66e94..f7daba83 100644 --- a/BLEUnlock/Base.lproj/Localizable.strings +++ b/BLEUnlock/Base.lproj/Localizable.strings @@ -82,3 +82,5 @@ "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/PhotoLocationCoordinator.swift b/BLEUnlock/PhotoLocationCoordinator.swift new file mode 100644 index 00000000..b6489ee8 --- /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 = 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 = 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/TelegramNotificationService.swift b/BLEUnlock/TelegramNotificationService.swift index 1dd32d39..36ee4846 100644 --- a/BLEUnlock/TelegramNotificationService.swift +++ b/BLEUnlock/TelegramNotificationService.swift @@ -194,6 +194,7 @@ 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 @@ -201,6 +202,7 @@ final class TelegramNotificationService: TelegramNotificationHandling { init(settings: TelegramSettings, sender: TelegramSending, camera: PhotoCapturing, + location: MacLocationProviding = CoreMacLocationProvider(), removeFile: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }, @@ -209,6 +211,7 @@ final class TelegramNotificationService: TelegramNotificationHandling { self.settings = settings self.sender = sender self.camera = camera + self.location = location self.removeFile = removeFile self.reporter = reporter self.formatter = formatter @@ -229,11 +232,20 @@ final class TelegramNotificationService: TelegramNotificationHandling { return } - let message = formatter.message(for: context) if context.event == .intruded && settings.takePhotoOnIntruded { - sendPhotoOrFallback(credentials: credentials, message: message, completion: nil) + 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: message, completion: nil) + sendText(credentials: credentials, + message: formatter.message(for: context), + completion: nil) } } @@ -251,16 +263,88 @@ final class TelegramNotificationService: TelegramNotificationHandling { return } - let message = formatter.message(for: .init(event: .intruded, - hostName: hostName, - timestamp: Date(), - rssi: nil)) + let context = TelegramEventContext(event: .intruded, + hostName: hostName, + timestamp: Date(), + rssi: nil) if settings.takePhotoOnIntruded { - sendPhotoOrFallback(credentials: credentials, - message: message, - completion: completion) + 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: message, completion: completion) + 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 }) + } + } } } diff --git a/BLEUnlock/da.lproj/Localizable.strings b/BLEUnlock/da.lproj/Localizable.strings index 68d33d56..7409be89 100644 --- a/BLEUnlock/da.lproj/Localizable.strings +++ b/BLEUnlock/da.lproj/Localizable.strings @@ -82,3 +82,5 @@ "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/Localizable.strings b/BLEUnlock/de.lproj/Localizable.strings index ff2ecb2b..1e8d3a93 100644 --- a/BLEUnlock/de.lproj/Localizable.strings +++ b/BLEUnlock/de.lproj/Localizable.strings @@ -82,3 +82,5 @@ "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/Localizable.strings b/BLEUnlock/ja.lproj/Localizable.strings index 476731d7..3c40592d 100644 --- a/BLEUnlock/ja.lproj/Localizable.strings +++ b/BLEUnlock/ja.lproj/Localizable.strings @@ -82,3 +82,5 @@ "telegram_message_accuracy" = "位置情報の精度"; "telegram_message_map" = "地図"; "telegram_location_unavailable" = "撮影時の位置情報を取得できませんでした。"; +"telegram_location_error" = "Macの位置情報を取得できませんでした。"; +"telegram_location_send_error" = "写真は送信されましたが、Telegramの地図位置情報を送信できませんでした。"; diff --git a/BLEUnlock/nb.lproj/Localizable.strings b/BLEUnlock/nb.lproj/Localizable.strings index 371f459c..47524399 100644 --- a/BLEUnlock/nb.lproj/Localizable.strings +++ b/BLEUnlock/nb.lproj/Localizable.strings @@ -82,3 +82,5 @@ "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/Localizable.strings b/BLEUnlock/sv.lproj/Localizable.strings index 42d2bc67..92c63bb0 100644 --- a/BLEUnlock/sv.lproj/Localizable.strings +++ b/BLEUnlock/sv.lproj/Localizable.strings @@ -82,3 +82,5 @@ "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/Localizable.strings b/BLEUnlock/tr.lproj/Localizable.strings index f1802535..2b0a3296 100644 --- a/BLEUnlock/tr.lproj/Localizable.strings +++ b/BLEUnlock/tr.lproj/Localizable.strings @@ -82,3 +82,5 @@ "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/Localizable.strings b/BLEUnlock/zh-Hans.lproj/Localizable.strings index 1ecbd055..e5749656 100644 --- a/BLEUnlock/zh-Hans.lproj/Localizable.strings +++ b/BLEUnlock/zh-Hans.lproj/Localizable.strings @@ -82,3 +82,5 @@ "telegram_message_accuracy" = "定位精度"; "telegram_message_map" = "地图"; "telegram_location_unavailable" = "无法获取拍照时的位置。"; +"telegram_location_error" = "无法获取 Mac 位置。"; +"telegram_location_send_error" = "照片已发送,但无法发送 Telegram 地图位置。"; diff --git a/BLEUnlockTests/LocalizationTests.swift b/BLEUnlockTests/LocalizationTests.swift index 720c405d..42cf34ee 100644 --- a/BLEUnlockTests/LocalizationTests.swift +++ b/BLEUnlockTests/LocalizationTests.swift @@ -23,7 +23,8 @@ final class LocalizationTests: XCTestCase { "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_message_map", "telegram_location_unavailable", + "telegram_location_error", "telegram_location_send_error" ] private let localizationDirectories = ["Base", "da", "de", "ja", "nb", "sv", "tr", "zh-Hans"] diff --git a/BLEUnlockTests/PhotoLocationCoordinatorTests.swift b/BLEUnlockTests/PhotoLocationCoordinatorTests.swift new file mode 100644 index 00000000..db8c68da --- /dev/null +++ b/BLEUnlockTests/PhotoLocationCoordinatorTests.swift @@ -0,0 +1,209 @@ +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 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/TelegramNotificationServiceTests.swift b/BLEUnlockTests/TelegramNotificationServiceTests.swift index 411762e7..d17fc678 100644 --- a/BLEUnlockTests/TelegramNotificationServiceTests.swift +++ b/BLEUnlockTests/TelegramNotificationServiceTests.swift @@ -9,6 +9,7 @@ final class TelegramNotificationServiceTests: XCTestCase { 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! @@ -22,12 +23,14 @@ final class TelegramNotificationServiceTests: XCTestCase { 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 ) @@ -40,6 +43,7 @@ final class TelegramNotificationServiceTests: XCTestCase { settings = nil sender = nil camera = nil + location = nil reporter = nil remover = nil service = nil @@ -74,6 +78,7 @@ final class TelegramNotificationServiceTests: XCTestCase { settings: settings, sender: sender, camera: camera, + location: location, removeFile: remover.remove, reporter: reporter ) @@ -179,6 +184,132 @@ final class TelegramNotificationServiceTests: XCTestCase { 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) @@ -313,8 +444,10 @@ final class TelegramNotificationServiceTests: XCTestCase { 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?, @@ -328,6 +461,22 @@ final class TelegramNotificationServiceTests: XCTestCase { } } +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 diff --git a/BLEUnlockTests/TestDoubles.swift b/BLEUnlockTests/TestDoubles.swift index 8ac4f4ee..6c5af216 100644 --- a/BLEUnlockTests/TestDoubles.swift +++ b/BLEUnlockTests/TestDoubles.swift @@ -1,6 +1,12 @@ 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] } @@ -54,10 +60,12 @@ final class RecordingTelegramSender: TelegramSending { 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) } @@ -66,6 +74,7 @@ final class RecordingTelegramSender: TelegramSending { photoURL: URL, caption: String, completion: @escaping (Result) -> Void) { + callOrder.append(.photo) photoCalls.append(.init(credentials: credentials, photoURL: photoURL, caption: caption)) @@ -75,6 +84,7 @@ final class RecordingTelegramSender: TelegramSending { func sendLocation(credentials: TelegramCredentials, location: TelegramLocation, completion: @escaping (Result) -> Void) { + callOrder.append(.location) locationCalls.append(.init(credentials: credentials, location: location)) completion(locationResult) } @@ -90,6 +100,30 @@ final class StubPhotoCapturer: PhotoCapturing { } } +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] = [] From b68b92d4647f9cacb334d2db52c4eb3344c2455a Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 19:24:11 -0600 Subject: [PATCH 21/23] Latch first photo and location callbacks --- BLEUnlock/PhotoLocationCoordinator.swift | 4 +-- .../PhotoLocationCoordinatorTests.swift | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/BLEUnlock/PhotoLocationCoordinator.swift b/BLEUnlock/PhotoLocationCoordinator.swift index b6489ee8..3de6ba5a 100644 --- a/BLEUnlock/PhotoLocationCoordinator.swift +++ b/BLEUnlock/PhotoLocationCoordinator.swift @@ -41,7 +41,7 @@ final class PhotoLocationCoordinator { completion: @escaping (PhotoLocationOutcome) -> Void) { var action: (() -> Void)? lock.lock() - if !didComplete { + if !didComplete, photo == nil { photo = result switch result { case .failure(let error): @@ -64,7 +64,7 @@ final class PhotoLocationCoordinator { completion: @escaping (PhotoLocationOutcome) -> Void) { var action: (() -> Void)? lock.lock() - if !didComplete { + if !didComplete, position == nil { position = result action = finishPhotoIfReady(completion: completion) } diff --git a/BLEUnlockTests/PhotoLocationCoordinatorTests.swift b/BLEUnlockTests/PhotoLocationCoordinatorTests.swift index db8c68da..62809755 100644 --- a/BLEUnlockTests/PhotoLocationCoordinatorTests.swift +++ b/BLEUnlockTests/PhotoLocationCoordinatorTests.swift @@ -61,6 +61,34 @@ final class PhotoLocationCoordinatorTests: XCTestCase { 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)) From 9671dfb5dbfa47502be3c0d1134283257c8d5b08 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Tue, 21 Jul 2026 19:32:29 -0600 Subject: [PATCH 22/23] Wire localized Telegram photo locations --- BLEUnlock.xcodeproj/project.pbxproj | 2 + BLEUnlock/AppDelegate.swift | 5 +- BLEUnlock/BLEUnlock.entitlements | 2 + BLEUnlock/Base.lproj/InfoPlist.strings | 1 + BLEUnlock/Info.plist | 2 + BLEUnlock/da.lproj/InfoPlist.strings | 1 + BLEUnlock/de.lproj/InfoPlist.strings | 1 + BLEUnlock/ja.lproj/InfoPlist.strings | 1 + BLEUnlock/nb.lproj/InfoPlist.strings | 1 + BLEUnlock/sv.lproj/InfoPlist.strings | 1 + BLEUnlock/tr.lproj/InfoPlist.strings | 1 + BLEUnlock/zh-Hans.lproj/InfoPlist.strings | 1 + BLEUnlockTests/LocalizationTests.swift | 78 +++++++++++++++++++ .../TelegramNotificationServiceTests.swift | 12 +++ 14 files changed, 108 insertions(+), 1 deletion(-) diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 04d17d7a..b5515355 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -730,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 = ( @@ -761,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 = ( diff --git a/BLEUnlock/AppDelegate.swift b/BLEUnlock/AppDelegate.swift index 497eba19..f14b7c3c 100644 --- a/BLEUnlock/AppDelegate.swift +++ b/BLEUnlock/AppDelegate.swift @@ -33,16 +33,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa let telegramSettings = TelegramSettings( secrets: KeychainStore(service: "jp.sone.BLEUnlock.telegram") ) + let macLocationProvider = CoreMacLocationProvider() lazy var telegramService: TelegramNotificationHandling = TelegramNotificationService( settings: telegramSettings, sender: TelegramNotifier(transport: URLSessionTransport()), camera: CameraCapture(), + location: macLocationProvider, reporter: RateLimitedFailureReporter() ) lazy var telegramMenuController = TelegramMenuController( settings: telegramSettings, service: telegramService, - dialogs: AppKitTelegramDialogPresenter() + dialogs: AppKitTelegramDialogPresenter(), + locationAuthorization: macLocationProvider ) private let telegramEventQueue = DispatchQueue( label: "jp.sone.BLEUnlock.telegram.events", 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 index 9423a1c3..17230c17 100644 --- a/BLEUnlock/Base.lproj/InfoPlist.strings +++ b/BLEUnlock/Base.lproj/InfoPlist.strings @@ -1 +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/Info.plist b/BLEUnlock/Info.plist index 6c1ec943..e43f7f3d 100644 --- a/BLEUnlock/Info.plist +++ b/BLEUnlock/Info.plist @@ -30,6 +30,8 @@ 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/da.lproj/InfoPlist.strings b/BLEUnlock/da.lproj/InfoPlist.strings index b9963948..ad729354 100644 --- a/BLEUnlock/da.lproj/InfoPlist.strings +++ b/BLEUnlock/da.lproj/InfoPlist.strings @@ -1 +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/de.lproj/InfoPlist.strings b/BLEUnlock/de.lproj/InfoPlist.strings index e6fc917c..56434e8f 100644 --- a/BLEUnlock/de.lproj/InfoPlist.strings +++ b/BLEUnlock/de.lproj/InfoPlist.strings @@ -1 +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/ja.lproj/InfoPlist.strings b/BLEUnlock/ja.lproj/InfoPlist.strings index a4e1dd94..3f2d462f 100644 --- a/BLEUnlock/ja.lproj/InfoPlist.strings +++ b/BLEUnlock/ja.lproj/InfoPlist.strings @@ -1 +1,2 @@ "NSCameraUsageDescription" = "BLEUnlockはシステムのデフォルトカメラで手動ロック解除を撮影し、その警告をTelegramへアップロードします。"; +"NSLocationUsageDescription" = "BLEUnlockは撮影時のこのMacの位置情報を取得し、座標と地図をTelegramのセキュリティ通知に添付します。"; diff --git a/BLEUnlock/nb.lproj/InfoPlist.strings b/BLEUnlock/nb.lproj/InfoPlist.strings index aa3483ae..c4cc4c1f 100644 --- a/BLEUnlock/nb.lproj/InfoPlist.strings +++ b/BLEUnlock/nb.lproj/InfoPlist.strings @@ -1 +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/sv.lproj/InfoPlist.strings b/BLEUnlock/sv.lproj/InfoPlist.strings index 45d53667..04e1a078 100644 --- a/BLEUnlock/sv.lproj/InfoPlist.strings +++ b/BLEUnlock/sv.lproj/InfoPlist.strings @@ -1 +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/tr.lproj/InfoPlist.strings b/BLEUnlock/tr.lproj/InfoPlist.strings index 3ccb33cd..d9f290f5 100644 --- a/BLEUnlock/tr.lproj/InfoPlist.strings +++ b/BLEUnlock/tr.lproj/InfoPlist.strings @@ -1 +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/zh-Hans.lproj/InfoPlist.strings b/BLEUnlock/zh-Hans.lproj/InfoPlist.strings index 99a07037..4376b7e9 100644 --- a/BLEUnlock/zh-Hans.lproj/InfoPlist.strings +++ b/BLEUnlock/zh-Hans.lproj/InfoPlist.strings @@ -1 +1,2 @@ "NSCameraUsageDescription" = "BLEUnlock 使用系统默认摄像头拍摄手动解锁,并将警报上传到 Telegram。"; +"NSLocationUsageDescription" = "BLEUnlock 获取这台 Mac 拍照时的位置,并将坐标和地图附加到 Telegram 安全通知。"; diff --git a/BLEUnlockTests/LocalizationTests.swift b/BLEUnlockTests/LocalizationTests.swift index 42cf34ee..b0adc82e 100644 --- a/BLEUnlockTests/LocalizationTests.swift +++ b/BLEUnlockTests/LocalizationTests.swift @@ -77,6 +77,77 @@ final class LocalizationTests: XCTestCase { } } + 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 安全通知。", + "zh-Hant": "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)" @@ -144,4 +215,11 @@ final class LocalizationTests: XCTestCase { 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[.. Date: Sun, 26 Jul 2026 19:55:11 -0600 Subject: [PATCH 23/23] Keep Telegram PR independent of Traditional Chinese --- BLEUnlockTests/LocalizationTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/BLEUnlockTests/LocalizationTests.swift b/BLEUnlockTests/LocalizationTests.swift index b0adc82e..3dda6009 100644 --- a/BLEUnlockTests/LocalizationTests.swift +++ b/BLEUnlockTests/LocalizationTests.swift @@ -86,8 +86,7 @@ final class LocalizationTests: XCTestCase { "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 安全通知。", - "zh-Hant": "BLEUnlock 取得這部 Mac 拍照當時的位置,並將座標與地圖附加到 Telegram 安全通知。" + "zh-Hans": "BLEUnlock 获取这台 Mac 拍照时的位置,并将坐标和地图附加到 Telegram 安全通知。" ] for name in localizationDirectories {