[google_maps_flutter] Fix iOS crash by observing map frame change only once (#3426)

diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md
index 565bf84..f05de47 100644
--- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md
+++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 2.1.3
+
+* Fixes iOS crash on `EXC_BAD_ACCESS KERN_PROTECTION_FAILURE` if the map frame changes long after creation.
+
 ## 2.1.2
 
 * Removes dependencies from `pubspec.yaml` that are only needed in `example/pubspec.yaml`
diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile b/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile
index 9686afa..29bfe63 100644
--- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile
+++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile
@@ -31,6 +31,8 @@
   flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
   target 'RunnerTests' do
     inherit! :search_paths
+
+    pod 'OCMock', '~> 3.9.1'
   end
 end
 
diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj
index fbb006a..6a0466c 100644
--- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj
@@ -15,6 +15,7 @@
 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
 		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+		982F2A6C27BADE17003C81F4 /* PartiallyMockedMapView.m in Sources */ = {isa = PBXBuildFile; fileRef = 982F2A6B27BADE17003C81F4 /* PartiallyMockedMapView.m */; };
 		F7151F13265D7ED70028CB91 /* GoogleMapsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */; };
 		F7151F21265D7EE50028CB91 /* GoogleMapsUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F20265D7EE50028CB91 /* GoogleMapsUITests.m */; };
 		FC8F35FC8CD533B128950487 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */; };
@@ -67,6 +68,8 @@
 		97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		982F2A6A27BADE17003C81F4 /* PartiallyMockedMapView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PartiallyMockedMapView.h; sourceTree = "<group>"; };
+		982F2A6B27BADE17003C81F4 /* PartiallyMockedMapView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PartiallyMockedMapView.m; sourceTree = "<group>"; };
 		B7AFC65E3DD5AC60D834D83D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
 		E52C6A6210A56F027C582EF9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
 		EA0E91726245EDC22B97E8B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
@@ -188,6 +191,8 @@
 			isa = PBXGroup;
 			children = (
 				F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */,
+				982F2A6A27BADE17003C81F4 /* PartiallyMockedMapView.h */,
+				982F2A6B27BADE17003C81F4 /* PartiallyMockedMapView.m */,
 				F7151F14265D7ED70028CB91 /* Info.plist */,
 			);
 			path = RunnerTests;
@@ -270,7 +275,7 @@
 		97C146E61CF9000F007C117D /* Project object */ = {
 			isa = PBXProject;
 			attributes = {
-				LastUpgradeCheck = 1100;
+				LastUpgradeCheck = 1320;
 				ORGANIZATIONNAME = "The Flutter Authors";
 				TargetAttributes = {
 					97C146ED1CF9000F007C117D = {
@@ -441,6 +446,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				F7151F13265D7ED70028CB91 /* GoogleMapsTests.m in Sources */,
+				982F2A6C27BADE17003C81F4 /* PartiallyMockedMapView.m in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -511,6 +517,7 @@
 				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
 				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
 				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
 				CLANG_WARN_STRICT_PROTOTYPES = YES;
 				CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -567,6 +574,7 @@
 				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
 				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
 				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
 				CLANG_WARN_STRICT_PROTOTYPES = YES;
 				CLANG_WARN_SUSPICIOUS_MOVE = YES;
diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index afdb55f..c983bfc 100644
--- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1100"
+   LastUpgradeVersion = "1320"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "YES"
diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/GoogleMapsTests.m b/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/GoogleMapsTests.m
index a833c74..f03dca2 100644
--- a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/GoogleMapsTests.m
+++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/GoogleMapsTests.m
@@ -3,8 +3,12 @@
 // found in the LICENSE file.
 
 @import google_maps_flutter;
+@import google_maps_flutter.Test;
 @import XCTest;
 
+#import <OCMock/OCMock.h>
+#import "PartiallyMockedMapView.h"
+
 @interface GoogleMapsTests : XCTestCase
 @end
 
@@ -15,4 +19,24 @@
   XCTAssertNotNil(plugin);
 }
 
+- (void)testFrameObserver {
+  id registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar));
+  CGRect frame = CGRectMake(0, 0, 100, 100);
+  PartiallyMockedMapView *mapView = [[PartiallyMockedMapView alloc]
+      initWithFrame:frame
+             camera:[[GMSCameraPosition alloc] initWithLatitude:0 longitude:0 zoom:0]];
+  FLTGoogleMapController *controller = [[FLTGoogleMapController alloc] initWithMapView:mapView
+                                                                        viewIdentifier:0
+                                                                             arguments:nil
+                                                                             registrar:registrar];
+
+  for (NSInteger i = 0; i < 10; ++i) {
+    [controller view];
+  }
+  XCTAssertEqual(mapView.frameObserverCount, 1);
+
+  mapView.frame = frame;
+  XCTAssertEqual(mapView.frameObserverCount, 0);
+}
+
 @end
diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/PartiallyMockedMapView.h b/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/PartiallyMockedMapView.h
new file mode 100644
index 0000000..4288401
--- /dev/null
+++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/PartiallyMockedMapView.h
@@ -0,0 +1,17 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+@import GoogleMaps;
+
+/**
+ * Defines a map view used for testing key-value observing.
+ */
+@interface PartiallyMockedMapView : GMSMapView
+
+/**
+ * The number of times that the `frame` KVO has been added.
+ */
+@property(nonatomic, assign, readonly) NSInteger frameObserverCount;
+
+@end
diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/PartiallyMockedMapView.m b/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/PartiallyMockedMapView.m
new file mode 100644
index 0000000..202a18d
--- /dev/null
+++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/PartiallyMockedMapView.m
@@ -0,0 +1,34 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "PartiallyMockedMapView.h"
+
+@interface PartiallyMockedMapView ()
+
+@property(nonatomic, assign) NSInteger frameObserverCount;
+
+@end
+
+@implementation PartiallyMockedMapView
+
+- (void)addObserver:(NSObject *)observer
+         forKeyPath:(NSString *)keyPath
+            options:(NSKeyValueObservingOptions)options
+            context:(void *)context {
+  [super addObserver:observer forKeyPath:keyPath options:options context:context];
+
+  if ([keyPath isEqualToString:@"frame"]) {
+    ++self.frameObserverCount;
+  }
+}
+
+- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
+  [super removeObserver:observer forKeyPath:keyPath];
+
+  if ([keyPath isEqualToString:@"frame"]) {
+    --self.frameObserverCount;
+  }
+}
+
+@end
diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m
index df4e876..ca80681 100644
--- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m
+++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m
@@ -51,7 +51,6 @@
   FlutterMethodChannel *_channel;
   BOOL _trackCameraPosition;
   NSObject<FlutterPluginRegistrar> *_registrar;
-  BOOL _cameraDidInitialSetup;
   FLTMarkersController *_markersController;
   FLTPolygonsController *_polygonsController;
   FLTPolylinesController *_polylinesController;
@@ -63,11 +62,19 @@
                viewIdentifier:(int64_t)viewId
                     arguments:(id _Nullable)args
                     registrar:(NSObject<FlutterPluginRegistrar> *)registrar {
+  GMSCameraPosition *camera = ToOptionalCameraPosition(args[@"initialCameraPosition"]);
+  GMSMapView *mapView = [GMSMapView mapWithFrame:frame camera:camera];
+  return [self initWithMapView:mapView viewIdentifier:viewId arguments:args registrar:registrar];
+}
+
+- (instancetype)initWithMapView:(GMSMapView *_Nonnull)mapView
+                 viewIdentifier:(int64_t)viewId
+                      arguments:(id _Nullable)args
+                      registrar:(NSObject<FlutterPluginRegistrar> *_Nonnull)registrar {
   if (self = [super init]) {
+    _mapView = mapView;
     _viewId = viewId;
 
-    GMSCameraPosition *camera = ToOptionalCameraPosition(args[@"initialCameraPosition"]);
-    _mapView = [GMSMapView mapWithFrame:frame camera:camera];
     _mapView.accessibilityElementsHidden = NO;
     _trackCameraPosition = NO;
     InterpretMapOptions(args[@"options"], self);
@@ -83,7 +90,6 @@
     }];
     _mapView.delegate = weakSelf;
     _registrar = registrar;
-    _cameraDidInitialSetup = NO;
     _markersController = [[FLTMarkersController alloc] init:_channel
                                                     mapView:_mapView
                                                   registrar:registrar];
@@ -119,12 +125,13 @@
     if ([tileOverlaysToAdd isKindOfClass:[NSArray class]]) {
       [_tileOverlaysController addTileOverlays:tileOverlaysToAdd];
     }
+
+    [_mapView addObserver:self forKeyPath:@"frame" options:0 context:nil];
   }
   return self;
 }
 
 - (UIView *)view {
-  [_mapView addObserver:self forKeyPath:@"frame" options:0 context:nil];
   return _mapView;
 }
 
@@ -132,11 +139,6 @@
                       ofObject:(id)object
                         change:(NSDictionary *)change
                        context:(void *)context {
-  if (_cameraDidInitialSetup) {
-    // We only observe the frame for initial setup.
-    [_mapView removeObserver:self forKeyPath:@"frame"];
-    return;
-  }
   if (object == _mapView && [keyPath isEqualToString:@"frame"]) {
     CGRect bounds = _mapView.bounds;
     if (CGRectEqualToRect(bounds, CGRectZero)) {
@@ -146,7 +148,7 @@
       // zero.
       return;
     }
-    _cameraDidInitialSetup = YES;
+    // We only observe the frame for initial setup.
     [_mapView removeObserver:self forKeyPath:@"frame"];
     [_mapView moveCamera:[GMSCameraUpdate setCamera:_mapView.camera]];
   } else {
diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController_Test.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController_Test.h
new file mode 100644
index 0000000..84f6f7c
--- /dev/null
+++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController_Test.h
@@ -0,0 +1,27 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import <Flutter/Flutter.h>
+#import <GoogleMaps/GoogleMaps.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FLTGoogleMapController (Test)
+
+/**
+ * Initializes a map controller with a concrete map view.
+ *
+ * @param mapView A map view that will be displayed by the controller
+ * @param viewId A unique identifier for the controller.
+ * @param args Parameters for initialising the map view.
+ * @param registrar The plugin registrar passed from Flutter.
+ */
+- (instancetype)initWithMapView:(GMSMapView *)mapView
+                 viewIdentifier:(int64_t)viewId
+                      arguments:(id _Nullable)args
+                      registrar:(NSObject<FlutterPluginRegistrar> *)registrar;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/google_maps_flutter-umbrella.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/google_maps_flutter-umbrella.h
new file mode 100644
index 0000000..50880a2
--- /dev/null
+++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/google_maps_flutter-umbrella.h
@@ -0,0 +1,11 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import <Foundation/Foundation.h>
+#import <google_maps_flutter/FLTGoogleMapTileOverlayController.h>
+#import <google_maps_flutter/FLTGoogleMapsPlugin.h>
+#import <google_maps_flutter/JsonConversions.h>
+
+FOUNDATION_EXPORT double google_maps_flutterVersionNumber;
+FOUNDATION_EXPORT const unsigned char google_maps_flutterVersionString[];
diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/google_maps_flutter.modulemap b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/google_maps_flutter.modulemap
new file mode 100644
index 0000000..19513f4
--- /dev/null
+++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/google_maps_flutter.modulemap
@@ -0,0 +1,10 @@
+framework module google_maps_flutter {
+  umbrella header "google_maps_flutter-umbrella.h"
+
+  export *
+  module * { export * }
+
+  explicit module Test {
+    header "GoogleMapController_Test.h"
+  }
+}
diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec b/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec
index f2ed5fc..e34919c 100644
--- a/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec
+++ b/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec
@@ -14,8 +14,9 @@
   s.author           = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' }
   s.source           = { :http => 'https://github.com/flutter/plugins/tree/main/packages/google_maps_flutter/google_maps_flutter' }
   s.documentation_url = 'https://pub.dev/packages/google_maps_flutter'
-  s.source_files = 'Classes/**/*'
+  s.source_files = 'Classes/**/*.{h,m}'
   s.public_header_files = 'Classes/**/*.h'
+  s.module_map = 'Classes/google_maps_flutter.modulemap'
   s.dependency 'Flutter'
   s.dependency 'GoogleMaps'
   s.static_framework = true
diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml
index 849019c..741fe69 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.2
+version: 2.1.3
 
 environment:
   sdk: ">=2.14.0 <3.0.0"