diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/CameraView/CameraViewPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Views/CameraView/CameraViewPage.xaml
index f34ac7d3a7..d90bcd224f 100644
--- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/CameraView/CameraViewPage.xaml
+++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/CameraView/CameraViewPage.xaml
@@ -57,7 +57,7 @@
-
+
@@ -73,15 +73,28 @@
ItemsSource="{Binding FlashModes}"
SelectedItem="{Binding FlashMode}" />
-
+ Text="Start Camera Preview" />
+ Text="Stop Camera Preview" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/CameraView/CameraViewPage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Views/CameraView/CameraViewPage.xaml.cs
index 3ee62783b4..38a987a1c5 100644
--- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/CameraView/CameraViewPage.xaml.cs
+++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/CameraView/CameraViewPage.xaml.cs
@@ -1,26 +1,28 @@
using System.Diagnostics;
using CommunityToolkit.Maui.Core;
using CommunityToolkit.Maui.Sample.ViewModels.Views;
+using CommunityToolkit.Maui.Storage;
namespace CommunityToolkit.Maui.Sample.Pages.Views;
-public partial class CameraViewPage : BasePage
+public sealed partial class CameraViewPage : BasePage
{
+ readonly IFileSaver fileSaver;
readonly string imagePath;
+
int pageCount;
+ Stream videoRecordingStream = Stream.Null;
- public CameraViewPage(CameraViewViewModel viewModel, IFileSystem fileSystem) : base(viewModel)
+ public CameraViewPage(CameraViewViewModel viewModel, IFileSystem fileSystem, IFileSaver fileSaver) : base(viewModel)
{
InitializeComponent();
+ this.fileSaver = fileSaver;
imagePath = Path.Combine(fileSystem.CacheDirectory, "camera-view-image.jpg");
Camera.MediaCaptured += OnMediaCaptured;
- Loaded += (s, e) =>
- {
- pageCount = Navigation.NavigationStack.Count;
- };
+ Loaded += (s, e) => { pageCount = Navigation.NavigationStack.Count; };
}
protected override async void OnAppearing()
@@ -51,6 +53,7 @@ async void OnImageTapped(object? sender, TappedEventArgs args)
{
return;
}
+
await Navigation.PushAsync(new ImageViewPage(imagePath));
}
@@ -82,7 +85,6 @@ void OnMediaCaptured(object? sender, MediaCapturedEventArgs e)
debugText.Text = $"Image saved to {imagePath}";
});
-
}
void ZoomIn(object? sender, EventArgs e)
@@ -94,4 +96,37 @@ void ZoomOut(object? sender, EventArgs e)
{
Camera.ZoomFactor -= 1.0f;
}
+
+ async void SetNightMode(object? sender, EventArgs e)
+ {
+#if ANDROID
+ await Camera.SetExtensionMode(AndroidX.Camera.Extensions.ExtensionMode.Night);
+#else
+ await Task.CompletedTask;
+#endif
+ }
+
+ async void StartCameraRecording(object? sender, EventArgs e)
+ {
+ await Camera.StartVideoRecording(CancellationToken.None);
+ }
+
+ async void StopCameraRecording(object? sender, EventArgs e)
+ {
+ videoRecordingStream = await Camera.StopVideoRecording(CancellationToken.None);
+ }
+
+ async void SaveVideo(object? sender, EventArgs e)
+ {
+ if (videoRecordingStream == Stream.Null)
+ {
+ await DisplayAlert("Unable to Save Video", "Stream is null", "OK");
+ }
+ else
+ {
+ await fileSaver.SaveAsync("recording.mp4", videoRecordingStream);
+ await videoRecordingStream.DisposeAsync();
+ videoRecordingStream = Stream.Null;
+ }
+ }
}
\ No newline at end of file
diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/CameraView/CameraViewViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/CameraView/CameraViewViewModel.cs
index e1b5804f65..5f44560acc 100644
--- a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/CameraView/CameraViewViewModel.cs
+++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/CameraView/CameraViewViewModel.cs
@@ -107,7 +107,7 @@ void UpdateResolutionText()
{
ResolutionText = $"Selected Resolution: {SelectedResolution.Width} x {SelectedResolution.Height}";
}
-
+
void HandleAvailableCamerasChanged(object? sender, IReadOnlyList? e)
{
OnPropertyChanged(nameof(Cameras));
diff --git a/src/CommunityToolkit.Maui.Camera/CameraManager.android.cs b/src/CommunityToolkit.Maui.Camera/CameraManager.android.cs
index c642ca4218..a7e5b10138 100644
--- a/src/CommunityToolkit.Maui.Camera/CameraManager.android.cs
+++ b/src/CommunityToolkit.Maui.Camera/CameraManager.android.cs
@@ -1,17 +1,22 @@
using System.Runtime.Versioning;
using Android.Content;
+using Android.Provider;
+using Android.Runtime;
using Android.Views;
using AndroidX.Camera.Core;
-using AndroidX.Camera.Core.Impl.Utils.Futures;
using AndroidX.Camera.Core.ResolutionSelector;
+using AndroidX.Camera.Extensions;
using AndroidX.Camera.Lifecycle;
+using AndroidX.Camera.Video;
using AndroidX.Core.Content;
+using AndroidX.Core.Util;
using AndroidX.Lifecycle;
using CommunityToolkit.Maui.Extensions;
using Java.Lang;
using Java.Util.Concurrent;
-using static Android.Media.Image;
+using Image = Android.Media.Image;
using Math = System.Math;
+using Object = Java.Lang.Object;
namespace CommunityToolkit.Maui.Core;
@@ -25,12 +30,36 @@ partial class CameraManager
ProcessCameraProvider? processCameraProvider;
ImageCapture? imageCapture;
ImageCallBack? imageCallback;
+ VideoCapture? videoCapture;
+ Recorder? videoRecorder;
+ Recording? videoRecording;
ICamera? camera;
ICameraControl? cameraControl;
Preview? cameraPreview;
ResolutionSelector? resolutionSelector;
ResolutionFilter? resolutionFilter;
OrientationListener? orientationListener;
+ Java.IO.File? videoRecordingFile;
+ TaskCompletionSource? videoRecordingFinalizeTcs;
+ Stream? videoRecordingStream;
+ int extensionMode = ExtensionMode.Auto;
+
+ public async Task SetExtensionMode(int mode, CancellationToken token)
+ {
+ extensionMode = mode;
+ if (cameraView.SelectedCamera is null
+ || processCameraProvider is null
+ || cameraPreview is null
+ || imageCapture is null
+ || videoCapture is null)
+ {
+ return;
+ }
+
+ camera = await RebindCamera(processCameraProvider, cameraView.SelectedCamera, token, cameraPreview, imageCapture, videoCapture);
+
+ cameraControl = camera.CameraControl;
+ }
public void Dispose()
{
@@ -47,6 +76,7 @@ public NativePlatformCameraPreviewView CreatePlatformView()
{
previewView.SetScaleType(NativePlatformCameraPreviewView.ScaleType.FitCenter);
}
+
cameraExecutor = Executors.NewSingleThreadExecutor() ?? throw new CameraException($"Unable to retrieve {nameof(IExecutorService)}");
orientationListener = new OrientationListener(SetImageCaptureTargetRotation, context);
orientationListener.Enable();
@@ -74,7 +104,7 @@ public async partial ValueTask UpdateCaptureResolution(Size resolution, Cancella
if (resolutionFilter is not null)
{
if (Math.Abs(resolutionFilter.TargetSize.Width - resolution.Width) < double.Epsilon &&
- Math.Abs(resolutionFilter.TargetSize.Height - resolution.Height) < double.Epsilon)
+ Math.Abs(resolutionFilter.TargetSize.Height - resolution.Height) < double.Epsilon)
{
return;
}
@@ -94,9 +124,9 @@ public async partial ValueTask UpdateCaptureResolution(Size resolution, Cancella
resolutionSelector?.Dispose();
resolutionSelector = new ResolutionSelector.Builder()
- .SetAllowedResolutionMode(ResolutionSelector.PreferHigherResolutionOverCaptureRate)
- .SetResolutionFilter(resolutionFilter)
- .Build();
+ .SetAllowedResolutionMode(ResolutionSelector.PreferHigherResolutionOverCaptureRate)
+ .SetResolutionFilter(resolutionFilter)
+ .Build();
if (IsInitialized)
{
@@ -108,6 +138,8 @@ protected virtual void Dispose(bool disposing)
{
if (disposing)
{
+ CleanupVideoRecordingResources();
+
camera?.Dispose();
camera = null;
@@ -123,6 +155,9 @@ protected virtual void Dispose(bool disposing)
imageCapture?.Dispose();
imageCapture = null;
+ videoCapture?.Dispose();
+ videoCapture = null;
+
imageCallback?.Dispose();
imageCallback = null;
@@ -141,6 +176,9 @@ protected virtual void Dispose(bool disposing)
orientationListener?.Disable();
orientationListener?.Dispose();
orientationListener = null;
+
+ videoRecordingStream?.Dispose();
+ videoRecordingStream = null;
}
}
@@ -171,7 +209,6 @@ protected virtual async partial Task PlatformConnectCamera(CancellationToken tok
await StartUseCase(token);
cameraProviderTCS.SetResult();
-
}), ContextCompat.GetMainExecutor(context));
await cameraProviderTCS.Task.WaitAsync(token);
@@ -189,20 +226,34 @@ protected async Task StartUseCase(CancellationToken token)
cameraPreview?.Dispose();
imageCapture?.Dispose();
+ videoCapture?.Dispose();
+ videoRecorder?.Dispose();
+
cameraPreview = new Preview.Builder().SetResolutionSelector(resolutionSelector).Build();
cameraPreview.SetSurfaceProvider(cameraExecutor, previewView?.SurfaceProvider);
imageCapture = new ImageCapture.Builder()
- .SetCaptureMode(ImageCapture.CaptureModeMaximizeQuality)
- .SetResolutionSelector(resolutionSelector)
- .Build();
+ .SetCaptureMode(ImageCapture.CaptureModeMaximizeQuality)
+ .SetResolutionSelector(resolutionSelector)
+ .Build();
+
+ var videoRecorderBuilder = new Recorder.Builder()
+ .SetExecutor(cameraExecutor);
+
+ if (Quality.Highest is not null)
+ {
+ videoRecorderBuilder = videoRecorderBuilder.SetQualitySelector(QualitySelector.From(Quality.Highest));
+ }
+
+ videoRecorder = videoRecorderBuilder.Build();
+ videoCapture = VideoCapture.WithOutput(videoRecorder);
await StartCameraPreview(token);
}
protected virtual async partial Task PlatformStartCameraPreview(CancellationToken token)
{
- if (previewView is null || processCameraProvider is null || cameraPreview is null || imageCapture is null)
+ if (previewView is null || processCameraProvider is null || cameraPreview is null || imageCapture is null || videoCapture is null)
{
return;
}
@@ -217,16 +268,11 @@ protected virtual async partial Task PlatformStartCameraPreview(CancellationToke
cameraView.SelectedCamera = cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");
}
- var cameraSelector = cameraView.SelectedCamera.CameraSelector ?? throw new CameraException($"Unable to retrieve {nameof(CameraSelector)}");
-
- var owner = (ILifecycleOwner)context;
- camera = processCameraProvider.BindToLifecycle(owner, cameraSelector, cameraPreview, imageCapture);
-
+ camera = await RebindCamera(processCameraProvider, cameraView.SelectedCamera, token, cameraPreview, imageCapture, videoCapture);
cameraControl = camera.CameraControl;
- //start the camera with AutoFocus
- MeteringPoint point = previewView.MeteringPointFactory.CreatePoint(previewView.Width / 2.0f, previewView.Height / 2.0f, 0.1f);
- FocusMeteringAction action = new FocusMeteringAction.Builder(point).Build();
+ var point = previewView.MeteringPointFactory.CreatePoint(previewView.Width / 2.0f, previewView.Height / 2.0f, 0.1f);
+ var action = new FocusMeteringAction.Builder(point).Build();
camera.CameraControl.StartFocusAndMetering(action);
IsInitialized = true;
@@ -246,7 +292,6 @@ protected virtual partial void PlatformStopCameraPreview()
protected virtual partial void PlatformDisconnect()
{
-
}
protected virtual partial ValueTask PlatformTakePicture(CancellationToken token)
@@ -258,30 +303,153 @@ protected virtual partial ValueTask PlatformTakePicture(CancellationToken token)
return ValueTask.CompletedTask;
}
- void SetImageCaptureTargetRotation(int rotation)
+ protected virtual async partial Task PlatformStartVideoRecording(Stream stream, CancellationToken token)
{
- if (imageCapture is not null)
+ if (previewView is null
+ || processCameraProvider is null
+ || cameraPreview is null
+ || imageCapture is null
+ || videoCapture is null
+ || videoRecorder is null
+ || videoRecordingFile is not null)
{
- imageCapture.TargetRotation = rotation switch
+ return;
+ }
+
+ videoRecordingStream = stream;
+
+ if (cameraView.SelectedCamera is null)
+ {
+ if (cameraProvider.AvailableCameras is null)
{
- >= 45 and < 135 => (int)SurfaceOrientation.Rotation270,
- >= 135 and < 225 => (int)SurfaceOrientation.Rotation180,
- >= 225 and < 315 => (int)SurfaceOrientation.Rotation90,
- _ => (int)SurfaceOrientation.Rotation0
- };
+ await cameraProvider.RefreshAvailableCameras(token);
+ }
+
+ cameraView.SelectedCamera = cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");
}
+
+ if (camera is null || !IsVideoCaptureAlreadyBound())
+ {
+ camera = await RebindCamera(processCameraProvider, cameraView.SelectedCamera, token, cameraPreview, imageCapture, videoCapture);
+ cameraControl = camera.CameraControl;
+ }
+
+ videoRecordingFile = new Java.IO.File(context.CacheDir, $"{DateTime.UtcNow.Ticks}.mp4");
+ videoRecordingFile.CreateNewFile();
+
+ var outputOptions = new FileOutputOptions.Builder(videoRecordingFile).Build();
+
+ videoRecordingFinalizeTcs = new TaskCompletionSource();
+ var captureListener = new CameraConsumer(videoRecordingFinalizeTcs);
+ var executor = ContextCompat.GetMainExecutor(context) ?? throw new CameraException($"Unable to retrieve {nameof(IExecutorService)}");
+ videoRecording = videoRecorder
+ .PrepareRecording(context, outputOptions)
+ .WithAudioEnabled()
+ .Start(executor, captureListener);
}
- sealed class FutureCallback(Action action, Action failure) : Java.Lang.Object, IFutureCallback
+ protected virtual async partial Task PlatformStopVideoRecording(CancellationToken token)
{
- public void OnSuccess(Java.Lang.Object? value)
+ ArgumentNullException.ThrowIfNull(cameraExecutor);
+ if (videoRecording is null
+ || videoRecordingFile is null
+ || videoRecordingFinalizeTcs is null
+ || videoRecordingStream is null)
{
- action.Invoke(value);
+ return Stream.Null;
}
- public void OnFailure(Throwable? throwable)
+ videoRecording.Stop();
+ await videoRecordingFinalizeTcs.Task.WaitAsync(token);
+
+ await using var inputStream = new FileStream(videoRecordingFile.AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ await inputStream.CopyToAsync(videoRecordingStream, token);
+ await videoRecordingStream.FlushAsync(token);
+ CleanupVideoRecordingResources();
+
+ return videoRecordingStream;
+ }
+
+ bool IsVideoCaptureAlreadyBound()
+ {
+ return processCameraProvider is not null
+ && videoCapture is not null
+ && processCameraProvider.IsBound(videoCapture);
+ }
+
+ void CleanupVideoRecordingResources()
+ {
+ videoRecording?.Dispose();
+ videoRecording = null;
+
+ if (videoRecordingFile is not null)
+ {
+ if (videoRecordingFile.Exists())
+ {
+ videoRecordingFile.Delete();
+ }
+
+ videoRecordingFile.Dispose();
+ videoRecordingFile = null;
+ }
+
+ videoRecorder?.Dispose();
+ videoRecorder = null;
+
+ videoCapture?.Dispose();
+ videoCapture = null;
+
+ videoRecordingFinalizeTcs = null;
+ }
+
+ async Task EnableModes(CameraInfo selectedCamera, CancellationToken token)
+ {
+ var cameraFutureCts = new TaskCompletionSource();
+ var cameraSelector = selectedCamera.CameraSelector ?? throw new CameraException($"Unable to retrieve {nameof(CameraSelector)}");
+ var cameraProviderFuture = ProcessCameraProvider.GetInstance(context) ?? throw new CameraException($"Unable to retrieve {nameof(ProcessCameraProvider)}");
+ cameraProviderFuture.AddListener(new Runnable(() =>
+ {
+ var cameraProviderInstance = cameraProviderFuture.Get().JavaCast();
+ if (cameraProviderInstance is null)
+ {
+ return;
+ }
+
+ var extensionsManagerFuture = ExtensionsManager.GetInstanceAsync(context, cameraProviderInstance);
+ extensionsManagerFuture.AddListener(new Runnable(() =>
+ {
+ var extensionsManager = (ExtensionsManager?)extensionsManagerFuture.Get();
+ if (extensionsManager is not null && extensionsManager.IsExtensionAvailable(cameraSelector, extensionMode))
+ {
+ cameraSelector = extensionsManager.GetExtensionEnabledCameraSelector(cameraSelector, extensionMode);
+ }
+
+ cameraFutureCts.SetResult();
+ }), ContextCompat.GetMainExecutor(context));
+ }), ContextCompat.GetMainExecutor(context));
+
+ await cameraFutureCts.Task.WaitAsync(token);
+ return cameraSelector;
+ }
+
+ async Task RebindCamera(ProcessCameraProvider provider, CameraInfo cameraInfo, CancellationToken token, params UseCase[] useCases)
+ {
+ var cameraSelector = await EnableModes(cameraInfo, token);
+ provider.UnbindAll();
+ return provider.BindToLifecycle((ILifecycleOwner)context, cameraSelector, useCases);
+ }
+
+ void SetImageCaptureTargetRotation(int rotation)
+ {
+ if (imageCapture is not null)
{
- failure.Invoke(throwable);
+ imageCapture.TargetRotation = rotation switch
+ {
+ >= 45 and < 135 => (int)SurfaceOrientation.Rotation270,
+ >= 135 and < 225 => (int)SurfaceOrientation.Rotation180,
+ >= 225 and < 315 => (int)SurfaceOrientation.Rotation90,
+ _ => (int)SurfaceOrientation.Rotation0
+ };
}
}
@@ -324,7 +492,7 @@ public override void OnCaptureSuccess(IImageProxy image)
image.Close();
}
- static Plane? GetFirstPlane(Plane[]? planes)
+ static Image.Plane? GetFirstPlane(Image.Plane[]? planes)
{
if (planes is null || planes.Length is 0)
{
@@ -342,33 +510,22 @@ public override void OnError(ImageCaptureException exception)
}
}
- sealed class ResolutionFilter(Android.Util.Size size) : Java.Lang.Object, IResolutionFilter
+ sealed class ResolutionFilter(Android.Util.Size size) : Object, IResolutionFilter
{
public Android.Util.Size TargetSize { get; set; } = size;
public IList Filter(IList supportedSizes, int rotationDegrees)
{
- var filteredList = supportedSizes.Where(size => size.Width <= TargetSize.Width && size.Height <= TargetSize.Height)
+ var filteredList = supportedSizes
+ .Where(size => size.Width <= TargetSize.Width && size.Height <= TargetSize.Height)
.OrderByDescending(size => size.Width * size.Height).ToList();
return filteredList.Count is 0 ? supportedSizes : filteredList;
}
}
- sealed class Observer(Action action) : Java.Lang.Object, IObserver
- {
- readonly Action observerAction = action;
-
- public void OnChanged(Java.Lang.Object? value)
- {
- observerAction.Invoke(value);
- }
- }
-
sealed class OrientationListener(Action callback, Context context) : OrientationEventListener(context)
{
- readonly Action callback = callback;
-
public override void OnOrientationChanged(int orientation)
{
if (orientation == OrientationUnknown)
@@ -379,4 +536,17 @@ public override void OnOrientationChanged(int orientation)
callback.Invoke(orientation);
}
}
+}
+
+public class CameraConsumer(TaskCompletionSource finalizeTcs) : Object, IConsumer
+{
+ readonly TaskCompletionSource? finalizeTcs = finalizeTcs;
+
+ public void Accept(Object? videoRecordEvent)
+ {
+ if (videoRecordEvent is VideoRecordEvent.Finalize)
+ {
+ finalizeTcs?.SetResult();
+ }
+ }
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.Camera/CameraManager.macios.cs b/src/CommunityToolkit.Maui.Camera/CameraManager.macios.cs
index 17941d0cf7..a3d91ce63e 100644
--- a/src/CommunityToolkit.Maui.Camera/CameraManager.macios.cs
+++ b/src/CommunityToolkit.Maui.Camera/CameraManager.macios.cs
@@ -3,6 +3,7 @@
using CommunityToolkit.Maui.Extensions;
using CoreMedia;
using Foundation;
+using ObjCRuntime;
using UIKit;
namespace CommunityToolkit.Maui.Core;
@@ -11,20 +12,33 @@ partial class CameraManager
{
// TODO: Check if we really need this
readonly NSDictionary codecSettings = new([AVVideo.CodecKey], [new NSString("jpeg")]);
+ AVCaptureDeviceInput? audioInput;
+ AVCaptureDevice? captureDevice;
+ AVCaptureInput? captureInput;
AVCaptureSession? captureSession;
- AVCapturePhotoOutput? photoOutput;
- AVCaptureInput? captureInput;
- AVCaptureDevice? captureDevice;
AVCaptureFlashMode flashMode;
IDisposable? orientationDidChangeObserver;
+ AVCapturePhotoOutput? photoOutput;
PreviewView? previewView;
+
+ AVCaptureDeviceInput? videoInput;
AVCaptureVideoOrientation videoOrientation;
+ AVCaptureMovieFileOutput? videoOutput;
+ string? videoRecordingFileName;
+ TaskCompletionSource? videoRecordingFinalizeTcs;
+ Stream? videoRecordingStream;
- // IN the future change the return type to be an alias
- public UIView CreatePlatformView()
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ public NativePlatformCameraPreviewView CreatePlatformView()
{
captureSession = new AVCaptureSession
{
@@ -42,13 +56,6 @@ public UIView CreatePlatformView()
return previewView;
}
- ///
- public void Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
- }
-
public partial void UpdateFlashMode(CameraFlashMode flashMode)
{
this.flashMode = flashMode.ToPlatform();
@@ -191,6 +198,161 @@ protected virtual partial void PlatformDisconnect()
{
}
+ protected virtual async partial Task PlatformStartVideoRecording(Stream stream, CancellationToken token)
+ {
+ var isPermissionGranted = await AVCaptureDevice.RequestAccessForMediaTypeAsync(AVAuthorizationMediaType.Video).WaitAsync(token);
+ if (!isPermissionGranted)
+ {
+ throw new CameraException("Camera permission is not granted. Please enable it in the app settings.");
+ }
+
+ if (captureSession is null)
+ {
+ throw new CameraException("Capture session is not initialized. Call ConnectCamera first.");
+ }
+
+ CleanupVideoRecordingResources();
+
+ var videoDevice = AVCaptureDevice.GetDefaultDevice(AVMediaTypes.Video) ?? throw new CameraException("Unable to get video device");
+
+ videoInput = new AVCaptureDeviceInput(videoDevice, out NSError? error);
+ if (error is not null)
+ {
+ throw new CameraException($"Error creating video input: {error.LocalizedDescription}");
+ }
+
+ if (!captureSession.CanAddInput(videoInput))
+ {
+ videoInput?.Dispose();
+ throw new CameraException("Unable to add video input to capture session.");
+ }
+
+ captureSession.BeginConfiguration();
+ captureSession.AddInput(videoInput);
+
+ try
+ {
+ var audioDevice = AVCaptureDevice.GetDefaultDevice(AVMediaTypes.Audio);
+ if (audioDevice is not null)
+ {
+ audioInput = new AVCaptureDeviceInput(audioDevice, out NSError? audioError);
+ if (audioError is null && captureSession.CanAddInput(audioInput))
+ {
+ captureSession.AddInput(audioInput);
+ }
+ else
+ {
+ audioInput?.Dispose();
+ audioInput = null;
+ }
+ }
+ }
+ catch
+ {
+ // Ignore audio configuration issues; proceed with video-only recording
+ }
+
+ videoOutput = new AVCaptureMovieFileOutput();
+
+ if (!captureSession.CanAddOutput(videoOutput))
+ {
+ captureSession.RemoveInput(videoInput);
+ if (audioInput is not null)
+ {
+ captureSession.RemoveInput(audioInput);
+ audioInput.Dispose();
+ audioInput = null;
+ }
+
+ videoInput?.Dispose();
+ videoOutput?.Dispose();
+ captureSession.CommitConfiguration();
+ throw new CameraException("Unable to add video output to capture session.");
+ }
+
+ captureSession.AddOutput(videoOutput);
+ captureSession.CommitConfiguration();
+
+ videoRecordingStream = stream;
+ videoRecordingFinalizeTcs = new TaskCompletionSource();
+ videoRecordingFileName = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mov");
+
+ var outputUrl = NSUrl.FromFilename(videoRecordingFileName);
+ videoOutput.StartRecordingToOutputFile(outputUrl, new AVCaptureMovieFileOutputRecordingDelegate(videoRecordingFinalizeTcs));
+ }
+
+ protected virtual async partial Task PlatformStopVideoRecording(CancellationToken token)
+ {
+ if (captureSession is null
+ || videoRecordingFileName is null
+ || videoInput is null
+ || videoOutput is null
+ || videoRecordingStream is null
+ || videoRecordingFinalizeTcs is null)
+ {
+ return Stream.Null;
+ }
+
+ videoOutput.StopRecording();
+ await videoRecordingFinalizeTcs.Task.WaitAsync(token);
+
+ if (File.Exists(videoRecordingFileName))
+ {
+ await using var inputStream = new FileStream(videoRecordingFileName, FileMode.Open, FileAccess.Read, FileShare.Read);
+ await inputStream.CopyToAsync(videoRecordingStream, token);
+ await videoRecordingStream.FlushAsync(token);
+ if (videoRecordingStream.CanSeek)
+ {
+ videoRecordingStream.Position = 0;
+ }
+ }
+
+ CleanupVideoRecordingResources();
+
+ return videoRecordingStream;
+ }
+
+ void CleanupVideoRecordingResources()
+ {
+ if (captureSession is not null)
+ {
+ captureSession.BeginConfiguration();
+
+ foreach (var input in captureSession.Inputs)
+ {
+ captureSession.RemoveInput(input);
+ input.Dispose();
+ }
+
+ foreach (var output in captureSession.Outputs)
+ {
+ captureSession.RemoveOutput(output);
+ output.Dispose();
+ }
+
+ // Restore to photo preset for preview after video recording
+ captureSession.SessionPreset = AVCaptureSession.PresetPhoto;
+ captureSession.CommitConfiguration();
+ }
+
+ videoOutput = null;
+ videoInput = null;
+ audioInput = null;
+
+ // Clean up temporary file
+ if (videoRecordingFileName is not null)
+ {
+ if (File.Exists(videoRecordingFileName))
+ {
+ File.Delete(videoRecordingFileName);
+ }
+
+ videoRecordingFileName = null;
+ }
+
+ videoRecordingFinalizeTcs = null;
+ }
+
protected virtual async partial ValueTask PlatformTakePicture(CancellationToken token)
{
ArgumentNullException.ThrowIfNull(photoOutput);
@@ -250,6 +412,8 @@ protected virtual void Dispose(bool disposing)
{
if (disposing)
{
+ CleanupVideoRecordingResources();
+
captureSession?.StopRunning();
captureSession?.Dispose();
captureSession = null;
@@ -257,11 +421,19 @@ protected virtual void Dispose(bool disposing)
captureInput?.Dispose();
captureInput = null;
+ captureDevice = null;
+
orientationDidChangeObserver?.Dispose();
orientationDidChangeObserver = null;
photoOutput?.Dispose();
photoOutput = null;
+
+ previewView?.Dispose();
+ previewView = null;
+
+ videoRecordingStream?.Dispose();
+ videoRecordingStream = null;
}
}
@@ -315,19 +487,13 @@ sealed record CapturePhotoResult
public NSError? Error { get; init; }
}
- sealed class PreviewView : UIView
+ sealed class PreviewView : NativePlatformCameraPreviewView
{
public PreviewView()
{
PreviewLayer.VideoGravity = AVLayerVideoGravity.ResizeAspectFill;
}
- [Export("layerClass")]
- public static ObjCRuntime.Class GetLayerClass()
- {
- return new ObjCRuntime.Class(typeof(AVCaptureVideoPreviewLayer));
- }
-
public AVCaptureSession? Session
{
get => PreviewLayer.Session;
@@ -336,6 +502,12 @@ public AVCaptureSession? Session
AVCaptureVideoPreviewLayer PreviewLayer => (AVCaptureVideoPreviewLayer)Layer;
+ [Export("layerClass")]
+ public static Class GetLayerClass()
+ {
+ return new Class(typeof(AVCaptureVideoPreviewLayer));
+ }
+
public override void LayoutSubviews()
{
base.LayoutSubviews();
@@ -350,4 +522,12 @@ public void UpdatePreviewVideoOrientation(AVCaptureVideoOrientation videoOrienta
}
}
}
+}
+
+class AVCaptureMovieFileOutputRecordingDelegate(TaskCompletionSource taskCompletionSource) : AVCaptureFileOutputRecordingDelegate
+{
+ public override void FinishedRecording(AVCaptureFileOutput captureOutput, NSUrl outputFileUrl, NSObject[] connections, NSError? error)
+ {
+ taskCompletionSource.SetResult();
+ }
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.Camera/CameraManager.net.cs b/src/CommunityToolkit.Maui.Camera/CameraManager.net.cs
index 41dae1d618..215459bf07 100644
--- a/src/CommunityToolkit.Maui.Camera/CameraManager.net.cs
+++ b/src/CommunityToolkit.Maui.Camera/CameraManager.net.cs
@@ -14,6 +14,8 @@ partial class CameraManager
public partial ValueTask UpdateCaptureResolution(Size resolution, CancellationToken token) => throw new NotSupportedException(notSupportedMessage);
+ protected virtual partial Task PlatformStartVideoRecording(Stream stream, CancellationToken token) => throw new NotSupportedException(notSupportedMessage);
+ protected virtual partial Task PlatformStopVideoRecording(CancellationToken token) => throw new NotSupportedException(notSupportedMessage);
protected virtual partial Task PlatformStartCameraPreview(CancellationToken token) => throw new NotSupportedException(notSupportedMessage);
protected virtual partial void PlatformStopCameraPreview() => throw new NotSupportedException(notSupportedMessage);
diff --git a/src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs b/src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs
index d7703d2572..fd16ef81a8 100644
--- a/src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs
+++ b/src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs
@@ -1,4 +1,6 @@
-namespace CommunityToolkit.Maui.Core;
+using System.Diagnostics;
+
+namespace CommunityToolkit.Maui.Core;
///
/// A class that manages the camera functionality.
@@ -27,7 +29,23 @@ partial class CameraManager(
///
/// Returns true if permission has been granted, false otherwise.
public async Task ArePermissionsGranted()
- => await Permissions.RequestAsync() is PermissionStatus.Granted;
+ {
+ var cameraRequest = await Permissions.RequestAsync();
+ var microphoneRequest = await Permissions.RequestAsync();
+ if (cameraRequest is not PermissionStatus.Granted)
+ {
+ Trace.TraceInformation("Camera permission is not granted.");
+ return false;
+ }
+
+ if (microphoneRequest is not PermissionStatus.Granted)
+ {
+ Trace.TraceInformation("Microphone permission is not granted.");
+ return false;
+ }
+
+ return true;
+ }
///
/// Connects to the camera.
@@ -52,6 +70,27 @@ public async Task ArePermissionsGranted()
/// A that can be awaited.
public Task StartCameraPreview(CancellationToken token) => PlatformStartCameraPreview(token);
+
+ ///
+ /// Starts the video recording.
+ ///
+ /// The output stream for video recording
+ ///
+ /// A that can be awaited.
+ ///
+ public Task StartVideoRecording(Stream stream, CancellationToken token)
+ {
+ return PlatformStartVideoRecording(stream, token);
+ }
+
+
+ ///
+ /// Stops the video recording.
+ ///
+ ///
+ /// A that can be awaited.
+ public Task StopVideoRecording(CancellationToken token) => PlatformStopVideoRecording(token);
+
///
/// Stops the camera preview.
///
@@ -131,4 +170,23 @@ public async ValueTask UpdateCurrentCamera(CameraInfo? cameraInfo, CancellationT
/// Stops the preview from the camera, at the platform-specific level.
///
protected virtual partial void PlatformStopCameraPreview();
+
+ ///
+ /// Starts video recording and writes the recorded data to the specified stream.
+ ///
+ /// This method is platform-specific and should be overridden in a derived class to provide the actual
+ /// implementation. Ensure that the stream remains open and writable for the duration of the recording.
+ /// The stream to which the video data will be written. Must be writable and not null.
+ /// A cancellation token that can be used to cancel the video recording operation.
+ /// A task that represents the asynchronous video recording operation.
+ protected virtual partial Task PlatformStartVideoRecording(Stream stream, CancellationToken token);
+
+ ///
+ /// Stops the video recording process asynchronously.
+ ///
+ /// This method is platform-specific and should be overridden in a derived class to implement the stop
+ /// functionality.
+ /// A cancellation token that can be used to cancel the stop operation.
+ /// A task that represents the asynchronous stop operation.
+ protected virtual partial Task PlatformStopVideoRecording(CancellationToken token);
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.Camera/CameraManager.tizen.cs b/src/CommunityToolkit.Maui.Camera/CameraManager.tizen.cs
index 9d2dd7a1a2..3bb3cb9347 100644
--- a/src/CommunityToolkit.Maui.Camera/CameraManager.tizen.cs
+++ b/src/CommunityToolkit.Maui.Camera/CameraManager.tizen.cs
@@ -23,4 +23,7 @@ partial class CameraManager
protected virtual partial void PlatformDisconnect() => throw new NotSupportedException(notSupportedMessage);
protected virtual partial ValueTask PlatformTakePicture(CancellationToken token) => throw new NotSupportedException(notSupportedMessage);
+ protected virtual partial Task PlatformStartVideoRecording(Stream stream, CancellationToken token) => throw new NotSupportedException(notSupportedMessage);
+ protected virtual partial Task PlatformStopVideoRecording(CancellationToken token) => throw new NotSupportedException(notSupportedMessage);
+
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.Camera/CameraManager.windows.cs b/src/CommunityToolkit.Maui.Camera/CameraManager.windows.cs
index 5edf33fc77..ddef2fc467 100644
--- a/src/CommunityToolkit.Maui.Camera/CameraManager.windows.cs
+++ b/src/CommunityToolkit.Maui.Camera/CameraManager.windows.cs
@@ -1,5 +1,4 @@
-using System.Runtime.Versioning;
-using CommunityToolkit.Maui.Extensions;
+using CommunityToolkit.Maui.Extensions;
using Microsoft.UI.Xaml.Controls;
using Windows.Media.Capture;
using Windows.Media.Capture.Frames;
@@ -8,12 +7,13 @@
namespace CommunityToolkit.Maui.Core;
-[SupportedOSPlatform("windows10.0.10240.0")]
partial class CameraManager
{
MediaPlayerElement? mediaElement;
MediaCapture? mediaCapture;
MediaFrameSource? frameSource;
+ LowLagMediaRecording? mediaRecording;
+ Stream? videoCaptureStream;
public MediaPlayerElement CreatePlatformView()
{
@@ -202,4 +202,46 @@ protected async Task PlatformUpdateResolution(Size resolution, CancellationToken
await mediaCapture.VideoDeviceController.SetMediaStreamPropertiesAsync(MediaStreamType.Photo, filteredPropertiesList.First()).AsTask(token);
}
}
+
+ protected virtual async partial Task PlatformStartVideoRecording(Stream stream, CancellationToken token)
+ {
+ if (!IsInitialized || mediaCapture is null || mediaElement is null)
+ {
+ return;
+ }
+
+ videoCaptureStream = stream;
+
+ var profile = MediaEncodingProfile.CreateMp4(VideoEncodingQuality.Auto);
+ mediaRecording = await mediaCapture.PrepareLowLagRecordToStreamAsync(profile, stream.AsRandomAccessStream());
+
+ frameSource = mediaCapture.FrameSources.FirstOrDefault(source =>
+ source.Value.Info.MediaStreamType == MediaStreamType.VideoRecord &&
+ source.Value.Info.SourceKind == MediaFrameSourceKind.Color).Value;
+ if (frameSource is not null)
+ {
+ var frameFormat = frameSource.SupportedFormats
+ .OrderByDescending(f => f.VideoFormat.Width * f.VideoFormat.Height)
+ .FirstOrDefault();
+
+ if (frameFormat is not null)
+ {
+ await frameSource.SetFormatAsync(frameFormat);
+ mediaElement.AutoPlay = true;
+ mediaElement.Source = MediaSource.CreateFromMediaFrameSource(frameSource);
+ await mediaRecording.StartAsync();
+ }
+ }
+ }
+
+ protected virtual async partial Task PlatformStopVideoRecording(CancellationToken token)
+ {
+ if (!IsInitialized || mediaElement is null || mediaRecording is null || videoCaptureStream is null)
+ {
+ return Stream.Null;
+ }
+
+ await mediaRecording.StopAsync();
+ return videoCaptureStream;
+ }
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.Camera/CommunityToolkit.Maui.Camera.csproj b/src/CommunityToolkit.Maui.Camera/CommunityToolkit.Maui.Camera.csproj
index bd22368e3c..e318badb0f 100644
--- a/src/CommunityToolkit.Maui.Camera/CommunityToolkit.Maui.Camera.csproj
+++ b/src/CommunityToolkit.Maui.Camera/CommunityToolkit.Maui.Camera.csproj
@@ -46,27 +46,28 @@
-
-
+
+
-
-
+
+
+
-
+
-
-
+
+
diff --git a/src/CommunityToolkit.Maui.Camera/Interfaces/ICameraProvider.shared.cs b/src/CommunityToolkit.Maui.Camera/Interfaces/ICameraProvider.shared.cs
index e1601760bf..9e41e51306 100644
--- a/src/CommunityToolkit.Maui.Camera/Interfaces/ICameraProvider.shared.cs
+++ b/src/CommunityToolkit.Maui.Camera/Interfaces/ICameraProvider.shared.cs
@@ -11,7 +11,7 @@ public interface ICameraProvider
/// Event fires when the contents has changed
///
event EventHandler?> AvailableCamerasChanged;
-
+
///
/// Cameras available on device
///
diff --git a/src/CommunityToolkit.Maui.Camera/Interfaces/ICameraView.shared.cs b/src/CommunityToolkit.Maui.Camera/Interfaces/ICameraView.shared.cs
index 739617d0be..1b98bd1ee6 100644
--- a/src/CommunityToolkit.Maui.Camera/Interfaces/ICameraView.shared.cs
+++ b/src/CommunityToolkit.Maui.Camera/Interfaces/ICameraView.shared.cs
@@ -75,6 +75,31 @@ public interface ICameraView : IView
///
void StopCameraPreview();
+ ///
+ /// Starts recording video and writes the output to a
+ ///
+ /// Ensure that the stream is properly disposed of after the recording is complete. The recording will be cancelled if cancellation token is triggered. Call to return the recorded
+ /// A cancellation token that can be used to cancel the video recording operation.
+ /// A task that represents the asynchronous video recording operation.
+ Task StartVideoRecording(CancellationToken token = default);
+
+ ///
+ /// Starts recording video and writes the output to the specified stream.
+ ///
+ /// Be sure to properly disposed of the provided parameter after the recording has completed. The recording will stop if the cancellation token is triggered.
+ /// The stream to which the video data will be written. The stream must be writable and remain open for the duration of the recording.
+ /// A cancellation token that can be used to cancel the video recording operation.
+ /// A task that represents the asynchronous video recording operation.
+ Task StartVideoRecording(Stream stream, CancellationToken token = default);
+
+ ///
+ /// Stops the ongoing video recording operation.
+ ///
+ /// This method is called to terminate the video recording process. Ensure that either or has been called before invoking this method. The operation can be cancelled by passing a cancellation token.
+ /// A cancellation token that can be used to cancel the stop operation.
+ /// A task that represents the asynchronous stop operation.
+ Task StopVideoRecording(CancellationToken token = default);
+
///
/// Retrieves the cameras available on the current device.
///
diff --git a/src/CommunityToolkit.Maui.Camera/Primitives/CameraViewDefaults.shared.cs b/src/CommunityToolkit.Maui.Camera/Primitives/CameraViewDefaults.shared.cs
index e974ec74d5..6da7e2c028 100644
--- a/src/CommunityToolkit.Maui.Camera/Primitives/CameraViewDefaults.shared.cs
+++ b/src/CommunityToolkit.Maui.Camera/Primitives/CameraViewDefaults.shared.cs
@@ -61,4 +61,16 @@ internal static ICommand CreateStopCameraPreviewCommand(BindableObject bindable)
var cameraView = (CameraView)bindable;
return new Command(_ => cameraView.StopCameraPreview());
}
+
+ internal static Command CreateStartVideoRecordingCommand(BindableObject bindable)
+ {
+ var cameraView = (CameraView)bindable;
+ return new Command(async stream => await cameraView.StartVideoRecording(stream).ConfigureAwait(false));
+ }
+
+ internal static Command CreateStopVideoRecordingCommand(BindableObject bindable)
+ {
+ var cameraView = (CameraView)bindable;
+ return new Command(async token => await cameraView.StopVideoRecording(token).ConfigureAwait(false));
+ }
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.Camera/Providers/CameraProvider.shared.cs b/src/CommunityToolkit.Maui.Camera/Providers/CameraProvider.shared.cs
index 7e7298461d..1bf348cb37 100644
--- a/src/CommunityToolkit.Maui.Camera/Providers/CameraProvider.shared.cs
+++ b/src/CommunityToolkit.Maui.Camera/Providers/CameraProvider.shared.cs
@@ -6,7 +6,7 @@
partial class CameraProvider : ICameraProvider
{
readonly WeakEventManager availableCamerasChangedEventManager = new();
-
+
public event EventHandler?> AvailableCamerasChanged
{
add => availableCamerasChangedEventManager.AddEventHandler(value);
@@ -22,7 +22,7 @@ private set
if (!AreCameraInfoListsEqual(field, value))
{
field = value;
- availableCamerasChangedEventManager.HandleEvent(this, value, nameof(AvailableCamerasChanged));
+ availableCamerasChangedEventManager.HandleEvent(this, value, nameof(AvailableCamerasChanged));
}
}
}
diff --git a/src/CommunityToolkit.Maui.Camera/Views/CameraView.shared.cs b/src/CommunityToolkit.Maui.Camera/Views/CameraView.shared.cs
index edbd26460d..03ab095ab0 100644
--- a/src/CommunityToolkit.Maui.Camera/Views/CameraView.shared.cs
+++ b/src/CommunityToolkit.Maui.Camera/Views/CameraView.shared.cs
@@ -79,6 +79,18 @@ public partial class CameraView : View, ICameraView, IDisposable
public static readonly BindableProperty StopCameraPreviewCommandProperty =
BindableProperty.CreateReadOnly(nameof(StopCameraPreviewCommand), typeof(ICommand), typeof(CameraView), null, BindingMode.OneWayToSource, defaultValueCreator: CameraViewDefaults.CreateStopCameraPreviewCommand).BindableProperty;
+ ///
+ /// Backing BindableProperty for the property.
+ ///
+ public static readonly BindableProperty StartVideoRecordingCommandProperty =
+ BindableProperty.CreateReadOnly(nameof(StartVideoRecordingCommand), typeof(Command), typeof(CameraView), null, BindingMode.OneWayToSource, defaultValueCreator: CameraViewDefaults.CreateStartVideoRecordingCommand).BindableProperty;
+
+ ///
+ /// Backing BindableProperty for the property.
+ ///
+ public static readonly BindableProperty StopVideoRecordingCommandProperty =
+ BindableProperty.CreateReadOnly(nameof(StopVideoRecordingCommand), typeof(Command), typeof(CameraView), null, BindingMode.OneWayToSource, defaultValueCreator: CameraViewDefaults.CreateStopVideoRecordingCommand).BindableProperty;
+
readonly SemaphoreSlim captureImageSemaphoreSlim = new(1, 1);
readonly WeakEventManager weakEventManager = new();
@@ -137,6 +149,25 @@ public event EventHandler MediaCaptured
///
public ICommand StopCameraPreviewCommand => (ICommand)GetValue(StopCameraPreviewCommandProperty);
+ ///
+ /// Gets the Command that starts video recording.
+ ///
+ ///
+ /// has a of Command<Stream> which requires a as a CommandParameter.
+ /// The parameter represents the destination where the recorded video will be saved.
+ /// See and for more information on passing a into as a CommandParameter.
+ ///
+ public Command StartVideoRecordingCommand => (Command)GetValue(StartVideoRecordingCommandProperty);
+
+ ///
+ /// Gets the Command that stops video recording.
+ ///
+ ///
+ /// has a of Command<CancellationToken>, which requires a as a CommandParameter.
+ /// See and for more information on passing a into as a CommandParameter.
+ ///
+ public Command StopVideoRecordingCommand => (Command)GetValue(StopVideoRecordingCommandProperty);
+
///
/// Gets or sets the .
///
@@ -217,6 +248,17 @@ public async ValueTask> GetAvailableCameras(Cancellati
return CameraProvider.AvailableCameras;
}
+#if ANDROID
+ ///
+ /// Set Extension Mode
+ ///
+ /// mode
+ public Task SetExtensionMode(int mode, CancellationToken token = default)
+ {
+ return Handler.CameraManager.SetExtensionMode(mode, token);
+ }
+#endif
+
///
public async Task CaptureImage(CancellationToken token)
{
@@ -258,6 +300,25 @@ public Task StartCameraPreview(CancellationToken token) =>
public void StopCameraPreview() =>
Handler.CameraManager.StopCameraPreview();
+ ///
+ public Task StartVideoRecording(CancellationToken token = default) =>
+ StartVideoRecording(new MemoryStream(), token);
+
+ ///
+ public Task StartVideoRecording(Stream stream, CancellationToken token = default) =>
+ Handler.CameraManager.StartVideoRecording(stream, token);
+
+ ///
+ public async Task StopVideoRecording(CancellationToken token = default) where TStream : Stream
+ {
+ var stream = await Handler.CameraManager.StopVideoRecording(token);
+ return (TStream)stream;
+ }
+
+ ///
+ public Task StopVideoRecording(CancellationToken token = default) =>
+ Handler.CameraManager.StopVideoRecording(token);
+
///
protected virtual void Dispose(bool disposing)
{
diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/CameraView/CameraProviderTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/CameraView/CameraProviderTests.cs
index 5731093af3..cdef941d24 100644
--- a/src/CommunityToolkit.Maui.UnitTests/Views/CameraView/CameraProviderTests.cs
+++ b/src/CommunityToolkit.Maui.UnitTests/Views/CameraView/CameraProviderTests.cs
@@ -10,586 +10,586 @@ namespace CommunityToolkit.Maui.UnitTests;
///
public class CameraProviderTests
{
- #region Null Handling Tests
-
- [Fact]
- public void AreCameraInfoListsEqual_BothListsNull_ShouldConsiderEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting null when already null
- typeof(MockCameraProvider)
- .GetProperty(nameof(MockCameraProvider.AvailableCameras))!
- .SetValue(provider, null);
-
- // Assert - No event should be raised as both are null
- Assert.Equal(0, eventRaisedCount);
- Assert.Null(provider.AvailableCameras);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_FirstNullSecondNotNull_ShouldConsiderNotEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- var cameras = new List
- {
- CreateCameraInfo("Camera1")
- };
-
- // Act - Setting non-null when currently null
- SetAvailableCameras(provider, cameras);
-
- // Assert - Event should be raised as they're different
- Assert.Equal(1, eventRaisedCount);
- Assert.NotNull(provider.AvailableCameras);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_FirstNotNullSecondNull_ShouldConsiderNotEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- var cameras = new List { CreateCameraInfo("Camera1") };
- SetAvailableCameras(provider, cameras);
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting null when currently non-null
- SetAvailableCameras(provider, null);
-
- // Assert - Event should be raised as they're different
- Assert.Equal(1, eventRaisedCount);
- Assert.Null(provider.AvailableCameras);
- }
-
- #endregion
-
- #region Empty List Tests
-
- [Fact]
- public void AreCameraInfoListsEqual_BothListsEmpty_ShouldConsiderEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List());
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting another empty list
- SetAvailableCameras(provider, new List());
-
- // Assert - No event should be raised
- Assert.Equal(0, eventRaisedCount);
- Assert.NotNull(provider.AvailableCameras);
- Assert.Empty(provider.AvailableCameras);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_EmptyListVsNull_ShouldConsiderNotEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List());
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting null when currently empty
- SetAvailableCameras(provider, null);
-
- // Assert - Event should be raised
- Assert.Equal(1, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_EmptyListVsNonEmpty_ShouldConsiderNotEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List());
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting non-empty when currently empty
- SetAvailableCameras(provider, new List { CreateCameraInfo("Camera1") });
-
- // Assert - Event should be raised
- Assert.Equal(1, eventRaisedCount);
- }
-
- #endregion
-
- #region Same Content Tests
-
- [Fact]
- public void AreCameraInfoListsEqual_SameSingleCamera_ShouldConsiderEqual()
- {
- // Arrange
- var camera = CreateCameraInfo("Camera1");
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { camera });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting list with same camera
- SetAvailableCameras(provider, new List { camera });
-
- // Assert - No event should be raised
- Assert.Equal(0, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_SameMultipleCameras_ShouldConsiderEqual()
- {
- // Arrange
- var camera1 = CreateCameraInfo("Camera1");
- var camera2 = CreateCameraInfo("Camera2");
- var camera3 = CreateCameraInfo("Camera3");
-
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { camera1, camera2, camera3 });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting list with same cameras
- SetAvailableCameras(provider, new List { camera1, camera2, camera3 });
-
- // Assert - No event should be raised
- Assert.Equal(0, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_SameCamerasDifferentOrder_ShouldConsiderEqual()
- {
- // Arrange
- var camera1 = CreateCameraInfo("Camera1");
- var camera2 = CreateCameraInfo("Camera2");
- var camera3 = CreateCameraInfo("Camera3");
-
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { camera1, camera2, camera3 });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting list with same cameras in different order
- SetAvailableCameras(provider, new List { camera3, camera1, camera2 });
-
- // Assert - No event should be raised (order-independent comparison)
- Assert.Equal(0, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_SameReferenceList_ShouldConsiderEqual()
- {
- // Arrange
- var cameras = new List { CreateCameraInfo("Camera1") };
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, cameras);
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting the exact same list reference
- SetAvailableCameras(provider, cameras);
-
- // Assert - No event should be raised
- Assert.Equal(0, eventRaisedCount);
- }
-
- #endregion
-
- #region Different Content Tests
-
- [Fact]
- public void AreCameraInfoListsEqual_DifferentSingleCamera_ShouldConsiderNotEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { CreateCameraInfo("Camera1") });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting list with different camera
- SetAvailableCameras(provider, new List { CreateCameraInfo("Camera2") });
-
- // Assert - Event should be raised
- Assert.Equal(1, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_DifferentCameraCount_MoreInSecond_ShouldConsiderNotEqual()
- {
- // Arrange
- var camera1 = CreateCameraInfo("Camera1");
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { camera1 });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting list with more cameras
- SetAvailableCameras(provider, new List { camera1, CreateCameraInfo("Camera2") });
-
- // Assert - Event should be raised
- Assert.Equal(1, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_DifferentCameraCount_LessInSecond_ShouldConsiderNotEqual()
- {
- // Arrange
- var camera1 = CreateCameraInfo("Camera1");
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { camera1, CreateCameraInfo("Camera2") });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting list with fewer cameras
- SetAvailableCameras(provider, new List { camera1 });
-
- // Assert - Event should be raised
- Assert.Equal(1, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_PartiallyOverlappingLists_ShouldConsiderNotEqual()
- {
- // Arrange
- var camera1 = CreateCameraInfo("Camera1");
- var camera2 = CreateCameraInfo("Camera2");
- var camera3 = CreateCameraInfo("Camera3");
-
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { camera1, camera2 });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting list with one common camera and one different
- SetAvailableCameras(provider, new List { camera1, camera3 });
-
- // Assert - Event should be raised
- Assert.Equal(1, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_CompletelyDifferentLists_ShouldConsiderNotEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List
- {
- CreateCameraInfo("Camera1"),
- CreateCameraInfo("Camera2")
- });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting completely different list
- SetAvailableCameras(provider, new List
- {
- CreateCameraInfo("Camera3"),
- CreateCameraInfo("Camera4")
- });
-
- // Assert - Event should be raised
- Assert.Equal(1, eventRaisedCount);
- }
-
- #endregion
-
- #region Duplicate Handling Tests
-
- [Fact]
- public void AreCameraInfoListsEqual_ListWithDuplicates_ShouldHandleCorrectly()
- {
- // Arrange
- var camera = CreateCameraInfo("Camera1");
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { camera, camera });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting same list with duplicates
- SetAvailableCameras(provider, new List { camera, camera });
-
- // Assert - No event should be raised
- Assert.Equal(0, eventRaisedCount);
- }
-
- #endregion
-
- #region CameraInfo Property Variations Tests
-
- [Fact]
- public void AreCameraInfoListsEqual_DifferentCameraName_ShouldNotConsiderNotEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List
- {
- CreateCameraInfo("Camera1", "device1", CameraPosition.Front)
- });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Different name, same device and position
- SetAvailableCameras(provider, new List
- {
- CreateCameraInfo("Camera2", "device1", CameraPosition.Front)
- });
-
- // Assert - Event should be raised (different camera)
- Assert.Equal(0, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_DifferentDeviceId_ShouldConsiderNotEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List
- {
- CreateCameraInfo("Camera1", "device1", CameraPosition.Front)
- });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Same name and position, different device
- SetAvailableCameras(provider, new List
- {
- CreateCameraInfo("Camera1", "device2", CameraPosition.Front)
- });
-
- // Assert - Event should be raised (different camera)
- Assert.Equal(1, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_AllPropertiesSame_ShouldConsiderEqual()
- {
- // Arrange
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List
- {
- CreateFullCameraInfo("Camera1", "device1", CameraPosition.Front, true, 1.0f, 5.0f)
- });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - All properties identical
- SetAvailableCameras(provider, new List
- {
- CreateFullCameraInfo("Camera1", "device1", CameraPosition.Front, true, 1.0f, 5.0f)
- });
-
- // Assert - No event should be raised
- Assert.Equal(0, eventRaisedCount);
- }
-
- #endregion
-
- #region Sequential Changes Tests
-
- [Fact]
- public void AreCameraInfoListsEqual_MultipleSequentialChanges_ShouldRaiseEventForEachChange()
- {
- // Arrange
- var provider = new MockCameraProvider();
- var camera1 = CreateCameraInfo("Camera1");
- var camera2 = CreateCameraInfo("Camera2");
- var camera3 = CreateCameraInfo("Camera3");
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Multiple different changes
- SetAvailableCameras(provider, new List { camera1 });
- SetAvailableCameras(provider, new List { camera2 });
- SetAvailableCameras(provider, new List { camera3 });
- SetAvailableCameras(provider, new List { camera1, camera2 });
-
- // Assert - Event raised for each change
- Assert.Equal(4, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_AlternatingBetweenTwoStates_ShouldRaiseEventForEachChange()
- {
- // Arrange
- var provider = new MockCameraProvider();
- var state1 = new List { CreateCameraInfo("Camera1") };
- var state2 = new List { CreateCameraInfo("Camera2") };
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Alternating between two different states
- SetAvailableCameras(provider, state1);
- SetAvailableCameras(provider, state2);
- SetAvailableCameras(provider, state1);
- SetAvailableCameras(provider, state2);
-
- // Assert - Event raised for each change
- Assert.Equal(4, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_NoChangeBetweenUpdates_ShouldNotRaiseEvent()
- {
- // Arrange
- var camera = CreateCameraInfo("Camera1");
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, new List { camera });
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Multiple updates with same content
- SetAvailableCameras(provider, new List { camera });
- SetAvailableCameras(provider, new List { camera });
- SetAvailableCameras(provider, new List { camera });
-
- // Assert - No events raised
- Assert.Equal(0, eventRaisedCount);
- }
-
- #endregion
-
- #region Large List Tests
-
- [Fact]
- public void AreCameraInfoListsEqual_LargeLists_SameContent_ShouldConsiderEqual()
- {
- // Arrange
- var cameras = Enumerable.Range(0, 100)
- .Select(i => CreateCameraInfo($"Camera{i}"))
- .ToList();
-
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, cameras);
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - Setting same large list
- SetAvailableCameras(provider, cameras.ToList());
-
- // Assert - No event should be raised
- Assert.Equal(0, eventRaisedCount);
- }
-
- [Fact]
- public void AreCameraInfoListsEqual_LargeLists_OneDifferent_ShouldConsiderNotEqual()
- {
- // Arrange
- var cameras1 = Enumerable.Range(0, 100)
- .Select(i => CreateCameraInfo($"Camera{i}"))
- .ToList();
-
- var cameras2 = Enumerable.Range(0, 100)
- .Select(i => i == 50 ? CreateCameraInfo($"DifferentCamera{i}") : CreateCameraInfo($"Camera{i}"))
- .ToList();
-
- var provider = new MockCameraProvider();
- SetAvailableCameras(provider, cameras1);
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Act - One camera different in large list
- SetAvailableCameras(provider, cameras2);
-
- // Assert - Event should be raised
- Assert.Equal(1, eventRaisedCount);
- }
-
- #endregion
-
- #region Edge Cases
-
- [Fact]
- public async Task AreCameraInfoListsEqual_AfterRefresh_ShouldCompareCorrectly()
- {
- // Arrange
- var provider = new MockCameraProvider();
- var initialCameras = provider.AvailableCameras;
-
- // Act - Refresh creates new cameras
- await provider.RefreshAvailableCameras(CancellationToken.None);
- var firstCamera = provider.AvailableCameras?[0];
-
- var eventRaisedCount = 0;
- provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
-
- // Refresh again (will create different camera due to new GUID)
- await provider.RefreshAvailableCameras(CancellationToken.None);
-
- // Assert - Event should be raised because GUIDs are different
- Assert.Equal(1, eventRaisedCount);
- Assert.NotNull(provider.AvailableCameras);
- Assert.Single(provider.AvailableCameras);
- }
-
- #endregion
-
- #region Helper Methods
-
- static void SetAvailableCameras(MockCameraProvider provider, IReadOnlyList? cameras)
- {
- var property = typeof(MockCameraProvider).GetProperty(nameof(MockCameraProvider.AvailableCameras)) ?? throw new InvalidOperationException();
- property.SetValue(provider, cameras);
- }
-
- static CameraInfo CreateCameraInfo(string name, string? deviceId = null, CameraPosition position = CameraPosition.Front)
- {
- return new CameraInfo(
- name,
- deviceId ?? Guid.NewGuid().ToString(),
- position,
- false,
- 1.0f,
- 5.0f,
- [new Size(1920, 1080)]);
- }
-
- static CameraInfo CreateFullCameraInfo(
- string name,
- string deviceId,
- CameraPosition position,
- bool isSupported,
- float minZoom,
- float maxZoom)
- {
- return new CameraInfo(
- name,
- deviceId,
- position,
- isSupported,
- minZoom,
- maxZoom,
- [new Size(1920, 1080)]);
- }
-
- #endregion
+ #region Null Handling Tests
+
+ [Fact]
+ public void AreCameraInfoListsEqual_BothListsNull_ShouldConsiderEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting null when already null
+ typeof(MockCameraProvider)
+ .GetProperty(nameof(MockCameraProvider.AvailableCameras))!
+ .SetValue(provider, null);
+
+ // Assert - No event should be raised as both are null
+ Assert.Equal(0, eventRaisedCount);
+ Assert.Null(provider.AvailableCameras);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_FirstNullSecondNotNull_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ var cameras = new List
+ {
+ CreateCameraInfo("Camera1")
+ };
+
+ // Act - Setting non-null when currently null
+ SetAvailableCameras(provider, cameras);
+
+ // Assert - Event should be raised as they're different
+ Assert.Equal(1, eventRaisedCount);
+ Assert.NotNull(provider.AvailableCameras);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_FirstNotNullSecondNull_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ var cameras = new List { CreateCameraInfo("Camera1") };
+ SetAvailableCameras(provider, cameras);
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting null when currently non-null
+ SetAvailableCameras(provider, null);
+
+ // Assert - Event should be raised as they're different
+ Assert.Equal(1, eventRaisedCount);
+ Assert.Null(provider.AvailableCameras);
+ }
+
+ #endregion
+
+ #region Empty List Tests
+
+ [Fact]
+ public void AreCameraInfoListsEqual_BothListsEmpty_ShouldConsiderEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List());
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting another empty list
+ SetAvailableCameras(provider, new List());
+
+ // Assert - No event should be raised
+ Assert.Equal(0, eventRaisedCount);
+ Assert.NotNull(provider.AvailableCameras);
+ Assert.Empty(provider.AvailableCameras);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_EmptyListVsNull_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List());
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting null when currently empty
+ SetAvailableCameras(provider, null);
+
+ // Assert - Event should be raised
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_EmptyListVsNonEmpty_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List());
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting non-empty when currently empty
+ SetAvailableCameras(provider, new List { CreateCameraInfo("Camera1") });
+
+ // Assert - Event should be raised
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ #endregion
+
+ #region Same Content Tests
+
+ [Fact]
+ public void AreCameraInfoListsEqual_SameSingleCamera_ShouldConsiderEqual()
+ {
+ // Arrange
+ var camera = CreateCameraInfo("Camera1");
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { camera });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting list with same camera
+ SetAvailableCameras(provider, new List { camera });
+
+ // Assert - No event should be raised
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_SameMultipleCameras_ShouldConsiderEqual()
+ {
+ // Arrange
+ var camera1 = CreateCameraInfo("Camera1");
+ var camera2 = CreateCameraInfo("Camera2");
+ var camera3 = CreateCameraInfo("Camera3");
+
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { camera1, camera2, camera3 });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting list with same cameras
+ SetAvailableCameras(provider, new List { camera1, camera2, camera3 });
+
+ // Assert - No event should be raised
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_SameCamerasDifferentOrder_ShouldConsiderEqual()
+ {
+ // Arrange
+ var camera1 = CreateCameraInfo("Camera1");
+ var camera2 = CreateCameraInfo("Camera2");
+ var camera3 = CreateCameraInfo("Camera3");
+
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { camera1, camera2, camera3 });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting list with same cameras in different order
+ SetAvailableCameras(provider, new List { camera3, camera1, camera2 });
+
+ // Assert - No event should be raised (order-independent comparison)
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_SameReferenceList_ShouldConsiderEqual()
+ {
+ // Arrange
+ var cameras = new List { CreateCameraInfo("Camera1") };
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, cameras);
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting the exact same list reference
+ SetAvailableCameras(provider, cameras);
+
+ // Assert - No event should be raised
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ #endregion
+
+ #region Different Content Tests
+
+ [Fact]
+ public void AreCameraInfoListsEqual_DifferentSingleCamera_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { CreateCameraInfo("Camera1") });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting list with different camera
+ SetAvailableCameras(provider, new List { CreateCameraInfo("Camera2") });
+
+ // Assert - Event should be raised
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_DifferentCameraCount_MoreInSecond_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var camera1 = CreateCameraInfo("Camera1");
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { camera1 });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting list with more cameras
+ SetAvailableCameras(provider, new List { camera1, CreateCameraInfo("Camera2") });
+
+ // Assert - Event should be raised
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_DifferentCameraCount_LessInSecond_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var camera1 = CreateCameraInfo("Camera1");
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { camera1, CreateCameraInfo("Camera2") });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting list with fewer cameras
+ SetAvailableCameras(provider, new List { camera1 });
+
+ // Assert - Event should be raised
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_PartiallyOverlappingLists_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var camera1 = CreateCameraInfo("Camera1");
+ var camera2 = CreateCameraInfo("Camera2");
+ var camera3 = CreateCameraInfo("Camera3");
+
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { camera1, camera2 });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting list with one common camera and one different
+ SetAvailableCameras(provider, new List { camera1, camera3 });
+
+ // Assert - Event should be raised
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_CompletelyDifferentLists_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List
+ {
+ CreateCameraInfo("Camera1"),
+ CreateCameraInfo("Camera2")
+ });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting completely different list
+ SetAvailableCameras(provider, new List
+ {
+ CreateCameraInfo("Camera3"),
+ CreateCameraInfo("Camera4")
+ });
+
+ // Assert - Event should be raised
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ #endregion
+
+ #region Duplicate Handling Tests
+
+ [Fact]
+ public void AreCameraInfoListsEqual_ListWithDuplicates_ShouldHandleCorrectly()
+ {
+ // Arrange
+ var camera = CreateCameraInfo("Camera1");
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { camera, camera });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting same list with duplicates
+ SetAvailableCameras(provider, new List { camera, camera });
+
+ // Assert - No event should be raised
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ #endregion
+
+ #region CameraInfo Property Variations Tests
+
+ [Fact]
+ public void AreCameraInfoListsEqual_DifferentCameraName_ShouldNotConsiderNotEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List
+ {
+ CreateCameraInfo("Camera1", "device1", CameraPosition.Front)
+ });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Different name, same device and position
+ SetAvailableCameras(provider, new List
+ {
+ CreateCameraInfo("Camera2", "device1", CameraPosition.Front)
+ });
+
+ // Assert - Event should be raised (different camera)
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_DifferentDeviceId_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List
+ {
+ CreateCameraInfo("Camera1", "device1", CameraPosition.Front)
+ });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Same name and position, different device
+ SetAvailableCameras(provider, new List
+ {
+ CreateCameraInfo("Camera1", "device2", CameraPosition.Front)
+ });
+
+ // Assert - Event should be raised (different camera)
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_AllPropertiesSame_ShouldConsiderEqual()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List
+ {
+ CreateFullCameraInfo("Camera1", "device1", CameraPosition.Front, true, 1.0f, 5.0f)
+ });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - All properties identical
+ SetAvailableCameras(provider, new List
+ {
+ CreateFullCameraInfo("Camera1", "device1", CameraPosition.Front, true, 1.0f, 5.0f)
+ });
+
+ // Assert - No event should be raised
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ #endregion
+
+ #region Sequential Changes Tests
+
+ [Fact]
+ public void AreCameraInfoListsEqual_MultipleSequentialChanges_ShouldRaiseEventForEachChange()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ var camera1 = CreateCameraInfo("Camera1");
+ var camera2 = CreateCameraInfo("Camera2");
+ var camera3 = CreateCameraInfo("Camera3");
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Multiple different changes
+ SetAvailableCameras(provider, new List { camera1 });
+ SetAvailableCameras(provider, new List { camera2 });
+ SetAvailableCameras(provider, new List { camera3 });
+ SetAvailableCameras(provider, new List { camera1, camera2 });
+
+ // Assert - Event raised for each change
+ Assert.Equal(4, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_AlternatingBetweenTwoStates_ShouldRaiseEventForEachChange()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ var state1 = new List { CreateCameraInfo("Camera1") };
+ var state2 = new List { CreateCameraInfo("Camera2") };
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Alternating between two different states
+ SetAvailableCameras(provider, state1);
+ SetAvailableCameras(provider, state2);
+ SetAvailableCameras(provider, state1);
+ SetAvailableCameras(provider, state2);
+
+ // Assert - Event raised for each change
+ Assert.Equal(4, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_NoChangeBetweenUpdates_ShouldNotRaiseEvent()
+ {
+ // Arrange
+ var camera = CreateCameraInfo("Camera1");
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, new List { camera });
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Multiple updates with same content
+ SetAvailableCameras(provider, new List { camera });
+ SetAvailableCameras(provider, new List { camera });
+ SetAvailableCameras(provider, new List { camera });
+
+ // Assert - No events raised
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ #endregion
+
+ #region Large List Tests
+
+ [Fact]
+ public void AreCameraInfoListsEqual_LargeLists_SameContent_ShouldConsiderEqual()
+ {
+ // Arrange
+ var cameras = Enumerable.Range(0, 100)
+ .Select(i => CreateCameraInfo($"Camera{i}"))
+ .ToList();
+
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, cameras);
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - Setting same large list
+ SetAvailableCameras(provider, cameras.ToList());
+
+ // Assert - No event should be raised
+ Assert.Equal(0, eventRaisedCount);
+ }
+
+ [Fact]
+ public void AreCameraInfoListsEqual_LargeLists_OneDifferent_ShouldConsiderNotEqual()
+ {
+ // Arrange
+ var cameras1 = Enumerable.Range(0, 100)
+ .Select(i => CreateCameraInfo($"Camera{i}"))
+ .ToList();
+
+ var cameras2 = Enumerable.Range(0, 100)
+ .Select(i => i == 50 ? CreateCameraInfo($"DifferentCamera{i}") : CreateCameraInfo($"Camera{i}"))
+ .ToList();
+
+ var provider = new MockCameraProvider();
+ SetAvailableCameras(provider, cameras1);
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Act - One camera different in large list
+ SetAvailableCameras(provider, cameras2);
+
+ // Assert - Event should be raised
+ Assert.Equal(1, eventRaisedCount);
+ }
+
+ #endregion
+
+ #region Edge Cases
+
+ [Fact]
+ public async Task AreCameraInfoListsEqual_AfterRefresh_ShouldCompareCorrectly()
+ {
+ // Arrange
+ var provider = new MockCameraProvider();
+ var initialCameras = provider.AvailableCameras;
+
+ // Act - Refresh creates new cameras
+ await provider.RefreshAvailableCameras(CancellationToken.None);
+ var firstCamera = provider.AvailableCameras?[0];
+
+ var eventRaisedCount = 0;
+ provider.AvailableCamerasChanged += (s, c) => eventRaisedCount++;
+
+ // Refresh again (will create different camera due to new GUID)
+ await provider.RefreshAvailableCameras(CancellationToken.None);
+
+ // Assert - Event should be raised because GUIDs are different
+ Assert.Equal(1, eventRaisedCount);
+ Assert.NotNull(provider.AvailableCameras);
+ Assert.Single(provider.AvailableCameras);
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ static void SetAvailableCameras(MockCameraProvider provider, IReadOnlyList? cameras)
+ {
+ var property = typeof(MockCameraProvider).GetProperty(nameof(MockCameraProvider.AvailableCameras)) ?? throw new InvalidOperationException();
+ property.SetValue(provider, cameras);
+ }
+
+ static CameraInfo CreateCameraInfo(string name, string? deviceId = null, CameraPosition position = CameraPosition.Front)
+ {
+ return new CameraInfo(
+ name,
+ deviceId ?? Guid.NewGuid().ToString(),
+ position,
+ false,
+ 1.0f,
+ 5.0f,
+ [new Size(1920, 1080)]);
+ }
+
+ static CameraInfo CreateFullCameraInfo(
+ string name,
+ string deviceId,
+ CameraPosition position,
+ bool isSupported,
+ float minZoom,
+ float maxZoom)
+ {
+ return new CameraInfo(
+ name,
+ deviceId,
+ position,
+ isSupported,
+ minZoom,
+ maxZoom,
+ [new Size(1920, 1080)]);
+ }
+
+ #endregion
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/CameraView/CameraViewTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/CameraView/CameraViewTests.cs
index 3f09ad0341..4f4b43b517 100644
--- a/src/CommunityToolkit.Maui.UnitTests/Views/CameraView/CameraViewTests.cs
+++ b/src/CommunityToolkit.Maui.UnitTests/Views/CameraView/CameraViewTests.cs
@@ -61,6 +61,22 @@ public void OnMediaCaptured_RaisesMediaCapturedEvent()
Assert.True(eventRaised);
}
+ [Fact]
+ public async Task StartVideoRecording_ShouldThrowException()
+ {
+ var imageData = new MemoryStream();
+
+ await Assert.ThrowsAsync(() => cameraView.StartVideoRecording(imageData, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task StopVideoRecording_ShouldThrowException()
+ {
+ var imageData = new MemoryStream();
+
+ await Assert.ThrowsAsync(() => cameraView.StopVideoRecording(TestContext.Current.CancellationToken));
+ }
+
[Fact(Timeout = (int)TestDuration.Short)]
public async Task OnMediaCapturedFailed_RaisesMediaCaptureFailedEvent()
{