Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,14 +574,15 @@ setMarkers((current) =>
A coordinate that arrives as `NaN` or out of range is dropped instead of being forwarded to MapKit and the Google Maps SDK, which throw on it:

- An invalid `region` is ignored, and the map keeps the region it already had.
- An invalid `camera` is ignored the same way, and a pitch past the range the SDKs draw is pulled back to it rather than rejected.
- An overlay whose coordinates, ring length or radius cannot be drawn is skipped; its neighbours still render.
- Anything supplied through `region` or through a `<Marker>` / `<Polyline>` / `<Polygon>` / `<Circle>` child is reported through `console.warn` in development.
- Anything supplied through `region`, `camera`, or a `<Marker>` / `<Polyline>` / `<Polygon>` / `<Circle>` child is reported through `console.warn` in development.

Where the check runs depends on the entry point. `region` and `fitToCoordinates` are guarded natively on both platforms, so a `hybridRef` call cannot reach the SDKs either. Overlay descriptors are additionally filtered natively on Android, where an undrawable overlay throws inside the Fabric mount transaction and would otherwise take the whole screen down; those skips are reported to logcat rather than `console.warn`.
Where the check runs depends on the entry point. `region`, `camera` and `fitToCoordinates` are guarded natively on both platforms, so a `hybridRef` call - `setCamera` and `animateCamera` included - cannot reach the SDKs either. Overlay descriptors are additionally filtered natively on Android, where an undrawable overlay throws inside the Fabric mount transaction and would otherwise take the whole screen down; those skips are reported to logcat rather than `console.warn`.

Two gaps are worth knowing about: the `camera` prop is not validated anywhere, and descriptors passed through the bulk `markers` prop are checked on neither side - only the `<Marker>` child is.
One gap is worth knowing about: descriptors passed through the bulk `markers` prop are checked on neither side - only the `<Marker>` child is.

Valid means: latitude and longitude finite and within ±90 / ±180, region deltas finite and greater than 0, two coordinates for a polyline, three per polygon ring, and a finite radius of at least 0 for a circle. A region whose span would run past a pole is pulled back to what the map can show rather than rejected.
Valid means: latitude and longitude finite and within ±90 / ±180, region deltas finite and greater than 0, camera `zoom` / `heading` / `pitch` / `altitude` finite when supplied (with `zoom` and `heading` also small enough for the 32-bit float the SDKs keep them in), two coordinates for a polyline, three per polygon ring, and a finite radius of at least 0 for a circle. A region whose span would run past a pole is pulled back to what the map can show rather than rejected.

## Capability matrix

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,23 @@ fun Camera.toCameraPosition(current: CameraPosition? = null): CameraPosition {
.target(LatLng(center.latitude, center.longitude))
.zoom((zoom ?: current?.zoom?.toDouble() ?: 10.0).toFloat())
.bearing((heading ?: current?.bearing?.toDouble() ?: 0.0).toFloat())
.tilt((pitch ?: current?.tilt?.toDouble() ?: 0.0).toFloat())
.tilt(drawableTilt(pitch ?: current?.tilt?.toDouble() ?: 0.0))
.build()
}

/**
* `CameraPosition.Builder.tilt` throws for anything outside 0..90, and that throw would unwind the
* Fabric mount transaction, so a pitch past the limit is pulled back to it. MapKit flattens such a
* camera rather than refusing it, which is the behaviour this matches. A non-finite pitch never
* reaches here - `Camera.isValid()` drops the whole camera first - but it is handled so this stays
* safe on its own.
*/
private fun drawableTilt(pitch: Double): Float =
if (pitch.isFinite()) pitch.coerceIn(MINIMUM_TILT, MAXIMUM_TILT).toFloat() else MINIMUM_TILT.toFloat()

private const val MINIMUM_TILT = 0.0
private const val MAXIMUM_TILT = 90.0

fun CameraPosition.toCamera(): Camera {
return Camera(
center = Coordinate(latitude = target.latitude, longitude = target.longitude),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.margelo.nitro.nitromaps

/**
* `CameraPosition.Builder.tilt` throws for a non-finite pitch, which unwinds the Fabric mount
* transaction, and a non-finite zoom or heading is taken silently and leaves the camera reading
* back as `NaN`.
*/
internal fun Camera.isValid(): Boolean =
isValidCoordinate(center.latitude, center.longitude) &&
zoom.isDrawableAsFloatOrAbsent() &&
heading.isDrawableAsFloatOrAbsent() &&
pitch.isFiniteOrAbsent() &&
altitude.isFiniteOrAbsent()

/**
* `CameraPosition` holds zoom and bearing as `Float`, so the value the SDK receives is the
* converted one: a `Double` past `Float.MAX_VALUE` - `Double.MAX_VALUE` among them - becomes
* `Infinity`, which the builder takes without complaint and then normalizes into a `NaN` bearing.
* Checking after the conversion is what makes this guard match what the map is handed.
*/
private fun Double?.isDrawableAsFloatOrAbsent(): Boolean = this == null || toFloat().isFinite()

/**
* Pitch and altitude stay `Double`: pitch is coerced into the drawable range before it is narrowed,
* and altitude never reaches `CameraPosition` at all. An absent value is filled in from the camera
* the map already has, so only a supplied one is checked.
*/
private fun Double?.isFiniteOrAbsent(): Boolean = this == null || isFinite()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,14 @@ class GoogleMapProviderAdapter(
animated: Boolean,
durationMs: Int = 0,
) {
// Checked before the main-thread hop: `runOnMain` posts to the looper when called from
// anywhere else, so a throw out of `CameraPosition` would surface as an uncaught main-looper
// exception the JS caller cannot catch.
if (!camera.isValid()) {
Log.w(NITRO_MAPS_LOG_TAG, "Ignored an invalid camera: $camera.")
return
}

runOnMain {
val map = googleMap ?: return@runOnMain
val target = camera.toCameraPosition(map.cameraPosition)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.margelo.nitro.nitromaps

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class CameraValidityTest {
@Test
fun acceptsACameraTheSdkCanRepresent() {
assertTrue(camera().isValid())
assertTrue(camera(latitude = -90.0, longitude = 180.0).isValid())
}

@Test
fun acceptsACameraThatSuppliesNoFramingValues() {
assertTrue(camera(zoom = null, heading = null, pitch = null, altitude = null).isValid())
}

@Test
fun rejectsANonFiniteCenter() {
assertFalse(camera(latitude = Double.NaN, longitude = Double.NaN).isValid())
assertFalse(camera(longitude = Double.POSITIVE_INFINITY).isValid())
}

@Test
fun rejectsACenterOutsideTheWorld() {
assertFalse(camera(latitude = 1000.0).isValid())
assertFalse(camera(longitude = -180.0001).isValid())
}

@Test
fun rejectsANonFiniteFramingValue() {
assertFalse(camera(zoom = Double.NaN).isValid())
assertFalse(camera(heading = Double.POSITIVE_INFINITY).isValid())
assertFalse(camera(pitch = Double.NaN).isValid())
assertFalse(camera(altitude = Double.NEGATIVE_INFINITY).isValid())
}

/** A pitch past the drawable range is clamped rather than rejected, so the camera still applies. */
@Test
fun acceptsAPitchOutsideTheDrawableRange() {
assertTrue(camera(pitch = 120.0).isValid())
assertTrue(camera(pitch = -10.0).isValid())
}

/**
* `CameraPosition.Builder.tilt` throws `IllegalArgumentException` for anything outside 0..90, so
* the clamp is what keeps a valid camera with an unsupported pitch from taking the mount
* transaction down.
*/
@Test
fun clampsAPitchTheSdkWouldRefuse() {
assertEquals(90.0f, camera(pitch = 120.0).toCameraPosition().tilt, 0.0f)
assertEquals(0.0f, camera(pitch = -10.0).toCameraPosition().tilt, 0.0f)
assertEquals(45.0f, camera(pitch = 45.0).toCameraPosition().tilt, 0.0f)
}

/**
* Zoom and bearing are narrowed to `Float` on the way into `CameraPosition`, so a `Double` past
* `Float.MAX_VALUE` is not something the map can be handed, however finite it is as a `Double`.
*/
@Test
fun rejectsAFramingValueThatOverflowsAFloat() {
assertFalse(camera(zoom = Double.MAX_VALUE).isValid())
assertFalse(camera(heading = -Double.MAX_VALUE).isValid())
}

/**
* What the guard above prevents, pinned against the real SDK: the builder takes the overflowed
* value without complaint, and its `% 360` normalization turns an infinite bearing into `NaN`.
*/
@Test
fun anOverflowingFramingValueWouldReachTheSdkAsInfinity() {
val position = camera(zoom = Double.MAX_VALUE, heading = Double.MAX_VALUE).toCameraPosition()

assertEquals(Float.POSITIVE_INFINITY, position.zoom, 0.0f)
assertTrue(position.bearing.isNaN())
}

/** Pitch is coerced into the drawable range before it is narrowed, so an absurd one still draws. */
@Test
fun clampsAPitchThatOverflowsAFloat() {
assertTrue(camera(pitch = Double.MAX_VALUE).isValid())
assertEquals(90.0f, camera(pitch = Double.MAX_VALUE).toCameraPosition().tilt, 0.0f)
}

private fun camera(
latitude: Double = 52.23,
longitude: Double = 21.01,
zoom: Double? = 12.0,
heading: Double? = 90.0,
pitch: Double? = 45.0,
altitude: Double? = 1000.0,
): Camera =
Camera(
center = Coordinate(latitude = latitude, longitude = longitude),
zoom = zoom,
heading = heading,
pitch = pitch,
altitude = altitude,
)
}
9 changes: 9 additions & 0 deletions package/ios/AppleMapProviderAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,15 @@ final class AppleMapProviderAdapter: MapProviderAdapter {
}

func updateMapCamera(_ camera: Camera, animated: Bool, duration: Double = 0) {
// `setCamera` raises an Objective-C NSException - `Invalid camera
// centerCoordinate` - from `-[MKMapCamera _validate]` for a center MapKit
// cannot place, and Swift cannot catch that. The framing values do not
// raise, but a non-finite one collapses the altitude or leaves
// `view.region` reading back as `NaN`.
guard camera.isValid else {
return
}

let mapCamera = camera.toMKMapCamera()
guard !view.camera.approximatelyEquals(mapCamera) else {
return
Expand Down
12 changes: 12 additions & 0 deletions package/ios/Camera+Validity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
extension Camera {
/// Whether the camera can be handed to `MKMapView.camera` without MapKit raising.
var isValid: Bool {
center.isValid
&& CameraFraming.isDrawable(
zoom: zoom,
heading: heading,
pitch: pitch,
altitude: altitude
)
}
}
44 changes: 44 additions & 0 deletions package/ios/Geometry/CameraFraming.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation

/// The framing half of a camera - zoom, heading, pitch and altitude - which
/// `CLLocationCoordinate2DIsValid` says nothing about.
enum CameraFraming {
/// Every value the caller supplied has to be a real number. `MKMapCamera`
/// does not reject a `NaN` one: it collapses the altitude to the minimum the
/// map allows, or leaves `MKMapView.region` reading back as `NaN`. On Android
/// a non-finite tilt throws out of `CameraPosition` instead.
static func isDrawable(
zoom: Double?,
heading: Double?,
pitch: Double?,
altitude: Double?
) -> Bool {
isRealAsFloatOrAbsent(zoom)
&& isRealOrAbsent(heading)
&& isRealOrAbsent(pitch)
&& isRealOrAbsent(altitude)
}

/// `GMSCameraPosition` holds zoom as a `Float`, and `CameraPosition` does the
/// same on Android, so the value the SDK receives is the narrowed one: a
/// `Double` past `Float.greatestFiniteMagnitude` arrives as `infinity`. Zoom
/// is the only value narrowed that way - heading, pitch and altitude stay
/// `Double` through `CLLocationDirection` and `MKMapCamera`.
private static func isRealAsFloatOrAbsent(_ value: Double?) -> Bool {
guard let value else {
return true
}

return value.isFinite && Float(value).isFinite
}

/// An omitted value is filled in from the camera the map already has, so only
/// a supplied one has to be checked.
private static func isRealOrAbsent(_ value: Double?) -> Bool {
guard let value else {
return true
}

return value.isFinite
}
}
14 changes: 10 additions & 4 deletions package/ios/GoogleMapProviderAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,22 @@
}

lazy var view: GMSMapView = {
let camera =
self.camera?.toGMSCameraPosition()
// The map is created from the stored prop directly, so it is checked here
// too: `updateMapCamera` never runs for the camera the map starts with.
let initialCamera =
self.camera.flatMap { $0.isValid ? $0.toGMSCameraPosition() : nil }
?? GMSCameraPosition(latitude: 0, longitude: 0, zoom: 10)
let mapView: GMSMapView
if let googleMapId = _googleMapId?.trimmingCharacters(in: .whitespacesAndNewlines),
!googleMapId.isEmpty
{
mapView = GMSMapView(

Check warning on line 44 in package/ios/GoogleMapProviderAdapter.swift

View workflow job for this annotation

GitHub Actions / native / iOS (google)

'init(frame:mapID:camera:)' is deprecated: Use -init or -initWithOptions: instead.

Check warning on line 44 in package/ios/GoogleMapProviderAdapter.swift

View workflow job for this annotation

GitHub Actions / native / iOS (google)

'init(frame:mapID:camera:)' is deprecated: Use -init or -initWithOptions: instead.
frame: .zero,
mapID: GMSMapID(identifier: googleMapId),
camera: camera
camera: initialCamera
)
} else {
mapView = GMSMapView(frame: .zero, camera: camera)
mapView = GMSMapView(frame: .zero, camera: initialCamera)

Check warning on line 50 in package/ios/GoogleMapProviderAdapter.swift

View workflow job for this annotation

GitHub Actions / native / iOS (google)

'init(frame:camera:)' is deprecated: Use -init or -initWithOptions: instead.

Check warning on line 50 in package/ios/GoogleMapProviderAdapter.swift

View workflow job for this annotation

GitHub Actions / native / iOS (google)

'init(frame:camera:)' is deprecated: Use -init or -initWithOptions: instead.
}

mapView.delegate = self
Expand Down Expand Up @@ -320,6 +322,10 @@
}

private func updateMapCamera(_ camera: Camera, animated: Bool, duration: Double? = nil) {
guard camera.isValid else {
return
}

let target = camera.toGMSCameraPosition(current: view.camera)
guard !view.camera.approximatelyEquals(target) else {
return
Expand Down
49 changes: 49 additions & 0 deletions package/ios/Tests/Geometry/CameraFramingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Testing

@testable import NitroMapsGeometry

@Test
func acceptsFramingValuesTheMapCanUse() {
#expect(CameraFraming.isDrawable(zoom: 12, heading: 90, pitch: 45, altitude: 1000))
#expect(CameraFraming.isDrawable(zoom: 0, heading: 0, pitch: 0, altitude: 0))
}

@Test
func acceptsAbsentFramingValues() {
#expect(CameraFraming.isDrawable(zoom: nil, heading: nil, pitch: nil, altitude: nil))
#expect(CameraFraming.isDrawable(zoom: 12, heading: nil, pitch: nil, altitude: nil))
}

@Test
func rejectsANonFiniteFramingValue() {
#expect(!CameraFraming.isDrawable(zoom: .nan, heading: nil, pitch: nil, altitude: nil))
#expect(!CameraFraming.isDrawable(zoom: nil, heading: .infinity, pitch: nil, altitude: nil))
#expect(!CameraFraming.isDrawable(zoom: nil, heading: nil, pitch: .nan, altitude: nil))
#expect(!CameraFraming.isDrawable(zoom: nil, heading: nil, pitch: nil, altitude: -.infinity))
}

/// A pitch past the range the SDKs draw is pulled back to it rather than
/// rejected, so the camera still reaches the map.
@Test
func acceptsAPitchOutsideTheDrawableRange() {
#expect(CameraFraming.isDrawable(zoom: nil, heading: nil, pitch: 120, altitude: nil))
#expect(CameraFraming.isDrawable(zoom: nil, heading: nil, pitch: -10, altitude: nil))
}

/// Zoom is narrowed to a `Float` on its way into both SDKs, so a `Double` too
/// large for one is not a zoom the map can be handed, however finite it is.
@Test
func rejectsAZoomThatOverflowsAFloat() {
#expect(!CameraFraming.isDrawable(zoom: .greatestFiniteMagnitude, heading: nil, pitch: nil, altitude: nil))
#expect(!CameraFraming.isDrawable(zoom: -.greatestFiniteMagnitude, heading: nil, pitch: nil, altitude: nil))
#expect(CameraFraming.isDrawable(zoom: 3.4e38, heading: nil, pitch: nil, altitude: nil))
}

/// Heading, pitch and altitude stay `Double` all the way to `MKMapCamera` and
/// `CLLocationDirection`, so a large one is still a value the map can take.
@Test
func keepsALargeHeadingPitchOrAltitude() {
#expect(CameraFraming.isDrawable(zoom: nil, heading: .greatestFiniteMagnitude, pitch: nil, altitude: nil))
#expect(CameraFraming.isDrawable(zoom: nil, heading: nil, pitch: .greatestFiniteMagnitude, altitude: nil))
#expect(CameraFraming.isDrawable(zoom: nil, heading: nil, pitch: nil, altitude: .greatestFiniteMagnitude))
}
Loading
Loading