blob: 764b5449aec4d2fc6551301660320244aaebaa24 [file] [log] [blame]
package com.android.mms.data;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import android.content.ContentUris;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Handler;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.Presence;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.Telephony.Mms;
import android.telephony.PhoneNumberUtils;
import android.text.TextUtils;
import android.util.Log;
import com.android.mms.ui.MessageUtils;
import com.android.mms.LogTag;
import com.google.android.mms.util.SqliteWrapper;
public class Contact {
private static final String TAG = "Contact";
private static final boolean V = false;
private static ContactsCache sContactCache;
// private static final ContentObserver sContactsObserver = new ContentObserver(new Handler()) {
// @Override
// public void onChange(boolean selfUpdate) {
// if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
// log("contact changed, invalidate cache");
// }
// invalidateCache();
// }
// };
private static final ContentObserver sPresenceObserver = new ContentObserver(new Handler()) {
@Override
public void onChange(boolean selfUpdate) {
if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
log("presence changed, invalidate cache");
}
invalidateCache();
}
};
private final HashSet<UpdateListener> mListeners = new HashSet<UpdateListener>();
private String mNumber;
private String mName;
private String mNameAndNumber; // for display, e.g. Fred Flintstone <670-782-1123>
private boolean mNumberIsModified; // true if the number is modified
private long mRecipientId; // used to find the Recipient cache entry
private String mLabel;
private long mPersonId;
private int mPresenceResId; // TODO: make this a state instead of a res ID
private String mPresenceText;
private BitmapDrawable mAvatar;
private byte [] mAvatarData;
private boolean mIsStale;
private boolean mQueryPending;
public interface UpdateListener {
public void onUpdate(Contact updated);
}
/*
* Make a basic contact object with a phone number.
*/
private Contact(String number) {
mName = "";
setNumber(number);
mNumberIsModified = false;
mLabel = "";
mPersonId = 0;
mPresenceResId = 0;
mIsStale = true;
}
@Override
public String toString() {
return String.format("{ number=%s, name=%s, nameAndNumber=%s, label=%s, person_id=%d }",
mNumber, mName, mNameAndNumber, mLabel, mPersonId);
}
private static void logWithTrace(String msg, Object... format) {
Thread current = Thread.currentThread();
StackTraceElement[] stack = current.getStackTrace();
StringBuilder sb = new StringBuilder();
sb.append("[");
sb.append(current.getId());
sb.append("] ");
sb.append(String.format(msg, format));
sb.append(" <- ");
int stop = stack.length > 7 ? 7 : stack.length;
for (int i = 3; i < stop; i++) {
String methodName = stack[i].getMethodName();
sb.append(methodName);
if ((i+1) != stop) {
sb.append(" <- ");
}
}
Log.d(TAG, sb.toString());
}
public static Contact get(String number, boolean canBlock) {
return sContactCache.get(number, canBlock);
}
public static void invalidateCache() {
if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
log("invalidateCache");
}
// While invalidating our local Cache doesn't remove the contacts, it will mark them
// stale so the next time we're asked for a particular contact, we'll return that
// stale contact and at the same time, fire off an asyncUpdateContact to update
// that contact's info in the background. UI elements using the contact typically
// call addListener() so they immediately get notified when the contact has been
// updated with the latest info. They redraw themselves when we call the
// listener's onUpdate().
sContactCache.invalidate();
}
private static String emptyIfNull(String s) {
return (s != null ? s : "");
}
public static String formatNameAndNumber(String name, String number) {
// Format like this: Mike Cleron <(650) 555-1234>
// Erick Tseng <(650) 555-1212>
// Tutankhamun <tutank1341@gmail.com>
// (408) 555-1289
String formattedNumber = number;
if (!Mms.isEmailAddress(number)) {
formattedNumber = PhoneNumberUtils.formatNumber(number);
}
if (!TextUtils.isEmpty(name) && !name.equals(number)) {
return name + " <" + formattedNumber + ">";
} else {
return formattedNumber;
}
}
public synchronized String getNumber() {
return mNumber;
}
public synchronized void setNumber(String number) {
mNumber = number;
updateNameAndNumber();
mNumberIsModified = true;
}
public boolean isNumberModified() {
return mNumberIsModified;
}
public void setIsNumberModified(boolean flag) {
mNumberIsModified = flag;
}
public synchronized String getName() {
if (TextUtils.isEmpty(mName)) {
return mNumber;
} else {
return mName;
}
}
public synchronized String getNameAndNumber() {
return mNameAndNumber;
}
private void updateNameAndNumber() {
mNameAndNumber = formatNameAndNumber(mName, mNumber);
}
public synchronized long getRecipientId() {
return mRecipientId;
}
public synchronized void setRecipientId(long id) {
mRecipientId = id;
}
public synchronized String getLabel() {
return mLabel;
}
public synchronized Uri getUri() {
return ContentUris.withAppendedId(Contacts.CONTENT_URI, mPersonId);
}
public long getPersonId() {
return mPersonId;
}
public synchronized int getPresenceResId() {
return mPresenceResId;
}
public synchronized boolean existsInDatabase() {
return (mPersonId > 0);
}
public synchronized void addListener(UpdateListener l) {
boolean added = mListeners.add(l);
if (V && added) dumpListeners();
}
public synchronized void removeListener(UpdateListener l) {
boolean removed = mListeners.remove(l);
if (V && removed) dumpListeners();
}
public synchronized void dumpListeners() {
int i=0;
Log.i(TAG, "[Contact] dumpListeners(" + mNumber + ") size=" + mListeners.size());
for (UpdateListener listener : mListeners) {
Log.i(TAG, "["+ (i++) + "]" + listener);
}
}
public synchronized boolean isEmail() {
return Mms.isEmailAddress(mNumber);
}
public String getPresenceText() {
return mPresenceText;
}
public synchronized Drawable getAvatar(Context context, Drawable defaultValue) {
if (mAvatar == null) {
if (mAvatarData != null) {
Bitmap b = BitmapFactory.decodeByteArray(mAvatarData, 0, mAvatarData.length);
mAvatar = new BitmapDrawable(context.getResources(), b);
}
}
return mAvatar != null ? mAvatar : defaultValue;
}
public static void init(final Context context) {
sContactCache = new ContactsCache(context);
RecipientIdCache.init(context);
// it maybe too aggressive to listen for *any* contact changes, and rebuild MMS contact
// cache each time that occurs. Unless we can get targeted updates for the contacts we
// care about(which probably won't happen for a long time), we probably should just
// invalidate cache peoridically, or surgically.
/*
context.getContentResolver().registerContentObserver(
Contacts.CONTENT_URI, true, sContactsObserver);
*/
}
public static void dump() {
sContactCache.dump();
}
private static class ContactsCache {
private final TaskStack mTaskQueue = new TaskStack();
private static final String SEPARATOR = ";";
// query params for caller id lookup
private static final String CALLER_ID_SELECTION = "PHONE_NUMBERS_EQUAL(" + Phone.NUMBER
+ ",?) AND " + Data.MIMETYPE + "='" + Phone.CONTENT_ITEM_TYPE + "'";
// Utilizing private API
private static final Uri PHONES_WITH_PRESENCE_URI = Data.CONTENT_URI;
private static final String[] CALLER_ID_PROJECTION = new String[] {
Phone.NUMBER, // 0
Phone.LABEL, // 1
Phone.DISPLAY_NAME, // 2
Phone.CONTACT_ID, // 3
Phone.CONTACT_PRESENCE, // 4
Phone.CONTACT_STATUS, // 5
};
private static final int PHONE_NUMBER_COLUMN = 0;
private static final int PHONE_LABEL_COLUMN = 1;
private static final int CONTACT_NAME_COLUMN = 2;
private static final int CONTACT_ID_COLUMN = 3;
private static final int CONTACT_PRESENCE_COLUMN = 4;
private static final int CONTACT_STATUS_COLUMN = 5;
// query params for contact lookup by email
private static final Uri EMAIL_WITH_PRESENCE_URI = Data.CONTENT_URI;
private static final String EMAIL_SELECTION = Email.DATA + "=? AND " + Data.MIMETYPE + "='"
+ Email.CONTENT_ITEM_TYPE + "'";
private static final String[] EMAIL_PROJECTION = new String[] {
Email.DISPLAY_NAME, // 0
Email.CONTACT_PRESENCE, // 1
Email.CONTACT_ID, // 2
Phone.DISPLAY_NAME, //
};
private static final int EMAIL_NAME_COLUMN = 0;
private static final int EMAIL_STATUS_COLUMN = 1;
private static final int EMAIL_ID_COLUMN = 2;
private static final int EMAIL_CONTACT_NAME_COLUMN = 3;
private String[] mContactInfoSelectionArgs = new String[1];
private final List<Contact> mCache;
private final Context mContext;
private ContactsCache(Context context) {
mCache = new ArrayList<Contact>();
mContext = context;
}
void dump() {
synchronized (ContactsCache.this) {
Log.d(TAG, "**** Contact cache dump ****");
for (Contact c : mCache) {
Log.d(TAG, c.toString());
}
}
}
private static class TaskStack {
Thread mWorkerThread;
private final ArrayList<Runnable> mThingsToLoad;
public TaskStack() {
mThingsToLoad = new ArrayList<Runnable>();
mWorkerThread = new Thread(new Runnable() {
public void run() {
while (true) {
Runnable r = null;
synchronized (mThingsToLoad) {
if (mThingsToLoad.size() == 0) {
try {
mThingsToLoad.wait();
} catch (InterruptedException ex) {
// nothing to do
}
}
if (mThingsToLoad.size() > 0) {
r = mThingsToLoad.remove(0);
}
}
if (r != null) {
r.run();
}
}
}
});
mWorkerThread.start();
}
public void push(Runnable r) {
synchronized (mThingsToLoad) {
mThingsToLoad.add(r);
mThingsToLoad.notify();
}
}
}
public void pushTask(Runnable r) {
mTaskQueue.push(r);
}
public Contact get(String number, boolean canBlock) {
if (V) logWithTrace("get(%s, %s)", number, canBlock);
if (TextUtils.isEmpty(number)) {
throw new IllegalArgumentException("Contact.get called with null or empty number");
}
// Always return a Contact object, if if we don't have an actual contact
// in the contacts db.
Contact contact = sContactCache.get(number);
boolean queryPending = false;
Runnable r = null;
synchronized (contact) {
// If there's a query pending and we're willing to block then
// wait here until the query completes.
while (canBlock && contact.mQueryPending) {
try {
contact.wait();
} catch (InterruptedException ex) {
// try again by virtue of the loop unless mQueryPending is false
}
}
// If we're stale and we haven't already kicked off a query then kick
// it off here.
if (contact.mIsStale && !contact.mQueryPending) {
contact.mIsStale = false;
if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
log("asyncUpdateContact for " + contact.toString() + " canBlock: " + canBlock +
" isStale: " + contact.mIsStale);
}
final Contact c = contact;
r = new Runnable() {
public void run() {
updateContact(c);
}
};
// set this to true while we have the lock on contact since we will
// either run the query directly (canBlock case) or push the query
// onto the queue. In either case the mQueryPending will get set
// to false via updateContact.
contact.mQueryPending = true;
}
}
// do this outside of the synchronized so we don't hold up any
// subsequent calls to "get" on other threads
if (r != null) {
if (canBlock) {
r.run();
} else {
pushTask(r);
}
}
return contact;
}
private boolean contactChanged(Contact orig, Contact newContactData) {
// The phone number should never change, so don't bother checking.
// TODO: Maybe update it if it has gotten longer, i.e. 650-234-5678 -> +16502345678?
String oldName = emptyIfNull(orig.mName);
String newName = emptyIfNull(newContactData.mName);
if (!oldName.equals(newName)) {
if (V) Log.d(TAG, String.format("name changed: %s -> %s", oldName, newName));
return true;
}
String oldLabel = emptyIfNull(orig.mLabel);
String newLabel = emptyIfNull(newContactData.mLabel);
if (!oldLabel.equals(newLabel)) {
if (V) Log.d(TAG, String.format("label changed: %s -> %s", oldLabel, newLabel));
return true;
}
if (orig.mPersonId != newContactData.mPersonId) {
if (V) Log.d(TAG, "person id changed");
return true;
}
if (orig.mPresenceResId != newContactData.mPresenceResId) {
if (V) Log.d(TAG, "presence changed");
return true;
}
if (!Arrays.equals(orig.mAvatarData, newContactData.mAvatarData)) {
if (V) Log.d(TAG, "avatar changed");
return true;
}
return false;
}
private void updateContact(final Contact c) {
if (c == null) {
return;
}
Contact entry = getContactInfo(c.mNumber);
synchronized (ContactsCache.this) {
if (contactChanged(c, entry)) {
if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
log("updateContact: contact changed for " + entry.mName);
}
//c.mNumber = entry.phoneNumber;
c.mName = entry.mName;
c.mNumber = entry.mNumber;
c.updateNameAndNumber();
c.mLabel = entry.mLabel;
c.mPersonId = entry.mPersonId;
c.mPresenceResId = entry.mPresenceResId;
c.mPresenceText = entry.mPresenceText;
c.mAvatarData = entry.mAvatarData;
c.mAvatar = entry.mAvatar;
// Check to see if this is the local ("me") number and update the name.
handleLocalNumber(c);
for (UpdateListener l : c.mListeners) {
if (V) Log.d(TAG, "updating " + l);
l.onUpdate(c);
}
}
synchronized (c) {
c.mQueryPending = false;
c.notifyAll();
}
}
}
/**
* Handles the special case where the local ("Me") number is being looked up.
* Updates the contact with the "me" name and returns true if it is the
* local number, no-ops and returns false if it is not.
*/
private boolean handleLocalNumber(Contact c) {
if (MessageUtils.isLocalNumber(c.mNumber)) {
c.mName = mContext.getString(com.android.internal.R.string.me);
c.updateNameAndNumber();
return true;
}
return false;
}
/**
* Returns the caller info in Contact.
*/
public Contact getContactInfo(String numberOrEmail) {
if (Mms.isEmailAddress(numberOrEmail)) {
return getContactInfoForEmailAddress(numberOrEmail);
} else {
return getContactInfoForPhoneNumber(numberOrEmail);
}
}
/**
* Queries the caller id info with the phone number.
* @return a Contact containing the caller id info corresponding to the number.
*/
private Contact getContactInfoForPhoneNumber(String number) {
number = PhoneNumberUtils.stripSeparators(number);
Contact entry = new Contact(number);
//if (LOCAL_DEBUG) log("queryContactInfoByNumber: number=" + number);
mContactInfoSelectionArgs[0] = number;
Cursor cursor = mContext.getContentResolver().query(
PHONES_WITH_PRESENCE_URI,
CALLER_ID_PROJECTION,
CALLER_ID_SELECTION,
mContactInfoSelectionArgs,
null);
if (cursor == null) {
Log.w(TAG, "queryContactInfoByNumber(" + number + ") returned NULL cursor!" +
" contact uri used " + PHONES_WITH_PRESENCE_URI);
return entry;
}
try {
if (cursor.moveToFirst()) {
entry.mLabel = cursor.getString(PHONE_LABEL_COLUMN);
entry.mName = cursor.getString(CONTACT_NAME_COLUMN);
entry.mPersonId = cursor.getLong(CONTACT_ID_COLUMN);
entry.mPresenceResId = getPresenceIconResourceId(
cursor.getInt(CONTACT_PRESENCE_COLUMN));
entry.mPresenceText = cursor.getString(CONTACT_STATUS_COLUMN);
if (V) {
log("queryContactInfoByNumber: name=" + entry.mName + ", number=" + number +
", presence=" + entry.mPresenceResId);
}
loadAvatarData(entry, cursor);
}
} finally {
cursor.close();
}
return entry;
}
/*
* Load the avatar data from the cursor into memory. Don't decode the data
* until someone calls for it (see getAvatar). Hang onto the raw data so that
* we can compare it when the data is reloaded.
* TODO: consider comparing a checksum so that we don't have to hang onto
* the raw bytes after the image is decoded.
*/
private void loadAvatarData(Contact entry, Cursor cursor) {
if (entry.mPersonId == 0 || entry.mAvatar != null) {
return;
}
Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, entry.mPersonId);
InputStream avatarDataStream =
Contacts.openContactPhotoInputStream(
mContext.getContentResolver(),
contactUri);
try {
if (avatarDataStream != null) {
byte [] data = new byte[avatarDataStream.available()];
avatarDataStream.read(data, 0, data.length);
entry.mAvatarData = data;
}
} catch (IOException ex) {
//
} finally {
try {
if (avatarDataStream != null) {
avatarDataStream.close();
}
} catch (IOException e) {
}
}
}
private int getPresenceIconResourceId(int presence) {
if (presence != Presence.OFFLINE) {
return Presence.getPresenceIconResourceId(presence);
}
return 0;
}
/**
* Query the contact email table to get the name of an email address.
*/
private Contact getContactInfoForEmailAddress(String email) {
Contact entry = new Contact(email);
mContactInfoSelectionArgs[0] = email;
Cursor cursor = SqliteWrapper.query(mContext, mContext.getContentResolver(),
EMAIL_WITH_PRESENCE_URI,
EMAIL_PROJECTION,
EMAIL_SELECTION,
mContactInfoSelectionArgs,
null);
if (cursor != null) {
try {
while (cursor.moveToNext()) {
entry.mPresenceResId = getPresenceIconResourceId(
cursor.getInt(EMAIL_STATUS_COLUMN));
entry.mPersonId = cursor.getLong(EMAIL_ID_COLUMN);
String name = cursor.getString(EMAIL_NAME_COLUMN);
if (TextUtils.isEmpty(name)) {
name = cursor.getString(EMAIL_CONTACT_NAME_COLUMN);
}
if (!TextUtils.isEmpty(name)) {
entry.mName = name;
loadAvatarData(entry, cursor);
if (V) {
log("queryEmailDisplayName: name=" + entry.mName + ", email=" + email +
", presence=" + entry.mPresenceResId);
}
break;
}
}
} finally {
cursor.close();
}
}
return entry;
}
private Contact getEmail(String number) {
synchronized (ContactsCache.this) {
for (Contact c : mCache) {
if (number.equalsIgnoreCase(c.mNumber)) {
return c;
}
}
return null;
}
}
Contact get(String number) {
if (Mms.isEmailAddress(number))
return getEmail(number);
synchronized (ContactsCache.this) {
for (Contact c : mCache) {
// if the numbers are an exact match (i.e. Google SMS), or if the phone
// number comparison returns a match, return the contact.
if (number.equals(c.mNumber) || PhoneNumberUtils.compare(number, c.mNumber)) {
return c;
}
}
Contact c = new Contact(number);
mCache.add(c);
return c;
}
}
String[] getNumbers() {
synchronized (ContactsCache.this) {
String[] numbers = new String[mCache.size()];
int i = 0;
for (Contact c : mCache) {
numbers[i++] = c.getNumber();
}
return numbers;
}
}
List<Contact> getContacts() {
synchronized (ContactsCache.this) {
return new ArrayList<Contact>(mCache);
}
}
void invalidate() {
// Don't remove the contacts. Just mark them stale so we'll update their
// info, particularly their presence.
synchronized (ContactsCache.this) {
for (Contact c : mCache) {
c.mIsStale = true;
}
}
}
}
private static void log(String msg) {
Log.d(TAG, msg);
}
}