Skip to content

Commit

Permalink
[camerax] Make fixes required to swap camera_android_camerax for came…
Browse files Browse the repository at this point in the history
…ra_android (#6697)

Makes changes needed to land #6629. Specifically:

- Fixes timing issue with `stopVideoRecording` such that the `Future` it returns will only complete when CameraX reports that the recording is finalized (via listening for the [finalized video recording event](https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize))
- Modifies `startVideoCapturing` such that the `Future` it returns will only complete when CameraX reports that video capturing has started (via listening for the [started video recording event](https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Start))
- Adds empty implementation and TODO for implementing `setDescriptionWhileRecording`
  • Loading branch information
camsim99 committed May 10, 2024
1 parent a8e9147 commit 1ab2f5b
Show file tree
Hide file tree
Showing 14 changed files with 501 additions and 32 deletions.
8 changes: 8 additions & 0 deletions packages/camera/camera_android_camerax/CHANGELOG.md
@@ -1,3 +1,11 @@
## 0.6.5

* Modifies `stopVideoRecording` to ensure that the method only returns when CameraX reports that the
recorded video finishes saving to a file.
* Modifies `startVideoCapturing` to ensure that the method only returns when CameraX reports that
video recording has started.
* Adds empty implementation for `setDescriptionWhileRecording` and leaves a todo to add this feature.

## 0.6.4+1

* Adds empty implementation for `prepareForVideoRecording` since this optimization is not used on Android.
Expand Down
13 changes: 5 additions & 8 deletions packages/camera/camera_android_camerax/README.md
Expand Up @@ -37,6 +37,10 @@ use cases, the plugin behaves according to the following:
video recording and image streaming is supported, but concurrent video recording, image
streaming, and image capture is not supported.

### `setDescriptionWhileRecording` is unimplemented [Issue #148013][148013]
`setDescriptionWhileRecording`, used to switch cameras while recording video, is currently unimplemented
due to this not currently being supported by CameraX.

### 240p resolution configuration for video recording

240p resolution configuration for video recording is unsupported by CameraX,
Expand Down Expand Up @@ -64,11 +68,4 @@ For more information on contributing to this plugin, see [`CONTRIBUTING.md`](CON
[6]: https://developer.android.com/media/camera/camerax/architecture#combine-use-cases
[7]: https://developer.android.com/reference/android/hardware/camera2/CameraMetadata#INFO_SUPPORTED_HARDWARE_LEVEL_3
[8]: https://developer.android.com/reference/android/hardware/camera2/CameraMetadata#INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED
[120462]: https://github.com/flutter/flutter/issues/120462
[125915]: https://github.com/flutter/flutter/issues/125915
[120715]: https://github.com/flutter/flutter/issues/120715
[120468]: https://github.com/flutter/flutter/issues/120468
[120467]: https://github.com/flutter/flutter/issues/120467
[125371]: https://github.com/flutter/flutter/issues/125371
[126477]: https://github.com/flutter/flutter/issues/126477
[127896]: https://github.com/flutter/flutter/issues/127896
[148013]: https://github.com/flutter/flutter/issues/148013
Expand Up @@ -146,6 +146,22 @@ private VideoResolutionFallbackRule(final int index) {
}
}

/**
* Video recording status.
*
* <p>See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.
*/
public enum VideoRecordEvent {
START(0),
FINALIZE(1);

final int index;

private VideoRecordEvent(final int index) {
this.index = index;
}
}

/**
* The types of capture request options this plugin currently supports.
*
Expand Down Expand Up @@ -558,6 +574,55 @@ ArrayList<Object> toList() {
}
}

/** Generated class from Pigeon that represents data sent in messages. */
public static final class VideoRecordEventData {
private @NonNull VideoRecordEvent value;

public @NonNull VideoRecordEvent getValue() {
return value;
}

public void setValue(@NonNull VideoRecordEvent setterArg) {
if (setterArg == null) {
throw new IllegalStateException("Nonnull field \"value\" is null.");
}
this.value = setterArg;
}

/** Constructor is non-public to enforce null safety; use Builder. */
VideoRecordEventData() {}

public static final class Builder {

private @Nullable VideoRecordEvent value;

public @NonNull Builder setValue(@NonNull VideoRecordEvent setterArg) {
this.value = setterArg;
return this;
}

public @NonNull VideoRecordEventData build() {
VideoRecordEventData pigeonReturn = new VideoRecordEventData();
pigeonReturn.setValue(value);
return pigeonReturn;
}
}

@NonNull
ArrayList<Object> toList() {
ArrayList<Object> toListResult = new ArrayList<Object>(1);
toListResult.add(value == null ? null : value.index);
return toListResult;
}

static @NonNull VideoRecordEventData fromList(@NonNull ArrayList<Object> list) {
VideoRecordEventData pigeonResult = new VideoRecordEventData();
Object value = list.get(0);
pigeonResult.setValue(value == null ? null : VideoRecordEvent.values()[(int) value]);
return pigeonResult;
}
}

/**
* Convenience class for building [FocusMeteringAction]s with multiple metering points.
*
Expand Down Expand Up @@ -2118,6 +2183,34 @@ static void setup(
}
}
}

private static class PendingRecordingFlutterApiCodec extends StandardMessageCodec {
public static final PendingRecordingFlutterApiCodec INSTANCE =
new PendingRecordingFlutterApiCodec();

private PendingRecordingFlutterApiCodec() {}

@Override
protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) {
switch (type) {
case (byte) 128:
return VideoRecordEventData.fromList((ArrayList<Object>) readValue(buffer));
default:
return super.readValueOfType(type, buffer);
}
}

@Override
protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) {
if (value instanceof VideoRecordEventData) {
stream.write(128);
writeValue(stream, ((VideoRecordEventData) value).toList());
} else {
super.writeValue(stream, value);
}
}
}

/** Generated class from Pigeon that represents Flutter messages that can be called from Java. */
public static class PendingRecordingFlutterApi {
private final @NonNull BinaryMessenger binaryMessenger;
Expand All @@ -2133,7 +2226,7 @@ public interface Reply<T> {
}
/** The codec used by PendingRecordingFlutterApi. */
static @NonNull MessageCodec<Object> getCodec() {
return new StandardMessageCodec();
return PendingRecordingFlutterApiCodec.INSTANCE;
}

public void create(@NonNull Long identifierArg, @NonNull Reply<Void> callback) {
Expand All @@ -2144,6 +2237,18 @@ public void create(@NonNull Long identifierArg, @NonNull Reply<Void> callback) {
new ArrayList<Object>(Collections.singletonList(identifierArg)),
channelReply -> callback.reply(null));
}

public void onVideoRecordingEvent(
@NonNull VideoRecordEventData eventArg, @NonNull Reply<Void> callback) {
BasicMessageChannel<Object> channel =
new BasicMessageChannel<>(
binaryMessenger,
"dev.flutter.pigeon.PendingRecordingFlutterApi.onVideoRecordingEvent",
getCodec());
channel.send(
new ArrayList<Object>(Collections.singletonList(eventArg)),
channelReply -> callback.reply(null));
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
public interface RecordingHostApi {
Expand Down Expand Up @@ -4027,6 +4132,8 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) {
return ResolutionInfo.fromList((ArrayList<Object>) readValue(buffer));
case (byte) 134:
return VideoQualityData.fromList((ArrayList<Object>) readValue(buffer));
case (byte) 135:
return VideoRecordEventData.fromList((ArrayList<Object>) readValue(buffer));
default:
return super.readValueOfType(type, buffer);
}
Expand Down Expand Up @@ -4055,6 +4162,9 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) {
} else if (value instanceof VideoQualityData) {
stream.write(134);
writeValue(stream, ((VideoQualityData) value).toList());
} else if (value instanceof VideoRecordEventData) {
stream.write(135);
writeValue(stream, ((VideoRecordEventData) value).toList());
} else {
super.writeValue(stream, value);
}
Expand Down
Expand Up @@ -9,6 +9,8 @@
import androidx.camera.video.PendingRecording;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugins.camerax.GeneratedCameraXLibrary.PendingRecordingFlutterApi;
import io.flutter.plugins.camerax.GeneratedCameraXLibrary.VideoRecordEvent;
import io.flutter.plugins.camerax.GeneratedCameraXLibrary.VideoRecordEventData;

public class PendingRecordingFlutterApiImpl extends PendingRecordingFlutterApi {
private final InstanceManager instanceManager;
Expand All @@ -22,4 +24,14 @@ public PendingRecordingFlutterApiImpl(
void create(@NonNull PendingRecording pendingRecording, @Nullable Reply<Void> reply) {
create(instanceManager.addHostCreatedInstance(pendingRecording), reply);
}

void sendVideoRecordingFinalizedEvent(@NonNull Reply<Void> reply) {
super.onVideoRecordingEvent(
new VideoRecordEventData.Builder().setValue(VideoRecordEvent.FINALIZE).build(), reply);
}

void sendVideoRecordingStartedEvent(@NonNull Reply<Void> reply) {
super.onVideoRecordingEvent(
new VideoRecordEventData.Builder().setValue(VideoRecordEvent.START).build(), reply);
}
}
Expand Up @@ -24,6 +24,8 @@ public class PendingRecordingHostApiImpl implements PendingRecordingHostApi {

@VisibleForTesting @NonNull public CameraXProxy cameraXProxy = new CameraXProxy();

@VisibleForTesting PendingRecordingFlutterApiImpl pendingRecordingFlutterApi;

@VisibleForTesting SystemServicesFlutterApiImpl systemServicesFlutterApi;

@VisibleForTesting RecordingFlutterApiImpl recordingFlutterApi;
Expand All @@ -37,6 +39,8 @@ public PendingRecordingHostApiImpl(
this.context = context;
systemServicesFlutterApi = cameraXProxy.createSystemServicesFlutterApiImpl(binaryMessenger);
recordingFlutterApi = new RecordingFlutterApiImpl(binaryMessenger, instanceManager);
pendingRecordingFlutterApi =
new PendingRecordingFlutterApiImpl(binaryMessenger, instanceManager);
}

/** Sets the context, which is used to get the {@link Executor} needed to start the recording. */
Expand Down Expand Up @@ -73,10 +77,16 @@ public Executor getExecutor() {
/**
* Handles {@link VideoRecordEvent}s that come in during video recording. Sends any errors
* encountered using {@link SystemServicesFlutterApiImpl}.
*
* <p>Currently only sends {@link VideoRecordEvent.Start} and {@link VideoRecordEvent.Finalize}
* events to the Dart side.
*/
@VisibleForTesting
public void handleVideoRecordEvent(@NonNull VideoRecordEvent event) {
if (event instanceof VideoRecordEvent.Finalize) {
if (event instanceof VideoRecordEvent.Start) {
pendingRecordingFlutterApi.sendVideoRecordingStartedEvent(reply -> {});
} else if (event instanceof VideoRecordEvent.Finalize) {
pendingRecordingFlutterApi.sendVideoRecordingFinalizedEvent(reply -> {});
VideoRecordEvent.Finalize castedEvent = (VideoRecordEvent.Finalize) event;
if (castedEvent.hasError()) {
String cameraErrorMessage;
Expand Down
Expand Up @@ -41,6 +41,7 @@ public class PendingRecordingTest {
@Mock public RecordingFlutterApiImpl mockRecordingFlutterApi;
@Mock public Context mockContext;
@Mock public SystemServicesFlutterApiImpl mockSystemServicesFlutterApi;
@Mock public PendingRecordingFlutterApiImpl mockPendingRecordingFlutterApi;
@Mock public VideoRecordEvent.Finalize event;
@Mock public Throwable throwable;

Expand Down Expand Up @@ -80,6 +81,7 @@ public void testHandleVideoRecordEventSendsError() {
PendingRecordingHostApiImpl pendingRecordingHostApi =
new PendingRecordingHostApiImpl(mockBinaryMessenger, testInstanceManager, mockContext);
pendingRecordingHostApi.systemServicesFlutterApi = mockSystemServicesFlutterApi;
pendingRecordingHostApi.pendingRecordingFlutterApi = mockPendingRecordingFlutterApi;
final String eventMessage = "example failure message";

when(event.hasError()).thenReturn(true);
Expand All @@ -89,9 +91,35 @@ public void testHandleVideoRecordEventSendsError() {

pendingRecordingHostApi.handleVideoRecordEvent(event);

verify(mockPendingRecordingFlutterApi).sendVideoRecordingFinalizedEvent(any());
verify(mockSystemServicesFlutterApi).sendCameraError(eq(eventMessage), any());
}

@Test
public void handleVideoRecordEvent_SendsVideoRecordingFinalizedEvent() {
PendingRecordingHostApiImpl pendingRecordingHostApi =
new PendingRecordingHostApiImpl(mockBinaryMessenger, testInstanceManager, mockContext);
pendingRecordingHostApi.pendingRecordingFlutterApi = mockPendingRecordingFlutterApi;

when(event.hasError()).thenReturn(false);

pendingRecordingHostApi.handleVideoRecordEvent(event);

verify(mockPendingRecordingFlutterApi).sendVideoRecordingFinalizedEvent(any());
}

@Test
public void handleVideoRecordEvent_SendsVideoRecordingStartedEvent() {
PendingRecordingHostApiImpl pendingRecordingHostApi =
new PendingRecordingHostApiImpl(mockBinaryMessenger, testInstanceManager, mockContext);
pendingRecordingHostApi.pendingRecordingFlutterApi = mockPendingRecordingFlutterApi;
VideoRecordEvent.Start mockStartEvent = mock(VideoRecordEvent.Start.class);

pendingRecordingHostApi.handleVideoRecordEvent(mockStartEvent);

verify(mockPendingRecordingFlutterApi).sendVideoRecordingStartedEvent(any());
}

@Test
public void flutterApiCreateTest() {
final PendingRecordingFlutterApiImpl spyPendingRecordingFlutterApi =
Expand Down
Expand Up @@ -13,6 +13,7 @@ import 'package:camera_platform_interface/camera_platform_interface.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:video_player/video_player.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
Expand Down Expand Up @@ -178,4 +179,81 @@ void main() {
}
}
});

testWidgets('Video capture records valid video', (WidgetTester tester) async {
final List<CameraDescription> cameras = await availableCameras();
if (cameras.isEmpty) {
return;
}

final CameraController controller = CameraController(cameras[0],
mediaSettings:
const MediaSettings(resolutionPreset: ResolutionPreset.low));
await controller.initialize();
await controller.prepareForVideoRecording();

await controller.startVideoRecording();
final int recordingStart = DateTime.now().millisecondsSinceEpoch;

sleep(const Duration(seconds: 2));

final XFile file = await controller.stopVideoRecording();
final int postStopTime =
DateTime.now().millisecondsSinceEpoch - recordingStart;

final File videoFile = File(file.path);
final VideoPlayerController videoController = VideoPlayerController.file(
videoFile,
);
await videoController.initialize();
final int duration = videoController.value.duration.inMilliseconds;
await videoController.dispose();

expect(duration, lessThan(postStopTime));
});

testWidgets('Pause and resume video recording', (WidgetTester tester) async {
final List<CameraDescription> cameras = await availableCameras();
if (cameras.isEmpty) {
return;
}

final CameraController controller = CameraController(cameras[0],
mediaSettings:
const MediaSettings(resolutionPreset: ResolutionPreset.low));
await controller.initialize();
await controller.prepareForVideoRecording();

int startPause;
int timePaused = 0;
const int pauseIterations = 2;

await controller.startVideoRecording();
final int recordingStart = DateTime.now().millisecondsSinceEpoch;
sleep(const Duration(milliseconds: 500));

for (int i = 0; i < pauseIterations; i++) {
await controller.pauseVideoRecording();
startPause = DateTime.now().millisecondsSinceEpoch;
sleep(const Duration(milliseconds: 500));
await controller.resumeVideoRecording();
timePaused += DateTime.now().millisecondsSinceEpoch - startPause;

sleep(const Duration(milliseconds: 500));
}

final XFile file = await controller.stopVideoRecording();
final int recordingTime =
DateTime.now().millisecondsSinceEpoch - recordingStart;

final File videoFile = File(file.path);
final VideoPlayerController videoController = VideoPlayerController.file(
videoFile,
);
await videoController.initialize();
final int duration = videoController.value.duration.inMilliseconds;
await videoController.dispose();

expect(duration, lessThan(recordingTime - timePaused));
});
}

0 comments on commit 1ab2f5b

Please sign in to comment.