Merge "Show commands for XMPP video chat for Google Talk"
diff --git a/res/drawable-hdpi/suggestion_bg.9.png b/res/drawable-hdpi/suggestion_bg.9.png
new file mode 100644
index 0000000..687cc08
--- /dev/null
+++ b/res/drawable-hdpi/suggestion_bg.9.png
Binary files differ
diff --git a/res/drawable-mdpi/suggestion_bg.9.png b/res/drawable-mdpi/suggestion_bg.9.png
new file mode 100644
index 0000000..687cc08
--- /dev/null
+++ b/res/drawable-mdpi/suggestion_bg.9.png
Binary files differ
diff --git a/res/layout/aggregation_suggestions.xml b/res/layout/aggregation_suggestions.xml
new file mode 100644
index 0000000..684fe91
--- /dev/null
+++ b/res/layout/aggregation_suggestions.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+ * Copyright 2010, 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.
+ */
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:background="@drawable/suggestion_bg">
+ <TextView
+ android:id="@+id/aggregation_suggestion_title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:layout_alignParentLeft="true"
+ android:layout_marginLeft="5dip"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="?android:attr/textColorPrimary"
+ android:textStyle="bold"
+ />
+
+ <LinearLayout
+ android:id="@+id/aggregation_suggestions"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ />
+</LinearLayout>
diff --git a/res/layout/aggregation_suggestions_item.xml b/res/layout/aggregation_suggestions_item.xml
new file mode 100644
index 0000000..06d153e
--- /dev/null
+++ b/res/layout/aggregation_suggestions_item.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+ * Copyright 2010, 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.
+ */
+-->
+
+<view xmlns:android="http://schemas.android.com/apk/res/android"
+ class="com.android.contacts.ui.widget.AggregationSuggestionView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingLeft="5dip"
+ android:paddingRight="15dip"
+>
+ <Button
+ android:id="@+id/aggregation_suggestion_join_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/aggregation_suggestion_join_button"
+ android:layout_centerInParent="true"
+ android:layout_alignParentRight="true"
+ />
+
+ <ImageView
+ android:id="@+id/aggregation_suggestion_photo"
+ android:layout_width="@dimen/aggregation_suggestion_icon_size"
+ android:layout_height="@dimen/aggregation_suggestion_icon_size"
+ android:layout_alignParentLeft="true"
+ android:layout_centerInParent="true"
+ android:layout_marginTop="4dip"
+ android:scaleType="fitCenter"
+ />
+
+ <TextView
+ android:id="@+id/aggregation_suggestion_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_toRightOf="@id/aggregation_suggestion_photo"
+ android:layout_toLeftOf="@id/aggregation_suggestion_join_button"
+ android:layout_marginLeft="10dip"
+ android:layout_marginTop="4dip"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="?android:attr/textColorSecondary"
+ />
+
+ <TextView
+ android:id="@+id/aggregation_suggestion_data"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_toRightOf="@id/aggregation_suggestion_photo"
+ android:layout_toLeftOf="@id/aggregation_suggestion_join_button"
+ android:layout_below="@id/aggregation_suggestion_name"
+ android:layout_marginLeft="10dip"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="?android:attr/textColorSecondary"
+ />
+</view>
diff --git a/res/layout/item_contact_editor.xml b/res/layout/item_contact_editor.xml
index b75c82a..6886503 100644
--- a/res/layout/item_contact_editor.xml
+++ b/res/layout/item_contact_editor.xml
@@ -105,6 +105,13 @@
android:layout_marginTop="6dip"
android:layout_marginBottom="4dip" />
+ <ViewStub android:id="@+id/aggregation_suggestion_stub"
+ android:inflatedId="@+id/aggregation_suggestion"
+ android:layout="@layout/aggregation_suggestions"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="visible"/>
+
<LinearLayout
android:id="@+id/sect_fields"
android:layout_width="match_parent"
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 3bc7ff6..ff9e92e 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -43,4 +43,6 @@
<dimen name="list_item_header_chip_right_margin">4dip</dimen>
<dimen name="list_item_header_checkbox_margin">5dip</dimen>
+ <dimen name="aggregation_suggestion_icon_size">40dip</dimen>
+
</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index e47eb18..100dcc4 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1250,4 +1250,21 @@
<!-- The name of the invisible local contact directory -->
<string name="local_invisible_directory">Other</string>
+
+ <!-- The heading of the contact aggregation suggestions section in Contact editor. [CHAR LIMIT=128]-->
+ <plurals name="aggregation_suggestion_title">
+ <item quantity="one">Similar contact</item>
+ <item quantity="other">Similar contacts</item>
+ </plurals>
+
+ <!-- The heading of the aggregation suggestion section of the Contact editor
+ indicating the contact that will be joined with the current contact. [CHAR LIMIT=128]-->
+ <string name="aggregation_suggestion_joined_title">Joined contact:</string>
+
+ <!-- The button next to a contact aggregation suggestion in Contact editor. [CHAR LIMIT=12]-->
+ <string name="aggregation_suggestion_join_button">Join</string>
+
+ <!-- The button next to the message about multiple contact aggregation suggestions in Contact editor. [CHAR LIMIT=12]-->
+ <string name="aggregation_suggestion_view_button">View</string>
+
</resources>
diff --git a/src/com/android/contacts/TwelveKeyDialer.java b/src/com/android/contacts/TwelveKeyDialer.java
index de53293..b9a65ae 100644
--- a/src/com/android/contacts/TwelveKeyDialer.java
+++ b/src/com/android/contacts/TwelveKeyDialer.java
@@ -303,6 +303,7 @@
if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) {
// see if we are "adding a call" from the InCallScreen; false by default.
mIsAddCallMode = intent.getBooleanExtra(ADD_CALL_MODE_KEY, false);
+
Uri uri = intent.getData();
if (uri != null) {
if ("tel".equals(uri.getScheme())) {
@@ -326,10 +327,15 @@
}
}
} else {
- // Like ACTION_MAIN
- // If there's already an active call, bring up an intermediate UI
- // to make the user confirm what they really want to do.
- needToShowDialpadChooser = phoneIsInUse();
+ // ACTION_DIAL or ACTION_VIEW with no data.
+ // This behaves basically like ACTION_MAIN: If there's
+ // already an active call, bring up an intermediate UI to
+ // make the user confirm what they really want to do.
+ // Be sure *not* to show the dialpad chooser if this is an
+ // explicit "Add call" action, though.
+ if (!mIsAddCallMode && phoneIsInUse()) {
+ needToShowDialpadChooser = true;
+ }
}
} else if (Intent.ACTION_MAIN.equals(action)) {
// The MAIN action means we're bringing up a blank dialer
diff --git a/src/com/android/contacts/list/ContactBrowseListFragment.java b/src/com/android/contacts/list/ContactBrowseListFragment.java
index de72b69..c09dc47 100644
--- a/src/com/android/contacts/list/ContactBrowseListFragment.java
+++ b/src/com/android/contacts/list/ContactBrowseListFragment.java
@@ -82,6 +82,7 @@
}
mSelectedContactUri = savedState.getParcelable(KEY_SELECTED_URI);
+ parseSelectedContactUri();
}
@Override
@@ -135,24 +136,7 @@
|| (mSelectedContactUri != null && !mSelectedContactUri.equals(uri))) {
mSelectedContactUri = uri;
- if (mSelectedContactUri != null) {
- if (!mSelectedContactUri.toString()
- .startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) {
- throw new IllegalStateException(
- "Contact list contains a non-lookup URI: " + mSelectedContactUri);
- }
-
- String directoryParam =
- mSelectedContactUri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY);
- mSelectedContactDirectoryId = TextUtils.isEmpty(directoryParam)
- ? Directory.DEFAULT
- : Long.parseLong(directoryParam);
- mSelectedContactLookupKey =
- Uri.encode(mSelectedContactUri.getPathSegments().get(2));
- } else {
- mSelectedContactDirectoryId = Directory.DEFAULT;
- mSelectedContactLookupKey = null;
- }
+ parseSelectedContactUri();
// Configure the adapter to show the selection based on the lookup key extracted
// from the URI
@@ -163,6 +147,27 @@
}
}
+ private void parseSelectedContactUri() {
+ if (mSelectedContactUri != null) {
+ if (!mSelectedContactUri.toString()
+ .startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) {
+ throw new IllegalStateException(
+ "Contact list contains a non-lookup URI: " + mSelectedContactUri);
+ }
+
+ String directoryParam =
+ mSelectedContactUri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY);
+ mSelectedContactDirectoryId = TextUtils.isEmpty(directoryParam)
+ ? Directory.DEFAULT
+ : Long.parseLong(directoryParam);
+ mSelectedContactLookupKey =
+ Uri.encode(mSelectedContactUri.getPathSegments().get(2));
+ } else {
+ mSelectedContactDirectoryId = Directory.DEFAULT;
+ mSelectedContactLookupKey = null;
+ }
+ }
+
@Override
protected void configureAdapter() {
super.configureAdapter();
diff --git a/src/com/android/contacts/list/ContactEntryListAdapter.java b/src/com/android/contacts/list/ContactEntryListAdapter.java
index aaf80f8..ef0807a 100644
--- a/src/com/android/contacts/list/ContactEntryListAdapter.java
+++ b/src/com/android/contacts/list/ContactEntryListAdapter.java
@@ -222,20 +222,17 @@
// TODO preserve the order of partition to match those of the cursor
// Phase I: add new directories
- try {
- while (cursor.moveToNext()) {
- long id = cursor.getLong(idColumnIndex);
- directoryIds.add(id);
- if (getPartitionByDirectoryId(id) == -1) {
- DirectoryPartition partition = new DirectoryPartition(false, true);
- partition.setDirectoryId(id);
- partition.setDirectoryType(cursor.getString(directoryTypeColumnIndex));
- partition.setDisplayName(cursor.getString(displayNameColumnIndex));
- addPartition(partition);
- }
+ cursor.moveToPosition(-1);
+ while (cursor.moveToNext()) {
+ long id = cursor.getLong(idColumnIndex);
+ directoryIds.add(id);
+ if (getPartitionByDirectoryId(id) == -1) {
+ DirectoryPartition partition = new DirectoryPartition(false, true);
+ partition.setDirectoryId(id);
+ partition.setDirectoryType(cursor.getString(directoryTypeColumnIndex));
+ partition.setDisplayName(cursor.getString(displayNameColumnIndex));
+ addPartition(partition);
}
- } finally {
- cursor.close();
}
// Phase II: remove deleted directories
@@ -257,10 +254,7 @@
@Override
public void changeCursor(int partitionIndex, Cursor cursor) {
if (partitionIndex >= getPartitionCount()) {
- // There is no partition for this data - just drop it on the ground
- if (cursor != null) {
- cursor.close();
- }
+ // There is no partition for this data
return;
}
diff --git a/src/com/android/contacts/model/EntityDeltaList.java b/src/com/android/contacts/model/EntityDeltaList.java
index f1af387..e68b6ef 100644
--- a/src/com/android/contacts/model/EntityDeltaList.java
+++ b/src/com/android/contacts/model/EntityDeltaList.java
@@ -34,6 +34,7 @@
import java.util.ArrayList;
import java.util.Iterator;
+import java.util.List;
/**
* Container for multiple {@link EntityDelta} objects, usually when editing
@@ -42,6 +43,7 @@
*/
public class EntityDeltaList extends ArrayList<EntityDelta> implements Parcelable {
private boolean mSplitRawContacts;
+ private List<Long> mJoinWithRawContactIds;
private EntityDeltaList() {
}
@@ -142,6 +144,22 @@
backRefs[rawContactIndex++] = firstBatch;
delta.buildDiff(diff);
+ // If the user chose to join with some other existing raw contact(s) at save time,
+ // add aggregation exceptions for all those raw contacts.
+ if (mJoinWithRawContactIds != null) {
+ for (Long joinedRawContactId : mJoinWithRawContactIds) {
+ final Builder builder = beginKeepTogether();
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, joinedRawContactId);
+ if (rawContactId != -1) {
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId);
+ } else {
+ builder.withValueBackReference(
+ AggregationExceptions.RAW_CONTACT_ID2, firstBatch);
+ }
+ diff.add(builder.build());
+ }
+ }
+
// Only create rules for inserts
if (!delta.isContactInsert()) continue;
@@ -313,6 +331,10 @@
mSplitRawContacts = true;
}
+ public void setJoinWithRawContacts(List<Long> rawContactIds) {
+ mJoinWithRawContactIds = rawContactIds;
+ }
+
/** {@inheritDoc} */
public int describeContents() {
// Nothing special about this parcel
@@ -326,14 +348,17 @@
for (EntityDelta delta : this) {
dest.writeParcelable(delta, flags);
}
+ dest.writeList(mJoinWithRawContactIds);
}
+ @SuppressWarnings("unchecked")
public void readFromParcel(Parcel source) {
final ClassLoader loader = getClass().getClassLoader();
final int size = source.readInt();
for (int i = 0; i < size; i++) {
this.add(source.<EntityDelta> readParcelable(loader));
}
+ mJoinWithRawContactIds = source.readArrayList(loader);
}
public static final Parcelable.Creator<EntityDeltaList> CREATOR =
diff --git a/src/com/android/contacts/ui/widget/AggregationSuggestionView.java b/src/com/android/contacts/ui/widget/AggregationSuggestionView.java
new file mode 100644
index 0000000..e03f78b
--- /dev/null
+++ b/src/com/android/contacts/ui/widget/AggregationSuggestionView.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2010 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.ui.widget;
+
+import com.android.contacts.R;
+import com.android.contacts.views.editor.AggregationSuggestionEngine.Suggestion;
+
+import android.content.Context;
+import android.graphics.BitmapFactory;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import java.util.List;
+
+/**
+ * A view that contains a name, picture and other data for a contact aggregation suggestion.
+ */
+public class AggregationSuggestionView extends RelativeLayout implements OnClickListener {
+
+ public interface Listener {
+
+ /**
+ * Callback that passes the contact ID to join with and, for convenience,
+ * also the list of constituent raw contact IDs to avoid a separate query
+ * for those.
+ */
+ public void onJoinAction(long contactId, List<Long> rawContacIds);
+ }
+
+ private Listener mListener;
+ private long mContactId;
+ private List<Long> mRawContactIds;
+
+ public AggregationSuggestionView(Context context) {
+ super(context);
+ }
+
+ public AggregationSuggestionView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public AggregationSuggestionView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public void bindSuggestion(Suggestion suggestion, boolean showJoinButton) {
+ mContactId = suggestion.contactId;
+ mRawContactIds = suggestion.rawContactIds;
+ ImageView photo = (ImageView) findViewById(R.id.aggregation_suggestion_photo);
+ if (suggestion.photo != null) {
+ photo.setImageBitmap(BitmapFactory.decodeByteArray(
+ suggestion.photo, 0, suggestion.photo.length));
+ } else {
+ photo.setImageResource(R.drawable.ic_contact_picture_2);
+ }
+
+ TextView name = (TextView) findViewById(R.id.aggregation_suggestion_name);
+ name.setText(suggestion.name);
+
+ TextView data = (TextView) findViewById(R.id.aggregation_suggestion_data);
+ String dataText = null;
+ if (suggestion.nickname != null) {
+ dataText = suggestion.nickname;
+ } else if (suggestion.emailAddress != null) {
+ dataText = suggestion.emailAddress;
+ } else if (suggestion.phoneNumber != null) {
+ dataText = suggestion.phoneNumber;
+ }
+ data.setText(dataText);
+
+ Button join = (Button) findViewById(R.id.aggregation_suggestion_join_button);
+ if (showJoinButton) {
+ join.setOnClickListener(this);
+ join.setVisibility(View.VISIBLE);
+ } else {
+ join.setVisibility(View.INVISIBLE);
+ }
+ }
+
+ public void setListener(Listener listener) {
+ mListener = listener;
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (mListener != null) {
+ mListener.onJoinAction(mContactId, mRawContactIds);
+ }
+ }
+}
diff --git a/src/com/android/contacts/ui/widget/BaseContactEditorView.java b/src/com/android/contacts/ui/widget/BaseContactEditorView.java
index c2f9136..7ee2dac 100644
--- a/src/com/android/contacts/ui/widget/BaseContactEditorView.java
+++ b/src/com/android/contacts/ui/widget/BaseContactEditorView.java
@@ -97,9 +97,4 @@
* apply to that state.
*/
public abstract void setState(EntityDelta state, ContactsSource source, ViewIdGenerator vig);
-
- /**
- * Sets the {@link EditorListener} on the name field
- */
- public abstract void setNameEditorListener(EditorListener listener);
}
diff --git a/src/com/android/contacts/ui/widget/ContactEditorView.java b/src/com/android/contacts/ui/widget/ContactEditorView.java
index 3a8b3b3..15d1726 100644
--- a/src/com/android/contacts/ui/widget/ContactEditorView.java
+++ b/src/com/android/contacts/ui/widget/ContactEditorView.java
@@ -200,12 +200,8 @@
}
}
- /**
- * Sets the {@link EditorListener} on the name field
- */
- @Override
- public void setNameEditorListener(EditorListener listener) {
- mName.setEditorListener(listener);
+ public GenericEditorView getNameEditor() {
+ return mName;
}
@Override
diff --git a/src/com/android/contacts/ui/widget/GenericEditorView.java b/src/com/android/contacts/ui/widget/GenericEditorView.java
index 612fff4..80982bc 100644
--- a/src/com/android/contacts/ui/widget/GenericEditorView.java
+++ b/src/com/android/contacts/ui/widget/GenericEditorView.java
@@ -434,6 +434,10 @@
if (mMoreOrLess != null) mMoreOrLess.setEnabled(enabled);
}
+ public ValuesDelta getValues() {
+ return mEntry;
+ }
+
/**
* Prepare dialog for entering a custom label. The input value is trimmed: white spaces before
* and after the input text is removed.
diff --git a/src/com/android/contacts/ui/widget/ReadOnlyContactEditorView.java b/src/com/android/contacts/ui/widget/ReadOnlyContactEditorView.java
index 4635f6a..011bcb1 100644
--- a/src/com/android/contacts/ui/widget/ReadOnlyContactEditorView.java
+++ b/src/com/android/contacts/ui/widget/ReadOnlyContactEditorView.java
@@ -191,14 +191,6 @@
}
}
- /**
- * Sets the {@link EditorListener} on the name field
- */
- @Override
- public void setNameEditorListener(EditorListener listener) {
- // do nothing
- }
-
@Override
public long getRawContactId() {
return mRawContactId;
diff --git a/src/com/android/contacts/views/editor/AggregationSuggestionEngine.java b/src/com/android/contacts/views/editor/AggregationSuggestionEngine.java
new file mode 100644
index 0000000..11f10f9
--- /dev/null
+++ b/src/com/android/contacts/views/editor/AggregationSuggestionEngine.java
@@ -0,0 +1,356 @@
+/*
+ * Copyright (C) 2010 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.views.editor;
+
+import com.android.contacts.model.EntityDelta.ValuesDelta;
+import com.google.android.collect.Lists;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.Process;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Contacts.AggregationSuggestions;
+import android.provider.ContactsContract.Contacts.AggregationSuggestions.Builder;
+import android.provider.ContactsContract.Data;
+import android.text.TextUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Runs asynchronous queries to obtain aggregation suggestions in the as-you-type mode.
+ */
+public class AggregationSuggestionEngine extends HandlerThread {
+ public static final String TAG = "AggregationSuggestionEngine";
+
+ private static final int MESSAGE_NAME_CHANGE = 1;
+ private static final int MESSAGE_DATA_CURSOR = 2;
+
+ private static final long SUGGESTION_LOOKUP_DELAY_MILLIS = 300;
+
+ private static final int MAX_SUGGESTION_COUNT = 3;
+
+ private final Context mContext;
+
+ private long[] mSuggestedContactIds = new long[0];
+
+ private Handler mMainHandler;
+ private Handler mHandler;
+ private long mContactId;
+ private Listener mListener;
+ private Cursor mDataCursor;
+
+ public interface Listener {
+ void onAggregationSuggestionChange();
+ }
+
+ public static final class Suggestion {
+ public long contactId;
+ public List<Long> rawContactIds;
+ public String lookupKey;
+ public String name;
+ public String phoneNumber;
+ public String emailAddress;
+ public String nickname;
+ public byte[] photo;
+
+ @Override
+ public String toString() {
+ return "ID: " + contactId + " rawContactIds: " + rawContactIds + " name: " + name
+ + " phone: " + phoneNumber + " email: " + emailAddress + " nickname: "
+ + nickname + (photo != null ? " [has photo]" : "");
+ }
+ }
+
+ public AggregationSuggestionEngine(Context context) {
+ super("AggregationSuggestions", Process.THREAD_PRIORITY_BACKGROUND);
+ mContext = context;
+ mMainHandler = new Handler() {
+ @Override
+ public void handleMessage(Message msg) {
+ AggregationSuggestionEngine.this.deliverNotification((Cursor) msg.obj);
+ }
+ };
+ }
+
+ protected Handler getHandler() {
+ if (mHandler == null) {
+ mHandler = new Handler(getLooper()) {
+ @Override
+ public void handleMessage(Message msg) {
+ AggregationSuggestionEngine.this.handleMessage(msg);
+ }
+ };
+ }
+ return mHandler;
+ }
+
+ public void setContactId(long contactId) {
+ mContactId = contactId;
+ }
+
+ public void setListener(Listener listener) {
+ mListener = listener;
+ }
+
+ @Override
+ public boolean quit() {
+ if (mDataCursor != null) {
+ mDataCursor.close();
+ }
+ mDataCursor = null;
+ return super.quit();
+ }
+
+ public void onNameChange(ValuesDelta values) {
+ Handler handler = getHandler();
+ handler.removeMessages(MESSAGE_NAME_CHANGE);
+
+ Uri uri = buildAggregationSuggestionUri(values);
+ if (uri == null) {
+ return;
+ }
+
+ Message msg = handler.obtainMessage(MESSAGE_NAME_CHANGE, uri);
+ handler.sendMessageDelayed(msg, SUGGESTION_LOOKUP_DELAY_MILLIS);
+ }
+
+ private Uri buildAggregationSuggestionUri(ValuesDelta values) {
+ StringBuilder nameSb = new StringBuilder();
+ appendValue(nameSb, values, StructuredName.PREFIX);
+ appendValue(nameSb, values, StructuredName.GIVEN_NAME);
+ appendValue(nameSb, values, StructuredName.MIDDLE_NAME);
+ appendValue(nameSb, values, StructuredName.FAMILY_NAME);
+ appendValue(nameSb, values, StructuredName.SUFFIX);
+
+ StringBuilder phoneticNameSb = new StringBuilder();
+ appendValue(phoneticNameSb, values, StructuredName.PHONETIC_FAMILY_NAME);
+ appendValue(phoneticNameSb, values, StructuredName.PHONETIC_MIDDLE_NAME);
+ appendValue(phoneticNameSb, values, StructuredName.PHONETIC_GIVEN_NAME);
+
+ if (nameSb.length() == 0 && phoneticNameSb.length() == 0) {
+ return null;
+ }
+
+ Builder builder = AggregationSuggestions.builder()
+ .setLimit(MAX_SUGGESTION_COUNT)
+ .setContactId(mContactId);
+
+ if (nameSb.length() != 0) {
+ builder.addParameter(AggregationSuggestions.PARAMETER_MATCH_NAME, nameSb.toString());
+ }
+
+ if (phoneticNameSb.length() != 0) {
+ builder.addParameter(
+ AggregationSuggestions.PARAMETER_MATCH_NAME, phoneticNameSb.toString());
+ }
+
+ return builder.build();
+ }
+
+ private void appendValue(StringBuilder sb, ValuesDelta values, String column) {
+ String value = values.getAsString(column);
+ if (!TextUtils.isEmpty(value)) {
+ if (sb.length() > 0) {
+ sb.append(' ');
+ }
+ sb.append(value);
+ }
+ }
+
+ protected void handleMessage(Message msg) {
+ switch(msg.what) {
+ case MESSAGE_NAME_CHANGE:
+ loadAggregationSuggestions((Uri) msg.obj);
+ break;
+ }
+ }
+
+ private static final class DataQuery {
+
+ public static final String SELECTION_PREFIX =
+ Data.MIMETYPE + " IN ('"
+ + Phone.CONTENT_ITEM_TYPE + "','"
+ + Email.CONTENT_ITEM_TYPE + "','"
+ + StructuredName.CONTENT_ITEM_TYPE + "','"
+ + Nickname.CONTENT_ITEM_TYPE + "','"
+ + Photo.CONTENT_ITEM_TYPE + "')"
+ + " AND " + Data.CONTACT_ID + " IN (";
+
+ public static final String[] COLUMNS = {
+ Data._ID,
+ Data.CONTACT_ID,
+ Data.LOOKUP_KEY,
+ Data.PHOTO_ID,
+ Data.DISPLAY_NAME,
+ Data.RAW_CONTACT_ID,
+ Data.MIMETYPE,
+ Data.DATA1,
+ Data.IS_SUPER_PRIMARY,
+ Photo.PHOTO,
+ };
+
+ public static final int ID = 0;
+ public static final int CONTACT_ID = 1;
+ public static final int LOOKUP_KEY = 2;
+ public static final int PHOTO_ID = 3;
+ public static final int DISPLAY_NAME = 4;
+ public static final int RAW_CONTACT_ID = 5;
+ public static final int MIMETYPE = 6;
+ public static final int DATA1 = 7;
+ public static final int IS_SUPERPRIMARY = 8;
+ public static final int PHOTO = 9;
+ }
+
+ private void loadAggregationSuggestions(Uri uri) {
+ ContentResolver contentResolver = mContext.getContentResolver();
+ Cursor cursor = contentResolver.query(uri, new String[]{Contacts._ID}, null, null, null);
+ try {
+ // If a new request is pending, chuck the result of the previous request
+ if (getHandler().hasMessages(MESSAGE_NAME_CHANGE)) {
+ return;
+ }
+
+ boolean changed = updateSuggestedContactIds(cursor);
+ if (!changed) {
+ return;
+ }
+
+ StringBuilder sb = new StringBuilder(DataQuery.SELECTION_PREFIX);
+ int count = mSuggestedContactIds.length;
+ for (int i = 0; i < count; i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append(mSuggestedContactIds[i]);
+ }
+ sb.append(')');
+ sb.toString();
+
+ Cursor dataCursor = contentResolver.query(Data.CONTENT_URI,
+ DataQuery.COLUMNS, sb.toString(), null, Data.CONTACT_ID);
+ mMainHandler.sendMessage(mMainHandler.obtainMessage(MESSAGE_DATA_CURSOR, dataCursor));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private boolean updateSuggestedContactIds(Cursor cursor) {
+ int count = cursor.getCount();
+ boolean changed = count != mSuggestedContactIds.length;
+ if (!changed) {
+ while (cursor.moveToNext()) {
+ long contactId = cursor.getLong(0);
+ if (Arrays.binarySearch(mSuggestedContactIds, contactId) < 0) {
+ changed = true;
+ break;
+ }
+ }
+ }
+
+ if (changed) {
+ mSuggestedContactIds = new long[count];
+ cursor.moveToPosition(-1);
+ for (int i = 0; i < count; i++) {
+ cursor.moveToNext();
+ mSuggestedContactIds[i] = cursor.getLong(0);
+ }
+ Arrays.sort(mSuggestedContactIds);
+ }
+
+ return changed;
+ }
+
+ protected void deliverNotification(Cursor dataCursor) {
+ if (mDataCursor != null) {
+ mDataCursor.close();
+ }
+ mDataCursor = dataCursor;
+ if (mListener != null) {
+ mListener.onAggregationSuggestionChange();
+ }
+ }
+
+ public int getSuggestedContactCount() {
+ return mSuggestedContactIds.length;
+ }
+
+ public List<Suggestion> getSuggestions() {
+ ArrayList<Suggestion> list = Lists.newArrayList();
+ if (mDataCursor != null) {
+ Suggestion suggestion = null;
+ long currentContactId = -1;
+ mDataCursor.moveToPosition(-1);
+ while (mDataCursor.moveToNext()) {
+ long contactId = mDataCursor.getLong(DataQuery.CONTACT_ID);
+ if (contactId != currentContactId) {
+ suggestion = new Suggestion();
+ suggestion.contactId = contactId;
+ suggestion.name = mDataCursor.getString(DataQuery.DISPLAY_NAME);
+ suggestion.rawContactIds = Lists.newArrayList();
+ list.add(suggestion);
+ currentContactId = contactId;
+ }
+
+ Long rawContactId = Long.valueOf(mDataCursor.getLong(DataQuery.RAW_CONTACT_ID));
+ if (!suggestion.rawContactIds.contains(rawContactId)) {
+ suggestion.rawContactIds.add(rawContactId);
+ }
+
+ String mimetype = mDataCursor.getString(DataQuery.MIMETYPE);
+ if (Phone.CONTENT_ITEM_TYPE.equals(mimetype)) {
+ String data = mDataCursor.getString(DataQuery.DATA1);
+ int superprimary = mDataCursor.getInt(DataQuery.IS_SUPERPRIMARY);
+ if (!TextUtils.isEmpty(data)
+ && (superprimary != 0 || suggestion.phoneNumber == null)) {
+ suggestion.phoneNumber = data;
+ }
+ } else if (Email.CONTENT_ITEM_TYPE.equals(mimetype)) {
+ String data = mDataCursor.getString(DataQuery.DATA1);
+ int superprimary = mDataCursor.getInt(DataQuery.IS_SUPERPRIMARY);
+ if (!TextUtils.isEmpty(data)
+ && (superprimary != 0 || suggestion.emailAddress == null)) {
+ suggestion.emailAddress = data;
+ }
+ } else if (Nickname.CONTENT_ITEM_TYPE.equals(mimetype)) {
+ String data = mDataCursor.getString(DataQuery.DATA1);
+ if (!TextUtils.isEmpty(data)) {
+ suggestion.nickname = data;
+ }
+ } else if (Photo.CONTENT_ITEM_TYPE.equals(mimetype)) {
+ long dataId = mDataCursor.getLong(DataQuery.ID);
+ long photoId = mDataCursor.getLong(DataQuery.PHOTO_ID);
+ if (dataId == photoId && !mDataCursor.isNull(DataQuery.PHOTO)) {
+ suggestion.photo = mDataCursor.getBlob(DataQuery.PHOTO);
+ }
+ }
+ }
+ }
+ return list;
+ }
+}
diff --git a/src/com/android/contacts/views/editor/ContactEditorFragment.java b/src/com/android/contacts/views/editor/ContactEditorFragment.java
index 24c30d4..681931c 100644
--- a/src/com/android/contacts/views/editor/ContactEditorFragment.java
+++ b/src/com/android/contacts/views/editor/ContactEditorFragment.java
@@ -19,21 +19,26 @@
import com.android.contacts.JoinContactActivity;
import com.android.contacts.R;
import com.android.contacts.model.ContactsSource;
+import com.android.contacts.model.ContactsSource.EditType;
import com.android.contacts.model.Editor;
+import com.android.contacts.model.Editor.EditorListener;
import com.android.contacts.model.EntityDelta;
-import com.android.contacts.model.EntityModifier;
+import com.android.contacts.model.EntityDelta.ValuesDelta;
import com.android.contacts.model.EntityDeltaList;
+import com.android.contacts.model.EntityModifier;
import com.android.contacts.model.GoogleSource;
import com.android.contacts.model.Sources;
-import com.android.contacts.model.ContactsSource.EditType;
-import com.android.contacts.model.Editor.EditorListener;
-import com.android.contacts.model.EntityDelta.ValuesDelta;
import com.android.contacts.ui.ViewIdGenerator;
+import com.android.contacts.ui.widget.AggregationSuggestionView;
import com.android.contacts.ui.widget.BaseContactEditorView;
+import com.android.contacts.ui.widget.ContactEditorView;
+import com.android.contacts.ui.widget.GenericEditorView;
import com.android.contacts.ui.widget.PhotoEditorView;
import com.android.contacts.util.EmptyService;
import com.android.contacts.util.WeakAsyncTask;
import com.android.contacts.views.ContactLoader;
+import com.android.contacts.views.editor.AggregationSuggestionEngine.Suggestion;
+import com.google.android.collect.Lists;
import android.accounts.Account;
import android.app.Activity;
@@ -44,6 +49,7 @@
import android.app.LoaderManager.LoaderCallbacks;
import android.content.ActivityNotFoundException;
import android.content.ContentProviderOperation;
+import android.content.ContentProviderOperation.Builder;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
@@ -54,9 +60,9 @@
import android.content.Intent;
import android.content.Loader;
import android.content.OperationApplicationException;
-import android.content.ContentProviderOperation.Builder;
import android.database.Cursor;
import android.graphics.Bitmap;
+import android.graphics.Rect;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Bundle;
@@ -64,12 +70,13 @@
import android.os.RemoteException;
import android.os.SystemClock;
import android.provider.ContactsContract;
-import android.provider.MediaStore;
import android.provider.ContactsContract.AggregationExceptions;
-import android.provider.ContactsContract.Contacts;
-import android.provider.ContactsContract.RawContacts;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.RawContactsEntity;
+import android.provider.MediaStore;
import android.util.Log;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
@@ -78,9 +85,12 @@
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewStub;
import android.widget.ArrayAdapter;
import android.widget.LinearLayout;
import android.widget.ListAdapter;
+import android.widget.TextView;
import android.widget.Toast;
import java.io.File;
@@ -89,10 +99,12 @@
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
+import java.util.Iterator;
+import java.util.List;
public class ContactEditorFragment extends Fragment implements
SplitContactConfirmationDialogFragment.Listener, PickPhotoDialogFragment.Listener,
- SelectAccountDialogFragment.Listener {
+ SelectAccountDialogFragment.Listener, AggregationSuggestionEngine.Listener {
private static final String TAG = "ContactEditorFragment";
@@ -145,6 +157,17 @@
private static final File PHOTO_DIR = new File(
Environment.getExternalStorageDirectory() + "/DCIM/Camera");
+ /**
+ * A delay in milliseconds used for bringing aggregation suggestions to
+ * the visible part of the screen. The reason this has to be done after
+ * a delay is a race condition with the soft keyboard. The keyboard
+ * may expand to display its own autocomplete suggestions, which will
+ * reduce the visible area of the screen. We will yield to the keyboard
+ * hoping that the delay is sufficient. If not - part of the
+ * suggestion will be hidden, which is not fatal.
+ */
+ private static final int AGGREGATION_SUGGESTION_SCROLL_DELAY = 200;
+
private File mCurrentPhotoFile;
private Context mContext;
@@ -165,6 +188,8 @@
private long mLoaderStartTime;
+ private AggregationSuggestionEngine mAggregationSuggestionEngine;
+
public ContactEditorFragment() {
}
@@ -175,6 +200,14 @@
}
@Override
+ public void onStop() {
+ super.onStop();
+ if (mAggregationSuggestionEngine != null) {
+ mAggregationSuggestionEngine.quit();
+ }
+ }
+
+ @Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
final View view = inflater.inflate(R.layout.contact_editor_fragment, container, false);
@@ -372,6 +405,29 @@
mContent.addView(editor);
editor.setState(entity, source, mViewIdGenerator);
+
+ if (editor instanceof ContactEditorView) {
+ final ContactEditorView rawContactEditor = (ContactEditorView) editor;
+ GenericEditorView nameEditor = rawContactEditor.getNameEditor();
+ nameEditor.setEditorListener(new EditorListener() {
+
+ @Override
+ public void onRequest(int request) {
+ acquireAggregationSuggestions(rawContactEditor);
+ }
+
+ @Override
+ public void onDeleted(Editor editor) {
+ }
+ });
+
+ // If the user has already decided to join with a specific contact,
+ // trigger a refresh of the aggregation suggestion view to redisplay
+ // the selection.
+ if (mContactIdForJoin != 0) {
+ acquireAggregationSuggestions(rawContactEditor);
+ }
+ }
}
// Show editor now that we've loaded state
@@ -555,7 +611,7 @@
}
/**
- * Asynchonously saves the changes made by the user. This can be called even if nothing
+ * Asynchronously saves the changes made by the user. This can be called even if nothing
* has changed
*/
public void save(boolean closeAfterSave) {
@@ -953,6 +1009,163 @@
}
}
+ /**
+ * Returns the contact ID for the currently edited contact or 0 if the contact is new.
+ */
+ protected long getContactId() {
+ for (EntityDelta rawContact : mState) {
+ Long contactId = rawContact.getValues().getAsLong(RawContacts.CONTACT_ID);
+ if (contactId != null) {
+ return contactId;
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Triggers an asynchronous search for aggregation suggestions.
+ */
+ public void acquireAggregationSuggestions(ContactEditorView rawContactEditor) {
+ if (mAggregationSuggestionEngine == null) {
+ mAggregationSuggestionEngine = new AggregationSuggestionEngine(getActivity());
+ mAggregationSuggestionEngine.setContactId(getContactId());
+ mAggregationSuggestionEngine.setListener(this);
+ mAggregationSuggestionEngine.start();
+ }
+
+ GenericEditorView nameEditor = rawContactEditor.getNameEditor();
+ mAggregationSuggestionEngine.onNameChange(nameEditor.getValues());
+ }
+
+ @Override
+ public void onAggregationSuggestionChange() {
+ // We may have chosen some contact to join with but then changed the contact name.
+ // Let's drop the obsolete decision to join.
+ setSelectedAggregationSuggestion(0, null);
+ updateAggregationSuggestionView();
+ }
+
+ protected void updateAggregationSuggestionView() {
+ ViewStub stub = (ViewStub)mContent.findViewById(R.id.aggregation_suggestion_stub);
+ if (stub != null) {
+ stub.inflate();
+ final View view = mContent.findViewById(R.id.aggregation_suggestion);
+ mContent.postDelayed(new Runnable() {
+
+ @Override
+ public void run() {
+ requestAggregationSuggestionOnScreen(view);
+ }
+ }, AGGREGATION_SUGGESTION_SCROLL_DELAY);
+ }
+
+ View view = mContent.findViewById(R.id.aggregation_suggestion);
+
+ List<Suggestion> suggestions = null;
+ int count = mAggregationSuggestionEngine.getSuggestedContactCount();
+ if (count > 0) {
+ suggestions = mAggregationSuggestionEngine.getSuggestions();
+
+ if (mContactIdForJoin != 0) {
+ Suggestion chosenSuggestion = null;
+ for (Suggestion suggestion : suggestions) {
+ if (suggestion.contactId == mContactIdForJoin) {
+ chosenSuggestion = suggestion;
+ break;
+ }
+ }
+
+ if (chosenSuggestion != null) {
+ suggestions = Lists.newArrayList(chosenSuggestion);
+ } else {
+ // If the contact we wanted to join with is no longer suggested,
+ // forget our decision to join with it.
+ setSelectedAggregationSuggestion(0, null);
+ }
+ }
+
+ count = suggestions.size();
+ }
+
+ if (count == 0) {
+ view.setVisibility(View.GONE);
+ return;
+ }
+
+ TextView title = (TextView) view.findViewById(R.id.aggregation_suggestion_title);
+ if (mContactIdForJoin != 0) {
+ title.setText(R.string.aggregation_suggestion_joined_title);
+ } else {
+ title.setText(getActivity().getResources().getQuantityString(
+ R.plurals.aggregation_suggestion_title, count));
+ }
+
+ LinearLayout itemList = (LinearLayout) view.findViewById(R.id.aggregation_suggestions);
+ itemList.removeAllViews();
+
+ LayoutInflater inflater = getActivity().getLayoutInflater();
+
+ for (Suggestion suggestion : suggestions) {
+ AggregationSuggestionView suggestionView =
+ (AggregationSuggestionView) inflater.inflate(
+ R.layout.aggregation_suggestions_item, null);
+ suggestionView.setLayoutParams(
+ new LinearLayout.LayoutParams(
+ LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
+ suggestionView.setListener(new AggregationSuggestionView.Listener() {
+
+ @Override
+ public void onJoinAction(long contactId, List<Long> rawContactIds) {
+ mState.setJoinWithRawContacts(rawContactIds);
+ // If we are in the edit mode (indicated by a non-zero contact ID),
+ // join the suggested contact, save all changes, and stay in the editor.
+ if (getContactId() != 0) {
+ doSaveAction(SaveMode.RELOAD);
+ } else {
+ mContactIdForJoin = contactId;
+ updateAggregationSuggestionView();
+ }
+ }
+ });
+ suggestionView.bindSuggestion(suggestion, mContactIdForJoin == 0 /* showJoinButton */);
+ itemList.addView(suggestionView);
+ }
+ view.setVisibility(View.VISIBLE);
+ }
+
+ /**
+ * Scrolls the editor if necessary to reveal the aggregation suggestion that is
+ * shown below the name editor. Makes sure that the currently focused field
+ * remains visible.
+ */
+ private void requestAggregationSuggestionOnScreen(final View view) {
+ Rect rect = getRelativeBounds(mContent, view);
+ View focused = mContent.findFocus();
+ if (focused != null) {
+ rect.union(getRelativeBounds(mContent, focused));
+ }
+ mContent.requestRectangleOnScreen(rect);
+ }
+
+ /**
+ * Computes bounds of the supplied view relative to its ascendant.
+ */
+ private Rect getRelativeBounds(View ascendant, View view) {
+ Rect rect = new Rect();
+ rect.set(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
+
+ View parent = (View) view.getParent();
+ while (parent != ascendant) {
+ rect.offset(parent.getLeft(), parent.getTop());
+ parent = (View) parent.getParent();
+ }
+ return rect;
+ }
+
+ protected void setSelectedAggregationSuggestion(long contactId, List<Long> rawContactIds) {
+ mContactIdForJoin = contactId;
+ mState.setJoinWithRawContacts(rawContactIds);
+ }
// TODO: There has to be a nicer way than this WeakAsyncTask...? Maybe call a service?
/**
diff --git a/src/com/android/contacts/widget/CompositeCursorAdapter.java b/src/com/android/contacts/widget/CompositeCursorAdapter.java
index 6465a21..c6aa775 100644
--- a/src/com/android/contacts/widget/CompositeCursorAdapter.java
+++ b/src/com/android/contacts/widget/CompositeCursorAdapter.java
@@ -108,14 +108,10 @@
}
/**
- * Removes cursors for all partitions, closing them as necessary.
+ * Removes cursors for all partitions.
*/
public void clearPartitions() {
for (int i = 0; i < mSize; i++) {
- Cursor cursor = mPartitions[i].cursor;
- if (cursor != null && !cursor.isClosed()) {
- cursor.close();
- }
mPartitions[i].cursor = null;
}
invalidate();