From aa75340c156a54be1b563f30c14ddd0e098639b6 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Sun, 26 Jul 2026 20:06:42 -0600 Subject: [PATCH 1/4] Add stable proximity confirmation engine --- BLEUnlock.xcodeproj/project.pbxproj | 108 +++++++++++++++ .../xcshareddata/xcschemes/BLEUnlock.xcscheme | 10 ++ BLEUnlock/ProximityConfirmation.swift | 96 +++++++++++++ .../ProximityConfirmationTests.swift | 131 ++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 BLEUnlock/ProximityConfirmation.swift create mode 100644 BLEUnlockTests/ProximityConfirmationTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index c7befb8b..a135a2e7 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 7E1000033000000000000001 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E1000133000000000000001 /* XCTest.framework */; }; + 7E10004C3000000000000001 /* ProximityConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10004D3000000000000001 /* ProximityConfirmation.swift */; }; + 7E10004E3000000000000001 /* ProximityConfirmationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */; }; 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 +29,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 +53,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 7E1000103000000000000001 /* BLEUnlockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BLEUnlockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7E1000133000000000000001 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; + 7E10004D3000000000000001 /* ProximityConfirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityConfirmation.swift; sourceTree = ""; }; + 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityConfirmationTests.swift; sourceTree = ""; }; 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 +115,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 +144,7 @@ isa = PBXGroup; children = ( 3DD4B652226C1C3200451B7B /* BLEUnlock */, + 7E1000143000000000000001 /* BLEUnlockTests */, 3D600A4122701A5B0068FB7B /* Launcher */, 3DD4B651226C1C3200451B7B /* Products */, 3DD4B667226C1E3700451B7B /* Frameworks */, @@ -130,6 +156,7 @@ children = ( 3DD4B650226C1C3200451B7B /* BLEUnlock.app */, 3D600A4022701A5A0068FB7B /* Launcher.app */, + 7E1000103000000000000001 /* BLEUnlockTests.xctest */, ); name = Products; sourceTree = ""; @@ -137,6 +164,7 @@ 3DD4B652226C1C3200451B7B /* BLEUnlock */ = { isa = PBXGroup; children = ( + 7E10004D3000000000000001 /* ProximityConfirmation.swift */, 3D95379322784D550017D8B9 /* AboutBox.swift */, 3D953799227853E20017D8B9 /* AboutBox.xib */, 3D600A35226CC1A40068FB7B /* Localizable.strings */, @@ -156,6 +184,14 @@ path = BLEUnlock; sourceTree = ""; }; + 7E1000143000000000000001 /* BLEUnlockTests */ = { + isa = PBXGroup; + children = ( + 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */, + ); + path = BLEUnlockTests; + sourceTree = ""; + }; 3DD4B667226C1E3700451B7B /* Frameworks */ = { isa = PBXGroup; children = ( @@ -204,6 +240,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 +318,7 @@ targets = ( 3DD4B64F226C1C3200451B7B /* BLEUnlock */, 3D600A3F22701A5A0068FB7B /* Launcher */, + 7E1000163000000000000001 /* BLEUnlockTests */, ); }; /* End PBXProject section */ @@ -312,6 +366,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 7E1000183000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E10004E3000000000000001 /* ProximityConfirmationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3D600A3C22701A5A0068FB7B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -325,6 +387,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E10004C3000000000000001 /* ProximityConfirmation.swift in Sources */, 3DD4B654226C1C3200451B7B /* AppDelegate.swift in Sources */, 3DD4B666226C1DF200451B7B /* BLE.swift in Sources */, 3DD4B664226C1CF000451B7B /* lowlevel.c in Sources */, @@ -337,6 +400,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; @@ -605,6 +676,34 @@ }; name = Release; }; + 7E1000193000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.13; + 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 = 10.13; + 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 +734,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"> + + + + ProximityConfirmationDecision { + if let activeThreshold = activeThreshold, + activeThreshold != unlockThreshold { + reset() + } + + if let startedAt = startedAt, + timestamp - startedAt >= timeout { + let sampleCount = samples.count + let elapsed = timestamp - startedAt + reset() + return .rejected(reason: .timeout, + sampleCount: sampleCount, + elapsed: elapsed) + } + + if startedAt == nil { + guard rssi >= unlockThreshold - candidateMargin else { + return .ignored + } + startedAt = timestamp + activeThreshold = unlockThreshold + } + + samples.append(rssi) + let qualifyingCount = samples.filter { $0 >= unlockThreshold }.count + let sampleCount = samples.count + let elapsed = timestamp - (startedAt ?? timestamp) + + if qualifyingCount >= requiredQualifyingSamples { + reset() + return .confirmed(sampleCount: sampleCount, elapsed: elapsed) + } + if sampleCount >= maximumSamples { + reset() + return .rejected(reason: .sampleLimit, + sampleCount: sampleCount, + elapsed: elapsed) + } + return sampleCount == 1 + ? .started(sampleCount: sampleCount, + qualifyingCount: qualifyingCount) + : .collecting(sampleCount: sampleCount, + qualifyingCount: qualifyingCount) + } + + mutating func expire(at timestamp: TimeInterval) -> ProximityConfirmationDecision { + guard let startedAt = startedAt, + timestamp - startedAt >= timeout else { + return .ignored + } + let sampleCount = samples.count + let elapsed = timestamp - startedAt + reset() + return .rejected(reason: .timeout, + sampleCount: sampleCount, + elapsed: elapsed) + } + + mutating func reset() { + startedAt = nil + activeThreshold = nil + samples.removeAll() + } +} diff --git a/BLEUnlockTests/ProximityConfirmationTests.swift b/BLEUnlockTests/ProximityConfirmationTests.swift new file mode 100644 index 00000000..c48651bd --- /dev/null +++ b/BLEUnlockTests/ProximityConfirmationTests.swift @@ -0,0 +1,131 @@ +import Foundation +import XCTest +@testable import BLEUnlock + +final class ProximityConfirmationTests: XCTestCase { + func testIgnoresSamplesBelowCandidateThreshold() { + var confirmation = ProximityConfirmation() + + let decision = confirmation.record(rssi: -61, + at: 0, + unlockThreshold: -55) + + XCTAssertEqual(decision, .ignored) + XCTAssertFalse(confirmation.isConfirming) + } + + func testCandidateBoundaryStartsConfirmationWithoutQualifying() { + var confirmation = ProximityConfirmation() + + let decision = confirmation.record(rssi: -60, + at: 0, + unlockThreshold: -55) + + XCTAssertEqual(decision, .started(sampleCount: 1, + qualifyingCount: 0)) + XCTAssertTrue(confirmation.isConfirming) + } + + func testTwoQualifyingSamplesOutOfThreeConfirm() { + var confirmation = ProximityConfirmation() + XCTAssertEqual(confirmation.record(rssi: -58, at: 0, + unlockThreshold: -55), + .started(sampleCount: 1, qualifyingCount: 0)) + XCTAssertEqual(confirmation.record(rssi: -54, at: 0.4, + unlockThreshold: -55), + .collecting(sampleCount: 2, qualifyingCount: 1)) + + let decision = confirmation.record(rssi: -55, at: 0.8, + unlockThreshold: -55) + + XCTAssertEqual(decision, .confirmed(sampleCount: 3, + elapsed: 0.8)) + XCTAssertFalse(confirmation.isConfirming) + } + + func testOneQualifyingSampleOutOfThreeRejects() { + var confirmation = ProximityConfirmation() + _ = confirmation.record(rssi: -60, at: 0, + unlockThreshold: -55) + _ = confirmation.record(rssi: -54, at: 0.4, + unlockThreshold: -55) + + let decision = confirmation.record(rssi: -57, at: 0.8, + unlockThreshold: -55) + + XCTAssertEqual(decision, + .rejected(reason: .sampleLimit, + sampleCount: 3, + elapsed: 0.8)) + XCTAssertFalse(confirmation.isConfirming) + } + + func testStrongSpikeFollowedByTwoWeakSamplesRejects() { + var confirmation = ProximityConfirmation() + _ = confirmation.record(rssi: -35, at: 0, + unlockThreshold: -55) + _ = confirmation.record(rssi: -58, at: 0.4, + unlockThreshold: -55) + + let decision = confirmation.record(rssi: -59, at: 0.8, + unlockThreshold: -55) + + XCTAssertEqual(decision, + .rejected(reason: .sampleLimit, + sampleCount: 3, + elapsed: 0.8)) + } + + func testSingleStrongSpikeTimesOutWithoutConfirming() { + var confirmation = ProximityConfirmation() + _ = confirmation.record(rssi: -40, at: 0, + unlockThreshold: -55) + + let decision = confirmation.expire(at: 1.5) + + XCTAssertEqual(decision, + .rejected(reason: .timeout, + sampleCount: 1, + elapsed: 1.5)) + XCTAssertFalse(confirmation.isConfirming) + } + + func testSampleAtTimeoutBoundaryIsRejectedAsTimeout() { + var confirmation = ProximityConfirmation() + _ = confirmation.record(rssi: -54, at: 0, + unlockThreshold: -55) + + let decision = confirmation.record(rssi: -54, at: 1.5, + unlockThreshold: -55) + + XCTAssertEqual(decision, + .rejected(reason: .timeout, + sampleCount: 1, + elapsed: 1.5)) + } + + func testResetClearsSamplesBeforeNextAttempt() { + var confirmation = ProximityConfirmation() + _ = confirmation.record(rssi: -54, at: 0, + unlockThreshold: -55) + confirmation.reset() + + let decision = confirmation.record(rssi: -54, at: 0.4, + unlockThreshold: -55) + + XCTAssertEqual(decision, .started(sampleCount: 1, + qualifyingCount: 1)) + } + + func testChangingThresholdStartsFreshAttempt() { + var confirmation = ProximityConfirmation() + _ = confirmation.record(rssi: -54, at: 0, + unlockThreshold: -55) + + let decision = confirmation.record(rssi: -59, at: 0.4, + unlockThreshold: -60) + + XCTAssertEqual(decision, .started(sampleCount: 1, + qualifyingCount: 1)) + } +} From 12d12fcf2dd930098eac0b05e20c4dda6b71bf58 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Sun, 26 Jul 2026 20:07:41 -0600 Subject: [PATCH 2/4] Add burst sampling for proximity confirmation --- BLEUnlock.xcodeproj/project.pbxproj | 8 ++ BLEUnlock/ProximityMonitor.swift | 123 ++++++++++++++++++++ BLEUnlockTests/ProximityMonitorTests.swift | 124 +++++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 BLEUnlock/ProximityMonitor.swift create mode 100644 BLEUnlockTests/ProximityMonitorTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index a135a2e7..5b2b1cab 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -9,7 +9,9 @@ /* Begin PBXBuildFile section */ 7E1000033000000000000001 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E1000133000000000000001 /* XCTest.framework */; }; 7E10004C3000000000000001 /* ProximityConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10004D3000000000000001 /* ProximityConfirmation.swift */; }; + 7E1000503000000000000001 /* ProximityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000513000000000000001 /* ProximityMonitor.swift */; }; 7E10004E3000000000000001 /* ProximityConfirmationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */; }; + 7E1000523000000000000001 /* ProximityMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000533000000000000001 /* ProximityMonitorTests.swift */; }; 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 */; }; @@ -56,7 +58,9 @@ 7E1000103000000000000001 /* BLEUnlockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BLEUnlockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7E1000133000000000000001 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; 7E10004D3000000000000001 /* ProximityConfirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityConfirmation.swift; sourceTree = ""; }; + 7E1000513000000000000001 /* ProximityMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitor.swift; sourceTree = ""; }; 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityConfirmationTests.swift; sourceTree = ""; }; + 7E1000533000000000000001 /* ProximityMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitorTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -165,6 +169,7 @@ isa = PBXGroup; children = ( 7E10004D3000000000000001 /* ProximityConfirmation.swift */, + 7E1000513000000000000001 /* ProximityMonitor.swift */, 3D95379322784D550017D8B9 /* AboutBox.swift */, 3D953799227853E20017D8B9 /* AboutBox.xib */, 3D600A35226CC1A40068FB7B /* Localizable.strings */, @@ -188,6 +193,7 @@ isa = PBXGroup; children = ( 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */, + 7E1000533000000000000001 /* ProximityMonitorTests.swift */, ); path = BLEUnlockTests; sourceTree = ""; @@ -371,6 +377,7 @@ buildActionMask = 2147483647; files = ( 7E10004E3000000000000001 /* ProximityConfirmationTests.swift in Sources */, + 7E1000523000000000000001 /* ProximityMonitorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -388,6 +395,7 @@ buildActionMask = 2147483647; files = ( 7E10004C3000000000000001 /* ProximityConfirmation.swift in Sources */, + 7E1000503000000000000001 /* ProximityMonitor.swift in Sources */, 3DD4B654226C1C3200451B7B /* AppDelegate.swift in Sources */, 3DD4B666226C1DF200451B7B /* BLE.swift in Sources */, 3DD4B664226C1CF000451B7B /* lowlevel.c in Sources */, diff --git a/BLEUnlock/ProximityMonitor.swift b/BLEUnlock/ProximityMonitor.swift new file mode 100644 index 00000000..fd417ff9 --- /dev/null +++ b/BLEUnlock/ProximityMonitor.swift @@ -0,0 +1,123 @@ +import Foundation + +protocol ProximityScheduledCancellation: AnyObject { + func cancel() +} + +protocol ProximityScheduling { + @discardableResult + func schedule(after interval: TimeInterval, + repeats: Bool, + _ action: @escaping () -> Void) -> ProximityScheduledCancellation +} + +private final class TimerProximityCancellation: ProximityScheduledCancellation { + private let timer: Timer + + init(timer: Timer) { + self.timer = timer + } + + func cancel() { + timer.invalidate() + } +} + +final class RunLoopProximityScheduler: ProximityScheduling { + func schedule(after interval: TimeInterval, + repeats: Bool, + _ action: @escaping () -> Void) -> ProximityScheduledCancellation { + let timer = Timer(timeInterval: interval, repeats: repeats) { _ in action() } + RunLoop.main.add(timer, forMode: .common) + return TimerProximityCancellation(timer: timer) + } +} + +final class ProximityMonitor { + private let scheduler: ProximityScheduling + private let now: () -> TimeInterval + private let requestSample: () -> Void + private let onConfirmed: () -> Void + private let logger: (String) -> Void + private var confirmation = ProximityConfirmation() + private var timeoutCancellation: ProximityScheduledCancellation? + private var burstCancellation: ProximityScheduledCancellation? + + init(scheduler: ProximityScheduling = RunLoopProximityScheduler(), + now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, + requestSample: @escaping () -> Void, + onConfirmed: @escaping () -> Void, + logger: @escaping (String) -> Void = { print($0) }) { + self.scheduler = scheduler + self.now = now + self.requestSample = requestSample + self.onConfirmed = onConfirmed + self.logger = logger + } + + func receive(rssi: Int, unlockThreshold: Int, allowsBurst: Bool) { + handle(confirmation.record(rssi: rssi, + at: now(), + unlockThreshold: unlockThreshold), + allowsBurst: allowsBurst, + rssi: rssi) + } + + func reset(reason: String) { + let wasConfirming = confirmation.isConfirming + confirmation.reset() + stopTimers() + if wasConfirming { + logger("Proximity confirmation reset: \(reason)") + } + } + + private func handle(_ decision: ProximityConfirmationDecision, + allowsBurst: Bool, + rssi: Int?) { + switch decision { + case .ignored: + return + case .started(let sampleCount, let qualifyingCount): + logger("Proximity confirmation started: sample \(sampleCount), RSSI \(rssi ?? 0), qualifying \(qualifyingCount)") + startTimers(allowsBurst: allowsBurst) + case .collecting(let sampleCount, let qualifyingCount): + logger("Proximity confirmation sample \(sampleCount), RSSI \(rssi ?? 0), qualifying \(qualifyingCount)") + case .confirmed(let sampleCount, let elapsed): + stopTimers() + logger("Proximity confirmed with \(sampleCount) samples in \(elapsed)s") + onConfirmed() + case .rejected(let reason, let sampleCount, let elapsed): + stopTimers() + logger("Proximity confirmation rejected: \(reason), \(sampleCount) samples in \(elapsed)s") + } + } + + private func startTimers(allowsBurst: Bool) { + stopTimers() + timeoutCancellation = scheduler.schedule(after: confirmation.timeout, + repeats: false) { [weak self] in + guard let self else { return } + self.handle(self.confirmation.expire(at: self.now()), + allowsBurst: false, + rssi: nil) + } + if allowsBurst { + burstCancellation = scheduler.schedule(after: 0.4, + repeats: true) { [weak self] in + self?.requestSample() + } + logger("Proximity burst sampling started") + } + } + + private func stopTimers() { + timeoutCancellation?.cancel() + timeoutCancellation = nil + if burstCancellation != nil { + logger("Proximity burst sampling stopped") + } + burstCancellation?.cancel() + burstCancellation = nil + } +} diff --git a/BLEUnlockTests/ProximityMonitorTests.swift b/BLEUnlockTests/ProximityMonitorTests.swift new file mode 100644 index 00000000..ddc563c8 --- /dev/null +++ b/BLEUnlockTests/ProximityMonitorTests.swift @@ -0,0 +1,124 @@ +import Foundation +import XCTest +@testable import BLEUnlock + +final class ProximityMonitorTests: XCTestCase { + func testCandidateStartsTimeoutAndNormalModeBurst() { + let fixture = Fixture() + + fixture.monitor.receive(rssi: -60, + unlockThreshold: -55, + allowsBurst: true) + + XCTAssertEqual(fixture.scheduler.entries.map(\.interval), [1.5, 0.4]) + XCTAssertEqual(fixture.scheduler.entries.map(\.repeats), [false, true]) + } + + func testPassiveModeStartsTimeoutWithoutBurstRequests() { + let fixture = Fixture() + + fixture.monitor.receive(rssi: -60, + unlockThreshold: -55, + allowsBurst: false) + + XCTAssertEqual(fixture.scheduler.entries.map(\.interval), [1.5]) + XCTAssertEqual(fixture.sampleRequests, 0) + } + + func testBurstTickRequestsFreshRSSI() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -60, + unlockThreshold: -55, + allowsBurst: true) + + try XCTUnwrap(fixture.scheduler.entries.first { $0.repeats }).action() + + XCTAssertEqual(fixture.sampleRequests, 1) + } + + func testSecondQualifyingSampleConfirmsOnceAndCancelsTimers() { + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + fixture.now = 0.4 + + fixture.monitor.receive(rssi: -55, + unlockThreshold: -55, + allowsBurst: true) + + XCTAssertEqual(fixture.confirmations, 1) + XCTAssertTrue(fixture.scheduler.entries.allSatisfy(\.cancellation.isCancelled)) + } + + func testTimeoutRejectsAndStopsBurst() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + fixture.now = 1.5 + + try XCTUnwrap(fixture.scheduler.entries.first { !$0.repeats }).action() + + XCTAssertEqual(fixture.confirmations, 0) + XCTAssertTrue(fixture.scheduler.entries.allSatisfy(\.cancellation.isCancelled)) + XCTAssertTrue(fixture.messages.contains { $0.contains("timeout") }) + } + + func testResetCancelsAttemptAndPreventsStaleTimeoutConfirmation() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + let timeout = try XCTUnwrap(fixture.scheduler.entries.first { !$0.repeats }) + + fixture.monitor.reset(reason: "device changed") + fixture.now = 1.5 + timeout.action() + + XCTAssertEqual(fixture.confirmations, 0) + XCTAssertTrue(timeout.cancellation.isCancelled) + } +} + +private final class Fixture { + var now: TimeInterval = 0 + var sampleRequests = 0 + var confirmations = 0 + var messages: [String] = [] + let scheduler = ManualProximityScheduler() + lazy var monitor = ProximityMonitor( + scheduler: scheduler, + now: { self.now }, + requestSample: { self.sampleRequests += 1 }, + onConfirmed: { self.confirmations += 1 }, + logger: { self.messages.append($0) } + ) +} + +private final class ManualProximityCancellation: ProximityScheduledCancellation { + private(set) var isCancelled = false + func cancel() { isCancelled = true } +} + +private final class ManualProximityScheduler: ProximityScheduling { + struct Entry { + let interval: TimeInterval + let repeats: Bool + let cancellation: ManualProximityCancellation + let action: () -> Void + } + + private(set) var entries: [Entry] = [] + + func schedule(after interval: TimeInterval, + repeats: Bool, + _ action: @escaping () -> Void) -> ProximityScheduledCancellation { + let cancellation = ManualProximityCancellation() + entries.append(Entry(interval: interval, + repeats: repeats, + cancellation: cancellation, + action: action)) + return cancellation + } +} From 6459994c4d033848be3e1223cb77ecb9bf9f6bae Mon Sep 17 00:00:00 2001 From: fred-lede Date: Sun, 26 Jul 2026 20:09:26 -0600 Subject: [PATCH 3/4] Harden automatic unlock proximity sampling --- BLEUnlock.xcodeproj/project.pbxproj | 4 + BLEUnlock/BLE.swift | 111 ++++++++++++--- BLEUnlock/ProximityMonitor.swift | 11 +- BLEUnlockTests/ProximityMonitorTests.swift | 150 +++++++++++++++++++- BLEUnlockTests/ProximityRSSIGateTests.swift | 67 +++++++++ 5 files changed, 322 insertions(+), 21 deletions(-) create mode 100644 BLEUnlockTests/ProximityRSSIGateTests.swift diff --git a/BLEUnlock.xcodeproj/project.pbxproj b/BLEUnlock.xcodeproj/project.pbxproj index 5b2b1cab..d24ff9f8 100644 --- a/BLEUnlock.xcodeproj/project.pbxproj +++ b/BLEUnlock.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 7E1000503000000000000001 /* ProximityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000513000000000000001 /* ProximityMonitor.swift */; }; 7E10004E3000000000000001 /* ProximityConfirmationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */; }; 7E1000523000000000000001 /* ProximityMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000533000000000000001 /* ProximityMonitorTests.swift */; }; + 7E1000433000000000000001 /* ProximityRSSIGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E1000423000000000000001 /* ProximityRSSIGateTests.swift */; }; 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 */; }; @@ -61,6 +62,7 @@ 7E1000513000000000000001 /* ProximityMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitor.swift; sourceTree = ""; }; 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityConfirmationTests.swift; sourceTree = ""; }; 7E1000533000000000000001 /* ProximityMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitorTests.swift; sourceTree = ""; }; + 7E1000423000000000000001 /* ProximityRSSIGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityRSSIGateTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -194,6 +196,7 @@ children = ( 7E10004F3000000000000001 /* ProximityConfirmationTests.swift */, 7E1000533000000000000001 /* ProximityMonitorTests.swift */, + 7E1000423000000000000001 /* ProximityRSSIGateTests.swift */, ); path = BLEUnlockTests; sourceTree = ""; @@ -378,6 +381,7 @@ files = ( 7E10004E3000000000000001 /* ProximityConfirmationTests.swift in Sources */, 7E1000523000000000000001 /* ProximityMonitorTests.swift in Sources */, + 7E1000433000000000000001 /* ProximityRSSIGateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BLEUnlock/BLE.swift b/BLEUnlock/BLE.swift index b37fc88b..58f34fef 100644 --- a/BLEUnlock/BLE.swift +++ b/BLEUnlock/BLE.swift @@ -115,6 +115,24 @@ protocol BLEDelegate { func bluetoothPowerWarn() } +enum ProximityRSSIGate { + static func isMonitoringAvailable(centralState: CBManagerState) -> Bool { + centralState == .poweredOn + } + + static func acceptedRSSI(rawRSSI: Int, + centralState: CBManagerState, + error: Error?) -> Int? { + guard isMonitoringAvailable(centralState: centralState), + error == nil, + rawRSSI != 127, + rawRSSI <= 0 else { + return nil + } + return rawRSSI + } +} + class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { let UNLOCK_DISABLED = 1 let LOCK_DISABLED = -100 @@ -139,9 +157,18 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { var latestN: Int = 5 var activeModeTimer : Timer? = nil var connectionTimer : Timer? = nil + private lazy var proximityMonitor = ProximityMonitor( + requestSample: { [weak self] in + self?.requestBurstRSSI() + }, + onConfirmed: { [weak self] in + self?.confirmMonitoredDeviceClose() + } + ) func scanForPeripherals() { - guard !centralMgr.isScanning else { return } + guard ProximityRSSIGate.isMonitoringAvailable(centralState: centralMgr.state), + !centralMgr.isScanning else { return } centralMgr.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) //print("Start scanning") } @@ -159,6 +186,7 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { } func setPassiveMode(_ mode: Bool) { + proximityMonitor.reset(reason: "passive mode changed") passiveMode = mode if passiveMode { activeModeTimer?.invalidate() @@ -171,6 +199,7 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { } func startMonitor(uuid: UUID) { + proximityMonitor.reset(reason: "monitored device changed") if let p = monitoredPeripheral { centralMgr.cancelPeripheralConnection(p) } @@ -185,10 +214,12 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { } func resetSignalTimer() { + guard ProximityRSSIGate.isMonitoringAvailable(centralState: centralMgr.state) else { return } signalTimer?.invalidate() signalTimer = Timer.scheduledTimer(withTimeInterval: signalTimeout, repeats: false, block: { _ in print("Device is lost") self.delegate?.updateRSSI(rssi: nil, active: false) + self.proximityMonitor.reset(reason: "signal lost") if self.presence { self.presence = false self.delegate?.updatePresence(presence: self.presence, reason: "lost") @@ -209,17 +240,31 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { powerWarn = false case .poweredOff: print("Bluetooth powered off") - presence = false - signalTimer?.invalidate() - signalTimer = nil + stopProximityMonitoring(reason: "Bluetooth powered off") if powerWarn { powerWarn = false delegate?.bluetoothPowerWarn() } - default: - break + case .unknown, .resetting, .unsupported, .unauthorized: + stopProximityMonitoring(reason: "Bluetooth unavailable") + @unknown default: + stopProximityMonitoring(reason: "Bluetooth unavailable") } } + + private func stopProximityMonitoring(reason: String) { + proximityMonitor.reset(reason: reason) + proximityTimer?.invalidate() + proximityTimer = nil + signalTimer?.invalidate() + signalTimer = nil + activeModeTimer?.invalidate() + activeModeTimer = nil + connectionTimer?.invalidate() + connectionTimer = nil + latestRSSIs.removeAll() + presence = false + } func getEstimatedRSSI(rssi: Int) -> Int { if latestRSSIs.count >= latestN { @@ -232,13 +277,35 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { return Int(mean) } + private func requestBurstRSSI() { + guard ProximityRSSIGate.isMonitoringAvailable(centralState: centralMgr.state), + !passiveMode, + let peripheral = monitoredPeripheral else { return } + if peripheral.state == .connected { + peripheral.readRSSI() + } else { + connectMonitoredPeripheral() + } + } + + private func confirmMonitoredDeviceClose() { + guard !presence else { return } + print("Device is close") + presence = true + delegate?.updatePresence(presence: true, reason: "close") + latestRSSIs.removeAll() + } + func updateMonitoredPeripheral(_ rssi: Int) { + guard ProximityRSSIGate.isMonitoringAvailable(centralState: centralMgr.state) else { return } // print(String(format: "rssi: %d", rssi)) - if rssi >= (unlockRSSI == UNLOCK_DISABLED ? lockRSSI : unlockRSSI) && !presence { - print("Device is close") - presence = true - delegate?.updatePresence(presence: presence, reason: "close") - latestRSSIs.removeAll() // Avoid bouncing + if !presence { + let effectiveUnlockRSSI = unlockRSSI == UNLOCK_DISABLED + ? lockRSSI + : unlockRSSI + proximityMonitor.receive(rssi: rssi, + unlockThreshold: effectiveUnlockRSSI, + allowsBurst: !passiveMode) } let estimatedRSSI = getEstimatedRSSI(rssi: rssi) @@ -253,6 +320,7 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { } else if presence && proximityTimer == nil { proximityTimer = Timer.scheduledTimer(withTimeInterval: proximityTimeout, repeats: false, block: { _ in print("Device is away") + self.proximityMonitor.reset(reason: "device away") self.presence = false self.delegate?.updatePresence(presence: self.presence, reason: "away") self.proximityTimer = nil @@ -278,7 +346,8 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { } func connectMonitoredPeripheral() { - guard let p = monitoredPeripheral else { return } + guard ProximityRSSIGate.isMonitoringAvailable(centralState: centralMgr.state), + let p = monitoredPeripheral else { return } // Idk why but this works like a charm when 'didConnect' won't get called. // However, this generates warnings in the log. @@ -304,8 +373,10 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { advertisementData: [String : Any], rssi RSSI: NSNumber) { - let rssi = RSSI.intValue > 0 ? 0 : RSSI.intValue - if let uuid = monitoredUUID { + let rssi = ProximityRSSIGate.acceptedRSSI(rawRSSI: RSSI.intValue, + centralState: central.state, + error: nil) + if let uuid = monitoredUUID, let rssi { if peripheral.identifier.description == uuid.description { if monitoredPeripheral == nil { monitoredPeripheral = peripheral @@ -320,7 +391,7 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { } } - if (scanMode) { + if scanMode, let rssi { if let uuids = advertisementData["kCBAdvDataServiceUUIDs"] as? [CBUUID] { for uuid in uuids { if uuid == ExposureNotification { @@ -357,7 +428,9 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { if scanMode { peripheral.discoverServices([DeviceInformation]) } - if peripheral == monitoredPeripheral && !passiveMode { + if peripheral == monitoredPeripheral, + ProximityRSSIGate.isMonitoringAvailable(centralState: central.state), + !passiveMode { print("Connected") connectionTimer?.invalidate() connectionTimer = nil @@ -370,8 +443,10 @@ class BLE: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { //MARK:- CBPeripheralDelegate start func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { - guard peripheral == monitoredPeripheral else { return } - let rssi = RSSI.intValue > 0 ? 0 : RSSI.intValue + guard peripheral == monitoredPeripheral, + let rssi = ProximityRSSIGate.acceptedRSSI(rawRSSI: RSSI.intValue, + centralState: centralMgr.state, + error: error) else { return } //print("readRSSI \(rssi)dBm") updateMonitoredPeripheral(rssi) lastReadAt = Date().timeIntervalSince1970 diff --git a/BLEUnlock/ProximityMonitor.swift b/BLEUnlock/ProximityMonitor.swift index fd417ff9..44abc1b3 100644 --- a/BLEUnlock/ProximityMonitor.swift +++ b/BLEUnlock/ProximityMonitor.swift @@ -42,6 +42,7 @@ final class ProximityMonitor { private var confirmation = ProximityConfirmation() private var timeoutCancellation: ProximityScheduledCancellation? private var burstCancellation: ProximityScheduledCancellation? + private var activeAttemptID: UUID? init(scheduler: ProximityScheduling = RunLoopProximityScheduler(), now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, @@ -95,9 +96,12 @@ final class ProximityMonitor { private func startTimers(allowsBurst: Bool) { stopTimers() + let attemptID = UUID() + activeAttemptID = attemptID timeoutCancellation = scheduler.schedule(after: confirmation.timeout, repeats: false) { [weak self] in - guard let self else { return } + guard let self, + self.activeAttemptID == attemptID else { return } self.handle(self.confirmation.expire(at: self.now()), allowsBurst: false, rssi: nil) @@ -105,13 +109,16 @@ final class ProximityMonitor { if allowsBurst { burstCancellation = scheduler.schedule(after: 0.4, repeats: true) { [weak self] in - self?.requestSample() + guard let self, + self.activeAttemptID == attemptID else { return } + self.requestSample() } logger("Proximity burst sampling started") } } private func stopTimers() { + activeAttemptID = nil timeoutCancellation?.cancel() timeoutCancellation = nil if burstCancellation != nil { diff --git a/BLEUnlockTests/ProximityMonitorTests.swift b/BLEUnlockTests/ProximityMonitorTests.swift index ddc563c8..6a2cbcaa 100644 --- a/BLEUnlockTests/ProximityMonitorTests.swift +++ b/BLEUnlockTests/ProximityMonitorTests.swift @@ -3,6 +3,35 @@ import XCTest @testable import BLEUnlock final class ProximityMonitorTests: XCTestCase { + func testUnrelatedPhotoSettingDoesNotChangeConfirmationSequence() { + func run(photoEnabled: Bool) -> Int { + let defaults = UserDefaults.standard + let key = "telegram.takePhotoOnIntruded" + let previous = defaults.object(forKey: key) + defer { + if let previous = previous { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defaults.set(photoEnabled, forKey: key) + + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + fixture.now = 0.4 + fixture.monitor.receive(rssi: -55, + unlockThreshold: -55, + allowsBurst: true) + return fixture.confirmations + } + + XCTAssertEqual(run(photoEnabled: false), 1) + XCTAssertEqual(run(photoEnabled: true), 1) + } + func testCandidateStartsTimeoutAndNormalModeBurst() { let fixture = Fixture() @@ -36,6 +65,19 @@ final class ProximityMonitorTests: XCTestCase { XCTAssertEqual(fixture.sampleRequests, 1) } + func testResetInvalidatesStaleBurstCallback() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -60, + unlockThreshold: -55, + allowsBurst: true) + let burst = try XCTUnwrap(fixture.scheduler.entries.first { $0.repeats }) + + fixture.monitor.reset(reason: "device changed") + burst.action() + + XCTAssertEqual(fixture.sampleRequests, 0) + } + func testSecondQualifyingSampleConfirmsOnceAndCancelsTimers() { let fixture = Fixture() fixture.monitor.receive(rssi: -54, @@ -51,6 +93,23 @@ final class ProximityMonitorTests: XCTestCase { XCTAssertTrue(fixture.scheduler.entries.allSatisfy(\.cancellation.isCancelled)) } + func testConfirmationInvalidatesStaleBurstCallback() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + let burst = try XCTUnwrap(fixture.scheduler.entries.first { $0.repeats }) + fixture.now = 0.4 + fixture.monitor.receive(rssi: -55, + unlockThreshold: -55, + allowsBurst: true) + + burst.action() + + XCTAssertEqual(fixture.confirmations, 1) + XCTAssertEqual(fixture.sampleRequests, 0) + } + func testTimeoutRejectsAndStopsBurst() throws { let fixture = Fixture() fixture.monitor.receive(rssi: -54, @@ -65,6 +124,22 @@ final class ProximityMonitorTests: XCTestCase { XCTAssertTrue(fixture.messages.contains { $0.contains("timeout") }) } + func testTimeoutInvalidatesStaleBurstCallback() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + let timeout = try XCTUnwrap(fixture.scheduler.entries.first { !$0.repeats }) + let burst = try XCTUnwrap(fixture.scheduler.entries.first { $0.repeats }) + fixture.now = 1.5 + timeout.action() + + burst.action() + + XCTAssertEqual(fixture.confirmations, 0) + XCTAssertEqual(fixture.sampleRequests, 0) + } + func testResetCancelsAttemptAndPreventsStaleTimeoutConfirmation() throws { let fixture = Fixture() fixture.monitor.receive(rssi: -54, @@ -79,6 +154,75 @@ final class ProximityMonitorTests: XCTestCase { XCTAssertEqual(fixture.confirmations, 0) XCTAssertTrue(timeout.cancellation.isCancelled) } + + func testOldTimeoutDoesNotAffectNewAttempt() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + let oldTimeout = try XCTUnwrap(fixture.scheduler.entries.first { !$0.repeats }) + + fixture.monitor.reset(reason: "device changed") + fixture.now = 0.4 + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + let newTimeout = try XCTUnwrap(fixture.scheduler.entries.last { !$0.repeats }) + fixture.messages.removeAll() + fixture.now = 1.9 + + oldTimeout.action() + + XCTAssertFalse(newTimeout.cancellation.isCancelled) + XCTAssertFalse(fixture.messages.contains { $0.contains("timeout") }) + } + + func testStaleCallbacksAfterConfirmationDoNotConfirmAgain() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + let timeout = try XCTUnwrap(fixture.scheduler.entries.first { !$0.repeats }) + let burst = try XCTUnwrap(fixture.scheduler.entries.first { $0.repeats }) + fixture.now = 0.4 + fixture.monitor.receive(rssi: -55, + unlockThreshold: -55, + allowsBurst: true) + fixture.requestedSample = { + fixture.monitor.receive(rssi: -55, + unlockThreshold: -55, + allowsBurst: true) + } + + fixture.now = 1.5 + timeout.action() + burst.action() + burst.action() + + XCTAssertEqual(fixture.confirmations, 1) + } + + func testStaleCallbacksAfterTimeoutDoNotConfirm() throws { + let fixture = Fixture() + fixture.monitor.receive(rssi: -54, + unlockThreshold: -55, + allowsBurst: true) + let timeout = try XCTUnwrap(fixture.scheduler.entries.first { !$0.repeats }) + let burst = try XCTUnwrap(fixture.scheduler.entries.first { $0.repeats }) + fixture.now = 1.5 + timeout.action() + fixture.requestedSample = { + fixture.monitor.receive(rssi: -55, + unlockThreshold: -55, + allowsBurst: true) + } + + timeout.action() + burst.action() + burst.action() + + XCTAssertEqual(fixture.confirmations, 0) + } } private final class Fixture { @@ -86,11 +230,15 @@ private final class Fixture { var sampleRequests = 0 var confirmations = 0 var messages: [String] = [] + var requestedSample: (() -> Void)? let scheduler = ManualProximityScheduler() lazy var monitor = ProximityMonitor( scheduler: scheduler, now: { self.now }, - requestSample: { self.sampleRequests += 1 }, + requestSample: { + self.sampleRequests += 1 + self.requestedSample?() + }, onConfirmed: { self.confirmations += 1 }, logger: { self.messages.append($0) } ) diff --git a/BLEUnlockTests/ProximityRSSIGateTests.swift b/BLEUnlockTests/ProximityRSSIGateTests.swift new file mode 100644 index 00000000..41d07ce5 --- /dev/null +++ b/BLEUnlockTests/ProximityRSSIGateTests.swift @@ -0,0 +1,67 @@ +import CoreBluetooth +import XCTest +@testable import BLEUnlock + +final class ProximityRSSIGateTests: XCTestCase { + func testInvalidAdvertisementRSSIDoesNotStartOrCompleteConfirmation() throws { + var confirmation = ProximityConfirmation() + + let firstSample = ProximityRSSIGate.acceptedRSSI(rawRSSI: -55, + centralState: .poweredOn, + error: nil) + XCTAssertEqual(firstSample, -55) + XCTAssertEqual(confirmation.record(rssi: try XCTUnwrap(firstSample), + at: 0, + unlockThreshold: -55), + .started(sampleCount: 1, qualifyingCount: 1)) + + XCTAssertNil(ProximityRSSIGate.acceptedRSSI(rawRSSI: 127, + centralState: .poweredOn, + error: nil)) + XCTAssertNil(ProximityRSSIGate.acceptedRSSI(rawRSSI: 1, + centralState: .poweredOn, + error: nil)) + + XCTAssertEqual(confirmation.record(rssi: -56, + at: 0.4, + unlockThreshold: -55), + .collecting(sampleCount: 2, qualifyingCount: 1)) + } + + func testRSSIReadErrorCannotCompleteConfirmation() throws { + var confirmation = ProximityConfirmation() + let sample = ProximityRSSIGate.acceptedRSSI(rawRSSI: -55, + centralState: .poweredOn, + error: nil) + XCTAssertEqual(confirmation.record(rssi: try XCTUnwrap(sample), + at: 0, + unlockThreshold: -55), + .started(sampleCount: 1, qualifyingCount: 1)) + + XCTAssertNil(ProximityRSSIGate.acceptedRSSI(rawRSSI: -55, + centralState: .poweredOn, + error: TestError.readFailed)) + XCTAssertEqual(confirmation.record(rssi: -56, + at: 0.4, + unlockThreshold: -55), + .collecting(sampleCount: 2, qualifyingCount: 1)) + } + + func testUnavailableCentralStatesDoNotAcceptStaleSamples() { + let unavailableStates: [CBManagerState] = [ + .unknown, .resetting, .unsupported, .unauthorized, .poweredOff + ] + + for state in unavailableStates { + XCTAssertFalse(ProximityRSSIGate.isMonitoringAvailable(centralState: state)) + XCTAssertNil(ProximityRSSIGate.acceptedRSSI(rawRSSI: -55, + centralState: state, + error: nil)) + } + XCTAssertTrue(ProximityRSSIGate.isMonitoringAvailable(centralState: .poweredOn)) + } +} + +private enum TestError: Error { + case readFailed +} From 35cfe01d05df362718c2f3a7e5425877bc14bd66 Mon Sep 17 00:00:00 2001 From: fred-lede Date: Sun, 26 Jul 2026 20:10:35 -0600 Subject: [PATCH 4/4] Keep proximity tests feature focused --- BLEUnlockTests/ProximityMonitorTests.swift | 29 ---------------------- 1 file changed, 29 deletions(-) diff --git a/BLEUnlockTests/ProximityMonitorTests.swift b/BLEUnlockTests/ProximityMonitorTests.swift index 6a2cbcaa..68a8d4b6 100644 --- a/BLEUnlockTests/ProximityMonitorTests.swift +++ b/BLEUnlockTests/ProximityMonitorTests.swift @@ -3,35 +3,6 @@ import XCTest @testable import BLEUnlock final class ProximityMonitorTests: XCTestCase { - func testUnrelatedPhotoSettingDoesNotChangeConfirmationSequence() { - func run(photoEnabled: Bool) -> Int { - let defaults = UserDefaults.standard - let key = "telegram.takePhotoOnIntruded" - let previous = defaults.object(forKey: key) - defer { - if let previous = previous { - defaults.set(previous, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - defaults.set(photoEnabled, forKey: key) - - let fixture = Fixture() - fixture.monitor.receive(rssi: -54, - unlockThreshold: -55, - allowsBurst: true) - fixture.now = 0.4 - fixture.monitor.receive(rssi: -55, - unlockThreshold: -55, - allowsBurst: true) - return fixture.confirmations - } - - XCTAssertEqual(run(photoEnabled: false), 1) - XCTAssertEqual(run(photoEnabled: true), 1) - } - func testCandidateStartsTimeoutAndNormalModeBurst() { let fixture = Fixture()