diff options
3 files changed, 219 insertions, 31 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/screenrecord/RecordingService.java b/packages/SystemUI/src/com/android/systemui/screenrecord/RecordingService.java index 5bb3413595ba..a837cbb8a50e 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 2133cf63d1c3..d098b4b3442a 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); - - mMediaRecorder.setOnInfoListener(mListener); + new VirtualDisplay.Callback() { + @Override + public void onStopped() { + onStop(); + } + }, + mHandler); + + 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(); @@ -337,6 +362,18 @@ public class ScreenMediaRecorder { } /** + * Release the resources without saving the data + */ + protected void release() { + if (mTempVideoFile != null) { + mTempVideoFile.delete(); + } + if (mTempAudioFile != null) { + mTempAudioFile.delete(); + } + } + + /** * Object representing the recording */ public class SavedRecording { @@ -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; + } + } + } } 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 91cafead596c..b05d9a31b475 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(); + } } |