Voicemail playback, with variable speed ui.
Introduces a new VoicemailPlaybackFragment and the
VoicemailPlaybackPresenter that goes with it.
Together these two form part of the CallDetailActivity, and allow us to
play back voicemails inline with variable speed.
Also included is the code from the CallLogFragment to launch the
CallDetailActivity with suitable extras buried in the Intent.
Change-Id: I86585685802b69441f5812b41c215f3534af26e4
diff --git a/Android.mk b/Android.mk
index c6bea9d..e9e7515 100644
--- a/Android.mk
+++ b/Android.mk
@@ -11,7 +11,10 @@
android-common \
guava \
android-support-v13 \
- android-support-v4
+ android-support-v4 \
+ android-ex-variablespeed \
+
+LOCAL_REQUIRED_MODULES := libvariablespeed
LOCAL_PACKAGE_NAME := Contacts
LOCAL_CERTIFICATE := shared
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 7fb7f37..d2cc492 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -30,6 +30,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
<uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH.mail" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
diff --git a/res/drawable/attachment_button.png b/res/drawable/attachment_button.png
new file mode 100644
index 0000000..3020e6e
--- /dev/null
+++ b/res/drawable/attachment_button.png
Binary files differ
diff --git a/res/drawable/pause_button.png b/res/drawable/pause_button.png
new file mode 100644
index 0000000..4b2f0e7
--- /dev/null
+++ b/res/drawable/pause_button.png
Binary files differ
diff --git a/res/drawable/play_button.png b/res/drawable/play_button.png
new file mode 100644
index 0000000..ef34449
--- /dev/null
+++ b/res/drawable/play_button.png
Binary files differ
diff --git a/res/drawable/seek_bar_thumb.png b/res/drawable/seek_bar_thumb.png
new file mode 100644
index 0000000..a512ef4
--- /dev/null
+++ b/res/drawable/seek_bar_thumb.png
Binary files differ
diff --git a/res/drawable/seekbar_drawable.xml b/res/drawable/seekbar_drawable.xml
new file mode 100644
index 0000000..2533b7f
--- /dev/null
+++ b/res/drawable/seekbar_drawable.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:id="@android:id/background">
+ <shape android:shape="line">
+ <stroke
+ android:width="2dip"
+ android:color="@color/voicemail_playback_seek_bar_yet_to_play"
+ />
+ </shape>
+ </item>
+ <!-- I am not defining a secondary progress colour - we don't use it. -->
+ <item android:id="@android:id/progress">
+ <clip>
+ <shape android:shape="line">
+ <stroke
+ android:width="2dip"
+ android:color="@color/voicemail_playback_seek_bar_already_played"
+ />
+ </shape>
+ </clip>
+ </item>
+</layer-list>
diff --git a/res/drawable/speakerphone_off_button.png b/res/drawable/speakerphone_off_button.png
new file mode 100644
index 0000000..ad6820b
--- /dev/null
+++ b/res/drawable/speakerphone_off_button.png
Binary files differ
diff --git a/res/drawable/speakerphone_on_button.png b/res/drawable/speakerphone_on_button.png
new file mode 100644
index 0000000..e6deda3
--- /dev/null
+++ b/res/drawable/speakerphone_on_button.png
Binary files differ
diff --git a/res/drawable/trash_button.png b/res/drawable/trash_button.png
new file mode 100644
index 0000000..2fbb1dd
--- /dev/null
+++ b/res/drawable/trash_button.png
Binary files differ
diff --git a/res/layout/call_detail.xml b/res/layout/call_detail.xml
index e797f0d..1e40964 100644
--- a/res/layout/call_detail.xml
+++ b/res/layout/call_detail.xml
@@ -44,10 +44,22 @@
android:layout_below="@id/action_bar"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
-
android:background="@drawable/ic_contact_picture"
/>
<LinearLayout
+ android:id="@+id/voicemail_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/contact_background"
+ >
+ <fragment
+ class="com.android.contacts.voicemail.VoicemailPlaybackFragment"
+ android:id="@+id/voicemail_playback_fragment"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ />
+ </LinearLayout>
+ <LinearLayout
android:layout_width="match_parent"
android:layout_height="?attr/call_detail_contact_background_overlay_height"
android:background="#3F000000"
@@ -85,7 +97,7 @@
android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_below="@id/contact_background"
+ android:layout_below="@id/voicemail_container"
android:background="?attr/call_log_primary_background_color"
/>
<ListView
diff --git a/res/layout/playback_layout.xml b/res/layout/playback_layout.xml
new file mode 100644
index 0000000..5fee6fc
--- /dev/null
+++ b/res/layout/playback_layout.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+>
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="100dip"
+ android:orientation="vertical"
+ android:background="@color/voicemail_playback_ui_background"
+ >
+ <!-- Mute, playback, trash buttons. -->
+ <LinearLayout
+ android:id="@+id/buttons_linear_layout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_alignParentTop="true"
+ >
+ <ImageButton
+ android:id="@+id/playback_speakerphone"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:padding="5px"
+ android:background="@color/voicemail_playback_ui_background"
+ android:src="@drawable/speakerphone_on_button"
+ android:layout_weight="1"
+ />
+ <ImageButton
+ android:id="@+id/playback_start_stop"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:padding="5px"
+ android:background="@color/voicemail_playback_ui_background"
+ android:src="@drawable/pause_button"
+ android:layout_weight="1"
+ />
+ <ImageButton
+ android:id="@+id/playback_trash"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:padding="5px"
+ android:background="@color/voicemail_playback_ui_background"
+ android:src="@drawable/trash_button"
+ android:layout_weight="1"
+ />
+ </LinearLayout>
+ <SeekBar
+ android:id="@+id/playback_seek"
+ android:layout_width="fill_parent"
+ android:layout_height="wrap_content"
+ android:progressDrawable="@drawable/seekbar_drawable"
+ android:thumb="@drawable/seek_bar_thumb"
+ android:thumbOffset="0dip"
+ android:paddingLeft="30dip"
+ android:paddingRight="30dip"
+ android:paddingTop="10dip"
+ android:paddingBottom="25dip"
+ android:progress="0"
+ android:max="50"
+ android:layout_alignParentBottom="true"
+ />
+ <TextView
+ android:id="@+id/playback_position_text"
+ android:text="@string/voicemail_initial_time"
+ android:layout_height="wrap_content"
+ android:layout_width="wrap_content"
+ android:paddingBottom="5dip"
+ android:layout_alignParentBottom="true"
+ android:layout_centerHorizontal="true"
+ />
+ <Button
+ android:id="@+id/rate_decrease_button"
+ android:layout_width="30dip"
+ android:layout_height="wrap_content"
+ android:background="@android:color/transparent"
+ android:textColor="@color/voicemail_playback_ui_text"
+ android:textSize="20dip"
+ android:textStyle="bold"
+ android:paddingTop="10dip"
+ android:paddingBottom="15dip"
+ android:text="@string/voicemail_decrease_button"
+ android:layout_alignLeft="@id/playback_seek"
+ android:layout_alignBottom="@id/playback_seek"
+ />
+ <Button
+ android:id="@+id/rate_increase_button"
+ android:layout_width="30dip"
+ android:layout_height="wrap_content"
+ android:background="@android:color/transparent"
+ android:textColor="@color/voicemail_playback_ui_text"
+ android:textSize="20dip"
+ android:textStyle="bold"
+ android:paddingTop="10dip"
+ android:paddingBottom="15dip"
+ android:text="@string/voicemail_increase_button"
+ android:layout_alignRight="@id/playback_seek"
+ android:layout_alignBottom="@id/playback_seek"
+ />
+ </RelativeLayout>
+</LinearLayout>
diff --git a/res/values/colors.xml b/res/values/colors.xml
index b92b82c..35727e0 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -61,4 +61,17 @@
<!-- Color of the text describing an unconsumed voicemail. -->
<color name="call_log_voicemail_highlight_color">#0000FF</color>
+
+ <!-- Palette section:
+ If you need a new color then add a new one and delete the ones that are no longer used. -->
+ <color name="lighter_grey">#cccccc</color>
+ <color name="seek_bar_blue">#32bdf1</color>
+ <color name="semi_transparent_grey">#cc696969</color>
+
+ <!-- This section defines the color used by different UI components and maps them to one of the
+ colors defined in the palette section above. -->
+ <color name="voicemail_playback_ui_background">@color/@android:color/white</color>
+ <color name="voicemail_playback_seek_bar_yet_to_play">@color/lighter_grey</color>
+ <color name="voicemail_playback_seek_bar_already_played">@color/seek_bar_blue</color>
+ <color name="voicemail_playback_ui_text">@color/semi_transparent_grey</color>
</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 52f8394..534fc4e 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1611,6 +1611,13 @@
<!-- Title of the notification of new voicemail. -->
<string name="notification_voicemail_title">New voicemail</string>
+ <!-- Initial display for position of current playback, do not translate. -->
+ <string name="voicemail_initial_time">00:05</string>
+ <!-- This string is temporary whilst I wait for an art asset to use on the button. -->
+ <string name="voicemail_decrease_button">-</string>
+ <!-- This string is temporary whilst I wait for an art asset to use on the button. -->
+ <string name="voicemail_increase_button">+</string>
+
<!-- The separator between the call type text and the date in the call log [CHAR LIMIT=3] -->
<string name="call_log_type_date_separator">/</string>
diff --git a/src/com/android/contacts/CallDetailActivity.java b/src/com/android/contacts/CallDetailActivity.java
index cce48dd..11888a5 100644
--- a/src/com/android/contacts/CallDetailActivity.java
+++ b/src/com/android/contacts/CallDetailActivity.java
@@ -19,7 +19,9 @@
import com.android.contacts.calllog.CallDetailHistoryAdapter;
import com.android.contacts.calllog.CallTypeHelper;
import com.android.contacts.calllog.PhoneNumberHelper;
+import com.android.contacts.voicemail.VoicemailPlaybackFragment;
+import android.app.FragmentManager;
import android.app.ListActivity;
import android.content.ContentResolver;
import android.content.ContentUris;
@@ -63,7 +65,11 @@
private static final String TAG = "CallDetail";
/** A long array extra containing ids of call log entries to display. */
- public static final String EXTRA_CALL_LOG_IDS = "com.android.contacts.CALL_LOG_IDS";
+ public static final String EXTRA_CALL_LOG_IDS = "EXTRA_CALL_LOG_IDS";
+ /** If we are started with a voicemail, we'll find the uri to play with this extra. */
+ public static final String EXTRA_VOICEMAIL_URI = "EXTRA_VOICEMAIL_URI";
+ /** If we should immediately start playback of the voicemail, this extra will be set to true. */
+ public static final String EXTRA_VOICEMAIL_START_PLAYBACK = "EXTRA_VOICEMAIL_START_PLAYBACK";
/** The views representing the details of a phone call. */
private PhoneCallDetailsViews mPhoneCallDetailsViews;
@@ -151,6 +157,29 @@
public void onResume() {
super.onResume();
updateData(getCallLogEntryUris());
+ optionallyHandleVoicemail();
+ }
+
+ /**
+ * Handle voicemail playback or hide voicemail ui.
+ * <p>
+ * If the Intent used to start this Activity contains the suitable extras, then start voicemail
+ * playback. If it doesn't, then hide the voicemail ui.
+ */
+ private void optionallyHandleVoicemail() {
+ FragmentManager manager = getFragmentManager();
+ VoicemailPlaybackFragment fragment = (VoicemailPlaybackFragment) manager.findFragmentById(
+ R.id.voicemail_playback_fragment);
+ Uri voicemailUri = getIntent().getExtras().getParcelable(EXTRA_VOICEMAIL_URI);
+ if (voicemailUri == null) {
+ // No voicemail uri: hide the voicemail fragment.
+ manager.beginTransaction().hide(fragment).commit();
+ } else {
+ // A voicemail: extra tells us if we should start playback or not.
+ boolean startPlayback = getIntent().getExtras().getBoolean(
+ EXTRA_VOICEMAIL_START_PLAYBACK, false);
+ fragment.setVoicemailUri(voicemailUri, startPlayback);
+ }
}
/**
diff --git a/src/com/android/contacts/calllog/CallLogFragment.java b/src/com/android/contacts/calllog/CallLogFragment.java
index cd95c88..783d06b 100644
--- a/src/com/android/contacts/calllog/CallLogFragment.java
+++ b/src/com/android/contacts/calllog/CallLogFragment.java
@@ -36,11 +36,15 @@
import android.content.res.Resources;
import android.database.CharArrayBuffer;
import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabaseCorruptException;
+import android.database.sqlite.SQLiteDiskIOException;
+import android.database.sqlite.SQLiteFullException;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
+import android.provider.CallLog;
import android.provider.CallLog.Calls;
import android.provider.ContactsContract.CommonDataKinds.SipAddress;
import android.provider.ContactsContract.Contacts;
@@ -72,8 +76,10 @@
/** The size of the cache of contact info. */
private static final int CONTACT_INFO_CACHE_SIZE = 100;
- /** The query for the call log table */
+ /** The query for the call log table. */
public static final class CallLogQuery {
+ // If you alter this, you must also alter the method that inserts a fake row to the headers
+ // in the CallLogQueryHandler class called createHeaderCursorFor().
public static final String[] _PROJECTION = new String[] {
Calls._ID,
Calls.NUMBER,
@@ -81,13 +87,16 @@
Calls.DURATION,
Calls.TYPE,
Calls.COUNTRY_ISO,
+ Calls.VOICEMAIL_URI,
};
+
public static final int ID = 0;
public static final int NUMBER = 1;
public static final int DATE = 2;
public static final int DURATION = 3;
public static final int CALL_TYPE = 4;
public static final int COUNTRY_ISO = 5;
+ public static final int VOICEMAIL_URI = 6;
/**
* The name of the synthetic "section" column.
@@ -97,7 +106,7 @@
*/
public static final String SECTION_NAME = "section";
/** The index of the "section" column in the projection. */
- public static final int SECTION = 6;
+ public static final int SECTION = 7;
/** The value of the "section" column for the header of the new section. */
public static final int SECTION_NEW_HEADER = 0;
/** The value of the "section" column for the items of the new section. */
@@ -166,6 +175,49 @@
public String lookupKey;
}
+ /** Encapsulates the information needed to call a number from the call log. */
+ private static final class NumberAndType {
+ private final String mNumber;
+ private final long mRowId;
+ private final int mCallType;
+ private final String mVoicemailUri;
+
+ public NumberAndType(String number, long rowId, int callType, String voicemailUri) {
+ mNumber = number;
+ mRowId = rowId;
+ mCallType = callType;
+ mVoicemailUri = voicemailUri;
+ }
+
+ public Intent getIntent(Context context) {
+ switch (mCallType) {
+ case CallLog.Calls.VOICEMAIL_TYPE:
+ Intent intent = new Intent(context, CallDetailActivity.class);
+ intent.setData(ContentUris.withAppendedId(
+ Calls.CONTENT_URI_WITH_VOICEMAIL, mRowId));
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI,
+ Uri.parse(mVoicemailUri));
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, true);
+ return intent;
+ case CallLog.Calls.INCOMING_TYPE:
+ case CallLog.Calls.OUTGOING_TYPE:
+ case CallLog.Calls.MISSED_TYPE:
+ default: {
+ // Here, "number" can either be a PSTN phone number or a
+ // SIP address. So turn it into either a tel: URI or a
+ // sip: URI, as appropriate.
+ Uri uri;
+ if (PhoneNumberUtils.isUriNumber(mNumber)) {
+ uri = Uri.fromParts("sip", mNumber, null);
+ } else {
+ uri = Uri.fromParts("tel", mNumber, null);
+ }
+ return new Intent(Intent.ACTION_CALL_PRIVILEGED, uri);
+ }
+ }
+ }
+ }
+
/** Adapter class to fill in data for the Call Log */
public final class CallLogAdapter extends GroupingListAdapter
implements Runnable, ViewTreeObserver.OnPreDrawListener, View.OnClickListener {
@@ -212,18 +264,9 @@
@Override
public void onClick(View view) {
- String number = (String) view.getTag();
- if (!TextUtils.isEmpty(number)) {
- // Here, "number" can either be a PSTN phone number or a
- // SIP address. So turn it into either a tel: URI or a
- // sip: URI, as appropriate.
- Uri callUri;
- if (PhoneNumberUtils.isUriNumber(number)) {
- callUri = Uri.fromParts("sip", number, null);
- } else {
- callUri = Uri.fromParts("tel", number, null);
- }
- startActivity(new Intent(Intent.ACTION_CALL_PRIVILEGED, callUri));
+ NumberAndType numberAndType = (NumberAndType) view.getTag();
+ if (numberAndType != null) {
+ startActivity(numberAndType.getIntent(CallLogFragment.this.getActivity()));
}
}
@@ -672,8 +715,11 @@
final String formattedNumber;
final String countryIso = c.getString(CallLogQuery.COUNTRY_ISO);
// Store away the number so we can call it directly if you click on the call icon
- if (views.callView != null) {
- views.callView.setTag(number);
+ if (views.callView != null && !TextUtils.isEmpty(number)) {
+ int callType = c.getInt(CallLogQuery.CALL_TYPE);
+ String voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI);
+ long rowId = c.getLong(CallLogQuery.ID);
+ views.callView.setTag(new NumberAndType(number, rowId, callType, voicemailUri));
}
// Lookup contacts with this number
@@ -1022,22 +1068,25 @@
@Override
public void onListItemClick(ListView l, View v, int position, long id) {
Intent intent = new Intent(getActivity(), CallDetailActivity.class);
+ Cursor cursor = (Cursor) mAdapter.getItem(position);
if (mAdapter.isGroupHeader(position)) {
+ // We want to restore the position in the cursor at the end.
+ int currentPosition = cursor.getPosition();
int groupSize = mAdapter.getGroupSize(position);
long[] ids = new long[groupSize];
// Copy the ids of the rows in the group.
- Cursor cursor = (Cursor) mAdapter.getItem(position);
- // Restore the position in the cursor at the end.
- int currentPosition = cursor.getPosition();
for (int index = 0; index < groupSize; ++index) {
ids[index] = cursor.getLong(CallLogQuery.ID);
cursor.moveToNext();
}
- cursor.moveToPosition(currentPosition);
intent.putExtra(CallDetailActivity.EXTRA_CALL_LOG_IDS, ids);
+ cursor.moveToPosition(currentPosition);
} else {
// If there is a single item, use the direct URI for it.
intent.setData(ContentUris.withAppendedId(Calls.CONTENT_URI_WITH_VOICEMAIL, id));
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI,
+ Uri.parse(cursor.getString(CallLogQuery.VOICEMAIL_URI)));
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, false);
}
startActivity(intent);
}
diff --git a/src/com/android/contacts/calllog/CallLogQueryHandler.java b/src/com/android/contacts/calllog/CallLogQueryHandler.java
index 6359479..977c84a 100644
--- a/src/com/android/contacts/calllog/CallLogQueryHandler.java
+++ b/src/com/android/contacts/calllog/CallLogQueryHandler.java
@@ -103,7 +103,9 @@
/** Creates a cursor that contains a single row and maps the section to the given value. */
private Cursor createHeaderCursorFor(int section) {
MatrixCursor matrixCursor = new MatrixCursor(getHeaderColumns());
- matrixCursor.addRow(new Object[]{ -1L, "", 0L, 0L, 0, "", section });
+ // The values in this row correspond to default values for _PROJECTION from CallLogQuery
+ // plus the section value.
+ matrixCursor.addRow(new Object[]{ -1L, "", 0L, 0L, 0, "", "", section });
return matrixCursor;
}
@@ -251,4 +253,4 @@
fragment.onCallsFetched(combinedCursor);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java b/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java
new file mode 100644
index 0000000..227b1fc
--- /dev/null
+++ b/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2011 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.contacts.voicemail;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import com.android.contacts.CallDetailActivity;
+import com.android.contacts.R;
+import com.android.ex.variablespeed.MediaPlayerProxy;
+import com.android.ex.variablespeed.VariableSpeed;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+/**
+ * Displays and plays back a single voicemail.
+ * <p>
+ * When the Activity containing this Fragment is created, voicemail playback
+ * will begin immediately. The Activity is expected to be started via an intent
+ * containing a suitable voicemail uri to playback.
+ * <p>
+ * This class is not thread-safe, it is thread-confined. All calls to all public
+ * methods on this class are expected to come from the main ui thread.
+ */
+@NotThreadSafe
+public class VoicemailPlaybackFragment extends Fragment {
+ private static final int NUMBER_OF_THREADS_IN_POOL = 2;
+
+ private VoicemailPlaybackPresenter mPresenter;
+ private ScheduledExecutorService mScheduledExecutorService;
+ private SeekBar mPlaybackSeek;
+ private ImageButton mStartStopButton;
+ private ImageButton mPlaybackSpeakerphone;
+ private ImageButton mPlaybackTrashButton;
+ private TextView mPlaybackPositionText;
+ private Button mRateDecreaseButton;
+ private Button mRateIncreaseButton;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.playback_layout, container);
+ mPlaybackSeek = (SeekBar) view.findViewById(R.id.playback_seek);
+ mPlaybackSeek = (SeekBar) view.findViewById(R.id.playback_seek);
+ mStartStopButton = (ImageButton) view.findViewById(R.id.playback_start_stop);
+ mPlaybackSpeakerphone = (ImageButton) view.findViewById(R.id.playback_speakerphone);
+ mPlaybackTrashButton = (ImageButton) view.findViewById(R.id.playback_trash);
+ mPlaybackPositionText = (TextView) view.findViewById(R.id.playback_position_text);
+ mRateDecreaseButton = (Button) view.findViewById(R.id.rate_decrease_button);
+ mRateIncreaseButton = (Button) view.findViewById(R.id.rate_increase_button);
+ return view;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ mScheduledExecutorService = createScheduledExecutorService();
+ mPresenter = new VoicemailPlaybackPresenter(new PlaybackViewImpl(),
+ createMediaPlayer(mScheduledExecutorService), mScheduledExecutorService);
+ mPresenter.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ mPresenter.onSaveInstanceState(outState);
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onDestroy() {
+ mPresenter.onDestroy();
+ mScheduledExecutorService.shutdown();
+ super.onDestroy();
+ }
+
+ /** Call this from the Activity containing this fragment to set the voicemail to play. */
+ public void setVoicemailUri(Uri voicemailUri, boolean startPlaying) {
+ mPresenter.setVoicemailUri(voicemailUri, startPlaying);
+ }
+
+ private MediaPlayerProxy createMediaPlayer(ExecutorService executorService) {
+ return VariableSpeed.createVariableSpeed(executorService);
+ }
+
+ private ScheduledExecutorService createScheduledExecutorService() {
+ return Executors.newScheduledThreadPool(NUMBER_OF_THREADS_IN_POOL);
+ }
+
+ /**
+ * Formats a number of milliseconds as something that looks like {@code 00:05}.
+ * <p>
+ * We always use four digits, two for minutes two for seconds. In the very unlikely event
+ * that the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes.
+ */
+ private String formatAsMinutesAndSeconds(int millis) {
+ int seconds = millis / 1000;
+ int minutes = seconds / 60;
+ seconds -= minutes * 60;
+ if (minutes > 99) {
+ minutes = 99;
+ }
+ return String.format("%02d:%02d", minutes, seconds);
+ }
+
+ private AudioManager getAudioManager() {
+ return (AudioManager) getActivity().getSystemService(Context.AUDIO_SERVICE);
+ }
+
+ /** Methods required by the PlaybackView for the VoicemailPlaybackPresenter. */
+ private class PlaybackViewImpl implements VoicemailPlaybackPresenter.PlaybackView {
+ @Override
+ public void finish() {
+ getActivity().finish();
+ }
+
+ @Override
+ public void runOnUiThread(Runnable runnable) {
+ getActivity().runOnUiThread(runnable);
+ }
+
+ @Override
+ public Context getDataSourceContext() {
+ return getActivity();
+ }
+
+ @Override
+ public void setRateDecreaseButtonListener(View.OnClickListener listener) {
+ mRateDecreaseButton.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setRateIncreaseButtonListener(View.OnClickListener listener) {
+ mRateIncreaseButton.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setStartStopListener(View.OnClickListener listener) {
+ mStartStopButton.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setSpeakerphoneListener(View.OnClickListener listener) {
+ mPlaybackSpeakerphone.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setRateDisplay(float rate) {
+ // TODO: This isn't being done yet. Old rate display code has been removed.
+ // Instead we're going to temporarily fade out the track position when you change
+ // rate, and display one of the words "slowest", "slower", "normal", "faster",
+ // "fastest" briefly when you change speed, before fading back in the time.
+ // At least, that's the current thinking.
+ }
+
+ @Override
+ public void setDeleteButtonListener(View.OnClickListener listener) {
+ mPlaybackTrashButton.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener) {
+ mPlaybackSeek.setOnSeekBarChangeListener(listener);
+ }
+
+ @Override
+ public void playbackStarted() {
+ mStartStopButton.setImageResource(R.drawable.pause_button);
+ }
+
+ @Override
+ public void playbackStopped() {
+ mStartStopButton.setImageResource(R.drawable.play_button);
+ }
+
+ @Override
+ public void setClipLength(int clipLengthInMillis) {
+ mPlaybackSeek.setMax(clipLengthInMillis);
+ // TODO: The old code used to set the static lenght-of-clip text field, but now
+ // the thinking is that we will only show this text whilst the recording is stopped.
+ }
+
+ @Override
+ public void setClipPosition(int clipPositionInMillis) {
+ mPlaybackSeek.setProgress(clipPositionInMillis);
+ mPlaybackPositionText.setText(formatAsMinutesAndSeconds(clipPositionInMillis));
+ }
+
+ @Override
+ public int getDesiredClipPosition() {
+ return mPlaybackSeek.getProgress();
+ }
+
+ @Override
+ public void playbackError() {
+ mStartStopButton.setEnabled(false);
+ mPlaybackSeek.setProgress(0);
+ mPlaybackSeek.setEnabled(false);
+ }
+
+ @Override
+ public boolean isSpeakerPhoneOn() {
+ return getAudioManager().isSpeakerphoneOn();
+ }
+
+ @Override
+ public void setSpeakerPhoneOn(boolean on) {
+ getAudioManager().setMode(AudioManager.MODE_IN_CALL);
+ getAudioManager().setSpeakerphoneOn(on);
+ if (on) {
+ mPlaybackSpeakerphone.setImageResource(R.drawable.speakerphone_on_button);
+ } else {
+ mPlaybackSpeakerphone.setImageResource(R.drawable.speakerphone_off_button);
+ }
+ }
+ }
+}
diff --git a/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java b/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java
new file mode 100644
index 0000000..f727080
--- /dev/null
+++ b/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java
@@ -0,0 +1,345 @@
+/*
+ * Copyright (C) 2011 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.contacts.voicemail;
+
+import android.content.Context;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.SeekBar;
+
+import com.android.ex.variablespeed.MediaPlayerProxy;
+import com.android.ex.variablespeed.SingleThreadedMediaPlayerProxy;
+
+import java.io.IOException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.NotThreadSafe;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * Contains the controlling logic for a voicemail playback ui.
+ * <p>
+ * Specifically right now this class is used to control the
+ * {@link com.android.contacts.voicemail.VoicemailPlaybackFragment}.
+ * <p>
+ * This class is not thread safe. The thread policy for this class is
+ * thread-confinement, all calls into this class from outside must be done from
+ * the main ui thread.
+ */
+@NotThreadSafe
+/*package*/ class VoicemailPlaybackPresenter {
+ /** Contract describing the behaviour we need from the ui we are controlling. */
+ public interface PlaybackView {
+ Context getDataSourceContext();
+ void runOnUiThread(Runnable runnable);
+ void setStartStopListener(View.OnClickListener listener);
+ void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener);
+ void setSpeakerphoneListener(View.OnClickListener listener);
+ void setDeleteButtonListener(View.OnClickListener listener);
+ void setClipLength(int clipLengthInMillis);
+ void setClipPosition(int clipPositionInMillis);
+ int getDesiredClipPosition();
+ void playbackStarted();
+ void playbackStopped();
+ void playbackError();
+ boolean isSpeakerPhoneOn();
+ void setSpeakerPhoneOn(boolean on);
+ void finish();
+ void setRateDisplay(float rate);
+ void setRateIncreaseButtonListener(View.OnClickListener listener);
+ void setRateDecreaseButtonListener(View.OnClickListener listener);
+ }
+
+ /** Update rate for the slider, 30fps. */
+ private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30;
+ /**
+ * If present in the saved instance bundle, we should not resume playback on
+ * create.
+ */
+ private static final String PAUSED_STATE_KEY = VoicemailPlaybackPresenter.class.getName()
+ + ".PAUSED_STATE_KEY";
+ /**
+ * If present in the saved instance bundle, indicates where to set the
+ * playback slider.
+ */
+ private static final String CLIP_POSITION_KEY = VoicemailPlaybackPresenter.class.getName()
+ + ".CLIP_POSITION_KEY";
+
+ /** The preset variable-speed rates. Each is greater than the previous by 25%. */
+ private static final float[] PRESET_RATES = new float[] {
+ 0.64f, 0.8f, 1.0f, 1.25f, 1.5625f
+ };
+
+ /** Index into {@link #PRESET_RATES} indicating the current playback speed. */
+ private final AtomicInteger mCurrentPlaybackRate = new AtomicInteger(2);
+
+ private final PlaybackView mView;
+ private final MediaPlayerProxy mPlayer;
+ private final PositionUpdater mPositionUpdater;
+
+ /** Voicemail uri to play, will be set with a call to {@link #setVoicemailUri(Uri, boolean)}. */
+ private Uri mVoicemailUri;
+
+ public VoicemailPlaybackPresenter(PlaybackView view, MediaPlayerProxy player,
+ ScheduledExecutorService executorService) {
+ mView = view;
+ mPlayer = player;
+ mPositionUpdater = new PositionUpdater(executorService, SLIDER_UPDATE_PERIOD_MILLIS);
+ }
+
+ public void onCreate(Bundle bundle) {
+ mView.setPositionSeekListener(new PlaybackPositionListener());
+ mView.setStartStopListener(new StartStopButtonListener());
+ mView.setSpeakerphoneListener(new SpeakerphoneListener());
+ mView.setDeleteButtonListener(new DeleteButtonListener());
+ mPlayer.setOnErrorListener(new MediaPlayerErrorListener());
+ mPlayer.setOnCompletionListener(new MediaPlayerCompletionListener());
+ mView.setSpeakerPhoneOn(mView.isSpeakerPhoneOn());
+ mView.setRateDecreaseButtonListener(createRateDecreaseListener());
+ mView.setRateIncreaseButtonListener(createRateIncreaseListener());
+ mView.setClipPosition(0);
+ // TODO: Now I'm ignoring the bundle, when previously I was checking for contains against
+ // the PAUSED_STATE_KEY, and CLIP_POSITION_KEY.
+ }
+
+ public void onSaveInstanceState(Bundle outState) {
+ outState.putInt(CLIP_POSITION_KEY, mView.getDesiredClipPosition());
+ if (!mPlayer.isPlaying()) {
+ outState.putBoolean(PAUSED_STATE_KEY, true);
+ }
+ }
+
+ public void onDestroy() {
+ mPlayer.release();
+ }
+
+ public void setVoicemailUri(Uri voicemailUri, boolean startPlaying) {
+ mVoicemailUri = voicemailUri;
+ if (startPlaying) {
+ resetPrepareStartPlaying(0);
+ }
+ }
+
+ private class MediaPlayerErrorListener implements MediaPlayer.OnErrorListener {
+ @Override
+ public boolean onError(MediaPlayer mp, int what, int extra) {
+ mView.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ handleError(new IllegalStateException("MediaPlayer error listener invoked"));
+ }
+ });
+ return true;
+ }
+ }
+
+ private class MediaPlayerCompletionListener implements MediaPlayer.OnCompletionListener {
+ @Override
+ public void onCompletion(final MediaPlayer mp) {
+ mView.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ handleCompletion(mp);
+ }
+ });
+ }
+ }
+
+ public View.OnClickListener createRateDecreaseListener() {
+ return new RateChangeListener(false);
+ }
+
+ public View.OnClickListener createRateIncreaseListener() {
+ return new RateChangeListener(true);
+ }
+
+ private class RateChangeListener implements View.OnClickListener {
+ private final boolean mIncrease;
+
+ public RateChangeListener(boolean increase) {
+ mIncrease = increase;
+ }
+
+ @Override
+ public void onClick(View v) {
+ int adjustment = (mIncrease ? 1 : -1);
+ int andGet = mCurrentPlaybackRate.addAndGet(adjustment);
+ if (andGet < 0) {
+ // TODO: discussions with interaction design have suggested that we might make
+ // an audible tone play here to indicate that you've hit the end of the range?
+ // Let's firm up this decision.
+ mCurrentPlaybackRate.set(0);
+ } else if (andGet >= PRESET_RATES.length) {
+ mCurrentPlaybackRate.set(PRESET_RATES.length - 1);
+ } else {
+ changeRate(PRESET_RATES[andGet]);
+ }
+ }
+ }
+
+ private void resetPrepareStartPlaying(int clipPositionInMillis) {
+ try {
+ mPlayer.reset();
+ mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri);
+ mPlayer.prepare();
+ int clipLengthInMillis = mPlayer.getDuration();
+ mView.setClipLength(clipLengthInMillis);
+ int startPosition = Math.min(Math.max(clipPositionInMillis, 0), clipLengthInMillis);
+ mPlayer.seekTo(startPosition);
+ mPlayer.start();
+ mView.playbackStarted();
+ mPositionUpdater.startUpdating(startPosition, clipLengthInMillis);
+ } catch (IOException e) {
+ handleError(e);
+ }
+ }
+
+ private void handleError(Exception e) {
+ mView.playbackError();
+ mPlayer.release();
+ mPositionUpdater.stopUpdating();
+ }
+
+ public void handleCompletion(MediaPlayer mediaPlayer) {
+ stopPlaybackAtPosition(0);
+ }
+
+ private void stopPlaybackAtPosition(int clipPosition) {
+ mView.playbackStopped();
+ mPositionUpdater.stopUpdating();
+ mView.setClipPosition(clipPosition);
+ if (mPlayer.isPlaying()) {
+ mPlayer.pause();
+ }
+ }
+
+ private class PlaybackPositionListener implements SeekBar.OnSeekBarChangeListener {
+ private boolean mShouldResumePlaybackAfterSeeking;
+
+ @Override
+ public void onStartTrackingTouch(SeekBar arg0) {
+ if (mPlayer.isPlaying()) {
+ mShouldResumePlaybackAfterSeeking = true;
+ stopPlaybackAtPosition(mPlayer.getCurrentPosition());
+ } else {
+ mShouldResumePlaybackAfterSeeking = false;
+ }
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar arg0) {
+ if (mPlayer.isPlaying()) {
+ stopPlaybackAtPosition(mPlayer.getCurrentPosition());
+ }
+ if (mShouldResumePlaybackAfterSeeking) {
+ resetPrepareStartPlaying(mView.getDesiredClipPosition());
+ }
+ }
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ mView.setClipPosition(seekBar.getProgress());
+ }
+ }
+
+ private void changeRate(float rate) {
+ ((SingleThreadedMediaPlayerProxy) mPlayer).setVariableSpeed(rate);
+ mView.setRateDisplay(rate);
+ }
+
+ private class SpeakerphoneListener implements View.OnClickListener {
+ @Override
+ public void onClick(View v) {
+ mView.setSpeakerPhoneOn(!mView.isSpeakerPhoneOn());
+ }
+ }
+
+ private class DeleteButtonListener implements View.OnClickListener {
+ @Override
+ public void onClick(View v) {
+ // TODO: Temporarily removed this whilst the team discuss the merits of porting
+ // the VoicemailHelper class across vs just hard-coding the delete via cursor.
+ mView.finish();
+ }
+ }
+
+ private class StartStopButtonListener implements View.OnClickListener {
+ @Override
+ public void onClick(View arg0) {
+ if (mPlayer.isPlaying()) {
+ stopPlaybackAtPosition(mPlayer.getCurrentPosition());
+ } else {
+ resetPrepareStartPlaying(mView.getDesiredClipPosition());
+ }
+ }
+ }
+
+ /**
+ * Controls the animation of the playback slider.
+ */
+ @ThreadSafe
+ private final class PositionUpdater implements Runnable {
+ private final ScheduledExecutorService mExecutorService;
+ private final int mPeriodMillis;
+ private final Object mLock = new Object();
+ @GuardedBy("mLock") private ScheduledFuture<?> mScheduledFuture;
+
+ public PositionUpdater(ScheduledExecutorService executorService, int periodMillis) {
+ mExecutorService = executorService;
+ mPeriodMillis = periodMillis;
+ }
+
+ @Override
+ public void run() {
+ synchronized (mLock) {
+ if (mScheduledFuture != null) {
+ mView.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mView.setClipPosition(mPlayer.getCurrentPosition());
+ }
+ });
+ }
+ }
+ }
+
+ public void startUpdating(int beginPosition, int endPosition) {
+ synchronized (mLock) {
+ if (mScheduledFuture != null) {
+ mScheduledFuture.cancel(false);
+ }
+ mScheduledFuture = mExecutorService.scheduleAtFixedRate(this, 0, mPeriodMillis,
+ TimeUnit.MILLISECONDS);
+ }
+ }
+
+ public void stopUpdating() {
+ synchronized (mLock) {
+ if (mScheduledFuture != null) {
+ mScheduledFuture.cancel(false);
+ mScheduledFuture = null;
+ }
+ }
+ }
+ }
+}