future = new CompletableFuture<>();
+
+ // 1. Set up the success listener
+ googleMap3D.setOnMapSteadyListener(isSteady -> {
+ if (isSteady && !future.isDone()) {
+ // Important: clear the listener when done
+ googleMap3D.setOnMapSteadyListener(null);
+ future.complete(true);
+ }
+ });
+
+ // 2. Schedule the timeout
+ scheduler.schedule(() -> {
+ if (!future.isDone()) {
+ // Important: clear the listener on timeout too
+ googleMap3D.setOnMapSteadyListener(null);
+ future.complete(false); // Resolve as false on timeout
+ }
+ }, timeout, unit);
+
+ return future;
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java
index a8632b5..aa9785f 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java
@@ -20,8 +20,6 @@
import android.os.Bundle;
import android.view.View;
-import com.example.maps3dcommon.R;
-
import androidx.annotation.StringRes;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
@@ -32,6 +30,7 @@
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
+import com.example.maps3dcommon.R;
import com.example.maps3djava.cameracontrols.CameraControlsActivity;
import com.example.maps3djava.hellomap.HelloMapActivity;
import com.example.maps3djava.markers.MarkersActivity;
@@ -52,6 +51,8 @@ public class MainActivity extends AppCompatActivity {
put(R.string.feature_title_polygons, PolygonsActivity.class);
put(R.string.feature_title_polylines, PolylinesActivity.class);
put(R.string.feature_title_3d_models, ModelsActivity.class);
+ put(R.string.feature_title_popovers, com.example.maps3djava.popovers.PopoversActivity.class);
+ put(R.string.feature_title_map_interactions, com.example.maps3djava.mapinteractions.MapInteractionsActivity.class);
}};
@Override
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mapinteractions/MapInteractionsActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mapinteractions/MapInteractionsActivity.java
new file mode 100644
index 0000000..ed81420
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mapinteractions/MapInteractionsActivity.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3djava.mapinteractions;
+
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+import com.google.android.gms.maps3d.model.Map3DMode;
+
+public class MapInteractionsActivity extends SampleBaseActivity {
+
+ private static final double BOULDER_LATITUDE = 40.029349;
+ private static final double BOULDER_LONGITUDE = -105.300354;
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "MapInteractionsActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return com.example.maps3d.common.UtilitiesKt.toValidCamera(new Camera(
+ new LatLngAltitude(BOULDER_LATITUDE, BOULDER_LONGITUDE, 1833.9),
+ 326.0,
+ 75.0,
+ 0.0,
+ 3757.0));
+ }
+
+ @Override
+ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setOnMapReadyListener((map) -> {
+ googleMap3D.setOnMapReadyListener(null);
+ onMapReady(googleMap3D);
+ });
+ }
+
+ private void onMapReady(@NonNull GoogleMap3D googleMap3D) {
+ googleMap3D.setMapMode(Map3DMode.HYBRID);
+
+ // Listeners for map clicks.
+ googleMap3D.setMap3DClickListener((location, placeId) -> {
+ runOnUiThread(() -> {
+ if (placeId != null) {
+ showToast("Clicked on place with ID: " + placeId);
+ } else {
+ showToast("Clicked on location: " + location);
+ }
+ });
+ });
+ }
+
+ protected void showToast(String message) {
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java
index db6031a..d93ab3b 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java
@@ -14,6 +14,21 @@
package com.example.maps3djava.markers;
+import com.google.android.gms.maps3d.model.FlyAroundOptions;
+import com.google.android.gms.maps3d.model.PopoverOptions;
+import com.google.android.gms.maps3d.Popover;
+import org.json.JSONObject;
+import org.json.JSONArray;
+import android.graphics.Color;
+import android.util.Log;
+import android.widget.TextView;
+import android.widget.PopupMenu;
+import java.util.concurrent.CompletableFuture;
+import android.os.Looper;
+import android.os.Handler;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Random;
import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
import android.view.View;
@@ -34,6 +49,9 @@
import com.google.android.gms.maps3d.model.MarkerOptions;
import com.google.android.gms.maps3d.model.PinConfiguration;
+import java.util.List;
+import java.util.ArrayList;
+
/**
* Demonstrates the use of different altitude modes for markers in a 3D map.
*
@@ -45,6 +63,8 @@
*/
public class MarkersActivity extends SampleBaseActivity {
+ private Popover activePopover = null;
+
@Override
public final String getTAG() {
return this.getClass().getSimpleName();
@@ -64,17 +84,15 @@ public final Camera getBerlinCamera() {
));
}
- public final Camera getTokyoCamera() {
- return toValidCamera(new Camera(
- new LatLngAltitude(
- 35.658708,
- 139.702206,
- 23.3),
- 117.0,
- 55.0,
- 0.0,
- 2868.0));
- }
+ private final List monsterCameras = new ArrayList<>();
+ private final List monsterMarkers = new ArrayList<>();
+ private final List monsterIds = new ArrayList<>();
+ private final List monsterLabels = new ArrayList<>();
+
+ private Handler tourHandler;
+ private Runnable tourRunnable;
+ private int tourIndex = 0;
+ private boolean isTourActive = false;
@Override
public final Camera getInitialCamera() {
@@ -92,13 +110,26 @@ public final Camera getInitialCamera() {
@Override
public void onMap3DViewReady(GoogleMap3D googleMap3D) {
super.onMap3DViewReady(googleMap3D);
+
+ Log.d(getTAG(), "onMap3DViewReady called");
+
+ googleMap3D.setOnMapReadyListener((map) -> {
+ Log.w(getTAG(), "on map ready listener fired");
+ googleMap3D.setOnMapReadyListener(null);
+ onMapReady(googleMap3D);
+ });
+ }
+
+ private void onMapReady(GoogleMap3D googleMap3D) {
+ googleMap3D.setCamera(getInitialCamera());
googleMap3D.setMapMode(Map3DMode.SATELLITE);
Button flyBerlinButton = findViewById(R.id.fly_berlin_button);
if (flyBerlinButton != null) {
runOnUiThread(() -> flyBerlinButton.setVisibility(View.VISIBLE));
flyBerlinButton.setOnClickListener(v -> {
- FlyToOptions options = new FlyToOptions(getBerlinCamera(), 2000L);
+ stopMonsterTour(googleMap3D);
+ FlyToOptions options = new FlyToOptions(getBerlinCamera(), 4000L);
googleMap3D.flyCameraTo(options);
});
}
@@ -107,20 +138,68 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
if (flyNycButton != null) {
runOnUiThread(() -> flyNycButton.setVisibility(View.VISIBLE));
flyNycButton.setOnClickListener(v -> {
- FlyToOptions options = new FlyToOptions(getInitialCamera(), 2000L);
+ stopMonsterTour(googleMap3D);
+ FlyToOptions options = new FlyToOptions(getInitialCamera(), 4000L);
googleMap3D.flyCameraTo(options);
});
}
- Button flyTokyoButton = findViewById(R.id.fly_tokyo_button);
- if (flyTokyoButton != null) {
- runOnUiThread(() -> flyTokyoButton.setVisibility(View.VISIBLE));
- flyTokyoButton.setOnClickListener(v -> {
- FlyToOptions options = new FlyToOptions(getTokyoCamera(), 2000L);
- googleMap3D.flyCameraTo(options);
+ Button flyRandomMonsterButton = findViewById(R.id.fly_random_monster_button);
+ if (flyRandomMonsterButton != null) {
+ runOnUiThread(() -> flyRandomMonsterButton.setVisibility(View.VISIBLE));
+ flyRandomMonsterButton.setOnClickListener(v -> {
+ stopMonsterTour(googleMap3D);
+ if (!monsterCameras.isEmpty()) {
+ Camera randomCamera = monsterCameras.get(new Random().nextInt(monsterCameras.size()));
+ FlyToOptions options = new FlyToOptions(randomCamera, 4000L);
+ googleMap3D.flyCameraTo(options);
+ }
+ });
+ flyRandomMonsterButton.setOnLongClickListener(v -> {
+ if (!monsterLabels.isEmpty()) {
+ PopupMenu popup = new PopupMenu(MarkersActivity.this, v);
+ for (int i = 0; i < monsterLabels.size(); i++) {
+ popup.getMenu().add(0, i, i, monsterLabels.get(i));
+ }
+ popup.setOnMenuItemClickListener(item -> {
+ stopMonsterTour(googleMap3D);
+ Camera selectedCamera = monsterCameras.get(item.getItemId());
+ FlyToOptions options = new FlyToOptions(selectedCamera, 4000L);
+ googleMap3D.flyCameraTo(options);
+ return true;
+ });
+ popup.show();
+ }
+ return true;
+ });
+ }
+
+ Button tourMonstersButton = findViewById(R.id.tour_monsters_button);
+ if (tourMonstersButton != null) {
+ runOnUiThread(() -> tourMonstersButton.setVisibility(View.VISIBLE));
+ tourMonstersButton.setOnClickListener(v -> {
+ if (!monsterCameras.isEmpty() && monsterMarkers.size() == monsterCameras.size() && !isTourActive) {
+ startMonsterTour(googleMap3D);
+ }
});
}
+ Button stopButton = findViewById(R.id.stop_button);
+ if (stopButton != null) {
+ stopButton.setOnClickListener(v -> stopMonsterTour(googleMap3D));
+ }
+
+ googleMap3D.setMap3DClickListener((location, placeId) -> {
+ runOnUiThread(() -> {
+ if (activePopover != null) {
+ activePopover.remove();
+ activePopover = null;
+ }
+ });
+ });
+
+ // Marker 1: Absolute Altitude
+ // This marker is placed at a fixed altitude of 150 meters above sea level.
addMarkerWithToastListener(googleMap3D,
new LatLngAltitude(52.519605780912585, 13.406867190588198, 150.0),
"Absolute (150m)",
@@ -128,6 +207,8 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL
);
+ // Marker 2: Relative to Ground
+ // This marker is positioned 50 meters above the ground directly beneath it.
addMarkerWithToastListener(googleMap3D,
new LatLngAltitude(52.519882191069016, 13.407410777254293, 50.0),
"Relative to Ground (50m)",
@@ -135,6 +216,10 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY
);
+ // Marker 3: Clamped to Ground
+ // This marker is attached to the ground. Its altitude value is effectively
+ // ignored
+ // for rendering purposes, but it's often set to 0.0 for clarity.
addMarkerWithToastListener(googleMap3D,
new LatLngAltitude(52.52027645136134, 13.408271658592406, 0.0),
"Clamped to Ground",
@@ -142,6 +227,10 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
CollisionBehavior.REQUIRED
);
+ // Marker 4: Relative to Mesh
+ // This marker is placed 10 meters above the 3D mesh, which includes buildings
+ // and other structures. This is ideal for placing markers on or relative to 3D
+ // objects.
addMarkerWithToastListener(googleMap3D,
new LatLngAltitude(52.520835071144226, 13.409426847943774, 10.0),
"Relative to Mesh (10m)",
@@ -152,7 +241,7 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
MarkerOptions apeOptions = new MarkerOptions();
apeOptions.setPosition(new LatLngAltitude(40.7484, -73.9857, 100.0));
apeOptions.setZIndex(1);
- apeOptions.setLabel("King Kong / Empire State Building");
+ apeOptions.setLabel("Giant Ape / Empire State Building");
apeOptions.setAltitudeMode(AltitudeMode.RELATIVE_TO_MESH);
apeOptions.setCollisionBehavior(CollisionBehavior.REQUIRED);
apeOptions.setExtruded(true);
@@ -162,11 +251,33 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
Marker apeMarker = googleMap3D.addMarker(apeOptions);
if (apeMarker != null) {
- apeMarker.setClickListener(
- () -> MarkersActivity.this.showToast("Clicked on marker: " + apeMarker.getLabel()));
+ apeMarker.setClickListener(() -> {
+ TextView textView = new TextView(MarkersActivity.this);
+ textView.setText(getString(R.string.monster_ape_blurb));
+ textView.setPadding(32, 16, 32, 16);
+ textView.setTextColor(Color.BLACK);
+ textView.setBackgroundColor(Color.WHITE);
+
+ PopoverOptions popoverOptions = new PopoverOptions();
+ popoverOptions.setPositionAnchor(apeMarker);
+ popoverOptions.setAltitudeMode(AltitudeMode.RELATIVE_TO_MESH);
+ popoverOptions.setContent(textView);
+ popoverOptions.setAutoCloseEnabled(true);
+ popoverOptions.setAutoPanEnabled(false);
+
+ if (activePopover != null) {
+ runOnUiThread(() -> activePopover.remove());
+ }
+
+ Popover popover = googleMap3D.addPopover(popoverOptions);
+ activePopover = popover;
+ if (popover != null) {
+ runOnUiThread(() -> popover.show());
+ }
+ });
}
- Glyph customColorGlyph = Glyph.fromColor(android.graphics.Color.CYAN);
+ Glyph customColorGlyph = Glyph.fromColor(Color.CYAN);
MarkerOptions customColorOptions = new MarkerOptions();
customColorOptions.setPosition(new LatLngAltitude(40.7486, -73.9848, 600.0));
customColorOptions.setExtruded(true);
@@ -174,8 +285,8 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
customColorOptions.setLabel("Custom Color Pin");
customColorOptions.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND);
PinConfiguration.Builder colorPinBuilder = PinConfiguration.builder();
- colorPinBuilder.setBackgroundColor(android.graphics.Color.RED);
- colorPinBuilder.setBorderColor(android.graphics.Color.WHITE);
+ colorPinBuilder.setBackgroundColor(Color.RED);
+ colorPinBuilder.setBorderColor(Color.WHITE);
colorPinBuilder.setGlyph(customColorGlyph);
customColorOptions.setStyle(colorPinBuilder.build());
@@ -185,7 +296,7 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
() -> MarkersActivity.this.showToast("Clicked on marker: " + colorMarker.getLabel()));
}
- Glyph textGlyph = Glyph.fromColor(android.graphics.Color.RED);
+ Glyph textGlyph = Glyph.fromColor(Color.RED);
textGlyph.setText("NYC\n π ");
MarkerOptions textOptions = new MarkerOptions();
textOptions.setPosition(new LatLngAltitude(40.7482, -73.9862, 600.0));
@@ -194,8 +305,8 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
textOptions.setLabel("Custom Text Pin");
textOptions.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND);
PinConfiguration.Builder textPinBuilder = PinConfiguration.builder();
- textPinBuilder.setBackgroundColor(android.graphics.Color.YELLOW);
- textPinBuilder.setBorderColor(android.graphics.Color.BLUE);
+ textPinBuilder.setBackgroundColor(Color.YELLOW);
+ textPinBuilder.setBorderColor(Color.BLUE);
textPinBuilder.setGlyph(textGlyph);
textOptions.setStyle(textPinBuilder.build());
@@ -205,18 +316,60 @@ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
() -> MarkersActivity.this.showToast("Clicked on marker: " + textMarker.getLabel()));
}
- MarkerOptions shibuyaOptions = new MarkerOptions();
- shibuyaOptions.setPosition(new LatLngAltitude(35.6595, 139.7005, 50.0));
- shibuyaOptions.setLabel("Shibuya Crossing Easter Egg");
- shibuyaOptions.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND);
- shibuyaOptions.setExtruded(true);
- shibuyaOptions.setDrawnWhenOccluded(true);
- shibuyaOptions.setStyle(new ImageView(R.drawable.gz));
+ // Monsters from JSON
+ try {
+ InputStream is = getAssets().open("monsters.json");
+ byte[] buffer = new byte[is.available()];
+ is.read(buffer);
+ is.close();
+ String jsonString = new String(buffer, StandardCharsets.UTF_8);
+
+ List parsedMonsters = com.example.maps3djava.markers.data.MonsterParser
+ .parse(jsonString);
+
+ for (com.example.maps3djava.markers.data.Monster monster : parsedMonsters) {
+ Camera cam = new Camera(
+ new LatLngAltitude(
+ monster.latitude,
+ monster.longitude,
+ monster.altitude),
+ monster.heading,
+ monster.tilt,
+ 0.0,
+ monster.range);
+
+ LatLngAltitude markerPos = new LatLngAltitude(
+ monster.markerLatitude,
+ monster.markerLongitude,
+ monster.markerAltitude);
+
+ String monsterId = monster.id;
+ String label = monster.label;
+ int parsedAltitudeMode = monster.altitudeMode;
- Marker shibuyaMarker = googleMap3D.addMarker(shibuyaOptions);
- if (shibuyaMarker != null) {
- shibuyaMarker.setClickListener(
- () -> MarkersActivity.this.showToast("Clicked on marker: " + shibuyaMarker.getLabel()));
+ int drawableId = getMonsterDrawableId(monster.drawable);
+ if (drawableId != 0) {
+ MarkerOptions monsterOptions = new MarkerOptions();
+ monsterOptions.setPosition(markerPos);
+ monsterOptions.setLabel(label);
+ monsterOptions.setAltitudeMode(parsedAltitudeMode);
+ monsterOptions.setExtruded(true);
+ monsterOptions.setDrawnWhenOccluded(true);
+ monsterOptions.setStyle(new ImageView(drawableId));
+
+ Marker monsterMarker = googleMap3D.addMarker(monsterOptions);
+ if (monsterMarker != null) {
+ monsterCameras.add(cam);
+ monsterMarkers.add(monsterMarker);
+ monsterIds.add(monsterId);
+ monsterLabels.add(label);
+
+ setupMarkerClickListener(monsterMarker, getMonsterBlurbResId(monsterId), googleMap3D);
+ }
+ }
+ }
+ } catch (Exception e) {
+ Log.e(getTAG(), "Error loading monsters.json", e);
}
}
@@ -236,7 +389,197 @@ private void addMarkerWithToastListener(
options.setExtruded(true);
options.setDrawnWhenOccluded(true);
+ Log.d(getTAG(), "Adding marker with options: "
+ + "position=" + options.getPosition()
+ + ", label=" + options.getLabel()
+ + ", altitudeMode=" + options.getAltitudeMode()
+ + ", collisionBehavior=" + options.getCollisionBehavior()
+ + ", extruded=" + options.isExtruded()
+ + ", drawnWhenOccluded=" + options.isDrawnWhenOccluded()
+ );
Marker marker = map.addMarker(options);
- marker.setClickListener(() -> MarkersActivity.this.showToast("Clicked on marker: " + label));
+ if (marker != null) {
+ Log.d(getTAG(), "Marker was not null!");
+ marker.setClickListener(() -> MarkersActivity.this.showToast("Clicked on marker: " + label));
+ }
+ }
+
+ private void setupMarkerClickListener(Marker marker, int blurbResId, GoogleMap3D googleMap3D) {
+ marker.setClickListener(() -> runOnUiThread(() -> {
+ if (blurbResId != 0) {
+ showMonsterPopover(marker, blurbResId, googleMap3D);
+ } else {
+ showToast("Clicked on marker: " + marker.getLabel());
+ }
+ }));
+ }
+
+ private void showMonsterPopover(Marker marker, int blurbResId, GoogleMap3D googleMap3D) {
+ if (blurbResId != 0) {
+ TextView textView = new TextView(MarkersActivity.this);
+ textView.setText(getString(blurbResId));
+ textView.setPadding(32, 16, 32, 16);
+ textView.setTextColor(Color.BLACK);
+ textView.setBackgroundColor(Color.WHITE);
+
+ PopoverOptions popOptions = new PopoverOptions();
+ popOptions.setPositionAnchor(marker);
+ popOptions.setAltitudeMode(marker.getAltitudeMode());
+ popOptions.setContent(textView);
+ popOptions.setAutoCloseEnabled(true);
+ popOptions.setAutoPanEnabled(false);
+
+ Popover newPopover = googleMap3D.addPopover(popOptions);
+
+ if (activePopover != null) {
+ activePopover.remove();
+ }
+ activePopover = newPopover;
+ activePopover.show();
+ }
+ }
+
+ private void startMonsterTour(GoogleMap3D map) {
+ isTourActive = true;
+ tourIndex = 0;
+
+ runOnUiThread(() -> {
+ Button stopButton = findViewById(R.id.stop_button);
+ if (stopButton != null)
+ stopButton.setVisibility(View.VISIBLE);
+ Button tourButton = findViewById(R.id.tour_monsters_button);
+ if (tourButton != null)
+ tourButton.setVisibility(View.GONE);
+ });
+
+ if (tourHandler == null) {
+ tourHandler = new Handler(Looper.getMainLooper());
+ }
+
+ advanceTour(map);
+ }
+
+ private void advanceTour(GoogleMap3D map) {
+ if (!isTourActive)
+ return;
+
+ // Clear any existing popover before moving to the next location
+ if (activePopover != null) {
+ activePopover.remove();
+ activePopover = null;
+ }
+
+ Camera camera = monsterCameras.get(tourIndex);
+ Marker marker = monsterMarkers.get(tourIndex);
+ String monsterId = monsterIds.get(tourIndex);
+
+ // We use the main UI thread executor so our UI-modifying code (like showing
+ // popovers)
+ // runs safely after the background CompletableFutures resolve.
+ java.util.concurrent.Executor mainThread = this::runOnUiThread;
+
+ FlyToOptions flyOptions = new FlyToOptions(camera, 4000L);
+
+ // Step 1: Start the camera flight to the monster's location.
+ com.example.maps3djava.common.MapUtils.awaitCameraAnimation(map, flyOptions)
+
+ // Step 2: Once the flight completes, wait for the 3D map tiles to fully load
+ // (become "steady").
+ // We set a 5-second timeout so the tour doesn't hang indefinitely if network is
+ // slow.
+ .thenComposeAsync(v -> com.example.maps3djava.common.MapUtils.awaitMapSteady(map, 5,
+ java.util.concurrent.TimeUnit.SECONDS), mainThread)
+
+ // Step 3: Now that we've arrived and the map is steady, begin an orbit
+ // animation.
+ .thenComposeAsync(isSteady -> {
+ // If the user tapped "Stop" while we were waiting, gracefully exit the chain.
+ if (!isTourActive)
+ return CompletableFuture.completedFuture((Void) null);
+
+ FlyAroundOptions orbitOptions = new FlyAroundOptions(camera, 5000L, 1.0);
+ return com.example.maps3djava.common.MapUtils.awaitCameraAnimation(map, orbitOptions);
+ }, mainThread)
+
+ // Step 4: After the full orbit is complete, show the monster's popover.
+ .thenAcceptAsync(v -> {
+ if (!isTourActive)
+ return;
+
+ showMonsterPopover(marker, getMonsterBlurbResId(monsterId), map);
+
+ // Step 5: Schedule the next leg of the tour to begin after a 4-second pause,
+ // allowing the user time to read the popover blurb.
+ tourRunnable = () -> {
+ tourIndex = (tourIndex + 1) % monsterCameras.size();
+ advanceTour(map);
+ };
+ tourHandler.postDelayed(tourRunnable, 4000);
+ }, mainThread);
+ }
+
+ private void stopMonsterTour(GoogleMap3D map) {
+ isTourActive = false;
+ if (tourHandler != null && tourRunnable != null) {
+ tourHandler.removeCallbacks(tourRunnable);
+ }
+ map.stopCameraAnimation();
+ map.setCameraAnimationEndListener(null);
+ map.setOnMapSteadyListener(null);
+
+ runOnUiThread(() -> {
+ Button stopButton = findViewById(R.id.stop_button);
+ if (stopButton != null)
+ stopButton.setVisibility(View.GONE);
+ Button tourButton = findViewById(R.id.tour_monsters_button);
+ if (tourButton != null)
+ tourButton.setVisibility(View.VISIBLE);
+ });
+ }
+
+ private int getMonsterDrawableId(String drawableName) {
+ switch (drawableName) {
+ case "alien":
+ return R.drawable.alien;
+ case "bigfoot":
+ return R.drawable.bigfoot;
+ case "frank":
+ return R.drawable.frank;
+ case "godzilla":
+ return R.drawable.godzilla;
+ case "mothra":
+ return R.drawable.mothra;
+ case "mummy":
+ return R.drawable.mummy;
+ case "nessie":
+ return R.drawable.nessie;
+ case "yeti":
+ return R.drawable.yeti;
+ default:
+ return 0;
+ }
+ }
+
+ private int getMonsterBlurbResId(String monsterId) {
+ switch (monsterId) {
+ case "alien":
+ return R.string.monster_alien_blurb;
+ case "bigfoot":
+ return R.string.monster_bigfoot_blurb;
+ case "frank":
+ return R.string.monster_frank_blurb;
+ case "godzilla":
+ return R.string.monster_godzilla_blurb;
+ case "mothra":
+ return R.string.monster_mothra_blurb;
+ case "mummy":
+ return R.string.monster_mummy_blurb;
+ case "nessie":
+ return R.string.monster_nessie_blurb;
+ case "yeti":
+ return R.string.monster_yeti_blurb;
+ default:
+ return 0;
+ }
}
}
\ No newline at end of file
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/data/Monster.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/data/Monster.java
new file mode 100644
index 0000000..c9dc1b0
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/data/Monster.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3djava.markers.data;
+
+public class Monster {
+ public final String id;
+ public final String label;
+ public final double latitude;
+ public final double longitude;
+ public final double altitude;
+ public final double heading;
+ public final double tilt;
+ public final double range;
+ public final double markerLatitude;
+ public final double markerLongitude;
+ public final double markerAltitude;
+ public final String drawable;
+ public final int altitudeMode;
+
+ public Monster(
+ String id,
+ String label,
+ double latitude,
+ double longitude,
+ double altitude,
+ double heading,
+ double tilt,
+ double range,
+ double markerLatitude,
+ double markerLongitude,
+ double markerAltitude,
+ String drawable,
+ int altitudeMode) {
+ this.id = id;
+ this.label = label;
+ this.latitude = latitude;
+ this.longitude = longitude;
+ this.altitude = altitude;
+ this.heading = heading;
+ this.tilt = tilt;
+ this.range = range;
+ this.markerLatitude = markerLatitude;
+ this.markerLongitude = markerLongitude;
+ this.markerAltitude = markerAltitude;
+ this.drawable = drawable;
+ this.altitudeMode = altitudeMode;
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/data/MonsterParser.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/data/MonsterParser.java
new file mode 100644
index 0000000..5d689a8
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/data/MonsterParser.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3djava.markers.data;
+
+import com.google.android.gms.maps3d.model.AltitudeMode;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.json.JSONException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class MonsterParser {
+ public static List parse(String jsonString) throws JSONException {
+ List monsters = new ArrayList<>();
+ JSONArray jsonArray = new JSONArray(jsonString);
+ for (int i = 0; i < jsonArray.length(); i++) {
+ JSONObject obj = jsonArray.getJSONObject(i);
+
+ String altitudeModeStr = obj.optString("altitudeMode", "ABSOLUTE");
+ int parsedAltitudeMode;
+ switch (altitudeModeStr) {
+ case "RELATIVE_TO_GROUND":
+ parsedAltitudeMode = AltitudeMode.RELATIVE_TO_GROUND;
+ break;
+ case "CLAMP_TO_GROUND":
+ parsedAltitudeMode = AltitudeMode.CLAMP_TO_GROUND;
+ break;
+ case "RELATIVE_TO_MESH":
+ parsedAltitudeMode = AltitudeMode.RELATIVE_TO_MESH;
+ break;
+ case "ABSOLUTE":
+ default:
+ parsedAltitudeMode = AltitudeMode.ABSOLUTE;
+ break;
+ }
+
+ monsters.add(new Monster(
+ obj.getString("id"),
+ obj.getString("label"),
+ obj.getDouble("latitude"),
+ obj.getDouble("longitude"),
+ obj.getDouble("altitude"),
+ obj.getDouble("heading"),
+ obj.getDouble("tilt"),
+ obj.getDouble("range"),
+ obj.getDouble("markerLatitude"),
+ obj.getDouble("markerLongitude"),
+ obj.getDouble("markerAltitude"),
+ obj.getString("drawable"),
+ parsedAltitudeMode
+ ));
+ }
+ return monsters;
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/popovers/PopoversActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/popovers/PopoversActivity.java
new file mode 100644
index 0000000..dcca79a
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/popovers/PopoversActivity.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3djava.popovers;
+
+import android.graphics.Color;
+import android.graphics.Point;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.Popover;
+import com.google.android.gms.maps3d.model.AltitudeMode;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.CollisionBehavior;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+import com.google.android.gms.maps3d.model.Map3DMode;
+import com.google.android.gms.maps3d.model.Marker;
+import com.google.android.gms.maps3d.model.MarkerOptions;
+import com.google.android.gms.maps3d.model.PopoverOptions;
+import com.google.android.gms.maps3d.model.PopoverShadow;
+import com.google.android.gms.maps3d.model.PopoverStyle;
+
+public class PopoversActivity extends SampleBaseActivity {
+
+ private static final double CONTENT_LAT = 37.820642;
+ private static final double CONTENT_LNG = -122.478227;
+ private static final double CONTENT_ALT = 0.0;
+
+ private Popover popover = null;
+ private int popoverToggleCount = 0;
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "PopoversActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return com.example.maps3d.common.UtilitiesKt.toValidCamera(new Camera(
+ new LatLngAltitude(CONTENT_LAT, CONTENT_LNG, CONTENT_ALT),
+ 0.0,
+ 45.0,
+ 0.0,
+ 4075.0));
+ }
+
+ @Override
+ public void onMap3DViewReady(GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setOnMapReadyListener((map) -> {
+ googleMap3D.setOnMapReadyListener(null);
+ onMapReady(googleMap3D);
+ });
+ }
+
+ private void onMapReady(@NonNull GoogleMap3D googleMap3D) {
+ googleMap3D.setMapMode(Map3DMode.SATELLITE);
+ setupPopover(googleMap3D);
+ }
+
+ public void setupPopover(GoogleMap3D googleMap3D) {
+ LatLngAltitude center = new LatLngAltitude(37.819852, -122.478549, 0.0);
+
+ MarkerOptions markerOptions = new MarkerOptions();
+ markerOptions.setPosition(center);
+ markerOptions.setLabel("Golden Gate Bridge");
+ markerOptions.setZIndex(1);
+ markerOptions.setExtruded(true);
+ markerOptions.setDrawnWhenOccluded(true);
+ markerOptions.setCollisionBehavior(CollisionBehavior.REQUIRED);
+ markerOptions.setAltitudeMode(AltitudeMode.RELATIVE_TO_MESH);
+
+ Marker markerInGoldenGate = googleMap3D.addMarker(markerOptions);
+
+ if (markerInGoldenGate == null) {
+ Log.e(getTAG(), "Failed to create marker");
+ return;
+ } else {
+ Log.w(getTAG(), "Marker created");
+ }
+
+ PopoverShadow shadow = new PopoverShadow();
+ shadow.setColor(Color.argb(77, 0, 0, 0)); // 0.3 * 255 = 76.5 ~= 77
+ shadow.setOffsetX(2.0f);
+ shadow.setOffsetY(4.0f);
+ shadow.setRadius(4.0f);
+
+ PopoverStyle style = new PopoverStyle();
+ style.setPadding(20.0f);
+ style.setBackgroundColor(Color.WHITE);
+ style.setBorderRadius(8.0f);
+ style.setShadow(shadow);
+
+ PopoverOptions popoverOptions = new PopoverOptions();
+ popoverOptions.setContent(createGoldenGateInfoView());
+ popoverOptions.setPositionAnchor(markerInGoldenGate);
+ popoverOptions.setAutoPanEnabled(true);
+ popoverOptions.setAutoCloseEnabled(true);
+ popoverOptions.setAnchorOffset(new Point(0, 0));
+ popoverOptions.setPopoverStyle(style);
+
+ popover = googleMap3D.addPopover(popoverOptions);
+
+ markerInGoldenGate.setClickListener(() -> {
+ Log.d(getTAG(), "Marker clicked");
+ if (popoverToggleCount > 5) {
+ runOnUiThread(() -> {
+ if (popover != null) {
+ popover.remove();
+ }
+ });
+ Log.d(getTAG(), "Popover removed");
+ popoverToggleCount = 0;
+ } else {
+ Log.d(getTAG(), "Popover toggled");
+ runOnUiThread(() -> {
+ if (popover != null) {
+ popover.toggle();
+ }
+ });
+ popoverToggleCount++;
+ }
+ });
+
+ Log.d(getTAG(), "Popover created");
+ }
+
+ private View createGoldenGateInfoView() {
+ LinearLayout layout = new LinearLayout(this);
+ layout.setOrientation(LinearLayout.VERTICAL);
+ layout.setLayoutParams(new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
+
+ TextView titleView = new TextView(this);
+ titleView.setText("The Golden Gate Bridge");
+ titleView.setTextSize(18f);
+ titleView.setTextColor(Color.BLACK);
+ layout.addView(titleView);
+
+ TextView headlineView = new TextView(this);
+ headlineView.setText("San Francisco, CA");
+ headlineView.setTextSize(14f);
+ headlineView.setTextColor(Color.DKGRAY);
+ layout.addView(headlineView);
+
+ TextView contentView = new TextView(this);
+ contentView.setText("The Golden Gate Bridge is a suspension bridge\n" +
+ " spanning the one-mile-wide strait connecting\n" +
+ " San Francisco Bay and the Pacific Ocean.\n" +
+ " The bridge was completed in 1937.");
+ contentView.setTextSize(12f);
+ contentView.setTextColor(Color.GRAY);
+ layout.addView(contentView);
+
+ return layout;
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/sampleactivity/SampleBaseActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/sampleactivity/SampleBaseActivity.java
index b0b022c..d3439f9 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/sampleactivity/SampleBaseActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/sampleactivity/SampleBaseActivity.java
@@ -98,13 +98,13 @@ protected void onCreate(Bundle savedInstanceState) {
if (appBarLayout != null) {
appBarLayout.setPadding(0, statusBarInsets.top, 0, 0);
}
- androidx.constraintlayout.helper.widget.Flow buttonFlow = findViewById(R.id.button_flow);
- if (buttonFlow != null) {
- android.view.ViewGroup.MarginLayoutParams layoutParams = (android.view.ViewGroup.MarginLayoutParams) buttonFlow
+ View controlScrollView = findViewById(R.id.control_scroll_view);
+ if (controlScrollView != null) {
+ android.view.ViewGroup.MarginLayoutParams layoutParams = (android.view.ViewGroup.MarginLayoutParams) controlScrollView
.getLayoutParams();
- int margin8dp = (int) (8 * getResources().getDisplayMetrics().density);
- layoutParams.bottomMargin = navInsets.bottom + margin8dp;
- buttonFlow.setLayoutParams(layoutParams);
+ int margin16dp = (int) (16 * getResources().getDisplayMetrics().density);
+ layoutParams.bottomMargin = navInsets.bottom + margin16dp;
+ controlScrollView.setLayoutParams(layoutParams);
}
return WindowInsetsCompat.CONSUMED;
diff --git a/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/mainactivity/MainActivityTest.java b/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/mainactivity/MainActivityTest.java
new file mode 100644
index 0000000..609944f
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/mainactivity/MainActivityTest.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3djava.mainactivity;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+
+@RunWith(RobolectricTestRunner.class)
+public class MainActivityTest {
+
+ @Test
+ public void testSampleActivitiesContainsAllSamples() throws Exception {
+ MainActivity activity = new MainActivity();
+ Field field = MainActivity.class.getDeclaredField("sampleActivities");
+ field.setAccessible(true);
+ Map> samples = (Map>) field.get(activity);
+
+ assertThat(samples).hasSize(8);
+ assertThat(samples.values()).contains(com.example.maps3djava.popovers.PopoversActivity.class);
+ assertThat(samples.values()).contains(com.example.maps3djava.mapinteractions.MapInteractionsActivity.class);
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/markers/data/MonsterIntegrationTest.java b/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/markers/data/MonsterIntegrationTest.java
new file mode 100644
index 0000000..d38cc14
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/markers/data/MonsterIntegrationTest.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3djava.markers.data;
+
+import android.content.Context;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.common.truth.Truth;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = { 34 })
+public class MonsterIntegrationTest {
+
+ @Test
+ public void testParseActualMonstersJson() throws Exception {
+ Context context = ApplicationProvider.getApplicationContext();
+ String jsonString;
+ try (InputStream is = context.getAssets().open("monsters.json")) {
+ byte[] buffer = new byte[is.available()];
+ int bytesRead = is.read(buffer);
+ Truth.assertThat(bytesRead).isGreaterThan(0); // Ensure some bytes were read
+ jsonString = new String(buffer, StandardCharsets.UTF_8);
+ }
+
+ List monsters = MonsterParser.parse(jsonString);
+
+ Truth.assertThat(monsters.size()).isAtLeast(8);
+
+ Monster mothra = null;
+ for (Monster m : monsters) {
+ if ("mothra".equals(m.id)) {
+ mothra = m;
+ break;
+ }
+ }
+
+ Truth.assertThat(mothra).isNotNull();
+ Truth.assertThat(mothra.label).isEqualTo("Tokyo Tower Mothra");
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/markers/data/MonsterParserTest.java b/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/markers/data/MonsterParserTest.java
new file mode 100644
index 0000000..5acfd48
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/markers/data/MonsterParserTest.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3djava.markers.data;
+
+import com.google.android.gms.maps3d.model.AltitudeMode;
+import com.google.common.truth.Truth;
+import org.junit.Test;
+import org.json.JSONException;
+import java.util.List;
+
+public class MonsterParserTest {
+
+ @Test
+ public void testParseValidJson() throws JSONException {
+ String json = "[\n" +
+ " {\n" +
+ " \"id\": \"mothra\",\n" +
+ " \"label\": \"Mothra\",\n" +
+ " \"latitude\": 35.6586,\n" +
+ " \"longitude\": 139.7454,\n" +
+ " \"altitude\": 0.0,\n" +
+ " \"heading\": 45.0,\n" +
+ " \"tilt\": 45.0,\n" +
+ " \"range\": 1000.0,\n" +
+ " \"markerLatitude\": 35.6586,\n" +
+ " \"markerLongitude\": 139.7454,\n" +
+ " \"markerAltitude\": 0.0,\n" +
+ " \"drawable\": \"mothra_icon\",\n" +
+ " \"altitudeMode\": \"RELATIVE_TO_GROUND\"\n" +
+ " }\n" +
+ "]";
+
+ List result = MonsterParser.parse(json);
+
+ Truth.assertThat(result).hasSize(1);
+ Monster monster = result.get(0);
+ Truth.assertThat(monster.id).isEqualTo("mothra");
+ Truth.assertThat(monster.label).isEqualTo("Mothra");
+ Truth.assertThat(monster.latitude).isWithin(0.001).of(35.6586);
+ Truth.assertThat(monster.longitude).isWithin(0.001).of(139.7454);
+ Truth.assertThat(monster.drawable).isEqualTo("mothra_icon");
+ Truth.assertThat(monster.altitudeMode).isEqualTo(AltitudeMode.RELATIVE_TO_GROUND);
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
index 59e7df4..c03fc6a 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
@@ -120,6 +120,11 @@ android {
compose = true
buildConfig = true
}
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
}
dependencies {
@@ -135,7 +140,11 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
- testImplementation(libs.junit)
+ testImplementation(libs.junit) // "junit:junit:4.13.2"
+ testImplementation(libs.json) // "org.json:json:20251224"
+ testImplementation(libs.robolectric) // "org.robolectric:robolectric:4.16.1"
+ testImplementation(libs.androidx.core) // "androidx.test:core:1.7.0"
+ testImplementation(libs.truth) // "com.google.truth:truth:1.4.5"
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.uiautomator)
androidTestImplementation(libs.androidx.espresso.core)
@@ -145,7 +154,7 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
- implementation(libs.play.services.base)
+ implementation(libs.play.services.base) // "com.google.android.gms:play-services-base:18.10.0"
implementation(project(":Maps3DSamples:ApiDemos:common"))
}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/common/MapExtensions.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/common/MapExtensions.kt
new file mode 100644
index 0000000..16486a6
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/common/MapExtensions.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3dkotlin.common
+
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.model.FlyAroundOptions
+import com.google.android.gms.maps3d.model.FlyToOptions
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.coroutines.resume
+import kotlin.time.Duration
+
+/**
+ * Initiates a FlyTo camera animation and suspends until it finishes.
+ * If the coroutine is cancelled, the animation is stopped and listeners cleaned up.
+ */
+suspend fun GoogleMap3D.awaitCameraAnimation(options: FlyToOptions) {
+ suspendCancellableCoroutine { cont ->
+ // 1. Set the listener to resume the coroutine when the animation finishes
+ this.setCameraAnimationEndListener {
+ this.setCameraAnimationEndListener(null)
+ if (cont.isActive) cont.resume(Unit)
+ }
+
+ // 2. Handle cancellation (e.g., if the user leaves the screen or stops the tour)
+ cont.invokeOnCancellation {
+ this.stopCameraAnimation()
+ this.setCameraAnimationEndListener(null)
+ }
+
+ // 3. Start the animation
+ this.flyCameraTo(options)
+ }
+}
+
+/**
+ * Initiates a FlyAround camera animation and suspends until it finishes.
+ */
+suspend fun GoogleMap3D.awaitCameraAnimation(options: FlyAroundOptions) {
+ suspendCancellableCoroutine { cont ->
+ this.setCameraAnimationEndListener {
+ this.setCameraAnimationEndListener(null)
+ if (cont.isActive) cont.resume(Unit)
+ }
+
+ cont.invokeOnCancellation {
+ this.stopCameraAnimation()
+ this.setCameraAnimationEndListener(null)
+ }
+
+ this.flyCameraAround(options)
+ }
+}
+
+/**
+ * Suspends until the map reports it is "steady" (finished rendering 3D tiles),
+ * up to a maximum duration.
+ *
+ * @param timeout The maximum amount of time to wait.
+ * @return True if the map became steady before the timeout, false if it timed out.
+ */
+suspend fun GoogleMap3D.awaitMapSteady(timeout: Duration): Boolean {
+ // withTimeoutOrNull returns null if the timeout is reached before the block finishes
+ val result = withTimeoutOrNull(timeout) {
+ suspendCancellableCoroutine { cont ->
+ this@awaitMapSteady.setOnMapSteadyListener { isSteady ->
+ if (isSteady) {
+ this@awaitMapSteady.setOnMapSteadyListener(null)
+ if (cont.isActive) cont.resume(Unit)
+ }
+ }
+
+ cont.invokeOnCancellation {
+ this@awaitMapSteady.setOnMapSteadyListener(null)
+ }
+ }
+ }
+
+ // If result is not null, it means we successfully resumed from the steady state!
+ return result != null
+}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/markers/MarkersActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/markers/MarkersActivity.kt
index 4e85b6d..30e1de1 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/markers/MarkersActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/markers/MarkersActivity.kt
@@ -29,13 +29,21 @@ import com.google.android.gms.maps3d.model.Glyph
import com.google.android.gms.maps3d.model.ImageView
import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.Marker
+import com.google.android.gms.maps3d.model.Camera
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.flyToOptions
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.android.gms.maps3d.model.markerOptions
import com.google.android.gms.maps3d.model.pinConfiguration
+import com.google.android.gms.maps3d.model.popoverOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import com.example.maps3dkotlin.common.awaitCameraAnimation
+import com.example.maps3dkotlin.common.awaitMapSteady
+import com.example.maps3dkotlin.markers.data.MonsterParser
+import kotlin.time.Duration.Companion.seconds
/**
* This activity demonstrates the various altitude modes available for markers on a 3D map.
@@ -56,6 +64,7 @@ import kotlinx.coroutines.launch
*/
class MarkersActivity : SampleBaseActivity() {
override val TAG = MarkersActivity::class.java.simpleName
+ private var activePopover: com.google.android.gms.maps3d.Popover? = null
val berlinCamera = camera {
center = latLngAltitude {
@@ -79,16 +88,12 @@ class MarkersActivity : SampleBaseActivity() {
range = 1518.0
}
- val tokyoCamera = camera {
- center = latLngAltitude {
- latitude = 35.658708
- longitude = 139.702206
- altitude = 23.3
- }
- heading = 117.0
- tilt = 55.0
- range = 2868.0
- }
+ private var monsterCameras: List = emptyList()
+ private var monsterMarkers: List = emptyList()
+ private var monsterIds: List = emptyList()
+ private var monsterLabels: List = emptyList()
+
+ private var tourJob: kotlinx.coroutines.Job? = null
// The initial camera position is defined declaratively, providing a clear overview of
// the starting view of the map. This makes it easy to understand and modify the initial
@@ -104,9 +109,10 @@ class MarkersActivity : SampleBaseActivity() {
visibility = View.VISIBLE
}
setOnClickListener {
+ stopMonsterTour()
googleMap3D.flyCameraTo(flyToOptions {
endCamera = berlinCamera
- durationInMillis = 2_000
+ durationInMillis = 4_000
})
}
}
@@ -116,28 +122,135 @@ class MarkersActivity : SampleBaseActivity() {
visibility = View.VISIBLE
}
setOnClickListener {
+ stopMonsterTour()
googleMap3D.flyCameraTo(flyToOptions {
endCamera = nycCamera
- durationInMillis = 2_000
+ durationInMillis = 4_000
})
}
}
- findViewById