Skip to content

Commit dec1985

Browse files
committed
fix: prevent crash on simultaneous min/max zoom changes and support zoom during navigation
1 parent 8292ae1 commit dec1985

18 files changed

Lines changed: 314 additions & 47 deletions

File tree

android/src/main/java/com/google/android/react/navsdk/MapViewController.java

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,18 @@
1515

1616
import android.annotation.SuppressLint;
1717
import android.app.Activity;
18+
import android.util.Log;
1819
import androidx.core.util.Supplier;
1920
import com.facebook.react.bridge.UiThreadUtil;
2021
import com.google.android.gms.maps.CameraUpdateFactory;
2122
import com.google.android.gms.maps.GoogleMap;
23+
import com.google.android.gms.maps.GoogleMap.CameraPerspective;
2224
import com.google.android.gms.maps.model.BitmapDescriptor;
2325
import com.google.android.gms.maps.model.BitmapDescriptorFactory;
2426
import com.google.android.gms.maps.model.CameraPosition;
2527
import com.google.android.gms.maps.model.Circle;
2628
import com.google.android.gms.maps.model.CircleOptions;
29+
import com.google.android.gms.maps.model.FollowMyLocationOptions;
2730
import com.google.android.gms.maps.model.GroundOverlay;
2831
import com.google.android.gms.maps.model.GroundOverlayOptions;
2932
import com.google.android.gms.maps.model.LatLng;
@@ -42,6 +45,7 @@
4245
import java.util.Map;
4346

4447
public class MapViewController implements INavigationViewControllerProperties {
48+
private static final String TAG = "MapViewController";
4549
private GoogleMap mGoogleMap;
4650
private Supplier<Activity> activitySupplier;
4751
private INavigationViewCallback mNavigationViewCallback;
@@ -883,23 +887,35 @@ public void setMinZoomLevel(float minZoomLevel) {
883887
return;
884888
}
885889

886-
// Get the effective max zoom for comparison
887-
float maxZoom =
890+
minZoomLevelPreference = minZoomLevel;
891+
892+
// Reset both preferences first so the new min/max pair is always applied
893+
// atomically. Without this, Fabric can deliver minZoomLevel and maxZoomLevel
894+
// prop updates in any order, causing a transient state where min > max.
895+
mGoogleMap.resetMinMaxZoomPreference();
896+
897+
float effectiveMin = (minZoomLevel < 0.0f) ? mGoogleMap.getMinZoomLevel() : minZoomLevel;
898+
float effectiveMax =
888899
(maxZoomLevelPreference != null && maxZoomLevelPreference >= 0.0f)
889900
? maxZoomLevelPreference
890901
: mGoogleMap.getMaxZoomLevel();
891902

892-
// Validate that min is not greater than max (unless using -1 sentinel)
893-
if (minZoomLevel >= 0.0f && minZoomLevel > maxZoom) {
894-
throw new IllegalArgumentException(
895-
"Minimum zoom level cannot be greater than maximum zoom level");
903+
if (effectiveMin > effectiveMax) {
904+
Log.w(
905+
TAG,
906+
"minZoomLevel ("
907+
+ effectiveMin
908+
+ ") is greater than maxZoomLevel ("
909+
+ effectiveMax
910+
+ "). Ignoring zoom level constraints.");
911+
return;
896912
}
897913

898-
minZoomLevelPreference = minZoomLevel;
899-
900-
// Use map's current minZoomLevel if -1 is provided
901-
float effectiveMin = (minZoomLevel < 0.0f) ? mGoogleMap.getMinZoomLevel() : minZoomLevel;
902914
mGoogleMap.setMinZoomPreference(effectiveMin);
915+
916+
if (maxZoomLevelPreference != null) {
917+
mGoogleMap.setMaxZoomPreference(effectiveMax);
918+
}
903919
}
904920

905921
@Override
@@ -908,23 +924,35 @@ public void setMaxZoomLevel(float maxZoomLevel) {
908924
return;
909925
}
910926

911-
// Get the effective min zoom for comparison
912-
float minZoom =
927+
maxZoomLevelPreference = maxZoomLevel;
928+
929+
// Reset both preferences first so the new min/max pair is always applied
930+
// atomically. Without this, Fabric can deliver minZoomLevel and maxZoomLevel
931+
// prop updates in any order, causing a transient state where min > max.
932+
mGoogleMap.resetMinMaxZoomPreference();
933+
934+
float effectiveMax = (maxZoomLevel < 0.0f) ? mGoogleMap.getMaxZoomLevel() : maxZoomLevel;
935+
float effectiveMin =
913936
(minZoomLevelPreference != null && minZoomLevelPreference >= 0.0f)
914937
? minZoomLevelPreference
915938
: mGoogleMap.getMinZoomLevel();
916939

917-
// Validate that max is not less than min (unless using -1 sentinel)
918-
if (maxZoomLevel >= 0.0f && maxZoomLevel < minZoom) {
919-
throw new IllegalArgumentException(
920-
"Maximum zoom level cannot be less than minimum zoom level");
940+
if (effectiveMin > effectiveMax) {
941+
Log.w(
942+
TAG,
943+
"minZoomLevel ("
944+
+ effectiveMin
945+
+ ") is greater than maxZoomLevel ("
946+
+ effectiveMax
947+
+ "). Ignoring zoom level constraints.");
948+
return;
921949
}
922950

923-
maxZoomLevelPreference = maxZoomLevel;
924-
925-
// Use map's current maxZoomLevel if -1 is provided
926-
float effectiveMax = (maxZoomLevel < 0.0f) ? mGoogleMap.getMaxZoomLevel() : maxZoomLevel;
927951
mGoogleMap.setMaxZoomPreference(effectiveMax);
952+
953+
if (minZoomLevelPreference != null) {
954+
mGoogleMap.setMinZoomPreference(effectiveMin);
955+
}
928956
}
929957

930958
public void setZoomGesturesEnabled(boolean enabled) {
@@ -1005,16 +1033,27 @@ public void resetMinMaxZoomLevel() {
10051033
return;
10061034
}
10071035

1036+
minZoomLevelPreference = null;
1037+
maxZoomLevelPreference = null;
10081038
mGoogleMap.resetMinMaxZoomPreference();
10091039
}
10101040

10111041
@SuppressLint("MissingPermission")
1012-
public void setFollowingPerspective(int jsValue) {
1042+
public void setFollowingPerspective(int jsValue, float zoomLevel) {
10131043
if (mGoogleMap == null) {
10141044
return;
10151045
}
10161046

1017-
mGoogleMap.followMyLocation(EnumTranslationUtil.getCameraPerspectiveFromJsValue(jsValue));
1047+
@CameraPerspective
1048+
int perspective = EnumTranslationUtil.getCameraPerspectiveFromJsValue(jsValue);
1049+
1050+
if (zoomLevel >= 0.0f) {
1051+
FollowMyLocationOptions options =
1052+
FollowMyLocationOptions.builder().setZoomLevel(zoomLevel).build();
1053+
mGoogleMap.followMyLocation(perspective, options);
1054+
} else {
1055+
mGoogleMap.followMyLocation(perspective);
1056+
}
10181057
}
10191058

10201059
public void setPadding(int top, int left, int bottom, int right) {

android/src/main/java/com/google/android/react/navsdk/NavAutoModule.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,14 +393,15 @@ public void setMyLocationEnabled(boolean enabled) {
393393
}
394394

395395
@Override
396-
public void setFollowingPerspective(double perspective) {
396+
public void setFollowingPerspective(double perspective, double zoomLevel) {
397397
int jsValue = (int) perspective;
398+
float zoom = (float) zoomLevel;
398399
UiThreadUtil.runOnUiThread(
399400
() -> {
400401
if (mMapViewController == null) {
401402
return;
402403
}
403-
mMapViewController.setFollowingPerspective(jsValue);
404+
mMapViewController.setFollowingPerspective(jsValue, zoom);
404405
});
405406
}
406407

android/src/main/java/com/google/android/react/navsdk/NavViewModule.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,8 @@ public void setNavigationUIEnabled(String nativeID, boolean enabled, final Promi
405405
}
406406

407407
@Override
408-
public void setFollowingPerspective(String nativeID, double perspective, final Promise promise) {
408+
public void setFollowingPerspective(
409+
String nativeID, double perspective, double zoomLevel, final Promise promise) {
409410
UiThreadUtil.runOnUiThread(
410411
() -> {
411412
IMapViewFragment fragment = mNavViewManager.getFragmentByNativeId(nativeID);
@@ -415,7 +416,9 @@ public void setFollowingPerspective(String nativeID, double perspective, final P
415416
}
416417

417418
if (fragment instanceof INavViewFragment) {
418-
fragment.getMapController().setFollowingPerspective((int) perspective);
419+
fragment
420+
.getMapController()
421+
.setFollowingPerspective((int) perspective, (float) zoomLevel);
419422
promise.resolve(null);
420423
} else {
421424
promise.reject(JsErrors.NOT_NAV_VIEW_ERROR_CODE, JsErrors.NOT_NAV_VIEW_ERROR_MESSAGE);

example/e2e/map.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,11 @@ describe('Map view tests', () => {
8989
await expectNoErrors();
9090
await expectSuccess();
9191
});
92+
93+
it('MT10 - test min and max zoom level constraints', async () => {
94+
await selectTestByName('testMinMaxZoomLevels');
95+
await waitForTestToFinish();
96+
await expectNoErrors();
97+
await expectSuccess();
98+
});
9299
});

example/src/screens/IntegrationTestsScreen.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
testStartGuidanceWithoutDestinations,
5656
testRouteTokenOptionsValidation,
5757
testMapStyle,
58+
testMinMaxZoomLevels,
5859
NO_ERRORS_DETECTED_LABEL,
5960
} from './integration_tests/integration_test';
6061

@@ -127,6 +128,12 @@ const IntegrationTestsScreen = () => {
127128
boolean | undefined
128129
>(undefined);
129130
const [mapStyle, setMapStyle] = useState<string | undefined>(undefined);
131+
const [minZoomLevel, setMinZoomLevel] = useState<number | undefined>(
132+
undefined
133+
);
134+
const [maxZoomLevel, setMaxZoomLevel] = useState<number | undefined>(
135+
undefined
136+
);
130137

131138
const onMapReady = useCallback(async () => {
132139
try {
@@ -236,6 +243,8 @@ const IntegrationTestsScreen = () => {
236243
setZoomControlsEnabled,
237244
setMapToolbarEnabled,
238245
setMapStyle,
246+
setMinZoomLevel,
247+
setMaxZoomLevel,
239248
};
240249
};
241250

@@ -305,6 +314,9 @@ const IntegrationTestsScreen = () => {
305314
case 'testMapStyle':
306315
await testMapStyle(getTestTools());
307316
break;
317+
case 'testMinMaxZoomLevels':
318+
await testMinMaxZoomLevels(getTestTools());
319+
break;
308320
default:
309321
resetTestState();
310322
break;
@@ -347,6 +359,8 @@ const IntegrationTestsScreen = () => {
347359
zoomControlsEnabled={zoomControlsEnabled}
348360
mapToolbarEnabled={mapToolbarEnabled}
349361
mapStyle={mapStyle}
362+
minZoomLevel={minZoomLevel}
363+
maxZoomLevel={maxZoomLevel}
350364
/>
351365
</View>
352366
<View style={{ flex: 4 }}>
@@ -518,6 +532,13 @@ const IntegrationTestsScreen = () => {
518532
}}
519533
testID="testMapStyle"
520534
/>
535+
<ExampleAppButton
536+
title="testMinMaxZoomLevels"
537+
onPress={() => {
538+
runTest('testMinMaxZoomLevels');
539+
}}
540+
testID="testMinMaxZoomLevels"
541+
/>
521542
</OverlayModal>
522543
</View>
523544
);

example/src/screens/integration_tests/integration_test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ interface TestTools {
6464
setZoomControlsEnabled: (enabled: boolean | undefined) => void;
6565
setMapToolbarEnabled: (enabled: boolean | undefined) => void;
6666
setMapStyle: (style: string | undefined) => void;
67+
setMinZoomLevel: (level: number | undefined) => void;
68+
setMaxZoomLevel: (level: number | undefined) => void;
6769
}
6870

6971
const NAVIGATOR_NOT_READY_ERROR_CODE = 'NO_NAVIGATOR_ERROR_CODE';
@@ -1667,3 +1669,106 @@ export const testMapStyle = async (testTools: TestTools) => {
16671669
failTest(`Failed to set mapStyle: ${error}`);
16681670
}
16691671
};
1672+
1673+
export const testMinMaxZoomLevels = async (testTools: TestTools) => {
1674+
const {
1675+
mapViewController,
1676+
passTest,
1677+
failTest,
1678+
expectFalseError,
1679+
setMinZoomLevel,
1680+
setMaxZoomLevel,
1681+
} = testTools;
1682+
1683+
if (!mapViewController) {
1684+
return failTest('mapViewController was expected to exist');
1685+
}
1686+
1687+
try {
1688+
// Test 1: Set valid min and max zoom levels (min < max)
1689+
setMinZoomLevel(5);
1690+
setMaxZoomLevel(15);
1691+
await delay(200);
1692+
1693+
// Verify camera zoom is constrained - move to zoom below min
1694+
mapViewController.moveCamera({
1695+
target: { lat: 37.7749, lng: -122.4194 },
1696+
bearing: 0,
1697+
tilt: 0,
1698+
zoom: 3,
1699+
});
1700+
await delay(200);
1701+
1702+
const posAfterMinClamp = await mapViewController.getCameraPosition();
1703+
if ((posAfterMinClamp.zoom ?? 0) < 5) {
1704+
return expectFalseError(
1705+
`zoom (${posAfterMinClamp.zoom}) should be >= minZoomLevel (5)`
1706+
);
1707+
}
1708+
1709+
// Move to zoom above max
1710+
mapViewController.moveCamera({
1711+
target: { lat: 37.7749, lng: -122.4194 },
1712+
bearing: 0,
1713+
tilt: 0,
1714+
zoom: 20,
1715+
});
1716+
await delay(200);
1717+
1718+
const posAfterMaxClamp = await mapViewController.getCameraPosition();
1719+
if ((posAfterMaxClamp.zoom ?? 0) > 15) {
1720+
return expectFalseError(
1721+
`zoom (${posAfterMaxClamp.zoom}) should be <= maxZoomLevel (15)`
1722+
);
1723+
}
1724+
1725+
// Test 2: Set zoom within range - should work normally
1726+
mapViewController.moveCamera({
1727+
target: { lat: 37.7749, lng: -122.4194 },
1728+
bearing: 0,
1729+
tilt: 0,
1730+
zoom: 10,
1731+
});
1732+
await delay(200);
1733+
1734+
const posInRange = await mapViewController.getCameraPosition();
1735+
if (posInRange.zoom !== 10) {
1736+
return expectFalseError(
1737+
`zoom (${posInRange.zoom}) should be 10 when within range`
1738+
);
1739+
}
1740+
1741+
// Test 3: Reset zoom levels
1742+
setMinZoomLevel(undefined);
1743+
setMaxZoomLevel(undefined);
1744+
await delay(200);
1745+
1746+
// Test 4: Invalid case - min > max (should not crash, constraints ignored)
1747+
setMinZoomLevel(15);
1748+
setMaxZoomLevel(5);
1749+
await delay(200);
1750+
1751+
// Verify app did not crash - camera operations should still work
1752+
mapViewController.moveCamera({
1753+
target: { lat: 37.7749, lng: -122.4194 },
1754+
bearing: 0,
1755+
tilt: 0,
1756+
zoom: 10,
1757+
});
1758+
await delay(200);
1759+
1760+
const posAfterInvalid = await mapViewController.getCameraPosition();
1761+
if (!posAfterInvalid) {
1762+
return failTest('getCameraPosition failed after invalid min/max zoom');
1763+
}
1764+
1765+
// Clean up - reset to defaults
1766+
setMinZoomLevel(undefined);
1767+
setMaxZoomLevel(undefined);
1768+
await delay(200);
1769+
1770+
passTest();
1771+
} catch (error) {
1772+
failTest(`testMinMaxZoomLevels failed: ${error}`);
1773+
}
1774+
};

ios/react-native-navigation-sdk/NavAutoModule.mm

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,10 +548,11 @@ - (void)setNightMode:(NSInteger)nightMode {
548548
});
549549
}
550550

551-
- (void)setFollowingPerspective:(NSInteger)perspective {
551+
- (void)setFollowingPerspective:(NSInteger)perspective zoomLevel:(double)zoomLevel {
552552
dispatch_async(dispatch_get_main_queue(), ^{
553553
if (self->_viewController) {
554-
[self->_viewController setFollowingPerspective:[NSNumber numberWithInteger:perspective]];
554+
[self->_viewController setFollowingPerspective:[NSNumber numberWithInteger:perspective]
555+
zoomLevel:(float)zoomLevel];
555556
}
556557
});
557558
}

0 commit comments

Comments
 (0)