Beth Thibodeau | 5898ac4 | 2018-10-26 13:00:09 -0400 | [diff] [blame] | 1 | /* |
| 2 | * Copyright (C) 2018 The Android Open Source Project |
| 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | * you may not use this file except in compliance with the License. |
| 6 | * You may obtain a copy of the License at |
| 7 | * |
| 8 | * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | * |
| 10 | * Unless required by applicable law or agreed to in writing, software |
| 11 | * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | * See the License for the specific language governing permissions and |
| 14 | * limitations under the License. |
| 15 | */ |
| 16 | |
| 17 | package com.android.systemui.screenrecord; |
| 18 | |
| 19 | import android.app.Activity; |
| 20 | import android.app.Notification; |
| 21 | import android.app.NotificationChannel; |
| 22 | import android.app.NotificationManager; |
| 23 | import android.app.PendingIntent; |
| 24 | import android.app.Service; |
| 25 | import android.content.Context; |
| 26 | import android.content.Intent; |
| 27 | import android.graphics.Bitmap; |
| 28 | import android.graphics.drawable.Icon; |
| 29 | import android.hardware.display.DisplayManager; |
| 30 | import android.hardware.display.VirtualDisplay; |
| 31 | import android.media.MediaRecorder; |
| 32 | import android.media.ThumbnailUtils; |
| 33 | import android.media.projection.MediaProjection; |
| 34 | import android.media.projection.MediaProjectionManager; |
| 35 | import android.net.Uri; |
| 36 | import android.os.Environment; |
| 37 | import android.os.IBinder; |
| 38 | import android.provider.MediaStore; |
| 39 | import android.provider.Settings; |
| 40 | import android.util.DisplayMetrics; |
| 41 | import android.util.Log; |
| 42 | import android.view.Surface; |
| 43 | import android.widget.Toast; |
| 44 | |
| 45 | import androidx.core.content.FileProvider; |
| 46 | |
| 47 | import com.android.systemui.R; |
| 48 | |
| 49 | import java.io.File; |
| 50 | import java.io.IOException; |
| 51 | import java.nio.file.Files; |
| 52 | import java.nio.file.Path; |
| 53 | import java.text.SimpleDateFormat; |
| 54 | import java.util.Date; |
| 55 | |
| 56 | /** |
| 57 | * A service which records the device screen and optionally microphone input. |
| 58 | */ |
| 59 | public class RecordingService extends Service { |
| 60 | private static final int NOTIFICATION_ID = 1; |
| 61 | private static final String TAG = "RecordingService"; |
| 62 | private static final String CHANNEL_ID = "screen_record"; |
| 63 | private static final String EXTRA_RESULT_CODE = "extra_resultCode"; |
| 64 | private static final String EXTRA_DATA = "extra_data"; |
| 65 | private static final String EXTRA_PATH = "extra_path"; |
| 66 | private static final String EXTRA_USE_AUDIO = "extra_useAudio"; |
| 67 | private static final String EXTRA_SHOW_TAPS = "extra_showTaps"; |
| 68 | private static final int REQUEST_CODE = 2; |
| 69 | |
| 70 | private static final String ACTION_START = "com.android.systemui.screenrecord.START"; |
| 71 | private static final String ACTION_STOP = "com.android.systemui.screenrecord.STOP"; |
| 72 | private static final String ACTION_PAUSE = "com.android.systemui.screenrecord.PAUSE"; |
| 73 | private static final String ACTION_RESUME = "com.android.systemui.screenrecord.RESUME"; |
| 74 | private static final String ACTION_CANCEL = "com.android.systemui.screenrecord.CANCEL"; |
| 75 | private static final String ACTION_SHARE = "com.android.systemui.screenrecord.SHARE"; |
| 76 | private static final String ACTION_DELETE = "com.android.systemui.screenrecord.DELETE"; |
| 77 | |
| 78 | private static final int TOTAL_NUM_TRACKS = 1; |
| 79 | private static final String RECORD_DIR = "Captures"; // TODO: use a translatable string |
| 80 | private static final int VIDEO_BIT_RATE = 6000000; |
| 81 | private static final int VIDEO_FRAME_RATE = 30; |
| 82 | private static final int AUDIO_BIT_RATE = 16; |
| 83 | private static final int AUDIO_SAMPLE_RATE = 44100; |
| 84 | private static final String FILE_PROVIDER = "com.android.systemui.fileprovider"; |
| 85 | |
| 86 | private MediaProjectionManager mMediaProjectionManager; |
| 87 | private MediaProjection mMediaProjection; |
| 88 | private Surface mInputSurface; |
| 89 | private VirtualDisplay mVirtualDisplay; |
| 90 | private MediaRecorder mMediaRecorder; |
| 91 | private Notification.Builder mRecordingNotificationBuilder; |
| 92 | |
| 93 | private boolean mUseAudio; |
| 94 | private boolean mShowTaps; |
| 95 | private File mTempFile; |
| 96 | |
| 97 | /** |
| 98 | * Get an intent to start the recording service. |
| 99 | * |
| 100 | * @param context Context from the requesting activity |
| 101 | * @param resultCode The result code from {@link android.app.Activity#onActivityResult(int, int, |
| 102 | * android.content.Intent)} |
| 103 | * @param data The data from {@link android.app.Activity#onActivityResult(int, int, |
| 104 | * android.content.Intent)} |
| 105 | * @param useAudio True to enable microphone input while recording |
| 106 | * @param showTaps True to make touches visible while recording |
| 107 | */ |
| 108 | public static Intent getStartIntent(Context context, int resultCode, Intent data, |
| 109 | boolean useAudio, boolean showTaps) { |
| 110 | return new Intent(context, RecordingService.class) |
| 111 | .setAction(ACTION_START) |
| 112 | .putExtra(EXTRA_RESULT_CODE, resultCode) |
| 113 | .putExtra(EXTRA_DATA, data) |
| 114 | .putExtra(EXTRA_USE_AUDIO, useAudio) |
| 115 | .putExtra(EXTRA_SHOW_TAPS, showTaps); |
| 116 | } |
| 117 | |
| 118 | @Override |
| 119 | public int onStartCommand(Intent intent, int flags, int startId) { |
| 120 | Log.d(TAG, "RecordingService is starting"); |
| 121 | if (intent == null) { |
| 122 | return Service.START_NOT_STICKY; |
| 123 | } |
| 124 | String action = intent.getAction(); |
| 125 | |
| 126 | NotificationManager notificationManager = |
| 127 | (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); |
| 128 | |
| 129 | switch (action) { |
| 130 | case ACTION_START: |
| 131 | int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED); |
| 132 | mUseAudio = intent.getBooleanExtra(EXTRA_USE_AUDIO, false); |
| 133 | mShowTaps = intent.getBooleanExtra(EXTRA_SHOW_TAPS, false); |
| 134 | Intent data = intent.getParcelableExtra(EXTRA_DATA); |
| 135 | if (data != null) { |
| 136 | mMediaProjection = mMediaProjectionManager.getMediaProjection(resultCode, data); |
| 137 | startRecording(); |
| 138 | } |
| 139 | break; |
| 140 | |
| 141 | case ACTION_CANCEL: |
| 142 | stopRecording(); |
| 143 | |
| 144 | // Delete temp file |
| 145 | if (!mTempFile.delete()) { |
| 146 | Log.e(TAG, "Error canceling screen recording!"); |
| 147 | Toast.makeText(this, R.string.screenrecord_delete_error, Toast.LENGTH_LONG) |
| 148 | .show(); |
| 149 | } else { |
| 150 | Toast.makeText(this, R.string.screenrecord_cancel_success, Toast.LENGTH_LONG) |
| 151 | .show(); |
| 152 | } |
| 153 | |
| 154 | // Close quick shade |
| 155 | sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); |
| 156 | break; |
| 157 | |
| 158 | case ACTION_STOP: |
| 159 | stopRecording(); |
| 160 | |
| 161 | // Move temp file to user directory |
| 162 | File recordDir = new File( |
| 163 | Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), |
| 164 | RECORD_DIR); |
| 165 | recordDir.mkdirs(); |
| 166 | |
| 167 | String fileName = new SimpleDateFormat("'screen-'yyyyMMdd-HHmmss'.mp4'") |
| 168 | .format(new Date()); |
| 169 | Path path = new File(recordDir, fileName).toPath(); |
| 170 | |
| 171 | try { |
| 172 | Files.move(mTempFile.toPath(), path); |
| 173 | Notification notification = createSaveNotification(path); |
| 174 | notificationManager.notify(NOTIFICATION_ID, notification); |
| 175 | } catch (IOException e) { |
| 176 | e.printStackTrace(); |
| 177 | Toast.makeText(this, R.string.screenrecord_delete_error, Toast.LENGTH_LONG) |
| 178 | .show(); |
| 179 | } |
| 180 | break; |
| 181 | |
| 182 | case ACTION_PAUSE: |
| 183 | mMediaRecorder.pause(); |
| 184 | setNotificationActions(true, notificationManager); |
| 185 | break; |
| 186 | |
| 187 | case ACTION_RESUME: |
| 188 | mMediaRecorder.resume(); |
| 189 | setNotificationActions(false, notificationManager); |
| 190 | break; |
| 191 | |
| 192 | case ACTION_SHARE: |
| 193 | File shareFile = new File(intent.getStringExtra(EXTRA_PATH)); |
| 194 | Uri shareUri = FileProvider.getUriForFile(this, FILE_PROVIDER, shareFile); |
| 195 | |
| 196 | Intent shareIntent = new Intent(Intent.ACTION_SEND) |
| 197 | .setType("video/mp4") |
| 198 | .putExtra(Intent.EXTRA_STREAM, shareUri); |
| 199 | String shareLabel = getResources().getString(R.string.screenrecord_share_label); |
| 200 | |
| 201 | // Close quick shade |
| 202 | sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); |
| 203 | |
| 204 | // Remove notification |
| 205 | notificationManager.cancel(NOTIFICATION_ID); |
| 206 | |
| 207 | startActivity(Intent.createChooser(shareIntent, shareLabel) |
| 208 | .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); |
| 209 | break; |
| 210 | case ACTION_DELETE: |
| 211 | // Close quick shade |
| 212 | sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); |
| 213 | |
| 214 | File file = new File(intent.getStringExtra(EXTRA_PATH)); |
| 215 | if (file.delete()) { |
| 216 | Toast.makeText( |
| 217 | this, |
| 218 | R.string.screenrecord_delete_description, |
| 219 | Toast.LENGTH_LONG).show(); |
| 220 | |
| 221 | // Remove notification |
| 222 | notificationManager.cancel(NOTIFICATION_ID); |
| 223 | } else { |
| 224 | Log.e(TAG, "Error deleting screen recording!"); |
| 225 | Toast.makeText(this, R.string.screenrecord_delete_error, Toast.LENGTH_LONG) |
| 226 | .show(); |
| 227 | } |
| 228 | break; |
| 229 | } |
| 230 | return Service.START_STICKY; |
| 231 | } |
| 232 | |
| 233 | @Override |
| 234 | public IBinder onBind(Intent intent) { |
| 235 | return null; |
| 236 | } |
| 237 | |
| 238 | @Override |
| 239 | public void onCreate() { |
| 240 | super.onCreate(); |
| 241 | |
| 242 | mMediaProjectionManager = |
| 243 | (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * Begin the recording session |
| 248 | */ |
| 249 | private void startRecording() { |
| 250 | try { |
| 251 | mTempFile = File.createTempFile("temp", ".mp4"); |
| 252 | Log.d(TAG, "Writing video output to: " + mTempFile.getAbsolutePath()); |
| 253 | |
| 254 | setTapsVisible(mShowTaps); |
| 255 | |
| 256 | // Set up media recorder |
| 257 | mMediaRecorder = new MediaRecorder(); |
| 258 | if (mUseAudio) { |
| 259 | mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); |
| 260 | } |
| 261 | mMediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); |
| 262 | mMediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); |
| 263 | |
| 264 | // Set up video |
| 265 | DisplayMetrics metrics = getResources().getDisplayMetrics(); |
| 266 | int screenWidth = metrics.widthPixels; |
| 267 | int screenHeight = metrics.heightPixels; |
| 268 | mMediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); |
| 269 | mMediaRecorder.setVideoSize(screenWidth, screenHeight); |
| 270 | mMediaRecorder.setVideoFrameRate(VIDEO_FRAME_RATE); |
| 271 | mMediaRecorder.setVideoEncodingBitRate(VIDEO_BIT_RATE); |
| 272 | |
| 273 | // Set up audio |
| 274 | if (mUseAudio) { |
| 275 | mMediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB); |
| 276 | mMediaRecorder.setAudioChannels(TOTAL_NUM_TRACKS); |
| 277 | mMediaRecorder.setAudioEncodingBitRate(AUDIO_BIT_RATE); |
| 278 | mMediaRecorder.setAudioSamplingRate(AUDIO_SAMPLE_RATE); |
| 279 | } |
| 280 | |
| 281 | mMediaRecorder.setOutputFile(mTempFile); |
| 282 | mMediaRecorder.prepare(); |
| 283 | |
| 284 | // Create surface |
| 285 | mInputSurface = mMediaRecorder.getSurface(); |
| 286 | mVirtualDisplay = mMediaProjection.createVirtualDisplay( |
| 287 | "Recording Display", |
| 288 | screenWidth, |
| 289 | screenHeight, |
| 290 | metrics.densityDpi, |
| 291 | DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, |
| 292 | mInputSurface, |
| 293 | null, |
| 294 | null); |
| 295 | |
| 296 | mMediaRecorder.start(); |
| 297 | } catch (IOException e) { |
| 298 | e.printStackTrace(); |
| 299 | throw new RuntimeException(e); |
| 300 | } |
| 301 | |
| 302 | createRecordingNotification(); |
| 303 | } |
| 304 | |
| 305 | private void createRecordingNotification() { |
| 306 | NotificationChannel channel = new NotificationChannel( |
| 307 | CHANNEL_ID, |
| 308 | getString(R.string.screenrecord_name), |
| 309 | NotificationManager.IMPORTANCE_HIGH); |
| 310 | channel.setDescription(getString(R.string.screenrecord_channel_description)); |
| 311 | channel.enableVibration(true); |
| 312 | NotificationManager notificationManager = |
| 313 | (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); |
| 314 | notificationManager.createNotificationChannel(channel); |
| 315 | |
| 316 | mRecordingNotificationBuilder = new Notification.Builder(this, CHANNEL_ID) |
| 317 | .setSmallIcon(R.drawable.ic_android) |
| 318 | .setContentTitle(getResources().getString(R.string.screenrecord_name)) |
| 319 | .setUsesChronometer(true) |
| 320 | .setOngoing(true); |
| 321 | setNotificationActions(false, notificationManager); |
| 322 | Notification notification = mRecordingNotificationBuilder.build(); |
| 323 | startForeground(NOTIFICATION_ID, notification); |
| 324 | } |
| 325 | |
| 326 | private void setNotificationActions(boolean isPaused, NotificationManager notificationManager) { |
| 327 | String pauseString = getResources() |
| 328 | .getString(isPaused ? R.string.screenrecord_resume_label |
| 329 | : R.string.screenrecord_pause_label); |
| 330 | Intent pauseIntent = isPaused ? getResumeIntent(this) : getPauseIntent(this); |
| 331 | |
| 332 | mRecordingNotificationBuilder.setActions( |
| 333 | new Notification.Action.Builder( |
| 334 | Icon.createWithResource(this, R.drawable.ic_android), |
| 335 | getResources().getString(R.string.screenrecord_stop_label), |
| 336 | PendingIntent |
| 337 | .getService(this, REQUEST_CODE, getStopIntent(this), |
| 338 | PendingIntent.FLAG_UPDATE_CURRENT)) |
| 339 | .build(), |
| 340 | new Notification.Action.Builder( |
| 341 | Icon.createWithResource(this, R.drawable.ic_android), pauseString, |
| 342 | PendingIntent.getService(this, REQUEST_CODE, pauseIntent, |
| 343 | PendingIntent.FLAG_UPDATE_CURRENT)) |
| 344 | .build(), |
| 345 | new Notification.Action.Builder( |
| 346 | Icon.createWithResource(this, R.drawable.ic_android), |
| 347 | getResources().getString(R.string.screenrecord_cancel_label), |
| 348 | PendingIntent |
| 349 | .getService(this, REQUEST_CODE, getCancelIntent(this), |
| 350 | PendingIntent.FLAG_UPDATE_CURRENT)) |
| 351 | .build()); |
| 352 | notificationManager.notify(NOTIFICATION_ID, mRecordingNotificationBuilder.build()); |
| 353 | } |
| 354 | |
| 355 | private Notification createSaveNotification(Path path) { |
| 356 | Uri saveUri = FileProvider.getUriForFile(this, FILE_PROVIDER, path.toFile()); |
| 357 | Log.d(TAG, "Screen recording saved to " + path.toString()); |
| 358 | |
| 359 | Intent viewIntent = new Intent(Intent.ACTION_VIEW) |
| 360 | .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION) |
| 361 | .setDataAndType(saveUri, "video/mp4"); |
| 362 | |
| 363 | Notification.Action shareAction = new Notification.Action.Builder( |
| 364 | Icon.createWithResource(this, R.drawable.ic_android), |
| 365 | getResources().getString(R.string.screenrecord_share_label), |
| 366 | PendingIntent.getService( |
| 367 | this, |
| 368 | REQUEST_CODE, |
| 369 | getShareIntent(this, path.toString()), |
| 370 | PendingIntent.FLAG_UPDATE_CURRENT)) |
| 371 | .build(); |
| 372 | |
| 373 | Notification.Action deleteAction = new Notification.Action.Builder( |
| 374 | Icon.createWithResource(this, R.drawable.ic_android), |
| 375 | getResources().getString(R.string.screenrecord_delete_label), |
| 376 | PendingIntent.getService( |
| 377 | this, |
| 378 | REQUEST_CODE, |
| 379 | getDeleteIntent(this, path.toString()), |
| 380 | PendingIntent.FLAG_UPDATE_CURRENT)) |
| 381 | .build(); |
| 382 | |
| 383 | Notification.Builder builder = new Notification.Builder(this, CHANNEL_ID) |
| 384 | .setSmallIcon(R.drawable.ic_android) |
| 385 | .setContentTitle(getResources().getString(R.string.screenrecord_name)) |
| 386 | .setContentText(getResources().getString(R.string.screenrecord_save_message)) |
| 387 | .setContentIntent(PendingIntent.getActivity( |
| 388 | this, |
| 389 | REQUEST_CODE, |
| 390 | viewIntent, |
| 391 | Intent.FLAG_GRANT_READ_URI_PERMISSION)) |
| 392 | .addAction(shareAction) |
| 393 | .addAction(deleteAction) |
| 394 | .setAutoCancel(true); |
| 395 | |
| 396 | // Add thumbnail if available |
| 397 | Bitmap thumbnailBitmap = ThumbnailUtils.createVideoThumbnail(path.toString(), |
| 398 | MediaStore.Video.Thumbnails.MINI_KIND); |
| 399 | if (thumbnailBitmap != null) { |
| 400 | Notification.BigPictureStyle pictureStyle = new Notification.BigPictureStyle() |
| 401 | .bigPicture(thumbnailBitmap) |
| 402 | .bigLargeIcon((Bitmap) null); |
| 403 | builder.setLargeIcon(thumbnailBitmap).setStyle(pictureStyle); |
| 404 | } |
| 405 | return builder.build(); |
| 406 | } |
| 407 | |
| 408 | private void stopRecording() { |
| 409 | setTapsVisible(false); |
| 410 | mMediaRecorder.stop(); |
| 411 | mMediaRecorder.release(); |
| 412 | mMediaRecorder = null; |
| 413 | mMediaProjection.stop(); |
| 414 | mMediaProjection = null; |
| 415 | mInputSurface.release(); |
| 416 | mVirtualDisplay.release(); |
| 417 | stopSelf(); |
| 418 | } |
| 419 | |
| 420 | private void setTapsVisible(boolean turnOn) { |
| 421 | int value = turnOn ? 1 : 0; |
| 422 | Settings.System.putInt(getApplicationContext().getContentResolver(), |
| 423 | Settings.System.SHOW_TOUCHES, value); |
| 424 | } |
| 425 | |
| 426 | private static Intent getStopIntent(Context context) { |
| 427 | return new Intent(context, RecordingService.class).setAction(ACTION_STOP); |
| 428 | } |
| 429 | |
| 430 | private static Intent getPauseIntent(Context context) { |
| 431 | return new Intent(context, RecordingService.class).setAction(ACTION_PAUSE); |
| 432 | } |
| 433 | |
| 434 | private static Intent getResumeIntent(Context context) { |
| 435 | return new Intent(context, RecordingService.class).setAction(ACTION_RESUME); |
| 436 | } |
| 437 | |
| 438 | private static Intent getCancelIntent(Context context) { |
| 439 | return new Intent(context, RecordingService.class).setAction(ACTION_CANCEL); |
| 440 | } |
| 441 | |
| 442 | private static Intent getShareIntent(Context context, String path) { |
| 443 | return new Intent(context, RecordingService.class).setAction(ACTION_SHARE) |
| 444 | .putExtra(EXTRA_PATH, path); |
| 445 | } |
| 446 | |
| 447 | private static Intent getDeleteIntent(Context context, String path) { |
| 448 | return new Intent(context, RecordingService.class).setAction(ACTION_DELETE) |
| 449 | .putExtra(EXTRA_PATH, path); |
| 450 | } |
| 451 | } |