diff --git a/LOG.md b/LOG.md new file mode 100644 index 0000000..400812e --- /dev/null +++ b/LOG.md @@ -0,0 +1,8 @@ + +### Task: Implement Monster Tour and Long Press popup (Project: Maps3D Samples) +* **Start:** [Unknown] (Branch: main) +* **End:** $(date +"%I:%M:%S %p %Z") +* **Time Spent:** [Unknown] +* **Purpose:** The user wanted to automate the traversal of the various monster markers in the map, and add a quick-select popup menu to the random button, configuring properties like altitude mode via JSON. +* **Summary of Work:** Shifted marker definitions to a central `monsters.json` data file. Implemented an asynchronous touring sequence in both Kotlin (Coroutines) and Java (Handlers) to fly to, orbit, and display info popovers for each character. Wired up a standard `android.widget.PopupMenu` for the random button long-press. Adjusted altitude modes in JSON. +* **Status:** **[IN PROGRESS (Unmerged)]** diff --git a/Maps3DSamples/ApiDemos/common/build.gradle.kts b/Maps3DSamples/ApiDemos/common/build.gradle.kts index 1f615e6..8cc0ae3 100644 --- a/Maps3DSamples/ApiDemos/common/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/common/build.gradle.kts @@ -69,6 +69,6 @@ dependencies { implementation(libs.androidx.material3) - api(libs.play.services.base) - api(libs.play.services.maps3d) + api(libs.play.services.base) // "com.google.android.gms:play-services-base:18.10.0" + api(libs.play.services.maps3d) // "com.google.android.gms:play-services-maps3d:0.2.0" } diff --git a/Maps3DSamples/ApiDemos/common/src/main/assets/monsters.json b/Maps3DSamples/ApiDemos/common/src/main/assets/monsters.json new file mode 100644 index 0000000..aeb07f3 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/assets/monsters.json @@ -0,0 +1,130 @@ +[ + { + "id": "alien", + "label": "Devil's Tower Alien", + "blurb": "They didn't just come to sculpt mashed potatoes.", + "drawable": "alien", + "latitude": 44.589994, + "longitude": -104.715326, + "altitude": 1508.9, + "markerLatitude": 44.59054845363309, + "markerLongitude": -104.715177415273, + "markerAltitude": 10.0, + "heading": 1.0, + "tilt": 75.0, + "range": 1635.0, + "altitudeMode": "RELATIVE_TO_MESH" + }, + { + "id": "bigfoot", + "label": "Mt. St. Helens Bigfoot", + "blurb": "Leaves some pretty big footprints, very camera shy.", + "drawable": "bigfoot", + "latitude": 46.199837, + "longitude": -122.205541, + "altitude": 2272.5, + "markerLatitude": 46.199837, + "markerLongitude": -122.205541, + "markerAltitude": 2272.5, + "heading": 318.0, + "tilt": 58.0, + "range": 6088.0, + "altitudeMode": "ABSOLUTE" + }, + { + "id": "frank", + "label": "Castle Frankenstein", + "blurb": "It's alive! Quite misunderstood, honestly.", + "drawable": "frank", + "latitude": 49.793566, + "longitude": 8.669903, + "altitude": 380.1, + "markerLatitude": 49.793570448322434, + "markerLongitude": 8.668253367313627, + "markerAltitude": 100.0, + "heading": 90.0, + "tilt": 45.0, + "range": 1246.0, + "altitudeMode": "RELATIVE_TO_MESH" + }, + { + "id": "godzilla", + "label": "Tokyo Bay Godzilla", + "blurb": "King of the Monsters. Loves a quick swim in the bay.", + "drawable": "godzilla", + "latitude": 35.5391, + "longitude": 139.8001, + "altitude": 100.0, + "markerLatitude": 35.5391, + "markerLongitude": 139.8001, + "markerAltitude": 100.0, + "heading": 0.0, + "tilt": 60.0, + "range": 3000.0, + "altitudeMode": "RELATIVE_TO_GROUND" + }, + { + "id": "mothra", + "label": "Tokyo Tower Mothra", + "blurb": "Like a butterfly, but much, much bigger. Attracted to bright lights.", + "drawable": "mothra", + "latitude": 35.658588, + "longitude": 139.745496, + "altitude": 247.9, + "markerLatitude": 35.658588, + "markerLongitude": 139.745496, + "markerAltitude": 333.0, + "heading": 270.0, + "tilt": 48.0, + "range": 810.0, + "altitudeMode": "RELATIVE_TO_GROUND" + }, + { + "id": "mummy", + "label": "Giza Mummy", + "blurb": "Woke up on the wrong side of the sarcophagus.", + "drawable": "mummy", + "latitude": 29.9792, + "longitude": 31.1342, + "altitude": 150.0, + "markerLatitude": 29.9792, + "markerLongitude": 31.1342, + "markerAltitude": 150.0, + "heading": 45.0, + "tilt": 60.0, + "range": 1500.0, + "altitudeMode": "ABSOLUTE" + }, + { + "id": "nessie", + "label": "Loch Ness Monster", + "blurb": "The original cryptid. Probably just a log, but we want to believe.", + "drawable": "nessie", + "latitude": 57.320312, + "longitude": -4.43019, + "altitude": 14.4, + "markerLatitude": 57.320312, + "markerLongitude": -4.43019, + "markerAltitude": 14.4, + "heading": 0.0, + "tilt": 45.0, + "range": 20303.0, + "altitudeMode": "ABSOLUTE" + }, + { + "id": "yeti", + "label": "Everest Yeti", + "blurb": "Abominable? More like adorable if you get to know him.", + "drawable": "yeti", + "latitude": 27.9881, + "longitude": 86.925, + "altitude": 8848.0, + "markerLatitude": 27.9881, + "markerLongitude": 86.925, + "markerAltitude": 8848.0, + "heading": 0.0, + "tilt": 46.0, + "range": 5000.0, + "altitudeMode": "ABSOLUTE" + } +] \ No newline at end of file diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/alien.png b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/alien.png new file mode 100644 index 0000000..2c38fc9 Binary files /dev/null and b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/alien.png differ diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/bigfoot.png b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/bigfoot.png new file mode 100644 index 0000000..30549f3 Binary files /dev/null and b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/bigfoot.png differ diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/frank.png b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/frank.png new file mode 100644 index 0000000..33b2ecc Binary files /dev/null and b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/frank.png differ diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/gz.png b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/godzilla.png similarity index 100% rename from Maps3DSamples/ApiDemos/common/src/main/res/drawable/gz.png rename to Maps3DSamples/ApiDemos/common/src/main/res/drawable/godzilla.png diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/mothra.png b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/mothra.png new file mode 100644 index 0000000..6310539 Binary files /dev/null and b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/mothra.png differ diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/mummy.png b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/mummy.png new file mode 100644 index 0000000..956a15f Binary files /dev/null and b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/mummy.png differ diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/nessie.png b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/nessie.png new file mode 100644 index 0000000..1284b3a Binary files /dev/null and b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/nessie.png differ diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/pill_background.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/pill_background.xml new file mode 100644 index 0000000..72e6081 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/pill_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/yeti.png b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/yeti.png new file mode 100644 index 0000000..5a0fcd6 Binary files /dev/null and b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/yeti.png differ diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_common_map.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_common_map.xml index 3c13b57..c745298 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_common_map.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_common_map.xml @@ -69,86 +69,119 @@ app:layout_constraintTop_toTopOf="parent" /> - + + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginBottom="16dp" + android:scrollbars="none" + android:clipToPadding="false" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"> - + - + - + - + - + - + + + + + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml index 72edd54..7c36e96 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml @@ -90,4 +90,19 @@ Zoo time Hiking time! Model clicked + + + They didn\'t just come to sculpt mashed potatoes. πŸ‘½ + Leaves some pretty big footprints, very camera shy. πŸ‘£ + It\'s alive! Quite misunderstood, honestly. ⚑ + King of the Monsters. Loves a quick swim in the bay. πŸ¦– + Like a butterfly, but much, much bigger. Attracted to bright lights. πŸ¦‹ + Woke up on the wrong side of the sarcophagus. ⚰️ + The original cryptid. Probably just a log, but we want to believe. πŸ¦• + Abominable? More like adorable if you get to know him. πŸ”οΈ + I am a Giant Ape! 🦍 + Tour Monsters + Fly to Random Monster + Fly to Berlin + Fly to NYC diff --git a/Maps3DSamples/ApiDemos/gradle/libs.versions.toml b/Maps3DSamples/ApiDemos/gradle/libs.versions.toml index 80555b3..69ea558 100644 --- a/Maps3DSamples/ApiDemos/gradle/libs.versions.toml +++ b/Maps3DSamples/ApiDemos/gradle/libs.versions.toml @@ -3,16 +3,16 @@ compileSdk = "36" minSdk = "26" targetSdk = "36" -activityCompose = "1.12.3" +activityCompose = "1.12.4" agp = "8.13.2" appcompat = "1.7.1" -composeBom = "2026.01.01" +composeBom = "2026.02.01" coreKtx = "1.17.0" desugar_jdk_libs = "2.1.5" espressoCore = "3.7.0" junit = "4.13.2" junitVersion = "1.3.0" -kotlin = "2.3.0" +kotlin = "2.2.0" lifecycleRuntimeKtx = "2.10.0" material = "1.13.0" @@ -21,6 +21,8 @@ playServicesMaps3d = "0.2.0" secretsGradlePlugin = "2.0.1" truth = "1.4.5" uiautomator = "2.3.0" +robolectric = "4.16.1" +androidxTestCore = "1.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -44,6 +46,8 @@ play-services-base = { module = "com.google.android.gms:play-services-base", ver play-services-maps3d = { module = "com.google.android.gms:play-services-maps3d", version.ref = "playServicesMaps3d" } truth = { module = "com.google.truth:truth", version.ref = "truth" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts index 73cccaa..78cc3b2 100644 --- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts @@ -111,6 +111,11 @@ android { buildFeatures { buildConfig = true } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -126,10 +131,14 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - 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")) - 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(project(":Maps3DSamples:ApiDemos:common")) diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml b/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml index 712dbc1..ddc9cc0 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml @@ -88,6 +88,20 @@ android:exported="true" /> + + + + diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/common/MapUtils.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/common/MapUtils.java new file mode 100644 index 0000000..de79e82 --- /dev/null +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/common/MapUtils.java @@ -0,0 +1,99 @@ +/* + * 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.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 java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class MapUtils { + + // A single threaded executor specifically for handling timeouts + private static final ScheduledExecutorService scheduler = + Executors.newScheduledThreadPool(1); + + /** + * Returns a CompletableFuture that resolves when the camera animation completes. + */ + public static CompletableFuture awaitCameraAnimation(GoogleMap3D googleMap3D, FlyToOptions options) { + CompletableFuture future = new CompletableFuture<>(); + + googleMap3D.setCameraAnimationEndListener(() -> { + googleMap3D.setCameraAnimationEndListener(null); + if (!future.isDone()) { + future.complete(null); + } + }); + + googleMap3D.flyCameraTo(options); + return future; + } + + /** + * Returns a CompletableFuture that resolves when the camera orbit animation completes. + */ + public static CompletableFuture awaitCameraAnimation(GoogleMap3D googleMap3D, FlyAroundOptions options) { + CompletableFuture future = new CompletableFuture<>(); + + googleMap3D.setCameraAnimationEndListener(() -> { + googleMap3D.setCameraAnimationEndListener(null); + if (!future.isDone()) { + future.complete(null); + } + }); + + googleMap3D.flyCameraAround(options); + return future; + } + + /** + * Returns a CompletableFuture that resolves to true when the map is steady, + * or false if the specified timeout is reached. + */ + public static CompletableFuture awaitMapSteady( + GoogleMap3D googleMap3D, + long timeout, + TimeUnit unit) { + + CompletableFuture 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