Adds Recent Interaction loading to QuickContacts.

This is launched after the Contact is loaded from CP2, during the bind stage.

Change-Id: I63290e0e94c476da1771f6e8b92a9c664f2fe9d3
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index d077dd8..a87949a 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -50,6 +50,7 @@
     <!-- allow broadcasting secret code intents that reboot the phone -->
     <uses-permission android:name="android.permission.REBOOT" />
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+    <uses-permission android:name="android.permission.READ_SMS" />
 
     <application
         android:name="com.android.contacts.ContactsApplication"
diff --git a/res/drawable-hdpi/ic_message_24dp.png b/res/drawable-hdpi/ic_message_24dp.png
new file mode 100644
index 0000000..48f008a
--- /dev/null
+++ b/res/drawable-hdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_message_24dp.png b/res/drawable-mdpi/ic_message_24dp.png
new file mode 100644
index 0000000..c18f225
--- /dev/null
+++ b/res/drawable-mdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_message_24dp.png b/res/drawable-xhdpi/ic_message_24dp.png
new file mode 100644
index 0000000..ee5021c
--- /dev/null
+++ b/res/drawable-xhdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_message_24dp.png b/res/drawable-xxhdpi/ic_message_24dp.png
new file mode 100644
index 0000000..3b74d32
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/layout-land/quickcontact_activity.xml b/res/layout-land/quickcontact_activity.xml
index 43d26db..497e3dd 100644
--- a/res/layout-land/quickcontact_activity.xml
+++ b/res/layout-land/quickcontact_activity.xml
@@ -32,5 +32,10 @@
     <com.android.contacts.quickcontact.ExpandingEntryCardView
         style="@style/ExpandingEntryCardStyle"
         android:id="@+id/communication_card"
-        android:layout_marginTop="@dimen/communication_card_marginTop" />
+        android:layout_marginTop="@dimen/communication_card_marginTop"
+        android:visibility="gone" />
+    <com.android.contacts.quickcontact.ExpandingEntryCardView
+        style="@style/ExpandingEntryCardStyle"
+        android:id="@+id/recent_card"
+        android:visibility="gone" />
 </LinearLayout>
\ No newline at end of file
diff --git a/res/layout-sw600dp-land/quickcontact_activity.xml b/res/layout-sw600dp-land/quickcontact_activity.xml
index fc2aee2..239c50c 100644
--- a/res/layout-sw600dp-land/quickcontact_activity.xml
+++ b/res/layout-sw600dp-land/quickcontact_activity.xml
@@ -28,5 +28,10 @@
     <com.android.contacts.quickcontact.ExpandingEntryCardView
         style="@style/ExpandingEntryCardStyle"
         android:id="@+id/communication_card"
-        android:layout_marginTop="@dimen/communication_card_marginTop" />
+        android:layout_marginTop="@dimen/communication_card_marginTop"
+        android:visibility="gone" />
+    <com.android.contacts.quickcontact.ExpandingEntryCardView
+        style="@style/ExpandingEntryCardStyle"
+        android:id="@+id/recent_card"
+        android:visibility="gone" />
 </LinearLayout>
\ No newline at end of file
diff --git a/res/layout-sw600dp/quickcontact_activity.xml b/res/layout-sw600dp/quickcontact_activity.xml
index 88efc46..6c275b1 100644
--- a/res/layout-sw600dp/quickcontact_activity.xml
+++ b/res/layout-sw600dp/quickcontact_activity.xml
@@ -28,5 +28,10 @@
     <com.android.contacts.quickcontact.ExpandingEntryCardView
         style="@style/ExpandingEntryCardStyle"
         android:id="@+id/communication_card"
-        android:layout_marginTop="@dimen/communication_card_marginTop" />
+        android:layout_marginTop="@dimen/communication_card_marginTop"
+        android:visibility="gone" />
+    <com.android.contacts.quickcontact.ExpandingEntryCardView
+        style="@style/ExpandingEntryCardStyle"
+        android:id="@+id/recent_card"
+        android:visibility="gone" />
 </LinearLayout>
\ No newline at end of file
diff --git a/res/layout-sw720dp-land/quickcontact_activity.xml b/res/layout-sw720dp-land/quickcontact_activity.xml
index fc2aee2..239c50c 100644
--- a/res/layout-sw720dp-land/quickcontact_activity.xml
+++ b/res/layout-sw720dp-land/quickcontact_activity.xml
@@ -28,5 +28,10 @@
     <com.android.contacts.quickcontact.ExpandingEntryCardView
         style="@style/ExpandingEntryCardStyle"
         android:id="@+id/communication_card"
-        android:layout_marginTop="@dimen/communication_card_marginTop" />
+        android:layout_marginTop="@dimen/communication_card_marginTop"
+        android:visibility="gone" />
+    <com.android.contacts.quickcontact.ExpandingEntryCardView
+        style="@style/ExpandingEntryCardStyle"
+        android:id="@+id/recent_card"
+        android:visibility="gone" />
 </LinearLayout>
\ No newline at end of file
diff --git a/res/layout/quickcontact_activity.xml b/res/layout/quickcontact_activity.xml
index 573890f..9af1079 100644
--- a/res/layout/quickcontact_activity.xml
+++ b/res/layout/quickcontact_activity.xml
@@ -50,9 +50,15 @@
             <com.android.contacts.quickcontact.ExpandingEntryCardView
                 style="@style/ExpandingEntryCardStyle"
                 android:id="@+id/communication_card"
-                android:layout_marginTop="@dimen/communication_card_marginTop" />
+                android:layout_marginTop="@dimen/communication_card_marginTop"
+                android:visibility="gone" />
+
+           <com.android.contacts.quickcontact.ExpandingEntryCardView
+                style="@style/ExpandingEntryCardStyle"
+                android:id="@+id/recent_card"
+                android:visibility="gone" />
         </LinearLayout>
 
     </com.android.contacts.widget.TouchlessScrollView>
 
-</com.android.contacts.widget.MultiShrinkScroller>
+</com.android.contacts.widget.MultiShrinkScroller>
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 06e52cf..84f3247 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -649,4 +649,11 @@
     <!-- Title of communication card. [CHAR LIMIT=60] -->
     <string name="communication_card_title">Contact</string>
 
+    <!-- Title of recent card. [CHAR LIMIT=60] -->
+    <string name="recent_card_title">Recent</string>
+
+    <!-- Timestamp string for interactions from yesterday. [CHAR LIMIT=40] -->
+    <string name="timestamp_string_yesterday">Yesterday</string>
+    <!-- Timestamp string for interactions from tomorrow. [CHAR LIMIT=40] -->
+    <string name="timestamp_string_tomorrow">Tomorrow</string>
 </resources>
diff --git a/src/com/android/contacts/interactions/ContactInteraction.java b/src/com/android/contacts/interactions/ContactInteraction.java
new file mode 100644
index 0000000..a70a0a8
--- /dev/null
+++ b/src/com/android/contacts/interactions/ContactInteraction.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2014 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.interactions;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+
+/**
+ * Represents a default interaction between the phone's owner and a contact
+ */
+public interface ContactInteraction {
+    Intent getIntent();
+    String getViewDate(Context context);
+    long getInteractionDate();
+    String getViewHeader(Context context);
+    String getViewBody(Context context);
+    String getViewFooter(Context context);
+    Drawable getIcon(Context context);
+    Drawable getBodyIcon(Context context);
+    Drawable getFooterIcon(Context context);
+}
diff --git a/src/com/android/contacts/interactions/ContactInteractionUtil.java b/src/com/android/contacts/interactions/ContactInteractionUtil.java
new file mode 100644
index 0000000..453a5bd
--- /dev/null
+++ b/src/com/android/contacts/interactions/ContactInteractionUtil.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2014 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.interactions;
+
+import com.google.common.base.Preconditions;
+
+import android.content.Context;
+import android.text.format.DateUtils;
+
+import com.android.contacts.common.testing.NeededForTesting;
+
+import java.text.DateFormat;
+
+import java.util.Calendar;
+
+import com.android.contacts.common.R;
+
+
+/**
+ * Utility methods for interactions and their loaders
+ */
+public class ContactInteractionUtil {
+
+    /**
+     * @return a string like (?,?,?...) with {@param count} question marks.
+     */
+    @NeededForTesting
+    public static String questionMarks(int count) {
+        Preconditions.checkArgument(count > 0);
+        StringBuilder sb = new StringBuilder("(?");
+        for (int i = 1; i < count; i++) {
+            sb.append(",?");
+        }
+        return sb.append(")").toString();
+    }
+
+    /**
+     * Same as {@link formatDateStringFromTimestamp(long, Context, Calendar)} but uses the current
+     * time.
+     */
+    @NeededForTesting
+    public static String formatDateStringFromTimestamp(long timestamp, Context context) {
+        return formatDateStringFromTimestamp(timestamp, context, Calendar.getInstance());
+    }
+
+    /**
+     * Takes in a timestamp and outputs a human legible date. This checks the timestamp against
+     * compareCalendar.
+     * This formats the date based on a few conditions:
+     * 1. If the timestamp is today, the time is shown
+     * 2. If the timestamp occurs tomorrow or yesterday, that is displayed
+     * 3. Otherwise {Month Date} format is used
+     */
+    @NeededForTesting
+    public static String formatDateStringFromTimestamp(long timestamp, Context context,
+            Calendar compareCalendar) {
+        Calendar interactionCalendar = Calendar.getInstance();
+        interactionCalendar.setTimeInMillis(timestamp);
+
+        // compareCalendar is initialized to today
+        if (compareCalendarDayYear(interactionCalendar, compareCalendar)) {
+            return DateFormat.getTimeInstance(DateFormat.SHORT).format(
+                    interactionCalendar.getTime());
+        }
+
+        // Turn compareCalendar to yesterday
+        compareCalendar.add(Calendar.DAY_OF_YEAR, -1);
+        if (compareCalendarDayYear(interactionCalendar, compareCalendar)) {
+            return context.getString(R.string.timestamp_string_yesterday);
+        }
+
+        // Turn compareCalendar to tomorrow
+        compareCalendar.add(Calendar.DAY_OF_YEAR, 2);
+        if (compareCalendarDayYear(interactionCalendar, compareCalendar)) {
+            return context.getString(R.string.timestamp_string_tomorrow);
+        }
+        return DateUtils.formatDateTime(context, interactionCalendar.getTimeInMillis(),
+                DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR);
+    }
+
+    /**
+     * Compares the day and year of two calendars.
+     */
+    private static boolean compareCalendarDayYear(Calendar c1, Calendar c2) {
+        return c1.get(Calendar.YEAR) == c2.get(Calendar.YEAR) &&
+                c1.get(Calendar.DAY_OF_YEAR) == c2.get(Calendar.DAY_OF_YEAR);
+    }
+}
diff --git a/src/com/android/contacts/interactions/SmsInteraction.java b/src/com/android/contacts/interactions/SmsInteraction.java
new file mode 100644
index 0000000..e922056
--- /dev/null
+++ b/src/com/android/contacts/interactions/SmsInteraction.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2014 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.interactions;
+
+import com.android.contacts.R;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.provider.Telephony.Sms;
+
+/**
+ * Represents an sms interaction, wrapping the columns in
+ * {@link android.provider.Telephony.Sms}.
+ */
+public class SmsInteraction implements ContactInteraction {
+
+    private static final String URI_TARGET_PREFIX = "smsto:";
+    private static final int SMS_ICON_RES = R.drawable.ic_message_24dp;
+
+    private ContentValues mValues;
+
+    public SmsInteraction(ContentValues values) {
+        mValues = values;
+    }
+
+    @Override
+    public Intent getIntent() {
+        return new Intent(Intent.ACTION_VIEW).setData(Uri.parse(URI_TARGET_PREFIX + getAddress()));
+    }
+
+    @Override
+    public String getViewDate(Context context) {
+        return ContactInteractionUtil.formatDateStringFromTimestamp(getDate(), context);
+    }
+
+    @Override
+    public long getInteractionDate() {
+        return getDate();
+    }
+
+    @Override
+    public String getViewHeader(Context context) {
+        return getBody();
+    }
+
+    @Override
+    public String getViewBody(Context context) {
+        return getAddress();
+    }
+
+    @Override
+    public String getViewFooter(Context context) {
+        return getViewDate(context);
+    }
+
+    @Override
+    public Drawable getIcon(Context context) {
+        return context.getResources().getDrawable(SMS_ICON_RES);
+    }
+
+    @Override
+    public Drawable getBodyIcon(Context context) {
+        return null;
+    }
+
+    @Override
+    public Drawable getFooterIcon(Context context) {
+        return null;
+    }
+
+    public String getAddress() {
+        return mValues.getAsString(Sms.ADDRESS);
+    }
+
+    public String getBody() {
+        return mValues.getAsString(Sms.BODY);
+    }
+
+    public long getDate() {
+        return mValues.getAsLong(Sms.DATE);
+    }
+
+
+    public long getDateSent() {
+        return mValues.getAsLong(Sms.DATE_SENT);
+    }
+
+    public int getErrorCode() {
+        return mValues.getAsInteger(Sms.ERROR_CODE);
+    }
+
+    public boolean getLocked() {
+        return mValues.getAsBoolean(Sms.LOCKED);
+    }
+
+    public int getPerson() {
+        return mValues.getAsInteger(Sms.PERSON);
+    }
+
+    public int getProtocol() {
+        return mValues.getAsInteger(Sms.PROTOCOL);
+    }
+
+    public boolean getRead() {
+        return mValues.getAsBoolean(Sms.READ);
+    }
+
+    public boolean getReplyPathPresent() {
+        return mValues.getAsBoolean(Sms.REPLY_PATH_PRESENT);
+    }
+
+    public boolean getSeen() {
+        return mValues.getAsBoolean(Sms.SEEN);
+    }
+
+    public String getServiceCenter() {
+        return mValues.getAsString(Sms.SERVICE_CENTER);
+    }
+
+    public int getStatus() {
+        return mValues.getAsInteger(Sms.STATUS);
+    }
+
+    public String getSubject() {
+        return mValues.getAsString(Sms.SUBJECT);
+    }
+
+    public int getThreadId() {
+        return mValues.getAsInteger(Sms.THREAD_ID);
+    }
+
+    public int getType() {
+        return mValues.getAsInteger(Sms.TYPE);
+    }
+}
diff --git a/src/com/android/contacts/interactions/SmsInteractionsLoader.java b/src/com/android/contacts/interactions/SmsInteractionsLoader.java
new file mode 100644
index 0000000..e0c8cf4
--- /dev/null
+++ b/src/com/android/contacts/interactions/SmsInteractionsLoader.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2014 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.interactions;
+
+import android.content.AsyncTaskLoader;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.provider.Telephony;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Loads the most recent sms between the passed in phone numbers.
+ *
+ * This is a two part process. The first step is retrieving the threadIds for each of the phone
+ * numbers using fuzzy matching. The next step is to run another query against these threadIds
+ * to retrieve the actual sms.
+ */
+public class SmsInteractionsLoader extends AsyncTaskLoader<List<ContactInteraction>> {
+
+    private static final String TAG = SmsInteractionsLoader.class.getSimpleName();
+
+    private String[] mPhoneNums;
+    private int mMaxToRetrieve;
+    private List<ContactInteraction> mData;
+
+    /**
+     * Loads a list of SmsInteraction from the supplied phone numbers.
+     */
+    public SmsInteractionsLoader(Context context, String[] phoneNums,
+            int maxToRetrieve) {
+        super(context);
+        Log.v(TAG, "SmsInteractionsLoader");
+        mPhoneNums = phoneNums;
+        mMaxToRetrieve = maxToRetrieve;
+    }
+
+    @Override
+    public List<ContactInteraction> loadInBackground() {
+        Log.v(TAG, "loadInBackground");
+        // Confirm the device has Telephony and numbers were provided before proceeding
+        if (!getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
+                || mPhoneNums == null || mPhoneNums.length == 0) {
+            return Collections.emptyList();
+        }
+
+        // Retrieve the thread IDs
+        List<String> threadIdStrings = new ArrayList<>();
+        for (String phone : mPhoneNums) {
+            threadIdStrings.add(String.valueOf(
+                    Telephony.Threads.getOrCreateThreadId(getContext(), phone)));
+        }
+
+        // Query the SMS database for the threads
+        Cursor cursor = getSmsCursorFromThreads(threadIdStrings);
+
+        List<ContactInteraction> interactions = new ArrayList<>();
+        while (cursor.moveToNext()) {
+            ContentValues values = new ContentValues();
+            DatabaseUtils.cursorRowToContentValues(cursor, values);
+            interactions.add(new SmsInteraction(values));
+        }
+
+        Log.v(TAG, "end loadInBackground");
+        return interactions;
+    }
+
+    /**
+     * Return the most recent messages between a list of threads
+     */
+    private Cursor getSmsCursorFromThreads(List<String> threadIds) {
+        String selection = Telephony.Sms.THREAD_ID + " IN "
+                + ContactInteractionUtil.questionMarks(threadIds.size());
+
+        return getContext().getContentResolver().query(
+                Telephony.Sms.CONTENT_URI,
+                /* projection = */ null,
+                selection,
+                threadIds.toArray(new String[threadIds.size()]),
+                Telephony.Sms.DEFAULT_SORT_ORDER
+                        + " LIMIT " + mMaxToRetrieve);
+    }
+
+    @Override
+    protected void onStartLoading() {
+        super.onStartLoading();
+
+        if (mData != null) {
+            deliverResult(mData);
+        }
+
+        if (takeContentChanged() || mData == null) {
+            forceLoad();
+        }
+    }
+
+    @Override
+    protected void onStopLoading() {
+        // Attempt to cancel the current load task if possible.
+        cancelLoad();
+    }
+
+    @Override
+    public void deliverResult(List<ContactInteraction> data) {
+        mData = data;
+        if (isStarted()) {
+            super.deliverResult(data);
+        }
+    }
+
+    @Override
+    protected void onReset() {
+        super.onReset();
+
+        // Ensure the loader is stopped
+        onStopLoading();
+        if (mData != null) {
+            mData.clear();
+        }
+    }
+}
diff --git a/src/com/android/contacts/quickcontact/QuickContactActivity.java b/src/com/android/contacts/quickcontact/QuickContactActivity.java
index 2ff34e1..dc6b5f2 100644
--- a/src/com/android/contacts/quickcontact/QuickContactActivity.java
+++ b/src/com/android/contacts/quickcontact/QuickContactActivity.java
@@ -65,9 +65,13 @@
 import com.android.contacts.common.model.dataitem.DataKind;
 import com.android.contacts.common.model.dataitem.EmailDataItem;
 import com.android.contacts.common.model.dataitem.ImDataItem;
+import com.android.contacts.common.model.dataitem.PhoneDataItem;
 import com.android.contacts.common.util.Constants;
 import com.android.contacts.common.util.DataStatus;
 import com.android.contacts.common.util.UriUtils;
+import com.android.contacts.interactions.ContactInteraction;
+import com.android.contacts.interactions.ContactInteractionUtil;
+import com.android.contacts.interactions.SmsInteractionsLoader;
 import com.android.contacts.quickcontact.ExpandingEntryCardView.Entry;
 import com.android.contacts.util.ImageViewDrawableSetter;
 import com.android.contacts.common.util.StopWatch;
@@ -79,9 +83,13 @@
 import com.google.common.collect.Lists;
 
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -111,8 +119,12 @@
     private ImageView mEditOrAddContactImage;
     private ImageView mStarImage;
     private ExpandingEntryCardView mCommunicationCard;
+    private ExpandingEntryCardView mRecentCard;
     private MultiShrinkScroller mScroller;
 
+    private static final int MIN_NUM_COMMUNICATION_ENTRIES_SHOWN = 3;
+    private static final int MIN_NUM_COLLAPSED_RECENT_ENTRIES_SHOWN = 3;
+
     private Contact mContactData;
     private ContactLoader mContactLoader;
 
@@ -148,8 +160,18 @@
     private static final List<String> TRAILING_MIMETYPES = Lists.newArrayList(
             StructuredPostal.CONTENT_ITEM_TYPE, Website.CONTENT_ITEM_TYPE);
 
-    /** Id for the background loader */
-    private static final int LOADER_ID = 0;
+    /** Id for the background contact loader */
+    private static final int LOADER_CONTACT_ID = 0;
+
+    /** Id for the background Sms Loader */
+    private static final int LOADER_SMS_ID = 1;
+    private static final String KEY_LOADER_EXTRA_SMS_PHONES =
+            QuickContactActivity.class.getCanonicalName() + ".KEY_LOADER_EXTRA_SMS_PHONES";
+    private static final int MAX_SMS_RETRIEVE = 3;
+
+    private static final int[] mRecentLoaderIds = new int[LOADER_SMS_ID];
+    private Map<Integer, List<ContactInteraction>> mRecentLoaderResults;
+
 
     private StopWatch mStopWatch = ENABLE_STOPWATCH
             ? StopWatch.start("QuickContact") : StopWatch.getNullStopWatch();
@@ -232,7 +254,7 @@
         mStopWatch.lap("i"); // intent parsed
 
         mContactLoader = (ContactLoader) getLoaderManager().initLoader(
-                LOADER_ID, null, mLoaderCallbacks);
+                LOADER_CONTACT_ID, null, mLoaderContactCallbacks);
 
         mStopWatch.lap("ld"); // loader started
 
@@ -247,15 +269,20 @@
         mEditOrAddContactImage = (ImageView) findViewById(R.id.contact_edit_image);
         mStarImage = (ImageView) findViewById(R.id.quickcontact_star_button);
         mCommunicationCard = (ExpandingEntryCardView) findViewById(R.id.communication_card);
+        mRecentCard = (ExpandingEntryCardView) findViewById(R.id.recent_card);
         mScroller = (MultiShrinkScroller) findViewById(R.id.multiscroller);
-        mCommunicationCard.setTitle(getResources().getString(R.string.communication_card_title));
 
         if (mScroller != null) {
             mScroller.initialize(mMultiShrinkScrollerListener);
         }
 
         mEditOrAddContactImage.setOnClickListener(mEditContactClickHandler);
+
         mCommunicationCard.setOnClickListener(mEntryClickHandler);
+        mCommunicationCard.setTitle(getResources().getString(R.string.communication_card_title));
+
+        mRecentCard.setOnClickListener(mEntryClickHandler);
+        mRecentCard.setTitle(getResources().getString(R.string.recent_card_title));
 
         // find and prepare correct header view
         mPhotoContainer = findViewById(R.id.photo_container);
@@ -314,7 +341,7 @@
     /**
      * Handle the result from the ContactLoader
      */
-    private void bindData(Contact data) {
+    private void bindContactData(Contact data) {
         mContactData = data;
         final ResolveCache cache = ResolveCache.getInstance(this);
         final Context context = this;
@@ -379,6 +406,8 @@
 
         mStopWatch.lap("ph"); // Photo set
 
+        // Maintain a list of phone numbers to pass into SmsInteractionsLoader
+        List<String> phoneNumbers = new ArrayList<>();
         for (RawContact rawContact : data.getRawContacts()) {
             for (DataItem dataItem : rawContact.getDataItems()) {
                 final String mimeType = dataItem.getMimeType();
@@ -386,6 +415,10 @@
                 final DataKind dataKind = AccountTypeManager.getInstance(this)
                         .getKindOrFallback(accountType, mimeType);
 
+                if (dataItem instanceof PhoneDataItem) {
+                    phoneNumbers.add(((PhoneDataItem) dataItem).getNormalizedNumber());
+                }
+
                 // Skip this data item if MIME-type excluded
                 if (isMimeExcluded(mimeType)) continue;
 
@@ -463,8 +496,22 @@
                 entries.addAll(actionsToEntries(mActions.get(mimeType)));
             }
         }
-        mCommunicationCard.initialize(entries, /* numInitialVisibleEntries = */ 2,
-                /* isExpanded = */ false, /* themeColor = */ 0);
+
+        Bundle smsExtraBundle = new Bundle();
+        smsExtraBundle.putStringArray(KEY_LOADER_EXTRA_SMS_PHONES,
+                phoneNumbers.toArray(new String[phoneNumbers.size()]));
+        getLoaderManager().initLoader(
+                LOADER_SMS_ID,
+                smsExtraBundle,
+                mLoaderInteractionsCallbacks);
+
+        if (entries.size() > 0) {
+            mCommunicationCard.initialize(entries,
+                    /* numInitialVisibleEntries = */ MIN_NUM_COMMUNICATION_ENTRIES_SHOWN,
+                    /* isExpanded = */ false,
+                    /* themeColor = */ 0);
+            mCommunicationCard.setVisibility(View.VISIBLE);
+        }
 
         final boolean hasData = !mSortedActionMimeTypes.isEmpty();
         mCommunicationCard.setVisibility(hasData ? View.VISIBLE: View.GONE);
@@ -572,7 +619,22 @@
         return entries;
     }
 
-    private LoaderCallbacks<Contact> mLoaderCallbacks =
+    private List<Entry> contactInteractionsToEntries(List<ContactInteraction> interactions) {
+        List<Entry> entries = new ArrayList<>();
+        for (ContactInteraction interaction : interactions) {
+            entries.add(new Entry(interaction.getIcon(this),
+                    interaction.getViewHeader(this),
+                    interaction.getViewBody(this),
+                    interaction.getBodyIcon(this),
+                    interaction.getViewFooter(this),
+                    interaction.getFooterIcon(this),
+                    interaction.getIntent(),
+                    /* isEditable = */ false));
+        }
+        return entries;
+    }
+
+    private LoaderCallbacks<Contact> mLoaderContactCallbacks =
             new LoaderCallbacks<Contact>() {
         @Override
         public void onLoaderReset(Loader<Contact> loader) {
@@ -596,7 +658,7 @@
                 return;
             }
 
-            bindData(data);
+            bindContactData(data);
 
             mStopWatch.lap("bd"); // bindData finished
 
@@ -631,6 +693,7 @@
                     false /*postViewNotification*/, true /*computeFormattedPhoneNumber*/);
         }
     };
+
     @Override
     public void onBackPressed() {
         if (mScroller != null) {
@@ -640,4 +703,70 @@
             super.onBackPressed();
         }
     }
+
+    private LoaderCallbacks<List<ContactInteraction>> mLoaderInteractionsCallbacks =
+            new LoaderCallbacks<List<ContactInteraction>>() {
+
+        @Override
+        public Loader<List<ContactInteraction>> onCreateLoader(int id, Bundle args) {
+            Log.v(TAG, "onCreateLoader");
+            Loader<List<ContactInteraction>> loader = null;
+            switch (id) {
+                case LOADER_SMS_ID:
+                    Log.v(TAG, "LOADER_SMS_ID");
+                    loader = new SmsInteractionsLoader(
+                            QuickContactActivity.this,
+                            args.getStringArray(KEY_LOADER_EXTRA_SMS_PHONES),
+                            MAX_SMS_RETRIEVE);
+                    break;
+            }
+            return loader;
+        }
+
+        @Override
+        public void onLoadFinished(Loader<List<ContactInteraction>> loader,
+                List<ContactInteraction> data) {
+            if (mRecentLoaderResults == null) {
+                mRecentLoaderResults = new HashMap<Integer, List<ContactInteraction>>();
+            }
+            mRecentLoaderResults.put(loader.getId(), data);
+
+            if (isAllRecentDataLoaded()) {
+                bindRecentData();
+            }
+        }
+
+        @Override
+        public void onLoaderReset(Loader<List<ContactInteraction>> loader) {
+            mRecentLoaderResults.remove(loader.getId());
+        }
+
+    };
+
+    private boolean isAllRecentDataLoaded() {
+        return mRecentLoaderResults.size() == mRecentLoaderIds.length;
+    }
+
+    private void bindRecentData() {
+        List<ContactInteraction> allInteractions = new ArrayList<>();
+        for (List<ContactInteraction> loaderInteractions : mRecentLoaderResults.values()) {
+            allInteractions.addAll(loaderInteractions);
+        }
+
+        // Sort the interactions by most recent
+        Collections.sort(allInteractions, new Comparator<ContactInteraction>() {
+            @Override
+            public int compare(ContactInteraction a, ContactInteraction b) {
+                return a.getInteractionDate() >= b.getInteractionDate() ? -1 : 1;
+            }
+        });
+
+        if (allInteractions.size() > 0) {
+            mRecentCard.initialize(contactInteractionsToEntries(allInteractions),
+                    /* numInitialVisibleEntries = */ MIN_NUM_COLLAPSED_RECENT_ENTRIES_SHOWN,
+                    /* isExpanded = */ false,
+                    /* themeColor = */ 0);
+            mRecentCard.setVisibility(View.VISIBLE);
+        }
+    }
 }
diff --git a/tests/src/com/android/contacts/interactions/ContactInteractionUtilTest.java b/tests/src/com/android/contacts/interactions/ContactInteractionUtilTest.java
new file mode 100644
index 0000000..05ad9b5
--- /dev/null
+++ b/tests/src/com/android/contacts/interactions/ContactInteractionUtilTest.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2014 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.interactions;
+
+import com.android.contacts.common.R;
+
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.test.AndroidTestCase;
+import android.text.format.DateUtils;
+
+import java.util.Calendar;
+import java.util.Locale;
+
+/**
+ * Tests for utility functions in {@link ContactInteractionUtil}
+ */
+public class ContactInteractionUtilTest extends AndroidTestCase {
+
+    private Locale mOriginalLocale;
+    private Calendar calendar;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        calendar = Calendar.getInstance();
+
+        // Time/Date utilities rely on specific locales. Forace US and set back in tearDown()
+        mOriginalLocale = Locale.getDefault();
+        setLocale(Locale.US);
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        setLocale(mOriginalLocale);
+        super.tearDown();
+    }
+
+    public void testOneQuestionMark() {
+        assertEquals("(?)", ContactInteractionUtil.questionMarks(1));
+    }
+
+    public void testTwoQuestionMarks() {
+        assertEquals("(?,?)", ContactInteractionUtil.questionMarks(2));
+    }
+
+    public void testFiveQuestionMarks() {
+        assertEquals("(?,?,?,?,?)", ContactInteractionUtil.questionMarks(5));
+    }
+
+    public void testFormatDateStringFromTimestamp_todaySingleMinuteAm() {
+        // Test today scenario (time shown)
+        // Single digit minute & AM
+        calendar.set(Calendar.HOUR_OF_DAY, 8);
+        calendar.set(Calendar.MINUTE, 8);
+        long todayTimestamp = calendar.getTimeInMillis();
+        assertEquals("8:08 AM", ContactInteractionUtil.formatDateStringFromTimestamp(
+                calendar.getTimeInMillis(), getContext()));
+    }
+
+    public void testFormatDateStringFromTimestamp_todayDoubleMinutePm() {
+        // Double digit minute & PM
+        calendar.set(Calendar.HOUR_OF_DAY, 22);
+        calendar.set(Calendar.MINUTE, 18);
+        assertEquals("10:18 PM",
+                ContactInteractionUtil.formatDateStringFromTimestamp(calendar.getTimeInMillis(),
+                        getContext()));
+    }
+
+    public void testFormatDateStringFromTimestamp_yesterday() {
+        // Test yesterday and tomorrow (Yesterday or Tomorrow shown)
+        calendar.add(Calendar.DAY_OF_YEAR, -1);
+        assertEquals(getContext().getResources().getString(R.string.timestamp_string_yesterday),
+                ContactInteractionUtil.formatDateStringFromTimestamp(calendar.getTimeInMillis(),
+                        getContext()));
+    }
+
+    public void testFormatDateStringFromTimestamp_yesterdayLastYear() {
+        // Set to non leap year
+        calendar.set(Calendar.YEAR, 1999);
+        calendar.set(Calendar.DAY_OF_YEAR, 365);
+        long lastYear = calendar.getTimeInMillis();
+        calendar.add(Calendar.DAY_OF_YEAR, 1);
+
+        assertEquals(getContext().getResources().getString(R.string.timestamp_string_yesterday),
+                ContactInteractionUtil.formatDateStringFromTimestamp(lastYear,
+                        getContext(), calendar));
+    }
+
+    public void testFormatDateStringFromTimestamp_tomorrow() {
+        calendar.add(Calendar.DAY_OF_YEAR, 1);
+        assertEquals(getContext().getResources().getString(R.string.timestamp_string_tomorrow),
+                ContactInteractionUtil.formatDateStringFromTimestamp(calendar.getTimeInMillis(),
+                        getContext()));
+    }
+
+    public void testFormatDateStringFromTimestamp_tomorrowNewYear() {
+        calendar.set(Calendar.DAY_OF_YEAR, 1);
+        long thisYear = calendar.getTimeInMillis();
+        calendar.add(Calendar.DAY_OF_YEAR, -1);
+
+        assertEquals(getContext().getResources().getString(R.string.timestamp_string_tomorrow),
+                ContactInteractionUtil.formatDateStringFromTimestamp(thisYear,
+                        getContext(), calendar));
+    }
+
+    public void testFormatDateStringFromTimestamp_other() {
+        // Test other (Month Date)
+        calendar.set(
+                /* year = */ 1991,
+                /* month = */ Calendar.MONTH,
+                /* day = */ 11);
+        assertEquals("March 11",
+                ContactInteractionUtil.formatDateStringFromTimestamp(calendar.getTimeInMillis(),
+                        getContext()));
+    }
+
+    private void setLocale(Locale locale) {
+        Locale.setDefault(locale);
+        Resources res = getContext().getResources();
+        Configuration config = res.getConfiguration();
+        config.locale = locale;
+        res.updateConfiguration(config, res.getDisplayMetrics());
+    }
+}
\ No newline at end of file