summaryrefslogtreecommitdiffstats
path: root/src/com/android/messaging/ui/mediapicker
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/messaging/ui/mediapicker')
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioLevelSource.java73
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java130
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioRecordView.java351
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraManager.java1200
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java481
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java102
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraPreview.java152
-rw-r--r--src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java128
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java62
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java159
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridView.java315
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java230
-rw-r--r--src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java118
-rw-r--r--src/com/android/messaging/ui/mediapicker/ImagePersistTask.java172
-rw-r--r--src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java223
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaChooser.java216
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPicker.java736
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java44
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java563
-rw-r--r--src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java127
-rw-r--r--src/com/android/messaging/ui/mediapicker/PausableChronometer.java75
-rw-r--r--src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java114
-rw-r--r--src/com/android/messaging/ui/mediapicker/SoundLevels.java212
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java24
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java589
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java95
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java202
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java825
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/README.txt3
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java178
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