also looking at this
feat(maps): add key-free native Android maps and shared map controls
#363
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.
| @@ -1,10 +1,105 @@ | ||
| 1 | 1 | import UIKit |
| 2 | 2 | import MapKit |
| 3 | import CoreLocation | |
| 3 | 4 | import XCTest |
| 4 | 5 | @testable import NativeRender |
| 5 | 6 | |
| 7 | private 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 | ||
| 6 | 19 | @MainActor |
| 7 | 20 | final 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 | ||
| 8 | 103 | func testConfiguredHomeShortcutUsesTheNativeAppDestination() throws { |
| 9 | 104 | let shortcut = try XCTUnwrap(UIApplication.shared.shortcutItems?.first { $0.type == "saved" }) |
| 10 | 105 | XCTAssertEqual(shortcut.localizedTitle, "Saved trails") |
| @@ -2248,7 +2248,7 @@ describe('Craft iOS builder', () => { | ||
| 2248 | 2248 | expect(noFixYet).toBeLessThan(body.indexOf('rejectCallback(')) |
| 2249 | 2249 | expect(noFixYet).toBeLessThan(body.indexOf('sendToWeb("craftLocationError"')) |
| 2250 | 2250 | // 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 }') | |
| 2252 | 2252 | expect(body).toContain('self.locationManager?.requestLocation()') |
| 2253 | 2253 | }) |
| 2254 | 2254 | |
| @@ -1928,6 +1928,9 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1928 | 1928 | private var locationManager: CLLocationManager? |
| 1929 | 1929 | private var singleLocationCallbackId: String? |
| 1930 | 1930 | private var singleLocationTimeoutWorkItem: DispatchWorkItem? |
| 1931 | private var singleLocationTimeoutMilliseconds: Double = 30_000 | |
| 1932 | private var singleLocationAcquisitionStarted = false | |
| 1933 | private var singleLocationAuthorizationRequested = false | |
| 1931 | 1934 | private var locationPermissionCallbackId: String? |
| 1932 | 1935 | private var locationPermissionRequiresAlways = false |
| 1933 | 1936 | private var isWatchingLocation = false |
| @@ -2000,14 +2003,14 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2000 | 2003 | // SQLite database |
| 2001 | 2004 | private var db: OpaquePointer? |
| 2002 | 2005 | |
| 2003 | init(config: CraftConfig) { | |
| 2006 | init(config: CraftConfig, locationManager suppliedLocationManager: CLLocationManager? = nil) { | |
| 2004 | 2007 | self.config = config |
| 2005 | 2008 | super.init() |
| 2006 | 2009 | if config.enableSpeechRecognition { |
| 2007 | 2010 | speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")) |
| 2008 | 2011 | } |
| 2009 | 2012 | if config.enableGeolocation { |
| 2010 | locationManager = CLLocationManager() | |
| 2013 | locationManager = suppliedLocationManager ?? CLLocationManager() | |
| 2011 | 2014 | locationManager?.desiredAccuracy = kCLLocationAccuracyBest |
| 2012 | 2015 | locationManager?.activityType = .fitness |
| 2013 | 2016 | locationManager?.pausesLocationUpdatesAutomatically = false |
| @@ -6302,7 +6305,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6302 | 6305 | } |
| 6303 | 6306 | singleLocationCallbackId = callbackId |
| 6304 | 6307 | let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0) |
| 6305 | if maximumAge > 0, | |
| 6308 | if (manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse), maximumAge > 0, | |
| 6306 | 6309 | let cachedLocation = manager.location, |
| 6307 | 6310 | max(0, Date().timeIntervalSince(cachedLocation.timestamp) * 1000) <= maximumAge { |
| 6308 | 6311 | finishSingleLocationRequest() |
| @@ -6310,7 +6313,35 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6310 | 6313 | return |
| 6311 | 6314 | } |
| 6312 | 6315 | |
| 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 | |
| 6314 | 6345 | let timeoutWorkItem = DispatchWorkItem { [weak self] in |
| 6315 | 6346 | guard let self, self.singleLocationCallbackId == callbackId else { return } |
| 6316 | 6347 | self.finishSingleLocationRequest() |
| @@ -6318,7 +6349,6 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6318 | 6349 | } |
| 6319 | 6350 | singleLocationTimeoutWorkItem = timeoutWorkItem |
| 6320 | 6351 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem) |
| 6321 | requestLocationAuthorization() | |
| 6322 | 6352 | manager.requestLocation() |
| 6323 | 6353 | } |
| 6324 | 6354 | |
| @@ -6326,6 +6356,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6326 | 6356 | singleLocationTimeoutWorkItem?.cancel() |
| 6327 | 6357 | singleLocationTimeoutWorkItem = nil |
| 6328 | 6358 | singleLocationCallbackId = nil |
| 6359 | singleLocationAcquisitionStarted = false | |
| 6360 | singleLocationAuthorizationRequested = false | |
| 6329 | 6361 | locationManager?.desiredAccuracy = kCLLocationAccuracyBest |
| 6330 | 6362 | } |
| 6331 | 6363 | |
| @@ -6546,6 +6578,10 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6546 | 6578 | |
| 6547 | 6579 | func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { |
| 6548 | 6580 | 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 | } | |
| 6549 | 6585 | // kCLErrorLocationUnknown is Core Location saying it has no fix |
| 6550 | 6586 | // *yet*, not that it failed. requestLocation gives up on it, so a |
| 6551 | 6587 | // one-shot asks again shortly instead of failing a caller whose |
| @@ -6554,7 +6590,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6554 | 6590 | if nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.locationUnknown.rawValue { |
| 6555 | 6591 | if let waiting = singleLocationCallbackId { |
| 6556 | 6592 | 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 } | |
| 6558 | 6594 | self.locationManager?.requestLocation() |
| 6559 | 6595 | } |
| 6560 | 6596 | } |
| @@ -6570,6 +6606,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6570 | 6606 | } |
| 6571 | 6607 | |
| 6572 | 6608 | func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { |
| 6609 | if singleLocationCallbackId != nil { continueSingleLocationRequest(manager) } | |
| 6573 | 6610 | guard let callbackId = locationPermissionCallbackId else { return } |
| 6574 | 6611 | let status = manager.authorizationStatus |
| 6575 | 6612 | if status == .notDetermined { return } |