From 356a28e57072774a3a5428f89768a9f08f6f2fe1 Mon Sep 17 00:00:00 2001 From: Takuto Tanaka Date: Tue, 15 Jul 2025 19:23:27 +0900 Subject: [PATCH 1/2] fix(camera_avfoundation): Add nil guard for CMSampleBufferGetImageBuffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard against nil image buffer that can occur right after recording stops - Decrement pending frame count when skipping nil buffers - Add comprehensive test coverage for nil buffer handling Fixes crash: "Thread 55: Fatal error: Unexpectedly found nil while unwrapping an Optional value" 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../RunnerTests/StreamingNilBufferTests.swift | 200 ++++++++++++++++++ .../camera_avfoundation/DefaultCamera.swift | 6 +- 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 packages/camera/camera_avfoundation/example/ios/RunnerTests/StreamingNilBufferTests.swift diff --git a/packages/camera/camera_avfoundation/example/ios/RunnerTests/StreamingNilBufferTests.swift b/packages/camera/camera_avfoundation/example/ios/RunnerTests/StreamingNilBufferTests.swift new file mode 100644 index 00000000000..9b824bc807d --- /dev/null +++ b/packages/camera/camera_avfoundation/example/ios/RunnerTests/StreamingNilBufferTests.swift @@ -0,0 +1,200 @@ +// 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 AVFoundation +import XCTest + +@testable import camera_avfoundation + +// Import Objectice-C part of the implementation when SwiftPM is used. +#if canImport(camera_avfoundation_objc) + import camera_avfoundation_objc +#endif + +/// Helper function to create a sample buffer without an image buffer (nil) +/// This simulates the condition that occurs right after recording stops +private func createNilImageBufferSampleBuffer() -> CMSampleBuffer { + var blockBuffer: CMBlockBuffer? + CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, + memoryBlock: nil, + blockLength: 100, + blockAllocator: kCFAllocatorDefault, + customBlockSource: nil, + offsetToData: 0, + dataLength: 100, + flags: kCMBlockBufferAssureMemoryNowFlag, + blockBufferOut: &blockBuffer) + + var formatDescription: CMFormatDescription? + var basicDescription = AudioStreamBasicDescription( + mSampleRate: 44100, + mFormatID: kAudioFormatLinearPCM, + mFormatFlags: 0, + mBytesPerPacket: 1, + mFramesPerPacket: 1, + mBitsPerChannel: 16, + mChannelsPerFrame: 1, + mReserved: 0) + + CMAudioFormatDescriptionCreate( + allocator: kCFAllocatorDefault, + asbd: &basicDescription, + layoutSize: 0, + layout: nil, + magicCookieSize: 0, + magicCookie: nil, + extensions: nil, + formatDescriptionOut: &formatDescription) + + var timingInfo = CMSampleTimingInfo( + duration: CMTimeMake(value: 1, timescale: 44100), + presentationTimeStamp: CMTime.zero, + decodeTimeStamp: CMTime.invalid) + + var sampleBuffer: CMSampleBuffer? + CMSampleBufferCreate( + allocator: kCFAllocatorDefault, + dataBuffer: blockBuffer, + dataReady: true, + makeDataReadyCallback: nil, + refcon: nil, + formatDescription: formatDescription, + sampleCount: 1, + sampleTimingEntryCount: 1, + sampleTimingArray: &timingInfo, + sampleSizeEntryCount: 0, + sampleSizeArray: nil, + sampleBufferOut: &sampleBuffer) + + return sampleBuffer! +} + +final class StreamingNilBufferTests: XCTestCase { + func testStreamingWithNilImageBuffer() { + let captureSessionQueue = DispatchQueue(label: "testing") + let configuration = CameraTestUtils.createTestCameraConfiguration() + configuration.captureSessionQueue = captureSessionQueue + + let camera = CameraTestUtils.createTestCamera(configuration) + let testVideoOutput = camera.captureVideoOutput.avOutput + let testVideoConnection = CameraTestUtils.createTestConnection(testVideoOutput) + + let handlerMock = MockImageStreamHandler() + var eventCallCount = 0 + + handlerMock.eventSinkStub = { event in + eventCallCount += 1 + } + + let finishStartStreamExpectation = expectation(description: "Finish startStream") + let messenger = MockFlutterBinaryMessenger() + + camera.startImageStream( + with: messenger, imageStreamHandler: handlerMock, + completion: { _ in + finishStartStreamExpectation.fulfill() + }) + + waitForExpectations(timeout: 30, handler: nil) + waitForQueueRoundTrip(with: DispatchQueue.main) + + XCTAssertEqual(camera.isStreamingImages, true) + XCTAssertEqual(camera.streamingPendingFramesCount, 0) + + // Send a nil image buffer sample (simulating post-recording condition) + let nilBufferSample = createNilImageBufferSampleBuffer() + camera.captureOutput(testVideoOutput, didOutput: nilBufferSample, from: testVideoConnection) + + // Verify that the frame count is still 0 (frame was skipped) + XCTAssertEqual(camera.streamingPendingFramesCount, 0) + XCTAssertEqual(eventCallCount, 0, "No events should be sent for nil image buffers") + + // Send a valid sample buffer to ensure streaming still works + let validSample = CameraTestUtils.createTestSampleBuffer() + camera.captureOutput(testVideoOutput, didOutput: validSample, from: testVideoConnection) + + // Wait a bit for async processing + waitForQueueRoundTrip(with: captureSessionQueue) + waitForQueueRoundTrip(with: DispatchQueue.main) + + // Verify that the valid frame was processed + XCTAssertEqual(camera.streamingPendingFramesCount, 1) + XCTAssertEqual(eventCallCount, 1, "Valid frame should trigger an event") + } + + func testStreamingWithMixedNilAndValidBuffers() { + let captureSessionQueue = DispatchQueue(label: "testing") + let configuration = CameraTestUtils.createTestCameraConfiguration() + configuration.captureSessionQueue = captureSessionQueue + + let camera = CameraTestUtils.createTestCamera(configuration) + let testVideoOutput = camera.captureVideoOutput.avOutput + let testVideoConnection = CameraTestUtils.createTestConnection(testVideoOutput) + + let handlerMock = MockImageStreamHandler() + var eventCallCount = 0 + + handlerMock.eventSinkStub = { event in + eventCallCount += 1 + } + + let finishStartStreamExpectation = expectation(description: "Finish startStream") + let messenger = MockFlutterBinaryMessenger() + + camera.startImageStream( + with: messenger, imageStreamHandler: handlerMock, + completion: { _ in + finishStartStreamExpectation.fulfill() + }) + + waitForExpectations(timeout: 30, handler: nil) + waitForQueueRoundTrip(with: DispatchQueue.main) + + // Send alternating nil and valid buffers + let nilBufferSample = createNilImageBufferSampleBuffer() + let validSample = CameraTestUtils.createTestSampleBuffer() + + camera.captureOutput(testVideoOutput, didOutput: validSample, from: testVideoConnection) + camera.captureOutput(testVideoOutput, didOutput: nilBufferSample, from: testVideoConnection) + camera.captureOutput(testVideoOutput, didOutput: validSample, from: testVideoConnection) + camera.captureOutput(testVideoOutput, didOutput: nilBufferSample, from: testVideoConnection) + camera.captureOutput(testVideoOutput, didOutput: validSample, from: testVideoConnection) + + // Wait for async processing + waitForQueueRoundTrip(with: captureSessionQueue) + waitForQueueRoundTrip(with: DispatchQueue.main) + + // Only valid buffers should be processed + XCTAssertEqual(eventCallCount, 3, "Only valid frames should trigger events") + } +} + +// Helper to wait for a dispatch queue to process pending operations +private func waitForQueueRoundTrip(with queue: DispatchQueue) { + let expectation = XCTestExpectation(description: "Queue round trip") + queue.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) +} + +// Mock class from StreamingTests.swift +private class MockImageStreamHandler: FLTImageStreamHandler { + var eventSinkStub: ((Any?) -> Void)? + + override var eventSink: FlutterEventSink? { + get { + if let stub = eventSinkStub { + return { event in + stub(event) + } + } + return nil + } + set { + eventSinkStub = newValue + } + } +} \ No newline at end of file diff --git a/packages/camera/camera_avfoundation/ios/camera_avfoundation/Sources/camera_avfoundation/DefaultCamera.swift b/packages/camera/camera_avfoundation/ios/camera_avfoundation/Sources/camera_avfoundation/DefaultCamera.swift index 4ebe0efec87..62441a054c0 100644 --- a/packages/camera/camera_avfoundation/ios/camera_avfoundation/Sources/camera_avfoundation/DefaultCamera.swift +++ b/packages/camera/camera_avfoundation/ios/camera_avfoundation/Sources/camera_avfoundation/DefaultCamera.swift @@ -276,7 +276,11 @@ final class DefaultCamera: FLTCam, Camera { { streamingPendingFramesCount += 1 - let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)! + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + // Skip this frame if pixelBuffer is nil (can happen right after recording stops) + streamingPendingFramesCount -= 1 + return + } // Must lock base address before accessing the pixel data CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) From 732f392eaf74929be424be50d52d9592622fbecb Mon Sep 17 00:00:00 2001 From: Takuto Tanaka Date: Tue, 15 Jul 2025 19:44:48 +0900 Subject: [PATCH 2/2] Apply swift format to StreamingNilBufferTests.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../RunnerTests/StreamingNilBufferTests.swift | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/camera/camera_avfoundation/example/ios/RunnerTests/StreamingNilBufferTests.swift b/packages/camera/camera_avfoundation/example/ios/RunnerTests/StreamingNilBufferTests.swift index 9b824bc807d..e685eed2b42 100644 --- a/packages/camera/camera_avfoundation/example/ios/RunnerTests/StreamingNilBufferTests.swift +++ b/packages/camera/camera_avfoundation/example/ios/RunnerTests/StreamingNilBufferTests.swift @@ -26,7 +26,7 @@ private func createNilImageBufferSampleBuffer() -> CMSampleBuffer { dataLength: 100, flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer) - + var formatDescription: CMFormatDescription? var basicDescription = AudioStreamBasicDescription( mSampleRate: 44100, @@ -37,7 +37,7 @@ private func createNilImageBufferSampleBuffer() -> CMSampleBuffer { mBitsPerChannel: 16, mChannelsPerFrame: 1, mReserved: 0) - + CMAudioFormatDescriptionCreate( allocator: kCFAllocatorDefault, asbd: &basicDescription, @@ -47,12 +47,12 @@ private func createNilImageBufferSampleBuffer() -> CMSampleBuffer { magicCookie: nil, extensions: nil, formatDescriptionOut: &formatDescription) - + var timingInfo = CMSampleTimingInfo( duration: CMTimeMake(value: 1, timescale: 44100), presentationTimeStamp: CMTime.zero, decodeTimeStamp: CMTime.invalid) - + var sampleBuffer: CMSampleBuffer? CMSampleBufferCreate( allocator: kCFAllocatorDefault, @@ -67,7 +67,7 @@ private func createNilImageBufferSampleBuffer() -> CMSampleBuffer { sampleSizeEntryCount: 0, sampleSizeArray: nil, sampleBufferOut: &sampleBuffer) - + return sampleBuffer! } @@ -76,96 +76,96 @@ final class StreamingNilBufferTests: XCTestCase { let captureSessionQueue = DispatchQueue(label: "testing") let configuration = CameraTestUtils.createTestCameraConfiguration() configuration.captureSessionQueue = captureSessionQueue - + let camera = CameraTestUtils.createTestCamera(configuration) let testVideoOutput = camera.captureVideoOutput.avOutput let testVideoConnection = CameraTestUtils.createTestConnection(testVideoOutput) - + let handlerMock = MockImageStreamHandler() var eventCallCount = 0 - + handlerMock.eventSinkStub = { event in eventCallCount += 1 } - + let finishStartStreamExpectation = expectation(description: "Finish startStream") let messenger = MockFlutterBinaryMessenger() - + camera.startImageStream( with: messenger, imageStreamHandler: handlerMock, completion: { _ in finishStartStreamExpectation.fulfill() }) - + waitForExpectations(timeout: 30, handler: nil) waitForQueueRoundTrip(with: DispatchQueue.main) - + XCTAssertEqual(camera.isStreamingImages, true) XCTAssertEqual(camera.streamingPendingFramesCount, 0) - + // Send a nil image buffer sample (simulating post-recording condition) let nilBufferSample = createNilImageBufferSampleBuffer() camera.captureOutput(testVideoOutput, didOutput: nilBufferSample, from: testVideoConnection) - + // Verify that the frame count is still 0 (frame was skipped) XCTAssertEqual(camera.streamingPendingFramesCount, 0) XCTAssertEqual(eventCallCount, 0, "No events should be sent for nil image buffers") - + // Send a valid sample buffer to ensure streaming still works let validSample = CameraTestUtils.createTestSampleBuffer() camera.captureOutput(testVideoOutput, didOutput: validSample, from: testVideoConnection) - + // Wait a bit for async processing waitForQueueRoundTrip(with: captureSessionQueue) waitForQueueRoundTrip(with: DispatchQueue.main) - + // Verify that the valid frame was processed XCTAssertEqual(camera.streamingPendingFramesCount, 1) XCTAssertEqual(eventCallCount, 1, "Valid frame should trigger an event") } - + func testStreamingWithMixedNilAndValidBuffers() { let captureSessionQueue = DispatchQueue(label: "testing") let configuration = CameraTestUtils.createTestCameraConfiguration() configuration.captureSessionQueue = captureSessionQueue - + let camera = CameraTestUtils.createTestCamera(configuration) let testVideoOutput = camera.captureVideoOutput.avOutput let testVideoConnection = CameraTestUtils.createTestConnection(testVideoOutput) - + let handlerMock = MockImageStreamHandler() var eventCallCount = 0 - + handlerMock.eventSinkStub = { event in eventCallCount += 1 } - + let finishStartStreamExpectation = expectation(description: "Finish startStream") let messenger = MockFlutterBinaryMessenger() - + camera.startImageStream( with: messenger, imageStreamHandler: handlerMock, completion: { _ in finishStartStreamExpectation.fulfill() }) - + waitForExpectations(timeout: 30, handler: nil) waitForQueueRoundTrip(with: DispatchQueue.main) - + // Send alternating nil and valid buffers let nilBufferSample = createNilImageBufferSampleBuffer() let validSample = CameraTestUtils.createTestSampleBuffer() - + camera.captureOutput(testVideoOutput, didOutput: validSample, from: testVideoConnection) camera.captureOutput(testVideoOutput, didOutput: nilBufferSample, from: testVideoConnection) camera.captureOutput(testVideoOutput, didOutput: validSample, from: testVideoConnection) camera.captureOutput(testVideoOutput, didOutput: nilBufferSample, from: testVideoConnection) camera.captureOutput(testVideoOutput, didOutput: validSample, from: testVideoConnection) - + // Wait for async processing waitForQueueRoundTrip(with: captureSessionQueue) waitForQueueRoundTrip(with: DispatchQueue.main) - + // Only valid buffers should be processed XCTAssertEqual(eventCallCount, 3, "Only valid frames should trigger events") } @@ -183,7 +183,7 @@ private func waitForQueueRoundTrip(with queue: DispatchQueue) { // Mock class from StreamingTests.swift private class MockImageStreamHandler: FLTImageStreamHandler { var eventSinkStub: ((Any?) -> Void)? - + override var eventSink: FlutterEventSink? { get { if let stub = eventSinkStub { @@ -197,4 +197,4 @@ private class MockImageStreamHandler: FLTImageStreamHandler { eventSinkStub = newValue } } -} \ No newline at end of file +}