Maui Error - Screen Recording - Error on StopRecording();

116 views Asked by At

I'm working on attempting to add Android implementation to https://github.com/jfversluis/Plugin.Maui.ScreenRecording

I think I checked all bases, but I'm getting this exception on MediaRecorder.Stop();

{Java.Lang.RuntimeException: stop failed.
   at Java.Interop.JniEnvironment.InstanceMethods.CallVoidMethod(JniObjectReference instance, JniMethodInfo method, JniArgumentValue* args) in /Users/runner/work/1/s/xamarin-android/external/Java.Interop/src/Java.Interop/obj/Release/net7.0/JniEnvironment.g.cs:line 20370
   at Java.Interop.JniPeerMembers.JniInstanceMethods.InvokeVirtualVoidMethod(String encodedMember, IJavaPeerable self, JniArgumentValue* parameters) in /Users/runner/work/1/s/xamarin-android/external/Java.Interop/src/Java.Interop/Java.Interop/JniPeerMembers.JniInstanceMethods_Invoke.cs:line 66
   at Android.Media.MediaRecorder.Stop() in /Users/runner/work/1/s/xamarin-android/src/Mono.Android/obj/Release/net8.0/android-34/mcw/Android.Media.MediaRecorder.cs:line 2477
   at Plugin.Maui.ScreenRecording.ScreenRecordingImplementation.StopRecording(ScreenRecordingOptions options) in D:\pmsr\src\Plugin.Maui.ScreenRecording\ScreenRecording.android.cs:line 84
  --- End of managed Java.Lang.RuntimeException stack trace ---
java.lang.RuntimeException: stop failed.
    at android.media.MediaRecorder.stop(Native Method)
    at crc64fcf28c0e24b4cc31.ButtonHandler_ButtonClickListener.n_onClick(Native Method)
    at crc64fcf28c0e24b4cc31.ButtonHandler_ButtonClickListener.onClick(ButtonHandler_ButtonClickListener.java:31)
    at android.view.View.performClick(View.java:7448)
    at com.google.android.material.button.MaterialButton.performClick(MaterialButton.java:1211)
    at android.view.View.performClickInternal(View.java:7425)
    at android.view.View.access$3600(View.java:810)
    at android.view.View$PerformClick.run(View.java:28305)
    at android.os.Handler.handleCallback(Handler.java:938)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loop(Looper.java:223)
    at android.app.ActivityThread.main(ActivityThread.java:7656)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

  --- End of managed Java.Lang.RuntimeException stack trace ---
java.lang.RuntimeException: stop failed.
    at android.media.MediaRecorder.stop(Native Method)
    at crc64fcf28c0e24b4cc31.ButtonHandler_ButtonClickListener.n_onClick(Native Method)
    at crc64fcf28c0e24b4cc31.ButtonHandler_ButtonClickListener.onClick(ButtonHandler_ButtonClickListener.java:31)
    at android.view.View.performClick(View.java:7448)
    at com.google.android.material.button.MaterialButton.performClick(MaterialButton.java:1211)
    at android.view.View.performClickInternal(View.java:7425)
    at android.view.View.access$3600(View.java:810)
    at android.view.View$PerformClick.run(View.java:28305)
    at android.os.Handler.handleCallback(Handler.java:938)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loop(Looper.java:223)
    at android.app.ActivityThread.main(ActivityThread.java:7656)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
}

I'm guessing it is something stupid I am looking over. Any guidance or suggestions would be greatly appreciated.

ScreenRecording.android.cs

using Android.Content;
using Android.Media;
using Android.Media.Projection;
using Android.Hardware.Display;
using Android.Views;
using Microsoft.Maui.ApplicationModel;
using Android.Content.PM;
using Android.OS;
using AndroidX.Core.App;
using Android.Graphics.Drawables;
using AndroidX.Core.Graphics.Drawable;
using Microsoft.Maui.Devices;
using static Android.Provider.Telephony.Mms;
using Microsoft.Maui.Storage;



namespace Plugin.Maui.ScreenRecording;

public partial class ScreenRecordingImplementation : IScreenRecording
{
    public MediaProjectionManager ProjectionManager;
    public MediaProjection MediaProjection;
    public VirtualDisplay VirtualDisplay;
    public MediaRecorder MediaRecorder;
    public string FilePath;
    public const int REQUEST_MEDIA_PROJECTION = 1;

    public static EventHandler<ScreenRecordingEventArgs> ScreenRecordingPermissionHandler;
    public ScreenRecordingImplementation()
    {
        ProjectionManager = (MediaProjectionManager)Platform.AppContext.GetSystemService(Context.MediaProjectionService);

        
    }

    public bool IsRecording { get; private set; }

    public bool IsSupported { get { return ProjectionManager != null; } private set { }}
    

    


    public async Task StartRecording(bool enableMicrophone)
    {
        if (IsSupported)
        {

            

            MediaRecorder = new MediaRecorder();
            //FilePath = Path.Combine(Platform.AppContext.FilesDir.AbsolutePath, $"recording_{DateTime.Now:yyyyMMdd_HHmmss}.mp4");

            FilePath = Path.Combine(FileSystem.Current.CacheDirectory, "Screen.mp4");

            SetUpMediaRecorder(enableMicrophone);
            try
            {

                await Task.Delay(2000);
                MediaRecorder.Start();
                IsRecording = true;
            }
            catch(Exception ex)
            {
                Console.WriteLine(ex.Message);
                throw new NotSupportedException("Screen recording did not start.");
            }
        }
        else
        {
            throw new NotSupportedException("Screen recording not supported on this device.");
        }
    }

    public async Task<ScreenRecordingFile?> StopRecording(ScreenRecordingOptions? options)
    {
        if (IsRecording)
        {
            IsRecording = false;
            try
            {
                MediaRecorder.Stop();
                MediaRecorder.Release();
                VirtualDisplay.Release();
                MediaProjection.Stop();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
            var context = Android.App.Application.Context;

            context.StopService(new Intent(context, typeof(ScreenRecordingService)));

            return new ScreenRecordingFile(FilePath);
        }

        return null;
    }

    public async void OnScreenCapturePermissionGranted(Result resultCode, Intent? data)
    {
        if (resultCode == Result.Ok && data != null)
        {
            var context = Android.App.Application.Context;
            Intent serviceIntent = new Intent(context, typeof(ScreenRecordingService));
            if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
            {
                context.StartForegroundService(serviceIntent);
            }
            else
            {
                context.StartService(serviceIntent);
            }

            await Task.Delay(2000);

            // Handle the successful permission result
            MediaProjection mediaProjection = ProjectionManager.GetMediaProjection((int)resultCode, data);

            VirtualDisplay = mediaProjection.CreateVirtualDisplay("Plugin.Maui.ScreenRecording.ScreenRecordingImplementation.ScreenRecordingService", (int)DeviceDisplay.Current.MainDisplayInfo.Width, (int)DeviceDisplay.Current.MainDisplayInfo.Height, (int)DeviceDisplay.Current.MainDisplayInfo.Density, Android.Views.DisplayFlags.Presentation, null, null, null);

        }
        else
        {
            // Handle the case where permission was not granted
        }
        // Additional setup or start recording
    }

    public void SetUpMediaRecorder(bool enableMicrophone)
    {
        MediaRecorder = new MediaRecorder();

        if (enableMicrophone)
        {
            MediaRecorder.SetAudioSource(AudioSource.Mic);
        }

        MediaRecorder.SetVideoSource(VideoSource.Surface);
        MediaRecorder.SetOutputFormat(OutputFormat.Mpeg4);
        MediaRecorder.SetVideoEncoder(VideoEncoder.H264);

        if (enableMicrophone)
        {
            MediaRecorder.SetAudioEncoder(AudioEncoder.AmrNb);
        }

        MediaRecorder.SetOutputFile(FilePath);
        MediaRecorder.SetVideoSize((int)DeviceDisplay.Current.MainDisplayInfo.Width, (int)DeviceDisplay.Current.MainDisplayInfo.Height);
        MediaRecorder.SetVideoFrameRate(30);

        try
        {
            MediaRecorder.Prepare();
        }
        catch (Java.IO.IOException ex)
        {
            Console.WriteLine($"MediaRecorder preparation failed: {ex.Message}");
            // Handle preparation failure
            MediaRecorder.Release();
        }
    }

    public void Setup()
    {
        Intent captureIntent = ProjectionManager.CreateScreenCaptureIntent();
        Platform.CurrentActivity.StartActivityForResult(captureIntent, REQUEST_MEDIA_PROJECTION);
    }

    public class ScreenRecordingEventArgs : EventArgs
    {
        public Result ResultCode { get; set; }

        public Intent? Data { get; set; }
    }

    [Service(ForegroundServiceType = ForegroundService.TypeMediaProjection)]
    public class ScreenRecordingService : Service
    {
        public override IBinder? OnBind(Intent? intent)
        {
            return null;
        }

        public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
        {
            string CHANNEL_ID = "ScreenRecordingService";
            int NOTIFICATION_ID = 1337;

            if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
            {
                var channelName = "Screen Recording Service";
                var channel = new NotificationChannel(CHANNEL_ID, channelName, NotificationImportance.Default)
                {
                    Description = "Notification Channel for Screen Recording Service"
                };

                var notificationManager = (NotificationManager)GetSystemService(NotificationService);
                notificationManager.CreateNotificationChannel(channel);
            }

            // Create a notification for the foreground service
            var notificationBuilder = new Notification.Builder(this, CHANNEL_ID)
                .SetContentTitle("Screen Recording")
                .SetContentText("Recording screen...")
                .SetSmallIcon(Resource.Drawable.notification_template_icon_low_bg); // Ensure you have 'ic_notification' in Resources/drawable

            var notification = notificationBuilder.Build();

            // Start the service in the foreground
            StartForeground(NOTIFICATION_ID, notification);

            return StartCommandResult.Sticky;
        }

    }
}

MainActivity.cs

using Android.App;
using Android.Content.PM;
using Android.OS;
using Plugin.Maui.ScreenRecording;

namespace ScreenRecordingSample;

[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
    public const int REQUEST_MEDIA_PROJECTION = 1;
    ScreenRecordingImplementation screenRecordingImplementation = new ScreenRecordingImplementation();


    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        // Initialize other components...

        // Initialize ScreenRecordingImplementation
        screenRecordingImplementation = new Plugin.Maui.ScreenRecording.ScreenRecordingImplementation();
    }
    protected override void OnActivityResult(int requestCode, Result resultCode, Android.Content.Intent? data)
    {
        base.OnActivityResult(requestCode, resultCode, data);

        if (requestCode == REQUEST_MEDIA_PROJECTION)
        {
            screenRecordingImplementation.OnScreenCapturePermissionGranted(resultCode, data);
        }
    }
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:allowBackup="true" android:supportsRtl="true">
        <service android:name="Plugin.Maui.ScreenRecording.ScreenRecordingImplementation.ScreenRecordingService" android:foregroundServiceType="mediaProjection" />
    </application>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-sdk />    
</manifest>

IScreenRecording.cs

namespace Plugin.Maui.ScreenRecording;

/// <summary>
/// Provides the ability to record the screen within your app.
/// </summary>
public interface IScreenRecording
{
    /// <summary>
    /// Gets whether or not screen recording is currently happening.
    /// </summary>
    bool IsRecording { get; }

    /// <summary>
    /// Gets whether or not screen recording is supported on this device.
    /// </summary>
    bool IsSupported { get; }

    /// <summary>
    /// Sets up the screenrecording.
    /// </summary>
    /// <remarks>
    /// This is needed for android to request media projection.
    /// </remarks>
    void Setup();

    /// <summary>
    /// Starts the screenrecording.
    /// </summary>
    /// <param name="enableMicrophone">Determines if the microphone should be used as input during the recording.</param>
    /// <returns>A <see cref="Task"/> object with information about this operation.</returns>
    /// <remarks>
    /// A permission to access the microphone might be needed, this is not requested by this method.
    /// Make sure to add an entry in the metadata of your app and request the runtime permission
    /// before calling this method.
    /// </remarks>
    Task StartRecording(bool enableMicrophone);

    /// <summary>
    /// Stops the recording and saves the video file to the device's gallery.
    /// </summary>
    /// <returns>A <see cref="Task"/> object with information about this operation.</returns>
    Task<ScreenRecordingFile?> StopRecording(ScreenRecordingOptions? options = null);
}

The rest of the code is the same as the main repository I believe.

1

There are 1 answers

3
VonC On BEST ANSWER

First, surround the MediaRecorder.Stop() call with a try-catch block to handle any runtime exceptions gracefully. That will not solve the root cause but can prevent the app from crashing.

try
{
    MediaRecorder.Stop();
}
catch (Java.Lang.RuntimeException ex)
{
    Console.WriteLine("Error stopping MediaRecorder: " + ex.Message);
    // Handle the exception, e.g., by logging
}

The MediaRecorder.Stop() method will fail if the recording has not actually started or if it is already stopped. Make sure MediaRecorder.Start() is successfully called before invoking Stop().

if (IsRecording)
{
    // Proceed to stop the recording
}

The MediaRecorder class is also very sensitive to the order of method calls. Make sure that the sequence of Prepare(), Start(), and Stop() calls is correct. You must call Prepare() before Start(), and Start() must have enough time to initiate the recording before you call Stop().

If the recording is stopped immediately after it starts, the MediaRecorder might not have enough data to finalize the video file, leading to this exception. Introduce a delay between start and stop to make sure some data is recorded.

// Wait for a minimum duration before allowing stop
if ((DateTime.UtcNow - recordingStartTime).TotalSeconds < MIN_RECORDING_DURATION)
{
    // Wait or notify the user
}

After stopping the recording, make sure resources are released properly. Call MediaRecorder.Release() after MediaRecorder.Stop(). Also, release the VirtualDisplay and MediaProjection objects.


The issue is the MediaProjectionManager and Virtual display are null once I got to the code since they aren't using the same instance, so I believe I have to DI so they are the same, right?

True, you should use the DI container to resolve your IScreenRecording instance.
But, as you have discovered, injecting dependencies directly into the MainActivity constructor is not straightforward because Android requires a default constructor for activities.

A possible workaround would, instead of using constructor injection, to use property or method injection mentioned in "Dependency injection". That can be done after the activity is created and its dependencies are ready to be injected.

The article "Are you using Dependency Injection in your .NET MAUI app yet?" from Julian Ewers-Peters mentions "service locator pattern" to resolve dependencies (examples in "Page Resolver and Navigation Extension for MAUI" from Matt Goldman), but cautions

I am aware that the ServiceHelper is an implementation of the Service Locator pattern, which is often regarded as an anti-pattern. However, in certain scenarios, especially with regards to .NET-baseed UI technologies, the Service Locator pattern is a necessity.

But a commonly used workaround would be to resolve the dependency in the OnCreate method (see ".NET MAUI / App lifecycle") of your MainActivity. That makes sure the dependency is resolved as soon as the activity is created, but before it is used.

For that last approach, your MainActivity using DI for IScreenRecording would be:

using Microsoft.Extensions.DependencyInjection; // Make sure you have this using directive

namespace ScreenRecordingSample;

[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
    private IScreenRecording screenRecordingImplementation;

    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        // Resolve IScreenRecording using the DI container
        screenRecordingImplementation = MauiApplication.Current.Services.GetService<IScreenRecording>();

        // Rest of the OnCreate implementation
    }

    // Rest of your MainActivity code
}