diff options
author | Mike Dodd <mdodd@google.com> | 2015-08-11 11:16:59 -0700 |
---|---|---|
committer | Mike Dodd <mdodd@google.com> | 2015-08-12 08:58:28 -0700 |
commit | 461a34b466cb4b13dbbc2ec6330b31e217b2ac4e (patch) | |
tree | bc4b489af52d0e2521e21167d2ad76a47256f348 /src/com/android/messaging/ui/mediapicker | |
parent | 8b3e2b9c1b0a09423a7ba5d1091b9192106502f8 (diff) | |
download | android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.tar.gz android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.tar.bz2 android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.zip |
Initial checkin of AOSP Messaging app.
b/23110861
Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
Diffstat (limited to 'src/com/android/messaging/ui/mediapicker')
30 files changed, 7899 insertions, 0 deletions
diff --git a/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java new file mode 100644 index 0000000..a211058 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import com.google.common.base.Preconditions; + +import javax.annotation.concurrent.ThreadSafe; + +/** + * Keeps track of the speech level as last observed by the recognition + * engine as microphone data flows through it. Can be polled by the UI to + * animate its views. + */ +@ThreadSafe +public class AudioLevelSource { + private volatile int mSpeechLevel; + private volatile Listener mListener; + + public static final int LEVEL_UNKNOWN = -1; + + public interface Listener { + void onSpeechLevel(int speechLevel); + } + + public void setSpeechLevel(int speechLevel) { + Preconditions.checkArgument(speechLevel >= 0 && speechLevel <= 100 || + speechLevel == LEVEL_UNKNOWN); + mSpeechLevel = speechLevel; + maybeNotify(); + } + + public int getSpeechLevel() { + return mSpeechLevel; + } + + public void reset() { + setSpeechLevel(LEVEL_UNKNOWN); + } + + public boolean isValid() { + return mSpeechLevel > 0; + } + + private void maybeNotify() { + final Listener l = mListener; + if (l != null) { + l.onSpeechLevel(mSpeechLevel); + } + } + + public synchronized void setListener(Listener listener) { + mListener = listener; + } + + public synchronized void clearListener(Listener listener) { + if (mListener == listener) { + mListener = null; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java new file mode 100644 index 0000000..5d79293 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.util.OsUtil; + +/** + * Chooser which allows the user to record audio + */ +class AudioMediaChooser extends MediaChooser implements + AudioRecordView.HostInterface { + private View mEnabledView; + private View mMissingPermissionView; + + AudioMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + } + + @Override + public int getSupportedMediaTypes() { + return MediaPicker.MEDIA_TYPE_AUDIO; + } + + @Override + public int getIconResource() { + return R.drawable.ic_audio_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_audioChooserDescription; + } + + @Override + public void onAudioRecorded(final MessagePartData item) { + mMediaPicker.dispatchItemsSelected(item, true); + } + + @Override + public void setThemeColor(final int color) { + if (mView != null) { + ((AudioRecordView) mView).setThemeColor(color); + } + } + + @Override + protected View createView(final ViewGroup container) { + final LayoutInflater inflater = getLayoutInflater(); + final AudioRecordView view = (AudioRecordView) inflater.inflate( + R.layout.mediapicker_audio_chooser, + container /* root */, + false /* attachToRoot */); + view.setHostInterface(this); + view.setThemeColor(mMediaPicker.getConversationThemeColor()); + mEnabledView = view.findViewById(R.id.mediapicker_enabled); + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + return view; + } + + @Override + int getActionBarTitleResId() { + return R.string.mediapicker_audio_title; + } + + @Override + public boolean isHandlingTouch() { + // Whenever the user is in the process of recording audio, we want to allow the user + // to move the finger within the panel without interpreting that as dragging the media + // picker panel. + return ((AudioRecordView) mView).shouldHandleTouch(); + } + + @Override + public void stopTouchHandling() { + ((AudioRecordView) mView).stopTouchHandling(); + } + + @Override + public void onPause() { + super.onPause(); + if (mView != null) { + ((AudioRecordView) mView).onPause(); + } + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected && !OsUtil.hasRecordAudioPermission()) { + requestRecordAudioPermission(); + } + } + + private void requestRecordAudioPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, + MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + mEnabledView.setVisibility(permissionGranted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(permissionGranted ? View.GONE : View.VISIBLE); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/AudioRecordView.java b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java new file mode 100644 index 0000000..fba493f --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java @@ -0,0 +1,351 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.media.MediaRecorder; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaUtil; +import com.android.messaging.util.MediaUtil.OnCompletionListener; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +/** + * Hosts an audio recorder with tap and hold to record functionality. + */ +public class AudioRecordView extends FrameLayout implements + MediaRecorder.OnErrorListener, + MediaRecorder.OnInfoListener { + /** + * An interface that communicates with the hosted AudioRecordView. + */ + public interface HostInterface extends DraftMessageSubscriptionDataProvider { + void onAudioRecorded(final MessagePartData item); + } + + /** The initial state, the user may press and hold to start recording */ + private static final int MODE_IDLE = 1; + + /** The user has pressed the record button and we are playing the sound indicating the + * start of recording session. Don't record yet since we don't want the beeping sound + * to get into the recording. */ + private static final int MODE_STARTING = 2; + + /** When the user is actively recording */ + private static final int MODE_RECORDING = 3; + + /** When the user has finished recording, we need to record for some additional time. */ + private static final int MODE_STOPPING = 4; + + // Bug: 16020175: The framework's MediaRecorder would cut off the ending portion of the + // recorded audio by about half a second. To mitigate this issue, we continue the recording + // for some extra time before stopping it. + private static final int AUDIO_RECORD_ENDING_BUFFER_MILLIS = 500; + + /** + * The minimum duration of any recording. Below this threshold, it will be treated as if the + * user clicked the record button and inform the user to tap and hold to record. + */ + private static final int AUDIO_RECORD_MINIMUM_DURATION_MILLIS = 300; + + // For accessibility, the touchable record button is bigger than the record button visual. + private ImageView mRecordButtonVisual; + private View mRecordButton; + private SoundLevels mSoundLevels; + private TextView mHintTextView; + private PausableChronometer mTimerTextView; + private LevelTrackingMediaRecorder mMediaRecorder; + private long mAudioRecordStartTimeMillis; + + private int mCurrentMode = MODE_IDLE; + private HostInterface mHostInterface; + private int mThemeColor; + + public AudioRecordView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mMediaRecorder = new LevelTrackingMediaRecorder(); + } + + public void setHostInterface(final HostInterface hostInterface) { + mHostInterface = hostInterface; + } + + @VisibleForTesting + public void testSetMediaRecorder(final LevelTrackingMediaRecorder recorder) { + mMediaRecorder = recorder; + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mSoundLevels = (SoundLevels) findViewById(R.id.sound_levels); + mRecordButtonVisual = (ImageView) findViewById(R.id.record_button_visual); + mRecordButton = findViewById(R.id.record_button); + mHintTextView = (TextView) findViewById(R.id.hint_text); + mTimerTextView = (PausableChronometer) findViewById(R.id.timer_text); + mSoundLevels.setLevelSource(mMediaRecorder.getLevelSource()); + mRecordButton.setOnTouchListener(new OnTouchListener() { + @Override + public boolean onTouch(final View v, final MotionEvent event) { + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + onRecordButtonTouchDown(); + + // Don't let the record button handle the down event to let it fall through + // so that we can handle it for the entire panel in onTouchEvent(). This is + // done so that: 1) the user taps on the record button to start recording + // 2) the entire panel owns the touch event so we'd keep recording even + // if the user moves outside the button region. + return false; + } + return false; + } + }); + } + + @Override + public boolean onTouchEvent(final MotionEvent event) { + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + return shouldHandleTouch(); + + case MotionEvent.ACTION_MOVE: + return true; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + return onRecordButtonTouchUp(); + } + return super.onTouchEvent(event); + } + + public void onPause() { + // The conversation draft cannot take any updates when it's paused. Therefore, forcibly + // stop recording on pause. + stopRecording(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + stopRecording(); + } + + private boolean isRecording() { + return mMediaRecorder.isRecording() && mCurrentMode == MODE_RECORDING; + } + + public boolean shouldHandleTouch() { + return mCurrentMode != MODE_IDLE; + } + + public void stopTouchHandling() { + setMode(MODE_IDLE); + stopRecording(); + } + + private void setMode(final int mode) { + if (mCurrentMode != mode) { + mCurrentMode = mode; + updateVisualState(); + } + } + + private void updateVisualState() { + switch (mCurrentMode) { + case MODE_IDLE: + mHintTextView.setVisibility(VISIBLE); + mHintTextView.setTypeface(null, Typeface.NORMAL); + mTimerTextView.setVisibility(GONE); + mSoundLevels.setEnabled(false); + mTimerTextView.stop(); + break; + + case MODE_RECORDING: + case MODE_STOPPING: + mHintTextView.setVisibility(GONE); + mTimerTextView.setVisibility(VISIBLE); + mSoundLevels.setEnabled(true); + mTimerTextView.restart(); + break; + + case MODE_STARTING: + break; // No-Op. + + default: + Assert.fail("invalid mode for AudioRecordView!"); + break; + } + updateRecordButtonAppearance(); + } + + public void setThemeColor(final int color) { + mThemeColor = color; + updateRecordButtonAppearance(); + } + + private void updateRecordButtonAppearance() { + final Drawable foregroundDrawable = getResources().getDrawable(R.drawable.ic_mp_audio_mic); + final GradientDrawable backgroundDrawable = ((GradientDrawable) getResources() + .getDrawable(R.drawable.audio_record_control_button_background)); + if (isRecording()) { + foregroundDrawable.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP); + backgroundDrawable.setColor(mThemeColor); + } else { + foregroundDrawable.setColorFilter(mThemeColor, PorterDuff.Mode.SRC_ATOP); + backgroundDrawable.setColor(Color.WHITE); + } + mRecordButtonVisual.setImageDrawable(foregroundDrawable); + mRecordButtonVisual.setBackground(backgroundDrawable); + } + + @VisibleForTesting + boolean onRecordButtonTouchDown() { + if (!mMediaRecorder.isRecording() && mCurrentMode == MODE_IDLE) { + setMode(MODE_STARTING); + playAudioStartSound(new OnCompletionListener() { + @Override + public void onCompletion() { + // Double-check the current mode before recording since the user may have + // lifted finger from the button before the beeping sound is played through. + final int maxSize = MmsConfig.get(mHostInterface.getConversationSelfSubId()) + .getMaxMessageSize(); + if (mCurrentMode == MODE_STARTING && + mMediaRecorder.startRecording(AudioRecordView.this, + AudioRecordView.this, maxSize)) { + setMode(MODE_RECORDING); + } + } + }); + mAudioRecordStartTimeMillis = System.currentTimeMillis(); + return true; + } + return false; + } + + @VisibleForTesting + boolean onRecordButtonTouchUp() { + if (System.currentTimeMillis() - mAudioRecordStartTimeMillis < + AUDIO_RECORD_MINIMUM_DURATION_MILLIS) { + // The recording is too short, bolden the hint text to instruct the user to + // "tap+hold" to record audio. + final Uri outputUri = stopRecording(); + if (outputUri != null) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + outputUri, null, null); + } + }); + } + setMode(MODE_IDLE); + mHintTextView.setTypeface(null, Typeface.BOLD); + } else if (isRecording()) { + // Record for some extra time to ensure the ending part is saved. + setMode(MODE_STOPPING); + ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { + @Override + public void run() { + onFinishedRecording(); + } + }, AUDIO_RECORD_ENDING_BUFFER_MILLIS); + } else { + setMode(MODE_IDLE); + } + return true; + } + + private Uri stopRecording() { + if (mMediaRecorder.isRecording()) { + return mMediaRecorder.stopRecording(); + } + return null; + } + + @Override // From MediaRecorder.OnInfoListener + public void onInfo(final MediaRecorder mr, final int what, final int extra) { + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { + // Max size reached. Finish recording immediately. + LogUtil.i(LogUtil.BUGLE_TAG, "Max size reached while recording audio"); + onFinishedRecording(); + } else { + // These are unknown errors. + onErrorWhileRecording(what, extra); + } + } + + @Override // From MediaRecorder.OnErrorListener + public void onError(final MediaRecorder mr, final int what, final int extra) { + onErrorWhileRecording(what, extra); + } + + private void onErrorWhileRecording(final int what, final int extra) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error occurred during audio recording what=" + what + + ", extra=" + extra); + UiUtils.showToastAtBottom(R.string.audio_recording_error); + setMode(MODE_IDLE); + stopRecording(); + } + + private void onFinishedRecording() { + final Uri outputUri = stopRecording(); + if (outputUri != null) { + final Rect startRect = new Rect(); + mRecordButtonVisual.getGlobalVisibleRect(startRect); + final MediaPickerMessagePartData audioItem = + new MediaPickerMessagePartData(startRect, + ContentType.AUDIO_3GPP, outputUri, 0, 0); + mHostInterface.onAudioRecorded(audioItem); + } + playAudioEndSound(); + setMode(MODE_IDLE); + } + + private void playAudioStartSound(final OnCompletionListener completionListener) { + MediaUtil.get().playSound(getContext(), R.raw.audio_initiate, completionListener); + } + + private void playAudioEndSound() { + MediaUtil.get().playSound(getContext(), R.raw.audio_end, null); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraManager.java b/src/com/android/messaging/ui/mediapicker/CameraManager.java new file mode 100644 index 0000000..166ebd7 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraManager.java @@ -0,0 +1,1200 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import android.view.OrientationEventListener; +import android.view.Surface; +import android.view.View; +import android.view.WindowManager; + +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.ui.mediapicker.camerafocus.FocusOverlayManager; +import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.io.FileNotFoundException; +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) + */ +class CameraManager implements FocusOverlayManager.Listener { + /** + * Wrapper around the framework camera API to allow mocking different hardware scenarios while + * unit testing + */ + interface CameraWrapper { + int getNumberOfCameras(); + void getCameraInfo(int index, CameraInfo cameraInfo); + Camera open(int cameraId); + /** Add a wrapper for release because a final method cannot be mocked */ + void release(Camera camera); + } + + /** + * Callbacks for the camera manager listener + */ + interface CameraManagerListener { + void onCameraError(int errorCode, Exception e); + void onCameraChanged(); + } + + /** + * Callback when taking image or video + */ + interface MediaCallback { + static final int MEDIA_CAMERA_CHANGED = 1; + static final 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 + static final int ERROR_OPENING_CAMERA = 1; + static final int ERROR_SHOWING_PREVIEW = 2; + static final int ERROR_INITIALIZING_VIDEO = 3; + static final int ERROR_STORAGE_FAILURE = 4; + static final int ERROR_RECORDING_VIDEO = 5; + static final int ERROR_HARDWARE_ACCELERATION_DISABLED = 6; + static final int ERROR_TAKING_PICTURE = 7; + + private static final String TAG = LogUtil.BUGLE_TAG; + private static final int NO_CAMERA_SELECTED = -1; + + private static CameraManager sInstance; + + /** Default camera wrapper which directs calls to the framework APIs */ + private static CameraWrapper sCameraWrapper = new CameraWrapper() { + @Override + public int getNumberOfCameras() { + return Camera.getNumberOfCameras(); + } + + @Override + public void getCameraInfo(final int index, final CameraInfo cameraInfo) { + Camera.getCameraInfo(index, cameraInfo); + } + + @Override + public Camera open(final int cameraId) { + return Camera.open(cameraId); + } + + @Override + public void release(final Camera camera) { + camera.release(); + } + }; + + /** 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; + + /** True if the camera is requested to be in video mode */ + private boolean mVideoModeRequested; + + /** The media recorder for video mode */ + private MmsVideoRecorder mMediaRecorder; + + /** Callback to call with video recording updates */ + private MediaCallback mVideoCallback; + + /** 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; + + /** Provides subscription-related data to access per-subscription configurations. */ + private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; + + /** 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 = sCameraWrapper.getNumberOfCameras(); + try { + for (int i = 0; i < cameraCount; i++) { + sCameraWrapper.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(TAG, "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 */ + static CameraManager get() { + if (sInstance == null) { + sInstance = new CameraManager(); + } + return sInstance; + } + + /** Allows tests to inject a custom camera wrapper */ + @VisibleForTesting + static void setCameraWrapper(final CameraWrapper cameraWrapper) { + sCameraWrapper = cameraWrapper; + sInstance = null; + } + + /** + * 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.isTrue(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()); + } + return true; + } + }); + } + mCameraPreview = preview; + tryShowPreview(); + } + + void setRenderOverlay(final RenderOverlay renderOverlay) { + mFocusOverlayManager.setFocusRenderer(renderOverlay != null ? + renderOverlay.getPieRenderer() : null); + } + + /** Convenience function to swap between front and back facing cameras */ + void swapCamera() { + Assert.isTrue(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 + */ + 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 = sCameraWrapper.getNumberOfCameras(); + Assert.isTrue(cameraCount > 0); + + mCameraIndex = NO_CAMERA_SELECTED; + setCamera(null); + final CameraInfo cameraInfo = new CameraInfo(); + for (int i = 0; i < cameraCount; i++) { + sCameraWrapper.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == desiredFacing) { + mCameraIndex = i; + sCameraWrapper.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; + sCameraWrapper.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(TAG, "RuntimeException in CameraManager.selectCamera", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + return false; + } + } + + int getCameraIndex() { + return mCameraIndex; + } + + void selectCameraByIndex(final int cameraIndex) { + if (mCameraIndex == cameraIndex) { + return; + } + + try { + mCameraIndex = cameraIndex; + sCameraWrapper.getCameraInfo(mCameraIndex, mCameraInfo); + if (mOpenRequested) { + openCamera(); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.selectCameraByIndex", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + + @VisibleForTesting + CameraInfo getCameraInfo() { + if (mCameraIndex == NO_CAMERA_SELECTED) { + return null; + } + return mCameraInfo; + } + + /** @return True if this device has camera capabilities */ + boolean hasAnyCamera() { + return sCameraWrapper.getNumberOfCameras() > 0; + } + + /** @return True if the device has both a front and back camera */ + 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]; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Opening camera " + mCameraIndex); + } + return sCameraWrapper.open(cameraIndex); + } catch (final Exception e) { + LogUtil.e(TAG, "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(); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Opened camera " + mCameraIndex + " " + (camera != null)); + } + + setCamera(camera); + if (camera == null) { + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, mException); + } + LogUtil.e(TAG, "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; + } + + } + }; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Start opening camera " + mCameraIndex); + } + + if (!delayTask) { + mOpenCameraTask.execute(mCameraIndex); + } + } + + boolean isVideoMode() { + return mVideoModeRequested; + } + + boolean isRecording() { + return mVideoModeRequested && mVideoCallback != null; + } + + void setVideoMode(final boolean videoMode) { + if (mVideoModeRequested == videoMode) { + return; + } + mVideoModeRequested = videoMode; + tryInitOrCleanupVideoMode(); + } + + /** Closes the camera releasing the resources it uses */ + void closeCamera() { + mOpenRequested = false; + setCamera(null); + } + + /** Temporarily closes the camera if it is open */ + void onPause() { + setCamera(null); + } + + /** Reopens the camera if it was opened when onPause was called */ + void onResume() { + if (mOpenRequested) { + openCamera(); + } + } + + /** + * Sets the listener which will be notified of errors or other events in the camera + * @param listener The listener to notify + */ + void setListener(final CameraManagerListener listener) { + Assert.isMainThread(); + mListener = listener; + if (!mIsHardwareAccelerationSupported && mListener != null) { + mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); + } + } + + void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { + mSubscriptionDataProvider = provider; + } + + void takePicture(final float heightPercent, @NonNull final MediaCallback callback) { + Assert.isTrue(!mVideoModeRequested); + Assert.isTrue(!mTakingPicture); + Assert.notNull(callback); + 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) { + width = size.height; + height = size.width; + } else { + width = size.width; + height = size.height; + } + new ImagePersistTask( + width, height, heightPercent, bytes, mCameraPreview.getContext(), callback) + .executeOnThreadPool(); + } + }; + + mTakingPicture = true; + try { + mCamera.takePicture( + null /* shutter */, + null /* raw */, + null /* postView */, + jpegCallback); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.takePicture", e); + mTakingPicture = false; + if (mListener != null) { + mListener.onCameraError(ERROR_TAKING_PICTURE, e); + } + } + } + + void startVideo(final MediaCallback callback) { + Assert.notNull(callback); + Assert.isTrue(!isRecording()); + mVideoCallback = callback; + tryStartVideoCapture(); + } + + /** + * 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) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Releasing camera " + mCameraIndex); + } + sCameraWrapper.release(camera); + return null; + } + }.execute(); + } + + private void releaseMediaRecorder(final boolean cleanupFile) { + if (mMediaRecorder == null) { + return; + } + mVideoModeRequested = false; + + if (cleanupFile) { + mMediaRecorder.cleanupTempFile(); + if (mVideoCallback != null) { + final MediaCallback callback = mVideoCallback; + mVideoCallback = null; + // Notify the callback that we've stopped recording + callback.onMediaReady(null /*uri*/, null /*contentType*/, 0 /*width*/, + 0 /*height*/); + } + } + + mMediaRecorder.release(); + mMediaRecorder = null; + + if (mCamera != null) { + try { + mCamera.reconnect(); + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.releaseMediaRecorder", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.releaseMediaRecorder", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + restoreRequestedOrientation(); + } + + /** 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; + if (mMediaRecorder == null) { + try { + mCamera.setDisplayOrientation(orientation); + final Camera.Parameters params = mCamera.getParameters(); + params.setRotation(rotation); + mCamera.setParameters(params); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "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; + } + + releaseMediaRecorder(true /* cleanupFile */); + 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(); + tryInitOrCleanupVideoMode(); + if (mOrientationHandler == null) { + mOrientationHandler = new OrientationHandler(mCameraPreview.getContext()); + mOrientationHandler.enable(); + } + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.tryShowPreview", e); + if (mListener != null) { + mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryShowPreview", e); + if (mListener != null) { + mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); + } + } + } + + private void tryInitOrCleanupVideoMode() { + if (!mVideoModeRequested || mCamera == null || mCameraPreview == null) { + releaseMediaRecorder(true /* cleanupFile */); + return; + } + + if (mMediaRecorder != null) { + return; + } + + try { + mCamera.unlock(); + final int maxMessageSize = getMmsConfig().getMaxMessageSize(); + mMediaRecorder = new MmsVideoRecorder(mCamera, mCameraIndex, mRotation, maxMessageSize); + mMediaRecorder.prepare(); + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "FileNotFoundException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_STORAGE_FAILURE, e); + } + setVideoMode(false); + return; + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); + } + setVideoMode(false); + return; + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); + } + setVideoMode(false); + return; + } + + tryStartVideoCapture(); + } + + private void tryStartVideoCapture() { + if (mMediaRecorder == null || mVideoCallback == null) { + return; + } + + mMediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() { + @Override + public void onError(final MediaRecorder mediaRecorder, final int what, + final int extra) { + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, null); + } + restoreRequestedOrientation(); + } + }); + + mMediaRecorder.setOnInfoListener(new MediaRecorder.OnInfoListener() { + @Override + public void onInfo(final MediaRecorder mediaRecorder, final int what, final int extra) { + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED || + what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { + stopVideo(); + } + } + }); + + try { + mMediaRecorder.start(); + final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + lockOrientation(); + } catch (final IllegalStateException e) { + LogUtil.e(TAG, "IllegalStateException in CameraManager.tryStartVideoCapture", e); + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, e); + } + setVideoMode(false); + restoreRequestedOrientation(); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryStartVideoCapture", e); + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, e); + } + setVideoMode(false); + restoreRequestedOrientation(); + } + } + + void stopVideo() { + int width = ImageRequest.UNSPECIFIED_SIZE; + int height = ImageRequest.UNSPECIFIED_SIZE; + Uri uri = null; + String contentType = null; + try { + final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + mMediaRecorder.stop(); + width = mMediaRecorder.getVideoWidth(); + height = mMediaRecorder.getVideoHeight(); + uri = mMediaRecorder.getVideoUri(); + contentType = mMediaRecorder.getContentType(); + } catch (final RuntimeException e) { + // MediaRecorder.stop will throw a RuntimeException if the video was too short, let the + // finally clause call the callback with null uri and handle cleanup + LogUtil.e(TAG, "RuntimeException in CameraManager.stopVideo", e); + } finally { + final MediaCallback videoCallback = mVideoCallback; + mVideoCallback = null; + releaseMediaRecorder(false /* cleanupFile */); + if (uri == null) { + tryInitOrCleanupVideoMode(); + } + videoCallback.onMediaReady(uri, contentType, width, height); + } + } + + boolean isCameraAvailable() { + return mCamera != null && !mTakingPicture && mIsHardwareAccelerationSupported; + } + + /** + * External components call into this to report if hardware acceleration is supported. When + * hardware acceleration isn't supported, we need to report an error through the listener + * interface + * @param isHardwareAccelerationSupported True if the preview is rendering in a hardware + * accelerated view. + */ + void reportHardwareAccelerationSupported(final boolean isHardwareAccelerationSupported) { + Assert.isMainThread(); + if (mIsHardwareAccelerationSupported == isHardwareAccelerationSupported) { + // If the value hasn't changed nothing more to do + return; + } + + mIsHardwareAccelerationSupported = isHardwareAccelerationSupported; + if (!isHardwareAccelerationSupported) { + LogUtil.e(TAG, "Software rendering - cannot open camera"); + if (mListener != null) { + mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); + } + } + } + + /** Returns the scale factor to scale the width/height to max allowed in MmsConfig */ + private float getScaleFactorForMaxAllowedSize(final int width, final int height, + final int maxWidth, final int maxHeight) { + if (maxWidth <= 0 || maxHeight <= 0) { + // MmsConfig initialization runs asynchronously on application startup, so there's a + // chance (albeit a very slight one) that we don't have it yet. + LogUtil.w(LogUtil.BUGLE_TAG, "Max image size not loaded in MmsConfig"); + return 1.0f; + } + + if (width <= maxWidth && height <= maxHeight) { + // Already meeting requirements. + return 1.0f; + } + + return Math.min(maxWidth * 1.0f / width, maxHeight * 1.0f / height); + } + + private MmsConfig getMmsConfig() { + final int subId = mSubscriptionDataProvider != null ? + mSubscriptionDataProvider.getConversationSelfSubId() : + ParticipantData.DEFAULT_SELF_SUB_ID; + return MmsConfig.get(subId); + } + + /** + * 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 + */ + private Camera.Size chooseBestPictureSize() { + final Context context = mCameraPreview.getContext(); + final Resources resources = context.getResources(); + final DisplayMetrics displayMetrics = resources.getDisplayMetrics(); + final int displayOrientation = resources.getConfiguration().orientation; + int cameraOrientation = mCameraInfo.orientation; + + int screenWidth; + int screenHeight; + if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) { + // Rotate the camera orientation 90 degrees to compensate for the rotated display + // metrics. Direction doesn't matter because we're just using it for width/height + cameraOrientation += 90; + } + + // Check the camera orientation relative to the display. + // For 0, 180, 360, the screen width/height are the display width/height + // For 90, 270, the screen width/height are inverted from the display + if (cameraOrientation % 180 == 0) { + screenWidth = displayMetrics.widthPixels; + screenHeight = displayMetrics.heightPixels; + } else { + screenWidth = displayMetrics.heightPixels; + screenHeight = displayMetrics.widthPixels; + } + + final MmsConfig mmsConfig = getMmsConfig(); + final int maxWidth = mmsConfig.getMaxImageWidth(); + final int maxHeight = mmsConfig.getMaxImageHeight(); + + // Constrain the size within the max width/height defined by MmsConfig. + final float scaleFactor = getScaleFactorForMaxAllowedSize(screenWidth, screenHeight, + maxWidth, maxHeight); + screenWidth *= scaleFactor; + screenHeight *= scaleFactor; + + final float aspectRatio = BugleGservices.get().getFloat( + BugleGservicesKeys.CAMERA_ASPECT_RATIO, + screenWidth / (float) screenHeight); + final List<Camera.Size> sizes = new ArrayList<Camera.Size>( + mCamera.getParameters().getSupportedPictureSizes()); + final int maxPixels = maxWidth * maxHeight; + + // Sort the sizes so the best size is first + Collections.sort(sizes, new SizeComparator(maxWidth, maxHeight, aspectRatio, maxPixels)); + + return sizes.get(0); + } + + /** + * 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(TAG, "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(TAG, "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(TAG, "RuntimeException in CameraManager setFocusParameters"); + } + } + + 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(TAG, prefix + size.width + "x" + size.height + + " (" + (size.width / (float) size.height) + ")"); + } + + + private Integer mSavedOrientation = null; + + private void lockOrientation() { + // when we start recording, lock our orientation + final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); + final WindowManager windowManager = + (WindowManager) a.getSystemService(Context.WINDOW_SERVICE); + final int rotation = windowManager.getDefaultDisplay().getRotation(); + + mSavedOrientation = a.getRequestedOrientation(); + switch (rotation) { + case Surface.ROTATION_0: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + break; + case Surface.ROTATION_90: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + break; + case Surface.ROTATION_180: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT); + break; + case Surface.ROTATION_270: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE); + break; + } + + } + + private void restoreRequestedOrientation() { + if (mSavedOrientation != null) { + final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); + if (a != null) { + a.setRequestedOrientation(mSavedOrientation); + } + mSavedOrientation = null; + } + } + + static boolean hasCameraPermission() { + return OsUtil.hasPermission(Manifest.permission.CAMERA); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java new file mode 100644 index 0000000..2c7a7f2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java @@ -0,0 +1,481 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Rect; +import android.hardware.Camera; +import android.net.Uri; +import android.os.SystemClock; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.widget.Chronometer; +import android.widget.ImageButton; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.ui.mediapicker.CameraManager.MediaCallback; +import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * Chooser which allows the user to take pictures or video without leaving the current app/activity + */ +class CameraMediaChooser extends MediaChooser implements + CameraManager.CameraManagerListener { + private CameraPreview.CameraPreviewHost mCameraPreviewHost; + private ImageButton mFullScreenButton; + private ImageButton mSwapCameraButton; + private ImageButton mSwapModeButton; + private ImageButton mCaptureButton; + private ImageButton mCancelVideoButton; + private Chronometer mVideoCounter; + private boolean mVideoCancelled; + private int mErrorToast; + private View mEnabledView; + private View mMissingPermissionView; + + CameraMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + } + + @Override + public int getSupportedMediaTypes() { + if (CameraManager.get().hasAnyCamera()) { + return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO; + } else { + return MediaPicker.MEDIA_TYPE_NONE; + } + } + + @Override + public View destroyView() { + CameraManager.get().closeCamera(); + CameraManager.get().setListener(null); + CameraManager.get().setSubscriptionDataProvider(null); + return super.destroyView(); + } + + @Override + protected View createView(final ViewGroup container) { + CameraManager.get().setListener(this); + CameraManager.get().setSubscriptionDataProvider(this); + CameraManager.get().setVideoMode(false); + final LayoutInflater inflater = getLayoutInflater(); + final CameraMediaChooserView view = (CameraMediaChooserView) inflater.inflate( + R.layout.mediapicker_camera_chooser, + container /* root */, + false /* attachToRoot */); + mCameraPreviewHost = (CameraPreview.CameraPreviewHost) view.findViewById( + R.id.camera_preview); + mCameraPreviewHost.getView().setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + if (CameraManager.get().isVideoMode()) { + // Prevent the swipe down in video mode because video is always captured in + // full screen + return true; + } + + return false; + } + }); + + final View shutterVisual = view.findViewById(R.id.camera_shutter_visual); + + mFullScreenButton = (ImageButton) view.findViewById(R.id.camera_fullScreen_button); + mFullScreenButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mMediaPicker.setFullScreen(true); + } + }); + + mSwapCameraButton = (ImageButton) view.findViewById(R.id.camera_swapCamera_button); + mSwapCameraButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + CameraManager.get().swapCamera(); + } + }); + + mCaptureButton = (ImageButton) view.findViewById(R.id.camera_capture_button); + mCaptureButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + final float heightPercent = Math.min(mMediaPicker.getViewPager().getHeight() / + (float) mCameraPreviewHost.getView().getHeight(), 1); + + if (CameraManager.get().isRecording()) { + CameraManager.get().stopVideo(); + } else { + final CameraManager.MediaCallback callback = new CameraManager.MediaCallback() { + @Override + public void onMediaReady( + final Uri uriToVideo, final String contentType, + final int width, final int height) { + mVideoCounter.stop(); + if (mVideoCancelled || uriToVideo == null) { + mVideoCancelled = false; + } else { + final Rect startRect = new Rect(); + // It's possible to throw out the chooser while taking the + // picture/video. In that case, still use the attachment, just + // skip the startRect + if (mView != null) { + mView.getGlobalVisibleRect(startRect); + } + mMediaPicker.dispatchItemsSelected( + new MediaPickerMessagePartData(startRect, contentType, + uriToVideo, width, height), + true /* dismissMediaPicker */); + } + updateViewState(); + } + + @Override + public void onMediaFailed(final Exception exception) { + UiUtils.showToastAtBottom(R.string.camera_media_failure); + updateViewState(); + } + + @Override + public void onMediaInfo(final int what) { + if (what == MediaCallback.MEDIA_NO_DATA) { + UiUtils.showToastAtBottom(R.string.camera_media_failure); + } + updateViewState(); + } + }; + if (CameraManager.get().isVideoMode()) { + CameraManager.get().startVideo(callback); + mVideoCounter.setBase(SystemClock.elapsedRealtime()); + mVideoCounter.start(); + updateViewState(); + } else { + showShutterEffect(shutterVisual); + CameraManager.get().takePicture(heightPercent, callback); + updateViewState(); + } + } + } + }); + + mSwapModeButton = (ImageButton) view.findViewById(R.id.camera_swap_mode_button); + mSwapModeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + final boolean isSwitchingToVideo = !CameraManager.get().isVideoMode(); + if (isSwitchingToVideo && !OsUtil.hasRecordAudioPermission()) { + requestRecordAudioPermission(); + } else { + onSwapMode(); + } + } + }); + + mCancelVideoButton = (ImageButton) view.findViewById(R.id.camera_cancel_button); + mCancelVideoButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mVideoCancelled = true; + CameraManager.get().stopVideo(); + mMediaPicker.dismiss(true); + } + }); + + mVideoCounter = (Chronometer) view.findViewById(R.id.camera_video_counter); + + CameraManager.get().setRenderOverlay((RenderOverlay) view.findViewById(R.id.focus_visual)); + + mEnabledView = view.findViewById(R.id.mediapicker_enabled); + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + + // Must set mView before calling updateViewState because it operates on mView + mView = view; + updateViewState(); + updateForPermissionState(CameraManager.hasCameraPermission()); + return view; + } + + @Override + public int getIconResource() { + return R.drawable.ic_camera_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_cameraChooserDescription; + } + + /** + * Updates the view when entering or leaving full-screen camera mode + * @param fullScreen + */ + @Override + void onFullScreenChanged(final boolean fullScreen) { + super.onFullScreenChanged(fullScreen); + if (!fullScreen && CameraManager.get().isVideoMode()) { + CameraManager.get().setVideoMode(false); + } + updateViewState(); + } + + /** + * Initializes the control to a default state when it is opened / closed + * @param open True if the control is opened + */ + @Override + void onOpenedChanged(final boolean open) { + super.onOpenedChanged(open); + updateViewState(); + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected) { + if (CameraManager.hasCameraPermission()) { + // If an error occurred before the chooser was selected, show it now + showErrorToastIfNeeded(); + } else { + requestCameraPermission(); + } + } + } + + private void requestCameraPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.CAMERA }, + MediaPicker.CAMERA_PERMISSION_REQUEST_CODE); + } + + private void requestRecordAudioPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, + MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.CAMERA_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + updateForPermissionState(permissionGranted); + if (permissionGranted) { + mCameraPreviewHost.onCameraPermissionGranted(); + } + } else if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { + Assert.isFalse(CameraManager.get().isVideoMode()); + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Switch to video mode + onSwapMode(); + } else { + // Stay in still-photo mode + } + } + } + + private void updateForPermissionState(final boolean granted) { + // onRequestPermissionsResult can sometimes get called before createView(). + if (mEnabledView == null) { + return; + } + + mEnabledView.setVisibility(granted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); + } + + @Override + public boolean canSwipeDown() { + if (CameraManager.get().isVideoMode()) { + return true; + } + return super.canSwipeDown(); + } + + /** + * Handles an error from the camera manager by showing the appropriate error message to the user + * @param errorCode One of the CameraManager.ERROR_* constants + * @param e The exception which caused the error, if any + */ + @Override + public void onCameraError(final int errorCode, final Exception e) { + switch (errorCode) { + case CameraManager.ERROR_OPENING_CAMERA: + case CameraManager.ERROR_SHOWING_PREVIEW: + mErrorToast = R.string.camera_error_opening; + break; + case CameraManager.ERROR_INITIALIZING_VIDEO: + mErrorToast = R.string.camera_error_video_init_fail; + updateViewState(); + break; + case CameraManager.ERROR_STORAGE_FAILURE: + mErrorToast = R.string.camera_error_storage_fail; + updateViewState(); + break; + case CameraManager.ERROR_TAKING_PICTURE: + mErrorToast = R.string.camera_error_failure_taking_picture; + break; + default: + mErrorToast = R.string.camera_error_unknown; + LogUtil.w(LogUtil.BUGLE_TAG, "Unknown camera error:" + errorCode); + break; + } + showErrorToastIfNeeded(); + } + + private void showErrorToastIfNeeded() { + if (mErrorToast != 0 && mSelected) { + UiUtils.showToastAtBottom(mErrorToast); + mErrorToast = 0; + } + } + + @Override + public void onCameraChanged() { + updateViewState(); + } + + private void onSwapMode() { + CameraManager.get().setVideoMode(!CameraManager.get().isVideoMode()); + if (CameraManager.get().isVideoMode()) { + mMediaPicker.setFullScreen(true); + + // For now we start recording immediately + mCaptureButton.performClick(); + } + updateViewState(); + } + + private void showShutterEffect(final View shutterVisual) { + final float maxAlpha = getContext().getResources().getFraction( + R.fraction.camera_shutter_max_alpha, 1 /* base */, 1 /* pBase */); + + // Divide by 2 so each half of the animation adds up to the full duration + final int animationDuration = getContext().getResources().getInteger( + R.integer.camera_shutter_duration) / 2; + + final AnimationSet animation = new AnimationSet(false /* shareInterpolator */); + final Animation alphaInAnimation = new AlphaAnimation(0.0f, maxAlpha); + alphaInAnimation.setDuration(animationDuration); + animation.addAnimation(alphaInAnimation); + + final Animation alphaOutAnimation = new AlphaAnimation(maxAlpha, 0.0f); + alphaOutAnimation.setStartOffset(animationDuration); + alphaOutAnimation.setDuration(animationDuration); + animation.addAnimation(alphaOutAnimation); + + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(final Animation animation) { + shutterVisual.setVisibility(View.VISIBLE); + } + + @Override + public void onAnimationEnd(final Animation animation) { + shutterVisual.setVisibility(View.GONE); + } + + @Override + public void onAnimationRepeat(final Animation animation) { + } + }); + shutterVisual.startAnimation(animation); + } + + /** Updates the state of the buttons and overlays based on the current state of the view */ + private void updateViewState() { + if (mView == null) { + return; + } + + final Context context = getContext(); + if (context == null) { + // Context is null if the fragment was already removed from the activity + return; + } + final boolean fullScreen = mMediaPicker.isFullScreen(); + final boolean videoMode = CameraManager.get().isVideoMode(); + final boolean isRecording = CameraManager.get().isRecording(); + final boolean isCameraAvailable = isCameraAvailable(); + final Camera.CameraInfo cameraInfo = CameraManager.get().getCameraInfo(); + final boolean frontCamera = cameraInfo != null && cameraInfo.facing == + Camera.CameraInfo.CAMERA_FACING_FRONT; + + mView.setSystemUiVisibility( + fullScreen ? View.SYSTEM_UI_FLAG_LOW_PROFILE : + View.SYSTEM_UI_FLAG_VISIBLE); + + mFullScreenButton.setVisibility(!fullScreen ? View.VISIBLE : View.GONE); + mFullScreenButton.setEnabled(isCameraAvailable); + mSwapCameraButton.setVisibility( + fullScreen && !isRecording && CameraManager.get().hasFrontAndBackCamera() ? + View.VISIBLE : View.GONE); + mSwapCameraButton.setImageResource(frontCamera ? + R.drawable.ic_camera_front_light : + R.drawable.ic_camera_rear_light); + mSwapCameraButton.setEnabled(isCameraAvailable); + + mCancelVideoButton.setVisibility(isRecording ? View.VISIBLE : View.GONE); + mVideoCounter.setVisibility(isRecording ? View.VISIBLE : View.GONE); + + mSwapModeButton.setImageResource(videoMode ? + R.drawable.ic_mp_camera_small_light : + R.drawable.ic_mp_video_small_light); + mSwapModeButton.setContentDescription(context.getString(videoMode ? + R.string.camera_switch_to_still_mode : R.string.camera_switch_to_video_mode)); + mSwapModeButton.setVisibility(isRecording ? View.GONE : View.VISIBLE); + mSwapModeButton.setEnabled(isCameraAvailable); + + if (isRecording) { + mCaptureButton.setImageResource(R.drawable.ic_mp_capture_stop_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_stop_recording)); + } else if (videoMode) { + mCaptureButton.setImageResource(R.drawable.ic_mp_video_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_start_recording)); + } else { + mCaptureButton.setImageResource(R.drawable.ic_checkmark_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_take_picture)); + } + mCaptureButton.setEnabled(isCameraAvailable); + } + + @Override + int getActionBarTitleResId() { + return 0; + } + + /** + * Returns if the camera is currently ready camera is loaded and not taking a picture. + * otherwise we should avoid taking another picture, swapping camera or recording video. + */ + private boolean isCameraAvailable() { + return CameraManager.get().isCameraAvailable(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java new file mode 100644 index 0000000..64c07b2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.graphics.Canvas; +import android.hardware.Camera; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import com.android.messaging.R; +import com.android.messaging.ui.PersistentInstanceState; +import com.android.messaging.util.ThreadUtil; + +public class CameraMediaChooserView extends FrameLayout implements PersistentInstanceState { + private static final String KEY_CAMERA_INDEX = "camera_index"; + + // True if we have at least queued an update to the view tree to support software rendering + // fallback + private boolean mIsSoftwareFallbackActive; + + public CameraMediaChooserView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected Parcelable onSaveInstanceState() { + final Bundle bundle = new Bundle(); + bundle.putInt(KEY_CAMERA_INDEX, CameraManager.get().getCameraIndex()); + return bundle; + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof Bundle)) { + return; + } + + final Bundle bundle = (Bundle) state; + CameraManager.get().selectCameraByIndex(bundle.getInt(KEY_CAMERA_INDEX)); + } + + @Override + public Parcelable saveState() { + return onSaveInstanceState(); + } + + @Override + public void restoreState(final Parcelable restoredState) { + onRestoreInstanceState(restoredState); + } + + @Override + public void resetState() { + CameraManager.get().selectCamera(Camera.CameraInfo.CAMERA_FACING_BACK); + } + + @Override + protected void onDraw(final Canvas canvas) { + super.onDraw(canvas); + // If the canvas isn't hardware accelerated, we have to replace the HardwareCameraPreview + // with a SoftwareCameraPreview which supports software rendering + if (!canvas.isHardwareAccelerated() && !mIsSoftwareFallbackActive) { + mIsSoftwareFallbackActive = true; + // Post modifying the tree since we can't modify the view tree during a draw pass + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + final HardwareCameraPreview cameraPreview = + (HardwareCameraPreview) findViewById(R.id.camera_preview); + if (cameraPreview == null) { + return; + } + final ViewGroup parent = ((ViewGroup) cameraPreview.getParent()); + final int index = parent.indexOfChild(cameraPreview); + final SoftwareCameraPreview softwareCameraPreview = + new SoftwareCameraPreview(getContext()); + // Be sure to remove the hardware view before adding the software view to + // prevent having 2 camera previews active at the same time + parent.removeView(cameraPreview); + parent.addView(softwareCameraPreview, index); + } + }); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraPreview.java b/src/com/android/messaging/ui/mediapicker/CameraPreview.java new file mode 100644 index 0000000..ecac978 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraPreview.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.content.res.Configuration; +import android.hardware.Camera; +import android.view.View; +import android.view.View.MeasureSpec; +import com.android.messaging.util.Assert; + +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 { + public interface CameraPreviewHost { + View getView(); + boolean isValid(); + void startPreview(final Camera camera) throws IOException; + void onCameraPermissionGranted(); + + } + + private int mCameraWidth = -1; + private int mCameraHeight = -1; + + private final CameraPreviewHost mHost; + + public CameraPreview(final CameraPreviewHost host) { + Assert.notNull(host); + Assert.notNull(host.getView()); + mHost = host; + } + + 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; + } + } + + public void onVisibilityChanged(final int visibility) { + if (CameraManager.hasCameraPermission()) { + if (visibility == View.VISIBLE) { + CameraManager.get().openCamera(); + } else { + CameraManager.get().closeCamera(); + } + } + } + + public Context getContext() { + return mHost.getView().getContext(); + } + + public void setOnTouchListener(final View.OnTouchListener listener) { + mHost.getView().setOnTouchListener(listener); + } + + public int getHeight() { + return mHost.getView().getHeight(); + } + + public void onAttachedToWindow() { + if (CameraManager.hasCameraPermission()) { + CameraManager.get().openCamera(); + } + } + + public void onDetachedFromWindow() { + CameraManager.get().closeCamera(); + } + + public void onRestoreInstanceState() { + if (CameraManager.hasCameraPermission()) { + CameraManager.get().openCamera(); + } + } + + public void onCameraPermissionGranted() { + CameraManager.get().openCamera(); + } + + /** + * @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/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java new file mode 100644 index 0000000..2c36752 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.SafeAsyncTask; + +/** + * Wraps around the functionalities to allow the user to pick images from the document + * picker. Instances of this class must be tied to a Fragment which is able to delegate activity + * result callbacks. + */ +public class DocumentImagePicker { + + /** + * An interface for a listener that listens for when a document has been picked. + */ + public interface SelectionListener { + /** + * Called when an document is selected from picker. At this point, the file hasn't been + * actually loaded and staged in the temp directory, so we are passing in a pending + * MessagePartData, which the consumer should use to display a placeholder image. + * @param pendingItem a temporary attachment data for showing the placeholder state. + */ + void onDocumentSelected(PendingAttachmentData pendingItem); + } + + // The owning fragment. + private final Fragment mFragment; + + // The listener on the picker events. + private final SelectionListener mListener; + + private static final String EXTRA_PHOTO_URL = "photo_url"; + + /** + * Creates a new instance of DocumentImagePicker. + * @param activity The activity that owns the picker, or the activity that hosts the owning + * fragment. + */ + public DocumentImagePicker(final Fragment fragment, + final SelectionListener listener) { + mFragment = fragment; + mListener = listener; + } + + /** + * Intent out to open an image/video from document picker. + */ + public void launchPicker() { + UIIntents.get().launchDocumentImagePicker(mFragment); + } + + /** + * Must be called from the fragment/activity's onActivityResult(). + */ + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + if (requestCode == UIIntents.REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER && + resultCode == Activity.RESULT_OK) { + // Sometimes called after media item has been picked from the document picker. + String url = data.getStringExtra(EXTRA_PHOTO_URL); + if (url == null) { + // we're using the builtin photo picker which supplies the return + // url as it's "data" + url = data.getDataString(); + if (url == null) { + final Bundle extras = data.getExtras(); + if (extras != null) { + final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM); + if (uri != null) { + url = uri.toString(); + } + } + } + } + + // Guard against null uri cases for when the activity returns a null/invalid intent. + if (url != null) { + final Uri uri = Uri.parse(url); + prepareDocumentForAttachment(uri); + } + } + } + + private void prepareDocumentForAttachment(final Uri documentUri) { + // Notify our listener with a PendingAttachmentData containing the metadata. + // Asynchronously get the content type for the picked image since + // ImageUtils.getContentType() potentially involves I/O and can be expensive. + new SafeAsyncTask<Void, Void, String>() { + @Override + protected String doInBackgroundTimed(final Void... params) { + return ImageUtils.getContentType( + Factory.get().getApplicationContext().getContentResolver(), documentUri); + } + + @Override + protected void onPostExecute(final String contentType) { + // Ask the listener to create a temporary placeholder item to show the progress. + final PendingAttachmentData pendingItem = + PendingAttachmentData.createPendingAttachmentData(contentType, + documentUri); + mListener.onDocumentSelected(pendingItem); + } + }.executeOnThreadPool(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java new file mode 100644 index 0000000..fda3b19 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; + +import com.android.messaging.R; +import com.android.messaging.ui.mediapicker.GalleryGridItemView.HostInterface; +import com.android.messaging.util.Assert; + +/** + * Bridges between the image cursor loaded by GalleryBoundCursorLoader and the GalleryGridView. + */ +public class GalleryGridAdapter extends CursorAdapter { + private GalleryGridItemView.HostInterface mGgivHostInterface; + + public GalleryGridAdapter(final Context context, final Cursor cursor) { + super(context, cursor, 0); + } + + public void setHostInterface(final HostInterface ggivHostInterface) { + mGgivHostInterface = ggivHostInterface; + } + + /** + * {@inheritDoc} + */ + @Override + public void bindView(final View view, final Context context, final Cursor cursor) { + Assert.isTrue(view instanceof GalleryGridItemView); + final GalleryGridItemView galleryImageView = (GalleryGridItemView) view; + galleryImageView.bind(cursor, mGgivHostInterface); + } + + /** + * {@inheritDoc} + */ + @Override + public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + return layoutInflater.inflate(R.layout.gallery_grid_item_view, parent, false); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java new file mode 100644 index 0000000..3d71fe6 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.TouchDelegate; +import android.view.View; +import android.widget.CheckBox; +import android.widget.FrameLayout; +import android.widget.ImageView.ScaleType; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.ConversationDrawables; +import com.google.common.annotations.VisibleForTesting; + +import java.util.concurrent.TimeUnit; + +/** + * Shows an item in the gallery picker grid view. Hosts an FileImageView with a checkbox. + */ +public class GalleryGridItemView extends FrameLayout { + /** + * Implemented by the owner of this GalleryGridItemView instance to communicate on media + * picking and selection events. + */ + public interface HostInterface { + void onItemClicked(View view, GalleryGridItemData data, boolean longClick); + boolean isItemSelected(GalleryGridItemData data); + boolean isMultiSelectEnabled(); + } + + @VisibleForTesting + GalleryGridItemData mData; + private AsyncImageView mImageView; + private CheckBox mCheckBox; + private HostInterface mHostInterface; + private final OnClickListener mOnClickListener = new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onItemClicked(GalleryGridItemView.this, mData, false /*longClick*/); + } + }; + + public GalleryGridItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = DataModel.get().createGalleryGridItemData(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mImageView = (AsyncImageView) findViewById(R.id.image); + mCheckBox = (CheckBox) findViewById(R.id.checkbox); + mCheckBox.setOnClickListener(mOnClickListener); + setOnClickListener(mOnClickListener); + final OnLongClickListener longClickListener = new OnLongClickListener() { + @Override + public boolean onLongClick(final View v) { + mHostInterface.onItemClicked(v, mData, true /* longClick */); + return true; + } + }; + setOnLongClickListener(longClickListener); + mCheckBox.setOnLongClickListener(longClickListener); + addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + // Enlarge the clickable region for the checkbox to fill the entire view. + final Rect region = new Rect(0, 0, getWidth(), getHeight()); + setTouchDelegate(new TouchDelegate(region, mCheckBox) { + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + setPressed(true); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + setPressed(false); + break; + } + return super.onTouchEvent(event); + } + }); + } + }); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + // The grid view auto-fit the columns, so we want to let the height match the width + // to make the image square. + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } + + public void bind(final Cursor cursor, final HostInterface hostInterface) { + final int desiredSize = getResources() + .getDimensionPixelSize(R.dimen.gallery_image_cell_size); + mData.bind(cursor, desiredSize, desiredSize); + mHostInterface = hostInterface; + updateViewState(); + } + + private void updateViewState() { + updateImageView(); + if (mHostInterface.isMultiSelectEnabled() && !mData.isDocumentPickerItem()) { + mCheckBox.setVisibility(VISIBLE); + mCheckBox.setClickable(true); + mCheckBox.setChecked(mHostInterface.isItemSelected(mData)); + } else { + mCheckBox.setVisibility(GONE); + mCheckBox.setClickable(false); + } + } + + private void updateImageView() { + if (mData.isDocumentPickerItem()) { + mImageView.setScaleType(ScaleType.CENTER); + setBackgroundColor(ConversationDrawables.get().getConversationThemeColor()); + mImageView.setImageResourceId(null); + mImageView.setImageResource(R.drawable.ic_photo_library_light); + mImageView.setContentDescription(getResources().getString( + R.string.pick_image_from_document_library_content_description)); + } else { + mImageView.setScaleType(ScaleType.CENTER_CROP); + setBackgroundColor(getResources().getColor(R.color.gallery_image_default_background)); + mImageView.setImageResourceId(mData.getImageRequestDescriptor()); + final long dateSeconds = mData.getDateSeconds(); + final boolean isValidDate = (dateSeconds > 0); + final int templateId = isValidDate ? + R.string.mediapicker_gallery_image_item_description : + R.string.mediapicker_gallery_image_item_description_no_date; + String contentDescription = String.format(getResources().getString(templateId), + dateSeconds * TimeUnit.SECONDS.toMillis(1)); + mImageView.setContentDescription(contentDescription); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java new file mode 100644 index 0000000..a5a7dad --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.util.ArrayMap; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.ui.PersistentInstanceState; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; + +import java.util.Iterator; +import java.util.Map; + +/** + * Shows a list of galley images from external storage in a GridView with multi-select + * capabilities, and with the option to intent out to a standalone image picker. + */ +public class GalleryGridView extends MediaPickerGridView implements + GalleryGridItemView.HostInterface, + PersistentInstanceState, + DraftMessageDataListener { + /** + * Implemented by the owner of this GalleryGridView instance to communicate on image + * picking and multi-image selection events. + */ + public interface GalleryGridViewListener { + void onDocumentPickerItemClicked(); + void onItemSelected(MessagePartData item); + void onItemUnselected(MessagePartData item); + void onConfirmSelection(); + void onUpdate(); + } + + private GalleryGridViewListener mListener; + + // TODO: Consider putting this into the data model object if we add more states. + private final ArrayMap<Uri, MessagePartData> mSelectedImages; + private boolean mIsMultiSelectMode = false; + private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel; + + public GalleryGridView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mSelectedImages = new ArrayMap<Uri, MessagePartData>(); + } + + public void setHostInterface(final GalleryGridViewListener hostInterface) { + mListener = hostInterface; + } + + public void setDraftMessageDataModel(final BindingBase<DraftMessageData> dataModel) { + mDraftMessageDataModel = BindingBase.createBindingReference(dataModel); + mDraftMessageDataModel.getData().addListener(this); + } + + @Override + public void onItemClicked(final View view, final GalleryGridItemData data, + final boolean longClick) { + if (data.isDocumentPickerItem()) { + mListener.onDocumentPickerItemClicked(); + } else if (ContentType.isMediaType(data.getContentType())) { + if (longClick) { + // Turn on multi-select mode when an item is long-pressed. + setMultiSelectEnabled(true); + } + + final Rect startRect = new Rect(); + view.getGlobalVisibleRect(startRect); + if (isMultiSelectEnabled()) { + toggleItemSelection(startRect, data); + } else { + mListener.onItemSelected(data.constructMessagePartData(startRect)); + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, + "Selected item has invalid contentType " + data.getContentType()); + } + } + + @Override + public boolean isItemSelected(final GalleryGridItemData data) { + return mSelectedImages.containsKey(data.getImageUri()); + } + + int getSelectionCount() { + return mSelectedImages.size(); + } + + @Override + public boolean isMultiSelectEnabled() { + return mIsMultiSelectMode; + } + + private void toggleItemSelection(final Rect startRect, final GalleryGridItemData data) { + Assert.isTrue(isMultiSelectEnabled()); + if (isItemSelected(data)) { + final MessagePartData item = mSelectedImages.remove(data.getImageUri()); + mListener.onItemUnselected(item); + if (mSelectedImages.size() == 0) { + // No image is selected any more, turn off multi-select mode. + setMultiSelectEnabled(false); + } + } else { + final MessagePartData item = data.constructMessagePartData(startRect); + mSelectedImages.put(data.getImageUri(), item); + mListener.onItemSelected(item); + } + invalidateViews(); + } + + private void toggleMultiSelect() { + mIsMultiSelectMode = !mIsMultiSelectMode; + invalidateViews(); + } + + private void setMultiSelectEnabled(final boolean enabled) { + if (mIsMultiSelectMode != enabled) { + toggleMultiSelect(); + } + } + + private boolean canToggleMultiSelect() { + // We allow the user to toggle multi-select mode only when nothing has selected. If + // something has been selected, we show a confirm button instead. + return mSelectedImages.size() == 0; + } + + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + inflater.inflate(R.menu.gallery_picker_menu, menu); + final MenuItem toggleMultiSelect = menu.findItem(R.id.action_multiselect); + final MenuItem confirmMultiSelect = menu.findItem(R.id.action_confirm_multiselect); + final boolean canToggleMultiSelect = canToggleMultiSelect(); + toggleMultiSelect.setVisible(canToggleMultiSelect); + confirmMultiSelect.setVisible(!canToggleMultiSelect); + } + + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_multiselect: + Assert.isTrue(canToggleMultiSelect()); + toggleMultiSelect(); + return true; + + case R.id.action_confirm_multiselect: + Assert.isTrue(!canToggleMultiSelect()); + mListener.onConfirmSelection(); + return true; + } + return false; + } + + + @Override + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + mDraftMessageDataModel.ensureBound(data); + // Whenever attachment changed, refresh selection state to remove those that are not + // selected. + if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == + DraftMessageData.ATTACHMENTS_CHANGED) { + refreshImageSelectionStateOnAttachmentChange(); + } + } + + @Override + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + mDraftMessageDataModel.ensureBound(data); + // Whenever draft attachment limit is reach, refresh selection state to remove those + // not actually added to draft. + refreshImageSelectionStateOnAttachmentChange(); + } + + @Override + public void onDraftAttachmentLoadFailed() { + // Nothing to do since the failed attachment gets removed automatically. + } + + private void refreshImageSelectionStateOnAttachmentChange() { + boolean changed = false; + final Iterator<Map.Entry<Uri, MessagePartData>> iterator = + mSelectedImages.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry<Uri, MessagePartData> entry = iterator.next(); + if (!mDraftMessageDataModel.getData().containsAttachment(entry.getKey())) { + iterator.remove(); + changed = true; + } + } + + if (changed) { + mListener.onUpdate(); + invalidateViews(); + } + } + + @Override // PersistentInstanceState + public Parcelable saveState() { + return onSaveInstanceState(); + } + + @Override // PersistentInstanceState + public void restoreState(final Parcelable restoredState) { + onRestoreInstanceState(restoredState); + invalidateViews(); + } + + @Override + public Parcelable onSaveInstanceState() { + final Parcelable superState = super.onSaveInstanceState(); + final SavedState savedState = new SavedState(superState); + savedState.isMultiSelectMode = mIsMultiSelectMode; + savedState.selectedImages = mSelectedImages.values() + .toArray(new MessagePartData[mSelectedImages.size()]); + return savedState; + } + + @Override + public void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + final SavedState savedState = (SavedState) state; + super.onRestoreInstanceState(savedState.getSuperState()); + mIsMultiSelectMode = savedState.isMultiSelectMode; + mSelectedImages.clear(); + for (int i = 0; i < savedState.selectedImages.length; i++) { + final MessagePartData selectedImage = savedState.selectedImages[i]; + mSelectedImages.put(selectedImage.getContentUri(), selectedImage); + } + } + + @Override // PersistentInstanceState + public void resetState() { + mSelectedImages.clear(); + mIsMultiSelectMode = false; + invalidateViews(); + } + + public static class SavedState extends BaseSavedState { + boolean isMultiSelectMode; + MessagePartData[] selectedImages; + + SavedState(final Parcelable superState) { + super(superState); + } + + private SavedState(final Parcel in) { + super(in); + isMultiSelectMode = in.readInt() == 1 ? true : false; + + // Read parts + final int partCount = in.readInt(); + selectedImages = new MessagePartData[partCount]; + for (int i = 0; i < partCount; i++) { + selectedImages[i] = ((MessagePartData) in.readParcelable( + MessagePartData.class.getClassLoader())); + } + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + out.writeInt(isMultiSelectMode ? 1 : 0); + + // Write parts + out.writeInt(selectedImages.length); + for (final MessagePartData image : selectedImages) { + out.writeParcelable(image, flags); + } + } + + public static final Parcelable.Creator<SavedState> CREATOR = + new Parcelable.Creator<SavedState>() { + @Override + public SavedState createFromParcel(final Parcel in) { + return new SavedState(in); + } + @Override + public SavedState[] newArray(final int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java new file mode 100644 index 0000000..9422386 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.MediaPickerData.MediaPickerDataListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; + +/** + * Chooser which allows the user to select one or more existing images or videos + */ +class GalleryMediaChooser extends MediaChooser implements + GalleryGridView.GalleryGridViewListener, MediaPickerDataListener { + private final GalleryGridAdapter mAdapter; + private GalleryGridView mGalleryGridView; + private View mMissingPermissionView; + + GalleryMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + mAdapter = new GalleryGridAdapter(Factory.get().getApplicationContext(), null); + } + + @Override + public int getSupportedMediaTypes() { + return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO; + } + + @Override + public View destroyView() { + mGalleryGridView.setAdapter(null); + mAdapter.setHostInterface(null); + // The loader is started only if startMediaPickerDataLoader() is called + if (OsUtil.hasStoragePermission()) { + mBindingRef.getData().destroyLoader(MediaPickerData.GALLERY_IMAGE_LOADER); + } + return super.destroyView(); + } + + @Override + public int getIconResource() { + return R.drawable.ic_image_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_galleryChooserDescription; + } + + @Override + public boolean canSwipeDown() { + return mGalleryGridView.canSwipeDown(); + } + + @Override + public void onItemSelected(final MessagePartData item) { + mMediaPicker.dispatchItemsSelected(item, !mGalleryGridView.isMultiSelectEnabled()); + } + + @Override + public void onItemUnselected(final MessagePartData item) { + mMediaPicker.dispatchItemUnselected(item); + } + + @Override + public void onConfirmSelection() { + // The user may only confirm if multiselect is enabled. + Assert.isTrue(mGalleryGridView.isMultiSelectEnabled()); + mMediaPicker.dispatchConfirmItemSelection(); + } + + @Override + public void onUpdate() { + mMediaPicker.invalidateOptionsMenu(); + } + + @Override + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + if (mView != null) { + mGalleryGridView.onCreateOptionsMenu(inflater, menu); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + return (mView != null) ? mGalleryGridView.onOptionsItemSelected(item) : false; + } + + @Override + protected View createView(final ViewGroup container) { + final LayoutInflater inflater = getLayoutInflater(); + final View view = inflater.inflate( + R.layout.mediapicker_image_chooser, + container /* root */, + false /* attachToRoot */); + + mGalleryGridView = (GalleryGridView) view.findViewById(R.id.gallery_grid_view); + mAdapter.setHostInterface(mGalleryGridView); + mGalleryGridView.setAdapter(mAdapter); + mGalleryGridView.setHostInterface(this); + mGalleryGridView.setDraftMessageDataModel(mMediaPicker.getDraftMessageDataModel()); + if (OsUtil.hasStoragePermission()) { + startMediaPickerDataLoader(); + } + + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + updateForPermissionState(OsUtil.hasStoragePermission()); + return view; + } + + @Override + int getActionBarTitleResId() { + return R.string.mediapicker_gallery_title; + } + + @Override + public void onDocumentPickerItemClicked() { + mMediaPicker.launchDocumentPicker(); + } + + @Override + void updateActionBar(final ActionBar actionBar) { + super.updateActionBar(actionBar); + if (mGalleryGridView == null) { + return; + } + final int selectionCount = mGalleryGridView.getSelectionCount(); + if (selectionCount > 0 && mGalleryGridView.isMultiSelectEnabled()) { + actionBar.setTitle(getContext().getResources().getString( + R.string.mediapicker_gallery_title_selection, + selectionCount)); + } + } + + @Override + public void onMediaPickerDataUpdated(final MediaPickerData mediaPickerData, final Object data, + final int loaderId) { + mBindingRef.ensureBound(mediaPickerData); + Assert.equals(MediaPickerData.GALLERY_IMAGE_LOADER, loaderId); + Cursor rawCursor = null; + if (data instanceof Cursor) { + rawCursor = (Cursor) data; + } + // Before delivering the cursor, wrap around the local gallery cursor + // with an extra item for document picker integration in the front. + final MatrixCursor specialItemsCursor = + new MatrixCursor(GalleryGridItemData.SPECIAL_ITEM_COLUMNS); + specialItemsCursor.addRow(new Object[] { GalleryGridItemData.ID_DOCUMENT_PICKER_ITEM }); + final MergeCursor cursor = + new MergeCursor(new Cursor[] { specialItemsCursor, rawCursor }); + mAdapter.swapCursor(cursor); + } + + @Override + public void onResume() { + if (OsUtil.hasStoragePermission()) { + // Work around a bug in MediaStore where cursors querying the Files provider don't get + // updated for changes to Images.Media or Video.Media. + startMediaPickerDataLoader(); + } + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected && !OsUtil.hasStoragePermission()) { + mMediaPicker.requestPermissions( + new String[] { Manifest.permission.READ_EXTERNAL_STORAGE }, + MediaPicker.GALLERY_PERMISSION_REQUEST_CODE); + } + } + + private void startMediaPickerDataLoader() { + mBindingRef.getData().startLoader(MediaPickerData.GALLERY_IMAGE_LOADER, mBindingRef, null, + this); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.GALLERY_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + if (permissionGranted) { + startMediaPickerDataLoader(); + } + updateForPermissionState(permissionGranted); + } + } + + private void updateForPermissionState(final boolean granted) { + // onRequestPermissionsResult can sometimes get called before createView(). + if (mGalleryGridView == null) { + return; + } + + mGalleryGridView.setVisibility(granted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java new file mode 100644 index 0000000..45d9579 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +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. + * + * 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 + 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/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java new file mode 100644 index 0000000..637eb84 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +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 com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.exif.ExifInterface; +import com.android.messaging.util.exif.ExifTag; + +import java.io.IOException; +import java.io.OutputStream; + +public class ImagePersistTask extends SafeAsyncTask<Void, Void, Void> { + private static final String JPEG_EXTENSION = "jpg"; + private static final String TAG = LogUtil.BUGLE_TAG; + + private int mWidth; + private int mHeight; + private final float mHeightPercent; + private final byte[] mBytes; + private final Context mContext; + private final CameraManager.MediaCallback mCallback; + private Uri mOutputUri; + private Exception mException; + + public ImagePersistTask( + final int width, + final int height, + final float heightPercent, + final byte[] bytes, + final Context context, + final CameraManager.MediaCallback callback) { + Assert.isTrue(heightPercent >= 0 && heightPercent <= 1); + Assert.notNull(bytes); + Assert.notNull(context); + Assert.notNull(callback); + mWidth = width; + mHeight = height; + mHeightPercent = heightPercent; + mBytes = bytes; + mContext = context; + mCallback = callback; + // TODO: We probably want to store directly in MMS storage to prevent this + // intermediate step + mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(JPEG_EXTENSION); + } + + @Override + protected Void doInBackgroundTimed(final Void... params) { + OutputStream outputStream = null; + Bitmap bitmap = null; + Bitmap clippedBitmap = null; + try { + outputStream = + mContext.getContentResolver().openOutputStream(mOutputUri); + if (mHeightPercent != 1.0f) { + 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(); + } + // The thumbnail is of the full image, but we're cropping it, so just clear + // the thumbnail + exifInterface.setCompressedThumbnail((byte[]) null); + } catch (IOException e) { + // Couldn't get exif tags, not the end of the world + } + bitmap = BitmapFactory.decodeByteArray(mBytes, 0, mBytes.length); + final int clippedWidth; + final int clippedHeight; + if (ExifInterface.getOrientationParams(orientation).invertDimensions) { + Assert.equals(mWidth, bitmap.getHeight()); + Assert.equals(mHeight, bitmap.getWidth()); + clippedWidth = (int) (mHeight * mHeightPercent); + clippedHeight = mWidth; + } else { + Assert.equals(mWidth, bitmap.getWidth()); + Assert.equals(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; + 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(); + // 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 + ExifTag orientationTag = exifInterface.getTag(ExifInterface.TAG_ORIENTATION); + exifInterface.clearExif(); + exifInterface.setTag(orientationTag); + exifInterface.writeExif(clippedBitmap, outputStream); + } else { + outputStream.write(mBytes); + } + } catch (final IOException e) { + mOutputUri = null; + mException = e; + LogUtil.e(TAG, "Unable to persist image to temp storage " + e); + } finally { + if (bitmap != null) { + bitmap.recycle(); + } + + if (clippedBitmap != null) { + clippedBitmap.recycle(); + } + + if (outputStream != null) { + try { + outputStream.flush(); + } catch (final IOException e) { + mOutputUri = null; + mException = e; + LogUtil.e(TAG, "error trying to flush and close the outputStream" + e); + } finally { + try { + outputStream.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } + return null; + } + + @Override + protected void onPostExecute(final Void aVoid) { + if (mOutputUri != null) { + mCallback.onMediaReady(mOutputUri, ContentType.IMAGE_JPEG, mWidth, mHeight); + } else { + Assert.notNull(mException); + mCallback.onMediaFailed(mException); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java new file mode 100644 index 0000000..06730a3 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; + +import java.io.IOException; + +/** + * Wraps around the functionalities of MediaRecorder, performs routine setup for audio recording + * and updates the audio level to be displayed in UI. + * + * During the start and end of a recording session, we kick off a thread that polls for audio + * levels, and updates the thread-safe AudioLevelSource instance. Consumers may bind to the + * sound level by either polling from the level source, or register for a level change callback + * on the level source object. In Bugle, the UI element (SoundLevels) polls for the sound level + * on the UI thread by using animation ticks and invalidating itself. + * + * Aside from tracking sound levels, this also encapsulates the functionality to save the file + * to the scratch space. The saved file is returned by calling stopRecording(). + */ +public class LevelTrackingMediaRecorder { + // We refresh sound level every 100ms during a recording session. + private static final int REFRESH_INTERVAL_MILLIS = 100; + + // The native amplitude returned from MediaRecorder ranges from 0~32768 (unfortunately, this + // is not a constant that's defined anywhere, but the framework's Recorder app is using the + // same hard-coded number). Therefore, a constant is needed in order to make it 0~100. + private static final int MAX_AMPLITUDE_FACTOR = 32768 / 100; + + // We want to limit the max audio file size by the max message size allowed by MmsConfig, + // plus multiplied by this fudge ratio to guarantee that we don't go over limit. + private static final float MAX_SIZE_RATIO = 0.8f; + + // Default recorder settings for Bugle. + // TODO: Do we want these to be tweakable? + private static final int MEDIA_RECORDER_AUDIO_SOURCE = MediaRecorder.AudioSource.MIC; + private static final int MEDIA_RECORDER_OUTPUT_FORMAT = MediaRecorder.OutputFormat.THREE_GPP; + private static final int MEDIA_RECORDER_AUDIO_ENCODER = MediaRecorder.AudioEncoder.AMR_NB; + + private final AudioLevelSource mLevelSource; + private Thread mRefreshLevelThread; + private MediaRecorder mRecorder; + private Uri mOutputUri; + private ParcelFileDescriptor mOutputFD; + + public LevelTrackingMediaRecorder() { + mLevelSource = new AudioLevelSource(); + } + + public AudioLevelSource getLevelSource() { + return mLevelSource; + } + + /** + * @return if we are currently in a recording session. + */ + public boolean isRecording() { + return mRecorder != null; + } + + /** + * Start a new recording session. + * @return true if a session is successfully started; false if something went wrong or if + * we are already recording. + */ + public boolean startRecording(final MediaRecorder.OnErrorListener errorListener, + final MediaRecorder.OnInfoListener infoListener, int maxSize) { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder == null) { + mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( + ContentType.THREE_GPP_EXTENSION); + mRecorder = new MediaRecorder(); + try { + // The scratch space file is a Uri, however MediaRecorder + // API only accepts absolute FD's. Therefore, get the + // FileDescriptor from the content resolver to ensure the + // directory is created and get the file path to output the + // audio to. + maxSize *= MAX_SIZE_RATIO; + mOutputFD = Factory.get().getApplicationContext() + .getContentResolver().openFileDescriptor(mOutputUri, "w"); + mRecorder.setAudioSource(MEDIA_RECORDER_AUDIO_SOURCE); + mRecorder.setOutputFormat(MEDIA_RECORDER_OUTPUT_FORMAT); + mRecorder.setAudioEncoder(MEDIA_RECORDER_AUDIO_ENCODER); + mRecorder.setOutputFile(mOutputFD.getFileDescriptor()); + mRecorder.setMaxFileSize(maxSize); + mRecorder.setOnErrorListener(errorListener); + mRecorder.setOnInfoListener(infoListener); + mRecorder.prepare(); + mRecorder.start(); + startTrackingSoundLevel(); + return true; + } catch (final Exception e) { + // There may be a device failure or I/O failure, record the error but + // don't fail. + LogUtil.e(LogUtil.BUGLE_TAG, "Something went wrong when starting " + + "media recorder. " + e); + UiUtils.showToastAtBottom(R.string.audio_recording_start_failed); + stopRecording(); + } + } else { + Assert.fail("Trying to start a new recording session while already recording!"); + } + return false; + } + } + + /** + * Stop the current recording session. + * @return the Uri of the output file, or null if not currently recording. + */ + public Uri stopRecording() { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + try { + mRecorder.stop(); + } catch (final RuntimeException ex) { + // This may happen when the recording is too short, so just drop the recording + // in this case. + LogUtil.w(LogUtil.BUGLE_TAG, "Something went wrong when stopping " + + "media recorder. " + ex); + if (mOutputUri != null) { + final Uri outputUri = mOutputUri; + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + outputUri, null, null); + } + }); + mOutputUri = null; + } + } finally { + mRecorder.release(); + mRecorder = null; + } + } else { + Assert.fail("Not currently recording!"); + return null; + } + } + + if (mOutputFD != null) { + try { + mOutputFD.close(); + } catch (final IOException e) { + // Nothing to do + } + mOutputFD = null; + } + + stopTrackingSoundLevel(); + return mOutputUri; + } + + private int getAmplitude() { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + final int maxAmplitude = mRecorder.getMaxAmplitude() / MAX_AMPLITUDE_FACTOR; + return Math.min(maxAmplitude, 100); + } else { + return 0; + } + } + } + + private void startTrackingSoundLevel() { + stopTrackingSoundLevel(); + mRefreshLevelThread = new Thread() { + @Override + public void run() { + try { + while (true) { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + mLevelSource.setSpeechLevel(getAmplitude()); + } else { + // The recording session is over, finish the thread. + return; + } + } + Thread.sleep(REFRESH_INTERVAL_MILLIS); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }; + mRefreshLevelThread.start(); + } + + private void stopTrackingSoundLevel() { + if (mRefreshLevelThread != null && mRefreshLevelThread.isAlive()) { + mRefreshLevelThread.interrupt(); + mRefreshLevelThread = null; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaChooser.java b/src/com/android/messaging/ui/mediapicker/MediaChooser.java new file mode 100644 index 0000000..9ac0d1b --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaChooser.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.app.FragmentManager; +import android.content.Context; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.ui.BasePagerViewHolder; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; + +abstract class MediaChooser extends BasePagerViewHolder + implements DraftMessageSubscriptionDataProvider { + /** The media picker that the chooser is hosted in */ + protected final MediaPicker mMediaPicker; + + /** Referencing the main media picker binding to perform data loading */ + protected final ImmutableBindingRef<MediaPickerData> mBindingRef; + + /** True if this is the selected chooser */ + protected boolean mSelected; + + /** True if this chooser is open */ + protected boolean mOpen; + + /** The button to show in the tab strip */ + private ImageButton mTabButton; + + /** Used by subclasses to indicate that no loader is required from the data model in order for + * this chooser to function. + */ + public static final int NO_LOADER_REQUIRED = -1; + + /** + * Initializes a new instance of the Chooser class + * @param mediaPicker The media picker that the chooser is hosted in + */ + MediaChooser(final MediaPicker mediaPicker) { + Assert.notNull(mediaPicker); + mMediaPicker = mediaPicker; + mBindingRef = mediaPicker.getMediaPickerDataBinding(); + mSelected = false; + } + + protected void setSelected(final boolean selected) { + mSelected = selected; + if (selected) { + // If we're selected, it must be open + mOpen = true; + } + if (mTabButton != null) { + mTabButton.setSelected(selected); + mTabButton.setAlpha(selected ? 1 : 0.5f); + } + } + + ImageButton getTabButton() { + return mTabButton; + } + + void onCreateTabButton(final LayoutInflater inflater, final ViewGroup parent) { + mTabButton = (ImageButton) inflater.inflate( + R.layout.mediapicker_tab_button, + parent, + false /* addToParent */); + mTabButton.setImageResource(getIconResource()); + mTabButton.setContentDescription( + inflater.getContext().getResources().getString(getIconDescriptionResource())); + setSelected(mSelected); + mTabButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mMediaPicker.selectChooser(MediaChooser.this); + } + }); + } + + protected Context getContext() { + return mMediaPicker.getActivity(); + } + + protected FragmentManager getFragmentManager() { + return OsUtil.isAtLeastJB_MR1() ? mMediaPicker.getChildFragmentManager() : + mMediaPicker.getFragmentManager(); + } + protected LayoutInflater getLayoutInflater() { + return LayoutInflater.from(getContext()); + } + + /** Allows the chooser to handle full screen change */ + void onFullScreenChanged(final boolean fullScreen) {} + + /** Allows the chooser to handle the chooser being opened or closed */ + void onOpenedChanged(final boolean open) { + mOpen = open; + } + + /** @return The bit field of media types that this chooser can pick */ + public abstract int getSupportedMediaTypes(); + + /** @return The resource id of the icon for the chooser */ + abstract int getIconResource(); + + /** @return The resource id of the string to use for the accessibility text of the icon */ + abstract int getIconDescriptionResource(); + + /** + * Sets up the action bar to show the current state of the full-screen chooser + * @param actionBar The action bar to populate + */ + void updateActionBar(final ActionBar actionBar) { + final int actionBarTitleResId = getActionBarTitleResId(); + if (actionBarTitleResId == 0) { + actionBar.hide(); + } else { + actionBar.setCustomView(null); + actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.show(); + // Use X instead of <- in the action bar + actionBar.setHomeAsUpIndicator(R.drawable.ic_remove_small_light); + actionBar.setTitle(actionBarTitleResId); + } + } + + /** + * Returns the resource Id used for the action bar title. + */ + abstract int getActionBarTitleResId(); + + /** + * Throws an exception if the media chooser object doesn't require data support. + */ + public void onDataUpdated(final Object data, final int loaderId) { + throw new IllegalStateException(); + } + + /** + * Called by the MediaPicker to determine whether this panel can be swiped down further. If + * not, then a swipe down gestured will be captured by the MediaPickerPanel to shrink the + * entire panel. + */ + public boolean canSwipeDown() { + return false; + } + + /** + * Typically the media picker is closed when the IME is opened, but this allows the chooser to + * specify that showing the IME is okay while the chooser is up + */ + public boolean canShowIme() { + return false; + } + + public boolean onBackPressed() { + return false; + } + + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + } + + public boolean onOptionsItemSelected(final MenuItem item) { + return false; + } + + public void setThemeColor(final int color) { + } + + /** + * Returns true if the chooser is owning any incoming touch events, so that the media picker + * panel won't process it and slide the panel. + */ + public boolean isHandlingTouch() { + return false; + } + + public void stopTouchHandling() { + } + + @Override + public int getConversationSelfSubId() { + return mMediaPicker.getConversationSelfSubId(); + } + + /** Optional activity life-cycle methods to be overridden by subclasses */ + public void onPause() { } + public void onResume() { } + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPicker.java b/src/com/android/messaging/ui/mediapicker/MediaPicker.java new file mode 100644 index 0000000..f441d09 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPicker.java @@ -0,0 +1,736 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.FixedViewPagerAdapter; +import com.android.messaging.ui.mediapicker.DocumentImagePicker.SelectionListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Fragment used to select or capture media to be added to the message + */ +public class MediaPicker extends Fragment implements DraftMessageSubscriptionDataProvider { + /** The listener interface for events from the media picker */ + public interface MediaPickerListener { + /** Called when the media picker is opened so the host can accommodate the UI */ + void onOpened(); + + /** + * Called when the media picker goes into or leaves full screen mode so the host can + * accommodate the fullscreen UI + */ + void onFullScreenChanged(boolean fullScreen); + + /** + * Called when the user selects one or more items + * @param items The list of items which were selected + */ + void onItemsSelected(Collection<MessagePartData> items, boolean dismissMediaPicker); + + /** + * Called when the user unselects one item. + */ + void onItemUnselected(MessagePartData item); + + /** + * Called when the media picker is closed. Always called immediately after onItemsSelected + */ + void onDismissed(); + + /** + * Called when media item selection is confirmed in a multi-select action. + */ + void onConfirmItemSelection(); + + /** + * Called when a pending attachment is added. + * @param pendingItem the pending attachment data being loaded. + */ + void onPendingItemAdded(PendingAttachmentData pendingItem); + + /** + * Called when a new media chooser is selected. + */ + void onChooserSelected(final int chooserIndex); + } + + /** The tag used when registering and finding this fragment */ + public static final String FRAGMENT_TAG = "mediapicker"; + + // Media type constants that the media picker supports + public static final int MEDIA_TYPE_DEFAULT = 0x0000; + public static final int MEDIA_TYPE_NONE = 0x0000; + public static final int MEDIA_TYPE_IMAGE = 0x0001; + public static final int MEDIA_TYPE_VIDEO = 0x0002; + public static final int MEDIA_TYPE_AUDIO = 0x0004; + public static final int MEDIA_TYPE_VCARD = 0x0008; + public static final int MEDIA_TYPE_LOCATION = 0x0010; + private static final int MEDA_TYPE_INVALID = 0x0020; + public static final int MEDIA_TYPE_ALL = 0xFFFF; + + /** The listener to call when events occur */ + private MediaPickerListener mListener; + + /** The handler used to dispatch calls to the listener */ + private Handler mListenerHandler; + + /** The bit flags of media types supported */ + private int mSupportedMediaTypes; + + /** The list of choosers which could be within the media picker */ + private final MediaChooser[] mChoosers; + + /** The list of currently enabled choosers */ + private final ArrayList<MediaChooser> mEnabledChoosers; + + /** The currently selected chooser */ + private MediaChooser mSelectedChooser; + + /** The main panel that controls the custom layout */ + private MediaPickerPanel mMediaPickerPanel; + + /** The linear layout that holds the icons to select individual chooser tabs */ + private LinearLayout mTabStrip; + + /** The view pager to swap between choosers */ + private ViewPager mViewPager; + + /** The current pager adapter for the view pager */ + private FixedViewPagerAdapter<MediaChooser> mPagerAdapter; + + /** True if the media picker is visible */ + private boolean mOpen; + + /** The theme color to use to make the media picker match the rest of the UI */ + private int mThemeColor; + + @VisibleForTesting + final Binding<MediaPickerData> mBinding = BindingBase.createBinding(this); + + /** Handles picking image from the document picker */ + private DocumentImagePicker mDocumentImagePicker; + + /** Provides subscription-related data to access per-subscription configurations. */ + private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; + + /** Provides access to DraftMessageData associated with the current conversation */ + private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel; + + public MediaPicker() { + this(Factory.get().getApplicationContext()); + } + + public MediaPicker(final Context context) { + mBinding.bind(DataModel.get().createMediaPickerData(context)); + mEnabledChoosers = new ArrayList<MediaChooser>(); + mChoosers = new MediaChooser[] { + new CameraMediaChooser(this), + new GalleryMediaChooser(this), + new AudioMediaChooser(this), + }; + + mOpen = false; + setSupportedMediaTypes(MEDIA_TYPE_ALL); + } + + private boolean mIsAttached; + private int mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID; + private boolean mAnimateOnAttach; + + @Override + public void onAttach (final Activity activity) { + super.onAttach(activity); + mIsAttached = true; + if (mStartingMediaTypeOnAttach != MEDA_TYPE_INVALID) { + // open() was previously called. Do the pending open now. + doOpen(mStartingMediaTypeOnAttach, mAnimateOnAttach); + } + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBinding.getData().init(getLoaderManager()); + mDocumentImagePicker = new DocumentImagePicker(this, + new SelectionListener() { + @Override + public void onDocumentSelected(final PendingAttachmentData data) { + if (mBinding.isBound()) { + dispatchPendingItemAdded(data); + } + } + }); + } + + @Override + public View onCreateView( + final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + mMediaPickerPanel = (MediaPickerPanel) inflater.inflate( + R.layout.mediapicker_fragment, + container, + false); + mMediaPickerPanel.setMediaPicker(this); + mTabStrip = (LinearLayout) mMediaPickerPanel.findViewById(R.id.mediapicker_tabstrip); + mTabStrip.setBackgroundColor(mThemeColor); + for (final MediaChooser chooser : mChoosers) { + chooser.onCreateTabButton(inflater, mTabStrip); + final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != + MEDIA_TYPE_NONE; + final ImageButton tabButton = chooser.getTabButton(); + if (tabButton != null) { + tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); + mTabStrip.addView(tabButton); + } + } + + mViewPager = (ViewPager) mMediaPickerPanel.findViewById(R.id.mediapicker_view_pager); + mViewPager.setOnPageChangeListener(new ViewPager.OnPageChangeListener() { + @Override + public void onPageScrolled( + final int position, + final float positionOffset, + final int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + // The position returned is relative to if we are in RtL mode. This class never + // switches the indices of the elements if we are in RtL mode so we need to + // translate the index back. For example, if the user clicked the item most to the + // right in RtL mode we would want the index to appear as 0 here, however the + // position returned would the last possible index. + if (UiUtils.isRtlMode()) { + position = mEnabledChoosers.size() - 1 - position; + } + selectChooser(mEnabledChoosers.get(position)); + } + + @Override + public void onPageScrollStateChanged(final int state) { + } + }); + // Camera initialization is expensive, so don't realize offscreen pages if not needed. + mViewPager.setOffscreenPageLimit(0); + mViewPager.setAdapter(mPagerAdapter); + final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( + getActivity()); + mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); + mMediaPickerPanel.setExpanded(mOpen, true, mEnabledChoosers.indexOf(mSelectedChooser)); + return mMediaPickerPanel; + } + + @Override + public void onPause() { + super.onPause(); + CameraManager.get().onPause(); + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.onPause(); + } + } + + @Override + public void onResume() { + super.onResume(); + CameraManager.get().onResume(); + + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.onResume(); + } + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + mDocumentImagePicker.onActivityResult(requestCode, resultCode, data); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + /** + * Sets the theme color to make the media picker match the surrounding UI + * @param themeColor The new theme color + */ + public void setConversationThemeColor(final int themeColor) { + mThemeColor = themeColor; + if (mTabStrip != null) { + mTabStrip.setBackgroundColor(mThemeColor); + } + + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.setThemeColor(mThemeColor); + } + } + + /** + * Gets the current conversation theme color. + */ + public int getConversationThemeColor() { + return mThemeColor; + } + + public void setDraftMessageDataModel(final BindingBase<DraftMessageData> draftBinding) { + mDraftMessageDataModel = Binding.createBindingReference(draftBinding); + } + + public ImmutableBindingRef<DraftMessageData> getDraftMessageDataModel() { + return mDraftMessageDataModel; + } + + public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { + mSubscriptionDataProvider = provider; + } + + @Override + public int getConversationSelfSubId() { + return mSubscriptionDataProvider.getConversationSelfSubId(); + } + + /** + * Opens the media picker and optionally shows the chooser for the supplied media type + * @param startingMediaType The media type of the chooser to open if {@link #MEDIA_TYPE_DEFAULT} + * is used, then the default chooser from saved shared prefs is opened + */ + public void open(final int startingMediaType, final boolean animate) { + mOpen = true; + if (mIsAttached) { + doOpen(startingMediaType, animate); + } else { + // open() can get called immediately after the MediaPicker is created. In that case, + // we defer doing work as it may require an attached fragment (eg. calling + // Fragment#requestPermission) + mStartingMediaTypeOnAttach = startingMediaType; + mAnimateOnAttach = animate; + } + } + + private void doOpen(int startingMediaType, final boolean animate) { + final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( + // getActivity() will be null at this point + Factory.get().getApplicationContext()); + + // If no specific starting type is specified (i.e. MEDIA_TYPE_DEFAULT), try to get the + // last opened chooser index from shared prefs. + if (startingMediaType == MEDIA_TYPE_DEFAULT) { + final int selectedChooserIndex = mBinding.getData().getSelectedChooserIndex(); + if (selectedChooserIndex >= 0 && selectedChooserIndex < mEnabledChoosers.size()) { + selectChooser(mEnabledChoosers.get(selectedChooserIndex)); + } else { + // This is the first time the picker is being used + if (isTouchExplorationEnabled) { + // Accessibility defaults to audio attachment mode. + startingMediaType = MEDIA_TYPE_AUDIO; + } + } + } + + if (mSelectedChooser == null) { + for (final MediaChooser chooser : mEnabledChoosers) { + if (startingMediaType == MEDIA_TYPE_DEFAULT || + (startingMediaType & chooser.getSupportedMediaTypes()) != MEDIA_TYPE_NONE) { + selectChooser(chooser); + break; + } + } + } + + if (mSelectedChooser == null) { + // Fall back to the first chooser. + selectChooser(mEnabledChoosers.get(0)); + } + + if (mMediaPickerPanel != null) { + mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); + mMediaPickerPanel.setExpanded(true, animate, + mEnabledChoosers.indexOf(mSelectedChooser)); + } + } + + /** @return True if the media picker is open */ + public boolean isOpen() { + return mOpen; + } + + /** + * Sets the list of media types to allow the user to select + * @param mediaTypes The bit flags of media types to allow. Can be any combination of the + * MEDIA_TYPE_* values + */ + void setSupportedMediaTypes(final int mediaTypes) { + mSupportedMediaTypes = mediaTypes; + mEnabledChoosers.clear(); + boolean selectNextChooser = false; + for (final MediaChooser chooser : mChoosers) { + final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != + MEDIA_TYPE_NONE; + if (enabled) { + // TODO Add a way to inform the chooser which media types are supported + mEnabledChoosers.add(chooser); + if (selectNextChooser) { + selectChooser(chooser); + selectNextChooser = false; + } + } else if (mSelectedChooser == chooser) { + selectNextChooser = true; + } + final ImageButton tabButton = chooser.getTabButton(); + if (tabButton != null) { + tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); + } + } + + if (selectNextChooser && mEnabledChoosers.size() > 0) { + selectChooser(mEnabledChoosers.get(0)); + } + final MediaChooser[] enabledChoosers = new MediaChooser[mEnabledChoosers.size()]; + mEnabledChoosers.toArray(enabledChoosers); + mPagerAdapter = new FixedViewPagerAdapter<MediaChooser>(enabledChoosers); + if (mViewPager != null) { + mViewPager.setAdapter(mPagerAdapter); + } + + // Only rebind data if we are currently bound. Otherwise, we must have not + // bound to any data yet and should wait until onCreate() to bind data. + if (mBinding.isBound() && getActivity() != null) { + mBinding.unbind(); + mBinding.bind(DataModel.get().createMediaPickerData(getActivity())); + mBinding.getData().init(getLoaderManager()); + } + } + + ViewPager getViewPager() { + return mViewPager; + } + + /** Hides the media picker, and frees up any resources it’s using */ + public void dismiss(final boolean animate) { + mOpen = false; + if (mMediaPickerPanel != null) { + mMediaPickerPanel.setExpanded(false, animate, MediaPickerPanel.PAGE_NOT_SET); + } + mSelectedChooser = null; + } + + /** + * Sets the listener for the media picker events + * @param listener The listener which will receive events + */ + public void setListener(final MediaPickerListener listener) { + Assert.isMainThread(); + mListener = listener; + mListenerHandler = listener != null ? new Handler() : null; + } + + /** @return True if the media picker is in full-screen mode */ + public boolean isFullScreen() { + return mMediaPickerPanel != null && mMediaPickerPanel.isFullScreen(); + } + + public void setFullScreen(final boolean fullScreen) { + mMediaPickerPanel.setFullScreenView(fullScreen, true); + } + + public void updateActionBar(final ActionBar actionBar) { + if (getActivity() == null) { + return; + } + if (isFullScreen() && mSelectedChooser != null) { + mSelectedChooser.updateActionBar(actionBar); + } else { + actionBar.hide(); + } + } + + /** + * Selects a new chooser + * @param newSelectedChooser The newly selected chooser + */ + void selectChooser(final MediaChooser newSelectedChooser) { + if (mSelectedChooser == newSelectedChooser) { + return; + } + + if (mSelectedChooser != null) { + mSelectedChooser.setSelected(false); + } + mSelectedChooser = newSelectedChooser; + if (mSelectedChooser != null) { + mSelectedChooser.setSelected(true); + } + + final int chooserIndex = mEnabledChoosers.indexOf(mSelectedChooser); + if (mViewPager != null) { + mViewPager.setCurrentItem(chooserIndex, true /* smoothScroll */); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + + // Save the newly selected chooser's index so we may directly switch to it the + // next time user opens the media picker. + mBinding.getData().saveSelectedChooserIndex(chooserIndex); + if (mMediaPickerPanel != null) { + mMediaPickerPanel.onChooserChanged(); + } + dispatchChooserSelected(chooserIndex); + } + + public boolean canShowIme() { + if (mSelectedChooser != null) { + return mSelectedChooser.canShowIme(); + } + return false; + } + + public boolean onBackPressed() { + return mSelectedChooser != null && mSelectedChooser.onBackPressed(); + } + + void invalidateOptionsMenu() { + ((BugleActionBarActivity) getActivity()).supportInvalidateOptionsMenu(); + } + + void dispatchOpened() { + setHasOptionsMenu(false); + mOpen = true; + mPagerAdapter.notifyDataSetChanged(); + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onOpened(); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onFullScreenChanged(false); + mSelectedChooser.onOpenedChanged(true); + } + } + + void dispatchDismissed() { + setHasOptionsMenu(false); + mOpen = false; + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onDismissed(); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onOpenedChanged(false); + } + } + + void dispatchFullScreen(final boolean fullScreen) { + setHasOptionsMenu(fullScreen); + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onFullScreenChanged(fullScreen); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onFullScreenChanged(fullScreen); + } + } + + void dispatchItemsSelected(final MessagePartData item, final boolean dismissMediaPicker) { + final List<MessagePartData> items = new ArrayList<MessagePartData>(1); + items.add(item); + dispatchItemsSelected(items, dismissMediaPicker); + } + + void dispatchItemsSelected(final Collection<MessagePartData> items, + final boolean dismissMediaPicker) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onItemsSelected(items, dismissMediaPicker); + } + }); + } + + if (isFullScreen() && !dismissMediaPicker) { + invalidateOptionsMenu(); + } + } + + void dispatchItemUnselected(final MessagePartData item) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onItemUnselected(item); + } + }); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + } + + void dispatchConfirmItemSelection() { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onConfirmItemSelection(); + } + }); + } + } + + void dispatchPendingItemAdded(final PendingAttachmentData pendingItem) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onPendingItemAdded(pendingItem); + } + }); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + } + + void dispatchChooserSelected(final int chooserIndex) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onChooserSelected(chooserIndex); + } + }); + } + } + + public boolean canSwipeDownChooser() { + return mSelectedChooser == null ? false : mSelectedChooser.canSwipeDown(); + } + + public boolean isChooserHandlingTouch() { + return mSelectedChooser == null ? false : mSelectedChooser.isHandlingTouch(); + } + + public void stopChooserTouchHandling() { + if (mSelectedChooser != null) { + mSelectedChooser.stopTouchHandling(); + } + } + + boolean getChooserShowsActionBarInFullScreen() { + return mSelectedChooser == null ? false : mSelectedChooser.getActionBarTitleResId() != 0; + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (mSelectedChooser != null) { + mSelectedChooser.onCreateOptionsMenu(inflater, menu); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + return (mSelectedChooser != null && mSelectedChooser.onOptionsItemSelected(item)) || + super.onOptionsItemSelected(item); + } + + PagerAdapter getPagerAdapter() { + return mPagerAdapter; + } + + public void resetViewHolderState() { + mPagerAdapter.resetState(); + } + + /** + * Launch an external picker to pick item from document picker as attachment. + */ + public void launchDocumentPicker() { + mDocumentImagePicker.launchPicker(); + } + + public ImmutableBindingRef<MediaPickerData> getMediaPickerDataBinding() { + return BindingBase.createBindingReference(mBinding); + } + + protected static final int CAMERA_PERMISSION_REQUEST_CODE = 1; + protected static final int LOCATION_PERMISSION_REQUEST_CODE = 2; + protected static final int RECORD_AUDIO_PERMISSION_REQUEST_CODE = 3; + protected static final int GALLERY_PERMISSION_REQUEST_CODE = 4; + + @Override + public void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (mSelectedChooser != null) { + mSelectedChooser.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java new file mode 100644 index 0000000..cc3a4a1 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.GridView; + +public class MediaPickerGridView extends GridView { + + public MediaPickerGridView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + /** + * Returns if the grid view can be swiped down further. It cannot be swiped down + * if there's no item or if we are already at the top. + */ + public boolean canSwipeDown() { + if (getAdapter() == null || getAdapter().getCount() == 0 || getChildCount() == 0) { + return false; + } + + final int firstVisiblePosition = getFirstVisiblePosition(); + if (firstVisiblePosition == 0 && getChildAt(0).getTop() >= 0) { + return false; + } + return true; + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java new file mode 100644 index 0000000..56b0a03 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java @@ -0,0 +1,563 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.content.res.Resources; +import android.os.Handler; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Transformation; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.ui.PagingAwareViewPager; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * Custom layout panel which makes the MediaPicker animations seamless and synchronized + * Designed to be very specific to the MediaPicker's usage + */ +public class MediaPickerPanel extends ViewGroup { + /** + * The window of time in which we might to decide to reinterpret the intent of a gesture. + */ + private static final long TOUCH_RECAPTURE_WINDOW_MS = 500L; + + // The two view components to layout + private LinearLayout mTabStrip; + private boolean mFullScreenOnly; + private PagingAwareViewPager mViewPager; + + /** + * True if the MediaPicker is full screen or animating into it + */ + private boolean mFullScreen; + + /** + * True if the MediaPicker is open at all + */ + private boolean mExpanded; + + /** + * The current desired height of the MediaPicker. This property may be animated and the + * measure pass uses it to determine what size the components are. + */ + private int mCurrentDesiredHeight; + + private final Handler mHandler = new Handler(); + + /** + * The media picker for dispatching events to the MediaPicker's listener + */ + private MediaPicker mMediaPicker; + + /** + * The computed default "half-screen" height of the view pager in px + */ + private final int mDefaultViewPagerHeight; + + /** + * The action bar height used to compute the padding on the view pager when it's full screen. + */ + private final int mActionBarHeight; + + private TouchHandler mTouchHandler; + + static final int PAGE_NOT_SET = -1; + + public MediaPickerPanel(final Context context, final AttributeSet attrs) { + super(context, attrs); + // Cache the computed dimension + mDefaultViewPagerHeight = getResources().getDimensionPixelSize( + R.dimen.mediapicker_default_chooser_height); + mActionBarHeight = getResources().getDimensionPixelSize(R.dimen.action_bar_height); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mTabStrip = (LinearLayout) findViewById(R.id.mediapicker_tabstrip); + mViewPager = (PagingAwareViewPager) findViewById(R.id.mediapicker_view_pager); + mTouchHandler = new TouchHandler(); + setOnTouchListener(mTouchHandler); + mViewPager.setOnTouchListener(mTouchHandler); + + // Make sure full screen mode is updated in landscape mode change when the panel is open. + addOnLayoutChangeListener(new OnLayoutChangeListener() { + private boolean mLandscapeMode = UiUtils.isLandscapeMode(); + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + final boolean newLandscapeMode = UiUtils.isLandscapeMode(); + if (mLandscapeMode != newLandscapeMode) { + mLandscapeMode = newLandscapeMode; + if (mExpanded) { + setExpanded(mExpanded, false /* animate */, mViewPager.getCurrentItem(), + true /* force */); + } + } + } + }); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + int requestedHeight = MeasureSpec.getSize(heightMeasureSpec); + if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { + requestedHeight -= mActionBarHeight; + } + int desiredHeight = Math.min(mCurrentDesiredHeight, requestedHeight); + if (mExpanded && desiredHeight == 0) { + // If we want to be shown, we have to have a non-0 height. Returning a height of 0 will + // cause the framework to abort the animation from 0, so we must always have some + // height once we start expanding + desiredHeight = 1; + } else if (!mExpanded && desiredHeight == 0) { + mViewPager.setVisibility(View.GONE); + mViewPager.setAdapter(null); + } + + measureChild(mTabStrip, widthMeasureSpec, heightMeasureSpec); + + int tabStripHeight; + if (requiresFullScreen()) { + // Ensure that the tab strip is always visible, even in full screen. + tabStripHeight = mTabStrip.getMeasuredHeight(); + } else { + // Slide out the tab strip at the end of the animation to full screen. + tabStripHeight = Math.min(mTabStrip.getMeasuredHeight(), + requestedHeight - desiredHeight); + } + + // If we are animating and have an interim desired height, use the default height. We can't + // take the max here as on some devices the mDefaultViewPagerHeight may be too big in + // landscape mode after animation. + final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight; + final int viewPagerHeight = + tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight; + + int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + viewPagerHeight, MeasureSpec.EXACTLY); + measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec); + setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + int y = top; + final int width = right - left; + + final int viewPagerHeight = mViewPager.getMeasuredHeight(); + mViewPager.layout(0, y, width, y + viewPagerHeight); + y += viewPagerHeight; + + mTabStrip.layout(0, y, width, y + mTabStrip.getMeasuredHeight()); + } + + void onChooserChanged() { + if (mFullScreen) { + setDesiredHeight(getDesiredHeight(), true); + } + } + + void setFullScreenOnly(boolean fullScreenOnly) { + mFullScreenOnly = fullScreenOnly; + } + + boolean isFullScreen() { + return mFullScreen; + } + + void setMediaPicker(final MediaPicker mediaPicker) { + mMediaPicker = mediaPicker; + } + + /** + * Get the desired height of the media picker panel for when the panel is not in motion (i.e. + * not being dragged by the user). + */ + private int getDesiredHeight() { + if (mFullScreen) { + int fullHeight = getContext().getResources().getDisplayMetrics().heightPixels; + if (OsUtil.isAtLeastKLP() && isAttachedToWindow()) { + // When we're attached to the window, we can get an accurate height, not necessary + // on older API level devices because they don't include the action bar height + View composeContainer = + getRootView().findViewById(R.id.conversation_and_compose_container); + if (composeContainer != null) { + // protect against composeContainer having been unloaded already + fullHeight -= UiUtils.getMeasuredBoundsOnScreen(composeContainer).top; + } + } + if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { + return fullHeight - mActionBarHeight; + } else { + return fullHeight; + } + } else if (mExpanded) { + return LayoutParams.WRAP_CONTENT; + } else { + return 0; + } + } + + private void setupViewPager(final int startingPage) { + mViewPager.setVisibility(View.VISIBLE); + if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) { + mViewPager.setAdapter(mMediaPicker.getPagerAdapter()); + mViewPager.setCurrentItem(startingPage); + } + updateViewPager(); + } + + /** + * Expand the media picker panel. Since we always set the pager adapter to null when the panel + * is collapsed, we need to restore the adapter and the starting page. + * @param expanded expanded or collapsed + * @param animate need animation + * @param startingPage the desired selected page to start + */ + void setExpanded(final boolean expanded, final boolean animate, final int startingPage) { + setExpanded(expanded, animate, startingPage, false /* force */); + } + + private void setExpanded(final boolean expanded, final boolean animate, final int startingPage, + final boolean force) { + if (expanded == mExpanded && !force) { + return; + } + mFullScreen = false; + mExpanded = expanded; + mHandler.post(new Runnable() { + @Override + public void run() { + setDesiredHeight(getDesiredHeight(), animate); + } + }); + if (expanded) { + setupViewPager(startingPage); + mMediaPicker.dispatchOpened(); + } else { + mMediaPicker.dispatchDismissed(); + } + + // Call setFullScreenView() when we are in landscape mode so it can go full screen as + // soon as it is expanded. + if (expanded && requiresFullScreen()) { + setFullScreenView(true, animate); + } + } + + private boolean requiresFullScreen() { + return mFullScreenOnly || UiUtils.isLandscapeMode(); + } + + private void setDesiredHeight(int height, final boolean animate) { + final int startHeight = mCurrentDesiredHeight; + if (height == LayoutParams.WRAP_CONTENT) { + height = measureHeight(); + } + clearAnimation(); + if (animate) { + final int deltaHeight = height - startHeight; + final Animation animation = new Animation() { + @Override + protected void applyTransformation(final float interpolatedTime, + final Transformation t) { + mCurrentDesiredHeight = (int) (startHeight + deltaHeight * interpolatedTime); + requestLayout(); + } + + @Override + public boolean willChangeBounds() { + return true; + } + }; + animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); + animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); + startAnimation(animation); + } else { + mCurrentDesiredHeight = height; + } + requestLayout(); + } + + /** + * @return The minimum total height of the view + */ + private int measureHeight() { + final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST); + measureChild(mTabStrip, measureSpec, measureSpec); + return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight(); + } + + /** + * Enters or leaves full screen view + * + * @param fullScreen True to enter full screen view, false to leave + * @param animate True to animate the transition + */ + void setFullScreenView(final boolean fullScreen, final boolean animate) { + if (fullScreen == mFullScreen) { + return; + } + + if (requiresFullScreen() && !fullScreen) { + setExpanded(false /* expanded */, true /* animate */, PAGE_NOT_SET); + return; + } + mFullScreen = fullScreen; + setDesiredHeight(getDesiredHeight(), animate); + mMediaPicker.dispatchFullScreen(mFullScreen); + updateViewPager(); + } + + /** + * ViewPager should have its paging disabled when in full screen mode. + */ + private void updateViewPager() { + mViewPager.setPagingEnabled(!mFullScreen); + } + + @Override + public boolean onInterceptTouchEvent(final MotionEvent ev) { + return mTouchHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev); + } + + /** + * Helper class to handle touch events and swipe gestures + */ + private class TouchHandler implements OnTouchListener { + /** + * The height of the view when the touch press started + */ + private int mDownHeight = -1; + + /** + * True if the panel moved at all (changed height) during the drag + */ + private boolean mMoved = false; + + // The threshold constants converted from DP to px + private final float mFlingThresholdPx; + private final float mBigFlingThresholdPx; + + // The system defined pixel size to determine when a movement is considered a drag. + private final int mTouchSlop; + + /** + * A copy of the MotionEvent that started the drag/swipe gesture + */ + private MotionEvent mDownEvent; + + /** + * Whether we are currently moving down. We may not be able to move down in full screen + * mode when the child view can swipe down (such as a list view). + */ + private boolean mMovedDown = false; + + /** + * Indicates whether the child view contained in the panel can swipe down at the beginning + * of the drag event (i.e. the initial down). The MediaPanel can contain + * scrollable children such as a list view / grid view. If the child view can swipe down, + * We want to let the child view handle the scroll first instead of handling it ourselves. + */ + private boolean mCanChildViewSwipeDown = false; + + /** + * Necessary direction ratio for a fling to be considered in one direction this prevents + * horizontal swipes with small vertical components from triggering vertical swipe actions + */ + private static final float DIRECTION_RATIO = 1.1f; + + TouchHandler() { + final Resources resources = getContext().getResources(); + final ViewConfiguration configuration = ViewConfiguration.get(getContext()); + mFlingThresholdPx = resources.getDimensionPixelSize( + R.dimen.mediapicker_fling_threshold); + mBigFlingThresholdPx = resources.getDimensionPixelSize( + R.dimen.mediapicker_big_fling_threshold); + mTouchSlop = configuration.getScaledTouchSlop(); + } + + /** + * The media picker panel may contain scrollable children such as a GridView, which eats + * all touch events before we get to it. Therefore, we'd like to intercept these events + * before the children to determine if we should handle swiping down in full screen mode. + * In non-full screen mode, we should handle all vertical scrolling events and leave + * horizontal scrolling to the view pager. + */ + public boolean onInterceptTouchEvent(final MotionEvent ev) { + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + // Never capture the initial down, so that the children may handle it + // as well. Let the touch handler know about the down event as well. + mTouchHandler.onTouch(MediaPickerPanel.this, ev); + + // Ask the MediaPicker whether the contained view can be swiped down. + // We record the value at the start of the drag to decide the swiping mode + // for the entire motion. + mCanChildViewSwipeDown = mMediaPicker.canSwipeDownChooser(); + return false; + + case MotionEvent.ACTION_MOVE: { + if (mMediaPicker.isChooserHandlingTouch()) { + if (shouldAllowRecaptureTouch(ev)) { + mMediaPicker.stopChooserTouchHandling(); + mViewPager.setPagingEnabled(true); + return false; + } + // If the chooser is claiming ownership on all touch events, then we + // shouldn't try to handle them (neither should the view pager). + mViewPager.setPagingEnabled(false); + return false; + } else if (mCanChildViewSwipeDown) { + // Never capture event if the child view can swipe down. + return false; + } else if (!mFullScreen && mMoved) { + // When we are not fullscreen, we own any vertical drag motion. + return true; + } else if (mMovedDown) { + // We are currently handling the down swipe ourselves, so always + // capture this event. + return true; + } else { + // The current interaction mode is undetermined, so always let the + // touch handler know about this event. However, don't capture this + // event so the child may handle it as well. + mTouchHandler.onTouch(MediaPickerPanel.this, ev); + + // Capture the touch event from now on if we are handling the drag. + return mFullScreen ? mMovedDown : mMoved; + } + } + } + return false; + } + + /** + * Determine whether we think the user is actually trying to expand or slide despite the + * fact that they touched first on a chooser that captured the input. + */ + private boolean shouldAllowRecaptureTouch(MotionEvent ev) { + final long elapsedMs = ev.getEventTime() - ev.getDownTime(); + if (mDownEvent == null || elapsedMs == 0 || elapsedMs > TOUCH_RECAPTURE_WINDOW_MS) { + // Either we don't have info to decide or it's been long enough that we no longer + // want to reinterpret user intent. + return false; + } + final float dx = ev.getRawX() - mDownEvent.getRawX(); + final float dy = ev.getRawY() - mDownEvent.getRawY(); + final float dt = elapsedMs / 1000.0f; + final float maxAbsDelta = Math.max(Math.abs(dx), Math.abs(dy)); + final float velocity = maxAbsDelta / dt; + return velocity > mFlingThresholdPx; + } + + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + switch (motionEvent.getAction()) { + case MotionEvent.ACTION_UP: { + if (!mMoved || mDownEvent == null) { + return false; + } + final float dx = motionEvent.getRawX() - mDownEvent.getRawX(); + final float dy = motionEvent.getRawY() - mDownEvent.getRawY(); + + final float dt = + (motionEvent.getEventTime() - mDownEvent.getEventTime()) / 1000.0f; + final float yVelocity = dy / dt; + + boolean handled = false; + + // Vertical swipe occurred if the direction is as least mostly in the y + // component and has the required velocity (px/sec) + if ((dx == 0 || (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) && + Math.abs(yVelocity) > mFlingThresholdPx) { + if (yVelocity < 0 && mExpanded) { + setFullScreenView(true, true); + handled = true; + } else if (yVelocity > 0) { + if (mFullScreen && yVelocity < mBigFlingThresholdPx) { + setFullScreenView(false, true); + } else { + setExpanded(false, true, PAGE_NOT_SET); + } + handled = true; + } + } + + if (!handled) { + // If they didn't swipe enough, animate back to resting state + setDesiredHeight(getDesiredHeight(), true); + } + resetState(); + break; + } + case MotionEvent.ACTION_DOWN: { + mDownHeight = getHeight(); + mDownEvent = MotionEvent.obtain(motionEvent); + // If we are here and care about the return value (i.e. this is not called + // from onInterceptTouchEvent), then presumably no children view in the panel + // handles the down event. We'd like to handle future ACTION_MOVE events, so + // always claim ownership on this event so it doesn't fall through and gets + // cancelled by the framework. + return true; + } + case MotionEvent.ACTION_MOVE: { + if (mDownEvent == null) { + return mMoved; + } + + final float dx = mDownEvent.getRawX() - motionEvent.getRawX(); + final float dy = mDownEvent.getRawY() - motionEvent.getRawY(); + // Don't act if the move is mostly horizontal + if (Math.abs(dy) > mTouchSlop && + (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) { + setDesiredHeight((int) (mDownHeight + dy), false); + mMoved = true; + if (dy < -mTouchSlop) { + mMovedDown = true; + } + } + return mMoved; + } + + } + return mMoved; + } + + private void resetState() { + mDownEvent = null; + mDownHeight = -1; + mMoved = false; + mMovedDown = false; + mCanChildViewSwipeDown = false; + updateViewPager(); + } + } +} + diff --git a/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java new file mode 100644 index 0000000..7ac7871 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.hardware.Camera; +import android.media.CamcorderProfile; +import android.media.MediaRecorder; +import android.net.Uri; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.SafeAsyncTask; + +import java.io.FileNotFoundException; + +class MmsVideoRecorder extends MediaRecorder { + private static final float VIDEO_OVERSHOOT_SLOP = .85F; + + private static final int BITS_PER_BYTE = 8; + + // We think user will expect to be able to record videos at least this long + private static final long MIN_DURATION_LIMIT_SECONDS = 25; + + /** The uri where video is being recorded to */ + private Uri mTempVideoUri; + + /** The settings used for video recording */ + private final CamcorderProfile mCamcorderProfile; + + public MmsVideoRecorder(final Camera camera, final int cameraIndex, final int orientation, + final int maxMessageSize) + throws FileNotFoundException { + mCamcorderProfile = + CamcorderProfile.get(cameraIndex, CamcorderProfile.QUALITY_LOW); + mTempVideoUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( + ContentType.getExtension(getContentType())); + + // The video recorder can sometimes return a file that's larger than the max we + // say we can handle. Try to handle that overshoot by specifying an 85% limit. + final long sizeLimit = (long) (maxMessageSize * VIDEO_OVERSHOOT_SLOP); + + // The QUALITY_LOW profile might not be low enough to allow for video of a reasonable + // minimum duration. Adjust a/v bitrates to allow at least MIN_DURATION_LIMIT video + // to be recorded. + int audioBitRate = mCamcorderProfile.audioBitRate; + int videoBitRate = mCamcorderProfile.videoBitRate; + final double initialDurationLimit = sizeLimit * BITS_PER_BYTE + / (double) (audioBitRate + videoBitRate); + if (initialDurationLimit < MIN_DURATION_LIMIT_SECONDS) { + // Reduce the suggested bitrates. These bitrates are only requests, if implementation + // can't actually hit these goals it will still record video at higher rate and stop when + // it hits the size limit. + final double bitRateAdjustmentFactor = initialDurationLimit / MIN_DURATION_LIMIT_SECONDS; + audioBitRate *= bitRateAdjustmentFactor; + videoBitRate *= bitRateAdjustmentFactor; + } + + setCamera(camera); + setOrientationHint(orientation); + setAudioSource(MediaRecorder.AudioSource.CAMCORDER); + setVideoSource(MediaRecorder.VideoSource.CAMERA); + setOutputFormat(mCamcorderProfile.fileFormat); + setOutputFile( + Factory.get().getApplicationContext().getContentResolver().openFileDescriptor( + mTempVideoUri, "w").getFileDescriptor()); + + // Copy settings from CamcorderProfile to MediaRecorder + setAudioEncodingBitRate(audioBitRate); + setAudioChannels(mCamcorderProfile.audioChannels); + setAudioEncoder(mCamcorderProfile.audioCodec); + setAudioSamplingRate(mCamcorderProfile.audioSampleRate); + setVideoEncodingBitRate(videoBitRate); + setVideoEncoder(mCamcorderProfile.videoCodec); + setVideoFrameRate(mCamcorderProfile.videoFrameRate); + setVideoSize( + mCamcorderProfile.videoFrameWidth, mCamcorderProfile.videoFrameHeight); + setMaxFileSize(sizeLimit); + } + + Uri getVideoUri() { + return mTempVideoUri; + } + + int getVideoWidth() { + return mCamcorderProfile.videoFrameWidth; + } + + int getVideoHeight() { + return mCamcorderProfile.videoFrameHeight; + } + + void cleanupTempFile() { + final Uri tempUri = mTempVideoUri; + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + tempUri, null, null); + } + }); + mTempVideoUri = null; + } + + String getContentType() { + if (mCamcorderProfile.fileFormat == OutputFormat.MPEG_4) { + return ContentType.VIDEO_MP4; + } else { + // 3GPP is the only other video format with a constant in OutputFormat + return ContentType.VIDEO_3GPP; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/PausableChronometer.java b/src/com/android/messaging/ui/mediapicker/PausableChronometer.java new file mode 100644 index 0000000..dc8f90b --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/PausableChronometer.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.content.Context; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.widget.Chronometer; + +import com.android.messaging.ui.PlaybackStateView; + +/** + * A pausable Chronometer implementation. The default Chronometer in Android only stops the UI + * from updating when you call stop(), but doesn't actually pause it. This implementation adds an + * additional timestamp that tracks the timespan for the pause and compensate for that. + */ +public class PausableChronometer extends Chronometer implements PlaybackStateView { + // Keeps track of how far long the Chronometer has been tracking when it's paused. We'd like + // to start from this time the next time it's resumed. + private long mTimeWhenPaused = 0; + + public PausableChronometer(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + /** + * Reset the timer and start counting from zero. + */ + @Override + public void restart() { + reset(); + start(); + } + + /** + * Reset the timer to zero, but don't start it. + */ + @Override + public void reset() { + stop(); + setBase(SystemClock.elapsedRealtime()); + mTimeWhenPaused = 0; + } + + /** + * Resume the timer after a previous pause. + */ + @Override + public void resume() { + setBase(SystemClock.elapsedRealtime() - mTimeWhenPaused); + start(); + } + + /** + * Pause the timer. + */ + @Override + public void pause() { + stop(); + mTimeWhenPaused = SystemClock.elapsedRealtime() - getBase(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java new file mode 100644 index 0000000..5dc3185 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +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. + * + * 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 + 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/src/com/android/messaging/ui/mediapicker/SoundLevels.java b/src/com/android/messaging/ui/mediapicker/SoundLevels.java new file mode 100644 index 0000000..6f4dca6 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/SoundLevels.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker; + +import android.animation.ObjectAnimator; +import android.animation.TimeAnimator; +import android.animation.TimeAnimator.TimeListener; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.android.messaging.R; +import com.android.messaging.util.LogUtil; + +/** + * This view draws circular sound levels. By default the sound levels are black, unless + * otherwise defined via {@link #mPrimaryLevelPaint}. + */ +public class SoundLevels extends View { + private static final String TAG = LogUtil.BUGLE_TAG; + private static final boolean DEBUG = false; + + private boolean mCenterDefined; + private int mCenterX; + private int mCenterY; + + // Paint for the main level meter, most closely follows the mic. + private final Paint mPrimaryLevelPaint; + + // The minimum size of the levels as a percentage of the max, that is the size when volume is 0. + private final float mMinimumLevel; + + // The minimum size of the levels, that is the size when volume is 0. + private final float mMinimumLevelSize; + + // The maximum size of the levels, that is the size when volume is 100. + private final float mMaximumLevelSize; + + // Generates clock ticks for the animation using the global animation loop. + private final TimeAnimator mSpeechLevelsAnimator; + + private float mCurrentVolume; + + // Indicates whether we should be animating the sound level. + private boolean mIsEnabled; + + // Input level is pulled from here. + private AudioLevelSource mLevelSource; + + public SoundLevels(final Context context) { + this(context, null); + } + + public SoundLevels(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public SoundLevels(final Context context, final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + + // Safe source, replaced with system one when attached. + mLevelSource = new AudioLevelSource(); + mLevelSource.setSpeechLevel(0); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SoundLevels, + defStyle, 0); + + mMaximumLevelSize = a.getDimensionPixelOffset( + R.styleable.SoundLevels_maxLevelRadius, 0); + mMinimumLevelSize = a.getDimensionPixelOffset( + R.styleable.SoundLevels_minLevelRadius, 0); + mMinimumLevel = mMinimumLevelSize / mMaximumLevelSize; + + mPrimaryLevelPaint = new Paint(); + mPrimaryLevelPaint.setColor( + a.getColor(R.styleable.SoundLevels_primaryColor, Color.BLACK)); + mPrimaryLevelPaint.setFlags(Paint.ANTI_ALIAS_FLAG); + + a.recycle(); + + // This animator generates ticks that invalidate the + // view so that the animation is synced with the global animation loop. + // TODO: We could probably remove this in favor of using postInvalidateOnAnimation + // which might improve things further. + mSpeechLevelsAnimator = new TimeAnimator(); + mSpeechLevelsAnimator.setRepeatCount(ObjectAnimator.INFINITE); + mSpeechLevelsAnimator.setTimeListener(new TimeListener() { + @Override + public void onTimeUpdate(final TimeAnimator animation, final long totalTime, + final long deltaTime) { + invalidate(); + } + }); + } + + @Override + protected void onDraw(final Canvas canvas) { + if (!mIsEnabled) { + return; + } + + if (!mCenterDefined) { + // One time computation here, because we can't rely on getWidth() to be computed at + // constructor time or in onFinishInflate :(. + mCenterX = getWidth() / 2; + mCenterY = getWidth() / 2; + mCenterDefined = true; + } + + final int level = mLevelSource.getSpeechLevel(); + // Either ease towards the target level, or decay away from it depending on whether + // its higher or lower than the current. + if (level > mCurrentVolume) { + mCurrentVolume = mCurrentVolume + ((level - mCurrentVolume) / 4); + } else { + mCurrentVolume = mCurrentVolume * 0.95f; + } + + final float radius = mMinimumLevel + (1f - mMinimumLevel) * mCurrentVolume / 100; + mPrimaryLevelPaint.setStyle(Style.FILL); + canvas.drawCircle(mCenterX, mCenterY, radius * mMaximumLevelSize, mPrimaryLevelPaint); + } + + public void setLevelSource(final AudioLevelSource source) { + if (DEBUG) { + Log.d(TAG, "Speech source set."); + } + mLevelSource = source; + } + + private void startSpeechLevelsAnimator() { + if (DEBUG) { + Log.d(TAG, "startAnimator()"); + } + if (!mSpeechLevelsAnimator.isStarted()) { + mSpeechLevelsAnimator.start(); + } + } + + private void stopSpeechLevelsAnimator() { + if (DEBUG) { + Log.d(TAG, "stopAnimator()"); + } + if (mSpeechLevelsAnimator.isStarted()) { + mSpeechLevelsAnimator.end(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + stopSpeechLevelsAnimator(); + } + + @Override + public void setEnabled(final boolean enabled) { + if (enabled == mIsEnabled) { + return; + } + if (DEBUG) { + Log.d("TAG", "setEnabled: " + enabled); + } + super.setEnabled(enabled); + mIsEnabled = enabled; + setKeepScreenOn(enabled); + updateSpeechLevelsAnimatorState(); + } + + private void updateSpeechLevelsAnimatorState() { + if (mIsEnabled) { + startSpeechLevelsAnimator(); + } else { + stopSpeechLevelsAnimator(); + } + } + + /** + * This is required to make the View findable by uiautomator + */ + @Override + public void onInitializeAccessibilityNodeInfo(final AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName(SoundLevels.class.getCanonicalName()); + } + + /** + * Set the alpha level of the sound circles. + */ + public void setPrimaryColorAlpha(final int alpha) { + mPrimaryLevelPaint.setAlpha(alpha); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java new file mode 100644 index 0000000..92ed3c1 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker.camerafocus; + +public interface FocusIndicator { + public void showStart(); + public void showSuccess(boolean timeout); + public void showFail(boolean timeout); + public void clear(); +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java new file mode 100644 index 0000000..e620fc2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java @@ -0,0 +1,589 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker.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.messaging.util.Assert; +import com.android.messaging.util.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. + * + * 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 TAG = LogUtil.BUGLE_TAG; + 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 int mDisplayOrientation; + private List<Object> mFocusArea; // focus area in driver format + private List<Object> mMeteringArea; // metering area in driver format + private String mFocusMode; + private String mOverrideFocusMode; + private Parameters mParameters; + private Handler mHandler; + Listener mListener; + + public interface Listener { + public void autoFocus(); + public void cancelAutoFocus(); + public boolean capture(); + public 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(); + } + + public void setDisplayOrientation(int displayOrientation) { + mDisplayOrientation = displayOrientation; + setMatrix(); + } + + private void setMatrix() { + if (mPreviewWidth != 0 && mPreviewHeight != 0) { + Matrix matrix = new Matrix(); + prepareMatrix(matrix, mMirror, mDisplayOrientation, + 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(); + } + } + + private void unlockAeAwbIfNeeded() { + if (mLockAeAwbNeeded && mAeAwbLock && (mState != STATE_FOCUSING_SNAP_ON_FINISH)) { + mAeAwbLock = false; + mListener.setFocusParameters(); + } + } + + public void onShutterDown() { + if (!mInitialized) { + return; + } + + boolean autoFocusCalled = false; + if (needAutoFocusCall()) { + // Do not focus if touch focus has been triggered. + if (mState != STATE_SUCCESS && mState != STATE_FAIL) { + autoFocus(); + autoFocusCalled = true; + } + } + + if (!autoFocusCalled) { + lockAeAwbIfNeeded(); + } + } + + public void onShutterUp() { + if (!mInitialized) { + return; + } + + if (needAutoFocusCall()) { + // User releases half-pressed focus key. + if (mState == STATE_FOCUSING || mState == STATE_SUCCESS + || mState == STATE_FAIL) { + cancelAutoFocus(); + } + } + + // Unlock AE and AWB after cancelAutoFocus. Camera API does not + // guarantee setParameters can be called during autofocus. + unlockAeAwbIfNeeded(); + } + + public void doSnap() { + if (!mInitialized) { + return; + } + + // If the user has half-pressed the shutter and focus is completed, we + // can take the photo right away. If the focus mode is infinity, we can + // also take the photo. + if (!needAutoFocusCall() || (mState == STATE_SUCCESS || mState == STATE_FAIL)) { + capture(); + } else if (mState == STATE_FOCUSING) { + // Half pressing the shutter (i.e. the focus button event) will + // already have requested AF for us, so just request capture on + // focus here. + mState = STATE_FOCUSING_SNAP_ON_FINISH; + } else if (mState == STATE_IDLE) { + // We didn't do focus. This can happen if the user press focus key + // while the snapshot is still in progress. The user probably wants + // the next snapshot as soon as possible, so we just do a snapshot + // without focusing again. + capture(); + } + } + + 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<Object>(); + mFocusArea.add(new Area(new Rect(), 1)); + } + + // Convert the coordinates to driver format. + calculateTapArea(focusWidth, focusHeight, 1f, x, y, previewWidth, previewHeight, + ((Area) 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<Object>(); + 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, + ((Area) 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(TAG, "Start autofocus."); + mListener.autoFocus(); + mState = STATE_FOCUSING; + updateFocusUI(); + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + private void cancelAutoFocus() { + LogUtil.v(TAG, "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() { + if (mOverrideFocusMode != null) { + return mOverrideFocusMode; + } + 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 getFocusAreas() { + return mFocusArea; + } + + public List getMeteringAreas() { + return mMeteringArea; + } + + public 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); + } + } + } + + public 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); + int left = clamp(x - areaWidth / 2, 0, previewWidth - areaWidth); + int top = clamp(y - areaHeight / 2, 0, previewHeight - areaHeight); + + RectF rectF = new RectF(left, top, left + areaWidth, top + areaHeight); + mMatrix.mapRect(rectF); + rectFToRect(rectF, rect); + } + + /* package */ int getFocusState() { + return mState; + } + + public boolean isFocusCompleted() { + return mState == STATE_SUCCESS || mState == STATE_FAIL; + } + + public boolean isFocusingSnapOnFinish() { + return mState == STATE_FOCUSING_SNAP_ON_FINISH; + } + + public void removeMessages() { + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + public void overrideFocusMode(String focusMode) { + mOverrideFocusMode = focusMode; + } + + public void setAeAwbLock(boolean lock) { + mAeAwbLock = lock; + } + + public boolean getAeAwbLock() { + return mAeAwbLock; + } + + private boolean needAutoFocusCall() { + String focusMode = getFocusMode(); + return !(focusMode.equals(Parameters.FOCUS_MODE_INFINITY) + || focusMode.equals(Parameters.FOCUS_MODE_FIXED) + || focusMode.equals(Parameters.FOCUS_MODE_EDOF)); + } + + public static boolean isAutoExposureLockSupported(Parameters params) { + return TRUE.equals(params.get(AUTO_EXPOSURE_LOCK_SUPPORTED)); + } + + public static boolean isAutoWhiteBalanceLockSupported(Parameters params) { + return TRUE.equals(params.get(AUTO_WHITE_BALANCE_LOCK_SUPPORTED)); + } + + public static boolean isSupported(String value, List<String> supported) { + return supported != null && supported.indexOf(value) >= 0; + } + + public static boolean isMeteringAreaSupported(Parameters params) { + return params.getMaxNumMeteringAreas() > 0; + } + + public static boolean isFocusAreaSupported(Parameters params) { + return (params.getMaxNumFocusAreas() > 0 + && isSupported(Parameters.FOCUS_MODE_AUTO, + params.getSupportedFocusModes())); + } + + public static void prepareMatrix(Matrix matrix, boolean mirror, int displayOrientation, + int viewWidth, int viewHeight) { + // Need mirror for front camera. + matrix.setScale(mirror ? -1 : 1, 1); + // This is the value for android.hardware.Camera.setDisplayOrientation. + matrix.postRotate(displayOrientation); + // 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); + } + + public static int clamp(int x, int min, int max) { + Assert.isTrue(max >= min); + if (x > max) { + return max; + } + if (x < min) { + return min; + } + return x; + } + + public static 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/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java new file mode 100644 index 0000000..df6734f --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.view.MotionEvent; + +public abstract class OverlayRenderer implements RenderOverlay.Renderer { + + private static final String TAG = "CAM OverlayRenderer"; + protected RenderOverlay mOverlay; + + protected int mLeft, mTop, mRight, mBottom; + + protected 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); + + 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(); + } + } + +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java new file mode 100644 index 0000000..c602852 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.drawable.Drawable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Pie menu item + */ +public class PieItem { + + public static 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; + private boolean mChangeAlphaWhenDisabled = true; + + 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 addItem(PieItem item) { + if (mItems == null) { + mItems = new ArrayList<PieItem>(); + } + mItems.add(item); + } + + public void setPath(Path p) { + mPath = p; + } + + public Path getPath() { + return mPath; + } + + public void setChangeAlphaWhenDisabled (boolean enable) { + mChangeAlphaWhenDisabled = enable; + } + + public void setAlpha(float alpha) { + mAlpha = alpha; + mDrawable.setAlpha((int) (255 * alpha)); + } + + public void setAnimationAngle(float a) { + animate = a; + } + + public float getAnimationAngle() { + return animate; + } + + public void setEnabled(boolean enabled) { + mEnabled = enabled; + if (mChangeAlphaWhenDisabled) { + 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 void setFixedSlice(float center, float sweep) { + mCenter = center; + this.sweep = sweep; + } + + 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); + } + +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java new file mode 100644 index 0000000..ce8ca00 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java @@ -0,0 +1,825 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker.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 com.android.messaging.R; + +import java.util.ArrayList; +import java.util.List; + +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() { + 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; + + public static interface PieListener { + public void onPieOpened(int centerX, int centerY); + public 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 = (int) res.getDimensionPixelSize(R.dimen.pie_radius_start); + mCircleSize = mRadius - res.getDimensionPixelSize(R.dimen.focus_radius_offset); + mRadiusInc = (int) res.getDimensionPixelSize(R.dimen.pie_radius_increment); + mTouchOffset = (int) 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 = (float) (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 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); + } + } + +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt new file mode 100644 index 0000000..ed4e783 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt @@ -0,0 +1,3 @@ +The files in this package were copied from the android-4.4.4_r1 branch of ASOP from the folders +com/android/camera/ and com/android/camera/ui from files with the same name. Some modifications +have been made to remove unneeded features and adjust to our needs.
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java new file mode 100644 index 0000000..95cddc4 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2015 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.messaging.ui.mediapicker.camerafocus; + +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; + +public class RenderOverlay extends FrameLayout { + + interface Renderer { + + public boolean handlesTouch(); + public boolean onTouchEvent(MotionEvent evt); + public void setOverlay(RenderOverlay overlay); + public void layout(int left, int top, int right, int bottom); + public 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<Renderer>(10); + mTouchClients = new ArrayList<Renderer>(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); + } + + public int getClientSize() { + return mClients.size(); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent m) { + return false; + } + + public boolean directDispatchTouch(MotionEvent m, Renderer target) { + mRenderView.setTouchTarget(target); + boolean res = super.dispatchTouchEvent(m); + mRenderView.setTouchTarget(null); + return res; + } + + private void adjustPosition() { + getLocationInWindow(mPosition); + } + + public int getWindowPositionX() { + return mPosition[0]; + } + + public int getWindowPositionY() { + return mPosition[1]; + } + + public void update() { + mRenderView.invalidate(); + } + + private class RenderView extends View { + + private Renderer mTouchTarget; + + public RenderView(Context context) { + super(context); + setWillNotDraw(false); + } + + public void setTouchTarget(Renderer target) { + mTouchTarget = target; + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + if (mTouchTarget != null) { + return mTouchTarget.onTouchEvent(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(); + } + } + } + +}
\ No newline at end of file |