Skip to content

Commit d2d5911

Browse files
authored
fix(ios): keep While Using selected after approval (#100512)
* fix(ios): preserve initial location permission selection * chore: keep iOS location fix independent
1 parent fccb888 commit d2d5911

3 files changed

Lines changed: 100 additions & 6 deletions

File tree

apps/ios/Sources/Location/LocationService.swift

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
1212

1313
private let manager = CLLocationManager()
1414
private var authWaitID: UUID?
15+
private var authWaitRequiresDeterminedStatus = false
1516
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
1617
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
1718
private var authorizationChangeHandler: (@MainActor @Sendable (CLAuthorizationStatus) -> Void)?
@@ -37,16 +38,18 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
3738

3839
let status = self.manager.authorizationStatus
3940
if status == .notDetermined {
40-
self.manager.requestWhenInUseAuthorization()
41-
let updated = await self.awaitAuthorizationChange()
41+
let updated = await self.requestAuthorization(requiresDeterminedStatus: true) {
42+
self.manager.requestWhenInUseAuthorization()
43+
}
4244
if mode != .always { return updated }
4345
}
4446

4547
if mode == .always {
4648
let current = self.manager.authorizationStatus
4749
if current == .authorizedWhenInUse {
48-
self.manager.requestAlwaysAuthorization()
49-
return await self.awaitAuthorizationChange()
50+
return await self.requestAuthorization(requiresDeterminedStatus: false) {
51+
self.manager.requestAlwaysAuthorization()
52+
}
5053
}
5154
return current
5255
}
@@ -72,11 +75,17 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
7275
})
7376
}
7477

75-
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {
78+
private func requestAuthorization(
79+
requiresDeterminedStatus: Bool,
80+
request: () -> Void) async -> CLAuthorizationStatus
81+
{
7682
await withCheckedContinuation { cont in
7783
let waitID = UUID()
7884
self.authWaitID = waitID
85+
self.authWaitRequiresDeterminedStatus = requiresDeterminedStatus
7986
self.authContinuation = cont
87+
// Install the waiter before requesting permission so a fast delegate callback cannot be lost.
88+
request()
8089
Task { @MainActor in
8190
let clock = ContinuousClock()
8291
let noPromptDeadline = clock.now.advanced(by: .milliseconds(1500))
@@ -91,15 +100,32 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
91100
continue
92101
}
93102
guard observedPrompt || clock.now >= noPromptDeadline else { continue }
94-
self.finishAuthorizationWait(waitID: waitID, status: self.manager.authorizationStatus)
103+
let status = self.manager.authorizationStatus
104+
guard Self.shouldCompleteAuthorizationWait(
105+
status: status,
106+
requiresDeterminedStatus: requiresDeterminedStatus)
107+
else { continue }
108+
self.finishAuthorizationWait(waitID: waitID, status: status)
95109
}
96110
}
97111
}
98112
}
99113

114+
nonisolated static func shouldCompleteAuthorizationWait(
115+
status: CLAuthorizationStatus,
116+
requiresDeterminedStatus: Bool) -> Bool
117+
{
118+
!requiresDeterminedStatus || status != .notDetermined
119+
}
120+
100121
private func finishAuthorizationWait(waitID: UUID, status: CLAuthorizationStatus) {
101122
guard self.authWaitID == waitID, let cont = self.authContinuation else { return }
123+
guard Self.shouldCompleteAuthorizationWait(
124+
status: status,
125+
requiresDeterminedStatus: self.authWaitRequiresDeterminedStatus)
126+
else { return }
102127
self.authWaitID = nil
128+
self.authWaitRequiresDeterminedStatus = false
103129
self.authContinuation = nil
104130
cont.resume(returning: status)
105131
}

apps/ios/Tests/LocationPermissionSummaryTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,21 @@ import Testing
8888
#expect(summary.detailText == "Location Services are off in iOS Settings.")
8989
}
9090

91+
@Test func `initial authorization wait ignores undetermined callbacks`() {
92+
#expect(!LocationService.shouldCompleteAuthorizationWait(
93+
status: .notDetermined,
94+
requiresDeterminedStatus: true))
95+
#expect(LocationService.shouldCompleteAuthorizationWait(
96+
status: .authorizedWhenInUse,
97+
requiresDeterminedStatus: true))
98+
#expect(LocationService.shouldCompleteAuthorizationWait(
99+
status: .denied,
100+
requiresDeterminedStatus: true))
101+
#expect(LocationService.shouldCompleteAuthorizationWait(
102+
status: .notDetermined,
103+
requiresDeterminedStatus: false))
104+
}
105+
91106
@MainActor @Test func `off mode stops significant location monitoring`() async {
92107
let locationService = MockLocationService(authorizationStatus: .authorizedAlways)
93108
let appModel = NodeAppModel(locationService: locationService)

apps/ios/UITests/OpenClawSnapshotUITests.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,59 @@ final class OpenClawSnapshotUITests: XCTestCase {
127127
self.attachScreenshot(named: "location-always-granted-after-slow-prompt")
128128
}
129129

130+
func testLocationWhileUsingStaysSelectedAfterSlowSystemPermissionResponse() throws {
131+
XCUIApplication().resetAuthorizationStatus(for: .location)
132+
self.launchApp(for: ScreenshotTarget(
133+
initialTab: "settings",
134+
initialDestination: "settings",
135+
name: "location-while-using-slow-prompt"))
136+
137+
let permissions = try XCTUnwrap(
138+
self.app?.buttons.containing(.staticText, identifier: "Permissions").firstMatch)
139+
XCTAssertTrue(permissions.waitForExistence(timeout: 8))
140+
permissions.tap()
141+
142+
let offMode = try XCTUnwrap(self.app?.buttons["Off"])
143+
if !offMode.isSelected {
144+
offMode.tap()
145+
XCTAssertTrue(offMode.isSelected)
146+
}
147+
let whileUsingMode = try XCTUnwrap(self.app?.buttons["While Using"])
148+
XCTAssertTrue(whileUsingMode.waitForExistence(timeout: 5))
149+
whileUsingMode.tap()
150+
151+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
152+
let prompt = springboard.alerts.firstMatch
153+
XCTAssertTrue(prompt.waitForExistence(timeout: 5))
154+
Thread.sleep(forTimeInterval: 3)
155+
XCTAssertTrue(prompt.exists)
156+
XCTAssertTrue(whileUsingMode.isSelected)
157+
XCTAssertTrue(self.app?.staticTexts["Requesting iOS location permission…"].exists == true)
158+
159+
let allow = prompt.buttons.matching(
160+
NSPredicate(format: "label CONTAINS[c] 'While Using'")).firstMatch
161+
XCTAssertTrue(allow.exists)
162+
allow.tap()
163+
164+
self.app?.activate()
165+
XCTAssertTrue(whileUsingMode.waitForExistence(timeout: 5))
166+
XCTAssertTrue(whileUsingMode.isSelected)
167+
XCTAssertFalse(self.app?.staticTexts["Requesting iOS location permission…"].exists == true)
168+
let foregroundAllowed = try XCTUnwrap(self.app?.staticTexts.matching(
169+
NSPredicate(format: "label BEGINSWITH %@", "Foreground location requests")).firstMatch)
170+
XCTAssertTrue(foregroundAllowed.waitForExistence(timeout: 5))
171+
172+
self.launchApp(for: ScreenshotTarget(
173+
initialTab: "settings",
174+
initialDestination: "settings",
175+
name: "location-while-using-relaunch"))
176+
let relaunchedPermissions = try XCTUnwrap(
177+
self.app?.buttons.containing(.staticText, identifier: "Permissions").firstMatch)
178+
XCTAssertTrue(relaunchedPermissions.waitForExistence(timeout: 8))
179+
relaunchedPermissions.tap()
180+
XCTAssertTrue(self.app?.buttons["While Using"].isSelected == true)
181+
}
182+
130183
func testSettingsBackReturnsToOriginatingPhoneTab() throws {
131184
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone settings navigation only")
132185

0 commit comments

Comments
 (0)