From 1b94d53122875a74c2b125623ee255f4684afbb4 Mon Sep 17 00:00:00 2001 From: Nick Chameyev Date: Wed, 6 Jul 2022 16:22:11 +0000 Subject: [PATCH] [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 --- .../screenrecord/RecordingService.java | 60 ++++++-- .../screenrecord/ScreenMediaRecorder.java | 129 ++++++++++++++++-- .../screenrecord/RecordingServiceTest.java | 59 +++++++- 3 files changed, 218 insertions(+), 30 deletions(-) diff --git a/packages/SystemUI/src/com/android/systemui/screenrecord/RecordingService.java b/packages/SystemUI/src/com/android/systemui/screenrecord/RecordingService.java index 5bb3413595ba8..a837cbb8a50e1 100644 --- a/packages/SystemUI/src/com/android/systemui/screenrecord/RecordingService.java +++ b/packages/SystemUI/src/com/android/systemui/screenrecord/RecordingService.java @@ -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) { - getRecorder().end(); - saveRecording(userId); + 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(); + } + } } diff --git a/packages/SystemUI/src/com/android/systemui/screenrecord/ScreenMediaRecorder.java b/packages/SystemUI/src/com/android/systemui/screenrecord/ScreenMediaRecorder.java index 2133cf63d1c3f..d098b4b3442ae 100644 --- a/packages/SystemUI/src/com/android/systemui/screenrecord/ScreenMediaRecorder.java +++ b/packages/SystemUI/src/com/android/systemui/screenrecord/ScreenMediaRecorder.java @@ -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 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; + } + } + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenrecord/RecordingServiceTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenrecord/RecordingServiceTest.java index 91cafead596cf..b05d9a31b475e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/screenrecord/RecordingServiceTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/screenrecord/RecordingServiceTest.java @@ -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(); + } }