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 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); + } + +}