package com.mattprecious.telescope; import android.animation.ValueAnimator; import android.annotation.TargetApi; import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; import android.content.ContextWrapper; import android.content.Intent; import android.content.IntentFilter; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.PixelFormat; import android.hardware.display.DisplayManager; import android.hardware.display.VirtualDisplay; import android.media.Image; import android.media.ImageReader; import android.media.projection.MediaProjection; import android.media.projection.MediaProjectionManager; import android.os.AsyncTask; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.Process; import android.os.Vibrator; import android.support.annotation.ColorInt; import android.support.annotation.IntRange; import android.support.annotation.NonNull; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.Log; import android.view.MotionEvent; import android.view.Surface; import android.view.View; import android.view.WindowManager; import android.widget.FrameLayout; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; import static android.Manifest.permission.VIBRATE; import static android.animation.ValueAnimator.AnimatorUpdateListener; import static android.content.pm.PackageManager.PERMISSION_GRANTED; import static android.graphics.Paint.Style; import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.LOLLIPOP; import static com.mattprecious.telescope.Preconditions.checkNotNull; /** * A layout used to take a screenshot and initiate a callback when the user long-presses the * container. */ public class TelescopeLayout extends FrameLayout { private static final String TAG = "Telescope"; static final SimpleDateFormat SCREENSHOT_FILE_FORMAT = new SimpleDateFormat("'telescope'-yyyy-MM-dd-HHmmss.'png'", Locale.US); private static final int PROGRESS_STROKE_DP = 4; private static final long CANCEL_DURATION_MS = 250; private static final long DONE_DURATION_MS = 1000; private static final long TRIGGER_DURATION_MS = 1000; private static final long VIBRATION_DURATION_MS = 50; private static final int DEFAULT_POINTER_COUNT = 2; private static final int DEFAULT_PROGRESS_COLOR = 0xff2196f3; private static Handler backgroundHandler; final MediaProjectionManager projectionManager; final WindowManager windowManager; private final Vibrator vibrator; private final Handler handler = new Handler(); private final Runnable trigger = new Runnable() { @Override public void run() { trigger(); } }; private final IntentFilter requestCaptureFilter; private final BroadcastReceiver requestCaptureReceiver; private final float halfStrokeWidth; private final Paint progressPaint; private final ValueAnimator progressAnimator; private final ValueAnimator progressCancelAnimator; private final ValueAnimator doneAnimator; Lens lens; private View screenshotTarget; private int pointerCount; private ScreenshotMode screenshotMode; private boolean screenshotChildrenOnly; private boolean vibrate; // State. float progressFraction; float doneFraction; private boolean pressing; private boolean capturing; boolean saving; public TelescopeLayout(Context context) { this(context, null); } public TelescopeLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public TelescopeLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); setWillNotDraw(false); screenshotTarget = this; float density = context.getResources().getDisplayMetrics().density; halfStrokeWidth = PROGRESS_STROKE_DP * density / 2; TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.telescope_TelescopeLayout, defStyle, 0); pointerCount = a.getInt(R.styleable.telescope_TelescopeLayout_telescope_pointerCount, DEFAULT_POINTER_COUNT); int progressColor = a.getColor(R.styleable.telescope_TelescopeLayout_telescope_progressColor, DEFAULT_PROGRESS_COLOR); screenshotMode = ScreenshotMode.values()[a.getInt( R.styleable.telescope_TelescopeLayout_telescope_screenshotMode, ScreenshotMode.SYSTEM.ordinal())]; screenshotChildrenOnly = a.getBoolean(R.styleable.telescope_TelescopeLayout_telescope_screenshotChildrenOnly, false); vibrate = a.getBoolean(R.styleable.telescope_TelescopeLayout_telescope_vibrate, true); a.recycle(); progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG); progressPaint.setColor(progressColor); progressPaint.setStrokeWidth(PROGRESS_STROKE_DP * density); progressPaint.setStyle(Style.STROKE); AnimatorUpdateListener progressUpdateListener = new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { progressFraction = (float) animation.getAnimatedValue(); invalidate(); } }; progressAnimator = new ValueAnimator(); progressAnimator.setDuration(TRIGGER_DURATION_MS); progressAnimator.addUpdateListener(progressUpdateListener); progressCancelAnimator = new ValueAnimator(); progressCancelAnimator.setDuration(CANCEL_DURATION_MS); progressCancelAnimator.addUpdateListener(progressUpdateListener); doneFraction = 1; doneAnimator = ValueAnimator.ofFloat(0, 1); doneAnimator.setDuration(DONE_DURATION_MS); doneAnimator.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { doneFraction = (float) animation.getAnimatedValue(); invalidate(); } }); if (isInEditMode()) { projectionManager = null; windowManager = null; vibrator = null; requestCaptureFilter = null; requestCaptureReceiver = null; return; } windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); if (SDK_INT < LOLLIPOP) { projectionManager = null; requestCaptureFilter = null; requestCaptureReceiver = null; } else { projectionManager = (MediaProjectionManager) context.getSystemService(Context.MEDIA_PROJECTION_SERVICE); requestCaptureFilter = new IntentFilter(RequestCaptureActivity.getResultBroadcastAction(context)); requestCaptureReceiver = new BroadcastReceiver() { @TargetApi(Build.VERSION_CODES.LOLLIPOP) @Override public void onReceive(Context context, Intent intent) { unregisterRequestCaptureReceiver(); int resultCode = intent.getIntExtra(RequestCaptureActivity.RESULT_EXTRA_CODE, Activity.RESULT_CANCELED); Intent data = intent.getParcelableExtra(RequestCaptureActivity.RESULT_EXTRA_DATA); final MediaProjection mediaProjection = projectionManager.getMediaProjection(resultCode, data); if (mediaProjection == null) { captureCanvasScreenshot(); return; } if (intent.getBooleanExtra(RequestCaptureActivity.RESULT_EXTRA_PROMPT_SHOWN, true)) { // Delay capture until after the permission dialog is gone. postDelayed(new Runnable() { @Override public void run() { captureNativeScreenshot(mediaProjection); } }, 500); } else { captureNativeScreenshot(mediaProjection); } } }; } } /** * Delete the screenshot folder for this app. Be careful not to call this before any intents have * finished using a screenshot reference. */ public static void cleanUp(Context context) { File path = getScreenshotFolder(context); if (!path.exists()) { return; } delete(path); } /** Set the {@link Lens} to be called when the user triggers a capture. */ public void setLens(@NonNull Lens lens) { checkNotNull(lens, "lens == null"); this.lens = lens; } /** Set the number of pointers requires to trigger the capture. Default is 2. */ public void setPointerCount(@IntRange(from = 1) int pointerCount) { if (pointerCount < 1) { throw new IllegalArgumentException("pointerCount < 1"); } this.pointerCount = pointerCount; } /** Set the color of the progress bars. */ public void setProgressColor(@ColorInt int progressColor) { progressPaint.setColor(progressColor); } /** Sets the {@link ScreenshotMode} used to capture a screenshot. */ public void setScreenshotMode(@NonNull ScreenshotMode screenshotMode) { checkNotNull(screenshotMode, "screenshotMode == null"); this.screenshotMode = screenshotMode; } /** * Set whether the screenshot will capture the children of this view only, or if it will * capture the whole window this view is in. Default is false. */ public void setScreenshotChildrenOnly(boolean screenshotChildrenOnly) { this.screenshotChildrenOnly = screenshotChildrenOnly; } /** Set the target view that the screenshot will capture. */ public void setScreenshotTarget(@NonNull View screenshotTarget) { checkNotNull(screenshotTarget, "screenshotTarget == null"); this.screenshotTarget = screenshotTarget; } /** * <p>Set whether vibration is enabled when a capture is triggered. Default is true.</p> * * <p><i>Requires the {@link android.Manifest.permission#VIBRATE} permission.</i></p> */ public void setVibrate(boolean vibrate) { this.vibrate = vibrate; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (!isEnabled()) { return false; } // Capture all clicks while capturing/saving. if (capturing || saving) { return true; } if (ev.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN && ev.getPointerCount() == pointerCount) { // onTouchEvent isn't called if we steal focus from a child, so call start here. start(); // Steal the events from our children. return true; } return super.onInterceptTouchEvent(ev); } @Override public boolean onTouchEvent(MotionEvent event) { if (!isEnabled()) { return false; } // Capture all clicks while capturing/saving. if (capturing || saving) { return true; } switch (event.getActionMasked()) { case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: case MotionEvent.ACTION_POINTER_UP: if (pressing) { cancel(); } return false; case MotionEvent.ACTION_DOWN: if (!pressing && event.getPointerCount() == pointerCount) { start(); } return true; case MotionEvent.ACTION_POINTER_DOWN: if (event.getPointerCount() == pointerCount) { // There's a few cases where we'll get called called in both onInterceptTouchEvent and // here, so make sure we only start once. if (!pressing) { start(); } return true; } else { cancel(); } break; case MotionEvent.ACTION_MOVE: if (pressing) { invalidate(); return true; } break; } return super.onTouchEvent(event); } @Override public void draw(Canvas canvas) { super.draw(canvas); // Do not draw any bars while we're capturing a screenshot. if (capturing) { return; } int width = getMeasuredWidth(); int height = getMeasuredHeight(); if (progressFraction > 0) { // Top (left to right). canvas.drawLine(0, halfStrokeWidth, width * progressFraction, halfStrokeWidth, progressPaint); // Right (top to bottom). canvas.drawLine(width - halfStrokeWidth, 0, width - halfStrokeWidth, height * progressFraction, progressPaint); // Bottom (right to left). canvas.drawLine(width, height - halfStrokeWidth, width - (width * progressFraction), height - halfStrokeWidth, progressPaint); // Left (bottom to top). canvas.drawLine(halfStrokeWidth, height, halfStrokeWidth, height - (height * progressFraction), progressPaint); } if (doneFraction < 1) { // Top (left to right). canvas.drawLine(width * doneFraction, halfStrokeWidth, width, halfStrokeWidth, progressPaint); // Right (top to bottom). canvas.drawLine(width - halfStrokeWidth, height * doneFraction, width - halfStrokeWidth, height, progressPaint); // Bottom (right to left). canvas.drawLine(width - (width * doneFraction), height - halfStrokeWidth, 0, height - halfStrokeWidth, progressPaint); // Left (bottom to top). canvas.drawLine(halfStrokeWidth, height - (height * doneFraction), halfStrokeWidth, 0, progressPaint); } } private void start() { pressing = true; progressAnimator.setFloatValues(progressFraction, 1); progressAnimator.start(); handler.postDelayed(trigger, TRIGGER_DURATION_MS); } private void stop() { pressing = false; } private void cancel() { stop(); progressAnimator.cancel(); progressCancelAnimator.setFloatValues(progressFraction, 0); progressCancelAnimator.start(); handler.removeCallbacks(trigger); } void trigger() { stop(); if (vibrate && hasVibratePermission(getContext())) { vibrator.vibrate(VIBRATION_DURATION_MS); } switch (screenshotMode) { case SYSTEM: if (projectionManager != null && !screenshotChildrenOnly && screenshotTarget == this && !windowHasSecureFlag()) { // Take a full screenshot of the device. Request permission first. registerRequestCaptureReceiver(); getContext().startActivity(new Intent(getContext(), RequestCaptureActivity.class)); break; } // System was requested but isn't supported. Fall through. case CANVAS: captureCanvasScreenshot(); break; case NONE: doneAnimator.start(); new SaveScreenshotTask(null).execute(); break; default: throw new IllegalStateException("Unknown screenshot mode: " + screenshotMode); } } private boolean windowHasSecureFlag() { // Find an activity. Context context = getContext(); while (!(context instanceof Activity) && context instanceof ContextWrapper) { context = ((ContextWrapper) context).getBaseContext(); } //noinspection SimplifiableIfStatement if (context instanceof Activity) { return (((Activity) context).getWindow().getAttributes().flags & WindowManager.LayoutParams.FLAG_SECURE) != 0; } // If we can't find an activity, return true so we fall back to canvas screenshots. return true; } void checkLens() { if (lens == null) { throw new IllegalStateException("Must call setLens() before capturing a screenshot."); } } void captureCanvasScreenshot() { capturingStart(); // Wait for the next frame to be sure our progress bars are hidden. post(new Runnable() { @Override public void run() { View view = getTargetView(); view.setDrawingCacheEnabled(true); Bitmap screenshot = Bitmap.createBitmap(view.getDrawingCache()); view.setDrawingCacheEnabled(false); capturingEnd(); checkLens(); lens.onCapture(screenshot, new BitmapProcessorListener() { @Override public void onBitmapReady(Bitmap screenshot) { new SaveScreenshotTask(screenshot).execute(); } }); } }); } private void capturingStart() { progressAnimator.end(); progressFraction = 0; capturing = true; invalidate(); } void capturingEnd() { capturing = false; doneAnimator.start(); } /** * Unless {@code screenshotChildrenOnly} is true, navigate up the layout hierarchy until we find * the root view. */ View getTargetView() { View view = screenshotTarget; if (!screenshotChildrenOnly) { while (view.getRootView() != view) { view = view.getRootView(); } } return view; } /** Recursive delete of a file or directory. */ private static void delete(File file) { if (file.isDirectory()) { File[] files = file.listFiles(); if (files != null) { for (File child : files) { delete(child); } } } file.delete(); } static File getScreenshotFolder(Context context) { return new File(context.getExternalFilesDir(null), "telescope"); } private static boolean hasVibratePermission(Context context) { return context.checkPermission(VIBRATE, Process.myPid(), Process.myUid()) == PERMISSION_GRANTED; } /** * Save a screenshot to external storage, start the done animation, and call the capture * listener. */ private class SaveScreenshotTask extends AsyncTask<Void, Void, File> { private final Context context; private final Bitmap screenshot; private String fileName; SaveScreenshotTask(Bitmap screenshot) { this.context = getContext(); this.screenshot = screenshot; } @Override protected void onPreExecute() { saving = true; fileName = SCREENSHOT_FILE_FORMAT.format(new Date()); } @Override protected File doInBackground(Void... params) { if (screenshot == null) { return null; } File screenshotFolder = getScreenshotFolder(context); if (!screenshotFolder.exists() && !screenshotFolder.mkdirs()) { Log.e(TAG, "Failed to save screenshot. Is the WRITE_EXTERNAL_STORAGE permission requested?"); return null; } File file = new File(screenshotFolder, fileName); FileOutputStream out; try { out = new FileOutputStream(file); } catch (FileNotFoundException e) { throw new AssertionError(e); } try { screenshot.compress(Bitmap.CompressFormat.PNG, 100, out); out.flush(); return file; } catch (IOException e) { Log.e(TAG, "Failed to save screenshot."); } finally { try { out.close(); } catch (IOException ignored) { } } return null; } @Override protected void onPostExecute(File screenshot) { saving = false; checkLens(); lens.onCapture(screenshot); } } private void registerRequestCaptureReceiver() { getContext().registerReceiver(requestCaptureReceiver, requestCaptureFilter); } void unregisterRequestCaptureReceiver() { getContext().unregisterReceiver(requestCaptureReceiver); } static Handler getBackgroundHandler() { if (backgroundHandler == null) { HandlerThread backgroundThread = new HandlerThread("telescope", Process.THREAD_PRIORITY_BACKGROUND); backgroundThread.start(); backgroundHandler = new Handler(backgroundThread.getLooper()); } return backgroundHandler; } @TargetApi(LOLLIPOP) void captureNativeScreenshot(final MediaProjection projection) { capturingStart(); // Wait for the next frame to be sure our progress bars are hidden. post(new Runnable() { @Override public void run() { DisplayMetrics displayMetrics = new DisplayMetrics(); windowManager.getDefaultDisplay().getRealMetrics(displayMetrics); final int width = displayMetrics.widthPixels; final int height = displayMetrics.heightPixels; ImageReader imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2); Surface surface = imageReader.getSurface(); final VirtualDisplay display = projection.createVirtualDisplay("telescope", width, height, displayMetrics.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION, surface, null, null); imageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() { @Override public void onImageAvailable(ImageReader reader) { Image image = null; Bitmap bitmap = null; try { image = reader.acquireLatestImage(); post(new Runnable() { @Override public void run() { capturingEnd(); } }); if (image == null) { return; } saving = true; Image.Plane[] planes = image.getPlanes(); ByteBuffer buffer = planes[0].getBuffer(); int pixelStride = planes[0].getPixelStride(); int rowStride = planes[0].getRowStride(); int rowPadding = rowStride - pixelStride * width; bitmap = Bitmap.createBitmap(width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888); bitmap.copyPixelsFromBuffer(buffer); // Trim the screenshot to the correct size. final Bitmap croppedBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height); checkLens(); lens.onCapture(croppedBitmap, new BitmapProcessorListener() { @Override public void onBitmapReady(Bitmap screenshot) { new SaveScreenshotTask(croppedBitmap).execute(); } }); } catch (UnsupportedOperationException e) { Log.e(TAG, "Failed to capture system screenshot. Setting the screenshot mode to CANVAS.", e); setScreenshotMode(ScreenshotMode.CANVAS); post(new Runnable() { @Override public void run() { captureCanvasScreenshot(); } }); } finally { if (bitmap != null) { bitmap.recycle(); } if (image != null) { image.close(); } reader.close(); display.release(); projection.stop(); } } }, getBackgroundHandler()); } }); } }