summaryrefslogtreecommitdiff
path: root/java/com/android/dialer/searchfragment/directories
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/android/dialer/searchfragment/directories')
-rw-r--r--java/com/android/dialer/searchfragment/directories/DirectoriesCursorLoader.java7
-rw-r--r--java/com/android/dialer/searchfragment/directories/DirectoryContactViewHolder.java145
-rw-r--r--java/com/android/dialer/searchfragment/directories/DirectoryContactsCursor.java153
-rw-r--r--java/com/android/dialer/searchfragment/directories/DirectoryContactsCursorLoader.java162
-rw-r--r--java/com/android/dialer/searchfragment/directories/res/values/strings.xml20
5 files changed, 486 insertions, 1 deletions
diff --git a/java/com/android/dialer/searchfragment/directories/DirectoriesCursorLoader.java b/java/com/android/dialer/searchfragment/directories/DirectoriesCursorLoader.java
index 39c1187a4..dbe11dd96 100644
--- a/java/com/android/dialer/searchfragment/directories/DirectoriesCursorLoader.java
+++ b/java/com/android/dialer/searchfragment/directories/DirectoriesCursorLoader.java
@@ -31,7 +31,12 @@ import com.google.auto.value.AutoValue;
import java.util.ArrayList;
import java.util.List;
-/** {@link CursorLoader} to load the list of all directories (local and remote). */
+/**
+ * {@link CursorLoader} to load information about all directories (local and remote).
+ *
+ * <p>Information about a directory includes its ID, display name, etc, but doesn't include the
+ * contacts in it.
+ */
public final class DirectoriesCursorLoader extends CursorLoader {
public static final String[] PROJECTION = {
diff --git a/java/com/android/dialer/searchfragment/directories/DirectoryContactViewHolder.java b/java/com/android/dialer/searchfragment/directories/DirectoryContactViewHolder.java
new file mode 100644
index 000000000..ff321fc75
--- /dev/null
+++ b/java/com/android/dialer/searchfragment/directories/DirectoryContactViewHolder.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2017 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.dialer.searchfragment.directories;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Contacts;
+import android.support.v7.widget.RecyclerView;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.QuickContactBadge;
+import android.widget.TextView;
+import com.android.contacts.common.compat.DirectoryCompat;
+import com.android.dialer.callintent.CallInitiationType;
+import com.android.dialer.callintent.CallIntentBuilder;
+import com.android.dialer.contactphoto.ContactPhotoManager;
+import com.android.dialer.lettertile.LetterTileDrawable;
+import com.android.dialer.precall.PreCall;
+import com.android.dialer.searchfragment.common.Projections;
+import com.android.dialer.searchfragment.common.QueryBoldingUtil;
+import com.android.dialer.searchfragment.common.R;
+import com.android.dialer.searchfragment.common.SearchCursor;
+
+/** ViewHolder for a directory contact row. */
+public final class DirectoryContactViewHolder extends RecyclerView.ViewHolder
+ implements View.OnClickListener {
+
+ private final Context context;
+ private final TextView nameView;
+ private final TextView numberView;
+ private final QuickContactBadge photo;
+ private final ImageView workBadge;
+
+ private String number;
+
+ public DirectoryContactViewHolder(View view) {
+ super(view);
+ view.setOnClickListener(this);
+ photo = view.findViewById(R.id.photo);
+ nameView = view.findViewById(R.id.primary);
+ numberView = view.findViewById(R.id.secondary);
+ workBadge = view.findViewById(R.id.work_icon);
+ context = view.getContext();
+ }
+
+ /**
+ * Binds the ViewHolder with a cursor from {@link DirectoryContactsCursorLoader} with the data
+ * found at the cursors current position.
+ */
+ public void bind(SearchCursor cursor, String query) {
+ number = cursor.getString(Projections.PHONE_NUMBER);
+ String name = cursor.getString(Projections.DISPLAY_NAME);
+ String label = getLabel(context.getResources(), cursor);
+ String secondaryInfo =
+ TextUtils.isEmpty(label)
+ ? number
+ : context.getString(
+ com.android.contacts.common.R.string.call_subject_type_and_number, label, number);
+
+ nameView.setText(QueryBoldingUtil.getNameWithQueryBolded(query, name, context));
+ numberView.setText(QueryBoldingUtil.getNameWithQueryBolded(query, secondaryInfo, context));
+ workBadge.setVisibility(
+ DirectoryCompat.isOnlyEnterpriseDirectoryId(cursor.getDirectoryId())
+ ? View.VISIBLE
+ : View.GONE);
+
+ if (shouldShowPhoto(cursor)) {
+ nameView.setVisibility(View.VISIBLE);
+ photo.setVisibility(View.VISIBLE);
+ String photoUri = cursor.getString(Projections.PHOTO_URI);
+ ContactPhotoManager.getInstance(context)
+ .loadDialerThumbnailOrPhoto(
+ photo,
+ getContactUri(cursor),
+ cursor.getLong(Projections.PHOTO_ID),
+ photoUri == null ? null : Uri.parse(photoUri),
+ name,
+ LetterTileDrawable.TYPE_DEFAULT);
+ } else {
+ nameView.setVisibility(View.GONE);
+ photo.setVisibility(View.INVISIBLE);
+ }
+ }
+
+ // Show the contact photo next to only the first number if a contact has multiple numbers
+ private boolean shouldShowPhoto(SearchCursor cursor) {
+ int currentPosition = cursor.getPosition();
+ String currentLookupKey = cursor.getString(Projections.LOOKUP_KEY);
+ cursor.moveToPosition(currentPosition - 1);
+
+ if (!cursor.isHeader() && !cursor.isBeforeFirst()) {
+ String previousLookupKey = cursor.getString(Projections.LOOKUP_KEY);
+ cursor.moveToPosition(currentPosition);
+ return !currentLookupKey.equals(previousLookupKey);
+ }
+ cursor.moveToPosition(currentPosition);
+ return true;
+ }
+
+ // TODO(calderwoodra): unify this into a utility method with CallLogAdapter#getNumberType
+ private static String getLabel(Resources resources, Cursor cursor) {
+ int numberType = cursor.getInt(Projections.PHONE_TYPE);
+ String numberLabel = cursor.getString(Projections.PHONE_LABEL);
+
+ // Returns empty label instead of "custom" if the custom label is empty.
+ if (numberType == Phone.TYPE_CUSTOM && TextUtils.isEmpty(numberLabel)) {
+ return "";
+ }
+ return (String) Phone.getTypeLabel(resources, numberType, numberLabel);
+ }
+
+ private static Uri getContactUri(SearchCursor cursor) {
+ long contactId = cursor.getLong(Projections.ID);
+ String lookupKey = cursor.getString(Projections.LOOKUP_KEY);
+ return Contacts.getLookupUri(contactId, lookupKey)
+ .buildUpon()
+ .appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(cursor.getDirectoryId()))
+ .build();
+ }
+
+ @Override
+ public void onClick(View v) {
+ PreCall.start(context, new CallIntentBuilder(number, CallInitiationType.Type.REGULAR_SEARCH));
+ }
+}
diff --git a/java/com/android/dialer/searchfragment/directories/DirectoryContactsCursor.java b/java/com/android/dialer/searchfragment/directories/DirectoryContactsCursor.java
new file mode 100644
index 000000000..bf0bdc057
--- /dev/null
+++ b/java/com/android/dialer/searchfragment/directories/DirectoryContactsCursor.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2017 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.dialer.searchfragment.directories;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MergeCursor;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import com.android.contacts.common.compat.DirectoryCompat;
+import com.android.dialer.common.Assert;
+import com.android.dialer.searchfragment.common.SearchCursor;
+import com.android.dialer.searchfragment.directories.DirectoriesCursorLoader.Directory;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * {@link MergeCursor} used for combining directory cursors into one cursor.
+ *
+ * <p>Usually a device with multiple Google accounts will have multiple directories returned by
+ * {@link DirectoriesCursorLoader}, each represented as a {@link Directory}.
+ *
+ * <p>This cursor merges them together with a header at the start of each cursor/list using {@link
+ * Directory#getDisplayName()} as the header text.
+ */
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+public final class DirectoryContactsCursor extends MergeCursor implements SearchCursor {
+
+ /**
+ * {@link SearchCursor#HEADER_PROJECTION} with {@link #COLUMN_DIRECTORY_ID} appended on the end.
+ *
+ * <p>This is needed to get the directoryId associated with each contact. directoryIds are needed
+ * to load the correct quick contact card.
+ */
+ private static final String[] PROJECTION = buildProjection();
+
+ private static final String COLUMN_DIRECTORY_ID = "directory_id";
+
+ /**
+ * Returns a single cursor with headers inserted between each non-empty cursor. If all cursors are
+ * empty, null or closed, this method returns null.
+ */
+ @Nullable
+ @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+ public static DirectoryContactsCursor newInstance(
+ Context context, Cursor[] cursors, List<Directory> directories) {
+ Assert.checkArgument(
+ cursors.length == directories.size(),
+ "Directories (%d) and cursors (%d) must be the same size.",
+ directories.size(),
+ cursors.length);
+ Cursor[] cursorsWithHeaders = insertHeaders(context, cursors, directories);
+ if (cursorsWithHeaders.length > 0) {
+ return new DirectoryContactsCursor(cursorsWithHeaders);
+ }
+ return null;
+ }
+
+ private DirectoryContactsCursor(Cursor[] cursors) {
+ super(cursors);
+ }
+
+ private static Cursor[] insertHeaders(
+ Context context, Cursor[] cursors, List<Directory> directories) {
+ List<Cursor> cursorList = new ArrayList<>();
+ for (int i = 0; i < cursors.length; i++) {
+ Cursor cursor = cursors[i];
+
+ if (cursor == null || cursor.isClosed()) {
+ continue;
+ }
+
+ Directory directory = directories.get(i);
+ if (cursor.getCount() == 0) {
+ // Since the cursor isn't being merged in, we need to close it here.
+ cursor.close();
+ continue;
+ }
+
+ cursorList.add(createHeaderCursor(context, directory.getDisplayName(), directory.getId()));
+ cursorList.add(cursor);
+ }
+ return cursorList.toArray(new Cursor[cursorList.size()]);
+ }
+
+ private static MatrixCursor createHeaderCursor(Context context, String name, long id) {
+ MatrixCursor headerCursor = new MatrixCursor(PROJECTION, 1);
+ if (DirectoryCompat.isOnlyEnterpriseDirectoryId(id)) {
+ headerCursor.addRow(
+ new Object[] {context.getString(R.string.directory_search_label_work), id});
+ } else {
+ headerCursor.addRow(new Object[] {context.getString(R.string.directory, name), id});
+ }
+ return headerCursor;
+ }
+
+ private static String[] buildProjection() {
+ String[] projection = Arrays.copyOf(HEADER_PROJECTION, HEADER_PROJECTION.length + 1);
+ projection[projection.length - 1] = COLUMN_DIRECTORY_ID;
+ return projection;
+ }
+
+ /** Returns true if the current position is a header row. */
+ @Override
+ public boolean isHeader() {
+ return !isClosed() && getColumnIndex(HEADER_PROJECTION[HEADER_TEXT_POSITION]) != -1;
+ }
+
+ @Override
+ public long getDirectoryId() {
+ int position = getPosition();
+ // proceed backwards until we reach the header row, which contains the directory ID.
+ while (moveToPrevious()) {
+ int columnIndex = getColumnIndex(COLUMN_DIRECTORY_ID);
+ if (columnIndex == -1) {
+ continue;
+ }
+
+ int id = getInt(columnIndex);
+ if (id == -1) {
+ continue;
+ }
+
+ // return the cursor to it's original position/state
+ moveToPosition(position);
+ return id;
+ }
+ throw Assert.createIllegalStateFailException("No directory id for contact at: " + position);
+ }
+
+ @Override
+ public boolean updateQuery(@Nullable String query) {
+ // When the query changes, a new network request is made for nearby places. Meaning this cursor
+ // will be closed and another created, so return false.
+ return false;
+ }
+}
diff --git a/java/com/android/dialer/searchfragment/directories/DirectoryContactsCursorLoader.java b/java/com/android/dialer/searchfragment/directories/DirectoryContactsCursorLoader.java
new file mode 100644
index 000000000..fc36f59bb
--- /dev/null
+++ b/java/com/android/dialer/searchfragment/directories/DirectoryContactsCursorLoader.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2017 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.dialer.searchfragment.directories;
+
+import android.content.Context;
+import android.content.CursorLoader;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import com.android.contacts.common.compat.DirectoryCompat;
+import com.android.dialer.searchfragment.common.Projections;
+import com.android.dialer.searchfragment.directories.DirectoriesCursorLoader.Directory;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Cursor loader to load extended contacts on device.
+ *
+ * <p>This loader performs several database queries in serial and merges the resulting cursors
+ * together into {@link DirectoryContactsCursor}. If there are no results, the loader will return a
+ * null cursor.
+ */
+public final class DirectoryContactsCursorLoader extends CursorLoader {
+
+ private static final Uri ENTERPRISE_CONTENT_FILTER_URI =
+ Uri.withAppendedPath(Phone.CONTENT_URI, "filter_enterprise");
+
+ private static final String IGNORE_NUMBER_TOO_LONG_CLAUSE = "length(" + Phone.NUMBER + ") < 1000";
+ private static final String PHONE_NUMBER_NOT_NULL = Phone.NUMBER + " IS NOT NULL";
+ private static final String MAX_RESULTS = "10";
+
+ private final String query;
+ private final List<Directory> directories;
+ private final Cursor[] cursors;
+
+ public DirectoryContactsCursorLoader(Context context, String query, List<Directory> directories) {
+ super(
+ context,
+ null,
+ Projections.DATA_PROJECTION,
+ IGNORE_NUMBER_TOO_LONG_CLAUSE + " AND " + PHONE_NUMBER_NOT_NULL,
+ null,
+ Phone.SORT_KEY_PRIMARY);
+ this.query = query;
+ this.directories = new ArrayList<>(directories);
+ cursors = new Cursor[directories.size()];
+ }
+
+ @Override
+ public Cursor loadInBackground() {
+ for (int i = 0; i < directories.size(); i++) {
+ Directory directory = directories.get(i);
+
+ // Only load contacts in the enterprise directory & remote directories.
+ if (!DirectoryCompat.isRemoteDirectoryId(directory.getId())
+ && !DirectoryCompat.isEnterpriseDirectoryId(directory.getId())) {
+ cursors[i] = null;
+ continue;
+ }
+
+ // Filter out invisible directories.
+ if (DirectoryCompat.isInvisibleDirectory(directory.getId())) {
+ cursors[i] = null;
+ continue;
+ }
+
+ Cursor cursor =
+ getContext()
+ .getContentResolver()
+ .query(
+ getContentFilterUri(query, directory.getId()),
+ getProjection(),
+ getSelection(),
+ getSelectionArgs(),
+ getSortOrder());
+ // Even though the cursor specifies "WHERE PHONE_NUMBER IS NOT NULL" the Blackberry Hub app's
+ // directory extension doesn't appear to respect it, and sometimes returns a null phone
+ // number. In this case just hide the row entirely. See a bug.
+ cursors[i] = createMatrixCursorFilteringNullNumbers(cursor);
+ }
+ return DirectoryContactsCursor.newInstance(getContext(), cursors, directories);
+ }
+
+ private MatrixCursor createMatrixCursorFilteringNullNumbers(Cursor cursor) {
+ if (cursor == null) {
+ return null;
+ }
+ MatrixCursor matrixCursor = new MatrixCursor(cursor.getColumnNames());
+ try {
+ if (cursor.moveToFirst()) {
+ do {
+ String number = cursor.getString(Projections.PHONE_NUMBER);
+ if (number == null) {
+ continue;
+ }
+ matrixCursor.addRow(objectArrayFromCursor(cursor));
+ } while (cursor.moveToNext());
+ }
+ } finally {
+ cursor.close();
+ }
+ return matrixCursor;
+ }
+
+ @NonNull
+ private static Object[] objectArrayFromCursor(@NonNull Cursor cursor) {
+ Object[] values = new Object[cursor.getColumnCount()];
+ for (int i = 0; i < cursor.getColumnCount(); i++) {
+ int fieldType = cursor.getType(i);
+ if (fieldType == Cursor.FIELD_TYPE_BLOB) {
+ values[i] = cursor.getBlob(i);
+ } else if (fieldType == Cursor.FIELD_TYPE_FLOAT) {
+ values[i] = cursor.getDouble(i);
+ } else if (fieldType == Cursor.FIELD_TYPE_INTEGER) {
+ values[i] = cursor.getLong(i);
+ } else if (fieldType == Cursor.FIELD_TYPE_STRING) {
+ values[i] = cursor.getString(i);
+ } else if (fieldType == Cursor.FIELD_TYPE_NULL) {
+ values[i] = null;
+ } else {
+ throw new IllegalStateException("Unknown fieldType (" + fieldType + ") for column: " + i);
+ }
+ }
+ return values;
+ }
+
+ @VisibleForTesting
+ static Uri getContentFilterUri(String query, long directoryId) {
+ Uri baseUri =
+ VERSION.SDK_INT >= VERSION_CODES.N
+ ? ENTERPRISE_CONTENT_FILTER_URI
+ : Phone.CONTENT_FILTER_URI;
+
+ return baseUri
+ .buildUpon()
+ .appendPath(query)
+ .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId))
+ .appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true")
+ .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, MAX_RESULTS)
+ .build();
+ }
+}
diff --git a/java/com/android/dialer/searchfragment/directories/res/values/strings.xml b/java/com/android/dialer/searchfragment/directories/res/values/strings.xml
new file mode 100644
index 000000000..beabba135
--- /dev/null
+++ b/java/com/android/dialer/searchfragment/directories/res/values/strings.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2017 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
+ -->
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+ <!-- Label for a list of contacts stored in a seperate directory [CHAR LIMIT=30]-->
+ <string name="directory">Directory <xliff:g example="google.com" id="email">%1$s</xliff:g></string>
+</resources> \ No newline at end of file