Fix issue where map updates don't take effect in Flutter v3.0.0 (#5787)

diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md
index 57d24e2..b4fb662 100644
--- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md
+++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md
@@ -1,5 +1,6 @@
-## NEXT
+## 2.1.6
 
+* Fixes issue in Flutter v3.0.0 where some updates to the map don't take effect on Android.
 * Fixes iOS native unit tests on M1 devices.
 * Minor fixes for new analysis options.
 
diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java
index 9b88103..2c2287c 100644
--- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java
+++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java
@@ -12,9 +12,11 @@
 import android.graphics.Point;
 import android.os.Bundle;
 import android.util.Log;
+import android.view.Choreographer;
 import android.view.View;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 import androidx.lifecycle.DefaultLifecycleObserver;
 import androidx.lifecycle.Lifecycle;
 import androidx.lifecycle.LifecycleOwner;
@@ -109,6 +111,11 @@
     return mapView;
   }
 
+  @VisibleForTesting
+  /*package*/ void setView(MapView view) {
+    mapView = view;
+  }
+
   void init() {
     lifecycleProvider.getLifecycle().addObserver(this);
     mapView.getMapAsync(this);
@@ -126,6 +133,58 @@
     return trackCameraPosition ? googleMap.getCameraPosition() : null;
   }
 
+  private boolean loadedCallbackPending = false;
+
+  /**
+   * Invalidates the map view after the map has finished rendering.
+   *
+   * <p>gmscore GL renderer uses a {@link android.view.TextureView}. Android platform views that are
+   * displayed as a texture after Flutter v3.0.0. require that the view hierarchy is notified after
+   * all drawing operations have been flushed.
+   *
+   * <p>Since the GL renderer doesn't use standard Android views, and instead uses GL directly, we
+   * notify the view hierarchy by invalidating the view.
+   *
+   * <p>Unfortunately, when {@link GoogleMap.OnMapLoadedCallback} is fired, the texture may not have
+   * been updated yet.
+   *
+   * <p>To workaround this limitation, wait two frames. This ensures that at least the frame budget
+   * (16.66ms at 60hz) have passed since the drawing operation was issued.
+   */
+  private void invalidateMapIfNeeded() {
+    if (googleMap == null || loadedCallbackPending) {
+      return;
+    }
+    loadedCallbackPending = true;
+    googleMap.setOnMapLoadedCallback(
+        new GoogleMap.OnMapLoadedCallback() {
+          @Override
+          public void onMapLoaded() {
+            loadedCallbackPending = false;
+            postFrameCallback(
+                () -> {
+                  postFrameCallback(
+                      () -> {
+                        if (mapView != null) {
+                          mapView.invalidate();
+                        }
+                      });
+                });
+          }
+        });
+  }
+
+  private static void postFrameCallback(Runnable f) {
+    Choreographer.getInstance()
+        .postFrameCallback(
+            new Choreographer.FrameCallback() {
+              @Override
+              public void doFrame(long frameTimeNanos) {
+                f.run();
+              }
+            });
+  }
+
   @Override
   public void onMapReady(GoogleMap googleMap) {
     this.googleMap = googleMap;
@@ -244,6 +303,7 @@
         }
       case "markers#update":
         {
+          invalidateMapIfNeeded();
           List<Object> markersToAdd = call.argument("markersToAdd");
           markersController.addMarkers(markersToAdd);
           List<Object> markersToChange = call.argument("markersToChange");
@@ -273,6 +333,7 @@
         }
       case "polygons#update":
         {
+          invalidateMapIfNeeded();
           List<Object> polygonsToAdd = call.argument("polygonsToAdd");
           polygonsController.addPolygons(polygonsToAdd);
           List<Object> polygonsToChange = call.argument("polygonsToChange");
@@ -284,6 +345,7 @@
         }
       case "polylines#update":
         {
+          invalidateMapIfNeeded();
           List<Object> polylinesToAdd = call.argument("polylinesToAdd");
           polylinesController.addPolylines(polylinesToAdd);
           List<Object> polylinesToChange = call.argument("polylinesToChange");
@@ -295,6 +357,7 @@
         }
       case "circles#update":
         {
+          invalidateMapIfNeeded();
           List<Object> circlesToAdd = call.argument("circlesToAdd");
           circlesController.addCircles(circlesToAdd);
           List<Object> circlesToChange = call.argument("circlesToChange");
@@ -374,12 +437,17 @@
         }
       case "map#setStyle":
         {
-          String mapStyle = (String) call.arguments;
+          invalidateMapIfNeeded();
           boolean mapStyleSet;
-          if (mapStyle == null) {
-            mapStyleSet = googleMap.setMapStyle(null);
+          if (call.arguments instanceof String) {
+            String mapStyle = (String) call.arguments;
+            if (mapStyle == null) {
+              mapStyleSet = googleMap.setMapStyle(null);
+            } else {
+              mapStyleSet = googleMap.setMapStyle(new MapStyleOptions(mapStyle));
+            }
           } else {
-            mapStyleSet = googleMap.setMapStyle(new MapStyleOptions(mapStyle));
+            mapStyleSet = googleMap.setMapStyle(null);
           }
           ArrayList<Object> mapStyleResult = new ArrayList<>(2);
           mapStyleResult.add(mapStyleSet);
@@ -392,6 +460,7 @@
         }
       case "tileOverlays#update":
         {
+          invalidateMapIfNeeded();
           List<Map<String, ?>> tileOverlaysToAdd = call.argument("tileOverlaysToAdd");
           tileOverlaysController.addTileOverlays(tileOverlaysToAdd);
           List<Map<String, ?>> tileOverlaysToChange = call.argument("tileOverlaysToChange");
@@ -403,6 +472,7 @@
         }
       case "tileOverlays#clearTileCache":
         {
+          invalidateMapIfNeeded();
           String tileOverlayId = call.argument("tileOverlayId");
           tileOverlaysController.clearTileCache(tileOverlayId);
           result.success(null);
diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java
index 6bda085..d8082b5 100644
--- a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java
+++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java
@@ -6,16 +6,24 @@
 
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 
 import android.content.Context;
 import android.os.Build;
 import androidx.activity.ComponentActivity;
 import androidx.test.core.app.ApplicationProvider;
 import com.google.android.gms.maps.GoogleMap;
+import com.google.android.gms.maps.MapView;
 import io.flutter.plugin.common.BinaryMessenger;
+import io.flutter.plugin.common.MethodCall;
+import io.flutter.plugin.common.MethodChannel;
+import java.util.HashMap;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.Robolectric;
@@ -58,4 +66,83 @@
     googleMapController.onDestroy(activity);
     assertNull(googleMapController.getView());
   }
+
+  @Test
+  public void InvalidateMapAfterMethodCalls() throws InterruptedException {
+    String[] methodsThatTriggerInvalidation = {
+      "markers#update",
+      "polygons#update",
+      "polylines#update",
+      "circles#update",
+      "map#setStyle",
+      "tileOverlays#update",
+      "tileOverlays#clearTileCache"
+    };
+
+    for (String methodName : methodsThatTriggerInvalidation) {
+      googleMapController =
+          new GoogleMapController(0, context, mockMessenger, activity::getLifecycle, null);
+      googleMapController.init();
+
+      mockGoogleMap = mock(GoogleMap.class);
+      googleMapController.onMapReady(mockGoogleMap);
+
+      MethodChannel.Result result = mock(MethodChannel.Result.class);
+      System.out.println(methodName);
+      googleMapController.onMethodCall(
+          new MethodCall(methodName, new HashMap<String, Object>()), result);
+
+      ArgumentCaptor<GoogleMap.OnMapLoadedCallback> argument =
+          ArgumentCaptor.forClass(GoogleMap.OnMapLoadedCallback.class);
+      verify(mockGoogleMap).setOnMapLoadedCallback(argument.capture());
+
+      MapView mapView = mock(MapView.class);
+      googleMapController.setView(mapView);
+
+      verify(mapView, never()).invalidate();
+      argument.getValue().onMapLoaded();
+      verify(mapView).invalidate();
+    }
+  }
+
+  @Test
+  public void InvalidateMapOnceAfterMethodCall() throws InterruptedException {
+    googleMapController.onMapReady(mockGoogleMap);
+
+    MethodChannel.Result result = mock(MethodChannel.Result.class);
+    googleMapController.onMethodCall(
+        new MethodCall("markers#update", new HashMap<String, Object>()), result);
+    googleMapController.onMethodCall(
+        new MethodCall("polygons#update", new HashMap<String, Object>()), result);
+
+    ArgumentCaptor<GoogleMap.OnMapLoadedCallback> argument =
+        ArgumentCaptor.forClass(GoogleMap.OnMapLoadedCallback.class);
+    verify(mockGoogleMap).setOnMapLoadedCallback(argument.capture());
+
+    MapView mapView = mock(MapView.class);
+    googleMapController.setView(mapView);
+
+    verify(mapView, never()).invalidate();
+    argument.getValue().onMapLoaded();
+    verify(mapView).invalidate();
+  }
+
+  @Test
+  public void MethodCalledAfterControllerIsDestroyed() throws InterruptedException {
+    googleMapController.onMapReady(mockGoogleMap);
+    MethodChannel.Result result = mock(MethodChannel.Result.class);
+    googleMapController.onMethodCall(
+        new MethodCall("markers#update", new HashMap<String, Object>()), result);
+
+    ArgumentCaptor<GoogleMap.OnMapLoadedCallback> argument =
+        ArgumentCaptor.forClass(GoogleMap.OnMapLoadedCallback.class);
+    verify(mockGoogleMap).setOnMapLoadedCallback(argument.capture());
+
+    MapView mapView = mock(MapView.class);
+    googleMapController.setView(mapView);
+    googleMapController.onDestroy(activity);
+
+    argument.getValue().onMapLoaded();
+    verify(mapView, never()).invalidate();
+  }
 }
diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml
index 831f3cc..59ee23d 100644
--- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml
+++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml
@@ -2,7 +2,7 @@
 description: A Flutter plugin for integrating Google Maps in iOS and Android applications.
 repository: https://github.com/flutter/plugins/tree/main/packages/google_maps_flutter/google_maps_flutter
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22
-version: 2.1.5
+version: 2.1.6
 
 environment:
   sdk: ">=2.14.0 <3.0.0"