Add Caller information to missed call notifications.

Includes moving ContactsAsyncHelper from services/Telephony to here.
This was a wholesale copy with only changes to:
1) Update logging to use Telecomm's implementation
2) Removal of ImageTracker inner class (was causing linking
   errors and was totally unused in both telecomm and
   services/Telephony).

Bug: 15313021
Change-Id: I4e3ce52ce1c31eb7b684d8e404fdcff4bb7db7ba
diff --git a/src/com/android/telecomm/Call.java b/src/com/android/telecomm/Call.java
index 5eb2f97..699a3e2 100644
--- a/src/com/android/telecomm/Call.java
+++ b/src/com/android/telecomm/Call.java
@@ -16,9 +16,13 @@
 
 package com.android.telecomm;
 
+import android.content.ContentUris;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.Handler;
+import android.provider.ContactsContract.Contacts;
 import android.telecomm.CallInfo;
 import android.telecomm.CallServiceDescriptor;
 import android.telecomm.CallState;
@@ -31,6 +35,8 @@
 import com.android.internal.telephony.CallerInfo;
 import com.android.internal.telephony.CallerInfoAsyncQuery;
 import com.android.internal.telephony.CallerInfoAsyncQuery.OnQueryCompleteListener;
+
+import com.android.telecomm.ContactsAsyncHelper.OnImageLoadCompleteListener;
 import com.google.android.collect.Sets;
 import com.google.common.base.Preconditions;
 
@@ -55,15 +61,27 @@
     }
 
     private static final OnQueryCompleteListener sCallerInfoQueryListener =
-        new OnQueryCompleteListener() {
-            /** ${inheritDoc} */
-            @Override
-            public void onQueryComplete(int token, Object cookie, CallerInfo callerInfo) {
-                if (cookie != null) {
-                    ((Call) cookie).setCallerInfo(callerInfo, token);
+            new OnQueryCompleteListener() {
+                /** ${inheritDoc} */
+                @Override
+                public void onQueryComplete(int token, Object cookie, CallerInfo callerInfo) {
+                    if (cookie != null) {
+                        ((Call) cookie).setCallerInfo(callerInfo, token);
+                    }
                 }
-            }
-        };
+            };
+
+    private static final OnImageLoadCompleteListener sPhotoLoadListener =
+            new OnImageLoadCompleteListener() {
+                /** ${inheritDoc} */
+                @Override
+                public void onImageLoadComplete(
+                        int token, Drawable photo, Bitmap photoIcon, Object cookie) {
+                    if (cookie != null) {
+                        ((Call) cookie).setPhoto(photo, photoIcon, token);
+                    }
+                }
+            };
 
     /** True if this is an incoming call. */
     private final boolean mIsIncoming;
@@ -82,8 +100,6 @@
      * service. */
     private final GatewayInfo mGatewayInfo;
 
-    private final Handler mHandler = new Handler();
-
     private long mConnectTimeMillis;
 
     /** The state of the call. */
@@ -226,6 +242,18 @@
         }
     }
 
+    String getName() {
+        return mCallerInfo == null ? null : mCallerInfo.name;
+    }
+
+    Bitmap getPhotoIcon() {
+        return mCallerInfo == null ? null : mCallerInfo.cachedPhotoIcon;
+    }
+
+    Drawable getPhoto() {
+        return mCallerInfo == null ? null : mCallerInfo.cachedPhoto;
+    }
+
     /**
      * @param disconnectCause The reason for the disconnection, any of
      *         {@link android.telephony.DisconnectCause}.
@@ -677,15 +705,43 @@
     }
 
     /**
-     * Saved the specified caller info if the specified token matches that of the last query
+     * Saves the specified caller info if the specified token matches that of the last query
      * that was made.
      *
      * @param callerInfo The new caller information to set.
      * @param token The token used with this query.
      */
     private void setCallerInfo(CallerInfo callerInfo, int token) {
+        Preconditions.checkNotNull(callerInfo);
+
         if (mQueryToken == token) {
             mCallerInfo = callerInfo;
+
+            if (mCallerInfo.person_id != 0) {
+                Uri personUri =
+                        ContentUris.withAppendedId(Contacts.CONTENT_URI, mCallerInfo.person_id);
+                Log.d(this, "Searching person uri %s for call %s", personUri, this);
+                ContactsAsyncHelper.startObtainPhotoAsync(
+                        token,
+                        TelecommApp.getInstance(),
+                        personUri,
+                        sPhotoLoadListener,
+                        this);
+            }
+        }
+    }
+
+    /**
+     * Saves the specified photo information if the specified token matches that of the last query.
+     *
+     * @param photo The photo as a drawable.
+     * @param photoIcon The photo as a small icon.
+     * @param token The token used with this query.
+     */
+    private void setPhoto(Drawable photo, Bitmap photoIcon, int token) {
+        if (mQueryToken == token) {
+            mCallerInfo.cachedPhoto = photo;
+            mCallerInfo.cachedPhotoIcon = photoIcon;
         }
     }
 }
diff --git a/src/com/android/telecomm/ContactsAsyncHelper.java b/src/com/android/telecomm/ContactsAsyncHelper.java
new file mode 100644
index 0000000..39229e5
--- /dev/null
+++ b/src/com/android/telecomm/ContactsAsyncHelper.java
@@ -0,0 +1,248 @@
+/*
+ * 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.telecomm;
+
+import android.app.Notification;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.provider.ContactsContract.Contacts;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Helper class for loading contacts photo asynchronously.
+ */
+public final class ContactsAsyncHelper {
+    private static final String LOG_TAG = ContactsAsyncHelper.class.getSimpleName();
+
+    /**
+     * Interface for a WorkerHandler result return.
+     */
+    public interface OnImageLoadCompleteListener {
+        /**
+         * Called when the image load is complete.
+         *
+         * @param token Integer passed in {@link ContactsAsyncHelper#startObtainPhotoAsync(int,
+         * Context, Uri, OnImageLoadCompleteListener, Object)}.
+         * @param photo Drawable object obtained by the async load.
+         * @param photoIcon Bitmap object obtained by the async load.
+         * @param cookie Object passed in {@link ContactsAsyncHelper#startObtainPhotoAsync(int,
+         * Context, Uri, OnImageLoadCompleteListener, Object)}. Can be null iff. the original
+         * cookie is null.
+         */
+        public void onImageLoadComplete(int token, Drawable photo, Bitmap photoIcon,
+                Object cookie);
+    }
+
+    // constants
+    private static final int EVENT_LOAD_IMAGE = 1;
+
+    private static final Handler sResultHandler = new Handler(Looper.getMainLooper()) {
+        /** Called when loading is done. */
+        @Override
+        public void handleMessage(Message msg) {
+            WorkerArgs args = (WorkerArgs) msg.obj;
+            switch (msg.arg1) {
+                case EVENT_LOAD_IMAGE:
+                    if (args.listener != null) {
+                        Log.d(this, "Notifying listener: " + args.listener.toString() +
+                                " image: " + args.uri + " completed");
+                        args.listener.onImageLoadComplete(msg.what, args.photo, args.photoIcon,
+                                args.cookie);
+                    }
+                    break;
+                default:
+            }
+        }
+    };
+
+    /** Handler run on a worker thread to load photo asynchronously. */
+    private static final Handler sThreadHandler;
+
+    static {
+        HandlerThread thread = new HandlerThread("ContactsAsyncWorker");
+        thread.start();
+        sThreadHandler = new WorkerHandler(thread.getLooper());
+    }
+
+    private ContactsAsyncHelper() {}
+
+    private static final class WorkerArgs {
+        public Context context;
+        public Uri uri;
+        public Drawable photo;
+        public Bitmap photoIcon;
+        public Object cookie;
+        public OnImageLoadCompleteListener listener;
+    }
+
+    /**
+     * Thread worker class that handles the task of opening the stream and loading
+     * the images.
+     */
+    private static class WorkerHandler extends Handler {
+        public WorkerHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            WorkerArgs args = (WorkerArgs) msg.obj;
+
+            switch (msg.arg1) {
+                case EVENT_LOAD_IMAGE:
+                    InputStream inputStream = null;
+                    try {
+                        try {
+                            inputStream = Contacts.openContactPhotoInputStream(
+                                    args.context.getContentResolver(), args.uri, true);
+                        } catch (Exception e) {
+                            Log.e(this, e, "Error opening photo input stream");
+                        }
+
+                        if (inputStream != null) {
+                            args.photo = Drawable.createFromStream(inputStream,
+                                    args.uri.toString());
+
+                            // This assumes Drawable coming from contact database is usually
+                            // BitmapDrawable and thus we can have (down)scaled version of it.
+                            args.photoIcon = getPhotoIconWhenAppropriate(args.context, args.photo);
+
+                            Log.d(this, "Loading image: " + msg.arg1 +
+                                    " token: " + msg.what + " image URI: " + args.uri);
+                        } else {
+                            args.photo = null;
+                            args.photoIcon = null;
+                            Log.d(this, "Problem with image: " + msg.arg1 +
+                                    " token: " + msg.what + " image URI: " + args.uri +
+                                    ", using default image.");
+                        }
+                    } finally {
+                        if (inputStream != null) {
+                            try {
+                                inputStream.close();
+                            } catch (IOException e) {
+                                Log.e(this, e, "Unable to close input stream.");
+                            }
+                        }
+                    }
+                    break;
+                default:
+            }
+
+            // send the reply to the enclosing class.
+            Message reply = sResultHandler.obtainMessage(msg.what);
+            reply.arg1 = msg.arg1;
+            reply.obj = msg.obj;
+            reply.sendToTarget();
+        }
+
+        /**
+         * Returns a Bitmap object suitable for {@link Notification}'s large icon. This might
+         * return null when the given Drawable isn't BitmapDrawable, or if the system fails to
+         * create a scaled Bitmap for the Drawable.
+         */
+        private Bitmap getPhotoIconWhenAppropriate(Context context, Drawable photo) {
+            if (!(photo instanceof BitmapDrawable)) {
+                return null;
+            }
+            int iconSize = context.getResources()
+                    .getDimensionPixelSize(R.dimen.notification_icon_size);
+            Bitmap orgBitmap = ((BitmapDrawable) photo).getBitmap();
+            int orgWidth = orgBitmap.getWidth();
+            int orgHeight = orgBitmap.getHeight();
+            int longerEdge = orgWidth > orgHeight ? orgWidth : orgHeight;
+            // We want downscaled one only when the original icon is too big.
+            if (longerEdge > iconSize) {
+                float ratio = ((float) longerEdge) / iconSize;
+                int newWidth = (int) (orgWidth / ratio);
+                int newHeight = (int) (orgHeight / ratio);
+                // If the longer edge is much longer than the shorter edge, the latter may
+                // become 0 which will cause a crash.
+                if (newWidth <= 0 || newHeight <= 0) {
+                    Log.w(this, "Photo icon's width or height become 0.");
+                    return null;
+                }
+
+                // It is sure ratio >= 1.0f in any case and thus the newly created Bitmap
+                // should be smaller than the original.
+                return Bitmap.createScaledBitmap(orgBitmap, newWidth, newHeight, true);
+            } else {
+                return orgBitmap;
+            }
+        }
+    }
+
+    /**
+     * Starts an asynchronous image load. After finishing the load,
+     * {@link OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable, Bitmap, Object)}
+     * will be called.
+     *
+     * @param token Arbitrary integer which will be returned as the first argument of
+     * {@link OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable, Bitmap, Object)}
+     * @param context Context object used to do the time-consuming operation.
+     * @param personUri Uri to be used to fetch the photo
+     * @param listener Callback object which will be used when the asynchronous load is done.
+     * Can be null, which means only the asynchronous load is done while there's no way to
+     * obtain the loaded photos.
+     * @param cookie Arbitrary object the caller wants to remember, which will become the
+     * fourth argument of {@link OnImageLoadCompleteListener#onImageLoadComplete(int, Drawable,
+     * Bitmap, Object)}. Can be null, at which the callback will also has null for the argument.
+     */
+    public static final void startObtainPhotoAsync(int token, Context context, Uri personUri,
+            OnImageLoadCompleteListener listener, Object cookie) {
+        ThreadUtil.checkOnMainThread();
+
+        // in case the source caller info is null, the URI will be null as well.
+        // just update using the placeholder image in this case.
+        if (personUri == null) {
+            Log.wtf(LOG_TAG, "Uri is missing");
+            return;
+        }
+
+        // Added additional Cookie field in the callee to handle arguments
+        // sent to the callback function.
+
+        // setup arguments
+        WorkerArgs args = new WorkerArgs();
+        args.cookie = cookie;
+        args.context = context;
+        args.uri = personUri;
+        args.listener = listener;
+
+        // setup message arguments
+        Message msg = sThreadHandler.obtainMessage(token);
+        msg.arg1 = EVENT_LOAD_IMAGE;
+        msg.obj = args;
+
+        Log.d(LOG_TAG, "Begin loading image: " + args.uri +
+                ", displaying default image for now.");
+
+        // notify the thread to begin working
+        sThreadHandler.sendMessage(msg);
+    }
+
+
+}
diff --git a/src/com/android/telecomm/MissedCallNotifier.java b/src/com/android/telecomm/MissedCallNotifier.java
index c9c57e5..79c9c0f 100644
--- a/src/com/android/telecomm/MissedCallNotifier.java
+++ b/src/com/android/telecomm/MissedCallNotifier.java
@@ -23,6 +23,9 @@
 import android.content.ContentValues;
 import android.content.Context;
 import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.provider.CallLog;
 import android.provider.CallLog.Calls;
@@ -128,7 +131,15 @@
                     mContext.getString(R.string.notification_missedCall_message),
                     createSendSmsFromNotificationPendingIntent(handleUri));
 
-            // TODO(santoscordon): Add photo for contact.
+            Bitmap photoIcon = call.getPhotoIcon();
+            if (photoIcon != null) {
+                builder.setLargeIcon(photoIcon);
+            } else {
+                Drawable photo = call.getPhoto();
+                if (photo != null && photo instanceof BitmapDrawable) {
+                    builder.setLargeIcon(((BitmapDrawable) photo).getBitmap());
+                }
+            }
         } else {
             Log.d(this, "Suppress actions. handle: %s, missedCalls: %d.", Log.piiHandle(handle),
                     mMissedCallCount);
@@ -136,6 +147,8 @@
 
         Notification notification = builder.build();
         configureLedOnNotification(notification);
+
+        Log.i(this, "Adding missed call notification for %s.", call);
         mNotificationManager.notify(MISSED_CALL_NOTIFICATION_ID, notification);
     }
 
@@ -150,10 +163,12 @@
      * Returns the name to use in the missed call notification.
      */
     private String getNameForCall(Call call) {
-        // TODO(santoscordon): Get detailed caller information.
-
         String handle = call.getHandle().getSchemeSpecificPart();
-        if (!TextUtils.isEmpty(handle)) {
+        String name = call.getName();
+
+        if (!TextUtils.isEmpty(name) && TextUtils.isGraphic(name)) {
+            return name;
+        } else if (!TextUtils.isEmpty(handle)) {
             // A handle should always be displayed LTR using {@link BidiFormatter} regardless of the
             // content of the rest of the notification.
             // TODO(santoscordon): Does this apply to SIP addresses?