diff --git a/telecine/src/main/java/com/jakewharton/telecine/Analytics.java b/telecine/src/main/java/com/jakewharton/telecine/Analytics.java index bf354aa..ee0a9e3 100644 --- a/telecine/src/main/java/com/jakewharton/telecine/Analytics.java +++ b/telecine/src/main/java/com/jakewharton/telecine/Analytics.java @@ -6,6 +6,7 @@ interface Analytics { String CATEGORY_SETTINGS = "Settings"; String CATEGORY_RECORDING = "Recording"; + String CATEGORY_SCREENSHOT = "Screenshot"; String CATEGORY_SHORTCUT = "Shortcut"; String ACTION_CAPTURE_INTENT_LAUNCH = "Launch Overlay Launch"; @@ -20,6 +21,7 @@ interface Analytics { String ACTION_OVERLAY_CANCEL = "Overlay Cancel"; String ACTION_RECORDING_START = "Recording Start"; String ACTION_RECORDING_STOP = "Recording Stop"; + String ACTION_SCREENSHOT_TAKEN = "Screenshot Taken"; String ACTION_SHORTCUT_ADDED = "Shortcut Added"; String ACTION_SHORTCUT_LAUNCHED = "Shortcut Launched"; diff --git a/telecine/src/main/java/com/jakewharton/telecine/FlashView.java b/telecine/src/main/java/com/jakewharton/telecine/FlashView.java new file mode 100644 index 0000000..5a3de30 --- /dev/null +++ b/telecine/src/main/java/com/jakewharton/telecine/FlashView.java @@ -0,0 +1,67 @@ +package com.jakewharton.telecine; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.view.WindowManager; +import android.view.animation.DecelerateInterpolator; +import android.widget.FrameLayout; +import butterknife.ButterKnife; +import static android.graphics.PixelFormat.TRANSLUCENT; +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; +import static android.view.WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR; +import static android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN; +import static android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; +import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; +import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL; +import static android.view.WindowManager.LayoutParams.TYPE_SYSTEM_ERROR; + +@SuppressLint("ViewConstructor") // Lint, in this case, I am smarter than you. +final class FlashView extends FrameLayout { + + private final Listener listener; + + static FlashView create(Context context, Listener listener) { + return new FlashView(context, listener); + } + + private FlashView(Context context, Listener listener) { + super(context); + + this.listener = listener; + inflate(context, R.layout.flash_view, this); + ButterKnife.bind(this); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + animate().alpha(100) + .setDuration(200) + .withEndAction(new Runnable() { + @Override + public void run() { + listener.onFlashComplete(); + } + }) + .setInterpolator(new DecelerateInterpolator()); + } + + static WindowManager.LayoutParams createLayoutParams() { + + final WindowManager.LayoutParams params = + new WindowManager.LayoutParams(MATCH_PARENT, MATCH_PARENT, TYPE_SYSTEM_ERROR, FLAG_NOT_FOCUSABLE + | FLAG_NOT_TOUCH_MODAL + | FLAG_LAYOUT_NO_LIMITS + | FLAG_LAYOUT_INSET_DECOR + | FLAG_LAYOUT_IN_SCREEN, TRANSLUCENT); + + return params; + } + + interface Listener { + /** Called when flash animation has completed. */ + void onFlashComplete(); + } + +} diff --git a/telecine/src/main/java/com/jakewharton/telecine/OverlayView.java b/telecine/src/main/java/com/jakewharton/telecine/OverlayView.java index 4a36005..bf72ddc 100644 --- a/telecine/src/main/java/com/jakewharton/telecine/OverlayView.java +++ b/telecine/src/main/java/com/jakewharton/telecine/OverlayView.java @@ -79,6 +79,11 @@ interface Listener { /** Called when stop is clicked. This view is unusable once this callback is invoked. */ void onStop(); + + void onScreenshot(); + /** Called when screenshot is clicked. This view will hide itself completely before invoking + * this callback. It will reappear once the screenshot has been saved. + */ } @Bind(R.id.record_overlay_buttons) View buttonsView; @@ -151,6 +156,10 @@ private OverlayView(Context context, Listener listener, boolean showCountDown) { }, showCountDown ? COUNTDOWN_DELAY : NON_COUNTDOWN_DELAY); } + @OnClick(R.id.record_overlay_screenshot) void onScreenshotClicked() { + listener.onScreenshot(); + } + private void startRecording() { recordingView.setVisibility(INVISIBLE); stopView.setVisibility(VISIBLE); diff --git a/telecine/src/main/java/com/jakewharton/telecine/RecordingSession.java b/telecine/src/main/java/com/jakewharton/telecine/RecordingSession.java index b61027b..47bf248 100644 --- a/telecine/src/main/java/com/jakewharton/telecine/RecordingSession.java +++ b/telecine/src/main/java/com/jakewharton/telecine/RecordingSession.java @@ -9,8 +9,11 @@ import android.content.Intent; import android.content.res.Configuration; import android.graphics.Bitmap; +import android.graphics.PixelFormat; import android.hardware.display.VirtualDisplay; import android.media.CamcorderProfile; +import android.media.Image; +import android.media.ImageReader; import android.media.MediaMetadataRetriever; import android.media.MediaRecorder; import android.media.MediaScannerConnection; @@ -21,6 +24,7 @@ import android.os.Environment; import android.os.Handler; import android.os.Looper; +import android.provider.MediaStore; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.DisplayMetrics; @@ -28,7 +32,9 @@ import android.view.WindowManager; import com.google.android.gms.analytics.HitBuilders; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.nio.ByteBuffer; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; @@ -47,13 +53,15 @@ import static android.media.MediaRecorder.OutputFormat.MPEG_4; import static android.media.MediaRecorder.VideoEncoder.H264; import static android.media.MediaRecorder.VideoSource.SURFACE; +import static android.os.Environment.DIRECTORY_DCIM; import static android.os.Environment.DIRECTORY_MOVIES; final class RecordingSession { static final int NOTIFICATION_ID = 522592; private static final String DISPLAY_NAME = "telecine"; - private static final String MIME_TYPE = "video/mp4"; + private static final String MIME_TYPE_RECORDING = "video/mp4"; + private static final String MIME_TYPE_SCREENSHOT = "image/jpeg"; interface Listener { /** Invoked immediately prior to the start of recording. */ @@ -64,6 +72,9 @@ interface Listener { /** Invoked after all work for this session has completed. */ void onEnd(); + + /**Invoked immediately prior to the start of screenshot. */ + void onScreenshot(); } private final Handler mainThread = new Handler(Looper.getMainLooper()); @@ -77,16 +88,21 @@ interface Listener { private final Provider showCountDown; private final Provider videoSizePercentage; - private final File outputRoot; - private final DateFormat fileFormat = + private final File videoOutputRoot; + private final File picturesOutputRoot; + private final DateFormat videofileFormat = new SimpleDateFormat("'Telecine_'yyyy-MM-dd-HH-mm-ss'.mp4'", Locale.US); + private final DateFormat audiofileFormat = + new SimpleDateFormat("'Telecine_'yyyy-MM-dd-HH-mm-ss'.jpeg'", Locale.US); private final NotificationManager notificationManager; private final WindowManager windowManager; private final MediaProjectionManager projectionManager; private OverlayView overlayView; + private FlashView flashView; private MediaRecorder recorder; + private ImageReader imageReader; private MediaProjection projection; private VirtualDisplay display; private String outputFile; @@ -104,8 +120,10 @@ interface Listener { this.showCountDown = showCountDown; this.videoSizePercentage = videoSizePercentage; - File picturesDir = Environment.getExternalStoragePublicDirectory(DIRECTORY_MOVIES); - outputRoot = new File(picturesDir, "Telecine"); + File moviesDir = Environment.getExternalStoragePublicDirectory(DIRECTORY_MOVIES); + videoOutputRoot = new File(moviesDir, "Telecine"); + File picturesDir = Environment.getExternalStoragePublicDirectory(DIRECTORY_DCIM); + picturesOutputRoot = new File(picturesDir, "Telecine"); notificationManager = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE); windowManager = (WindowManager) context.getSystemService(WINDOW_SERVICE); @@ -127,6 +145,18 @@ public void showOverlay() { @Override public void onStop() { stopRecording(); } + + @Override public void onScreenshot() { + overlayView.animate() + .alpha(0) + .setDuration(0) + .withEndAction(new Runnable() { + @Override + public void run() { + takeScreenshot(); + } + }); + } }; overlayView = OverlayView.create(context, overlayListener, showCountDown.get()); windowManager.addView(overlayView, OverlayView.createLayoutParams(context)); @@ -186,11 +216,27 @@ private RecordingInfo getRecordingInfo() { cameraWidth, cameraHeight, sizePercentage); } + private RecordingInfo getScreenshotInfo() { + DisplayMetrics displayMetrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) context.getSystemService(WINDOW_SERVICE); + wm.getDefaultDisplay().getRealMetrics(displayMetrics); + int displayWidth = displayMetrics.widthPixels; + int displayHeight = displayMetrics.heightPixels; + int displayDensity = displayMetrics.densityDpi; + Timber.d("Display size: %s x %s @ %s", displayWidth, displayHeight, displayDensity); + + Configuration configuration = context.getResources().getConfiguration(); + boolean isLandscape = configuration.orientation == ORIENTATION_LANDSCAPE; + Timber.d("Display landscape: %s", isLandscape); + + return new RecordingInfo(displayWidth, displayHeight, displayDensity); + } + private void startRecording() { Timber.d("Starting screen recording..."); - if (!outputRoot.mkdirs()) { - Timber.e("Unable to create output directory '%s'.", outputRoot.getAbsolutePath()); + if (!videoOutputRoot.mkdirs()) { + Timber.e("Unable to create output directory '%s'.", videoOutputRoot.getAbsolutePath()); // We're probably about to crash, but at least the log will indicate as to why. } @@ -206,8 +252,8 @@ private void startRecording() { recorder.setVideoSize(recordingInfo.width, recordingInfo.height); recorder.setVideoEncodingBitRate(8 * 1000 * 1000); - String outputName = fileFormat.format(new Date()); - outputFile = new File(outputRoot, outputName).getAbsolutePath(); + String outputName = videofileFormat.format(new Date()); + outputFile = new File(videoOutputRoot, outputName).getAbsolutePath(); Timber.i("Output file '%s'.", outputFile); recorder.setOutputFile(outputFile); @@ -285,12 +331,144 @@ private void stopRecording() { }); } + private void takeScreenshot() { + Timber.d("Start screenshot"); + + + + if (!picturesOutputRoot.mkdirs()) { + Timber.e("Unable to create output directory '%s'.", picturesOutputRoot.getAbsolutePath()); + // We're probably about to crash, but at least the log will indicate as to why. + } + + final RecordingInfo recordingInfo = getScreenshotInfo(); + Timber.d("Screenshot: %s x %s @ %s", recordingInfo.width, recordingInfo.height, + recordingInfo.density); + + String outputName = audiofileFormat.format(new Date()); + outputFile = new File(picturesOutputRoot, outputName).getAbsolutePath(); + Timber.i("Output file '%s'.", outputFile); + + projection = projectionManager.getMediaProjection(resultCode, data); + + imageReader = ImageReader.newInstance(recordingInfo.width, recordingInfo.height, PixelFormat.RGBA_8888, 2); + Surface surface = imageReader.getSurface(); + display = + projection.createVirtualDisplay(DISPLAY_NAME, recordingInfo.width, recordingInfo.height, + recordingInfo.density, VIRTUAL_DISPLAY_FLAG_PRESENTATION, surface, null, null); + + + imageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() { + @Override + public void onImageAvailable(ImageReader reader) { + Timber.d("ImageReader: Image available"); + Image image = null; + FileOutputStream fos = null; + Bitmap bitmap = null; + Bitmap croppedBitmap = null; + + try { + image = imageReader.acquireLatestImage(); + if (image != null) { + + FlashView.Listener flashViewListener = new FlashView.Listener() { + @Override + public void onFlashComplete() { + if (flashView != null) { + windowManager.removeView(flashView); + flashView = null; + } + + } + }; + flashView = FlashView.create(context, flashViewListener); + windowManager.addView(flashView, FlashView.createLayoutParams()); + + Image.Plane[] planes = image.getPlanes(); + ByteBuffer buffer = planes[0].getBuffer(); + int pixelStride = planes[0].getPixelStride(); + int rowStride = planes[0].getRowStride(); + int rowPadding = rowStride - pixelStride * recordingInfo.width; + + // create bitmap + bitmap = Bitmap.createBitmap(recordingInfo.width + rowPadding / pixelStride, recordingInfo.height, Bitmap.Config.ARGB_8888); + bitmap.copyPixelsFromBuffer(buffer); + + //Trimming the bitmap to the w/h of the screen. For some reason, image reader adds more pixels to width. + croppedBitmap = Bitmap.createBitmap(bitmap, 0, 0, recordingInfo.width, recordingInfo.height); + + bitmap.recycle(); + bitmap = null; + + // write bitmap to a file + fos = new FileOutputStream(outputFile); + croppedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos); + + Timber.d("Screenshot taken. Notifying media scanner of new screenshot."); + MediaScannerConnection.scanFile(context, new String[]{outputFile}, null, + new MediaScannerConnection.OnScanCompletedListener() { + @Override + public void onScanCompleted(String path, final Uri uri) { + Timber.d("Media scanner completed."); + mainThread.post(new Runnable() { + @Override + public void run() { + showScreenshotNotification(uri, null); + } + }); + } + }); + } + + } catch (Exception e) { + e.printStackTrace(); + Timber.e("Error converting image to jpeg"); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException ioe) { + ioe.printStackTrace(); + } + } + + if (bitmap != null) { + bitmap.recycle(); + } + + if (croppedBitmap != null) { + croppedBitmap.recycle(); + } + + if (image != null) { + image.close(); + } + + imageReader.close(); + display.release(); + projection.stop(); + + overlayView.animate() + .alpha(1) + .setDuration(0); + + analytics.send(new HitBuilders.EventBuilder() // + .setCategory(Analytics.CATEGORY_SCREENSHOT) + .setAction(Analytics.ACTION_SCREENSHOT_TAKEN) + .build()); + + Timber.d("Screenshot success"); + } + } + }, null); + } + private void showNotification(final Uri uri, Bitmap bitmap) { Intent viewIntent = new Intent(ACTION_VIEW, uri); PendingIntent pendingViewIntent = PendingIntent.getActivity(context, 0, viewIntent, 0); Intent shareIntent = new Intent(ACTION_SEND); - shareIntent.setType(MIME_TYPE); + shareIntent.setType(MIME_TYPE_RECORDING); shareIntent.putExtra(Intent.EXTRA_STREAM, uri); shareIntent = Intent.createChooser(shareIntent, null); PendingIntent pendingShareIntent = PendingIntent.getActivity(context, 0, shareIntent, 0); @@ -347,6 +525,72 @@ private void showNotification(final Uri uri, Bitmap bitmap) { }.execute(); } + private void showScreenshotNotification(final Uri uri, final Bitmap bitmap) { + Intent viewIntent = new Intent(ACTION_VIEW, uri); + PendingIntent pendingViewIntent = PendingIntent.getActivity(context, 0, viewIntent, 0); + + Intent shareIntent = new Intent(ACTION_SEND); + shareIntent.setType(MIME_TYPE_SCREENSHOT); + shareIntent.putExtra(Intent.EXTRA_STREAM, uri); + shareIntent = Intent.createChooser(shareIntent, null); + PendingIntent pendingShareIntent = PendingIntent.getActivity(context, 0, shareIntent, 0); + + Intent deleteIntent = new Intent(context, DeleteRecordingBroadcastReceiver.class); + deleteIntent.setData(uri); + PendingIntent pendingDeleteIntent = PendingIntent.getBroadcast(context, 0, deleteIntent, 0); + + CharSequence title = context.getText(R.string.notification_screenshot_captured_title); + CharSequence subtitle = context.getText(R.string.notification_screenshot_captured_subtitle); + CharSequence share = context.getText(R.string.notification_captured_share); + CharSequence delete = context.getText(R.string.notification_captured_delete); + Notification.Builder builder = new Notification.Builder(context) // + .setContentTitle(title) + .setContentText(subtitle) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setSmallIcon(R.drawable.ic_camera_alt_white_24dp) + .setColor(context.getResources().getColor(R.color.primary_normal)) + .setContentIntent(pendingViewIntent) + .setAutoCancel(true) + .addAction(R.drawable.ic_share_white_24dp, share, pendingShareIntent) + .addAction(R.drawable.ic_delete_white_24dp, delete, pendingDeleteIntent); + + if (bitmap != null) { + builder.setLargeIcon(createSquareBitmap(bitmap)) + .setStyle(new Notification.BigPictureStyle() // + .setBigContentTitle(title) // + .setSummaryText(subtitle) // + .bigPicture(bitmap)); + } + + notificationManager.notify(NOTIFICATION_ID, builder.build()); + + if (bitmap != null) { + listener.onEnd(); + return; + } + + new AsyncTask() { + @Override protected Bitmap doInBackground(@NonNull Void... none) { + Bitmap bitmap = null; + try { + bitmap = MediaStore.Images.Media.getBitmap(context.getContentResolver(), uri); + } catch (Exception e) { + Timber.d("Failed to create bitmap from screenshot file."); + } + return bitmap; + } + + @Override protected void onPostExecute(@Nullable Bitmap bitmap) { + if (bitmap != null) { + showScreenshotNotification(uri, bitmap); + } else { + listener.onEnd(); + } + } + }.execute(); + } + static RecordingInfo calculateRecordingInfo(int displayWidth, int displayHeight, int displayDensity, boolean isLandscapeDevice, int cameraWidth, int cameraHeight, int sizePercentage) { diff --git a/telecine/src/main/java/com/jakewharton/telecine/TelecineService.java b/telecine/src/main/java/com/jakewharton/telecine/TelecineService.java index 74243c4..62bace4 100644 --- a/telecine/src/main/java/com/jakewharton/telecine/TelecineService.java +++ b/telecine/src/main/java/com/jakewharton/telecine/TelecineService.java @@ -72,6 +72,10 @@ public static Intent newIntent(Context context, int resultCode, Intent data) { stopForeground(true /* remove notification */); } + @Override public void onScreenshot() { + + } + @Override public void onEnd() { Timber.d("Shutting down."); stopSelf(); diff --git a/telecine/src/main/res/drawable-hdpi/ic_camera_alt_white_24dp.png b/telecine/src/main/res/drawable-hdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000..497c88c Binary files /dev/null and b/telecine/src/main/res/drawable-hdpi/ic_camera_alt_white_24dp.png differ diff --git a/telecine/src/main/res/drawable-mdpi/ic_camera_alt_white_24dp.png b/telecine/src/main/res/drawable-mdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000..e830522 Binary files /dev/null and b/telecine/src/main/res/drawable-mdpi/ic_camera_alt_white_24dp.png differ diff --git a/telecine/src/main/res/drawable-xhdpi/ic_camera_alt_white_24dp.png b/telecine/src/main/res/drawable-xhdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000..be9fb22 Binary files /dev/null and b/telecine/src/main/res/drawable-xhdpi/ic_camera_alt_white_24dp.png differ diff --git a/telecine/src/main/res/drawable-xxhdpi/ic_camera_alt_white_24dp.png b/telecine/src/main/res/drawable-xxhdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000..c8e69dc Binary files /dev/null and b/telecine/src/main/res/drawable-xxhdpi/ic_camera_alt_white_24dp.png differ diff --git a/telecine/src/main/res/drawable-xxxhdpi/ic_camera_alt_white_24dp.png b/telecine/src/main/res/drawable-xxxhdpi/ic_camera_alt_white_24dp.png new file mode 100644 index 0000000..777658e Binary files /dev/null and b/telecine/src/main/res/drawable-xxxhdpi/ic_camera_alt_white_24dp.png differ diff --git a/telecine/src/main/res/drawable/screenshot_background.xml b/telecine/src/main/res/drawable/screenshot_background.xml new file mode 100644 index 0000000..e402063 --- /dev/null +++ b/telecine/src/main/res/drawable/screenshot_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/telecine/src/main/res/layout/flash_view.xml b/telecine/src/main/res/layout/flash_view.xml new file mode 100644 index 0000000..bab7bb1 --- /dev/null +++ b/telecine/src/main/res/layout/flash_view.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/telecine/src/main/res/layout/overlay_view.xml b/telecine/src/main/res/layout/overlay_view.xml index 07875d0..6c72c88 100644 --- a/telecine/src/main/res/layout/overlay_view.xml +++ b/telecine/src/main/res/layout/overlay_view.xml @@ -17,6 +17,15 @@ android:background="@drawable/cancel_background" android:contentDescription="@string/clear" /> + 16dp - 96dp + 144dp 25dp 24dp diff --git a/telecine/src/main/res/values/strings.xml b/telecine/src/main/res/values/strings.xml index e79d2fb..6d2243e 100644 --- a/telecine/src/main/res/values/strings.xml +++ b/telecine/src/main/res/values/strings.xml @@ -17,6 +17,8 @@ Video size Screen recording captured. Touch to view your screen recording. + Screenshot captured. + Touch to view your screenshot. Share Delete Recording screen.