Merge "Fix for loading the same contact photo twice."
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-sw580dp/group_detail_fragment.xml b/res/layout-sw580dp/group_detail_fragment.xml
index a824319..7c65036 100644
--- a/res/layout-sw580dp/group_detail_fragment.xml
+++ b/res/layout-sw580dp/group_detail_fragment.xml
@@ -37,8 +37,8 @@
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:paddingLeft="@dimen/group_detail_border_padding"
-        android:textAppearance="?android:attr/textAppearanceMedium"
-        android:textColor="?android:attr/textColorSecondary" />
+        android:textAppearance="?android:attr/textAppearanceSmall"
+        android:textColor="?android:attr/textColorTertiary" />
 
     <View
         android:layout_width="match_parent"
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..54bb5ed 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1569,6 +1569,12 @@
       (Contacts themselves will not be deleted.)
     </string>
 
+    <!-- Subtitle of the group detail page that describes how many people are in the current group [CHAR LIMIT=30] -->
+    <plurals name="num_contacts_in_group">
+        <item quantity="one"><xliff:g id="count">%1$d</xliff:g> person from <xliff:g id="account_type">%2$s</xliff:g></item>
+        <item quantity="other"><xliff:g id="count">%1$d</xliff:g> people from <xliff:g id="account_type">%2$s</xliff:g></item>
+    </plurals>
+
     <!-- Toast displayed when the user creates a new contact and attempts to join it
       with another before entering any data  [CHAR LIMIT=256] -->
     <string name="toast_join_with_empty_contact">Please enter contact name before joining
@@ -1611,6 +1617,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..9fd1b03 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;
@@ -103,7 +109,7 @@
         PhoneLookup.LABEL,
         PhoneLookup.NUMBER,
         PhoneLookup.NORMALIZED_NUMBER,
-        PhoneLookup.PHOTO_ID,
+        PhoneLookup.PHOTO_URI,
     };
     static final int COLUMN_INDEX_ID = 0;
     static final int COLUMN_INDEX_NAME = 1;
@@ -111,7 +117,7 @@
     static final int COLUMN_INDEX_LABEL = 3;
     static final int COLUMN_INDEX_NUMBER = 4;
     static final int COLUMN_INDEX_NORMALIZED_NUMBER = 5;
-    static final int COLUMN_INDEX_PHOTO_ID = 6;
+    static final int COLUMN_INDEX_PHOTO_URI = 6;
 
     @Override
     protected void onCreate(Bundle icicle) {
@@ -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);
+        }
     }
 
     /**
@@ -220,7 +249,7 @@
         // We know that all calls are from the same number and the same contact, so pick the first.
         mNumber = details[0].number.toString();
         final long personId = details[0].personId;
-        final long photoId = details[0].photoId;
+        final Uri photoUri = details[0].photoUri;
 
         // Set the details header, based on the first phone call.
         mPhoneCallDetailsHelper.setPhoneCallDetails(mPhoneCallDetailsViews,
@@ -327,7 +356,7 @@
         ListView historyList = (ListView) findViewById(R.id.history);
         historyList.setAdapter(
                 new CallDetailHistoryAdapter(this, mInflater, mCallTypeHelper, details));
-        loadContactPhotos(photoId);
+        loadContactPhotos(photoUri);
     }
 
     /** Return the phone call details for a given call log URI. */
@@ -356,7 +385,7 @@
             int numberType = 0;
             CharSequence numberLabel = "";
             long personId = -1L;
-            long photoId = 0L;
+            Uri photoUri = null;
             // If this is not a regular number, there is no point in looking it up in the contacts.
             if (!mPhoneNumberHelper.canPlaceCallsTo(number)) {
                 numberText = mPhoneNumberHelper.getDisplayNumber(number, null);
@@ -370,7 +399,8 @@
                     if (phonesCursor != null && phonesCursor.moveToFirst()) {
                         personId = phonesCursor.getLong(COLUMN_INDEX_ID);
                         nameText = phonesCursor.getString(COLUMN_INDEX_NAME);
-                        photoId = phonesCursor.getLong(COLUMN_INDEX_PHOTO_ID);
+                        String photoUriString = phonesCursor.getString(COLUMN_INDEX_PHOTO_URI);
+                        photoUri = photoUriString == null ? null : Uri.parse(photoUriString);
                         candidateNumberText = PhoneNumberUtils.formatNumber(
                                 phonesCursor.getString(COLUMN_INDEX_NUMBER),
                                 phonesCursor.getString(COLUMN_INDEX_NORMALIZED_NUMBER),
@@ -390,7 +420,7 @@
                 }
             }
             return new PhoneCallDetails(number, numberText, new int[]{ callType }, date, duration,
-                    nameText, numberType, numberLabel, personId, photoId);
+                    nameText, numberType, numberLabel, personId, photoUri);
         } finally {
             if (callCursor != null) {
                 callCursor.close();
@@ -399,8 +429,8 @@
     }
 
     /** Load the contact photos and places them in the corresponding views. */
-    private void loadContactPhotos(final long photoId) {
-        mContactPhotoManager.loadPhoto(mContactBackgroundView, photoId);
+    private void loadContactPhotos(Uri photoUri) {
+        mContactPhotoManager.loadPhoto(mContactBackgroundView, photoUri);
     }
 
     private String getVoicemailNumber() {
diff --git a/src/com/android/contacts/PhoneCallDetails.java b/src/com/android/contacts/PhoneCallDetails.java
index 6ab47aa..d4786d9 100644
--- a/src/com/android/contacts/PhoneCallDetails.java
+++ b/src/com/android/contacts/PhoneCallDetails.java
@@ -16,6 +16,7 @@
 
 package com.android.contacts;
 
+import android.net.Uri;
 import android.provider.CallLog.Calls;
 import android.provider.ContactsContract.CommonDataKinds.Phone;
 
@@ -45,19 +46,22 @@
     public final CharSequence numberLabel;
     /** The id of the contact associated with this phone call. */
     public final long personId;
-    /** The photo id of the contact associated with this phone call. */
-    public final long photoId;
+    /**
+     * The photo uri of the picture of the contact that is associated with this phone call or
+     * null if there is none.
+     */
+    public final Uri photoUri;
 
     /** Create the details for a call with a number not associated with a contact. */
     public PhoneCallDetails(CharSequence number, CharSequence formattedNumber, int[] callTypes,
             long date, long duration) {
-        this(number, formattedNumber, callTypes, date, duration, "", 0, "", -1L, 0L);
+        this(number, formattedNumber, callTypes, date, duration, "", 0, "", -1L, null);
     }
 
     /** Create the details for a call with a number associated with a contact. */
     public PhoneCallDetails(CharSequence number, CharSequence formattedNumber, int[] callTypes,
             long date, long duration, CharSequence name, int numberType, CharSequence numberLabel,
-            long personId, long photoId) {
+            long personId, Uri photoUri) {
         this.number = number;
         this.formattedNumber = formattedNumber;
         this.callTypes = callTypes;
@@ -67,6 +71,6 @@
         this.numberType = numberType;
         this.numberLabel = numberLabel;
         this.personId = personId;
-        this.photoId = photoId;
+        this.photoUri = photoUri;
     }
 }
diff --git a/src/com/android/contacts/activities/ContactDetailActivity.java b/src/com/android/contacts/activities/ContactDetailActivity.java
index 0637fd5..de262b2 100644
--- a/src/com/android/contacts/activities/ContactDetailActivity.java
+++ b/src/com/android/contacts/activities/ContactDetailActivity.java
@@ -172,9 +172,8 @@
     }
 
     @Override
-    public void startSearch(String initialQuery, boolean selectInitialQuery, Bundle appSearchData,
-            boolean globalSearch) {
-        // Ignore search key press
+    public boolean onSearchRequested() {
+        return true; // Don't respond to the search key.
     }
 
     @Override
diff --git a/src/com/android/contacts/activities/ContactEditorActivity.java b/src/com/android/contacts/activities/ContactEditorActivity.java
index 1ee7f1d..6655c81 100644
--- a/src/com/android/contacts/activities/ContactEditorActivity.java
+++ b/src/com/android/contacts/activities/ContactEditorActivity.java
@@ -120,9 +120,8 @@
     }
 
     @Override
-    public void startSearch(String initialQuery, boolean selectInitialQuery, Bundle appSearchData,
-            boolean globalSearch) {
-        // Ignore search key press
+    public boolean onSearchRequested() {
+        return true; // Don't respond to the search key.
     }
 
     @Override
diff --git a/src/com/android/contacts/activities/GroupDetailActivity.java b/src/com/android/contacts/activities/GroupDetailActivity.java
index 635c130..dcd35fa 100644
--- a/src/com/android/contacts/activities/GroupDetailActivity.java
+++ b/src/com/android/contacts/activities/GroupDetailActivity.java
@@ -92,8 +92,7 @@
     }
 
     @Override
-    public void startSearch(String initialQuery, boolean selectInitialQuery, Bundle appSearchData,
-            boolean globalSearch) {
-        // Ignore search key press
+    public boolean onSearchRequested() {
+        return true; // Don't respond to the search key.
     }
 }
diff --git a/src/com/android/contacts/activities/GroupEditorActivity.java b/src/com/android/contacts/activities/GroupEditorActivity.java
index ecadcec..b2553a4 100644
--- a/src/com/android/contacts/activities/GroupEditorActivity.java
+++ b/src/com/android/contacts/activities/GroupEditorActivity.java
@@ -83,9 +83,8 @@
     }
 
     @Override
-    public void startSearch(String initialQuery, boolean selectInitialQuery, Bundle appSearchData,
-            boolean globalSearch) {
-        // Ignore search key press
+    public boolean onSearchRequested() {
+        return true; // Don't respond to the search key.
     }
 
     @Override
diff --git a/src/com/android/contacts/activities/PeopleActivity.java b/src/com/android/contacts/activities/PeopleActivity.java
index 9dfa972..ee36d8f 100644
--- a/src/com/android/contacts/activities/PeopleActivity.java
+++ b/src/com/android/contacts/activities/PeopleActivity.java
@@ -120,8 +120,6 @@
 
     private ActionBarAdapter mActionBarAdapter;
 
-    private boolean mSearchMode;
-
     private ContactDetailFragment mContactDetailFragment;
     private ContactDetailUpdatesFragment mContactDetailUpdatesFragment;
     private final ContactDetailFragmentListener mContactDetailFragmentListener =
@@ -138,8 +136,6 @@
     private StrequentContactListFragment.Listener mFavoritesFragmentListener =
             new StrequentContactListFragmentListener();
 
-    private boolean mSearchInitiated;
-
     private ContactListFilterController mContactListFilterController;
 
     private ContactsUnavailableFragment mContactsUnavailableFragment;
@@ -470,6 +466,7 @@
         if (fromRequest) {
             ContactListFilter filter = null;
             int actionCode = mRequest.getActionCode();
+            boolean searchMode = mRequest.isSearchMode();
             switch (actionCode) {
                 case ContactsRequest.ACTION_ALL_CONTACTS:
                     filter = ContactListFilter.createFilterWithType(
@@ -494,25 +491,17 @@
                     }
             }
 
-            mSearchMode = mRequest.isSearchMode();
             if (filter != null) {
                 mContactListFilterController.setContactListFilter(filter, false);
-                mSearchMode = false;
-            } else if (mRequest.getActionCode() == ContactsRequest.ACTION_ALL_CONTACTS) {
-                mContactListFilterController.setContactListFilter(
-                        ContactListFilter.createFilterWithType(
-                        ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS), false);
+                searchMode = false;
             }
 
             if (mRequest.getContactUri() != null) {
-                mSearchMode = false;
+                searchMode = false;
             }
 
-            mAllFragment.setContactsRequest(mRequest);
+            mActionBarAdapter.setSearchMode(searchMode);
             configureContactListFragmentForRequest();
-
-        } else {
-            mSearchMode = mActionBarAdapter.isSearchMode();
         }
 
         configureContactListFragment();
@@ -805,13 +794,15 @@
     }
 
     private void configureContactListFragmentForRequest() {
+        mAllFragment.setContactsRequest(mRequest);
+
         Uri contactUri = mRequest.getContactUri();
         if (contactUri != null) {
             mAllFragment.setSelectedContactUri(contactUri);
         }
 
-        mAllFragment.setSearchMode(mRequest.isSearchMode());
-        mAllFragment.setQueryString(mRequest.getQueryString(), false);
+        mAllFragment.setSearchMode(mActionBarAdapter.isSearchMode());
+        mAllFragment.setQueryString(mActionBarAdapter.getQueryString(), false);
 
         if (mRequest.isDirectorySearchEnabled()) {
             mAllFragment.setDirectorySearchMode(DirectoryListLoader.SEARCH_MODE_DEFAULT);
@@ -825,10 +816,11 @@
     }
 
     private void configureContactListFragment() {
-        mAllFragment.setSearchMode(mSearchMode);
+        final boolean searchMode = mActionBarAdapter.isSearchMode();
+        mAllFragment.setSearchMode(searchMode);
 
         final boolean useTwoPane = PhoneCapabilityTester.isUsingTwoPanes(this);
-        mAllFragment.setVisibleScrollbarEnabled(!mSearchMode);
+        mAllFragment.setVisibleScrollbarEnabled(!searchMode);
         mAllFragment.setVerticalScrollbarPosition(
                 useTwoPane
                         ? View.SCROLLBAR_POSITION_LEFT
@@ -1340,13 +1332,9 @@
     }
 
     @Override
-    public void startSearch(String initialQuery, boolean selectInitialQuery, Bundle appSearchData,
-            boolean globalSearch) {
-        if (mAllFragment != null && mAllFragment.isAdded() && !globalSearch) {
-            mAllFragment.startSearch(initialQuery);
-        } else {
-            super.startSearch(initialQuery, selectInitialQuery, appSearchData, globalSearch);
-        }
+    public boolean onSearchRequested() { // Search key pressed.
+        mActionBarAdapter.setSearchMode(true);
+        return true;
     }
 
     @Override
@@ -1437,12 +1425,6 @@
                         mActionBarAdapter.setQueryString(query);
                         mActionBarAdapter.setSearchMode(true);
                         return true;
-                    } else if (!mRequest.isSearchMode()) {
-                        if (!mSearchInitiated) {
-                            mSearchInitiated = true;
-                            startSearch(query, false, null, false);
-                            return true;
-                        }
                     }
                 }
             }
@@ -1453,7 +1435,7 @@
 
     @Override
     public void onBackPressed() {
-        if (mSearchMode && mActionBarAdapter != null) {
+        if (mActionBarAdapter.isSearchMode()) {
             mActionBarAdapter.setSearchMode(false);
         } else {
             super.onBackPressed();
@@ -1478,7 +1460,6 @@
     @Override
     protected void onSaveInstanceState(Bundle outState) {
         super.onSaveInstanceState(outState);
-        outState.putBoolean(KEY_SEARCH_MODE, mSearchMode);
         mActionBarAdapter.onSaveInstanceState(outState);
         if (mContactDetailLayoutController != null) {
             mContactDetailLayoutController.onSaveInstanceState(outState);
@@ -1493,7 +1474,6 @@
     @Override
     protected void onRestoreInstanceState(Bundle inState) {
         super.onRestoreInstanceState(inState);
-        mSearchMode = inState.getBoolean(KEY_SEARCH_MODE);
         if (mContactDetailLayoutController != null) {
             mContactDetailLayoutController.onRestoreInstanceState(inState);
         }
diff --git a/src/com/android/contacts/calllog/CallLogFragment.java b/src/com/android/contacts/calllog/CallLogFragment.java
index 1fb0a03..7db5281 100644
--- a/src/com/android/contacts/calllog/CallLogFragment.java
+++ b/src/com/android/contacts/calllog/CallLogFragment.java
@@ -37,11 +37,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;
@@ -75,8 +79,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,
@@ -84,13 +90,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.
@@ -100,7 +109,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. */
@@ -120,7 +129,7 @@
                 PhoneLookup.LABEL,
                 PhoneLookup.NUMBER,
                 PhoneLookup.NORMALIZED_NUMBER,
-                PhoneLookup.PHOTO_ID,
+                PhoneLookup.PHOTO_THUMBNAIL_URI,
                 PhoneLookup.LOOKUP_KEY};
 
         public static final int PERSON_ID = 0;
@@ -129,7 +138,7 @@
         public static final int LABEL = 3;
         public static final int MATCHED_NUMBER = 4;
         public static final int NORMALIZED_NUMBER = 5;
-        public static final int PHOTO_ID = 6;
+        public static final int THUMBNAIL_URI = 6;
         public static final int LOOKUP_KEY = 7;
     }
 
@@ -158,7 +167,7 @@
         public String number;
         public String formattedNumber;
         public String normalizedNumber;
-        public long photoId;
+        public Uri thumbnailUri;
         public String lookupKey;
 
         public static ContactInfo EMPTY = new ContactInfo();
@@ -170,10 +179,53 @@
         public String name;
         public int numberType;
         public String numberLabel;
-        public long photoId;
+        public Uri thumbnailUri;
         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 {
@@ -220,18 +272,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()));
             }
         }
 
@@ -341,15 +384,15 @@
             mContactInfoCache.expireAll();
         }
 
-        private void enqueueRequest(String number, boolean immediate, int position,
-                String name, int numberType, String numberLabel, long photoId, String lookupKey) {
+        private void enqueueRequest(String number, boolean immediate, int position, String name,
+                int numberType, String numberLabel, Uri thumbnailUri, String lookupKey) {
             CallerInfoQuery ciq = new CallerInfoQuery();
             ciq.number = number;
             ciq.position = position;
             ciq.name = name;
             ciq.numberType = numberType;
             ciq.numberLabel = numberLabel;
-            ciq.photoId = photoId;
+            ciq.thumbnailUri = thumbnailUri;
             ciq.lookupKey = lookupKey;
             synchronized (mRequests) {
                 mRequests.add(ciq);
@@ -430,8 +473,11 @@
                             info.number = dataTableCursor.getString(
                                     dataTableCursor.getColumnIndex(Data.DATA1));
                             info.normalizedNumber = null;  // meaningless for SIP addresses
-                            info.photoId = dataTableCursor.getLong(
-                                    dataTableCursor.getColumnIndex(Data.PHOTO_ID));
+                            final String thumbnailUriString = dataTableCursor.getString(
+                                    dataTableCursor.getColumnIndex(Data.PHOTO_THUMBNAIL_URI));
+                            info.thumbnailUri = thumbnailUriString == null
+                                    ? null
+                                    : Uri.parse(thumbnailUriString);
                             info.lookupKey = dataTableCursor.getString(
                                     dataTableCursor.getColumnIndex(Data.LOOKUP_KEY));
 
@@ -458,7 +504,11 @@
                                     .getString(PhoneQuery.MATCHED_NUMBER);
                             info.normalizedNumber = phonesCursor
                                     .getString(PhoneQuery.NORMALIZED_NUMBER);
-                            info.photoId = phonesCursor.getLong(PhoneQuery.PHOTO_ID);
+                            final String thumbnailUriString = phonesCursor.getString(
+                                    PhoneQuery.THUMBNAIL_URI);
+                            info.thumbnailUri = thumbnailUriString == null
+                                    ? null
+                                    : Uri.parse(thumbnailUriString);
                             info.lookupKey = phonesCursor.getString(PhoneQuery.LOOKUP_KEY);
 
                             infoUpdated = true;
@@ -679,8 +729,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
@@ -696,7 +749,7 @@
                 mContactInfoCache.put(number, info);
                 Log.d(TAG, "Contact info missing: " + number);
                 // Request the contact details immediately since they are currently missing.
-                enqueueRequest(number, true, c.getPosition(), "", 0, "", 0L, "");
+                enqueueRequest(number, true, c.getPosition(), "", 0, "", null, "");
             } else if (info != ContactInfo.EMPTY) { // Has been queried
                 if (cachedInfo.isExpired()) {
                     Log.d(TAG, "Contact info expired: " + number);
@@ -706,7 +759,7 @@
                     // The contact info is no longer up to date, we should request it. However, we
                     // do not need to request them immediately.
                     enqueueRequest(number, false, c.getPosition(), info.name, info.type, info.label,
-                            info.photoId, info.lookupKey);
+                            info.thumbnailUri, info.lookupKey);
                 }
 
                 // Format and cache phone number for found contact
@@ -724,7 +777,7 @@
             final String name = info.name;
             final int ntype = info.type;
             final String label = info.label;
-            final long photoId = info.photoId;
+            final Uri thumbnailUri = info.thumbnailUri;
             final String lookupKey = info.lookupKey;
             // Assumes the call back feature is on most of the
             // time. For private and unknown numbers: hide it.
@@ -738,7 +791,7 @@
                 details = new PhoneCallDetails(number, formattedNumber, callTypes, date, duration);
             } else {
                 details = new PhoneCallDetails(number, formattedNumber, callTypes, date, duration,
-                        name, ntype, label, personId, photoId);
+                        name, ntype, label, personId, thumbnailUri);
             }
 
             final boolean isNew = isNewSection(c);
@@ -748,7 +801,7 @@
             final boolean isHighlighted = isNew;
             mCallLogViewsHelper.setPhoneCallDetails(views, details, useIcons, isHighlighted);
             if (views.photoView != null) {
-                bindQuickContact(views.photoView, photoId, personId, lookupKey);
+                bindQuickContact(views.photoView, thumbnailUri, personId, lookupKey);
             }
 
 
@@ -778,10 +831,10 @@
             return callTypes;
         }
 
-        private void bindQuickContact(QuickContactBadge view, long photoId, long contactId,
+        private void bindQuickContact(QuickContactBadge view, Uri thumbnailUri, long contactId,
                 String lookupKey) {
             view.assignContactUri(getContactUri(contactId, lookupKey));
-            mContactPhotoManager.loadPhoto(view, photoId);
+            mContactPhotoManager.loadPhoto(view, thumbnailUri);
         }
 
         private Uri getContactUri(long contactId, String lookupKey) {
@@ -1064,22 +1117,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/group/GroupDetailFragment.java b/src/com/android/contacts/group/GroupDetailFragment.java
index c5f6ef4..7f0536f 100644
--- a/src/com/android/contacts/group/GroupDetailFragment.java
+++ b/src/com/android/contacts/group/GroupDetailFragment.java
@@ -23,6 +23,8 @@
 import com.android.contacts.interactions.GroupDeletionDialogFragment;
 import com.android.contacts.list.ContactTileAdapter;
 import com.android.contacts.list.ContactTileAdapter.DisplayType;
+import com.android.contacts.model.AccountType;
+import com.android.contacts.model.AccountTypeManager;
 
 import android.app.Activity;
 import android.app.Fragment;
@@ -90,10 +92,12 @@
 
     private ContactTileAdapter mAdapter;
     private ContactPhotoManager mPhotoManager;
+    private AccountTypeManager mAccountTypeManager;
 
     private Uri mGroupUri;
     private long mGroupId;
     private String mGroupName;
+    private String mAccountTypeString;
 
     private boolean mOptionsMenuEditable;
     private boolean mCloseActivityAfterDelete;
@@ -105,6 +109,7 @@
     public void onAttach(Activity activity) {
         super.onAttach(activity);
         mContext = activity;
+        mAccountTypeManager = AccountTypeManager.getInstance(mContext);
 
         Resources res = getResources();
         int columnCount = res.getInteger(R.integer.contact_tile_column_count);
@@ -214,7 +219,7 @@
                     return;
                 }
             }
-            updateSize(null);
+            updateSize(-1);
             updateTitle(null);
         }
 
@@ -235,7 +240,7 @@
 
         @Override
         public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
-            updateSize(Integer.toString(data.getCount()));
+            updateSize(data.getCount());
             mAdapter.loadFromCursor(data);
         }
 
@@ -246,6 +251,7 @@
     private void bindGroupMetaData(Cursor cursor) {
         cursor.moveToPosition(-1);
         if (cursor.moveToNext()) {
+            mAccountTypeString = cursor.getString(GroupMetaDataLoader.ACCOUNT_TYPE);
             mGroupId = cursor.getLong(GroupMetaDataLoader.GROUP_ID);
             mGroupName = cursor.getString(GroupMetaDataLoader.TITLE);
             updateTitle(mGroupName);
@@ -265,11 +271,26 @@
         }
     }
 
-    private void updateSize(String size) {
-        if (mGroupSize != null) {
-            mGroupSize.setText(size);
+    /**
+     * Display the count of the number of group members.
+     * @param size of the group (can be -1 if no size could be determined)
+     */
+    private void updateSize(int size) {
+        String groupSizeString;
+        if (size == -1) {
+            groupSizeString = null;
         } else {
-            mListener.onGroupSizeUpdated(size);
+            String groupSizeTemplateString = getResources().getQuantityString(
+                    R.plurals.num_contacts_in_group, size);
+            AccountType accountType = mAccountTypeManager.getAccountType(mAccountTypeString);
+            groupSizeString = String.format(groupSizeTemplateString, size,
+                    accountType.getDisplayLabel(mContext));
+        }
+
+        if (mGroupSize != null) {
+            mGroupSize.setText(groupSizeString);
+        } else {
+            mListener.onGroupSizeUpdated(groupSizeString);
         }
     }
 
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;
+                }
+            }
+        }
+    }
+}
diff --git a/tests/src/com/android/contacts/activities/CallLogActivityTests.java b/tests/src/com/android/contacts/activities/CallLogActivityTests.java
index ee7f608..8900c5e 100644
--- a/tests/src/com/android/contacts/activities/CallLogActivityTests.java
+++ b/tests/src/com/android/contacts/activities/CallLogActivityTests.java
@@ -26,6 +26,7 @@
 import android.database.MatrixCursor;
 import android.graphics.Bitmap;
 import android.graphics.drawable.BitmapDrawable;
+import android.net.Uri;
 import android.provider.CallLog.Calls;
 import android.provider.ContactsContract.CommonDataKinds.Phone;
 import android.telephony.PhoneNumberUtils;
@@ -69,8 +70,8 @@
 
     /** A test value for the person id of a contact. */
     private static final long TEST_PERSON_ID = 1;
-    /** A test value for the photo id of a contact. */
-    private static final long TEST_PHOTO_ID = 2;
+    /** A test value for the photo uri of a contact. */
+    private static final Uri TEST_THUMBNAIL_URI = Uri.parse("something://picture/2");
     /** A test value for the lookup key for contacts. */
     private static final String TEST_LOOKUP_KEY = "contact_id";
     /** A test value for the country ISO of the phone number in the call log. */
@@ -398,7 +399,7 @@
         }
         contactInfo.formattedNumber = formattedNumber;
         contactInfo.normalizedNumber = number;
-        contactInfo.photoId = TEST_PHOTO_ID;
+        contactInfo.thumbnailUri = TEST_THUMBNAIL_URI;
         contactInfo.lookupKey = TEST_LOOKUP_KEY;
         mAdapter.injectContactInfoForTest(number, contactInfo);
     }