Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1b94d531 authored by Nick Chameyev's avatar Nick Chameyev
Browse files

[Partial screen sharing] Handle system-requested media projection stop in SysUI screen recorder

Stops the recording when the system notified that
it is going to stop the media projection session
or destroy the virtual display.

When the system stops the recording it will perform the same
operations as if the user clicked on 'stop' button.
If the recording is empty (e.g. system couldn't setup
screen recording and immediately stopped it) we won't save
the file and show an error toast.

Bug: 220727636
Test: com.android.systemui.screenrecord.RecordingServiceTest
Test: start screen sharing and emulate setup failure in ContentRecorder
Test: start partial screen sharing and kill the target app
Change-Id: Ic3c2342c01fbaa5f87e36418483eb431d6484a38
parent a9a4f1e4
Loading
Loading
Loading
Loading
+47 −13
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ import android.graphics.drawable.Icon;
import android.media.MediaRecorder;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.UserHandle;
@@ -40,6 +41,8 @@ import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.UiEventLogger;
import com.android.systemui.R;
import com.android.systemui.dagger.qualifiers.LongRunning;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.screenrecord.ScreenMediaRecorder.ScreenMediaRecorderListener;
import com.android.systemui.settings.UserContextProvider;
import com.android.systemui.statusbar.phone.KeyguardDismissUtil;

@@ -51,9 +54,10 @@ import javax.inject.Inject;
/**
 * A service which records the device screen and optionally microphone input.
 */
public class RecordingService extends Service implements MediaRecorder.OnInfoListener {
public class RecordingService extends Service implements ScreenMediaRecorderListener {
    public static final int REQUEST_CODE = 2;

    private static final int USER_ID_NOT_SPECIFIED = -1;
    private static final int NOTIFICATION_RECORDING_ID = 4274;
    private static final int NOTIFICATION_PROCESSING_ID = 4275;
    private static final int NOTIFICATION_VIEW_ID = 4273;
@@ -73,6 +77,7 @@ public class RecordingService extends Service implements MediaRecorder.OnInfoLis

    private final RecordingController mController;
    private final KeyguardDismissUtil mKeyguardDismissUtil;
    private final Handler mMainHandler;
    private ScreenRecordingAudioSource mAudioSource;
    private boolean mShowTaps;
    private boolean mOriginalShowTaps;
@@ -84,10 +89,12 @@ public class RecordingService extends Service implements MediaRecorder.OnInfoLis

    @Inject
    public RecordingService(RecordingController controller, @LongRunning Executor executor,
            UiEventLogger uiEventLogger, NotificationManager notificationManager,
            @Main Handler handler, UiEventLogger uiEventLogger,
            NotificationManager notificationManager,
            UserContextProvider userContextTracker, KeyguardDismissUtil keyguardDismissUtil) {
        mController = controller;
        mLongExecutor = executor;
        mMainHandler = handler;
        mUiEventLogger = uiEventLogger;
        mNotificationManager = notificationManager;
        mUserContextTracker = userContextTracker;
@@ -138,6 +145,7 @@ public class RecordingService extends Service implements MediaRecorder.OnInfoLis

                mRecorder = new ScreenMediaRecorder(
                        mUserContextTracker.getUserContext(),
                        mMainHandler,
                        currentUserId,
                        mAudioSource,
                        this
@@ -166,14 +174,8 @@ public class RecordingService extends Service implements MediaRecorder.OnInfoLis
                }
                // Check user ID - we may be getting a stop intent after user switch, in which case
                // we want to post the notifications for that user, which is NOT current user
                int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1);
                if (userId == -1) {
                    userId = mUserContextTracker.getUserContext().getUserId();
                }
                Log.d(TAG, "notifying for user " + userId);
                stopRecording(userId);
                mNotificationManager.cancel(NOTIFICATION_RECORDING_ID);
                stopSelf();
                int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, USER_ID_NOT_SPECIFIED);
                stopService(userId);
                break;

            case ACTION_SHARE:
@@ -378,15 +380,39 @@ public class RecordingService extends Service implements MediaRecorder.OnInfoLis
        return builder.build();
    }

    private void stopRecording(int userId) {
    private void stopService() {
        stopService(USER_ID_NOT_SPECIFIED);
    }

    private void stopService(int userId) {
        if (userId == USER_ID_NOT_SPECIFIED) {
            userId = mUserContextTracker.getUserContext().getUserId();
        }
        Log.d(TAG, "notifying for user " + userId);
        setTapsVisible(mOriginalShowTaps);
        if (getRecorder() != null) {
            try {
                getRecorder().end();
                saveRecording(userId);
            } catch (RuntimeException exception) {
                // RuntimeException could happen if the recording stopped immediately after starting
                // let's release the recorder and delete all temporary files in this case
                getRecorder().release();
                showErrorToast(R.string.screenrecord_start_error);
                Log.e(TAG, "stopRecording called, but there was an error when ending"
                        + "recording");
                exception.printStackTrace();
            } catch (Throwable throwable) {
                // Something unexpected happen, SystemUI will crash but let's delete
                // the temporary files anyway
                getRecorder().release();
                throw new RuntimeException(throwable);
            }
        } else {
            Log.e(TAG, "stopRecording called, but recorder was null");
        }
        updateState(false);
        stopSelf();
    }

    private void saveRecording(int userId) {
@@ -446,4 +472,12 @@ public class RecordingService extends Service implements MediaRecorder.OnInfoLis
        Log.d(TAG, "Media recorder info: " + what);
        onStartCommand(getStopIntent(this), 0, 0);
    }

    @Override
    public void onStopped() {
        if (mController.isRecording()) {
            Log.d(TAG, "Stopping recording because the system requested the stop");
            stopService();
        }
    }
}
+115 −16
Original line number Diff line number Diff line
@@ -40,6 +40,7 @@ import android.media.projection.IMediaProjectionManager;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.net.Uri;
import android.os.Handler;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.ServiceManager;
@@ -51,16 +52,19 @@ import android.view.Surface;
import android.view.WindowManager;

import java.io.File;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
 * Recording screen and mic/internal audio
 */
public class ScreenMediaRecorder {
public class ScreenMediaRecorder extends MediaProjection.Callback {
    private static final int TOTAL_NUM_TRACKS = 1;
    private static final int VIDEO_FRAME_RATE = 30;
    private static final int VIDEO_FRAME_RATE_TO_RESOLUTION_RATIO = 6;
@@ -81,14 +85,16 @@ public class ScreenMediaRecorder {
    private ScreenRecordingMuxer mMuxer;
    private ScreenInternalAudioRecorder mAudio;
    private ScreenRecordingAudioSource mAudioSource;
    private final Handler mHandler;

    private Context mContext;
    MediaRecorder.OnInfoListener mListener;
    ScreenMediaRecorderListener mListener;

    public ScreenMediaRecorder(Context context,
    public ScreenMediaRecorder(Context context, Handler handler,
            int user, ScreenRecordingAudioSource audioSource,
            MediaRecorder.OnInfoListener listener) {
            ScreenMediaRecorderListener listener) {
        mContext = context;
        mHandler = handler;
        mUser = user;
        mListener = listener;
        mAudioSource = audioSource;
@@ -105,6 +111,7 @@ public class ScreenMediaRecorder {
        IBinder projection = proj.asBinder();
        mMediaProjection = new MediaProjection(mContext,
                IMediaProjection.Stub.asInterface(projection));
        mMediaProjection.registerCallback(this, mHandler);

        File cacheDir = mContext.getCacheDir();
        cacheDir.mkdirs();
@@ -162,10 +169,15 @@ public class ScreenMediaRecorder {
                metrics.densityDpi,
                DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
                mInputSurface,
                null,
                null);
                new VirtualDisplay.Callback() {
                    @Override
                    public void onStopped() {
                        onStop();
                    }
                },
                mHandler);

        mMediaRecorder.setOnInfoListener(mListener);
        mMediaRecorder.setOnInfoListener((mr, what, extra) -> mListener.onInfo(mr, what, extra));
        if (mAudioSource == INTERNAL ||
                mAudioSource == MIC_AND_INTERNAL) {
            mTempAudioFile = File.createTempFile("temp", ".aac",
@@ -259,21 +271,34 @@ public class ScreenMediaRecorder {
    }

    /**
     * End screen recording
     * End screen recording, throws an exception if stopping recording failed
     */
    void end() {
        mMediaRecorder.stop();
        mMediaRecorder.release();
        mInputSurface.release();
        mVirtualDisplay.release();
        mMediaProjection.stop();
    void end() throws IOException {
        Closer closer = new Closer();

        // MediaRecorder might throw RuntimeException if stopped immediately after starting
        // We should remove the recording in this case as it will be invalid
        closer.register(mMediaRecorder::stop);
        closer.register(mMediaRecorder::release);
        closer.register(mInputSurface::release);
        closer.register(mVirtualDisplay::release);
        closer.register(mMediaProjection::stop);
        closer.register(this::stopInternalAudioRecording);

        closer.close();

        mMediaRecorder = null;
        mMediaProjection = null;
        stopInternalAudioRecording();

        Log.d(TAG, "end recording");
    }

    @Override
    public void onStop() {
        Log.d(TAG, "The system notified about stopping the projection");
        mListener.onStopped();
    }

    private void stopInternalAudioRecording() {
        if (mAudioSource == INTERNAL || mAudioSource == MIC_AND_INTERNAL) {
            mAudio.end();
@@ -336,6 +361,18 @@ public class ScreenMediaRecorder {
        return recording;
    }

    /**
     * Release the resources without saving the data
     */
    protected void release() {
        if (mTempVideoFile != null) {
            mTempVideoFile.delete();
        }
        if (mTempAudioFile != null) {
            mTempAudioFile.delete();
        }
    }

    /**
    * Object representing the recording
    */
@@ -362,4 +399,66 @@ public class ScreenMediaRecorder {
            return mThumbnailBitmap;
        }
    }

    interface ScreenMediaRecorderListener {
        /**
         * Called to indicate an info or a warning during recording.
         * See {@link MediaRecorder.OnInfoListener} for the full description.
         */
        void onInfo(MediaRecorder mr, int what, int extra);

        /**
         * Called when the recording stopped by the system.
         * For example, this might happen when doing partial screen sharing of an app
         * and the app that is being captured is closed.
         */
        void onStopped();
    }

    /**
     * Allows to register multiple {@link Closeable} objects and close them all by calling
     * {@link Closer#close}. If there is an exception thrown during closing of one
     * of the registered closeables it will continue trying closing the rest closeables.
     * If there are one or more exceptions thrown they will be re-thrown at the end.
     * In case of multiple exceptions only the first one will be thrown and all the rest
     * will be printed.
     */
    private static class Closer implements Closeable {
        private final List<Closeable> mCloseables = new ArrayList<>();

        void register(Closeable closeable) {
            mCloseables.add(closeable);
        }

        @Override
        public void close() throws IOException {
            Throwable throwable = null;

            for (int i = 0; i < mCloseables.size(); i++) {
                Closeable closeable = mCloseables.get(i);

                try {
                    closeable.close();
                } catch (Throwable e) {
                    if (throwable == null) {
                        throwable = e;
                    } else {
                        e.printStackTrace();
                    }
                }
            }

            if (throwable != null) {
                if (throwable instanceof IOException) {
                    throw (IOException) throwable;
                }

                if (throwable instanceof RuntimeException) {
                    throw (RuntimeException) throwable;
                }

                throw (Error) throwable;
            }
        }
    }
}
+57 −2
Original line number Diff line number Diff line
@@ -16,18 +16,21 @@

package com.android.systemui.screenrecord;

import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import android.app.Notification;
import android.app.NotificationManager;
import android.content.Intent;
import android.os.Handler;
import android.os.RemoteException;
import android.testing.AndroidTestingRunner;

@@ -66,6 +69,8 @@ public class RecordingServiceTest extends SysuiTestCase {
    @Mock
    private Executor mExecutor;
    @Mock
    private Handler mHandler;
    @Mock
    private UserContextProvider mUserContextTracker;
    private KeyguardDismissUtil mKeyguardDismissUtil = new KeyguardDismissUtil() {
        public void executeWhenUnlocked(ActivityStarter.OnDismissAction action,
@@ -79,8 +84,8 @@ public class RecordingServiceTest extends SysuiTestCase {
    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        mRecordingService = Mockito.spy(new RecordingService(mController, mExecutor, mUiEventLogger,
                mNotificationManager, mUserContextTracker, mKeyguardDismissUtil));
        mRecordingService = Mockito.spy(new RecordingService(mController, mExecutor, mHandler,
                mUiEventLogger, mNotificationManager, mUserContextTracker, mKeyguardDismissUtil));

        // Return actual context info
        doReturn(mContext).when(mRecordingService).getApplicationContext();
@@ -143,4 +148,54 @@ public class RecordingServiceTest extends SysuiTestCase {
        // Then the state is set to not recording
        verify(mController).updateState(false);
    }

    @Test
    public void testOnSystemRequestedStop_recordingInProgress_endsRecording() throws IOException {
        doReturn(true).when(mController).isRecording();

        mRecordingService.onStopped();

        verify(mScreenMediaRecorder).end();
    }

    @Test
    public void testOnSystemRequestedStop_recordingInProgress_updatesState() {
        doReturn(true).when(mController).isRecording();

        mRecordingService.onStopped();

        verify(mController).updateState(false);
    }

    @Test
    public void testOnSystemRequestedStop_recordingIsNotInProgress_doesNotEndRecording()
            throws IOException {
        doReturn(false).when(mController).isRecording();

        mRecordingService.onStopped();

        verify(mScreenMediaRecorder, never()).end();
    }

    @Test
    public void testOnSystemRequestedStop_recorderEndThrowsRuntimeException_releasesRecording()
            throws IOException {
        doReturn(true).when(mController).isRecording();
        doThrow(new RuntimeException()).when(mScreenMediaRecorder).end();

        mRecordingService.onStopped();

        verify(mScreenMediaRecorder).release();
    }

    @Test
    public void testOnSystemRequestedStop_recorderEndThrowsOOMError_releasesRecording()
            throws IOException {
        doReturn(true).when(mController).isRecording();
        doThrow(new OutOfMemoryError()).when(mScreenMediaRecorder).end();

        assertThrows(Throwable.class, () -> mRecordingService.onStopped());

        verify(mScreenMediaRecorder).release();
    }
}