diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts
index 9556a50af..710097bd7 100644
--- a/demo/build.gradle.kts
+++ b/demo/build.gradle.kts
@@ -65,6 +65,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.material)
+ implementation(libs.play.services.location)
testImplementation(libs.junit)
testImplementation(libs.truth)
diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml
index a504a7619..1573afb00 100644
--- a/demo/src/main/AndroidManifest.xml
+++ b/demo/src/main/AndroidManifest.xml
@@ -67,6 +67,9 @@
+
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.kt
index 8dcb036e6..ab6713a04 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.kt
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.kt
@@ -58,7 +58,7 @@ internal fun keyHasValidFormat(apiKey: String): Boolean {
* @param context The context to retrieve the API key from.
* @return The API key if found, `null` otherwise.
*/
-private fun getMapsApiKey(context: Context): String? {
+fun getMapsApiKey(context: Context): String? {
try {
val bundle = context.packageManager
.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA)
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/IsochroneMapActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/IsochroneMapActivity.java
new file mode 100644
index 000000000..741d76c0c
--- /dev/null
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/IsochroneMapActivity.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2025 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.google.maps.android.utils.demo;
+
+import static com.google.maps.android.utils.demo.ApiKeyValidatorKt.getMapsApiKey;
+
+import android.Manifest;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.widget.FrameLayout;
+import android.widget.ProgressBar;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+
+import com.google.android.gms.location.FusedLocationProviderClient;
+import com.google.android.gms.location.LocationServices;
+import com.google.android.gms.maps.CameraUpdateFactory;
+import com.google.android.gms.maps.GoogleMap;
+import com.google.android.gms.maps.MapView;
+import com.google.android.gms.maps.OnMapReadyCallback;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.maps.android.isochrone.IsochroneMapProvider;
+
+public class IsochroneMapActivity extends AppCompatActivity implements OnMapReadyCallback {
+
+ private static final String TAG = "IsochroneMapActivity";
+
+ private FrameLayout rootLayout;
+ private MapView mapView;
+ private ProgressBar progressBar;
+
+ private GoogleMap map;
+ private FusedLocationProviderClient fusedLocationClient;
+
+ private ActivityResultLauncher requestPermissionLauncher;
+
+ private IsochroneMapProvider isochroneMapProvider;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ rootLayout = new FrameLayout(this);
+ mapView = new MapView(this);
+
+ progressBar = new ProgressBar(this);
+ progressBar.setIndeterminate(true);
+ progressBar.setVisibility(View.GONE);
+
+ FrameLayout.LayoutParams mapParams = new FrameLayout.LayoutParams(
+ LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT);
+
+ FrameLayout.LayoutParams progressParams = new FrameLayout.LayoutParams(
+ LayoutParams.WRAP_CONTENT,
+ LayoutParams.WRAP_CONTENT,
+ Gravity.CENTER);
+
+ rootLayout.addView(mapView, mapParams);
+ rootLayout.addView(progressBar, progressParams);
+
+ setContentView(rootLayout);
+
+ mapView.onCreate(savedInstanceState);
+ mapView.getMapAsync(this);
+
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
+
+ requestPermissionLauncher = registerForActivityResult(
+ new ActivityResultContracts.RequestPermission(),
+ isGranted -> {
+ if (isGranted) {
+ getLocationAndDraw();
+ } else {
+ Log.e(TAG, "Location permission denied");
+ }
+ });
+ }
+
+ @Override
+ public void onMapReady(@NonNull GoogleMap googleMap) {
+ map = googleMap;
+ map.getUiSettings().setZoomControlsEnabled(true);
+ checkPermissionAndStart();
+ }
+
+ private void checkPermissionAndStart() {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
+ != PackageManager.PERMISSION_GRANTED) {
+ requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION);
+ } else {
+ getLocationAndDraw();
+ }
+ }
+
+ private void getLocationAndDraw() {
+ // Permission check to satisfy lint and prevent security exceptions
+ if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED
+ && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
+ Log.e(TAG, "Location permission not granted");
+ return;
+ }
+
+ IsochroneMapProvider.UiThreadExecutor uiThreadExecutor = this::runOnUiThread;
+
+ fusedLocationClient.getLastLocation().addOnSuccessListener(location -> {
+ if (location != null) {
+ LatLng origin = new LatLng(location.getLatitude(), location.getLongitude());
+ map.moveCamera(CameraUpdateFactory.newLatLngZoom(origin, 14f));
+
+ if (isochroneMapProvider == null) {
+ String apiKey = getMapsApiKey(this);
+ isochroneMapProvider = new IsochroneMapProvider(
+ map,
+ apiKey,
+ new IsochroneMapProvider.LoadingListener() {
+ @Override
+ public void onLoadingStarted() {
+ runOnUiThread(() -> progressBar.setVisibility(View.VISIBLE));
+ }
+
+ @Override
+ public void onLoadingFinished() {
+ runOnUiThread(() -> progressBar.setVisibility(View.GONE));
+ }
+ },
+ IsochroneMapProvider.TransportMode.BICYCLING,
+ uiThreadExecutor,
+ null // Use default fetcher
+ );
+ }
+
+ isochroneMapProvider.drawIsochrones(origin, new int[]{2, 4, 6, 9}, IsochroneMapProvider.ColorSchema.GREEN_RED);
+
+ } else {
+ Log.e(TAG, "Location is null");
+ }
+ });
+ }
+
+ // MapView lifecycle methods
+ @Override protected void onResume() { super.onResume(); mapView.onResume(); }
+ @Override protected void onStart() { super.onStart(); mapView.onStart(); }
+ @Override protected void onStop() { super.onStop(); mapView.onStop(); }
+ @Override protected void onPause() { mapView.onPause(); super.onPause(); }
+ @Override protected void onDestroy() { mapView.onDestroy(); super.onDestroy(); }
+ @Override public void onLowMemory() { super.onLowMemory(); mapView.onLowMemory(); }
+}
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java
index 4f22adbfc..6e95a1446 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java
@@ -56,6 +56,7 @@ protected void onCreate(Bundle savedInstanceState) {
addDemo("Multi Layer", MultiLayerDemoActivity.class);
addDemo("AnimationUtil sample", AnimationUtilDemoActivity.class);
addDemo("Street View Demo", StreetViewDemoActivity.class);
+ addDemo("Isochrone Map", IsochroneMapActivity.class);
}
private void addDemo(String demoName, Class extends Activity> activityClass) {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ef54291e6..eba29410f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -19,6 +19,7 @@ mockk = "1.14.2"
lint = "31.10.1"
org-jacoco-core = "0.8.13"
material = "1.12.0"
+play-services-location = "21.3.0"
[libraries]
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@@ -47,4 +48,5 @@ lint = { module = "com.android.tools.lint:lint", version.ref = "lint" }
lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lint" }
testutils = { module = "com.android.tools:testutils", version.ref = "lint" }
org-jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "org-jacoco-core" }
-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
\ No newline at end of file
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "play-services-location" }
\ No newline at end of file
diff --git a/library/build.gradle.kts b/library/build.gradle.kts
index 38f34bd7f..a13b64c15 100644
--- a/library/build.gradle.kts
+++ b/library/build.gradle.kts
@@ -69,6 +69,7 @@ dependencies {
testImplementation(libs.kxml2)
testImplementation(libs.mockk)
testImplementation (libs.kotlin.test)
+ testImplementation(libs.mockito.core)
implementation(libs.kotlin.stdlib.jdk8)
}
diff --git a/library/src/main/java/com/google/maps/android/isochrone/IsochroneMapProvider.java b/library/src/main/java/com/google/maps/android/isochrone/IsochroneMapProvider.java
new file mode 100644
index 000000000..8040c15e0
--- /dev/null
+++ b/library/src/main/java/com/google/maps/android/isochrone/IsochroneMapProvider.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright 2025 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.google.maps.android.isochrone;
+
+import android.annotation.SuppressLint;
+import android.util.Log;
+
+import com.google.android.gms.maps.GoogleMap;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.android.gms.maps.model.PolygonOptions;
+
+import org.json.JSONObject;
+
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class IsochroneMapProvider {
+
+ public interface LoadingListener {
+ void onLoadingStarted();
+ void onLoadingFinished();
+ }
+
+ public enum ColorSchema {
+ GREEN_RED(new int[]{
+ 0xFF00FF00, // Green (innermost)
+ 0xFFFFFF00, // Yellow
+ 0xFFFFA500, // Orange
+ 0xFFFF0000 // Red (outermost)
+ });
+
+ private final int[] colors;
+
+ ColorSchema(int[] colors) {
+ this.colors = colors;
+ }
+
+ public int[] getColors() {
+ return colors;
+ }
+ }
+
+ public enum TransportMode {
+ BICYCLING("bicycling"),
+ DRIVING("driving"),
+ WALKING("walking"),
+ TRANSIT("transit");
+
+ private final String modeName;
+
+ TransportMode(String modeName) {
+ this.modeName = modeName;
+ }
+
+ public String getModeName() {
+ return modeName;
+ }
+ }
+
+ private static class IsochronePolygon {
+ int duration;
+ List points;
+ int baseColor;
+
+ IsochronePolygon(int duration, List points, int baseColor) {
+ this.duration = duration;
+ this.points = points;
+ this.baseColor = baseColor;
+ }
+ }
+
+ private static final String TAG = "IsochroneMapProvider";
+ private static final int SLICES = 36;
+ private static final int MAX_CYCLES = 10;
+ private static final double EPSILON = 1e-5;
+ private static final double METERS_PER_DEGREE = 111000.0;
+ private static final double METERS_PER_MINUTE = 250;
+
+ private final GoogleMap map;
+ private final String apiKey;
+ private final LoadingListener loadingListener;
+ private TransportMode transportMode;
+ private final TravelTimeFetcher travelTimeFetcher;
+
+ public interface TravelTimeFetcher {
+ int fetchTravelTime(LatLng origin, LatLng dest);
+ }
+
+ private final UiThreadExecutor uiThreadExecutor;
+
+ public interface UiThreadExecutor {
+ void execute(Runnable runnable);
+ }
+
+ private final TravelTimeFetcher DEFAULT_TRAVEL_TIME_FETCHER = new TravelTimeFetcher() {
+ @Override
+ public int fetchTravelTime(LatLng origin, LatLng dest) {
+ try {
+ @SuppressLint("DefaultLocale") String urlString = String.format(
+ "https://maps.googleapis.com/maps/api/directions/json?origin=%f,%f&destination=%f,%f&mode=%s&key=%s",
+ origin.latitude, origin.longitude,
+ dest.latitude, dest.longitude,
+ transportMode.getModeName(),
+ apiKey);
+
+ HttpURLConnection connection = (HttpURLConnection) new URL(urlString).openConnection();
+ connection.setRequestMethod("GET");
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+
+ InputStreamReader reader = new InputStreamReader(connection.getInputStream());
+ StringBuilder responseBuilder = new StringBuilder();
+ int c;
+ while ((c = reader.read()) != -1) {
+ responseBuilder.append((char) c);
+ }
+ reader.close();
+
+ JSONObject json = new JSONObject(responseBuilder.toString());
+ return json.getJSONArray("routes")
+ .getJSONObject(0)
+ .getJSONArray("legs")
+ .getJSONObject(0)
+ .getJSONObject("duration")
+ .getInt("value");
+ } catch (Exception e) {
+ Log.e(TAG, "Error fetching travel time", e);
+ return -1;
+ }
+ }
+ };
+
+ public IsochroneMapProvider(GoogleMap map,
+ String apiKey,
+ LoadingListener loadingListener,
+ TransportMode transportMode) {
+ this(map, apiKey, loadingListener, transportMode, null, null);
+ }
+
+ public IsochroneMapProvider(GoogleMap map,
+ String apiKey,
+ LoadingListener loadingListener,
+ TransportMode transportMode,
+ UiThreadExecutor uiThreadExecutor,
+ TravelTimeFetcher travelTimeFetcher) {
+ this.map = map;
+ this.apiKey = apiKey;
+ this.loadingListener = loadingListener;
+ this.transportMode = transportMode;
+ this.uiThreadExecutor = uiThreadExecutor;
+ this.travelTimeFetcher = travelTimeFetcher != null ? travelTimeFetcher : DEFAULT_TRAVEL_TIME_FETCHER;
+ }
+
+ public void setTransportMode(TransportMode transportMode) {
+ this.transportMode = transportMode;
+ }
+
+ public void drawIsochrones(LatLng origin, int[] durationsInMinutes, ColorSchema schema) {
+ int[] sortedDurations = durationsInMinutes.clone();
+ java.util.Arrays.sort(sortedDurations);
+ reverseArray(sortedDurations);
+
+ if (loadingListener != null) {
+ loadingListener.onLoadingStarted();
+ }
+
+ int[] colors = schema.getColors();
+ List polygons = Collections.synchronizedList(new ArrayList<>());
+
+ ExecutorService executor = Executors.newFixedThreadPool(4);
+ for (int i = 0; i < sortedDurations.length; i++) {
+ final int durationIndex = sortedDurations.length - 1 - i; // reverse index
+ final int minutes = sortedDurations[i];
+ final int baseColor = (durationIndex < colors.length) ? colors[durationIndex] : colors[colors.length - 1];
+
+ executor.execute(() -> {
+ List polygon = computeIsochrone(origin, minutes);
+ if (!polygon.isEmpty()) {
+ polygons.add(new IsochronePolygon(minutes, polygon, baseColor));
+ }
+ });
+ }
+
+ executor.shutdown();
+
+ new Thread(() -> {
+ try {
+ while (!executor.isTerminated()) {
+ Thread.sleep(100);
+ }
+ Collections.sort(polygons, (a, b) -> Integer.compare(b.duration, a.duration));
+
+ runOnUiThread(() -> {
+ for (IsochronePolygon poly : polygons) {
+ drawPolygon(poly.points, poly.baseColor);
+ }
+ if (loadingListener != null) {
+ loadingListener.onLoadingFinished();
+ }
+ });
+ } catch (InterruptedException e) {
+ Log.e(TAG, "Interrupted while waiting for executor", e);
+ }
+ }).start();
+ }
+
+ List computeIsochrone(LatLng origin, int minutes) {
+ final List points = Collections.synchronizedList(new ArrayList<>());
+ final int maxTravelTimeSec = minutes * 60;
+
+ ExecutorService executor = Executors.newFixedThreadPool(4);
+ for (int slice = 0; slice < SLICES; slice++) {
+ final double angleRad = 2 * Math.PI * slice / SLICES;
+ executor.execute(() -> {
+ try {
+ double minRadius = 0.0;
+ double maxRadius = (METERS_PER_MINUTE * minutes) / METERS_PER_DEGREE;
+ double bestRadius = minRadius;
+
+ for (int cycle = 0; cycle < MAX_CYCLES; cycle++) {
+ double midRadius = (minRadius + maxRadius) / 2;
+ double latOffset = midRadius * Math.cos(angleRad);
+ double lngOffset = midRadius * Math.sin(angleRad) / Math.cos(Math.toRadians(origin.latitude));
+ LatLng dest = new LatLng(origin.latitude + latOffset, origin.longitude + lngOffset);
+ int travelTime = travelTimeFetcher.fetchTravelTime(origin, dest);
+ if (travelTime < 0) break;
+ if (travelTime <= maxTravelTimeSec) {
+ bestRadius = midRadius;
+ minRadius = midRadius;
+ } else {
+ maxRadius = midRadius;
+ }
+ if ((maxRadius - minRadius) < EPSILON) break;
+ }
+
+ double finalLatOffset = bestRadius * Math.cos(angleRad);
+ double finalLngOffset = bestRadius * Math.sin(angleRad) / Math.cos(Math.toRadians(origin.latitude));
+ LatLng finalPoint = new LatLng(origin.latitude + finalLatOffset, origin.longitude + finalLngOffset);
+ points.add(finalPoint);
+ } catch (Exception e) {
+ Log.e(TAG, "Error computing slice", e);
+ }
+ });
+ }
+
+ executor.shutdown();
+ try {
+ while (!executor.isTerminated()) {
+ Thread.sleep(50);
+ }
+ } catch (InterruptedException e) {
+ Log.e(TAG, "Interrupted while computing isochrone", e);
+ }
+
+ if (!points.isEmpty()) {
+ points.add(points.get(0));
+ return chaikinSmoothing(points, 2);
+ }
+ return new ArrayList<>();
+ }
+
+ private void drawPolygon(List points, int baseColor) {
+ int fillColor = (baseColor & 0x00FFFFFF) | (0x33 << 24);
+ int strokeColor = baseColor | 0xFF000000;
+ map.addPolygon(new PolygonOptions()
+ .addAll(points)
+ .strokeColor(strokeColor)
+ .fillColor(fillColor));
+ }
+
+ private List chaikinSmoothing(List input, int iterations) {
+ List output = new ArrayList<>(input);
+ for (int iter = 0; iter < iterations; iter++) {
+ List newPoints = new ArrayList<>();
+ for (int i = 0; i < output.size() - 1; i++) {
+ LatLng p0 = output.get(i);
+ LatLng p1 = output.get(i + 1);
+ LatLng Q = new LatLng(0.75 * p0.latitude + 0.25 * p1.latitude, 0.75 * p0.longitude + 0.25 * p1.longitude);
+ LatLng R = new LatLng(0.25 * p0.latitude + 0.75 * p1.latitude, 0.25 * p0.longitude + 0.75 * p1.longitude);
+ newPoints.add(Q);
+ newPoints.add(R);
+ }
+ newPoints.add(newPoints.get(0));
+ output = newPoints;
+ }
+ return output;
+ }
+
+ private void runOnUiThread(Runnable runnable) {
+ if (uiThreadExecutor != null) {
+ uiThreadExecutor.execute(runnable);
+ } else {
+ Log.e(TAG, "UI thread executor not set! Cannot run UI code.");
+ }
+ }
+
+ private void reverseArray(int[] array) {
+ int left = 0, right = array.length - 1;
+ while (left < right) {
+ int temp = array[left];
+ array[left] = array[right];
+ array[right] = temp;
+ left++;
+ right--;
+ }
+ }
+}
diff --git a/library/src/test/java/com/google/maps/android/isochrone/IsochroneMapProviderTest.java b/library/src/test/java/com/google/maps/android/isochrone/IsochroneMapProviderTest.java
new file mode 100644
index 000000000..77ebd5411
--- /dev/null
+++ b/library/src/test/java/com/google/maps/android/isochrone/IsochroneMapProviderTest.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2025 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.google.maps.android.isochrone;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import com.google.android.gms.maps.GoogleMap;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.android.gms.maps.model.Polygon;
+import com.google.android.gms.maps.model.PolygonOptions;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+public class IsochroneMapProviderTest {
+
+ private GoogleMap mockMap;
+ private IsochroneMapProvider.LoadingListener mockLoadingListener;
+ private IsochroneMapProvider.TravelTimeFetcher mockTravelTimeFetcher;
+
+ @Before
+ public void setup() {
+ mockMap = mock(GoogleMap.class);
+ mockLoadingListener = mock(IsochroneMapProvider.LoadingListener.class);
+ mockTravelTimeFetcher = mock(IsochroneMapProvider.TravelTimeFetcher.class);
+ }
+
+ @Test
+ public void testComputeIsochroneReturnsPoints() {
+ when(mockTravelTimeFetcher.fetchTravelTime(any(LatLng.class), any(LatLng.class))).thenReturn(60);
+
+ // For testing, just run directly
+ IsochroneMapProvider provider = new IsochroneMapProvider(
+ mockMap,
+ "FAKE_API_KEY",
+ mockLoadingListener,
+ IsochroneMapProvider.TransportMode.BICYCLING,
+ Runnable::run,
+ null // or a mock TravelTimeFetcher if needed
+ );
+
+ LatLng origin = new LatLng(0, 0);
+ List polygon = provider.computeIsochrone(origin, 10);
+
+ assertNotNull(polygon);
+ assertTrue(polygon.size() > 3); // polygon points + closing point
+ }
+
+ @Test
+ public void testDrawIsochronesCallsLoadingListener() throws InterruptedException {
+ when(mockTravelTimeFetcher.fetchTravelTime(any(LatLng.class), any(LatLng.class))).thenReturn(60);
+
+ // For testing, just run directly
+ IsochroneMapProvider provider = new IsochroneMapProvider(
+ mockMap,
+ "FAKE_API_KEY",
+ mockLoadingListener,
+ IsochroneMapProvider.TransportMode.BICYCLING,
+ Runnable::run,
+ null
+ );
+
+ CountDownLatch latch = new CountDownLatch(1);
+
+ doAnswer(invocation -> {
+ latch.countDown();
+ return null;
+ }).when(mockLoadingListener).onLoadingFinished();
+
+ // Run UI tasks immediately (synchronously)
+
+
+ provider.drawIsochrones(new LatLng(0, 0), new int[]{5, 10}, IsochroneMapProvider.ColorSchema.GREEN_RED);
+
+ boolean finished = latch.await(5, TimeUnit.SECONDS);
+
+ assertTrue("Loading finished callback should be called", finished);
+
+ verify(mockLoadingListener).onLoadingStarted();
+ verify(mockLoadingListener).onLoadingFinished();
+ }
+
+ @Test
+ public void testPolygonsDrawnInCorrectOrder() throws InterruptedException {
+ when(mockTravelTimeFetcher.fetchTravelTime(any(LatLng.class), any(LatLng.class))).thenReturn(60);
+
+ IsochroneMapProvider provider = new IsochroneMapProvider(
+ mockMap,
+ "FAKE_API_KEY",
+ mockLoadingListener,
+ IsochroneMapProvider.TransportMode.BICYCLING,
+ Runnable::run,
+ null
+ );
+
+
+ Polygon mockPolygon = mock(Polygon.class);
+ when(mockMap.addPolygon(any(PolygonOptions.class))).thenReturn(mockPolygon);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PolygonOptions.class);
+
+ provider.drawIsochrones(new LatLng(0, 0), new int[]{10, 5}, IsochroneMapProvider.ColorSchema.GREEN_RED);
+
+ Thread.sleep(1000);
+
+ verify(mockMap, times(2)).addPolygon(captor.capture());
+
+ List drawnPolygons = captor.getAllValues();
+
+ assertEquals(2, drawnPolygons.size());
+
+ int outerFillColor = drawnPolygons.get(0).getFillColor();
+ int innerFillColor = drawnPolygons.get(1).getFillColor();
+
+ int outerRed = (outerFillColor >> 16) & 0xFF;
+ int outerGreen = (outerFillColor >> 8) & 0xFF;
+
+ int innerRed = (innerFillColor >> 16) & 0xFF;
+ int innerGreen = (innerFillColor >> 8) & 0xFF;
+
+ System.out.printf("Outer polygon fill color: 0x%08X (R:%d G:%d)%n", outerFillColor, outerRed, outerGreen);
+ System.out.printf("Inner polygon fill color: 0x%08X (R:%d G:%d)%n", innerFillColor, innerRed, innerGreen);
+
+ // Outer polygon expected to be yellow (high red and green)
+ assertTrue("Outer polygon red should be >= inner polygon red", outerRed >= innerRed);
+ assertTrue("Outer polygon green should be >= inner polygon green", outerGreen >= innerGreen);
+
+ // Inner polygon expected to be green (high green, low red)
+ assertTrue("Inner polygon green should be > outer polygon green", innerGreen > outerGreen || innerGreen == outerGreen);
+ assertTrue("Inner polygon red should be <= outer polygon red", innerRed <= outerRed);
+ }
+
+}