ReviewOS

also looking at this

craft-native/craft

feat(maps): add key-free native Android maps and shared map controls

#363
Merged chris wants to merge codex/wildloop-native-map-providers into main
28 files +1857 -481

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+95-0
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -1,10 +1,105 @@
11import UIKit
22import MapKit
3import CoreLocation
34import XCTest
45@testable import NativeRender
56
7private final class UnitLocationManager: CLLocationManager {
8 var state: CLAuthorizationStatus = .notDetermined
9 var whenInUseRequests = 0
10 var alwaysRequests = 0
11 var fixRequests = 0
12 var onAuthorizationRequest: (() -> Void)?
13 override var authorizationStatus: CLAuthorizationStatus { state }
14 override func requestWhenInUseAuthorization() { whenInUseRequests += 1; onAuthorizationRequest?() }
15 override func requestAlwaysAuthorization() { alwaysRequests += 1; onAuthorizationRequest?() }
16 override func requestLocation() { fixRequests += 1 }
17}
18
619@MainActor
720final class NativeRenderUnitTests: XCTestCase {
21 func testOneShotLocationWaitsForPermissionBeforeStartingItsFixAndTimeout() throws {
22 var config = CraftConfig()
23 config.enableGeolocation = true
24 config.enableBackgroundLocation = true
25 let manager = UnitLocationManager()
26 let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager)
27 let previous = CraftSwiftShim.coordinator
28 CraftSwiftShim.coordinator = coordinator
29 defer { CraftSwiftShim.coordinator = previous }
30 let permission = expectation(description: "foreground location permission requested")
31 manager.onAuthorizationRequest = { permission.fulfill() }
32 let completed = expectation(description: "authorized native fix delivered")
33 var answer: Result<Any, CraftNativeActionError>?
34 CraftWebServices.perform(action: "getCurrentPosition", body: ["timeout": 20]) { result in
35 answer = result
36 completed.fulfill()
37 }
38 wait(for: [permission], timeout: 2)
39 XCTAssertEqual(manager.whenInUseRequests, 1)
40 XCTAssertEqual(manager.alwaysRequests, 0, "One foreground fix does not ask for background access")
41 XCTAssertEqual(manager.fixRequests, 0)
42 coordinator.locationManager(manager, didFailWithError: NSError(domain: kCLErrorDomain, code: CLError.Code.denied.rawValue))
43 let deciding = expectation(description: "person is still reading the permission sheet")
44 DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { deciding.fulfill() }
45 wait(for: [deciding], timeout: 2)
46 XCTAssertNil(answer, "The acquisition timeout excludes time spent deciding permission")
47 manager.state = .authorizedWhenInUse
48 coordinator.locationManagerDidChangeAuthorization(manager)
49 XCTAssertEqual(manager.fixRequests, 1)
50 coordinator.locationManagerDidChangeAuthorization(manager)
51 XCTAssertEqual(manager.fixRequests, 1, "Repeated authorization notifications do not restart acquisition")
52 coordinator.locationManager(manager, didUpdateLocations: [CLLocation(latitude: 34, longitude: -118)])
53 wait(for: [completed], timeout: 2)
54 guard case .success(let value)? = answer else { return XCTFail("Expected an authorized native fix") }
55 XCTAssertEqual((value as? [String: Any])?["latitude"] as? Double, 34)
56 }
57
58 func testOneShotLocationRejectsDeniedPermissionWithoutRequestingAFix() {
59 for status in [CLAuthorizationStatus.denied, .restricted] {
60 var config = CraftConfig()
61 config.enableGeolocation = true
62 let manager = UnitLocationManager()
63 manager.state = status
64 let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager)
65 let previous = CraftSwiftShim.coordinator
66 CraftSwiftShim.coordinator = coordinator
67 let completed = expectation(description: "denied native request settled")
68 CraftWebServices.perform(action: "getCurrentPosition", body: [:]) { result in
69 guard case .failure(let error) = result else { completed.fulfill(); return XCTFail("Denied permission returned a fix") }
70 XCTAssertEqual(error.code, "PERMISSION_DENIED")
71 completed.fulfill()
72 }
73 wait(for: [completed], timeout: 2)
74 XCTAssertEqual(manager.fixRequests, 0)
75 XCTAssertEqual(manager.whenInUseRequests, 0)
76 CraftSwiftShim.coordinator = previous
77 }
78 }
79
80 func testOneShotLocationRejectsWhenPermissionPromptIsDeclined() {
81 var config = CraftConfig()
82 config.enableGeolocation = true
83 let manager = UnitLocationManager()
84 let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager)
85 let previous = CraftSwiftShim.coordinator
86 CraftSwiftShim.coordinator = coordinator
87 defer { CraftSwiftShim.coordinator = previous }
88 let permission = expectation(description: "foreground permission requested")
89 manager.onAuthorizationRequest = { permission.fulfill() }
90 let completed = expectation(description: "declined native request settled")
91 CraftWebServices.perform(action: "getCurrentPosition", body: [:]) { result in
92 guard case .failure(let error) = result else { completed.fulfill(); return XCTFail("Declined permission returned a fix") }
93 XCTAssertEqual(error.code, "PERMISSION_DENIED")
94 completed.fulfill()
95 }
96 wait(for: [permission], timeout: 2)
97 manager.state = .denied
98 coordinator.locationManagerDidChangeAuthorization(manager)
99 wait(for: [completed], timeout: 2)
100 XCTAssertEqual(manager.fixRequests, 0)
101 }
102
8103 func testConfiguredHomeShortcutUsesTheNativeAppDestination() throws {
9104 let shortcut = try XCTUnwrap(UIApplication.shared.shortcutItems?.first { $0.type == "saved" })
10105 XCTAssertEqual(shortcut.localizedTitle, "Saved trails")
packages/ios/src/index.test.tsmodified+1-1
Changes to packages/ios/src/index.test.ts
@@ -2248,7 +2248,7 @@ describe('Craft iOS builder', () => {
22482248 expect(noFixYet).toBeLessThan(body.indexOf('rejectCallback('))
22492249 expect(noFixYet).toBeLessThan(body.indexOf('sendToWeb("craftLocationError"'))
22502250 // Asks again only for the caller that was told to wait.
2251 expect(body).toContain('guard let self, self.singleLocationCallbackId == waiting else { return }')
2251 expect(body).toContain('guard let self, self.singleLocationCallbackId == waiting, self.singleLocationAcquisitionStarted else { return }')
22522252 expect(body).toContain('self.locationManager?.requestLocation()')
22532253 })
22542254
packages/ios/templates/CraftApp.swiftmodified+43-6
Changes to packages/ios/templates/CraftApp.swift
@@ -1928,6 +1928,9 @@ struct CraftWebView: UIViewRepresentable {
19281928 private var locationManager: CLLocationManager?
19291929 private var singleLocationCallbackId: String?
19301930 private var singleLocationTimeoutWorkItem: DispatchWorkItem?
1931 private var singleLocationTimeoutMilliseconds: Double = 30_000
1932 private var singleLocationAcquisitionStarted = false
1933 private var singleLocationAuthorizationRequested = false
19311934 private var locationPermissionCallbackId: String?
19321935 private var locationPermissionRequiresAlways = false
19331936 private var isWatchingLocation = false
@@ -2000,14 +2003,14 @@ struct CraftWebView: UIViewRepresentable {
20002003 // SQLite database
20012004 private var db: OpaquePointer?
20022005
2003 init(config: CraftConfig) {
2006 init(config: CraftConfig, locationManager suppliedLocationManager: CLLocationManager? = nil) {
20042007 self.config = config
20052008 super.init()
20062009 if config.enableSpeechRecognition {
20072010 speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
20082011 }
20092012 if config.enableGeolocation {
2010 locationManager = CLLocationManager()
2013 locationManager = suppliedLocationManager ?? CLLocationManager()
20112014 locationManager?.desiredAccuracy = kCLLocationAccuracyBest
20122015 locationManager?.activityType = .fitness
20132016 locationManager?.pausesLocationUpdatesAutomatically = false
@@ -6302,7 +6305,7 @@ struct CraftWebView: UIViewRepresentable {
63026305 }
63036306 singleLocationCallbackId = callbackId
63046307 let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0)
6305 if maximumAge > 0,
6308 if (manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse), maximumAge > 0,
63066309 let cachedLocation = manager.location,
63076310 max(0, Date().timeIntervalSince(cachedLocation.timestamp) * 1000) <= maximumAge {
63086311 finishSingleLocationRequest()
@@ -6310,7 +6313,35 @@ struct CraftWebView: UIViewRepresentable {
63106313 return
63116314 }
63126315
6313 let timeoutMs = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
6316 singleLocationTimeoutMilliseconds = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
6317 continueSingleLocationRequest(manager)
6318 }
6319
6320 private func continueSingleLocationRequest(_ manager: CLLocationManager) {
6321 guard let callbackId = singleLocationCallbackId else { return }
6322 switch manager.authorizationStatus {
6323 case .notDetermined:
6324 if !singleLocationAuthorizationRequested {
6325 singleLocationAuthorizationRequested = true
6326 manager.requestWhenInUseAuthorization()
6327 }
6328 return
6329 case .denied, .restricted:
6330 finishSingleLocationRequest()
6331 rejectCallback(callbackId, error: "Location permission was denied.", code: "PERMISSION_DENIED")
6332 return
6333 case .authorizedAlways, .authorizedWhenInUse:
6334 break
6335 @unknown default:
6336 finishSingleLocationRequest()
6337 rejectCallback(callbackId, error: "Location authorization is unavailable.", code: "PERMISSION_DENIED")
6338 return
6339 }
6340 guard !singleLocationAcquisitionStarted else { return }
6341 singleLocationAcquisitionStarted = true
6342 // The acquisition timeout starts after the person decides. A
6343 // permission sheet is not a failed or timed-out GPS fix.
6344 let timeoutMs = singleLocationTimeoutMilliseconds
63146345 let timeoutWorkItem = DispatchWorkItem { [weak self] in
63156346 guard let self, self.singleLocationCallbackId == callbackId else { return }
63166347 self.finishSingleLocationRequest()
@@ -6318,7 +6349,6 @@ struct CraftWebView: UIViewRepresentable {
63186349 }
63196350 singleLocationTimeoutWorkItem = timeoutWorkItem
63206351 DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem)
6321 requestLocationAuthorization()
63226352 manager.requestLocation()
63236353 }
63246354
@@ -6326,6 +6356,8 @@ struct CraftWebView: UIViewRepresentable {
63266356 singleLocationTimeoutWorkItem?.cancel()
63276357 singleLocationTimeoutWorkItem = nil
63286358 singleLocationCallbackId = nil
6359 singleLocationAcquisitionStarted = false
6360 singleLocationAuthorizationRequested = false
63296361 locationManager?.desiredAccuracy = kCLLocationAccuracyBest
63306362 }
63316363
@@ -6546,6 +6578,10 @@ struct CraftWebView: UIViewRepresentable {
65466578
65476579 func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
65486580 let nativeError = error as NSError
6581 if nativeError.domain == kCLErrorDomain, nativeError.code == CLError.Code.denied.rawValue,
6582 manager.authorizationStatus == .notDetermined, singleLocationCallbackId != nil, !singleLocationAcquisitionStarted {
6583 return
6584 }
65496585 // kCLErrorLocationUnknown is Core Location saying it has no fix
65506586 // *yet*, not that it failed. requestLocation gives up on it, so a
65516587 // one-shot asks again shortly instead of failing a caller whose
@@ -6554,7 +6590,7 @@ struct CraftWebView: UIViewRepresentable {
65546590 if nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.locationUnknown.rawValue {
65556591 if let waiting = singleLocationCallbackId {
65566592 DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
6557 guard let self, self.singleLocationCallbackId == waiting else { return }
6593 guard let self, self.singleLocationCallbackId == waiting, self.singleLocationAcquisitionStarted else { return }
65586594 self.locationManager?.requestLocation()
65596595 }
65606596 }
@@ -6570,6 +6606,7 @@ struct CraftWebView: UIViewRepresentable {
65706606 }
65716607
65726608 func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
6609 if singleLocationCallbackId != nil { continueSingleLocationRequest(manager) }
65736610 guard let callbackId = locationPermissionCallbackId else { return }
65746611 let status = manager.authorizationStatus
65756612 if status == .notDetermined { return }