From 6856b78a056686e866b57335c3b874f7252af733 Mon Sep 17 00:00:00 2001 From: Sannihith Date: Sat, 9 May 2026 02:14:59 +0530 Subject: [PATCH] feat(marker): add imageBase64 and anchor support --- .../react/navsdk/MapViewController.java | 43 +++++++++++-- example/e2e/map.test.js | 7 ++ .../src/screens/IntegrationTestsScreen.tsx | 11 ++++ .../integration_tests/integration_test.ts | 64 +++++++++++++++++++ .../NavAutoModule.mm | 22 ++++++- .../NavViewModule.mm | 22 ++++++- src/maps/mapView/types.ts | 6 +- src/native/NativeNavViewModule.ts | 2 + 8 files changed, 168 insertions(+), 9 deletions(-) diff --git a/android/src/main/java/com/google/android/react/navsdk/MapViewController.java b/android/src/main/java/com/google/android/react/navsdk/MapViewController.java index 9f30f826..f49518c3 100644 --- a/android/src/main/java/com/google/android/react/navsdk/MapViewController.java +++ b/android/src/main/java/com/google/android/react/navsdk/MapViewController.java @@ -15,6 +15,9 @@ import android.annotation.SuppressLint; import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.util.Base64; import android.util.Log; import androidx.core.util.Supplier; import com.facebook.react.bridge.UiThreadUtil; @@ -250,6 +253,7 @@ public Marker addMarker(Map optionsMap) { } private Marker createMarker(Map optionsMap, String customId) { + String imageBase64 = CollectionUtil.getString("imageBase64", optionsMap); String imagePath = CollectionUtil.getString("imgPath", optionsMap); String title = CollectionUtil.getString("title", optionsMap); String snippet = CollectionUtil.getString("snippet", optionsMap); @@ -261,15 +265,28 @@ private Marker createMarker(Map optionsMap, String customId) { boolean visible = CollectionUtil.getBool("visible", optionsMap, true); MarkerOptions options = new MarkerOptions(); - if (imagePath != null && !imagePath.isEmpty()) { + if (imageBase64 != null && !imageBase64.isEmpty()) { + byte[] decodedBytes = Base64.decode(imageBase64, Base64.DEFAULT); + Bitmap bitmap = BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.length); + if (bitmap == null) { + throw new IllegalArgumentException(JsErrors.INVALID_IMAGE_ERROR_MESSAGE); + } + options.icon(BitmapDescriptorFactory.fromBitmap(bitmap)); + } else if (imagePath != null && !imagePath.isEmpty()) { try { - BitmapDescriptor icon = BitmapDescriptorFactory.fromAsset(imagePath); - options.icon(icon); + options.icon(BitmapDescriptorFactory.fromAsset(imagePath)); } catch (Exception e) { throw new IllegalArgumentException(JsErrors.INVALID_IMAGE_ERROR_MESSAGE); } } + if (optionsMap.containsKey("anchor")) { + Map anchor = (Map) optionsMap.get("anchor"); + float u = Double.valueOf(anchor.get("u").toString()).floatValue(); + float v = Double.valueOf(anchor.get("v").toString()).floatValue(); + options.anchor(u, v); + } + options.position( ObjectTranslationUtil.getLatLngFromMap((Map) optionsMap.get("position"))); @@ -298,6 +315,7 @@ private Marker createMarker(Map optionsMap, String customId) { } private void updateMarker(Marker marker, Map optionsMap) { + String imageBase64 = CollectionUtil.getString("imageBase64", optionsMap); String imagePath = CollectionUtil.getString("imgPath", optionsMap); String title = CollectionUtil.getString("title", optionsMap); String snippet = CollectionUtil.getString("snippet", optionsMap); @@ -308,15 +326,28 @@ private void updateMarker(Marker marker, Map optionsMap) { boolean flat = CollectionUtil.getBool("flat", optionsMap, false); boolean visible = CollectionUtil.getBool("visible", optionsMap, true); - if (imagePath != null && !imagePath.isEmpty()) { + if (imageBase64 != null && !imageBase64.isEmpty()) { + byte[] decodedBytes = Base64.decode(imageBase64, Base64.DEFAULT); + Bitmap bitmap = BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.length); + if (bitmap == null) { + throw new IllegalArgumentException(JsErrors.INVALID_IMAGE_ERROR_MESSAGE); + } + marker.setIcon(BitmapDescriptorFactory.fromBitmap(bitmap)); + } else if (imagePath != null && !imagePath.isEmpty()) { try { - BitmapDescriptor icon = BitmapDescriptorFactory.fromAsset(imagePath); - marker.setIcon(icon); + marker.setIcon(BitmapDescriptorFactory.fromAsset(imagePath)); } catch (Exception e) { throw new IllegalArgumentException(JsErrors.INVALID_IMAGE_ERROR_MESSAGE); } } + if (optionsMap.containsKey("anchor")) { + Map anchor = (Map) optionsMap.get("anchor"); + float u = Double.valueOf(anchor.get("u").toString()).floatValue(); + float v = Double.valueOf(anchor.get("v").toString()).floatValue(); + marker.setAnchor(u, v); + } + marker.setPosition( ObjectTranslationUtil.getLatLngFromMap((Map) optionsMap.get("position"))); diff --git a/example/e2e/map.test.js b/example/e2e/map.test.js index e3814007..8c66e38c 100644 --- a/example/e2e/map.test.js +++ b/example/e2e/map.test.js @@ -96,4 +96,11 @@ describe('Map view tests', () => { await expectNoErrors(); await expectSuccess(); }); + + it('MT11 - test adding marker with imageBase64 and anchor', async () => { + await selectTestByName('testMarkerImageBase64'); + await waitForTestToFinish(); + await expectNoErrors(); + await expectSuccess(); + }); }); diff --git a/example/src/screens/IntegrationTestsScreen.tsx b/example/src/screens/IntegrationTestsScreen.tsx index 292bd34e..3f50e27a 100644 --- a/example/src/screens/IntegrationTestsScreen.tsx +++ b/example/src/screens/IntegrationTestsScreen.tsx @@ -58,6 +58,7 @@ import { testMinMaxZoomLevels, testSetFollowingPerspective, testNavInfoEventsAfterCleanup, + testMarkerImageBase64, NO_ERRORS_DETECTED_LABEL, } from './integration_tests/integration_test'; @@ -327,6 +328,9 @@ const IntegrationTestsScreen = () => { case 'testNavInfoEventsAfterCleanup': await testNavInfoEventsAfterCleanup(getTestTools()); break; + case 'testMarkerImageBase64': + await testMarkerImageBase64(getTestTools()); + break; default: resetTestState(); break; @@ -563,6 +567,13 @@ const IntegrationTestsScreen = () => { }} testID="testNavInfoEventsAfterCleanup" /> + { + runTest('testMarkerImageBase64'); + }} + testID="testMarkerImageBase64" + /> ); diff --git a/example/src/screens/integration_tests/integration_test.ts b/example/src/screens/integration_tests/integration_test.ts index 4987e770..4d3d22dd 100644 --- a/example/src/screens/integration_tests/integration_test.ts +++ b/example/src/screens/integration_tests/integration_test.ts @@ -1971,3 +1971,67 @@ export const testNavInfoEventsAfterCleanup = async (testTools: TestTools) => { await initializeNavigation(navigationController, failTest); }; + +// Minimal 1x1 transparent PNG encoded as base64 — used as a self-contained +// test fixture to verify the imageBase64 decoding path without network access. +const MINIMAL_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +export const testMarkerImageBase64 = async (testTools: TestTools) => { + const { mapViewController, passTest, failTest, expectFalseError } = testTools; + if (!mapViewController) { + return failTest('mapViewController was expected to exist'); + } + + // Test adding a marker with imageBase64 and a custom anchor + const marker = await mapViewController.addMarker({ + position: { lat: 37.7749, lng: -122.4194 }, + imageBase64: MINIMAL_PNG_BASE64, + anchor: { u: 0.5, v: 0.5 }, + title: 'Base64 Marker', + }); + + if (!marker.id) { + return expectFalseError('marker.id should exist after adding imageBase64 marker'); + } + if (marker.position.lat !== 37.7749 || marker.position.lng !== -122.4194) { + return expectFalseError('marker.position should match input'); + } + + // Verify getMarkers returns the marker + let markers = await mapViewController.getMarkers(); + if (markers.length !== 1) { + return expectFalseError('getMarkers should return 1 marker'); + } + if (markers[0]!.id !== marker.id) { + return expectFalseError('getMarkers should return marker with correct id'); + } + + // Test updating the marker with a new imageBase64 (same id → update path) + const updatedMarker = await mapViewController.addMarker({ + id: marker.id, + position: { lat: 37.7749, lng: -122.4194 }, + imageBase64: MINIMAL_PNG_BASE64, + anchor: { u: 0.5, v: 0.5 }, + title: 'Updated Base64 Marker', + }); + + if (updatedMarker.id !== marker.id) { + return expectFalseError('updatedMarker.id should match original marker.id'); + } + + // Verify still one marker (update, not duplicate) + markers = await mapViewController.getMarkers(); + if (markers.length !== 1) { + return expectFalseError('getMarkers should still return 1 marker after update'); + } + + // Remove and verify clean up + await mapViewController.removeMarker(marker.id); + markers = await mapViewController.getMarkers(); + if (markers.length !== 0) { + return expectFalseError('getMarkers should return 0 markers after removal'); + } + + passTest(); +}; diff --git a/ios/react-native-navigation-sdk/NavAutoModule.mm b/ios/react-native-navigation-sdk/NavAutoModule.mm index adfd47a8..f4c5c657 100644 --- a/ios/react-native-navigation-sdk/NavAutoModule.mm +++ b/ios/react-native-navigation-sdk/NavAutoModule.mm @@ -142,9 +142,21 @@ - (void)addMarker:(MarkerOptionsSpec &)options dispatch_async(dispatch_get_main_queue(), ^{ CLLocationCoordinate2D position = CLLocationCoordinate2DMake(optionsCopy.position().lat(), optionsCopy.position().lng()); + + NSString *imageBase64 = optionsCopy.imageBase64(); NSString *imgPath = optionsCopy.imgPath(); UIImage *icon = nil; - if (imgPath && [imgPath isKindOfClass:[NSString class]] && imgPath.length > 0) { + + if (imageBase64 && imageBase64.length > 0) { + NSData *imageData = + [[NSData alloc] initWithBase64EncodedString:imageBase64 + options:NSDataBase64DecodingIgnoreUnknownCharacters]; + icon = [UIImage imageWithData:imageData]; + if (!icon) { + reject(@"INVALID_IMAGE", @"Failed to decode base64 image data", nil); + return; + } + } else if (imgPath && imgPath.length > 0) { icon = [UIImage imageNamed:imgPath]; if (!icon) { reject(@"INVALID_IMAGE", @"Failed to load image from the provided path", nil); @@ -152,6 +164,12 @@ - (void)addMarker:(MarkerOptionsSpec &)options } } + CGPoint anchorPoint = CGPointMake(0.5, 1.0); + if (optionsCopy.anchor().has_value()) { + auto anchor = optionsCopy.anchor().value(); + anchorPoint = CGPointMake(anchor.u(), anchor.v()); + } + GMSMarker *marker = [ObjectTranslationUtil createMarker:position title:optionsCopy.title() @@ -164,6 +182,8 @@ - (void)addMarker:(MarkerOptionsSpec &)options zIndex:optionsCopy.zIndex().has_value() ? @(optionsCopy.zIndex().value()) : nil identifier:optionsCopy.id_()]; + marker.groundAnchor = anchorPoint; + [self->_viewController addMarker:marker visible:optionsCopy.visible().value_or(YES) result:^(NSDictionary *result) { diff --git a/ios/react-native-navigation-sdk/NavViewModule.mm b/ios/react-native-navigation-sdk/NavViewModule.mm index b3193875..a2c92e9d 100644 --- a/ios/react-native-navigation-sdk/NavViewModule.mm +++ b/ios/react-native-navigation-sdk/NavViewModule.mm @@ -145,9 +145,21 @@ - (void)addMarker:(NSString *)nativeID dispatch_async(dispatch_get_main_queue(), ^{ CLLocationCoordinate2D position = CLLocationCoordinate2DMake(optionsCopy.position().lat(), optionsCopy.position().lng()); + + NSString *imageBase64 = optionsCopy.imageBase64(); NSString *imgPath = optionsCopy.imgPath(); UIImage *icon = nil; - if (imgPath && [imgPath isKindOfClass:[NSString class]] && imgPath.length > 0) { + + if (imageBase64 && imageBase64.length > 0) { + NSData *imageData = + [[NSData alloc] initWithBase64EncodedString:imageBase64 + options:NSDataBase64DecodingIgnoreUnknownCharacters]; + icon = [UIImage imageWithData:imageData]; + if (!icon) { + reject(@"INVALID_IMAGE", @"Failed to decode base64 image data", nil); + return; + } + } else if (imgPath && imgPath.length > 0) { icon = [UIImage imageNamed:imgPath]; if (!icon) { reject(@"INVALID_IMAGE", @"Failed to load image from the provided path", nil); @@ -155,6 +167,12 @@ - (void)addMarker:(NSString *)nativeID } } + CGPoint anchorPoint = CGPointMake(0.5, 1.0); + if (optionsCopy.anchor().has_value()) { + auto anchor = optionsCopy.anchor().value(); + anchorPoint = CGPointMake(anchor.u(), anchor.v()); + } + GMSMarker *marker = [ObjectTranslationUtil createMarker:position title:optionsCopy.title() @@ -167,6 +185,8 @@ - (void)addMarker:(NSString *)nativeID zIndex:optionsCopy.zIndex().has_value() ? @(optionsCopy.zIndex().value()) : nil identifier:optionsCopy.id_()]; + marker.groundAnchor = anchorPoint; + [viewController addMarker:marker visible:optionsCopy.visible().value_or(YES) result:^(NSDictionary *result) { diff --git a/src/maps/mapView/types.ts b/src/maps/mapView/types.ts index 59ea599d..7c27c4a1 100644 --- a/src/maps/mapView/types.ts +++ b/src/maps/mapView/types.ts @@ -56,8 +56,12 @@ export interface MarkerOptions { id?: string; /** The LatLng value for the marker's position on the map. You can change this value at any time if you want to move the marker. */ position: LatLng; - /** Path to a local image asset that should be displayed in the marker instead of using the default marker pin. */ + /** Path to an image asset bundled with the application to use as the marker icon. Ignored when imageBase64 is provided. */ imgPath?: string; + /** A raw base64-encoded PNG or JPEG image to use as the marker icon. Takes priority over imgPath. */ + imageBase64?: string; + /** The anchor point of the marker icon in normalized icon coordinates (u, v). Defaults to (0.5, 1.0). */ + anchor?: { u: number; v: number }; /** A text string that's displayed in an info window when the user taps the marker. You can change this value at any time. */ title?: string; /** Additional text that's displayed below the title. You can change this value at any time. */ diff --git a/src/native/NativeNavViewModule.ts b/src/native/NativeNavViewModule.ts index ae26454a..dbf594da 100644 --- a/src/native/NativeNavViewModule.ts +++ b/src/native/NativeNavViewModule.ts @@ -50,7 +50,9 @@ type MarkerOptionsSpec = Readonly<{ draggable?: WithDefault; flat?: WithDefault; id?: WithDefault; + imageBase64?: WithDefault; imgPath?: WithDefault; + anchor?: Readonly<{ u: Float; v: Float }>; position: Readonly<{ lat: Float; lng: Float }>; rotation?: WithDefault; snippet?: WithDefault;