[camera]remove "selfRef" for SavePhotoDelegate and ensure thread safety (#4780)

diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md
index d2b2f90..0089130 100644
--- a/packages/camera/camera/CHANGELOG.md
+++ b/packages/camera/camera/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.9.4+13
+
+* Updates iOS camera's photo capture delegate reference on a background queue to prevent potential race conditions, and some related internal code cleanup.
+
 ## 0.9.4+12
 
 * Skips unnecessary AppDelegate setup for unit tests on iOS.
diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj
index ac39de2..5f788fc 100644
--- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
 	archiveVersion = 1;
 	classes = {
 	};
-	objectVersion = 50;
+	objectVersion = 46;
 	objects = {
 
 /* Begin PBXBuildFile section */
@@ -23,11 +23,12 @@
 		E01EE4A82799F3A5008C1950 /* QueueHelperTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E01EE4A72799F3A5008C1950 /* QueueHelperTests.m */; };
 		E032F250279F5E94009E9028 /* CameraCaptureSessionQueueRaceConditionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E032F24F279F5E94009E9028 /* CameraCaptureSessionQueueRaceConditionTests.m */; };
 		E04F108627A87CA600573D0C /* FLTSavePhotoDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */; };
+		E071CF7227B3061B006EF3BA /* FLTCamPhotoCaptureTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */; };
+		E071CF7427B31DE4006EF3BA /* FLTCamSampleBufferTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */; };
 		E0C6E2002770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */; };
 		E0C6E2012770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */; };
 		E0C6E2022770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */; };
 		E0F95E3D27A32AB900699390 /* CameraPropertiesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0F95E3C27A32AB900699390 /* CameraPropertiesTests.m */; };
-		E0F95E4427A36B9200699390 /* SampleBufferQueueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0F95E4327A36B9200699390 /* SampleBufferQueueTests.m */; };
 		E487C86026D686A10034AC92 /* CameraPreviewPauseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */; };
 		F6EE622F2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m in Sources */ = {isa = PBXBuildFile; fileRef = F6EE622E2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m */; };
 /* End PBXBuildFile section */
@@ -85,11 +86,12 @@
 		E01EE4A72799F3A5008C1950 /* QueueHelperTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QueueHelperTests.m; sourceTree = "<group>"; };
 		E032F24F279F5E94009E9028 /* CameraCaptureSessionQueueRaceConditionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CameraCaptureSessionQueueRaceConditionTests.m; sourceTree = "<group>"; };
 		E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTSavePhotoDelegateTests.m; sourceTree = "<group>"; };
+		E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTCamPhotoCaptureTests.m; sourceTree = "<group>"; };
+		E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTCamSampleBufferTests.m; sourceTree = "<group>"; };
 		E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeMethodChannelTests.m; sourceTree = "<group>"; };
 		E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeTextureRegistryTests.m; sourceTree = "<group>"; };
 		E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeEventChannelTests.m; sourceTree = "<group>"; };
 		E0F95E3C27A32AB900699390 /* CameraPropertiesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraPropertiesTests.m; sourceTree = "<group>"; };
-		E0F95E4327A36B9200699390 /* SampleBufferQueueTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SampleBufferQueueTests.m; sourceTree = "<group>"; };
 		E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraPreviewPauseTests.m; sourceTree = "<group>"; };
 		F63F9EED27143B19002479BF /* MockFLTThreadSafeFlutterResult.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MockFLTThreadSafeFlutterResult.h; sourceTree = "<group>"; };
 		F6EE622E2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MockFLTThreadSafeFlutterResult.m; sourceTree = "<group>"; };
@@ -126,8 +128,9 @@
 				E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */,
 				E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */,
 				E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */,
-				E0F95E4327A36B9200699390 /* SampleBufferQueueTests.m */,
 				E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */,
+				E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */,
+				E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */,
 				E01EE4A72799F3A5008C1950 /* QueueHelperTests.m */,
 				E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */,
 				F6EE622E2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m */,
@@ -396,12 +399,13 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
-				E0F95E4427A36B9200699390 /* SampleBufferQueueTests.m in Sources */,
 				03F6F8B226CBB4670024B8D3 /* ThreadSafeFlutterResultTests.m in Sources */,
 				033B94BE269C40A200B4DF97 /* CameraMethodChannelTests.m in Sources */,
+				E071CF7227B3061B006EF3BA /* FLTCamPhotoCaptureTests.m in Sources */,
 				E0F95E3D27A32AB900699390 /* CameraPropertiesTests.m in Sources */,
 				03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */,
 				E487C86026D686A10034AC92 /* CameraPreviewPauseTests.m in Sources */,
+				E071CF7427B31DE4006EF3BA /* FLTCamSampleBufferTests.m in Sources */,
 				E04F108627A87CA600573D0C /* FLTSavePhotoDelegateTests.m in Sources */,
 				F6EE622F2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m in Sources */,
 				334733EA2668111C00DCC49E /* CameraOrientationTests.m in Sources */,
diff --git a/packages/camera/camera/example/ios/RunnerTests/FLTCamPhotoCaptureTests.m b/packages/camera/camera/example/ios/RunnerTests/FLTCamPhotoCaptureTests.m
new file mode 100644
index 0000000..fdb2abd
--- /dev/null
+++ b/packages/camera/camera/example/ios/RunnerTests/FLTCamPhotoCaptureTests.m
@@ -0,0 +1,114 @@
+// 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 camera;
+@import camera.Test;
+@import AVFoundation;
+@import XCTest;
+#import <OCMock/OCMock.h>
+
+@interface FLTCamPhotoCaptureTests : XCTestCase
+
+@end
+
+@implementation FLTCamPhotoCaptureTests
+
+- (void)testCaptureToFile_mustReportErrorToResultIfSavePhotoDelegateCompletionsWithError {
+  XCTestExpectation *errorExpectation =
+      [self expectationWithDescription:
+                @"Must send error to result if save photo delegate completes with error."];
+
+  dispatch_queue_t captureSessionQueue = dispatch_queue_create("capture_session_queue", NULL);
+  dispatch_queue_set_specific(captureSessionQueue, FLTCaptureSessionQueueSpecific,
+                              (void *)FLTCaptureSessionQueueSpecific, NULL);
+  FLTCam *cam = [self createFLTCamWithCaptureSessionQueue:captureSessionQueue];
+  AVCapturePhotoSettings *settings = [AVCapturePhotoSettings photoSettings];
+  id mockSettings = OCMClassMock([AVCapturePhotoSettings class]);
+  OCMStub([mockSettings photoSettings]).andReturn(settings);
+
+  NSError *error = [NSError errorWithDomain:@"test" code:0 userInfo:nil];
+  id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]);
+  OCMStub([mockResult sendError:error]).andDo(^(NSInvocation *invocation) {
+    [errorExpectation fulfill];
+  });
+
+  id mockOutput = OCMClassMock([AVCapturePhotoOutput class]);
+  OCMStub([mockOutput capturePhotoWithSettings:OCMOCK_ANY delegate:OCMOCK_ANY])
+      .andDo(^(NSInvocation *invocation) {
+        FLTSavePhotoDelegate *delegate = cam.inProgressSavePhotoDelegates[@(settings.uniqueID)];
+        // Completion runs on IO queue.
+        dispatch_queue_t ioQueue = dispatch_queue_create("io_queue", NULL);
+        dispatch_async(ioQueue, ^{
+          delegate.completionHandler(nil, error);
+        });
+      });
+  cam.capturePhotoOutput = mockOutput;
+
+  // `FLTCam::captureToFile` runs on capture session queue.
+  dispatch_async(captureSessionQueue, ^{
+    [cam captureToFile:mockResult];
+  });
+
+  [self waitForExpectationsWithTimeout:1 handler:nil];
+}
+
+- (void)testCaptureToFile_mustReportPathToResultIfSavePhotoDelegateCompletionsWithPath {
+  XCTestExpectation *pathExpectation =
+      [self expectationWithDescription:
+                @"Must send file path to result if save photo delegate completes with file path."];
+
+  dispatch_queue_t captureSessionQueue = dispatch_queue_create("capture_session_queue", NULL);
+  dispatch_queue_set_specific(captureSessionQueue, FLTCaptureSessionQueueSpecific,
+                              (void *)FLTCaptureSessionQueueSpecific, NULL);
+  FLTCam *cam = [self createFLTCamWithCaptureSessionQueue:captureSessionQueue];
+
+  AVCapturePhotoSettings *settings = [AVCapturePhotoSettings photoSettings];
+  id mockSettings = OCMClassMock([AVCapturePhotoSettings class]);
+  OCMStub([mockSettings photoSettings]).andReturn(settings);
+
+  NSString *filePath = @"test";
+  id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]);
+  OCMStub([mockResult sendSuccessWithData:filePath]).andDo(^(NSInvocation *invocation) {
+    [pathExpectation fulfill];
+  });
+
+  id mockOutput = OCMClassMock([AVCapturePhotoOutput class]);
+  OCMStub([mockOutput capturePhotoWithSettings:OCMOCK_ANY delegate:OCMOCK_ANY])
+      .andDo(^(NSInvocation *invocation) {
+        FLTSavePhotoDelegate *delegate = cam.inProgressSavePhotoDelegates[@(settings.uniqueID)];
+        // Completion runs on IO queue.
+        dispatch_queue_t ioQueue = dispatch_queue_create("io_queue", NULL);
+        dispatch_async(ioQueue, ^{
+          delegate.completionHandler(filePath, nil);
+        });
+      });
+  cam.capturePhotoOutput = mockOutput;
+
+  // `FLTCam::captureToFile` runs on capture session queue.
+  dispatch_async(captureSessionQueue, ^{
+    [cam captureToFile:mockResult];
+  });
+  [self waitForExpectationsWithTimeout:1 handler:nil];
+}
+
+/// Creates an `FLTCam` that runs its operations on a given capture session queue.
+- (FLTCam *)createFLTCamWithCaptureSessionQueue:(dispatch_queue_t)captureSessionQueue {
+  id inputMock = OCMClassMock([AVCaptureDeviceInput class]);
+  OCMStub([inputMock deviceInputWithDevice:[OCMArg any] error:[OCMArg setTo:nil]])
+      .andReturn(inputMock);
+
+  id sessionMock = OCMClassMock([AVCaptureSession class]);
+  OCMStub([sessionMock alloc]).andReturn(sessionMock);
+  OCMStub([sessionMock addInputWithNoConnections:[OCMArg any]]);  // no-op
+  OCMStub([sessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES);
+
+  return [[FLTCam alloc] initWithCameraName:@"camera"
+                           resolutionPreset:@"medium"
+                                enableAudio:true
+                                orientation:UIDeviceOrientationPortrait
+                        captureSessionQueue:captureSessionQueue
+                                      error:nil];
+}
+
+@end
diff --git a/packages/camera/camera/example/ios/RunnerTests/SampleBufferQueueTests.m b/packages/camera/camera/example/ios/RunnerTests/FLTCamSampleBufferTests.m
similarity index 93%
rename from packages/camera/camera/example/ios/RunnerTests/SampleBufferQueueTests.m
rename to packages/camera/camera/example/ios/RunnerTests/FLTCamSampleBufferTests.m
index 19cead9..ccc8de5 100644
--- a/packages/camera/camera/example/ios/RunnerTests/SampleBufferQueueTests.m
+++ b/packages/camera/camera/example/ios/RunnerTests/FLTCamSampleBufferTests.m
@@ -8,11 +8,11 @@
 @import XCTest;
 #import <OCMock/OCMock.h>
 
-@interface SampleBufferQueueTests : XCTestCase
+@interface FLTCamSampleBufferTests : XCTestCase
 
 @end
 
-@implementation SampleBufferQueueTests
+@implementation FLTCamSampleBufferTests
 
 - (void)testSampleBufferCallbackQueueMustBeCaptureSessionQueue {
   id inputMock = OCMClassMock([AVCaptureDeviceInput class]);
diff --git a/packages/camera/camera/example/ios/RunnerTests/FLTSavePhotoDelegateTests.m b/packages/camera/camera/example/ios/RunnerTests/FLTSavePhotoDelegateTests.m
index b6ea84d..9e8e244 100644
--- a/packages/camera/camera/example/ios/RunnerTests/FLTSavePhotoDelegateTests.m
+++ b/packages/camera/camera/example/ios/RunnerTests/FLTSavePhotoDelegateTests.m
@@ -14,40 +14,44 @@
 
 @implementation FLTSavePhotoDelegateTests
 
-- (void)testHandlePhotoCaptureResult_mustSendErrorIfFailedToCapture {
-  NSError *error = [NSError errorWithDomain:@"test" code:0 userInfo:nil];
-  dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL);
-  id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]);
-  FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc] initWithPath:@"test"
-                                                                       result:mockResult
-                                                                      ioQueue:ioQueue];
+- (void)testHandlePhotoCaptureResult_mustCompleteWithErrorIfFailedToCapture {
+  XCTestExpectation *completionExpectation =
+      [self expectationWithDescription:@"Must complete with error if failed to capture photo."];
 
-  [delegate handlePhotoCaptureResultWithError:error
+  NSError *captureError = [NSError errorWithDomain:@"test" code:0 userInfo:nil];
+  dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL);
+  FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc]
+           initWithPath:@"test"
+                ioQueue:ioQueue
+      completionHandler:^(NSString *_Nullable path, NSError *_Nullable error) {
+        XCTAssertEqualObjects(captureError, error);
+        XCTAssertNil(path);
+        [completionExpectation fulfill];
+      }];
+
+  [delegate handlePhotoCaptureResultWithError:captureError
                             photoDataProvider:^NSData * {
                               return nil;
                             }];
-  OCMVerify([mockResult sendError:error]);
+  [self waitForExpectationsWithTimeout:1 handler:nil];
 }
 
-- (void)testHandlePhotoCaptureResult_mustSendErrorIfFailedToWrite {
-  XCTestExpectation *resultExpectation =
-      [self expectationWithDescription:@"Must send IOError to the result if failed to write file."];
+- (void)testHandlePhotoCaptureResult_mustCompleteWithErrorIfFailedToWrite {
+  XCTestExpectation *completionExpectation =
+      [self expectationWithDescription:@"Must complete with error if failed to write file."];
   dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL);
-  id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]);
 
   NSError *ioError = [NSError errorWithDomain:@"IOError"
                                          code:0
                                      userInfo:@{NSLocalizedDescriptionKey : @"Localized IO Error"}];
-
-  OCMStub([mockResult sendErrorWithCode:@"IOError"
-                                message:@"Unable to write file"
-                                details:ioError.localizedDescription])
-      .andDo(^(NSInvocation *invocation) {
-        [resultExpectation fulfill];
-      });
-  FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc] initWithPath:@"test"
-                                                                       result:mockResult
-                                                                      ioQueue:ioQueue];
+  FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc]
+           initWithPath:@"test"
+                ioQueue:ioQueue
+      completionHandler:^(NSString *_Nullable path, NSError *_Nullable error) {
+        XCTAssertEqualObjects(ioError, error);
+        XCTAssertNil(path);
+        [completionExpectation fulfill];
+      }];
 
   // We can't use OCMClassMock for NSData because some XCTest APIs uses NSData (e.g.
   // `XCTRunnerIDESession::logDebugMessage:`) on a private queue.
@@ -63,23 +67,25 @@
   [self waitForExpectationsWithTimeout:1 handler:nil];
 }
 
-- (void)testHandlePhotoCaptureResult_mustSendSuccessIfSuccessToWrite {
-  XCTestExpectation *resultExpectation = [self
-      expectationWithDescription:@"Must send file path to the result if success to write file."];
+- (void)testHandlePhotoCaptureResult_mustCompleteWithFilePathIfSuccessToWrite {
+  XCTestExpectation *completionExpectation =
+      [self expectationWithDescription:@"Must complete with file path if success to write file."];
 
   dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL);
-  id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]);
-  FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc] initWithPath:@"test"
-                                                                       result:mockResult
-                                                                      ioQueue:ioQueue];
-  OCMStub([mockResult sendSuccessWithData:delegate.path]).andDo(^(NSInvocation *invocation) {
-    [resultExpectation fulfill];
-  });
+  NSString *filePath = @"test";
+  FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc]
+           initWithPath:filePath
+                ioQueue:ioQueue
+      completionHandler:^(NSString *_Nullable path, NSError *_Nullable error) {
+        XCTAssertNil(error);
+        XCTAssertEqualObjects(filePath, path);
+        [completionExpectation fulfill];
+      }];
 
   // We can't use OCMClassMock for NSData because some XCTest APIs uses NSData (e.g.
   // `XCTRunnerIDESession::logDebugMessage:`) on a private queue.
   id mockData = OCMPartialMock([NSData data]);
-  OCMStub([mockData writeToFile:OCMOCK_ANY options:NSDataWritingAtomic error:[OCMArg setTo:nil]])
+  OCMStub([mockData writeToFile:filePath options:NSDataWritingAtomic error:[OCMArg setTo:nil]])
       .andReturn(YES);
 
   [delegate handlePhotoCaptureResultWithError:nil
@@ -94,16 +100,12 @@
       [self expectationWithDescription:@"Data provider must run on io queue."];
   XCTestExpectation *writeFileQueueExpectation =
       [self expectationWithDescription:@"File writing must run on io queue"];
-  XCTestExpectation *resultExpectation = [self
-      expectationWithDescription:@"Must send file path to the result if success to write file."];
+  XCTestExpectation *completionExpectation =
+      [self expectationWithDescription:@"Must complete with file path if success to write file."];
 
   dispatch_queue_t ioQueue = dispatch_queue_create("test", NULL);
   const char *ioQueueSpecific = "io_queue_specific";
   dispatch_queue_set_specific(ioQueue, ioQueueSpecific, (void *)ioQueueSpecific, NULL);
-  id mockResult = OCMClassMock([FLTThreadSafeFlutterResult class]);
-  OCMStub([mockResult sendSuccessWithData:OCMOCK_ANY]).andDo(^(NSInvocation *invocation) {
-    [resultExpectation fulfill];
-  });
 
   // We can't use OCMClassMock for NSData because some XCTest APIs uses NSData (e.g.
   // `XCTRunnerIDESession::logDebugMessage:`) on a private queue.
@@ -116,9 +118,14 @@
       })
       .andReturn(YES);
 
-  FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc] initWithPath:@"test"
-                                                                       result:mockResult
-                                                                      ioQueue:ioQueue];
+  NSString *filePath = @"test";
+  FLTSavePhotoDelegate *delegate = [[FLTSavePhotoDelegate alloc]
+           initWithPath:filePath
+                ioQueue:ioQueue
+      completionHandler:^(NSString *_Nullable path, NSError *_Nullable error) {
+        [completionExpectation fulfill];
+      }];
+
   [delegate handlePhotoCaptureResultWithError:nil
                             photoDataProvider:^NSData * {
                               if (dispatch_get_specific(ioQueueSpecific)) {
diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m
index 97e9954..fcea190 100644
--- a/packages/camera/camera/ios/Classes/CameraPlugin.m
+++ b/packages/camera/camera/ios/Classes/CameraPlugin.m
@@ -13,6 +13,7 @@
 #import "FLTThreadSafeFlutterResult.h"
 #import "FLTThreadSafeMethodChannel.h"
 #import "FLTThreadSafeTextureRegistry.h"
+#import "QueueHelper.h"
 
 @interface CameraPlugin ()
 @property(readonly, nonatomic) FLTThreadSafeTextureRegistry *registry;
@@ -38,6 +39,9 @@
   _registry = [[FLTThreadSafeTextureRegistry alloc] initWithTextureRegistry:registry];
   _messenger = messenger;
   _captureSessionQueue = dispatch_queue_create("io.flutter.camera.captureSessionQueue", NULL);
+  dispatch_queue_set_specific(_captureSessionQueue, FLTCaptureSessionQueueSpecific,
+                              (void *)FLTCaptureSessionQueueSpecific, NULL);
+
   [self initDeviceEventMethodChannel];
   [self startOrientationListener];
   return self;
diff --git a/packages/camera/camera/ios/Classes/FLTCam.m b/packages/camera/camera/ios/Classes/FLTCam.m
index 94f9850..31a9dec 100644
--- a/packages/camera/camera/ios/Classes/FLTCam.m
+++ b/packages/camera/camera/ios/Classes/FLTCam.m
@@ -5,6 +5,7 @@
 #import "FLTCam.h"
 #import "FLTCam_Test.h"
 #import "FLTSavePhotoDelegate.h"
+#import "QueueHelper.h"
 
 @import CoreMotion;
 #import <libkern/OSAtomic.h>
@@ -50,7 +51,6 @@
 @property(nonatomic) FLTImageStreamHandler *imageStreamHandler;
 @property(readonly, nonatomic) AVCaptureSession *captureSession;
 
-@property(readonly, nonatomic) AVCapturePhotoOutput *capturePhotoOutput API_AVAILABLE(ios(10));
 @property(readonly, nonatomic) AVCaptureInput *captureVideoInput;
 @property(readonly) CVPixelBufferRef volatile latestPixelBuffer;
 @property(readonly, nonatomic) CGSize captureSize;
@@ -76,8 +76,8 @@
 @property AVAssetWriterInputPixelBufferAdaptor *videoAdaptor;
 /// All FLTCam's state access and capture session related operations should be on run on this queue.
 @property(strong, nonatomic) dispatch_queue_t captureSessionQueue;
-/// The queue on which captured photos (not videos) are wrote to disk.
-/// Videos are wrote to disk by `videoAdaptor` on an internal queue managed by AVFoundation.
+/// The queue on which captured photos (not videos) are written to disk.
+/// Videos are written to disk by `videoAdaptor` on an internal queue managed by AVFoundation.
 @property(strong, nonatomic) dispatch_queue_t photoIOQueue;
 @property(assign, nonatomic) UIDeviceOrientation deviceOrientation;
 @end
@@ -110,6 +110,7 @@
   _lockedCaptureOrientation = UIDeviceOrientationUnknown;
   _deviceOrientation = orientation;
   _videoFormat = kCVPixelFormatType_32BGRA;
+  _inProgressSavePhotoDelegates = [NSMutableDictionary dictionary];
 
   NSError *localError = nil;
   _captureVideoInput = [AVCaptureDeviceInput deviceInputWithDevice:_captureDevice
@@ -220,11 +221,30 @@
     return;
   }
 
-  [_capturePhotoOutput
-      capturePhotoWithSettings:settings
-                      delegate:[[FLTSavePhotoDelegate alloc] initWithPath:path
-                                                                   result:result
-                                                                  ioQueue:self.photoIOQueue]];
+  FLTSavePhotoDelegate *savePhotoDelegate = [[FLTSavePhotoDelegate alloc]
+           initWithPath:path
+                ioQueue:self.photoIOQueue
+      completionHandler:^(NSString *_Nullable path, NSError *_Nullable error) {
+        dispatch_async(self.captureSessionQueue, ^{
+          // Dispatch back to capture session queue to delete reference.
+          // Retain cycle is broken after the dictionary entry is cleared.
+          // This is to keep the behavior with the previous `selfReference` approach in the
+          // FLTSavePhotoDelegate, where delegate is released only after capture completion.
+          [self.inProgressSavePhotoDelegates removeObjectForKey:@(settings.uniqueID)];
+        });
+
+        if (error) {
+          [result sendError:error];
+        } else {
+          NSAssert(path, @"Path must not be nil if no error.");
+          [result sendSuccessWithData:path];
+        }
+      }];
+
+  NSAssert(dispatch_get_specific(FLTCaptureSessionQueueSpecific),
+           @"save photo delegate references must be updated on the capture session queue");
+  self.inProgressSavePhotoDelegates[@(settings.uniqueID)] = savePhotoDelegate;
+  [self.capturePhotoOutput capturePhotoWithSettings:settings delegate:savePhotoDelegate];
 }
 
 - (AVCaptureVideoOrientation)getVideoOrientationForDeviceOrientation:
diff --git a/packages/camera/camera/ios/Classes/FLTCam_Test.h b/packages/camera/camera/ios/Classes/FLTCam_Test.h
index 556578e..db885d0 100644
--- a/packages/camera/camera/ios/Classes/FLTCam_Test.h
+++ b/packages/camera/camera/ios/Classes/FLTCam_Test.h
@@ -3,10 +3,23 @@
 // found in the LICENSE file.
 
 #import "FLTCam.h"
+#import "FLTSavePhotoDelegate.h"
 
 // APIs exposed for unit testing.
 @interface FLTCam ()
 
+/// The output for video capturing.
 @property(readonly, nonatomic) AVCaptureVideoDataOutput *captureVideoOutput;
 
+/// The output for photo capturing. Exposed setter for unit tests.
+@property(strong, nonatomic) AVCapturePhotoOutput *capturePhotoOutput API_AVAILABLE(ios(10));
+
+/// A dictionary to retain all in-progress FLTSavePhotoDelegates. The key of the dictionary is the
+/// AVCapturePhotoSettings's uniqueID for each photo capture operation, and the value is the
+/// FLTSavePhotoDelegate that handles the result of each photo capture operation. Note that photo
+/// capture operations may overlap, so we have to keep track of multiple delegates in progress,
+/// instead of just a single delegate reference.
+@property(readonly, nonatomic)
+    NSMutableDictionary<NSNumber *, FLTSavePhotoDelegate *> *inProgressSavePhotoDelegates;
+
 @end
diff --git a/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.h b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.h
index a773b46..40e4562 100644
--- a/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.h
+++ b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.h
@@ -4,34 +4,35 @@
 
 @import AVFoundation;
 @import Foundation;
-@import Flutter;
 
 #import "FLTThreadSafeFlutterResult.h"
 
 NS_ASSUME_NONNULL_BEGIN
 
+/// The completion handler block for save photo operations.
+/// Can be called from either main queue or IO queue.
+/// If success, `error` will be present and `path` will be nil. Otherewise, `error` will be nil and
+/// `path` will be present.
+/// @param path the path for successfully saved photo file.
+/// @param error photo capture error or IO error.
+typedef void (^FLTSavePhotoDelegateCompletionHandler)(NSString *_Nullable path,
+                                                      NSError *_Nullable error);
+
 /**
  Delegate object that handles photo capture results.
  */
 @interface FLTSavePhotoDelegate : NSObject <AVCapturePhotoCaptureDelegate>
-/// The file path for the captured photo.
-@property(readonly, nonatomic) NSString *path;
-/// The thread safe flutter result wrapper to report the result.
-@property(readonly, nonatomic) FLTThreadSafeFlutterResult *result;
-/// The queue on which captured photos are wrote to disk.
-@property(strong, nonatomic) dispatch_queue_t ioQueue;
-/// Used to keep the delegate alive until didFinishProcessingPhotoSampleBuffer.
-@property(strong, nonatomic, nullable) FLTSavePhotoDelegate *selfReference;
 
 /**
  * Initialize a photo capture delegate.
  * @param path the path for captured photo file.
- * @param result the thread safe flutter result wrapper to report the result.
- * @param ioQueue the queue on which captured photos are wrote to disk.
+ * @param ioQueue the queue on which captured photos are written to disk.
+ * @param completionHandler The completion handler block for save photo operations. Can
+ * be called from either main queue or IO queue.
  */
 - (instancetype)initWithPath:(NSString *)path
-                      result:(FLTThreadSafeFlutterResult *)result
-                     ioQueue:(dispatch_queue_t)ioQueue;
+                     ioQueue:(dispatch_queue_t)ioQueue
+           completionHandler:(FLTSavePhotoDelegateCompletionHandler)completionHandler;
 @end
 
 NS_ASSUME_NONNULL_END
diff --git a/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.m b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.m
index 8dadfec..ced3cb5 100644
--- a/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.m
+++ b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate.m
@@ -3,37 +3,41 @@
 // found in the LICENSE file.
 
 #import "FLTSavePhotoDelegate.h"
+#import "FLTSavePhotoDelegate_Test.h"
+
+@interface FLTSavePhotoDelegate ()
+/// The file path for the captured photo.
+@property(readonly, nonatomic) NSString *path;
+/// The queue on which captured photos are written to disk.
+@property(readonly, nonatomic) dispatch_queue_t ioQueue;
+@end
 
 @implementation FLTSavePhotoDelegate
 
 - (instancetype)initWithPath:(NSString *)path
-                      result:(FLTThreadSafeFlutterResult *)result
-                     ioQueue:(dispatch_queue_t)ioQueue {
+                     ioQueue:(dispatch_queue_t)ioQueue
+           completionHandler:(FLTSavePhotoDelegateCompletionHandler)completionHandler {
   self = [super init];
   NSAssert(self, @"super init cannot be nil");
   _path = path;
-  _selfReference = self;
-  _result = result;
   _ioQueue = ioQueue;
+  _completionHandler = completionHandler;
   return self;
 }
 
 - (void)handlePhotoCaptureResultWithError:(NSError *)error
                         photoDataProvider:(NSData * (^)(void))photoDataProvider {
-  self.selfReference = nil;
   if (error) {
-    [self.result sendError:error];
+    self.completionHandler(nil, error);
     return;
   }
   dispatch_async(self.ioQueue, ^{
     NSData *data = photoDataProvider();
     NSError *ioError;
     if ([data writeToFile:self.path options:NSDataWritingAtomic error:&ioError]) {
-      [self.result sendSuccessWithData:self.path];
+      self.completionHandler(self.path, nil);
     } else {
-      [self.result sendErrorWithCode:@"IOError"
-                             message:@"Unable to write file"
-                             details:ioError.localizedDescription];
+      self.completionHandler(nil, ioError);
     }
   });
 }
diff --git a/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate_Test.h b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate_Test.h
index c0b77c7..2d0d4f9 100644
--- a/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate_Test.h
+++ b/packages/camera/camera/ios/Classes/FLTSavePhotoDelegate_Test.h
@@ -9,6 +9,11 @@
  */
 @interface FLTSavePhotoDelegate ()
 
+/// The completion handler block for capture and save photo operations.
+/// Can be called from either main queue or IO queue.
+/// Exposed for unit tests to manually trigger the completion.
+@property(readonly, nonatomic) FLTSavePhotoDelegateCompletionHandler completionHandler;
+
 /// Handler to write captured photo data into a file.
 /// @param error the capture error.
 /// @param photoDataProvider a closure that provides photo data.
diff --git a/packages/camera/camera/ios/Classes/QueueHelper.h b/packages/camera/camera/ios/Classes/QueueHelper.h
index c254814..dc73732 100644
--- a/packages/camera/camera/ios/Classes/QueueHelper.h
+++ b/packages/camera/camera/ios/Classes/QueueHelper.h
@@ -6,8 +6,18 @@
 
 NS_ASSUME_NONNULL_BEGIN
 
+/// Queue-specific context data to be associated with the capture session queue.
+extern const char *FLTCaptureSessionQueueSpecific;
+
+/// A class that contains dispatch queue related helper functions.
 @interface QueueHelper : NSObject
+
+/// Ensures the given block to be run on the main queue.
+/// If caller site is already on the main queue, the block will be run synchronously. Otherwise, the
+/// block will be dispatched asynchronously to the main queue.
+/// @param block the block to be run on the main queue.
 + (void)ensureToRunOnMainQueue:(void (^)(void))block;
+
 @end
 
 NS_ASSUME_NONNULL_END
diff --git a/packages/camera/camera/ios/Classes/QueueHelper.m b/packages/camera/camera/ios/Classes/QueueHelper.m
index 194dfa9..2cef7b6 100644
--- a/packages/camera/camera/ios/Classes/QueueHelper.m
+++ b/packages/camera/camera/ios/Classes/QueueHelper.m
@@ -4,7 +4,10 @@
 
 #import "QueueHelper.h"
 
+const char *FLTCaptureSessionQueueSpecific = "capture_session_queue";
+
 @implementation QueueHelper
+
 + (void)ensureToRunOnMainQueue:(void (^)(void))block {
   if (!NSThread.isMainThread) {
     dispatch_async(dispatch_get_main_queue(), block);
@@ -12,4 +15,5 @@
     block();
   }
 }
+
 @end
diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml
index 7421a77..80d0393 100644
--- a/packages/camera/camera/pubspec.yaml
+++ b/packages/camera/camera/pubspec.yaml
@@ -4,7 +4,7 @@
   Dart.
 repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22
-version: 0.9.4+12
+version: 0.9.4+13
 
 environment:
   sdk: ">=2.14.0 <3.0.0"