diff options
Diffstat (limited to 'java/com/android/dialer/callcomposer/camera')
25 files changed, 5624 insertions, 0 deletions
diff --git a/java/com/android/dialer/callcomposer/camera/AndroidManifest.xml b/java/com/android/dialer/callcomposer/camera/AndroidManifest.xml new file mode 100644 index 000000000..82f141284 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/AndroidManifest.xml @@ -0,0 +1,16 @@ +<!-- + ~ Copyright (C) 2016 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<manifest package="com.android.dialer.callcomposer.camera"/>
\ No newline at end of file diff --git a/java/com/android/dialer/callcomposer/camera/CameraManager.java b/java/com/android/dialer/callcomposer/camera/CameraManager.java new file mode 100644 index 000000000..87cd16a99 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/CameraManager.java @@ -0,0 +1,822 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera; + +import android.content.Context; +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.view.MotionEvent; +import android.view.OrientationEventListener; +import android.view.Surface; +import android.view.View; +import android.view.WindowManager; +import com.android.dialer.callcomposer.camera.camerafocus.FocusOverlayManager; +import com.android.dialer.callcomposer.camera.camerafocus.RenderOverlay; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Class which manages interactions with the camera, but does not do any UI. This class is designed + * to be a singleton to ensure there is one component managing the camera and releasing the native + * resources. In order to acquire a camera, a caller must: + * + * <ul> + * <li>Call selectCamera to select front or back camera + * <li>Call setSurface to control where the preview is shown + * <li>Call openCamera to request the camera start preview + * </ul> + * + * Callers should call onPause and onResume to ensure that the camera is release while the activity + * is not active. This class is not thread safe. It should only be called from one thread (the UI + * thread or test thread) + */ +public class CameraManager implements FocusOverlayManager.Listener { + /** Callbacks for the camera manager listener */ + public interface CameraManagerListener { + void onCameraError(int errorCode, Exception e); + + void onCameraChanged(); + } + + /** Callback when taking image or video */ + public interface MediaCallback { + int MEDIA_CAMERA_CHANGED = 1; + int MEDIA_NO_DATA = 2; + + void onMediaReady(Uri uriToMedia, String contentType, int width, int height); + + void onMediaFailed(Exception exception); + + void onMediaInfo(int what); + } + + // Error codes + private static final int ERROR_OPENING_CAMERA = 1; + private static final int ERROR_SHOWING_PREVIEW = 2; + private static final int ERROR_HARDWARE_ACCELERATION_DISABLED = 3; + private static final int ERROR_TAKING_PICTURE = 4; + + private static final int NO_CAMERA_SELECTED = -1; + + private static final Camera.ShutterCallback DUMMY_SHUTTER_CALLBACK = + new Camera.ShutterCallback() { + @Override + public void onShutter() { + // Do nothing + } + }; + + private static CameraManager sInstance; + + /** The CameraInfo for the currently selected camera */ + private final CameraInfo mCameraInfo; + + /** The index of the selected camera or NO_CAMERA_SELECTED if a camera hasn't been selected yet */ + private int mCameraIndex; + + /** True if the device has front and back cameras */ + private final boolean mHasFrontAndBackCamera; + + /** True if the camera should be open (may not yet be actually open) */ + private boolean mOpenRequested; + + /** The preview view to show the preview on */ + private CameraPreview mCameraPreview; + + /** The helper classs to handle orientation changes */ + private OrientationHandler mOrientationHandler; + + /** Tracks whether the preview has hardware acceleration */ + private boolean mIsHardwareAccelerationSupported; + + /** + * The task for opening the camera, so it doesn't block the UI thread Using AsyncTask rather than + * SafeAsyncTask because the tasks need to be serialized, but don't need to be on the UI thread + * TODO: If we have other AyncTasks (not SafeAsyncTasks) this may contend and we may need + * to create a dedicated thread, or synchronize the threads in the thread pool + */ + private AsyncTask<Integer, Void, Camera> mOpenCameraTask; + + /** + * The camera index that is queued to be opened, but not completed yet, or NO_CAMERA_SELECTED if + * no open task is pending + */ + private int mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + + /** The instance of the currently opened camera */ + private Camera mCamera; + + /** The rotation of the screen relative to the camera's natural orientation */ + private int mRotation; + + /** The callback to notify when errors or other events occur */ + private CameraManagerListener mListener; + + /** True if the camera is currently in the process of taking an image */ + private boolean mTakingPicture; + + /** Manages auto focus visual and behavior */ + private final FocusOverlayManager mFocusOverlayManager; + + private CameraManager() { + mCameraInfo = new CameraInfo(); + mCameraIndex = NO_CAMERA_SELECTED; + + // Check to see if a front and back camera exist + boolean hasFrontCamera = false; + boolean hasBackCamera = false; + final CameraInfo cameraInfo = new CameraInfo(); + final int cameraCount = Camera.getNumberOfCameras(); + try { + for (int i = 0; i < cameraCount; i++) { + Camera.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) { + hasFrontCamera = true; + } else if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) { + hasBackCamera = true; + } + if (hasFrontCamera && hasBackCamera) { + break; + } + } + } catch (final RuntimeException e) { + LogUtil.e("CameraManager.CameraManager", "Unable to load camera info", e); + } + mHasFrontAndBackCamera = hasFrontCamera && hasBackCamera; + mFocusOverlayManager = new FocusOverlayManager(this, Looper.getMainLooper()); + + // Assume the best until we are proven otherwise + mIsHardwareAccelerationSupported = true; + } + + /** Gets the singleton instance */ + public static CameraManager get() { + if (sInstance == null) { + sInstance = new CameraManager(); + } + return sInstance; + } + + /** + * Sets the surface to use to display the preview This must only be called AFTER the CameraPreview + * has a texture ready + * + * @param preview The preview surface view + */ + void setSurface(final CameraPreview preview) { + if (preview == mCameraPreview) { + return; + } + + if (preview != null) { + Assert.checkArgument(preview.isValid()); + preview.setOnTouchListener( + new View.OnTouchListener() { + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + if ((motionEvent.getActionMasked() & MotionEvent.ACTION_UP) + == MotionEvent.ACTION_UP) { + mFocusOverlayManager.setPreviewSize(view.getWidth(), view.getHeight()); + mFocusOverlayManager.onSingleTapUp( + (int) motionEvent.getX() + view.getLeft(), + (int) motionEvent.getY() + view.getTop()); + } + view.performClick(); + return true; + } + }); + } + mCameraPreview = preview; + tryShowPreview(); + } + + public void setRenderOverlay(final RenderOverlay renderOverlay) { + mFocusOverlayManager.setFocusRenderer( + renderOverlay != null ? renderOverlay.getPieRenderer() : null); + } + + /** Convenience function to swap between front and back facing cameras */ + public void swapCamera() { + Assert.checkState(mCameraIndex >= 0); + selectCamera( + mCameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT + ? CameraInfo.CAMERA_FACING_BACK + : CameraInfo.CAMERA_FACING_FRONT); + } + + /** + * Selects the first camera facing the desired direction, or the first camera if there is no + * camera in the desired direction + * + * @param desiredFacing One of the CameraInfo.CAMERA_FACING_* constants + * @return True if a camera was selected, or false if selecting a camera failed + */ + public boolean selectCamera(final int desiredFacing) { + try { + // We already selected a camera facing that direction + if (mCameraIndex >= 0 && mCameraInfo.facing == desiredFacing) { + return true; + } + + final int cameraCount = Camera.getNumberOfCameras(); + Assert.checkState(cameraCount > 0); + + mCameraIndex = NO_CAMERA_SELECTED; + setCamera(null); + final CameraInfo cameraInfo = new CameraInfo(); + for (int i = 0; i < cameraCount; i++) { + Camera.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == desiredFacing) { + mCameraIndex = i; + Camera.getCameraInfo(i, mCameraInfo); + break; + } + } + + // There's no camera in the desired facing direction, just select the first camera + // regardless of direction + if (mCameraIndex < 0) { + mCameraIndex = 0; + Camera.getCameraInfo(0, mCameraInfo); + } + + if (mOpenRequested) { + // The camera is open, so reopen with the newly selected camera + openCamera(); + } + return true; + } catch (final RuntimeException e) { + LogUtil.e("CameraManager.selectCamera", "RuntimeException in CameraManager.selectCamera", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + return false; + } + } + + public int getCameraIndex() { + return mCameraIndex; + } + + public void selectCameraByIndex(final int cameraIndex) { + if (mCameraIndex == cameraIndex) { + return; + } + + try { + mCameraIndex = cameraIndex; + Camera.getCameraInfo(mCameraIndex, mCameraInfo); + if (mOpenRequested) { + openCamera(); + } + } catch (final RuntimeException e) { + LogUtil.e( + "CameraManager.selectCameraByIndex", + "RuntimeException in CameraManager.selectCameraByIndex", + e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + + @VisibleForTesting + public CameraInfo getCameraInfo() { + if (mCameraIndex == NO_CAMERA_SELECTED) { + return null; + } + return mCameraInfo; + } + + /** @return True if the device has both a front and back camera */ + public boolean hasFrontAndBackCamera() { + return mHasFrontAndBackCamera; + } + + /** Opens the camera on a separate thread and initiates the preview if one is available */ + void openCamera() { + if (mCameraIndex == NO_CAMERA_SELECTED) { + // Ensure a selected camera if none is currently selected. This may happen if the + // camera chooser is not the default media chooser. + selectCamera(CameraInfo.CAMERA_FACING_BACK); + } + mOpenRequested = true; + // We're already opening the camera or already have the camera handle, nothing more to do + if (mPendingOpenCameraIndex == mCameraIndex || mCamera != null) { + return; + } + + // True if the task to open the camera has to be delayed until the current one completes + boolean delayTask = false; + + // Cancel any previous open camera tasks + if (mOpenCameraTask != null) { + mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + delayTask = true; + } + + mPendingOpenCameraIndex = mCameraIndex; + mOpenCameraTask = + new AsyncTask<Integer, Void, Camera>() { + private Exception mException; + + @Override + protected Camera doInBackground(final Integer... params) { + try { + final int cameraIndex = params[0]; + LogUtil.v("CameraManager.doInBackground", "Opening camera " + mCameraIndex); + return Camera.open(cameraIndex); + } catch (final Exception e) { + LogUtil.e("CameraManager.doInBackground", "Exception while opening camera", e); + mException = e; + return null; + } + } + + @Override + protected void onPostExecute(final Camera camera) { + // If we completed, but no longer want this camera, then release the camera + if (mOpenCameraTask != this || !mOpenRequested) { + releaseCamera(camera); + cleanup(); + return; + } + + cleanup(); + + LogUtil.v( + "CameraManager.onPostExecute", + "Opened camera " + mCameraIndex + " " + (camera != null)); + setCamera(camera); + if (camera == null) { + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, mException); + } + LogUtil.e("CameraManager.onPostExecute", "Error opening camera"); + } + } + + @Override + protected void onCancelled() { + super.onCancelled(); + cleanup(); + } + + private void cleanup() { + mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + if (mOpenCameraTask != null && mOpenCameraTask.getStatus() == Status.PENDING) { + // If there's another task waiting on this one to complete, start it now + mOpenCameraTask.execute(mCameraIndex); + } else { + mOpenCameraTask = null; + } + } + }; + LogUtil.v("CameraManager.openCamera", "Start opening camera " + mCameraIndex); + if (!delayTask) { + mOpenCameraTask.execute(mCameraIndex); + } + } + + /** Closes the camera releasing the resources it uses */ + void closeCamera() { + mOpenRequested = false; + setCamera(null); + } + + /** + * Sets the listener which will be notified of errors or other events in the camera + * + * @param listener The listener to notify + */ + public void setListener(final CameraManagerListener listener) { + Assert.isMainThread(); + mListener = listener; + if (!mIsHardwareAccelerationSupported && mListener != null) { + mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); + } + } + + public void takePicture(final float heightPercent, @NonNull final MediaCallback callback) { + Assert.checkState(!mTakingPicture); + Assert.isNotNull(callback); + mCameraPreview.setFocusable(false); + mFocusOverlayManager.cancelAutoFocus(); + if (mCamera == null) { + // The caller should have checked isCameraAvailable first, but just in case, protect + // against a null camera by notifying the callback that taking the picture didn't work + callback.onMediaFailed(null); + return; + } + final Camera.PictureCallback jpegCallback = + new Camera.PictureCallback() { + @Override + public void onPictureTaken(final byte[] bytes, final Camera camera) { + mTakingPicture = false; + if (mCamera != camera) { + // This may happen if the camera was changed between front/back while the + // picture is being taken. + callback.onMediaInfo(MediaCallback.MEDIA_CAMERA_CHANGED); + return; + } + + if (bytes == null) { + callback.onMediaInfo(MediaCallback.MEDIA_NO_DATA); + return; + } + + final Camera.Size size = camera.getParameters().getPictureSize(); + int width; + int height; + if (mRotation == 90 || mRotation == 270) { + // Is rotated, so swapping dimensions is desired + //noinspection SuspiciousNameCombination + width = size.height; + //noinspection SuspiciousNameCombination + height = size.width; + } else { + width = size.width; + height = size.height; + } + LogUtil.i( + "CameraManager.onPictureTaken", "taken picture size: " + bytes.length + " bytes"); + new ImagePersistTask( + width, height, heightPercent, bytes, mCameraPreview.getContext(), callback) + .execute(); + } + }; + + mTakingPicture = true; + try { + mCamera.takePicture( + // A shutter callback is required to enable shutter sound + DUMMY_SHUTTER_CALLBACK, null /* raw */, null /* postView */, jpegCallback); + } catch (final RuntimeException e) { + LogUtil.e("CameraManager.takePicture", "RuntimeException in CameraManager.takePicture", e); + mTakingPicture = false; + if (mListener != null) { + mListener.onCameraError(ERROR_TAKING_PICTURE, e); + } + } + } + + /** + * Asynchronously releases a camera + * + * @param camera The camera to release + */ + private void releaseCamera(final Camera camera) { + if (camera == null) { + return; + } + + mFocusOverlayManager.onCameraReleased(); + + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(final Void... params) { + LogUtil.v("CameraManager.doInBackground", "Releasing camera " + mCameraIndex); + camera.release(); + return null; + } + }.execute(); + } + + /** Updates the orientation of the camera to match the orientation of the device */ + private void updateCameraOrientation() { + if (mCamera == null || mCameraPreview == null || mTakingPicture) { + return; + } + + final WindowManager windowManager = + (WindowManager) mCameraPreview.getContext().getSystemService(Context.WINDOW_SERVICE); + + int degrees = 0; + switch (windowManager.getDefaultDisplay().getRotation()) { + case Surface.ROTATION_0: + degrees = 0; + break; + case Surface.ROTATION_90: + degrees = 90; + break; + case Surface.ROTATION_180: + degrees = 180; + break; + case Surface.ROTATION_270: + degrees = 270; + break; + } + + // The display orientation of the camera (this controls the preview image). + int orientation; + + // The clockwise rotation angle relative to the orientation of the camera. This affects + // pictures returned by the camera in Camera.PictureCallback. + int rotation; + if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + orientation = (mCameraInfo.orientation + degrees) % 360; + rotation = orientation; + // compensate the mirror but only for orientation + orientation = (360 - orientation) % 360; + } else { // back-facing + orientation = (mCameraInfo.orientation - degrees + 360) % 360; + rotation = orientation; + } + mRotation = rotation; + try { + mCamera.setDisplayOrientation(orientation); + final Camera.Parameters params = mCamera.getParameters(); + params.setRotation(rotation); + mCamera.setParameters(params); + } catch (final RuntimeException e) { + LogUtil.e( + "CameraManager.updateCameraOrientation", + "RuntimeException in CameraManager.updateCameraOrientation", + e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + + /** Sets the current camera, releasing any previously opened camera */ + private void setCamera(final Camera camera) { + if (mCamera == camera) { + return; + } + + releaseCamera(mCamera); + mCamera = camera; + tryShowPreview(); + if (mListener != null) { + mListener.onCameraChanged(); + } + } + + /** Shows the preview if the camera is open and the preview is loaded */ + private void tryShowPreview() { + if (mCameraPreview == null || mCamera == null) { + if (mOrientationHandler != null) { + mOrientationHandler.disable(); + mOrientationHandler = null; + } + // releaseMediaRecorder(true /* cleanupFile */); + mFocusOverlayManager.onPreviewStopped(); + return; + } + try { + mCamera.stopPreview(); + updateCameraOrientation(); + + final Camera.Parameters params = mCamera.getParameters(); + final Camera.Size pictureSize = chooseBestPictureSize(); + final Camera.Size previewSize = chooseBestPreviewSize(pictureSize); + params.setPreviewSize(previewSize.width, previewSize.height); + params.setPictureSize(pictureSize.width, pictureSize.height); + logCameraSize("Setting preview size: ", previewSize); + logCameraSize("Setting picture size: ", pictureSize); + mCameraPreview.setSize(previewSize, mCameraInfo.orientation); + for (final String focusMode : params.getSupportedFocusModes()) { + if (TextUtils.equals(focusMode, Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + // Use continuous focus if available + params.setFocusMode(focusMode); + break; + } + } + + mCamera.setParameters(params); + mCameraPreview.startPreview(mCamera); + mCamera.startPreview(); + mCamera.setAutoFocusMoveCallback( + new Camera.AutoFocusMoveCallback() { + @Override + public void onAutoFocusMoving(final boolean start, final Camera camera) { + mFocusOverlayManager.onAutoFocusMoving(start); + } + }); + mFocusOverlayManager.setParameters(mCamera.getParameters()); + mFocusOverlayManager.setMirror(mCameraInfo.facing == CameraInfo.CAMERA_FACING_BACK); + mFocusOverlayManager.onPreviewStarted(); + if (mOrientationHandler == null) { + mOrientationHandler = new OrientationHandler(mCameraPreview.getContext()); + mOrientationHandler.enable(); + } + } catch (final IOException e) { + LogUtil.e("CameraManager.tryShowPreview", "IOException in CameraManager.tryShowPreview", e); + if (mListener != null) { + mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); + } + } catch (final RuntimeException e) { + LogUtil.e( + "CameraManager.tryShowPreview", "RuntimeException in CameraManager.tryShowPreview", e); + if (mListener != null) { + mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); + } + } + } + + public boolean isCameraAvailable() { + return mCamera != null && !mTakingPicture && mIsHardwareAccelerationSupported; + } + + /** + * Choose the best picture size by trying to find a size close to the MmsConfig's max size, which + * is closest to the screen aspect ratio. In case of RCS conversation returns default size. + */ + private Camera.Size chooseBestPictureSize() { + return mCamera.getParameters().getPictureSize(); + } + + /** + * Chose the best preview size based on the picture size. Try to find a size with the same aspect + * ratio and size as the picture if possible + */ + private Camera.Size chooseBestPreviewSize(final Camera.Size pictureSize) { + final List<Camera.Size> sizes = + new ArrayList<Camera.Size>(mCamera.getParameters().getSupportedPreviewSizes()); + final float aspectRatio = pictureSize.width / (float) pictureSize.height; + final int capturePixels = pictureSize.width * pictureSize.height; + + // Sort the sizes so the best size is first + Collections.sort( + sizes, + new SizeComparator(Integer.MAX_VALUE, Integer.MAX_VALUE, aspectRatio, capturePixels)); + + return sizes.get(0); + } + + private class OrientationHandler extends OrientationEventListener { + OrientationHandler(final Context context) { + super(context); + } + + @Override + public void onOrientationChanged(final int orientation) { + updateCameraOrientation(); + } + } + + private static class SizeComparator implements Comparator<Camera.Size> { + private static final int PREFER_LEFT = -1; + private static final int PREFER_RIGHT = 1; + + // The max width/height for the preferred size. Integer.MAX_VALUE if no size limit + private final int mMaxWidth; + private final int mMaxHeight; + + // The desired aspect ratio + private final float mTargetAspectRatio; + + // The desired size (width x height) to try to match + private final int mTargetPixels; + + public SizeComparator( + final int maxWidth, + final int maxHeight, + final float targetAspectRatio, + final int targetPixels) { + mMaxWidth = maxWidth; + mMaxHeight = maxHeight; + mTargetAspectRatio = targetAspectRatio; + mTargetPixels = targetPixels; + } + + /** + * Returns a negative value if left is a better choice than right, or a positive value if right + * is a better choice is better than left. 0 if they are equal + */ + @Override + public int compare(final Camera.Size left, final Camera.Size right) { + // If one size is less than the max size prefer it over the other + if ((left.width <= mMaxWidth && left.height <= mMaxHeight) + != (right.width <= mMaxWidth && right.height <= mMaxHeight)) { + return left.width <= mMaxWidth ? PREFER_LEFT : PREFER_RIGHT; + } + + // If one is closer to the target aspect ratio, prefer it. + final float leftAspectRatio = left.width / (float) left.height; + final float rightAspectRatio = right.width / (float) right.height; + final float leftAspectRatioDiff = Math.abs(leftAspectRatio - mTargetAspectRatio); + final float rightAspectRatioDiff = Math.abs(rightAspectRatio - mTargetAspectRatio); + if (leftAspectRatioDiff != rightAspectRatioDiff) { + return (leftAspectRatioDiff - rightAspectRatioDiff) < 0 ? PREFER_LEFT : PREFER_RIGHT; + } + + // At this point they have the same aspect ratio diff and are either both bigger + // than the max size or both smaller than the max size, so prefer the one closest + // to target size + final int leftDiff = Math.abs((left.width * left.height) - mTargetPixels); + final int rightDiff = Math.abs((right.width * right.height) - mTargetPixels); + return leftDiff - rightDiff; + } + } + + @Override // From FocusOverlayManager.Listener + public void autoFocus() { + if (mCamera == null) { + return; + } + + try { + mCamera.autoFocus( + new Camera.AutoFocusCallback() { + @Override + public void onAutoFocus(final boolean success, final Camera camera) { + mFocusOverlayManager.onAutoFocus(success, false /* shutterDown */); + } + }); + } catch (final RuntimeException e) { + LogUtil.e("CameraManager.autoFocus", "RuntimeException in CameraManager.autoFocus", e); + // If autofocus fails, the camera should have called the callback with success=false, + // but some throw an exception here + mFocusOverlayManager.onAutoFocus(false /*success*/, false /*shutterDown*/); + } + } + + @Override // From FocusOverlayManager.Listener + public void cancelAutoFocus() { + if (mCamera == null) { + return; + } + try { + mCamera.cancelAutoFocus(); + } catch (final RuntimeException e) { + // Ignore + LogUtil.e( + "CameraManager.cancelAutoFocus", "RuntimeException in CameraManager.cancelAutoFocus", e); + } + } + + @Override // From FocusOverlayManager.Listener + public boolean capture() { + return false; + } + + @Override // From FocusOverlayManager.Listener + public void setFocusParameters() { + if (mCamera == null) { + return; + } + try { + final Camera.Parameters parameters = mCamera.getParameters(); + parameters.setFocusMode(mFocusOverlayManager.getFocusMode()); + if (parameters.getMaxNumFocusAreas() > 0) { + // Don't set focus areas (even to null) if focus areas aren't supported, camera may + // crash + parameters.setFocusAreas(mFocusOverlayManager.getFocusAreas()); + } + parameters.setMeteringAreas(mFocusOverlayManager.getMeteringAreas()); + mCamera.setParameters(parameters); + } catch (final RuntimeException e) { + // This occurs when the device is out of space or when the camera is locked + LogUtil.e( + "CameraManager.setFocusParameters", + "RuntimeException in CameraManager setFocusParameters"); + } + } + + public void resetPreview() { + mCamera.startPreview(); + if (mCameraPreview != null) { + mCameraPreview.setFocusable(true); + } + } + + private void logCameraSize(final String prefix, final Camera.Size size) { + // Log the camera size and aspect ratio for help when examining bug reports for camera + // failures + LogUtil.i( + "CameraManager.logCameraSize", + prefix + size.width + "x" + size.height + " (" + (size.width / (float) size.height) + ")"); + } + + @VisibleForTesting + public void resetCameraManager() { + sInstance = null; + } +} diff --git a/java/com/android/dialer/callcomposer/camera/CameraPreview.java b/java/com/android/dialer/callcomposer/camera/CameraPreview.java new file mode 100644 index 000000000..6581ad67b --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/CameraPreview.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera; + +import android.content.Context; +import android.content.res.Configuration; +import android.hardware.Camera; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnTouchListener; +import com.android.dialer.common.Assert; +import com.android.dialer.util.PermissionsUtil; +import java.io.IOException; + +/** + * Contains shared code for SoftwareCameraPreview and HardwareCameraPreview, cannot use inheritance + * because those classes must inherit from separate Views, so those classes delegate calls to this + * helper class. Specifics for each implementation are in CameraPreviewHost + */ +public class CameraPreview { + /** Implemented by the camera for rendering. */ + public interface CameraPreviewHost { + View getView(); + + boolean isValid(); + + void startPreview(final Camera camera) throws IOException; + + void onCameraPermissionGranted(); + + void setShown(); + } + + private int mCameraWidth = -1; + private int mCameraHeight = -1; + private boolean mTabHasBeenShown = false; + private OnTouchListener mListener; + + private final CameraPreviewHost mHost; + + public CameraPreview(final CameraPreviewHost host) { + Assert.isNotNull(host); + Assert.isNotNull(host.getView()); + mHost = host; + } + + // This is set when the tab is actually selected. + public void setShown() { + mTabHasBeenShown = true; + maybeOpenCamera(); + } + + // Opening camera is very expensive. Most of the ANR reports seem to be related to the camera. + // So we delay until the camera is actually needed. See b/23287938 + private void maybeOpenCamera() { + boolean visible = mHost.getView().getVisibility() == View.VISIBLE; + if (mTabHasBeenShown && visible && PermissionsUtil.hasCameraPermissions(getContext())) { + CameraManager.get().openCamera(); + } + } + + public void setSize(final Camera.Size size, final int orientation) { + switch (orientation) { + case 0: + case 180: + mCameraWidth = size.width; + mCameraHeight = size.height; + break; + case 90: + case 270: + default: + mCameraWidth = size.height; + mCameraHeight = size.width; + } + mHost.getView().requestLayout(); + } + + public int getWidthMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { + if (mCameraHeight >= 0) { + final int width = View.MeasureSpec.getSize(widthMeasureSpec); + return MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY); + } else { + return widthMeasureSpec; + } + } + + public int getHeightMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { + if (mCameraHeight >= 0) { + final int orientation = getContext().getResources().getConfiguration().orientation; + final int width = View.MeasureSpec.getSize(widthMeasureSpec); + final float aspectRatio = (float) mCameraWidth / (float) mCameraHeight; + int height; + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + height = (int) (width * aspectRatio); + } else { + height = (int) (width / aspectRatio); + } + return View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY); + } else { + return heightMeasureSpec; + } + } + + // onVisibilityChanged is set to Visible when the tab is _created_, + // which may be when the user is viewing a different tab. + public void onVisibilityChanged(final int visibility) { + if (PermissionsUtil.hasCameraPermissions(getContext())) { + if (visibility == View.VISIBLE) { + maybeOpenCamera(); + } else { + CameraManager.get().closeCamera(); + } + } + } + + public Context getContext() { + return mHost.getView().getContext(); + } + + public void setOnTouchListener(final View.OnTouchListener listener) { + mListener = listener; + mHost.getView().setOnTouchListener(listener); + } + + public void setFocusable(boolean focusable) { + mHost.getView().setOnTouchListener(focusable ? mListener : null); + } + + public int getHeight() { + return mHost.getView().getHeight(); + } + + public void onAttachedToWindow() { + maybeOpenCamera(); + } + + public void onDetachedFromWindow() { + CameraManager.get().closeCamera(); + } + + public void onRestoreInstanceState() { + maybeOpenCamera(); + } + + public void onCameraPermissionGranted() { + maybeOpenCamera(); + } + + /** @return True if the view is valid and prepared for the camera to start showing the preview */ + public boolean isValid() { + return mHost.isValid(); + } + + /** + * Starts the camera preview on the current surface. Abstracts out the differences in API from the + * CameraManager + * + * @throws IOException Which is caught by the CameraManager to display an error + */ + public void startPreview(final Camera camera) throws IOException { + mHost.startPreview(camera); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/HardwareCameraPreview.java b/java/com/android/dialer/callcomposer/camera/HardwareCameraPreview.java new file mode 100644 index 000000000..c0d61f3f8 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/HardwareCameraPreview.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera; + +import android.content.Context; +import android.graphics.SurfaceTexture; +import android.hardware.Camera; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.TextureView; +import android.view.View; +import java.io.IOException; + +/** + * A hardware accelerated preview texture for the camera. This is the preferred CameraPreview + * because it animates smoother. When hardware acceleration isn't available, SoftwareCameraPreview + * is used. + * + * <p>There is a significant amount of duplication between HardwareCameraPreview and + * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The + * implementations of the shared methods are delegated to CameraPreview + */ +public class HardwareCameraPreview extends TextureView implements CameraPreview.CameraPreviewHost { + private CameraPreview mPreview; + + public HardwareCameraPreview(final Context context, final AttributeSet attrs) { + super(context, attrs); + mPreview = new CameraPreview(this); + setSurfaceTextureListener( + new SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable( + final SurfaceTexture surfaceTexture, final int i, final int i2) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void onSurfaceTextureSizeChanged( + final SurfaceTexture surfaceTexture, final int i, final int i2) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public boolean onSurfaceTextureDestroyed(final SurfaceTexture surfaceTexture) { + CameraManager.get().setSurface(null); + return true; + } + + @Override + public void onSurfaceTextureUpdated(final SurfaceTexture surfaceTexture) { + CameraManager.get().setSurface(mPreview); + } + }); + } + + @Override + public void setShown() { + mPreview.setShown(); + } + + @Override + protected void onVisibilityChanged(final View changedView, final int visibility) { + super.onVisibilityChanged(changedView, visibility); + mPreview.onVisibilityChanged(visibility); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mPreview.onDetachedFromWindow(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mPreview.onAttachedToWindow(); + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + super.onRestoreInstanceState(state); + mPreview.onRestoreInstanceState(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); + heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public View getView() { + return this; + } + + @Override + public boolean isValid() { + return getSurfaceTexture() != null; + } + + @Override + public void startPreview(final Camera camera) throws IOException { + camera.setPreviewTexture(getSurfaceTexture()); + } + + @Override + public void onCameraPermissionGranted() { + mPreview.onCameraPermissionGranted(); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/ImagePersistTask.java b/java/com/android/dialer/callcomposer/camera/ImagePersistTask.java new file mode 100644 index 000000000..150009495 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/ImagePersistTask.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.net.Uri; +import android.os.Build.VERSION_CODES; +import android.support.v4.content.FileProvider; +import com.android.dialer.callcomposer.camera.exif.ExifInterface; +import com.android.dialer.callcomposer.camera.exif.ExifTag; +import com.android.dialer.callcomposer.util.CopyAndResizeImageTask; +import com.android.dialer.common.Assert; +import com.android.dialer.common.FallibleAsyncTask; +import com.android.dialer.constants.Constants; +import com.android.dialer.util.DialerUtils; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** Persisting image routine. */ +@TargetApi(VERSION_CODES.M) +public class ImagePersistTask extends FallibleAsyncTask<Void, Void, Uri> { + private int mWidth; + private int mHeight; + private final float mHeightPercent; + private final byte[] mBytes; + private final Context mContext; + private final CameraManager.MediaCallback mCallback; + + ImagePersistTask( + final int width, + final int height, + final float heightPercent, + final byte[] bytes, + final Context context, + final CameraManager.MediaCallback callback) { + Assert.checkArgument(heightPercent >= 0 && heightPercent <= 1); + Assert.isNotNull(bytes); + Assert.isNotNull(context); + Assert.isNotNull(callback); + mWidth = width; + mHeight = height; + mHeightPercent = heightPercent; + mBytes = bytes; + mContext = context; + mCallback = callback; + } + + @Override + protected Uri doInBackgroundFallible(final Void... params) throws Exception { + File outputFile = DialerUtils.createShareableFile(mContext); + + try (OutputStream outputStream = new FileOutputStream(outputFile)) { + if (mHeightPercent != 1.0f) { + writeClippedBitmap(outputStream); + } else { + outputStream.write(mBytes, 0, mBytes.length); + } + } + + return FileProvider.getUriForFile( + mContext, Constants.get().getFileProviderAuthority(), outputFile); + } + + @Override + protected void onPostExecute(FallibleTaskResult<Uri> result) { + if (result.isFailure()) { + mCallback.onMediaFailed(new Exception("Persisting image failed", result.getThrowable())); + } else { + mCallback.onMediaReady(result.getResult(), "image/jpeg", mWidth, mHeight); + } + } + + private void writeClippedBitmap(OutputStream outputStream) throws IOException { + int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; + final ExifInterface exifInterface = new ExifInterface(); + try { + exifInterface.readExif(mBytes); + final Integer orientationValue = exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION); + if (orientationValue != null) { + orientation = orientationValue.intValue(); + } + } catch (final IOException e) { + // Couldn't get exif tags, not the end of the world + } + Bitmap bitmap = BitmapFactory.decodeByteArray(mBytes, 0, mBytes.length); + final int clippedWidth; + final int clippedHeight; + if (ExifInterface.getOrientationParams(orientation).invertDimensions) { + Assert.checkState(mWidth == bitmap.getHeight()); + Assert.checkState(mHeight == bitmap.getWidth()); + clippedWidth = (int) (mHeight * mHeightPercent); + clippedHeight = mWidth; + } else { + Assert.checkState(mWidth == bitmap.getWidth()); + Assert.checkState(mHeight == bitmap.getHeight()); + clippedWidth = mWidth; + clippedHeight = (int) (mHeight * mHeightPercent); + } + final int offsetTop = (bitmap.getHeight() - clippedHeight) / 2; + final int offsetLeft = (bitmap.getWidth() - clippedWidth) / 2; + mWidth = clippedWidth; + mHeight = clippedHeight; + Bitmap clippedBitmap = + Bitmap.createBitmap(clippedWidth, clippedHeight, Bitmap.Config.ARGB_8888); + clippedBitmap.setDensity(bitmap.getDensity()); + final Canvas clippedBitmapCanvas = new Canvas(clippedBitmap); + final Matrix matrix = new Matrix(); + matrix.postTranslate(-offsetLeft, -offsetTop); + clippedBitmapCanvas.drawBitmap(bitmap, matrix, null /* paint */); + clippedBitmapCanvas.save(); + clippedBitmap = CopyAndResizeImageTask.resizeForEnrichedCalling(clippedBitmap); + // EXIF data can take a big chunk of the file size and is often cleared by the + // carrier, only store orientation since that's critical + final ExifTag orientationTag = exifInterface.getTag(ExifInterface.TAG_ORIENTATION); + exifInterface.clearExif(); + exifInterface.setTag(orientationTag); + exifInterface.writeExif(clippedBitmap, outputStream); + + clippedBitmap.recycle(); + bitmap.recycle(); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/SoftwareCameraPreview.java b/java/com/android/dialer/callcomposer/camera/SoftwareCameraPreview.java new file mode 100644 index 000000000..fe2c600df --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/SoftwareCameraPreview.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera; + +import android.content.Context; +import android.hardware.Camera; +import android.os.Parcelable; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; +import java.io.IOException; + +/** + * A software rendered preview surface for the camera. This renders slower and causes more jank, so + * HardwareCameraPreview is preferred if possible. + * + * <p>There is a significant amount of duplication between HardwareCameraPreview and + * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The + * implementations of the shared methods are delegated to CameraPreview + */ +public class SoftwareCameraPreview extends SurfaceView implements CameraPreview.CameraPreviewHost { + private final CameraPreview mPreview; + + public SoftwareCameraPreview(final Context context) { + super(context); + mPreview = new CameraPreview(this); + getHolder() + .addCallback( + new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(final SurfaceHolder surfaceHolder) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void surfaceChanged( + final SurfaceHolder surfaceHolder, + final int format, + final int width, + final int height) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void surfaceDestroyed(final SurfaceHolder surfaceHolder) { + CameraManager.get().setSurface(null); + } + }); + } + + @Override + public void setShown() { + mPreview.setShown(); + } + + @Override + protected void onVisibilityChanged(final View changedView, final int visibility) { + super.onVisibilityChanged(changedView, visibility); + mPreview.onVisibilityChanged(visibility); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mPreview.onDetachedFromWindow(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mPreview.onAttachedToWindow(); + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + super.onRestoreInstanceState(state); + mPreview.onRestoreInstanceState(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); + heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public View getView() { + return this; + } + + @Override + public boolean isValid() { + return getHolder() != null; + } + + @Override + public void startPreview(final Camera camera) throws IOException { + camera.setPreviewDisplay(getHolder()); + } + + @Override + public void onCameraPermissionGranted() { + mPreview.onCameraPermissionGranted(); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/camerafocus/AndroidManifest.xml b/java/com/android/dialer/callcomposer/camera/camerafocus/AndroidManifest.xml new file mode 100644 index 000000000..77ef22295 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/camerafocus/AndroidManifest.xml @@ -0,0 +1,16 @@ +<!-- + ~ Copyright (C) 2016 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<manifest package="com.android.dialer.callcomposer.camera.camerafocus"/>
\ No newline at end of file diff --git a/java/com/android/dialer/callcomposer/camera/camerafocus/FocusIndicator.java b/java/com/android/dialer/callcomposer/camera/camerafocus/FocusIndicator.java new file mode 100644 index 000000000..234cf5388 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/camerafocus/FocusIndicator.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.camerafocus; + +/** Methods needed by the CameraPreview in order communicate camera events. */ +public interface FocusIndicator { + void showStart(); + + void showSuccess(boolean timeout); + + void showFail(boolean timeout); + + void clear(); +} diff --git a/java/com/android/dialer/callcomposer/camera/camerafocus/FocusOverlayManager.java b/java/com/android/dialer/callcomposer/camera/camerafocus/FocusOverlayManager.java new file mode 100644 index 000000000..1c5ac380c --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/camerafocus/FocusOverlayManager.java @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.camerafocus; + +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.RectF; +import android.hardware.Camera.Area; +import android.hardware.Camera.Parameters; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import java.util.ArrayList; +import java.util.List; + +/** + * A class that handles everything about focus in still picture mode. This also handles the metering + * area because it is the same as focus area. + * + * <p>The test cases: (1) The camera has continuous autofocus. Move the camera. Take a picture when + * CAF is not in progress. (2) The camera has continuous autofocus. Move the camera. Take a picture + * when CAF is in progress. (3) The camera has face detection. Point the camera at some faces. Hold + * the shutter. Release to take a picture. (4) The camera has face detection. Point the camera at + * some faces. Single tap the shutter to take a picture. (5) The camera has autofocus. Single tap + * the shutter to take a picture. (6) The camera has autofocus. Hold the shutter. Release to take a + * picture. (7) The camera has no autofocus. Single tap the shutter and take a picture. (8) The + * camera has autofocus and supports focus area. Touch the screen to trigger autofocus. Take a + * picture. (9) The camera has autofocus and supports focus area. Touch the screen to trigger + * autofocus. Wait until it times out. (10) The camera has no autofocus and supports metering area. + * Touch the screen to change metering area. + */ +public class FocusOverlayManager { + private static final String TRUE = "true"; + private static final String AUTO_EXPOSURE_LOCK_SUPPORTED = "auto-exposure-lock-supported"; + private static final String AUTO_WHITE_BALANCE_LOCK_SUPPORTED = + "auto-whitebalance-lock-supported"; + + private static final int RESET_TOUCH_FOCUS = 0; + private static final int RESET_TOUCH_FOCUS_DELAY = 3000; + + private int mState = STATE_IDLE; + private static final int STATE_IDLE = 0; // Focus is not active. + private static final int STATE_FOCUSING = 1; // Focus is in progress. + // Focus is in progress and the camera should take a picture after focus finishes. + private static final int STATE_FOCUSING_SNAP_ON_FINISH = 2; + private static final int STATE_SUCCESS = 3; // Focus finishes and succeeds. + private static final int STATE_FAIL = 4; // Focus finishes and fails. + + private boolean mInitialized; + private boolean mFocusAreaSupported; + private boolean mMeteringAreaSupported; + private boolean mLockAeAwbNeeded; + private boolean mAeAwbLock; + private Matrix mMatrix; + + private PieRenderer mPieRenderer; + + private int mPreviewWidth; // The width of the preview frame layout. + private int mPreviewHeight; // The height of the preview frame layout. + private boolean mMirror; // true if the camera is front-facing. + private List<Area> mFocusArea; // focus area in driver format + private List<Area> mMeteringArea; // metering area in driver format + private String mFocusMode; + private Parameters mParameters; + private Handler mHandler; + private Listener mListener; + + /** Listener used for the focus indicator to communicate back to the camera. */ + public interface Listener { + void autoFocus(); + + void cancelAutoFocus(); + + boolean capture(); + + void setFocusParameters(); + } + + private class MainHandler extends Handler { + public MainHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case RESET_TOUCH_FOCUS: + { + cancelAutoFocus(); + break; + } + } + } + } + + public FocusOverlayManager(Listener listener, Looper looper) { + mHandler = new MainHandler(looper); + mMatrix = new Matrix(); + mListener = listener; + } + + public void setFocusRenderer(PieRenderer renderer) { + mPieRenderer = renderer; + mInitialized = (mMatrix != null); + } + + public void setParameters(Parameters parameters) { + // parameters can only be null when onConfigurationChanged is called + // before camera is open. We will just return in this case, because + // parameters will be set again later with the right parameters after + // camera is open. + if (parameters == null) { + return; + } + mParameters = parameters; + mFocusAreaSupported = isFocusAreaSupported(parameters); + mMeteringAreaSupported = isMeteringAreaSupported(parameters); + mLockAeAwbNeeded = + (isAutoExposureLockSupported(mParameters) || isAutoWhiteBalanceLockSupported(mParameters)); + } + + public void setPreviewSize(int previewWidth, int previewHeight) { + if (mPreviewWidth != previewWidth || mPreviewHeight != previewHeight) { + mPreviewWidth = previewWidth; + mPreviewHeight = previewHeight; + setMatrix(); + } + } + + public void setMirror(boolean mirror) { + mMirror = mirror; + setMatrix(); + } + + private void setMatrix() { + if (mPreviewWidth != 0 && mPreviewHeight != 0) { + Matrix matrix = new Matrix(); + prepareMatrix(matrix, mMirror, mPreviewWidth, mPreviewHeight); + // In face detection, the matrix converts the driver coordinates to UI + // coordinates. In tap focus, the inverted matrix converts the UI + // coordinates to driver coordinates. + matrix.invert(mMatrix); + mInitialized = (mPieRenderer != null); + } + } + + private void lockAeAwbIfNeeded() { + if (mLockAeAwbNeeded && !mAeAwbLock) { + mAeAwbLock = true; + mListener.setFocusParameters(); + } + } + + public void onAutoFocus(boolean focused, boolean shutterButtonPressed) { + if (mState == STATE_FOCUSING_SNAP_ON_FINISH) { + // Take the picture no matter focus succeeds or fails. No need + // to play the AF sound if we're about to play the shutter + // sound. + if (focused) { + mState = STATE_SUCCESS; + } else { + mState = STATE_FAIL; + } + updateFocusUI(); + capture(); + } else if (mState == STATE_FOCUSING) { + // This happens when (1) user is half-pressing the focus key or + // (2) touch focus is triggered. Play the focus tone. Do not + // take the picture now. + if (focused) { + mState = STATE_SUCCESS; + } else { + mState = STATE_FAIL; + } + updateFocusUI(); + // If this is triggered by touch focus, cancel focus after a + // while. + if (mFocusArea != null) { + mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); + } + if (shutterButtonPressed) { + // Lock AE & AWB so users can half-press shutter and recompose. + lockAeAwbIfNeeded(); + } + } else if (mState == STATE_IDLE) { + // User has released the focus key before focus completes. + // Do nothing. + } + } + + public void onAutoFocusMoving(boolean moving) { + if (!mInitialized) { + return; + } + + // Ignore if we have requested autofocus. This method only handles + // continuous autofocus. + if (mState != STATE_IDLE) { + return; + } + + if (moving) { + mPieRenderer.showStart(); + } else { + mPieRenderer.showSuccess(true); + } + } + + private void initializeFocusAreas( + int focusWidth, int focusHeight, int x, int y, int previewWidth, int previewHeight) { + if (mFocusArea == null) { + mFocusArea = new ArrayList<>(); + mFocusArea.add(new Area(new Rect(), 1)); + } + + // Convert the coordinates to driver format. + calculateTapArea( + focusWidth, focusHeight, 1f, x, y, previewWidth, previewHeight, mFocusArea.get(0).rect); + } + + private void initializeMeteringAreas( + int focusWidth, int focusHeight, int x, int y, int previewWidth, int previewHeight) { + if (mMeteringArea == null) { + mMeteringArea = new ArrayList<>(); + mMeteringArea.add(new Area(new Rect(), 1)); + } + + // Convert the coordinates to driver format. + // AE area is bigger because exposure is sensitive and + // easy to over- or underexposure if area is too small. + calculateTapArea( + focusWidth, + focusHeight, + 1.5f, + x, + y, + previewWidth, + previewHeight, + mMeteringArea.get(0).rect); + } + + public void onSingleTapUp(int x, int y) { + if (!mInitialized || mState == STATE_FOCUSING_SNAP_ON_FINISH) { + return; + } + + // Let users be able to cancel previous touch focus. + if ((mFocusArea != null) + && (mState == STATE_FOCUSING || mState == STATE_SUCCESS || mState == STATE_FAIL)) { + cancelAutoFocus(); + } + // Initialize variables. + int focusWidth = mPieRenderer.getSize(); + int focusHeight = mPieRenderer.getSize(); + if (focusWidth == 0 || mPieRenderer.getWidth() == 0 || mPieRenderer.getHeight() == 0) { + return; + } + int previewWidth = mPreviewWidth; + int previewHeight = mPreviewHeight; + // Initialize mFocusArea. + if (mFocusAreaSupported) { + initializeFocusAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); + } + // Initialize mMeteringArea. + if (mMeteringAreaSupported) { + initializeMeteringAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); + } + + // Use margin to set the focus indicator to the touched area. + mPieRenderer.setFocus(x, y); + + // Set the focus area and metering area. + mListener.setFocusParameters(); + if (mFocusAreaSupported) { + autoFocus(); + } else { // Just show the indicator in all other cases. + updateFocusUI(); + // Reset the metering area in 3 seconds. + mHandler.removeMessages(RESET_TOUCH_FOCUS); + mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); + } + } + + public void onPreviewStarted() { + mState = STATE_IDLE; + } + + public void onPreviewStopped() { + // If auto focus was in progress, it would have been stopped. + mState = STATE_IDLE; + resetTouchFocus(); + updateFocusUI(); + } + + public void onCameraReleased() { + onPreviewStopped(); + } + + private void autoFocus() { + LogUtil.v("FocusOverlayManager.autoFocus", "Start autofocus."); + mListener.autoFocus(); + mState = STATE_FOCUSING; + updateFocusUI(); + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + public void cancelAutoFocus() { + LogUtil.v("FocusOverlayManager.cancelAutoFocus", "Cancel autofocus."); + + // Reset the tap area before calling mListener.cancelAutofocus. + // Otherwise, focus mode stays at auto and the tap area passed to the + // driver is not reset. + resetTouchFocus(); + mListener.cancelAutoFocus(); + mState = STATE_IDLE; + updateFocusUI(); + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + private void capture() { + if (mListener.capture()) { + mState = STATE_IDLE; + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + } + + public String getFocusMode() { + List<String> supportedFocusModes = mParameters.getSupportedFocusModes(); + + if (mFocusAreaSupported && mFocusArea != null) { + // Always use autofocus in tap-to-focus. + mFocusMode = Parameters.FOCUS_MODE_AUTO; + } else { + mFocusMode = Parameters.FOCUS_MODE_CONTINUOUS_PICTURE; + } + + if (!isSupported(mFocusMode, supportedFocusModes)) { + // For some reasons, the driver does not support the current + // focus mode. Fall back to auto. + if (isSupported(Parameters.FOCUS_MODE_AUTO, mParameters.getSupportedFocusModes())) { + mFocusMode = Parameters.FOCUS_MODE_AUTO; + } else { + mFocusMode = mParameters.getFocusMode(); + } + } + return mFocusMode; + } + + public List<Area> getFocusAreas() { + return mFocusArea; + } + + public List<Area> getMeteringAreas() { + return mMeteringArea; + } + + private void updateFocusUI() { + if (!mInitialized) { + return; + } + FocusIndicator focusIndicator = mPieRenderer; + + if (mState == STATE_IDLE) { + if (mFocusArea == null) { + focusIndicator.clear(); + } else { + // Users touch on the preview and the indicator represents the + // metering area. Either focus area is not supported or + // autoFocus call is not required. + focusIndicator.showStart(); + } + } else if (mState == STATE_FOCUSING || mState == STATE_FOCUSING_SNAP_ON_FINISH) { + focusIndicator.showStart(); + } else { + if (Parameters.FOCUS_MODE_CONTINUOUS_PICTURE.equals(mFocusMode)) { + // TODO: check HAL behavior and decide if this can be removed. + focusIndicator.showSuccess(false); + } else if (mState == STATE_SUCCESS) { + focusIndicator.showSuccess(false); + } else if (mState == STATE_FAIL) { + focusIndicator.showFail(false); + } + } + } + + private void resetTouchFocus() { + if (!mInitialized) { + return; + } + + // Put focus indicator to the center. clear reset position + mPieRenderer.clear(); + + mFocusArea = null; + mMeteringArea = null; + } + + private void calculateTapArea( + int focusWidth, + int focusHeight, + float areaMultiple, + int x, + int y, + int previewWidth, + int previewHeight, + Rect rect) { + int areaWidth = (int) (focusWidth * areaMultiple); + int areaHeight = (int) (focusHeight * areaMultiple); + final int maxW = previewWidth - areaWidth; + int left = maxW > 0 ? clamp(x - areaWidth / 2, 0, maxW) : 0; + final int maxH = previewHeight - areaHeight; + int top = maxH > 0 ? clamp(y - areaHeight / 2, 0, maxH) : 0; + + RectF rectF = new RectF(left, top, left + areaWidth, top + areaHeight); + mMatrix.mapRect(rectF); + rectFToRect(rectF, rect); + } + + private int clamp(int x, int min, int max) { + Assert.checkArgument(max >= min); + if (x > max) { + return max; + } + if (x < min) { + return min; + } + return x; + } + + private boolean isAutoExposureLockSupported(Parameters params) { + return TRUE.equals(params.get(AUTO_EXPOSURE_LOCK_SUPPORTED)); + } + + private boolean isAutoWhiteBalanceLockSupported(Parameters params) { + return TRUE.equals(params.get(AUTO_WHITE_BALANCE_LOCK_SUPPORTED)); + } + + private boolean isSupported(String value, List<String> supported) { + return supported != null && supported.indexOf(value) >= 0; + } + + private boolean isMeteringAreaSupported(Parameters params) { + return params.getMaxNumMeteringAreas() > 0; + } + + private boolean isFocusAreaSupported(Parameters params) { + return (params.getMaxNumFocusAreas() > 0 + && isSupported(Parameters.FOCUS_MODE_AUTO, params.getSupportedFocusModes())); + } + + private void prepareMatrix(Matrix matrix, boolean mirror, int viewWidth, int viewHeight) { + // Need mirror for front camera. + matrix.setScale(mirror ? -1 : 1, 1); + // Camera driver coordinates range from (-1000, -1000) to (1000, 1000). + // UI coordinates range from (0, 0) to (width, height). + matrix.postScale(viewWidth / 2000f, viewHeight / 2000f); + matrix.postTranslate(viewWidth / 2f, viewHeight / 2f); + } + + private void rectFToRect(RectF rectF, Rect rect) { + rect.left = Math.round(rectF.left); + rect.top = Math.round(rectF.top); + rect.right = Math.round(rectF.right); + rect.bottom = Math.round(rectF.bottom); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/camerafocus/OverlayRenderer.java b/java/com/android/dialer/callcomposer/camera/camerafocus/OverlayRenderer.java new file mode 100644 index 000000000..4a3b522bb --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/camerafocus/OverlayRenderer.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.view.MotionEvent; + +/** Abstract class that all Camera overlays should implement. */ +public abstract class OverlayRenderer implements RenderOverlay.Renderer { + + protected RenderOverlay mOverlay; + + private int mLeft; + private int mTop; + private int mRight; + private int mBottom; + private boolean mVisible; + + public void setVisible(boolean vis) { + mVisible = vis; + update(); + } + + public boolean isVisible() { + return mVisible; + } + + // default does not handle touch + @Override + public boolean handlesTouch() { + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + return false; + } + + public abstract void onDraw(Canvas canvas); + + @Override + public void draw(Canvas canvas) { + if (mVisible) { + onDraw(canvas); + } + } + + @Override + public void setOverlay(RenderOverlay overlay) { + mOverlay = overlay; + } + + @Override + public void layout(int left, int top, int right, int bottom) { + mLeft = left; + mRight = right; + mTop = top; + mBottom = bottom; + } + + protected Context getContext() { + if (mOverlay != null) { + return mOverlay.getContext(); + } else { + return null; + } + } + + public int getWidth() { + return mRight - mLeft; + } + + public int getHeight() { + return mBottom - mTop; + } + + protected void update() { + if (mOverlay != null) { + mOverlay.update(); + } + } +} diff --git a/java/com/android/dialer/callcomposer/camera/camerafocus/PieItem.java b/java/com/android/dialer/callcomposer/camera/camerafocus/PieItem.java new file mode 100644 index 000000000..86f69c0ae --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/camerafocus/PieItem.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.drawable.Drawable; +import java.util.List; + +/** Pie menu item. */ +public class PieItem { + + /** Listener to detect pie item clicks. */ + public interface OnClickListener { + void onClick(PieItem item); + } + + private Drawable mDrawable; + private int level; + private float mCenter; + private float start; + private float sweep; + private float animate; + private int inner; + private int outer; + private boolean mSelected; + private boolean mEnabled; + private List<PieItem> mItems; + private Path mPath; + private OnClickListener mOnClickListener; + private float mAlpha; + + // Gray out the view when disabled + private static final float ENABLED_ALPHA = 1; + private static final float DISABLED_ALPHA = (float) 0.3; + + public PieItem(Drawable drawable, int level) { + mDrawable = drawable; + this.level = level; + setAlpha(1f); + mEnabled = true; + setAnimationAngle(getAnimationAngle()); + start = -1; + mCenter = -1; + } + + public boolean hasItems() { + return mItems != null; + } + + public List<PieItem> getItems() { + return mItems; + } + + public void setPath(Path p) { + mPath = p; + } + + public Path getPath() { + return mPath; + } + + public void setAlpha(float alpha) { + mAlpha = alpha; + mDrawable.setAlpha((int) (255 * alpha)); + } + + public void setAnimationAngle(float a) { + animate = a; + } + + private float getAnimationAngle() { + return animate; + } + + public void setEnabled(boolean enabled) { + mEnabled = enabled; + if (mEnabled) { + setAlpha(ENABLED_ALPHA); + } else { + setAlpha(DISABLED_ALPHA); + } + } + + public boolean isEnabled() { + return mEnabled; + } + + public void setSelected(boolean s) { + mSelected = s; + } + + public boolean isSelected() { + return mSelected; + } + + public int getLevel() { + return level; + } + + public void setGeometry(float st, float sw, int inside, int outside) { + start = st; + sweep = sw; + inner = inside; + outer = outside; + } + + public float getCenter() { + return mCenter; + } + + public float getStart() { + return start; + } + + public float getStartAngle() { + return start + animate; + } + + public float getSweep() { + return sweep; + } + + public int getInnerRadius() { + return inner; + } + + public int getOuterRadius() { + return outer; + } + + public void setOnClickListener(OnClickListener listener) { + mOnClickListener = listener; + } + + public void performClick() { + if (mOnClickListener != null) { + mOnClickListener.onClick(this); + } + } + + public int getIntrinsicWidth() { + return mDrawable.getIntrinsicWidth(); + } + + public int getIntrinsicHeight() { + return mDrawable.getIntrinsicHeight(); + } + + public void setBounds(int left, int top, int right, int bottom) { + mDrawable.setBounds(left, top, right, bottom); + } + + public void draw(Canvas canvas) { + mDrawable.draw(canvas); + } + + public void setImageResource(Context context, int resId) { + Drawable d = context.getResources().getDrawable(resId).mutate(); + d.setBounds(mDrawable.getBounds()); + mDrawable = d; + setAlpha(mAlpha); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/camerafocus/PieRenderer.java b/java/com/android/dialer/callcomposer/camera/camerafocus/PieRenderer.java new file mode 100644 index 000000000..59b57b002 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/camerafocus/PieRenderer.java @@ -0,0 +1,816 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.camerafocus; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Handler; +import android.os.Message; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.LinearInterpolator; +import android.view.animation.Transformation; +import java.util.ArrayList; +import java.util.List; + +/** Used to draw and render the pie item focus indicator. */ +public class PieRenderer extends OverlayRenderer implements FocusIndicator { + // Sometimes continuous autofocus starts and stops several times quickly. + // These states are used to make sure the animation is run for at least some + // time. + private volatile int mState; + private ScaleAnimation mAnimation = new ScaleAnimation(); + private static final int STATE_IDLE = 0; + private static final int STATE_FOCUSING = 1; + private static final int STATE_FINISHING = 2; + private static final int STATE_PIE = 8; + + private Runnable mDisappear = new Disappear(); + private Animation.AnimationListener mEndAction = new EndAction(); + private static final int SCALING_UP_TIME = 600; + private static final int SCALING_DOWN_TIME = 100; + private static final int DISAPPEAR_TIMEOUT = 200; + private static final int DIAL_HORIZONTAL = 157; + + private static final long PIE_FADE_IN_DURATION = 200; + private static final long PIE_XFADE_DURATION = 200; + private static final long PIE_SELECT_FADE_DURATION = 300; + + private static final int MSG_OPEN = 0; + private static final int MSG_CLOSE = 1; + private static final float PIE_SWEEP = (float) (Math.PI * 2 / 3); + // geometry + private Point mCenter; + private int mRadius; + private int mRadiusInc; + + // the detection if touch is inside a slice is offset + // inbounds by this amount to allow the selection to show before the + // finger covers it + private int mTouchOffset; + + private List<PieItem> mItems; + + private PieItem mOpenItem; + + private Paint mSelectedPaint; + private Paint mSubPaint; + + // touch handling + private PieItem mCurrentItem; + + private Paint mFocusPaint; + private int mSuccessColor; + private int mFailColor; + private int mCircleSize; + private int mFocusX; + private int mFocusY; + private int mCenterX; + private int mCenterY; + + private int mDialAngle; + private RectF mCircle; + private RectF mDial; + private Point mPoint1; + private Point mPoint2; + private int mStartAnimationAngle; + private boolean mFocused; + private int mInnerOffset; + private int mOuterStroke; + private int mInnerStroke; + private boolean mTapMode; + private boolean mBlockFocus; + private int mTouchSlopSquared; + private Point mDown; + private boolean mOpening; + private LinearAnimation mXFade; + private LinearAnimation mFadeIn; + private volatile boolean mFocusCancelled; + + private Handler mHandler = + new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MSG_OPEN: + if (mListener != null) { + mListener.onPieOpened(mCenter.x, mCenter.y); + } + break; + case MSG_CLOSE: + if (mListener != null) { + mListener.onPieClosed(); + } + break; + } + } + }; + + private PieListener mListener; + + /** Listener for the pie item to communicate back to the renderer. */ + public interface PieListener { + void onPieOpened(int centerX, int centerY); + + void onPieClosed(); + } + + public void setPieListener(PieListener pl) { + mListener = pl; + } + + public PieRenderer(Context context) { + init(context); + } + + private void init(Context ctx) { + setVisible(false); + mItems = new ArrayList<PieItem>(); + Resources res = ctx.getResources(); + mRadius = res.getDimensionPixelSize(R.dimen.pie_radius_start); + mCircleSize = mRadius - res.getDimensionPixelSize(R.dimen.focus_radius_offset); + mRadiusInc = res.getDimensionPixelSize(R.dimen.pie_radius_increment); + mTouchOffset = res.getDimensionPixelSize(R.dimen.pie_touch_offset); + mCenter = new Point(0, 0); + mSelectedPaint = new Paint(); + mSelectedPaint.setColor(Color.argb(255, 51, 181, 229)); + mSelectedPaint.setAntiAlias(true); + mSubPaint = new Paint(); + mSubPaint.setAntiAlias(true); + mSubPaint.setColor(Color.argb(200, 250, 230, 128)); + mFocusPaint = new Paint(); + mFocusPaint.setAntiAlias(true); + mFocusPaint.setColor(Color.WHITE); + mFocusPaint.setStyle(Paint.Style.STROKE); + mSuccessColor = Color.GREEN; + mFailColor = Color.RED; + mCircle = new RectF(); + mDial = new RectF(); + mPoint1 = new Point(); + mPoint2 = new Point(); + mInnerOffset = res.getDimensionPixelSize(R.dimen.focus_inner_offset); + mOuterStroke = res.getDimensionPixelSize(R.dimen.focus_outer_stroke); + mInnerStroke = res.getDimensionPixelSize(R.dimen.focus_inner_stroke); + mState = STATE_IDLE; + mBlockFocus = false; + mTouchSlopSquared = ViewConfiguration.get(ctx).getScaledTouchSlop(); + mTouchSlopSquared = mTouchSlopSquared * mTouchSlopSquared; + mDown = new Point(); + } + + public boolean showsItems() { + return mTapMode; + } + + public void addItem(PieItem item) { + // add the item to the pie itself + mItems.add(item); + } + + public void removeItem(PieItem item) { + mItems.remove(item); + } + + public void clearItems() { + mItems.clear(); + } + + public void showInCenter() { + if ((mState == STATE_PIE) && isVisible()) { + mTapMode = false; + show(false); + } else { + if (mState != STATE_IDLE) { + cancelFocus(); + } + mState = STATE_PIE; + setCenter(mCenterX, mCenterY); + mTapMode = true; + show(true); + } + } + + public void hide() { + show(false); + } + + /** + * guaranteed has center set + * + * @param show + */ + private void show(boolean show) { + if (show) { + mState = STATE_PIE; + // ensure clean state + mCurrentItem = null; + mOpenItem = null; + for (PieItem item : mItems) { + item.setSelected(false); + } + layoutPie(); + fadeIn(); + } else { + mState = STATE_IDLE; + mTapMode = false; + if (mXFade != null) { + mXFade.cancel(); + } + } + setVisible(show); + mHandler.sendEmptyMessage(show ? MSG_OPEN : MSG_CLOSE); + } + + private void fadeIn() { + mFadeIn = new LinearAnimation(0, 1); + mFadeIn.setDuration(PIE_FADE_IN_DURATION); + mFadeIn.setAnimationListener( + new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + mFadeIn = null; + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + mFadeIn.startNow(); + mOverlay.startAnimation(mFadeIn); + } + + public void setCenter(int x, int y) { + mCenter.x = x; + mCenter.y = y; + // when using the pie menu, align the focus ring + alignFocus(x, y); + } + + private void layoutPie() { + int rgap = 2; + int inner = mRadius + rgap; + int outer = mRadius + mRadiusInc - rgap; + int gap = 1; + layoutItems(mItems, (float) (Math.PI / 2), inner, outer, gap); + } + + private void layoutItems(List<PieItem> items, float centerAngle, int inner, int outer, int gap) { + float emptyangle = PIE_SWEEP / 16; + float sweep = (PIE_SWEEP - 2 * emptyangle) / items.size(); + float angle = centerAngle - PIE_SWEEP / 2 + emptyangle + sweep / 2; + // check if we have custom geometry + // first item we find triggers custom sweep for all + // this allows us to re-use the path + for (PieItem item : items) { + if (item.getCenter() >= 0) { + sweep = item.getSweep(); + break; + } + } + Path path = makeSlice(getDegrees(0) - gap, getDegrees(sweep) + gap, outer, inner, mCenter); + for (PieItem item : items) { + // shared between items + item.setPath(path); + if (item.getCenter() >= 0) { + angle = item.getCenter(); + } + int w = item.getIntrinsicWidth(); + int h = item.getIntrinsicHeight(); + // move views to outer border + int r = inner + (outer - inner) * 2 / 3; + int x = (int) (r * Math.cos(angle)); + int y = mCenter.y - (int) (r * Math.sin(angle)) - h / 2; + x = mCenter.x + x - w / 2; + item.setBounds(x, y, x + w, y + h); + float itemstart = angle - sweep / 2; + item.setGeometry(itemstart, sweep, inner, outer); + if (item.hasItems()) { + layoutItems(item.getItems(), angle, inner, outer + mRadiusInc / 2, gap); + } + angle += sweep; + } + } + + private Path makeSlice(float start, float end, int outer, int inner, Point center) { + RectF bb = new RectF(center.x - outer, center.y - outer, center.x + outer, center.y + outer); + RectF bbi = new RectF(center.x - inner, center.y - inner, center.x + inner, center.y + inner); + Path path = new Path(); + path.arcTo(bb, start, end - start, true); + path.arcTo(bbi, end, start - end); + path.close(); + return path; + } + + /** + * converts a + * + * @param angle from 0..PI to Android degrees (clockwise starting at 3 o'clock) + * @return skia angle + */ + private float getDegrees(double angle) { + return (float) (360 - 180 * angle / Math.PI); + } + + private void startFadeOut() { + mOverlay + .animate() + .alpha(0) + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + deselect(); + show(false); + mOverlay.setAlpha(1); + super.onAnimationEnd(animation); + } + }) + .setDuration(PIE_SELECT_FADE_DURATION); + } + + @Override + public void onDraw(Canvas canvas) { + float alpha = 1; + if (mXFade != null) { + alpha = mXFade.getValue(); + } else if (mFadeIn != null) { + alpha = mFadeIn.getValue(); + } + int state = canvas.save(); + if (mFadeIn != null) { + float sf = 0.9f + alpha * 0.1f; + canvas.scale(sf, sf, mCenter.x, mCenter.y); + } + drawFocus(canvas); + if (mState == STATE_FINISHING) { + canvas.restoreToCount(state); + return; + } + if ((mOpenItem == null) || (mXFade != null)) { + // draw base menu + for (PieItem item : mItems) { + drawItem(canvas, item, alpha); + } + } + if (mOpenItem != null) { + for (PieItem inner : mOpenItem.getItems()) { + drawItem(canvas, inner, (mXFade != null) ? (1 - 0.5f * alpha) : 1); + } + } + canvas.restoreToCount(state); + } + + private void drawItem(Canvas canvas, PieItem item, float alpha) { + if (mState == STATE_PIE) { + if (item.getPath() != null) { + if (item.isSelected()) { + Paint p = mSelectedPaint; + int state = canvas.save(); + float r = getDegrees(item.getStartAngle()); + canvas.rotate(r, mCenter.x, mCenter.y); + canvas.drawPath(item.getPath(), p); + canvas.restoreToCount(state); + } + alpha = alpha * (item.isEnabled() ? 1 : 0.3f); + // draw the item view + item.setAlpha(alpha); + item.draw(canvas); + } + } + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + float x = evt.getX(); + float y = evt.getY(); + int action = evt.getActionMasked(); + PointF polar = getPolar(x, y, !(mTapMode)); + if (MotionEvent.ACTION_DOWN == action) { + mDown.x = (int) evt.getX(); + mDown.y = (int) evt.getY(); + mOpening = false; + if (mTapMode) { + PieItem item = findItem(polar); + if ((item != null) && (mCurrentItem != item)) { + mState = STATE_PIE; + onEnter(item); + } + } else { + setCenter((int) x, (int) y); + show(true); + } + return true; + } else if (MotionEvent.ACTION_UP == action) { + if (isVisible()) { + PieItem item = mCurrentItem; + if (mTapMode) { + item = findItem(polar); + if (item != null && mOpening) { + mOpening = false; + return true; + } + } + if (item == null) { + mTapMode = false; + show(false); + } else if (!mOpening && !item.hasItems()) { + item.performClick(); + startFadeOut(); + mTapMode = false; + } + return true; + } + } else if (MotionEvent.ACTION_CANCEL == action) { + if (isVisible() || mTapMode) { + show(false); + } + deselect(); + return false; + } else if (MotionEvent.ACTION_MOVE == action) { + if (polar.y < mRadius) { + if (mOpenItem != null) { + mOpenItem = null; + } else { + deselect(); + } + return false; + } + PieItem item = findItem(polar); + boolean moved = hasMoved(evt); + if ((item != null) && (mCurrentItem != item) && (!mOpening || moved)) { + // only select if we didn't just open or have moved past slop + mOpening = false; + if (moved) { + // switch back to swipe mode + mTapMode = false; + } + onEnter(item); + } + } + return false; + } + + private boolean hasMoved(MotionEvent e) { + return mTouchSlopSquared + < (e.getX() - mDown.x) * (e.getX() - mDown.x) + (e.getY() - mDown.y) * (e.getY() - mDown.y); + } + + /** + * enter a slice for a view updates model only + * + * @param item + */ + private void onEnter(PieItem item) { + if (mCurrentItem != null) { + mCurrentItem.setSelected(false); + } + if (item != null && item.isEnabled()) { + item.setSelected(true); + mCurrentItem = item; + if ((mCurrentItem != mOpenItem) && mCurrentItem.hasItems()) { + openCurrentItem(); + } + } else { + mCurrentItem = null; + } + } + + private void deselect() { + if (mCurrentItem != null) { + mCurrentItem.setSelected(false); + } + if (mOpenItem != null) { + mOpenItem = null; + } + mCurrentItem = null; + } + + private void openCurrentItem() { + if ((mCurrentItem != null) && mCurrentItem.hasItems()) { + mCurrentItem.setSelected(false); + mOpenItem = mCurrentItem; + mOpening = true; + mXFade = new LinearAnimation(1, 0); + mXFade.setDuration(PIE_XFADE_DURATION); + mXFade.setAnimationListener( + new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + mXFade = null; + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + mXFade.startNow(); + mOverlay.startAnimation(mXFade); + } + } + + private PointF getPolar(float x, float y, boolean useOffset) { + PointF res = new PointF(); + // get angle and radius from x/y + res.x = (float) Math.PI / 2; + x = x - mCenter.x; + y = mCenter.y - y; + res.y = (float) Math.sqrt(x * x + y * y); + if (x != 0) { + res.x = (float) Math.atan2(y, x); + if (res.x < 0) { + res.x = (float) (2 * Math.PI + res.x); + } + } + res.y = res.y + (useOffset ? mTouchOffset : 0); + return res; + } + + /** + * @param polar x: angle, y: dist + * @return the item at angle/dist or null + */ + private PieItem findItem(PointF polar) { + // find the matching item: + List<PieItem> items = (mOpenItem != null) ? mOpenItem.getItems() : mItems; + for (PieItem item : items) { + if (inside(polar, item)) { + return item; + } + } + return null; + } + + private boolean inside(PointF polar, PieItem item) { + return (item.getInnerRadius() < polar.y) + && (item.getStartAngle() < polar.x) + && (item.getStartAngle() + item.getSweep() > polar.x) + && (!mTapMode || (item.getOuterRadius() > polar.y)); + } + + @Override + public boolean handlesTouch() { + return true; + } + + // focus specific code + + public void setBlockFocus(boolean blocked) { + mBlockFocus = blocked; + if (blocked) { + clear(); + } + } + + public void setFocus(int x, int y) { + mFocusX = x; + mFocusY = y; + setCircle(mFocusX, mFocusY); + } + + public void alignFocus(int x, int y) { + mOverlay.removeCallbacks(mDisappear); + mAnimation.cancel(); + mAnimation.reset(); + mFocusX = x; + mFocusY = y; + mDialAngle = DIAL_HORIZONTAL; + setCircle(x, y); + mFocused = false; + } + + public int getSize() { + return 2 * mCircleSize; + } + + private int getRandomRange() { + return (int) (-60 + 120 * Math.random()); + } + + @Override + public void layout(int l, int t, int r, int b) { + super.layout(l, t, r, b); + mCenterX = (r - l) / 2; + mCenterY = (b - t) / 2; + mFocusX = mCenterX; + mFocusY = mCenterY; + setCircle(mFocusX, mFocusY); + if (isVisible() && mState == STATE_PIE) { + setCenter(mCenterX, mCenterY); + layoutPie(); + } + } + + private void setCircle(int cx, int cy) { + mCircle.set(cx - mCircleSize, cy - mCircleSize, cx + mCircleSize, cy + mCircleSize); + mDial.set( + cx - mCircleSize + mInnerOffset, + cy - mCircleSize + mInnerOffset, + cx + mCircleSize - mInnerOffset, + cy + mCircleSize - mInnerOffset); + } + + public void drawFocus(Canvas canvas) { + if (mBlockFocus) { + return; + } + mFocusPaint.setStrokeWidth(mOuterStroke); + canvas.drawCircle((float) mFocusX, (float) mFocusY, (float) mCircleSize, mFocusPaint); + if (mState == STATE_PIE) { + return; + } + int color = mFocusPaint.getColor(); + if (mState == STATE_FINISHING) { + mFocusPaint.setColor(mFocused ? mSuccessColor : mFailColor); + } + mFocusPaint.setStrokeWidth(mInnerStroke); + drawLine(canvas, mDialAngle, mFocusPaint); + drawLine(canvas, mDialAngle + 45, mFocusPaint); + drawLine(canvas, mDialAngle + 180, mFocusPaint); + drawLine(canvas, mDialAngle + 225, mFocusPaint); + canvas.save(); + // rotate the arc instead of its offset to better use framework's shape caching + canvas.rotate(mDialAngle, mFocusX, mFocusY); + canvas.drawArc(mDial, 0, 45, false, mFocusPaint); + canvas.drawArc(mDial, 180, 45, false, mFocusPaint); + canvas.restore(); + mFocusPaint.setColor(color); + } + + private void drawLine(Canvas canvas, int angle, Paint p) { + convertCart(angle, mCircleSize - mInnerOffset, mPoint1); + convertCart(angle, mCircleSize - mInnerOffset + mInnerOffset / 3, mPoint2); + canvas.drawLine( + mPoint1.x + mFocusX, mPoint1.y + mFocusY, mPoint2.x + mFocusX, mPoint2.y + mFocusY, p); + } + + private static void convertCart(int angle, int radius, Point out) { + double a = 2 * Math.PI * (angle % 360) / 360; + out.x = (int) (radius * Math.cos(a) + 0.5); + out.y = (int) (radius * Math.sin(a) + 0.5); + } + + @Override + public void showStart() { + if (mState == STATE_PIE) { + return; + } + cancelFocus(); + mStartAnimationAngle = 67; + int range = getRandomRange(); + startAnimation(SCALING_UP_TIME, false, mStartAnimationAngle, mStartAnimationAngle + range); + mState = STATE_FOCUSING; + } + + @Override + public void showSuccess(boolean timeout) { + if (mState == STATE_FOCUSING) { + startAnimation(SCALING_DOWN_TIME, timeout, mStartAnimationAngle); + mState = STATE_FINISHING; + mFocused = true; + } + } + + @Override + public void showFail(boolean timeout) { + if (mState == STATE_FOCUSING) { + startAnimation(SCALING_DOWN_TIME, timeout, mStartAnimationAngle); + mState = STATE_FINISHING; + mFocused = false; + } + } + + private void cancelFocus() { + mFocusCancelled = true; + mOverlay.removeCallbacks(mDisappear); + if (mAnimation != null) { + mAnimation.cancel(); + } + mFocusCancelled = false; + mFocused = false; + mState = STATE_IDLE; + } + + @Override + public void clear() { + if (mState == STATE_PIE) { + return; + } + cancelFocus(); + mOverlay.post(mDisappear); + } + + private void startAnimation(long duration, boolean timeout, float toScale) { + startAnimation(duration, timeout, mDialAngle, toScale); + } + + private void startAnimation(long duration, boolean timeout, float fromScale, float toScale) { + setVisible(true); + mAnimation.reset(); + mAnimation.setDuration(duration); + mAnimation.setScale(fromScale, toScale); + mAnimation.setAnimationListener(timeout ? mEndAction : null); + mOverlay.startAnimation(mAnimation); + update(); + } + + private class EndAction implements Animation.AnimationListener { + @Override + public void onAnimationEnd(Animation animation) { + // Keep the focus indicator for some time. + if (!mFocusCancelled) { + mOverlay.postDelayed(mDisappear, DISAPPEAR_TIMEOUT); + } + } + + @Override + public void onAnimationRepeat(Animation animation) {} + + @Override + public void onAnimationStart(Animation animation) {} + } + + private class Disappear implements Runnable { + @Override + public void run() { + if (mState == STATE_PIE) { + return; + } + setVisible(false); + mFocusX = mCenterX; + mFocusY = mCenterY; + mState = STATE_IDLE; + setCircle(mFocusX, mFocusY); + mFocused = false; + } + } + + private class ScaleAnimation extends Animation { + private float mFrom = 1f; + private float mTo = 1f; + + public ScaleAnimation() { + setFillAfter(true); + } + + public void setScale(float from, float to) { + mFrom = from; + mTo = to; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + mDialAngle = (int) (mFrom + (mTo - mFrom) * interpolatedTime); + } + } + + private static class LinearAnimation extends Animation { + private float mFrom; + private float mTo; + private float mValue; + + public LinearAnimation(float from, float to) { + setFillAfter(true); + setInterpolator(new LinearInterpolator()); + mFrom = from; + mTo = to; + } + + public float getValue() { + return mValue; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + mValue = (mFrom + (mTo - mFrom) * interpolatedTime); + } + } +} diff --git a/java/com/android/dialer/callcomposer/camera/camerafocus/RenderOverlay.java b/java/com/android/dialer/callcomposer/camera/camerafocus/RenderOverlay.java new file mode 100644 index 000000000..c38ae6c81 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/camerafocus/RenderOverlay.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.camerafocus; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; +import java.util.ArrayList; +import java.util.List; + +/** Focusing overlay. */ +public class RenderOverlay extends FrameLayout { + + /** Render interface. */ + interface Renderer { + boolean handlesTouch(); + + boolean onTouchEvent(MotionEvent evt); + + void setOverlay(RenderOverlay overlay); + + void layout(int left, int top, int right, int bottom); + + void draw(Canvas canvas); + } + + private RenderView mRenderView; + private List<Renderer> mClients; + + // reverse list of touch clients + private List<Renderer> mTouchClients; + private int[] mPosition = new int[2]; + + public RenderOverlay(Context context, AttributeSet attrs) { + super(context, attrs); + mRenderView = new RenderView(context); + addView(mRenderView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + mClients = new ArrayList<>(10); + mTouchClients = new ArrayList<>(10); + setWillNotDraw(false); + + addRenderer(new PieRenderer(context)); + } + + public PieRenderer getPieRenderer() { + for (Renderer renderer : mClients) { + if (renderer instanceof PieRenderer) { + return (PieRenderer) renderer; + } + } + return null; + } + + public void addRenderer(Renderer renderer) { + mClients.add(renderer); + renderer.setOverlay(this); + if (renderer.handlesTouch()) { + mTouchClients.add(0, renderer); + } + renderer.layout(getLeft(), getTop(), getRight(), getBottom()); + } + + public void addRenderer(int pos, Renderer renderer) { + mClients.add(pos, renderer); + renderer.setOverlay(this); + renderer.layout(getLeft(), getTop(), getRight(), getBottom()); + } + + public void remove(Renderer renderer) { + mClients.remove(renderer); + renderer.setOverlay(null); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent m) { + return false; + } + + private void adjustPosition() { + getLocationInWindow(mPosition); + } + + public void update() { + mRenderView.invalidate(); + } + + @SuppressLint("ClickableViewAccessibility") + private class RenderView extends View { + + public RenderView(Context context) { + super(context); + setWillNotDraw(false); + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + if (mTouchClients != null) { + boolean res = false; + for (Renderer client : mTouchClients) { + res |= client.onTouchEvent(evt); + } + return res; + } + return false; + } + + @Override + public void onLayout(boolean changed, int left, int top, int right, int bottom) { + adjustPosition(); + super.onLayout(changed, left, top, right, bottom); + if (mClients == null) { + return; + } + for (Renderer renderer : mClients) { + renderer.layout(left, top, right, bottom); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + if (mClients == null) { + return; + } + boolean redraw = false; + for (Renderer renderer : mClients) { + renderer.draw(canvas); + redraw = redraw || ((OverlayRenderer) renderer).isVisible(); + } + if (redraw) { + invalidate(); + } + } + } +} diff --git a/java/com/android/dialer/callcomposer/camera/camerafocus/res/values/dimens.xml b/java/com/android/dialer/callcomposer/camera/camerafocus/res/values/dimens.xml new file mode 100644 index 000000000..fba631b0e --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/camerafocus/res/values/dimens.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2016 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<resources> + <!-- Camera focus indicator values --> + <dimen name="pie_radius_start">40dp</dimen> + <dimen name="pie_radius_increment">30dp</dimen> + <dimen name="pie_touch_offset">20dp</dimen> + <dimen name="focus_radius_offset">8dp</dimen> + <dimen name="focus_inner_offset">12dp</dimen> + <dimen name="focus_outer_stroke">3dp</dimen> + <dimen name="focus_inner_stroke">2dp</dimen> +</resources>
\ No newline at end of file diff --git a/java/com/android/dialer/callcomposer/camera/exif/CountedDataInputStream.java b/java/com/android/dialer/callcomposer/camera/exif/CountedDataInputStream.java new file mode 100644 index 000000000..e2c8185da --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/CountedDataInputStream.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +import com.android.dialer.common.Assert; +import java.io.EOFException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.Charset; + +class CountedDataInputStream extends FilterInputStream { + + private int mCount = 0; + + // allocate a byte buffer for a long value; + private final byte[] mByteArray = new byte[8]; + private final ByteBuffer mByteBuffer = ByteBuffer.wrap(mByteArray); + + CountedDataInputStream(InputStream in) { + super(in); + } + + int getReadByteCount() { + return mCount; + } + + @Override + public int read(byte[] b) throws IOException { + int r = in.read(b); + mCount += (r >= 0) ? r : 0; + return r; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int r = in.read(b, off, len); + mCount += (r >= 0) ? r : 0; + return r; + } + + @Override + public int read() throws IOException { + int r = in.read(); + mCount += (r >= 0) ? 1 : 0; + return r; + } + + @Override + public long skip(long length) throws IOException { + long skip = in.skip(length); + mCount += skip; + return skip; + } + + private void skipOrThrow(long length) throws IOException { + if (skip(length) != length) { + throw new EOFException(); + } + } + + void skipTo(long target) throws IOException { + long cur = mCount; + long diff = target - cur; + Assert.checkArgument(diff >= 0); + skipOrThrow(diff); + } + + private void readOrThrow(byte[] b, int off, int len) throws IOException { + int r = read(b, off, len); + if (r != len) { + throw new EOFException(); + } + } + + private void readOrThrow(byte[] b) throws IOException { + readOrThrow(b, 0, b.length); + } + + void setByteOrder(ByteOrder order) { + mByteBuffer.order(order); + } + + ByteOrder getByteOrder() { + return mByteBuffer.order(); + } + + short readShort() throws IOException { + readOrThrow(mByteArray, 0, 2); + mByteBuffer.rewind(); + return mByteBuffer.getShort(); + } + + int readUnsignedShort() throws IOException { + return readShort() & 0xffff; + } + + int readInt() throws IOException { + readOrThrow(mByteArray, 0, 4); + mByteBuffer.rewind(); + return mByteBuffer.getInt(); + } + + long readUnsignedInt() throws IOException { + return readInt() & 0xffffffffL; + } + + String readString(int n, Charset charset) throws IOException { + byte[] buf = new byte[n]; + readOrThrow(buf); + return new String(buf, charset); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/ExifData.java b/java/com/android/dialer/callcomposer/camera/exif/ExifData.java new file mode 100644 index 000000000..27936ae2f --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/ExifData.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +/** + * This class stores the EXIF header in IFDs according to the JPEG specification. It is the result + * produced by {@link ExifReader}. + * + * @see ExifReader + * @see IfdData + */ +public class ExifData { + + private final IfdData[] mIfdDatas = new IfdData[IfdId.TYPE_IFD_COUNT]; + + /** + * Adds IFD data. If IFD data of the same type already exists, it will be replaced by the new + * data. + */ + void addIfdData(IfdData data) { + mIfdDatas[data.getId()] = data; + } + + /** Returns the {@link IfdData} object corresponding to a given IFD if it exists or null. */ + IfdData getIfdData(int ifdId) { + if (ExifTag.isValidIfd(ifdId)) { + return mIfdDatas[ifdId]; + } + return null; + } + + /** + * Returns the tag with a given TID in the given IFD if the tag exists. Otherwise returns null. + */ + protected ExifTag getTag(short tag, int ifd) { + IfdData ifdData = mIfdDatas[ifd]; + return (ifdData == null) ? null : ifdData.getTag(tag); + } + + /** + * Adds the given ExifTag to its default IFD and returns an existing ExifTag with the same TID or + * null if none exist. + */ + ExifTag addTag(ExifTag tag) { + if (tag != null) { + int ifd = tag.getIfd(); + return addTag(tag, ifd); + } + return null; + } + + /** + * Adds the given ExifTag to the given IFD and returns an existing ExifTag with the same TID or + * null if none exist. + */ + private ExifTag addTag(ExifTag tag, int ifdId) { + if (tag != null && ExifTag.isValidIfd(ifdId)) { + IfdData ifdData = getOrCreateIfdData(ifdId); + return ifdData.setTag(tag); + } + return null; + } + + /** + * Returns the {@link IfdData} object corresponding to a given IFD or generates one if none exist. + */ + private IfdData getOrCreateIfdData(int ifdId) { + IfdData ifdData = mIfdDatas[ifdId]; + if (ifdData == null) { + ifdData = new IfdData(ifdId); + mIfdDatas[ifdId] = ifdData; + } + return ifdData; + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/ExifInterface.java b/java/com/android/dialer/callcomposer/camera/exif/ExifInterface.java new file mode 100644 index 000000000..92dee1c94 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/ExifInterface.java @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +import android.annotation.SuppressLint; +import android.graphics.Bitmap; +import android.util.SparseIntArray; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.HashSet; +import java.util.TimeZone; + +/** + * This class provides methods and constants for reading and writing jpeg file metadata. It contains + * a collection of ExifTags, and a collection of definitions for creating valid ExifTags. The + * collection of ExifTags can be updated by: reading new ones from a file, deleting or adding + * existing ones, or building new ExifTags from a tag definition. These ExifTags can be written to a + * valid jpeg image as exif metadata. + * + * <p>Each ExifTag has a tag ID (TID) and is stored in a specific image file directory (IFD) as + * specified by the exif standard. A tag definition can be looked up with a constant that is a + * combination of TID and IFD. This definition has information about the type, number of components, + * and valid IFDs for a tag. + * + * @see ExifTag + */ +public class ExifInterface { + private static final int IFD_NULL = -1; + static final int DEFINITION_NULL = 0; + + /** Tag constants for Jeita EXIF 2.2 */ + // IFD 0 + public static final int TAG_ORIENTATION = defineTag(IfdId.TYPE_IFD_0, (short) 0x0112); + + static final int TAG_EXIF_IFD = defineTag(IfdId.TYPE_IFD_0, (short) 0x8769); + static final int TAG_GPS_IFD = defineTag(IfdId.TYPE_IFD_0, (short) 0x8825); + static final int TAG_STRIP_OFFSETS = defineTag(IfdId.TYPE_IFD_0, (short) 0x0111); + static final int TAG_STRIP_BYTE_COUNTS = defineTag(IfdId.TYPE_IFD_0, (short) 0x0117); + // IFD 1 + static final int TAG_JPEG_INTERCHANGE_FORMAT = defineTag(IfdId.TYPE_IFD_1, (short) 0x0201); + static final int TAG_JPEG_INTERCHANGE_FORMAT_LENGTH = defineTag(IfdId.TYPE_IFD_1, (short) 0x0202); + // IFD Exif Tags + static final int TAG_INTEROPERABILITY_IFD = defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA005); + + /** Tags that contain offset markers. These are included in the banned defines. */ + private static HashSet<Short> sOffsetTags = new HashSet<>(); + + static { + sOffsetTags.add(getTrueTagKey(TAG_GPS_IFD)); + sOffsetTags.add(getTrueTagKey(TAG_EXIF_IFD)); + sOffsetTags.add(getTrueTagKey(TAG_JPEG_INTERCHANGE_FORMAT)); + sOffsetTags.add(getTrueTagKey(TAG_INTEROPERABILITY_IFD)); + sOffsetTags.add(getTrueTagKey(TAG_STRIP_OFFSETS)); + } + + private static final String NULL_ARGUMENT_STRING = "Argument is null"; + + private static final String GPS_DATE_FORMAT_STR = "yyyy:MM:dd"; + + private ExifData mData = new ExifData(); + + @SuppressLint("SimpleDateFormat") + public ExifInterface() { + DateFormat mGPSDateStampFormat = new SimpleDateFormat(GPS_DATE_FORMAT_STR); + mGPSDateStampFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + /** + * Reads the exif tags from a byte array, clearing this ExifInterface object's existing exif tags. + * + * @param jpeg a byte array containing a jpeg compressed image. + * @throws java.io.IOException + */ + public void readExif(byte[] jpeg) throws IOException { + readExif(new ByteArrayInputStream(jpeg)); + } + + /** + * Reads the exif tags from an InputStream, clearing this ExifInterface object's existing exif + * tags. + * + * @param inStream an InputStream containing a jpeg compressed image. + * @throws java.io.IOException + */ + private void readExif(InputStream inStream) throws IOException { + if (inStream == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + ExifData d; + try { + d = new ExifReader(this).read(inStream); + } catch (ExifInvalidFormatException e) { + throw new IOException("Invalid exif format : " + e); + } + mData = d; + } + + /** Returns the TID for a tag constant. */ + static short getTrueTagKey(int tag) { + // Truncate + return (short) tag; + } + + /** Returns the constant representing a tag with a given TID and default IFD. */ + private static int defineTag(int ifdId, short tagId) { + return (tagId & 0x0000ffff) | (ifdId << 16); + } + + static boolean isIfdAllowed(int info, int ifd) { + int[] ifds = IfdData.getIfds(); + int ifdFlags = getAllowedIfdFlagsFromInfo(info); + for (int i = 0; i < ifds.length; i++) { + if (ifd == ifds[i] && ((ifdFlags >> i) & 1) == 1) { + return true; + } + } + return false; + } + + private static int getAllowedIfdFlagsFromInfo(int info) { + return info >>> 24; + } + + /** + * Returns true if tag TID is one of the following: {@code TAG_EXIF_IFD}, {@code TAG_GPS_IFD}, + * {@code TAG_JPEG_INTERCHANGE_FORMAT}, {@code TAG_STRIP_OFFSETS}, {@code + * TAG_INTEROPERABILITY_IFD} + * + * <p>Note: defining tags with these TID's is disallowed. + * + * @param tag a tag's TID (can be obtained from a defined tag constant with {@link + * #getTrueTagKey}). + * @return true if the TID is that of an offset tag. + */ + static boolean isOffsetTag(short tag) { + return sOffsetTags.contains(tag); + } + + private SparseIntArray mTagInfo = null; + + SparseIntArray getTagInfo() { + if (mTagInfo == null) { + mTagInfo = new SparseIntArray(); + initTagInfo(); + } + return mTagInfo; + } + + private void initTagInfo() { + /** + * We put tag information in a 4-bytes integer. The first byte a bitmask representing the + * allowed IFDs of the tag, the second byte is the data type, and the last two byte are a short + * value indicating the default component count of this tag. + */ + // IFD0 tags + int[] ifdAllowedIfds = {IfdId.TYPE_IFD_0, IfdId.TYPE_IFD_1}; + int ifdFlags = getFlagsFromAllowedIfds(ifdAllowedIfds) << 24; + mTagInfo.put(ExifInterface.TAG_STRIP_OFFSETS, ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16); + mTagInfo.put(ExifInterface.TAG_EXIF_IFD, ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_IFD, ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_ORIENTATION, ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_STRIP_BYTE_COUNTS, ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16); + // IFD1 tags + int[] ifd1AllowedIfds = {IfdId.TYPE_IFD_1}; + int ifdFlags1 = getFlagsFromAllowedIfds(ifd1AllowedIfds) << 24; + mTagInfo.put( + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT, + ifdFlags1 | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put( + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH, + ifdFlags1 | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + // Exif tags + int[] exifAllowedIfds = {IfdId.TYPE_IFD_EXIF}; + int exifFlags = getFlagsFromAllowedIfds(exifAllowedIfds) << 24; + mTagInfo.put( + ExifInterface.TAG_INTEROPERABILITY_IFD, exifFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + } + + private static int getFlagsFromAllowedIfds(int[] allowedIfds) { + if (allowedIfds == null || allowedIfds.length == 0) { + return 0; + } + int flags = 0; + int[] ifds = IfdData.getIfds(); + for (int i = 0; i < IfdId.TYPE_IFD_COUNT; i++) { + for (int j : allowedIfds) { + if (ifds[i] == j) { + flags |= 1 << i; + break; + } + } + } + return flags; + } + + private Integer getTagIntValue(int tagId, int ifdId) { + int[] l = getTagIntValues(tagId, ifdId); + if (l == null || l.length <= 0) { + return null; + } + return l[0]; + } + + private int[] getTagIntValues(int tagId, int ifdId) { + ExifTag t = getTag(tagId, ifdId); + if (t == null) { + return null; + } + return t.getValueAsInts(); + } + + /** Gets an ExifTag for an IFD other than the tag's default. */ + public ExifTag getTag(int tagId, int ifdId) { + if (!ExifTag.isValidIfd(ifdId)) { + return null; + } + return mData.getTag(getTrueTagKey(tagId), ifdId); + } + + public Integer getTagIntValue(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagIntValue(tagId, ifdId); + } + + /** + * Gets the default IFD for a tag. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_EXIF_IFD}. + * @return the default IFD for a tag definition or {@link #IFD_NULL} if no definition exists. + */ + private int getDefinedTagDefaultIfd(int tagId) { + int info = getTagInfo().get(tagId); + if (info == DEFINITION_NULL) { + return IFD_NULL; + } + return getTrueIfd(tagId); + } + + /** Returns the default IFD for a tag constant. */ + private static int getTrueIfd(int tag) { + return tag >>> 16; + } + + /** + * Constants for {@code TAG_ORIENTATION}. They can be interpreted as follows: + * + * <ul> + * <li>TOP_LEFT is the normal orientation. + * <li>TOP_RIGHT is a left-right mirror. + * <li>BOTTOM_LEFT is a 180 degree rotation. + * <li>BOTTOM_RIGHT is a top-bottom mirror. + * <li>LEFT_TOP is mirrored about the top-left<->bottom-right axis. + * <li>RIGHT_TOP is a 90 degree clockwise rotation. + * <li>LEFT_BOTTOM is mirrored about the top-right<->bottom-left axis. + * <li>RIGHT_BOTTOM is a 270 degree clockwise rotation. + * </ul> + */ + interface Orientation { + short TOP_LEFT = 1; + short TOP_RIGHT = 2; + short BOTTOM_LEFT = 3; + short BOTTOM_RIGHT = 4; + short LEFT_TOP = 5; + short RIGHT_TOP = 6; + short LEFT_BOTTOM = 7; + short RIGHT_BOTTOM = 8; + } + + /** Wrapper class to define some orientation parameters. */ + public static class OrientationParams { + int rotation = 0; + int scaleX = 1; + int scaleY = 1; + public boolean invertDimensions = false; + } + + public static OrientationParams getOrientationParams(int orientation) { + OrientationParams params = new OrientationParams(); + switch (orientation) { + case Orientation.TOP_RIGHT: // Flip horizontal + params.scaleX = -1; + break; + case Orientation.BOTTOM_RIGHT: // Flip vertical + params.scaleY = -1; + break; + case Orientation.BOTTOM_LEFT: // Rotate 180 + params.rotation = 180; + break; + case Orientation.RIGHT_BOTTOM: // Rotate 270 + params.rotation = 270; + params.invertDimensions = true; + break; + case Orientation.RIGHT_TOP: // Rotate 90 + params.rotation = 90; + params.invertDimensions = true; + break; + case Orientation.LEFT_TOP: // Transpose + params.rotation = 90; + params.scaleX = -1; + params.invertDimensions = true; + break; + case Orientation.LEFT_BOTTOM: // Transverse + params.rotation = 270; + params.scaleX = -1; + params.invertDimensions = true; + break; + } + return params; + } + + /** Clears this ExifInterface object's existing exif tags. */ + public void clearExif() { + mData = new ExifData(); + } + + /** + * Puts an ExifTag into this ExifInterface object's tags, removing a previous ExifTag with the + * same TID and IFD. The IFD it is put into will be the one the tag was created with in {@link + * #buildTag}. + * + * @param tag an ExifTag to put into this ExifInterface's tags. + * @return the previous ExifTag with the same TID and IFD or null if none exists. + */ + public ExifTag setTag(ExifTag tag) { + return mData.addTag(tag); + } + + /** + * Returns the ExifTag in that tag's default IFD for a defined tag constant or null if none + * exists. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_EXIF_IFD}. + * @return an {@link ExifTag} or null if none exists. + */ + public ExifTag getTag(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTag(tagId, ifdId); + } + + /** + * Writes the tags from this ExifInterface object into a jpeg compressed bitmap, removing prior + * exif tags. + * + * @param bmap a bitmap to compress and write exif into. + * @param exifOutStream the OutputStream to which the jpeg image with added exif tags will be + * written. + * @throws java.io.IOException + */ + public void writeExif(Bitmap bmap, OutputStream exifOutStream) throws IOException { + if (bmap == null || exifOutStream == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + bmap.compress(Bitmap.CompressFormat.JPEG, 90, exifOutStream); + exifOutStream.flush(); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/ExifInvalidFormatException.java b/java/com/android/dialer/callcomposer/camera/exif/ExifInvalidFormatException.java new file mode 100644 index 000000000..92449d74f --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/ExifInvalidFormatException.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +/** Exception for invalid exif formats. */ +public class ExifInvalidFormatException extends Exception { + ExifInvalidFormatException(String meg) { + super(meg); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/ExifParser.java b/java/com/android/dialer/callcomposer/camera/exif/ExifParser.java new file mode 100644 index 000000000..23d748c17 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/ExifParser.java @@ -0,0 +1,846 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +import android.annotation.SuppressLint; +import com.android.dialer.common.LogUtil; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteOrder; +import java.nio.charset.Charset; +import java.util.Map.Entry; +import java.util.TreeMap; + +/** + * This class provides a low-level EXIF parsing API. Given a JPEG format InputStream, the caller can + * request which IFD's to read via {@link #parse(java.io.InputStream, int)} with given options. + * + * <p>Below is an example of getting EXIF data from IFD 0 and EXIF IFD using the parser. + * + * <pre> + * void parse() { + * ExifParser parser = ExifParser.parse(mImageInputStream, + * ExifParser.OPTION_IFD_0 | ExifParser.OPTIONS_IFD_EXIF); + * int event = parser.next(); + * while (event != ExifParser.EVENT_END) { + * switch (event) { + * case ExifParser.EVENT_START_OF_IFD: + * break; + * case ExifParser.EVENT_NEW_TAG: + * ExifTag tag = parser.getTag(); + * if (!tag.hasValue()) { + * parser.registerForTagValue(tag); + * } else { + * processTag(tag); + * } + * break; + * case ExifParser.EVENT_VALUE_OF_REGISTERED_TAG: + * tag = parser.getTag(); + * if (tag.getDataType() != ExifTag.TYPE_UNDEFINED) { + * processTag(tag); + * } + * break; + * } + * event = parser.next(); + * } + * } + * + * void processTag(ExifTag tag) { + * // process the tag as you like. + * } + * </pre> + */ +public class ExifParser { + private static final boolean LOGV = false; + /** + * When the parser reaches a new IFD area. Call {@link #getCurrentIfd()} to know which IFD we are + * in. + */ + static final int EVENT_START_OF_IFD = 0; + /** When the parser reaches a new tag. Call {@link #getTag()}to get the corresponding tag. */ + static final int EVENT_NEW_TAG = 1; + /** + * When the parser reaches the value area of tag that is registered by {@link + * #registerForTagValue(ExifTag)} previously. Call {@link #getTag()} to get the corresponding tag. + */ + static final int EVENT_VALUE_OF_REGISTERED_TAG = 2; + + /** When the parser reaches the compressed image area. */ + static final int EVENT_COMPRESSED_IMAGE = 3; + /** + * When the parser reaches the uncompressed image strip. Call {@link #getStripIndex()} to get the + * index of the strip. + * + * @see #getStripIndex() + */ + static final int EVENT_UNCOMPRESSED_STRIP = 4; + /** When there is nothing more to parse. */ + static final int EVENT_END = 5; + + /** Option bit to request to parse IFD0. */ + private static final int OPTION_IFD_0 = 1; + /** Option bit to request to parse IFD1. */ + private static final int OPTION_IFD_1 = 1 << 1; + /** Option bit to request to parse Exif-IFD. */ + private static final int OPTION_IFD_EXIF = 1 << 2; + /** Option bit to request to parse GPS-IFD. */ + private static final int OPTION_IFD_GPS = 1 << 3; + /** Option bit to request to parse Interoperability-IFD. */ + private static final int OPTION_IFD_INTEROPERABILITY = 1 << 4; + /** Option bit to request to parse thumbnail. */ + private static final int OPTION_THUMBNAIL = 1 << 5; + + private static final int EXIF_HEADER = 0x45786966; // EXIF header "Exif" + private static final short EXIF_HEADER_TAIL = (short) 0x0000; // EXIF header in APP1 + + // TIFF header + private static final short LITTLE_ENDIAN_TAG = (short) 0x4949; // "II" + private static final short BIG_ENDIAN_TAG = (short) 0x4d4d; // "MM" + private static final short TIFF_HEADER_TAIL = 0x002A; + + private static final int TAG_SIZE = 12; + private static final int OFFSET_SIZE = 2; + + private static final Charset US_ASCII = Charset.forName("US-ASCII"); + + private static final int DEFAULT_IFD0_OFFSET = 8; + + private final CountedDataInputStream mTiffStream; + private final int mOptions; + private int mIfdStartOffset = 0; + private int mNumOfTagInIfd = 0; + private int mIfdType; + private ExifTag mTag; + private ImageEvent mImageEvent; + private ExifTag mStripSizeTag; + private ExifTag mJpegSizeTag; + private boolean mNeedToParseOffsetsInCurrentIfd; + private boolean mContainExifData = false; + private int mApp1End; + private byte[] mDataAboveIfd0; + private int mIfd0Position; + private final ExifInterface mInterface; + + private static final short TAG_EXIF_IFD = ExifInterface.getTrueTagKey(ExifInterface.TAG_EXIF_IFD); + private static final short TAG_GPS_IFD = ExifInterface.getTrueTagKey(ExifInterface.TAG_GPS_IFD); + private static final short TAG_INTEROPERABILITY_IFD = + ExifInterface.getTrueTagKey(ExifInterface.TAG_INTEROPERABILITY_IFD); + private static final short TAG_JPEG_INTERCHANGE_FORMAT = + ExifInterface.getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT); + private static final short TAG_JPEG_INTERCHANGE_FORMAT_LENGTH = + ExifInterface.getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH); + private static final short TAG_STRIP_OFFSETS = + ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS); + private static final short TAG_STRIP_BYTE_COUNTS = + ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_BYTE_COUNTS); + + private final TreeMap<Integer, Object> mCorrespondingEvent = new TreeMap<>(); + + private boolean isIfdRequested(int ifdType) { + switch (ifdType) { + case IfdId.TYPE_IFD_0: + return (mOptions & OPTION_IFD_0) != 0; + case IfdId.TYPE_IFD_1: + return (mOptions & OPTION_IFD_1) != 0; + case IfdId.TYPE_IFD_EXIF: + return (mOptions & OPTION_IFD_EXIF) != 0; + case IfdId.TYPE_IFD_GPS: + return (mOptions & OPTION_IFD_GPS) != 0; + case IfdId.TYPE_IFD_INTEROPERABILITY: + return (mOptions & OPTION_IFD_INTEROPERABILITY) != 0; + } + return false; + } + + private boolean isThumbnailRequested() { + return (mOptions & OPTION_THUMBNAIL) != 0; + } + + private ExifParser(InputStream inputStream, int options, ExifInterface iRef) + throws IOException, ExifInvalidFormatException { + if (inputStream == null) { + throw new IOException("Null argument inputStream to ExifParser"); + } + if (LOGV) { + LogUtil.v("ExifParser.ExifParser", "Reading exif..."); + } + mInterface = iRef; + mContainExifData = seekTiffData(inputStream); + mTiffStream = new CountedDataInputStream(inputStream); + mOptions = options; + if (!mContainExifData) { + return; + } + + parseTiffHeader(); + long offset = mTiffStream.readUnsignedInt(); + if (offset > Integer.MAX_VALUE) { + throw new ExifInvalidFormatException("Invalid offset " + offset); + } + mIfd0Position = (int) offset; + mIfdType = IfdId.TYPE_IFD_0; + if (isIfdRequested(IfdId.TYPE_IFD_0) || needToParseOffsetsInCurrentIfd()) { + registerIfd(IfdId.TYPE_IFD_0, offset); + if (offset != DEFAULT_IFD0_OFFSET) { + mDataAboveIfd0 = new byte[(int) offset - DEFAULT_IFD0_OFFSET]; + read(mDataAboveIfd0); + } + } + } + + /** + * Parses the the given InputStream with the given options + * + * @exception java.io.IOException + * @exception ExifInvalidFormatException + */ + protected static ExifParser parse(InputStream inputStream, int options, ExifInterface iRef) + throws IOException, ExifInvalidFormatException { + return new ExifParser(inputStream, options, iRef); + } + + /** + * Parses the the given InputStream with default options; that is, every IFD and thumbnaill will + * be parsed. + * + * @exception java.io.IOException + * @exception ExifInvalidFormatException + * @see #parse(java.io.InputStream, int, ExifInterface) + */ + protected static ExifParser parse(InputStream inputStream, ExifInterface iRef) + throws IOException, ExifInvalidFormatException { + return new ExifParser( + inputStream, + OPTION_IFD_0 + | OPTION_IFD_1 + | OPTION_IFD_EXIF + | OPTION_IFD_GPS + | OPTION_IFD_INTEROPERABILITY + | OPTION_THUMBNAIL, + iRef); + } + + /** + * Moves the parser forward and returns the next parsing event + * + * @exception java.io.IOException + * @exception ExifInvalidFormatException + * @see #EVENT_START_OF_IFD + * @see #EVENT_NEW_TAG + * @see #EVENT_VALUE_OF_REGISTERED_TAG + * @see #EVENT_COMPRESSED_IMAGE + * @see #EVENT_UNCOMPRESSED_STRIP + * @see #EVENT_END + */ + protected int next() throws IOException, ExifInvalidFormatException { + if (!mContainExifData) { + return EVENT_END; + } + int offset = mTiffStream.getReadByteCount(); + int endOfTags = mIfdStartOffset + OFFSET_SIZE + TAG_SIZE * mNumOfTagInIfd; + if (offset < endOfTags) { + mTag = readTag(); + if (mTag == null) { + return next(); + } + if (mNeedToParseOffsetsInCurrentIfd) { + checkOffsetOrImageTag(mTag); + } + return EVENT_NEW_TAG; + } else if (offset == endOfTags) { + // There is a link to ifd1 at the end of ifd0 + if (mIfdType == IfdId.TYPE_IFD_0) { + long ifdOffset = readUnsignedLong(); + if (isIfdRequested(IfdId.TYPE_IFD_1) || isThumbnailRequested()) { + if (ifdOffset != 0) { + registerIfd(IfdId.TYPE_IFD_1, ifdOffset); + } + } + } else { + int offsetSize = 4; + // Some camera models use invalid length of the offset + if (mCorrespondingEvent.size() > 0) { + offsetSize = mCorrespondingEvent.firstEntry().getKey() - mTiffStream.getReadByteCount(); + } + if (offsetSize < 4) { + LogUtil.i("ExifParser.next", "Invalid size of link to next IFD: " + offsetSize); + } else { + long ifdOffset = readUnsignedLong(); + if (ifdOffset != 0) { + LogUtil.i("ExifParser.next", "Invalid link to next IFD: " + ifdOffset); + } + } + } + } + while (mCorrespondingEvent.size() != 0) { + Entry<Integer, Object> entry = mCorrespondingEvent.pollFirstEntry(); + Object event = entry.getValue(); + try { + skipTo(entry.getKey()); + } catch (IOException e) { + LogUtil.i( + "ExifParser.next", + "Failed to skip to data at: " + + entry.getKey() + + " for " + + event.getClass().getName() + + ", the file may be broken."); + continue; + } + if (event instanceof IfdEvent) { + mIfdType = ((IfdEvent) event).ifd; + mNumOfTagInIfd = mTiffStream.readUnsignedShort(); + mIfdStartOffset = entry.getKey(); + + if (mNumOfTagInIfd * TAG_SIZE + mIfdStartOffset + OFFSET_SIZE > mApp1End) { + LogUtil.i("ExifParser.next", "Invalid size of IFD " + mIfdType); + return EVENT_END; + } + + mNeedToParseOffsetsInCurrentIfd = needToParseOffsetsInCurrentIfd(); + if (((IfdEvent) event).isRequested) { + return EVENT_START_OF_IFD; + } else { + skipRemainingTagsInCurrentIfd(); + } + } else if (event instanceof ImageEvent) { + mImageEvent = (ImageEvent) event; + return mImageEvent.type; + } else { + ExifTagEvent tagEvent = (ExifTagEvent) event; + mTag = tagEvent.tag; + if (mTag.getDataType() != ExifTag.TYPE_UNDEFINED) { + readFullTagValue(mTag); + checkOffsetOrImageTag(mTag); + } + if (tagEvent.isRequested) { + return EVENT_VALUE_OF_REGISTERED_TAG; + } + } + } + return EVENT_END; + } + + /** + * Skips the tags area of current IFD, if the parser is not in the tag area, nothing will happen. + * + * @throws java.io.IOException + * @throws ExifInvalidFormatException + */ + private void skipRemainingTagsInCurrentIfd() throws IOException, ExifInvalidFormatException { + int endOfTags = mIfdStartOffset + OFFSET_SIZE + TAG_SIZE * mNumOfTagInIfd; + int offset = mTiffStream.getReadByteCount(); + if (offset > endOfTags) { + return; + } + if (mNeedToParseOffsetsInCurrentIfd) { + while (offset < endOfTags) { + mTag = readTag(); + offset += TAG_SIZE; + if (mTag == null) { + continue; + } + checkOffsetOrImageTag(mTag); + } + } else { + skipTo(endOfTags); + } + long ifdOffset = readUnsignedLong(); + // For ifd0, there is a link to ifd1 in the end of all tags + if (mIfdType == IfdId.TYPE_IFD_0 + && (isIfdRequested(IfdId.TYPE_IFD_1) || isThumbnailRequested())) { + if (ifdOffset > 0) { + registerIfd(IfdId.TYPE_IFD_1, ifdOffset); + } + } + } + + private boolean needToParseOffsetsInCurrentIfd() { + switch (mIfdType) { + case IfdId.TYPE_IFD_0: + return isIfdRequested(IfdId.TYPE_IFD_EXIF) + || isIfdRequested(IfdId.TYPE_IFD_GPS) + || isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY) + || isIfdRequested(IfdId.TYPE_IFD_1); + case IfdId.TYPE_IFD_1: + return isThumbnailRequested(); + case IfdId.TYPE_IFD_EXIF: + // The offset to interoperability IFD is located in Exif IFD + return isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY); + default: + return false; + } + } + + /** + * If {@link #next()} return {@link #EVENT_NEW_TAG} or {@link #EVENT_VALUE_OF_REGISTERED_TAG}, + * call this function to get the corresponding tag. + * + * <p>For {@link #EVENT_NEW_TAG}, the tag may not contain the value if the size of the value is + * greater than 4 bytes. One should call {@link ExifTag#hasValue()} to check if the tag contains + * value. If there is no value,call {@link #registerForTagValue(ExifTag)} to have the parser emit + * {@link #EVENT_VALUE_OF_REGISTERED_TAG} when it reaches the area pointed by the offset. + * + * <p>When {@link #EVENT_VALUE_OF_REGISTERED_TAG} is emitted, the value of the tag will have + * already been read except for tags of undefined type. For tags of undefined type, call one of + * the read methods to get the value. + * + * @see #registerForTagValue(ExifTag) + * @see #read(byte[]) + * @see #read(byte[], int, int) + * @see #readLong() + * @see #readRational() + * @see #readString(int) + * @see #readString(int, java.nio.charset.Charset) + */ + protected ExifTag getTag() { + return mTag; + } + + /** + * Gets the ID of current IFD. + * + * @see IfdId#TYPE_IFD_0 + * @see IfdId#TYPE_IFD_1 + * @see IfdId#TYPE_IFD_GPS + * @see IfdId#TYPE_IFD_INTEROPERABILITY + * @see IfdId#TYPE_IFD_EXIF + */ + int getCurrentIfd() { + return mIfdType; + } + + /** + * When receiving {@link #EVENT_UNCOMPRESSED_STRIP}, call this function to get the index of this + * strip. + */ + int getStripIndex() { + return mImageEvent.stripIndex; + } + + /** When receiving {@link #EVENT_UNCOMPRESSED_STRIP}, call this function to get the strip size. */ + int getStripSize() { + if (mStripSizeTag == null) { + return 0; + } + return (int) mStripSizeTag.getValueAt(0); + } + + /** + * When receiving {@link #EVENT_COMPRESSED_IMAGE}, call this function to get the image data size. + */ + int getCompressedImageSize() { + if (mJpegSizeTag == null) { + return 0; + } + return (int) mJpegSizeTag.getValueAt(0); + } + + private void skipTo(int offset) throws IOException { + mTiffStream.skipTo(offset); + while (!mCorrespondingEvent.isEmpty() && mCorrespondingEvent.firstKey() < offset) { + mCorrespondingEvent.pollFirstEntry(); + } + } + + /** + * When getting {@link #EVENT_NEW_TAG} in the tag area of IFD, the tag may not contain the value + * if the size of the value is greater than 4 bytes. When the value is not available here, call + * this method so that the parser will emit {@link #EVENT_VALUE_OF_REGISTERED_TAG} when it reaches + * the area where the value is located. + * + * @see #EVENT_VALUE_OF_REGISTERED_TAG + */ + void registerForTagValue(ExifTag tag) { + if (tag.getOffset() >= mTiffStream.getReadByteCount()) { + mCorrespondingEvent.put(tag.getOffset(), new ExifTagEvent(tag, true)); + } + } + + private void registerIfd(int ifdType, long offset) { + // Cast unsigned int to int since the offset is always smaller + // than the size of APP1 (65536) + mCorrespondingEvent.put((int) offset, new IfdEvent(ifdType, isIfdRequested(ifdType))); + } + + private void registerCompressedImage(long offset) { + mCorrespondingEvent.put((int) offset, new ImageEvent(EVENT_COMPRESSED_IMAGE)); + } + + private void registerUncompressedStrip(int stripIndex, long offset) { + mCorrespondingEvent.put((int) offset, new ImageEvent(EVENT_UNCOMPRESSED_STRIP, stripIndex)); + } + + @SuppressLint("DefaultLocale") + private ExifTag readTag() throws IOException, ExifInvalidFormatException { + short tagId = mTiffStream.readShort(); + short dataFormat = mTiffStream.readShort(); + long numOfComp = mTiffStream.readUnsignedInt(); + if (numOfComp > Integer.MAX_VALUE) { + throw new ExifInvalidFormatException("Number of component is larger then Integer.MAX_VALUE"); + } + // Some invalid image file contains invalid data type. Ignore those tags + if (!ExifTag.isValidType(dataFormat)) { + LogUtil.i("ExifParser.readTag", "Tag %04x: Invalid data type %d", tagId, dataFormat); + mTiffStream.skip(4); + return null; + } + // TODO: handle numOfComp overflow + ExifTag tag = + new ExifTag( + tagId, + dataFormat, + (int) numOfComp, + mIfdType, + ((int) numOfComp) != ExifTag.SIZE_UNDEFINED); + int dataSize = tag.getDataSize(); + if (dataSize > 4) { + long offset = mTiffStream.readUnsignedInt(); + if (offset > Integer.MAX_VALUE) { + throw new ExifInvalidFormatException("offset is larger then Integer.MAX_VALUE"); + } + // Some invalid images put some undefined data before IFD0. + // Read the data here. + if ((offset < mIfd0Position) && (dataFormat == ExifTag.TYPE_UNDEFINED)) { + byte[] buf = new byte[(int) numOfComp]; + System.arraycopy( + mDataAboveIfd0, (int) offset - DEFAULT_IFD0_OFFSET, buf, 0, (int) numOfComp); + tag.setValue(buf); + } else { + tag.setOffset((int) offset); + } + } else { + boolean defCount = tag.hasDefinedCount(); + // Set defined count to 0 so we can add \0 to non-terminated strings + tag.setHasDefinedCount(false); + // Read value + readFullTagValue(tag); + tag.setHasDefinedCount(defCount); + mTiffStream.skip(4 - dataSize); + // Set the offset to the position of value. + tag.setOffset(mTiffStream.getReadByteCount() - 4); + } + return tag; + } + + /** + * Check the if the tag is one of the offset tag that points to the IFD or image the caller is + * interested in, register the IFD or image. + */ + private void checkOffsetOrImageTag(ExifTag tag) { + // Some invalid formattd image contains tag with 0 size. + if (tag.getComponentCount() == 0) { + return; + } + short tid = tag.getTagId(); + int ifd = tag.getIfd(); + if (tid == TAG_EXIF_IFD && checkAllowed(ifd, ExifInterface.TAG_EXIF_IFD)) { + if (isIfdRequested(IfdId.TYPE_IFD_EXIF) || isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY)) { + registerIfd(IfdId.TYPE_IFD_EXIF, tag.getValueAt(0)); + } + } else if (tid == TAG_GPS_IFD && checkAllowed(ifd, ExifInterface.TAG_GPS_IFD)) { + if (isIfdRequested(IfdId.TYPE_IFD_GPS)) { + registerIfd(IfdId.TYPE_IFD_GPS, tag.getValueAt(0)); + } + } else if (tid == TAG_INTEROPERABILITY_IFD + && checkAllowed(ifd, ExifInterface.TAG_INTEROPERABILITY_IFD)) { + if (isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY)) { + registerIfd(IfdId.TYPE_IFD_INTEROPERABILITY, tag.getValueAt(0)); + } + } else if (tid == TAG_JPEG_INTERCHANGE_FORMAT + && checkAllowed(ifd, ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT)) { + if (isThumbnailRequested()) { + registerCompressedImage(tag.getValueAt(0)); + } + } else if (tid == TAG_JPEG_INTERCHANGE_FORMAT_LENGTH + && checkAllowed(ifd, ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH)) { + if (isThumbnailRequested()) { + mJpegSizeTag = tag; + } + } else if (tid == TAG_STRIP_OFFSETS && checkAllowed(ifd, ExifInterface.TAG_STRIP_OFFSETS)) { + if (isThumbnailRequested()) { + if (tag.hasValue()) { + for (int i = 0; i < tag.getComponentCount(); i++) { + if (tag.getDataType() == ExifTag.TYPE_UNSIGNED_SHORT) { + registerUncompressedStrip(i, tag.getValueAt(i)); + } else { + registerUncompressedStrip(i, tag.getValueAt(i)); + } + } + } else { + mCorrespondingEvent.put(tag.getOffset(), new ExifTagEvent(tag, false)); + } + } + } else if (tid == TAG_STRIP_BYTE_COUNTS + && checkAllowed(ifd, ExifInterface.TAG_STRIP_BYTE_COUNTS) + && isThumbnailRequested() + && tag.hasValue()) { + mStripSizeTag = tag; + } + } + + private boolean checkAllowed(int ifd, int tagId) { + int info = mInterface.getTagInfo().get(tagId); + return info != ExifInterface.DEFINITION_NULL && ExifInterface.isIfdAllowed(info, ifd); + } + + void readFullTagValue(ExifTag tag) throws IOException { + // Some invalid images contains tags with wrong size, check it here + short type = tag.getDataType(); + if (type == ExifTag.TYPE_ASCII + || type == ExifTag.TYPE_UNDEFINED + || type == ExifTag.TYPE_UNSIGNED_BYTE) { + int size = tag.getComponentCount(); + if (mCorrespondingEvent.size() > 0) { + if (mCorrespondingEvent.firstEntry().getKey() < mTiffStream.getReadByteCount() + size) { + Object event = mCorrespondingEvent.firstEntry().getValue(); + if (event instanceof ImageEvent) { + // Tag value overlaps thumbnail, ignore thumbnail. + LogUtil.i( + "ExifParser.readFullTagValue", + "Thumbnail overlaps value for tag: \n" + tag.toString()); + Entry<Integer, Object> entry = mCorrespondingEvent.pollFirstEntry(); + LogUtil.i("ExifParser.readFullTagValue", "Invalid thumbnail offset: " + entry.getKey()); + } else { + // Tag value overlaps another shorten count + if (event instanceof IfdEvent) { + LogUtil.i( + "ExifParser.readFullTagValue", + "Ifd " + ((IfdEvent) event).ifd + " overlaps value for tag: \n" + tag.toString()); + } else if (event instanceof ExifTagEvent) { + LogUtil.i( + "ExifParser.readFullTagValue", + "Tag value for tag: \n" + + ((ExifTagEvent) event).tag.toString() + + " overlaps value for tag: \n" + + tag.toString()); + } + size = mCorrespondingEvent.firstEntry().getKey() - mTiffStream.getReadByteCount(); + LogUtil.i( + "ExifParser.readFullTagValue", + "Invalid size of tag: \n" + tag.toString() + " setting count to: " + size); + tag.forceSetComponentCount(size); + } + } + } + } + switch (tag.getDataType()) { + case ExifTag.TYPE_UNSIGNED_BYTE: + case ExifTag.TYPE_UNDEFINED: + { + byte[] buf = new byte[tag.getComponentCount()]; + read(buf); + tag.setValue(buf); + } + break; + case ExifTag.TYPE_ASCII: + tag.setValue(readString(tag.getComponentCount())); + break; + case ExifTag.TYPE_UNSIGNED_LONG: + { + long[] value = new long[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readUnsignedLong(); + } + tag.setValue(value); + } + break; + case ExifTag.TYPE_UNSIGNED_RATIONAL: + { + Rational[] value = new Rational[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readUnsignedRational(); + } + tag.setValue(value); + } + break; + case ExifTag.TYPE_UNSIGNED_SHORT: + { + int[] value = new int[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readUnsignedShort(); + } + tag.setValue(value); + } + break; + case ExifTag.TYPE_LONG: + { + int[] value = new int[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readLong(); + } + tag.setValue(value); + } + break; + case ExifTag.TYPE_RATIONAL: + { + Rational[] value = new Rational[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readRational(); + } + tag.setValue(value); + } + break; + } + if (LOGV) { + LogUtil.v("ExifParser.readFullTagValue", "\n" + tag.toString()); + } + } + + private void parseTiffHeader() throws IOException, ExifInvalidFormatException { + short byteOrder = mTiffStream.readShort(); + if (LITTLE_ENDIAN_TAG == byteOrder) { + mTiffStream.setByteOrder(ByteOrder.LITTLE_ENDIAN); + } else if (BIG_ENDIAN_TAG == byteOrder) { + mTiffStream.setByteOrder(ByteOrder.BIG_ENDIAN); + } else { + throw new ExifInvalidFormatException("Invalid TIFF header"); + } + + if (mTiffStream.readShort() != TIFF_HEADER_TAIL) { + throw new ExifInvalidFormatException("Invalid TIFF header"); + } + } + + private boolean seekTiffData(InputStream inputStream) + throws IOException, ExifInvalidFormatException { + CountedDataInputStream dataStream = new CountedDataInputStream(inputStream); + if (dataStream.readShort() != JpegHeader.SOI) { + throw new ExifInvalidFormatException("Invalid JPEG format"); + } + + short marker = dataStream.readShort(); + while (marker != JpegHeader.EOI && !JpegHeader.isSofMarker(marker)) { + int length = dataStream.readUnsignedShort(); + // Some invalid formatted image contains multiple APP1, + // try to find the one with Exif data. + if (marker == JpegHeader.APP1) { + int header; + short headerTail; + if (length >= 8) { + header = dataStream.readInt(); + headerTail = dataStream.readShort(); + length -= 6; + if (header == EXIF_HEADER && headerTail == EXIF_HEADER_TAIL) { + mApp1End = length; + return true; + } + } + } + if (length < 2 || (length - 2) != dataStream.skip(length - 2)) { + LogUtil.i("ExifParser.seekTiffData", "Invalid JPEG format."); + return false; + } + marker = dataStream.readShort(); + } + return false; + } + + /** Reads bytes from the InputStream. */ + protected int read(byte[] buffer, int offset, int length) throws IOException { + return mTiffStream.read(buffer, offset, length); + } + + /** Equivalent to read(buffer, 0, buffer.length). */ + protected int read(byte[] buffer) throws IOException { + return mTiffStream.read(buffer); + } + + /** + * Reads a String from the InputStream with US-ASCII charset. The parser will read n bytes and + * convert it to ascii string. This is used for reading values of type {@link ExifTag#TYPE_ASCII}. + */ + private String readString(int n) throws IOException { + return readString(n, US_ASCII); + } + + /** + * Reads a String from the InputStream with the given charset. The parser will read n bytes and + * convert it to string. This is used for reading values of type {@link ExifTag#TYPE_ASCII}. + */ + private String readString(int n, Charset charset) throws IOException { + if (n > 0) { + return mTiffStream.readString(n, charset); + } else { + return ""; + } + } + + /** Reads value of type {@link ExifTag#TYPE_UNSIGNED_SHORT} from the InputStream. */ + private int readUnsignedShort() throws IOException { + return mTiffStream.readShort() & 0xffff; + } + + /** Reads value of type {@link ExifTag#TYPE_UNSIGNED_LONG} from the InputStream. */ + private long readUnsignedLong() throws IOException { + return readLong() & 0xffffffffL; + } + + /** Reads value of type {@link ExifTag#TYPE_UNSIGNED_RATIONAL} from the InputStream. */ + private Rational readUnsignedRational() throws IOException { + long nomi = readUnsignedLong(); + long denomi = readUnsignedLong(); + return new Rational(nomi, denomi); + } + + /** Reads value of type {@link ExifTag#TYPE_LONG} from the InputStream. */ + private int readLong() throws IOException { + return mTiffStream.readInt(); + } + + /** Reads value of type {@link ExifTag#TYPE_RATIONAL} from the InputStream. */ + private Rational readRational() throws IOException { + int nomi = readLong(); + int denomi = readLong(); + return new Rational(nomi, denomi); + } + + private static class ImageEvent { + int stripIndex; + int type; + + ImageEvent(int type) { + this.stripIndex = 0; + this.type = type; + } + + ImageEvent(int type, int stripIndex) { + this.type = type; + this.stripIndex = stripIndex; + } + } + + private static class IfdEvent { + int ifd; + boolean isRequested; + + IfdEvent(int ifd, boolean isInterestedIfd) { + this.ifd = ifd; + this.isRequested = isInterestedIfd; + } + } + + private static class ExifTagEvent { + ExifTag tag; + boolean isRequested; + + ExifTagEvent(ExifTag tag, boolean isRequireByUser) { + this.tag = tag; + this.isRequested = isRequireByUser; + } + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/ExifReader.java b/java/com/android/dialer/callcomposer/camera/exif/ExifReader.java new file mode 100644 index 000000000..89d212661 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/ExifReader.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +import com.android.dialer.common.LogUtil; +import java.io.IOException; +import java.io.InputStream; + +/** This class reads the EXIF header of a JPEG file and stores it in {@link ExifData}. */ +class ExifReader { + + private final ExifInterface mInterface; + + ExifReader(ExifInterface iRef) { + mInterface = iRef; + } + + /** + * Parses the inputStream and and returns the EXIF data in an {@link ExifData}. + * + * @throws ExifInvalidFormatException + * @throws java.io.IOException + */ + protected ExifData read(InputStream inputStream) throws ExifInvalidFormatException, IOException { + ExifParser parser = ExifParser.parse(inputStream, mInterface); + ExifData exifData = new ExifData(); + ExifTag tag; + + int event = parser.next(); + while (event != ExifParser.EVENT_END) { + switch (event) { + case ExifParser.EVENT_START_OF_IFD: + exifData.addIfdData(new IfdData(parser.getCurrentIfd())); + break; + case ExifParser.EVENT_NEW_TAG: + tag = parser.getTag(); + if (!tag.hasValue()) { + parser.registerForTagValue(tag); + } else { + exifData.getIfdData(tag.getIfd()).setTag(tag); + } + break; + case ExifParser.EVENT_VALUE_OF_REGISTERED_TAG: + tag = parser.getTag(); + if (tag.getDataType() == ExifTag.TYPE_UNDEFINED) { + parser.readFullTagValue(tag); + } + exifData.getIfdData(tag.getIfd()).setTag(tag); + break; + case ExifParser.EVENT_COMPRESSED_IMAGE: + byte[] buf = new byte[parser.getCompressedImageSize()]; + if (buf.length != parser.read(buf)) { + LogUtil.i("ExifReader.read", "Failed to read the compressed thumbnail"); + } + break; + case ExifParser.EVENT_UNCOMPRESSED_STRIP: + buf = new byte[parser.getStripSize()]; + if (buf.length != parser.read(buf)) { + LogUtil.i("ExifReader.read", "Failed to read the strip bytes"); + } + break; + } + event = parser.next(); + } + return exifData; + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/ExifTag.java b/java/com/android/dialer/callcomposer/camera/exif/ExifTag.java new file mode 100644 index 000000000..a254ae93b --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/ExifTag.java @@ -0,0 +1,619 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Objects; + +/** + * This class stores information of an EXIF tag. For more information about defined EXIF tags, + * please read the Jeita EXIF 2.2 standard. Tags should be instantiated using {@link + * ExifInterface#buildTag}. + * + * @see ExifInterface + */ +public class ExifTag { + /** The BYTE type in the EXIF standard. An 8-bit unsigned integer. */ + static final short TYPE_UNSIGNED_BYTE = 1; + /** + * The ASCII type in the EXIF standard. An 8-bit byte containing one 7-bit ASCII code. The final + * byte is terminated with NULL. + */ + static final short TYPE_ASCII = 2; + /** The SHORT type in the EXIF standard. A 16-bit (2-byte) unsigned integer */ + static final short TYPE_UNSIGNED_SHORT = 3; + /** The LONG type in the EXIF standard. A 32-bit (4-byte) unsigned integer */ + static final short TYPE_UNSIGNED_LONG = 4; + /** + * The RATIONAL type of EXIF standard. It consists of two LONGs. The first one is the numerator + * and the second one expresses the denominator. + */ + static final short TYPE_UNSIGNED_RATIONAL = 5; + /** + * The UNDEFINED type in the EXIF standard. An 8-bit byte that can take any value depending on the + * field definition. + */ + static final short TYPE_UNDEFINED = 7; + /** + * The SLONG type in the EXIF standard. A 32-bit (4-byte) signed integer (2's complement + * notation). + */ + static final short TYPE_LONG = 9; + /** + * The SRATIONAL type of EXIF standard. It consists of two SLONGs. The first one is the numerator + * and the second one is the denominator. + */ + static final short TYPE_RATIONAL = 10; + + private static final Charset US_ASCII = Charset.forName("US-ASCII"); + private static final int[] TYPE_TO_SIZE_MAP = new int[11]; + private static final int UNSIGNED_SHORT_MAX = 65535; + private static final long UNSIGNED_LONG_MAX = 4294967295L; + private static final long LONG_MAX = Integer.MAX_VALUE; + private static final long LONG_MIN = Integer.MIN_VALUE; + + static { + TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_BYTE] = 1; + TYPE_TO_SIZE_MAP[TYPE_ASCII] = 1; + TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_SHORT] = 2; + TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_LONG] = 4; + TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_RATIONAL] = 8; + TYPE_TO_SIZE_MAP[TYPE_UNDEFINED] = 1; + TYPE_TO_SIZE_MAP[TYPE_LONG] = 4; + TYPE_TO_SIZE_MAP[TYPE_RATIONAL] = 8; + } + + static final int SIZE_UNDEFINED = 0; + + // Exif TagId + private final short mTagId; + // Exif Tag Type + private final short mDataType; + // If tag has defined count + private boolean mHasDefinedDefaultComponentCount; + // Actual data count in tag (should be number of elements in value array) + private int mComponentCountActual; + // The ifd that this tag should be put in + private int mIfd; + // The value (array of elements of type Tag Type) + private Object mValue; + // Value offset in exif header. + private int mOffset; + + /** Returns true if the given IFD is a valid IFD. */ + static boolean isValidIfd(int ifdId) { + return ifdId == IfdId.TYPE_IFD_0 + || ifdId == IfdId.TYPE_IFD_1 + || ifdId == IfdId.TYPE_IFD_EXIF + || ifdId == IfdId.TYPE_IFD_INTEROPERABILITY + || ifdId == IfdId.TYPE_IFD_GPS; + } + + /** Returns true if a given type is a valid tag type. */ + static boolean isValidType(short type) { + return type == TYPE_UNSIGNED_BYTE + || type == TYPE_ASCII + || type == TYPE_UNSIGNED_SHORT + || type == TYPE_UNSIGNED_LONG + || type == TYPE_UNSIGNED_RATIONAL + || type == TYPE_UNDEFINED + || type == TYPE_LONG + || type == TYPE_RATIONAL; + } + + // Use builtTag in ExifInterface instead of constructor. + ExifTag(short tagId, short type, int componentCount, int ifd, boolean hasDefinedComponentCount) { + mTagId = tagId; + mDataType = type; + mComponentCountActual = componentCount; + mHasDefinedDefaultComponentCount = hasDefinedComponentCount; + mIfd = ifd; + mValue = null; + } + + /** + * Gets the element size of the given data type in bytes. + * + * @see #TYPE_ASCII + * @see #TYPE_LONG + * @see #TYPE_RATIONAL + * @see #TYPE_UNDEFINED + * @see #TYPE_UNSIGNED_BYTE + * @see #TYPE_UNSIGNED_LONG + * @see #TYPE_UNSIGNED_RATIONAL + * @see #TYPE_UNSIGNED_SHORT + */ + private static int getElementSize(short type) { + return TYPE_TO_SIZE_MAP[type]; + } + + /** + * Returns the ID of the IFD this tag belongs to. + * + * @see IfdId#TYPE_IFD_0 + * @see IfdId#TYPE_IFD_1 + * @see IfdId#TYPE_IFD_EXIF + * @see IfdId#TYPE_IFD_GPS + * @see IfdId#TYPE_IFD_INTEROPERABILITY + */ + int getIfd() { + return mIfd; + } + + void setIfd(int ifdId) { + mIfd = ifdId; + } + + /** Gets the TID of this tag. */ + short getTagId() { + return mTagId; + } + + /** + * Gets the data type of this tag + * + * @see #TYPE_ASCII + * @see #TYPE_LONG + * @see #TYPE_RATIONAL + * @see #TYPE_UNDEFINED + * @see #TYPE_UNSIGNED_BYTE + * @see #TYPE_UNSIGNED_LONG + * @see #TYPE_UNSIGNED_RATIONAL + * @see #TYPE_UNSIGNED_SHORT + */ + short getDataType() { + return mDataType; + } + + /** Gets the total data size in bytes of the value of this tag. */ + int getDataSize() { + return getComponentCount() * getElementSize(getDataType()); + } + + /** Gets the component count of this tag. */ + + // TODO: fix integer overflows with this + int getComponentCount() { + return mComponentCountActual; + } + + /** + * Sets the component count of this tag. Call this function before setValue() if the length of + * value does not match the component count. + */ + void forceSetComponentCount(int count) { + mComponentCountActual = count; + } + + /** + * Returns true if this ExifTag contains value; otherwise, this tag will contain an offset value + * that is determined when the tag is written. + */ + boolean hasValue() { + return mValue != null; + } + + /** + * Sets integer values into this tag. This method should be used for tags of type {@link + * #TYPE_UNSIGNED_SHORT}. This method will fail if: + * + * <ul> + * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_SHORT}, {@link + * #TYPE_UNSIGNED_LONG}, or {@link #TYPE_LONG}. + * <li>The value overflows. + * <li>The value.length does NOT match the component count in the definition for this tag. + * </ul> + */ + boolean setValue(int[] value) { + if (checkBadComponentCount(value.length)) { + return false; + } + if (mDataType != TYPE_UNSIGNED_SHORT + && mDataType != TYPE_LONG + && mDataType != TYPE_UNSIGNED_LONG) { + return false; + } + if (mDataType == TYPE_UNSIGNED_SHORT && checkOverflowForUnsignedShort(value)) { + return false; + } else if (mDataType == TYPE_UNSIGNED_LONG && checkOverflowForUnsignedLong(value)) { + return false; + } + + long[] data = new long[value.length]; + for (int i = 0; i < value.length; i++) { + data[i] = value[i]; + } + mValue = data; + mComponentCountActual = value.length; + return true; + } + + /** + * Sets long values into this tag. This method should be used for tags of type {@link + * #TYPE_UNSIGNED_LONG}. This method will fail if: + * + * <ul> + * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_LONG}. + * <li>The value overflows. + * <li>The value.length does NOT match the component count in the definition for this tag. + * </ul> + */ + boolean setValue(long[] value) { + if (checkBadComponentCount(value.length) || mDataType != TYPE_UNSIGNED_LONG) { + return false; + } + if (checkOverflowForUnsignedLong(value)) { + return false; + } + mValue = value; + mComponentCountActual = value.length; + return true; + } + + /** + * Sets a string value into this tag. This method should be used for tags of type {@link + * #TYPE_ASCII}. The string is converted to an ASCII string. Characters that cannot be converted + * are replaced with '?'. The length of the string must be equal to either (component count -1) or + * (component count). The final byte will be set to the string null terminator '\0', overwriting + * the last character in the string if the value.length is equal to the component count. This + * method will fail if: + * + * <ul> + * <li>The data type is not {@link #TYPE_ASCII} or {@link #TYPE_UNDEFINED}. + * <li>The length of the string is not equal to (component count -1) or (component count) in the + * definition for this tag. + * </ul> + */ + boolean setValue(String value) { + if (mDataType != TYPE_ASCII && mDataType != TYPE_UNDEFINED) { + return false; + } + + byte[] buf = value.getBytes(US_ASCII); + byte[] finalBuf = buf; + if (buf.length > 0) { + finalBuf = + (buf[buf.length - 1] == 0 || mDataType == TYPE_UNDEFINED) + ? buf + : Arrays.copyOf(buf, buf.length + 1); + } else if (mDataType == TYPE_ASCII && mComponentCountActual == 1) { + finalBuf = new byte[] {0}; + } + int count = finalBuf.length; + if (checkBadComponentCount(count)) { + return false; + } + mComponentCountActual = count; + mValue = finalBuf; + return true; + } + + /** + * Sets Rational values into this tag. This method should be used for tags of type {@link + * #TYPE_UNSIGNED_RATIONAL}, or {@link #TYPE_RATIONAL}. This method will fail if: + * + * <ul> + * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_RATIONAL} or {@link + * #TYPE_RATIONAL}. + * <li>The value overflows. + * <li>The value.length does NOT match the component count in the definition for this tag. + * </ul> + * + * @see Rational + */ + boolean setValue(Rational[] value) { + if (checkBadComponentCount(value.length)) { + return false; + } + if (mDataType != TYPE_UNSIGNED_RATIONAL && mDataType != TYPE_RATIONAL) { + return false; + } + if (mDataType == TYPE_UNSIGNED_RATIONAL && checkOverflowForUnsignedRational(value)) { + return false; + } else if (mDataType == TYPE_RATIONAL && checkOverflowForRational(value)) { + return false; + } + + mValue = value; + mComponentCountActual = value.length; + return true; + } + + /** + * Sets byte values into this tag. This method should be used for tags of type {@link + * #TYPE_UNSIGNED_BYTE} or {@link #TYPE_UNDEFINED}. This method will fail if: + * + * <ul> + * <li>The component type of this tag is not {@link #TYPE_UNSIGNED_BYTE} or {@link + * #TYPE_UNDEFINED} . + * <li>The length does NOT match the component count in the definition for this tag. + * </ul> + */ + private boolean setValue(byte[] value, int offset, int length) { + if (checkBadComponentCount(length)) { + return false; + } + if (mDataType != TYPE_UNSIGNED_BYTE && mDataType != TYPE_UNDEFINED) { + return false; + } + mValue = new byte[length]; + System.arraycopy(value, offset, mValue, 0, length); + mComponentCountActual = length; + return true; + } + + /** Equivalent to setValue(value, 0, value.length). */ + boolean setValue(byte[] value) { + return setValue(value, 0, value.length); + } + + /** + * Gets the value as an array of ints. This method should be used for tags of type {@link + * #TYPE_UNSIGNED_SHORT}, {@link #TYPE_UNSIGNED_LONG}. + * + * @return the value as as an array of ints, or null if the tag's value does not exist or cannot + * be converted to an array of ints. + */ + int[] getValueAsInts() { + if (mValue == null) { + return null; + } else if (mValue instanceof long[]) { + long[] val = (long[]) mValue; + int[] arr = new int[val.length]; + for (int i = 0; i < val.length; i++) { + arr[i] = (int) val[i]; // Truncates + } + return arr; + } + return null; + } + + /** Gets the tag's value or null if none exists. */ + public Object getValue() { + return mValue; + } + + /** Gets a string representation of the value. */ + private String forceGetValueAsString() { + if (mValue == null) { + return ""; + } else if (mValue instanceof byte[]) { + if (mDataType == TYPE_ASCII) { + return new String((byte[]) mValue, US_ASCII); + } else { + return Arrays.toString((byte[]) mValue); + } + } else if (mValue instanceof long[]) { + if (((long[]) mValue).length == 1) { + return String.valueOf(((long[]) mValue)[0]); + } else { + return Arrays.toString((long[]) mValue); + } + } else if (mValue instanceof Object[]) { + if (((Object[]) mValue).length == 1) { + Object val = ((Object[]) mValue)[0]; + if (val == null) { + return ""; + } else { + return val.toString(); + } + } else { + return Arrays.toString((Object[]) mValue); + } + } else { + return mValue.toString(); + } + } + + /** + * Gets the value for type {@link #TYPE_ASCII}, {@link #TYPE_LONG}, {@link #TYPE_UNDEFINED}, + * {@link #TYPE_UNSIGNED_BYTE}, {@link #TYPE_UNSIGNED_LONG}, or {@link #TYPE_UNSIGNED_SHORT}. + * + * @exception IllegalArgumentException if the data type is {@link #TYPE_RATIONAL} or {@link + * #TYPE_UNSIGNED_RATIONAL}. + */ + long getValueAt(int index) { + if (mValue instanceof long[]) { + return ((long[]) mValue)[index]; + } else if (mValue instanceof byte[]) { + return ((byte[]) mValue)[index]; + } + throw new IllegalArgumentException( + "Cannot get integer value from " + convertTypeToString(mDataType)); + } + + /** + * Gets the {@link #TYPE_ASCII} data. + * + * @exception IllegalArgumentException If the type is NOT {@link #TYPE_ASCII}. + */ + protected String getString() { + if (mDataType != TYPE_ASCII) { + throw new IllegalArgumentException( + "Cannot get ASCII value from " + convertTypeToString(mDataType)); + } + return new String((byte[]) mValue, US_ASCII); + } + + /** + * Gets the offset of this tag. This is only valid if this data size > 4 and contains an offset to + * the location of the actual value. + */ + protected int getOffset() { + return mOffset; + } + + /** Sets the offset of this tag. */ + protected void setOffset(int offset) { + mOffset = offset; + } + + void setHasDefinedCount(boolean d) { + mHasDefinedDefaultComponentCount = d; + } + + boolean hasDefinedCount() { + return mHasDefinedDefaultComponentCount; + } + + private boolean checkBadComponentCount(int count) { + return mHasDefinedDefaultComponentCount && (mComponentCountActual != count); + } + + private static String convertTypeToString(short type) { + switch (type) { + case TYPE_UNSIGNED_BYTE: + return "UNSIGNED_BYTE"; + case TYPE_ASCII: + return "ASCII"; + case TYPE_UNSIGNED_SHORT: + return "UNSIGNED_SHORT"; + case TYPE_UNSIGNED_LONG: + return "UNSIGNED_LONG"; + case TYPE_UNSIGNED_RATIONAL: + return "UNSIGNED_RATIONAL"; + case TYPE_UNDEFINED: + return "UNDEFINED"; + case TYPE_LONG: + return "LONG"; + case TYPE_RATIONAL: + return "RATIONAL"; + default: + return ""; + } + } + + private boolean checkOverflowForUnsignedShort(int[] value) { + for (int v : value) { + if (v > UNSIGNED_SHORT_MAX || v < 0) { + return true; + } + } + return false; + } + + private boolean checkOverflowForUnsignedLong(long[] value) { + for (long v : value) { + if (v < 0 || v > UNSIGNED_LONG_MAX) { + return true; + } + } + return false; + } + + private boolean checkOverflowForUnsignedLong(int[] value) { + for (int v : value) { + if (v < 0) { + return true; + } + } + return false; + } + + private boolean checkOverflowForUnsignedRational(Rational[] value) { + for (Rational v : value) { + if (v.getNumerator() < 0 + || v.getDenominator() < 0 + || v.getNumerator() > UNSIGNED_LONG_MAX + || v.getDenominator() > UNSIGNED_LONG_MAX) { + return true; + } + } + return false; + } + + private boolean checkOverflowForRational(Rational[] value) { + for (Rational v : value) { + if (v.getNumerator() < LONG_MIN + || v.getDenominator() < LONG_MIN + || v.getNumerator() > LONG_MAX + || v.getDenominator() > LONG_MAX) { + return true; + } + } + return false; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj instanceof ExifTag) { + ExifTag tag = (ExifTag) obj; + if (tag.mTagId != this.mTagId + || tag.mComponentCountActual != this.mComponentCountActual + || tag.mDataType != this.mDataType) { + return false; + } + if (mValue != null) { + if (tag.mValue == null) { + return false; + } else if (mValue instanceof long[]) { + if (!(tag.mValue instanceof long[])) { + return false; + } + return Arrays.equals((long[]) mValue, (long[]) tag.mValue); + } else if (mValue instanceof Rational[]) { + if (!(tag.mValue instanceof Rational[])) { + return false; + } + return Arrays.equals((Rational[]) mValue, (Rational[]) tag.mValue); + } else if (mValue instanceof byte[]) { + if (!(tag.mValue instanceof byte[])) { + return false; + } + return Arrays.equals((byte[]) mValue, (byte[]) tag.mValue); + } else { + return mValue.equals(tag.mValue); + } + } else { + return tag.mValue == null; + } + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash( + mTagId, + mDataType, + mHasDefinedDefaultComponentCount, + mComponentCountActual, + mIfd, + mValue, + mOffset); + } + + @Override + public String toString() { + return String.format("tag id: %04X\n", mTagId) + + "ifd id: " + + mIfd + + "\ntype: " + + convertTypeToString(mDataType) + + "\ncount: " + + mComponentCountActual + + "\noffset: " + + mOffset + + "\nvalue: " + + forceGetValueAsString() + + "\n"; + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/IfdData.java b/java/com/android/dialer/callcomposer/camera/exif/IfdData.java new file mode 100644 index 000000000..b808defc6 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/IfdData.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * This class stores all the tags in an IFD. + * + * @see ExifData + * @see ExifTag + */ +class IfdData { + + private final int mIfdId; + private final Map<Short, ExifTag> mExifTags = new HashMap<>(); + private static final int[] sIfds = { + IfdId.TYPE_IFD_0, + IfdId.TYPE_IFD_1, + IfdId.TYPE_IFD_EXIF, + IfdId.TYPE_IFD_INTEROPERABILITY, + IfdId.TYPE_IFD_GPS + }; + /** + * Creates an IfdData with given IFD ID. + * + * @see IfdId#TYPE_IFD_0 + * @see IfdId#TYPE_IFD_1 + * @see IfdId#TYPE_IFD_EXIF + * @see IfdId#TYPE_IFD_GPS + * @see IfdId#TYPE_IFD_INTEROPERABILITY + */ + IfdData(int ifdId) { + mIfdId = ifdId; + } + + static int[] getIfds() { + return sIfds; + } + + /** Get a array the contains all {@link ExifTag} in this IFD. */ + private ExifTag[] getAllTags() { + return mExifTags.values().toArray(new ExifTag[mExifTags.size()]); + } + + /** + * Gets the ID of this IFD. + * + * @see IfdId#TYPE_IFD_0 + * @see IfdId#TYPE_IFD_1 + * @see IfdId#TYPE_IFD_EXIF + * @see IfdId#TYPE_IFD_GPS + * @see IfdId#TYPE_IFD_INTEROPERABILITY + */ + protected int getId() { + return mIfdId; + } + + /** Gets the {@link ExifTag} with given tag id. Return null if there is no such tag. */ + protected ExifTag getTag(short tagId) { + return mExifTags.get(tagId); + } + + /** Adds or replaces a {@link ExifTag}. */ + protected ExifTag setTag(ExifTag tag) { + tag.setIfd(mIfdId); + return mExifTags.put(tag.getTagId(), tag); + } + + /** Gets the tags count in the IFD. */ + private int getTagCount() { + return mExifTags.size(); + } + + /** + * Returns true if all tags in this two IFDs are equal. Note that tags of IFDs offset or thumbnail + * offset will be ignored. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof IfdData) { + IfdData data = (IfdData) obj; + if (data.getId() == mIfdId && data.getTagCount() == getTagCount()) { + ExifTag[] tags = data.getAllTags(); + for (ExifTag tag : tags) { + if (ExifInterface.isOffsetTag(tag.getTagId())) { + continue; + } + ExifTag tag2 = mExifTags.get(tag.getTagId()); + if (!tag.equals(tag2)) { + return false; + } + } + return true; + } + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(mIfdId, mExifTags); + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/IfdId.java b/java/com/android/dialer/callcomposer/camera/exif/IfdId.java new file mode 100644 index 000000000..c61545752 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/IfdId.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +/** The constants of the IFD ID defined in EXIF spec. */ +public interface IfdId { + int TYPE_IFD_0 = 0; + int TYPE_IFD_1 = 1; + int TYPE_IFD_EXIF = 2; + int TYPE_IFD_INTEROPERABILITY = 3; + int TYPE_IFD_GPS = 4; + /* This is used in ExifData to allocate enough IfdData */ + int TYPE_IFD_COUNT = 5; +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/JpegHeader.java b/java/com/android/dialer/callcomposer/camera/exif/JpegHeader.java new file mode 100644 index 000000000..3d98fcc0e --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/JpegHeader.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +class JpegHeader { + static final short SOI = (short) 0xFFD8; + static final short APP1 = (short) 0xFFE1; + static final short EOI = (short) 0xFFD9; + + /** + * SOF (start of frame). All value between SOF0 and SOF15 is SOF marker except for DHT, JPG, and + * DAC marker. + */ + private static final short SOF0 = (short) 0xFFC0; + + private static final short SOF15 = (short) 0xFFCF; + private static final short DHT = (short) 0xFFC4; + private static final short JPG = (short) 0xFFC8; + private static final short DAC = (short) 0xFFCC; + + static boolean isSofMarker(short marker) { + return marker >= SOF0 && marker <= SOF15 && marker != DHT && marker != JPG && marker != DAC; + } +} diff --git a/java/com/android/dialer/callcomposer/camera/exif/Rational.java b/java/com/android/dialer/callcomposer/camera/exif/Rational.java new file mode 100644 index 000000000..9afca8449 --- /dev/null +++ b/java/com/android/dialer/callcomposer/camera/exif/Rational.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callcomposer.camera.exif; + +import java.util.Objects; + +/** + * The rational data type of EXIF tag. Contains a pair of longs representing the numerator and + * denominator of a Rational number. + */ +public class Rational { + + private final long mNumerator; + private final long mDenominator; + + /** Create a Rational with a given numerator and denominator. */ + Rational(long nominator, long denominator) { + mNumerator = nominator; + mDenominator = denominator; + } + + /** Gets the numerator of the rational. */ + long getNumerator() { + return mNumerator; + } + + /** Gets the denominator of the rational */ + long getDenominator() { + return mDenominator; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (this == obj) { + return true; + } + if (obj instanceof Rational) { + Rational data = (Rational) obj; + return mNumerator == data.mNumerator && mDenominator == data.mDenominator; + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(mNumerator, mDenominator); + } + + @Override + public String toString() { + return mNumerator + "/" + mDenominator; + } +} |