Skip to content

Commit bd8d9df

Browse files
authored
Merge 31ddea5 into 1755a9d
2 parents 1755a9d + 31ddea5 commit bd8d9df

13 files changed

Lines changed: 845 additions & 117 deletions

apps/.i18n/native-source.json

Lines changed: 284 additions & 100 deletions
Large diffs are not rendered by default.

apps/ios/Sources/Design/SettingsProTab.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ struct SettingsProTab: View {
5151
@State var showResetOnboardingAlert = false
5252
@State var suppressCredentialPersist = false
5353
@State var locationStatusText: String?
54+
@State var locationPermissionSummary = LocationPermissionSummary(
55+
desiredMode: .off,
56+
locationServicesEnabled: true,
57+
authorizationStatus: .notDetermined,
58+
accuracyAuthorization: .fullAccuracy)
59+
@State var locationPermissionRefreshID = 0
5460
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
5561
@State var notificationStatus: SettingsNotificationStatus = .checking
5662
@State var isRequestingNotificationAuthorization = false

apps/ios/Sources/Design/SettingsProTabActions.swift

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CoreLocation
12
import OpenClawKit
23
import SwiftUI
34
import UIKit
@@ -170,12 +171,43 @@ extension SettingsProTab {
170171
self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : ""
171172
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
172173
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
174+
self.refreshLocationPermissionSummary()
173175
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
174176
guard !trimmedInstanceId.isEmpty else { return }
175177
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
176178
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
177179
}
178180

181+
func refreshLocationPermissionSummary(desiredMode modeOverride: OpenClawLocationMode? = nil) {
182+
let mode = modeOverride ?? OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
183+
let manager = CLLocationManager()
184+
self.locationPermissionRefreshID &+= 1
185+
let refreshID = self.locationPermissionRefreshID
186+
let currentSummary = self.locationPermissionSummary
187+
self.locationPermissionSummary = LocationPermissionSummary(
188+
desiredMode: mode,
189+
locationServicesEnabled: currentSummary.locationServicesEnabled,
190+
authorizationStatus: manager.authorizationStatus,
191+
accuracyAuthorization: manager.accuracyAuthorization)
192+
Task {
193+
let locationServicesEnabled = await Self.locationServicesEnabled()
194+
guard refreshID == self.locationPermissionRefreshID else { return }
195+
let latestManager = CLLocationManager()
196+
let latestMode = modeOverride ?? OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
197+
self.locationPermissionSummary = LocationPermissionSummary(
198+
desiredMode: latestMode,
199+
locationServicesEnabled: locationServicesEnabled,
200+
authorizationStatus: latestManager.authorizationStatus,
201+
accuracyAuthorization: latestManager.accuracyAuthorization)
202+
}
203+
}
204+
205+
private static func locationServicesEnabled() async -> Bool {
206+
await Task.detached(priority: .utility) {
207+
CLLocationManager.locationServicesEnabled()
208+
}.value
209+
}
210+
179211
func syncAfterOnboardingReset() {
180212
self.connectingGatewayID = nil
181213
self.setupStatusText = nil
@@ -376,22 +408,28 @@ extension SettingsProTab {
376408
{
377409
self.isChangingLocationMode = true
378410
self.locationStatusText = nil
411+
self.refreshLocationPermissionSummary(desiredMode: mode)
379412
defer { self.isChangingLocationMode = false }
380413

381414
if mode == .off {
415+
_ = await self.appModel.requestLocationPermissions(mode: mode)
382416
self.previousLocationModeRaw = rawValue
417+
self.refreshLocationPermissionSummary(desiredMode: mode)
383418
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
384419
return
385420
}
386421

387422
let granted = await self.appModel.requestLocationPermissions(mode: mode)
423+
self.refreshLocationPermissionSummary(desiredMode: mode)
388424
if granted {
389425
self.previousLocationModeRaw = rawValue
390426
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
391427
} else {
392428
self.locationModeRaw = previous
393429
self.previousLocationModeRaw = previous
394430
self.locationStatusText = "Location permission was not granted."
431+
self.refreshLocationPermissionSummary(
432+
desiredMode: OpenClawLocationMode(rawValue: previous) ?? .off)
395433
}
396434
}
397435

@@ -773,15 +811,35 @@ extension SettingsProTab {
773811

774812
var privacyDetail: String {
775813
let location = OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
776-
return location == .off ? "Location off" : "Location \(self.locationLabel)"
777-
}
778-
779-
var locationLabel: String {
780-
switch OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off {
781-
case .off: "Off"
782-
case .whileUsing: "While Using"
783-
case .always: "Always"
784-
}
814+
return switch (location, self.locationPermissionSummary.effectiveMode) {
815+
case (.off, _):
816+
"Location off"
817+
case (.whileUsing, .whileUsing):
818+
"Location While Using"
819+
case (.whileUsing, .off):
820+
"Location While Using, effective Off"
821+
case (.whileUsing, .always):
822+
"Location While Using, effective Always"
823+
case (.always, .always):
824+
"Location Always"
825+
case (.always, .whileUsing):
826+
"Location Always, effective While Using"
827+
case (.always, .off):
828+
"Location Always, effective Off"
829+
}
830+
}
831+
832+
var locationPermissionDetailText: String {
833+
if self.isChangingLocationMode {
834+
return "Requesting iOS location permission…"
835+
}
836+
return self.locationPermissionSummary.detailText
837+
}
838+
839+
var locationPermissionWarningText: String? {
840+
guard let locationStatusText else { return nil }
841+
guard locationStatusText != self.locationPermissionSummary.detailText else { return nil }
842+
return locationStatusText
785843
}
786844

787845
var notificationStatusText: String {

apps/ios/Sources/Design/SettingsProTabSections.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -713,8 +713,13 @@ extension SettingsProTab {
713713
.pickerStyle(.segmented)
714714
.disabled(self.isChangingLocationMode)
715715

716-
if let locationStatusText {
717-
Text(locationStatusText)
716+
Text(self.locationPermissionDetailText)
717+
.font(.caption2)
718+
.foregroundStyle(
719+
self.locationPermissionSummary.needsAttention ? OpenClawBrand.warn : .secondary)
720+
721+
if let locationPermissionWarningText {
722+
Text(locationPermissionWarningText)
718723
.font(.caption2)
719724
.foregroundStyle(OpenClawBrand.warn)
720725
}

apps/ios/Sources/Info.plist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
<key>UIBackgroundModes</key>
9797
<array>
9898
<string>audio</string>
99+
<string>location</string>
99100
<string>remote-notification</string>
100101
</array>
101102
<key>UILaunchScreen</key>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import CoreLocation
2+
import Foundation
3+
import OpenClawKit
4+
5+
struct LocationPermissionSummary: Equatable {
6+
var desiredMode: OpenClawLocationMode
7+
var locationServicesEnabled: Bool
8+
var authorizationStatus: CLAuthorizationStatus
9+
var accuracyAuthorization: CLAccuracyAuthorization
10+
11+
var effectiveMode: OpenClawLocationMode {
12+
guard self.desiredMode != .off else { return .off }
13+
guard self.locationServicesEnabled else { return .off }
14+
switch self.authorizationStatus {
15+
case .authorizedAlways:
16+
return self.desiredMode == .always ? .always : .whileUsing
17+
case .authorizedWhenInUse:
18+
return .whileUsing
19+
default:
20+
return .off
21+
}
22+
}
23+
24+
var canUseLocationInBackground: Bool {
25+
self.locationServicesEnabled && self.desiredMode == .always && self.authorizationStatus == .authorizedAlways
26+
}
27+
28+
var needsAttention: Bool {
29+
switch self.desiredMode {
30+
case .off:
31+
false
32+
case .whileUsing:
33+
!self.locationServicesEnabled ||
34+
(self.authorizationStatus != .authorizedWhenInUse && self.authorizationStatus != .authorizedAlways)
35+
case .always:
36+
!self.locationServicesEnabled || self.authorizationStatus != .authorizedAlways
37+
}
38+
}
39+
40+
var statusText: String {
41+
switch self.effectiveMode {
42+
case .off:
43+
"Off"
44+
case .whileUsing:
45+
"While Using"
46+
case .always:
47+
"Always"
48+
}
49+
}
50+
51+
var detailText: String {
52+
switch (
53+
self.desiredMode,
54+
self.locationServicesEnabled,
55+
self.authorizationStatus,
56+
self.accuracyAuthorization)
57+
{
58+
case (.off, false, _, _):
59+
"Location sharing is disabled in OpenClaw. Location Services are off in iOS Settings."
60+
case (.off, true, .authorizedAlways, _):
61+
"Location sharing is disabled in OpenClaw. iOS currently allows Always."
62+
case (.off, true, .authorizedWhenInUse, _):
63+
"Location sharing is disabled in OpenClaw. iOS currently allows While Using."
64+
case (.off, _, _, _):
65+
"Location sharing is disabled."
66+
case (_, false, _, _):
67+
"Location Services are off in iOS Settings."
68+
case (.whileUsing, true, .authorizedWhenInUse, .fullAccuracy):
69+
"Foreground location requests are allowed. Precise Location is on."
70+
case (.whileUsing, true, .authorizedAlways, .fullAccuracy):
71+
"Foreground location requests are allowed. Precise Location is on."
72+
case (.whileUsing, true, .authorizedWhenInUse, .reducedAccuracy):
73+
"Foreground location requests are allowed. Precise Location is off."
74+
case (.whileUsing, true, .authorizedAlways, .reducedAccuracy):
75+
"Foreground location requests are allowed. Precise Location is off."
76+
case (.whileUsing, true, .authorizedWhenInUse, _):
77+
"Foreground location requests are allowed."
78+
case (.whileUsing, true, .authorizedAlways, _):
79+
"Foreground location requests are allowed."
80+
case (.always, true, .authorizedAlways, .fullAccuracy):
81+
"Background location requests and significant-change updates are allowed. Precise Location is on."
82+
case (.always, true, .authorizedAlways, .reducedAccuracy):
83+
"Background location requests and significant-change updates are allowed. Precise Location is off."
84+
case (.always, true, .authorizedAlways, _):
85+
"Background location requests and significant-change updates are allowed."
86+
case (.always, true, .authorizedWhenInUse, .fullAccuracy):
87+
"Always is selected, but iOS currently allows location only while using the app. Precise Location is on."
88+
case (.always, true, .authorizedWhenInUse, .reducedAccuracy):
89+
"Always is selected, but iOS currently allows location only while using the app. Precise Location is off."
90+
case (.always, true, .authorizedWhenInUse, _):
91+
"Always is selected, but iOS currently allows location only while using the app."
92+
case (_, true, .denied, _):
93+
"Location permission is denied in iOS Settings."
94+
case (_, true, .restricted, _):
95+
"Location permission is restricted on this device."
96+
case (_, true, .notDetermined, _):
97+
"Choose a location mode to request iOS permission."
98+
@unknown default:
99+
"OpenClaw cannot determine the current iOS location permission."
100+
}
101+
}
102+
}

apps/ios/Sources/Location/LocationService.swift

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import CoreLocation
22
import Foundation
33
import OpenClawKit
4+
import UIKit
45

56
@MainActor
67
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
@@ -10,8 +11,10 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
1011
}
1112

1213
private let manager = CLLocationManager()
14+
private var authWaitID: UUID?
1315
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
1416
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
17+
private var authorizationChangeHandler: (@MainActor @Sendable (CLAuthorizationStatus) -> Void)?
1518
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
1619
private var isMonitoringSignificantChanges = false
1720

@@ -71,10 +74,36 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
7174

7275
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {
7376
await withCheckedContinuation { cont in
77+
let waitID = UUID()
78+
self.authWaitID = waitID
7479
self.authContinuation = cont
80+
Task { @MainActor in
81+
let clock = ContinuousClock()
82+
let noPromptDeadline = clock.now.advanced(by: .milliseconds(1500))
83+
var observedPrompt = UIApplication.shared.applicationState != .active
84+
// A slow system prompt must not trigger the no-callback fallback. Once iOS makes
85+
// the app inactive, wait until the user dismisses the prompt and the app returns.
86+
while self.authWaitID == waitID, self.authContinuation != nil {
87+
try? await Task.sleep(for: .milliseconds(100))
88+
let applicationIsActive = UIApplication.shared.applicationState == .active
89+
if !applicationIsActive {
90+
observedPrompt = true
91+
continue
92+
}
93+
guard observedPrompt || clock.now >= noPromptDeadline else { continue }
94+
self.finishAuthorizationWait(waitID: waitID, status: self.manager.authorizationStatus)
95+
}
96+
}
7597
}
7698
}
7799

100+
private func finishAuthorizationWait(waitID: UUID, status: CLAuthorizationStatus) {
101+
guard self.authWaitID == waitID, let cont = self.authContinuation else { return }
102+
self.authWaitID = nil
103+
self.authContinuation = nil
104+
cont.resume(returning: status)
105+
}
106+
78107
private func withTimeout<T: Sendable>(
79108
timeoutMs: Int,
80109
operation: @escaping @Sendable () async throws -> T) async throws -> T
@@ -89,13 +118,28 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
89118
self.manager.startMonitoringSignificantLocationChanges()
90119
}
91120

121+
func setBackgroundLocationUpdatesEnabled(_ enabled: Bool) {
122+
self.manager.allowsBackgroundLocationUpdates = enabled
123+
}
124+
125+
func setAuthorizationChangeHandler(
126+
_ handler: @escaping @MainActor @Sendable (CLAuthorizationStatus) -> Void)
127+
{
128+
self.authorizationChangeHandler = handler
129+
}
130+
131+
func stopMonitoringSignificantLocationChanges() {
132+
self.significantLocationCallback = nil
133+
self.isMonitoringSignificantChanges = false
134+
self.manager.stopMonitoringSignificantLocationChanges()
135+
}
136+
92137
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
93138
let status = manager.authorizationStatus
94139
Task { @MainActor in
95-
if let cont = self.authContinuation {
96-
self.authContinuation = nil
97-
cont.resume(returning: status)
98-
}
140+
self.authorizationChangeHandler?(status)
141+
guard let waitID = self.authWaitID else { return }
142+
self.finishAuthorizationWait(waitID: waitID, status: status)
99143
}
100144
}
101145

apps/ios/Sources/Location/SignificantLocationMonitor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum SignificantLocationMonitor {
1616
guard locationMode == .always else { return }
1717
let status = locationService.authorizationStatus()
1818
guard status == .authorizedAlways else { return }
19+
locationService.setBackgroundLocationUpdatesEnabled(true)
1920
locationService.startMonitoringSignificantLocationChanges { location in
2021
struct Payload: Codable {
2122
var lat: Double

0 commit comments

Comments
 (0)