diff --git a/.gitignore b/.gitignore
index 5c04032..881ae42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@
app/build/
modules/*/build/
modules/*/.git/
+modules/*/sample/
*.apk
*.ap_
*.dex
diff --git a/modules/chips b/modules/chips
deleted file mode 160000
index 7ccb8ba..0000000
--- a/modules/chips
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 7ccb8ba59b168d748edfce5030ab6b01226ef393
diff --git a/modules/chips/Android.mk b/modules/chips/Android.mk
new file mode 100644
index 0000000..5ae29d4
--- /dev/null
+++ b/modules/chips/Android.mk
@@ -0,0 +1,33 @@
+# Copyright (C) 2011 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.
+
+LOCAL_PATH := $(call my-dir)
+
+include $(CLEAR_VARS)
+LOCAL_MODULE := libchips
+LOCAL_STATIC_ANDROID_LIBRARIES := \
+ android-support-compat \
+ android-support-annotations
+LOCAL_SDK_VERSION := current
+LOCAL_SRC_FILES := \
+ $(call all-java-files-under, src) \
+ $(call all-logtags-files-under, src)
+LOCAL_USE_AAPT2 := true
+LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res
+include $(BUILD_STATIC_JAVA_LIBRARY)
+
+##################################################
+# Build all sub-directories
+
+include $(call all-makefiles-under,$(LOCAL_PATH))
diff --git a/modules/chips/AndroidManifest.xml b/modules/chips/AndroidManifest.xml
new file mode 100644
index 0000000..86d52b5
--- /dev/null
+++ b/modules/chips/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/modules/chips/build.gradle b/modules/chips/build.gradle
new file mode 100644
index 0000000..120a389
--- /dev/null
+++ b/modules/chips/build.gradle
@@ -0,0 +1,31 @@
+plugins {
+ id 'com.android.library'
+}
+
+android {
+ namespace 'com.android.ex.chips'
+ compileSdk 35
+
+ defaultConfig {
+ minSdk 33
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src']
+ res.srcDirs = ['res']
+ }
+ }
+}
+
+dependencies {
+ implementation 'androidx.annotation:annotation:1.8.2'
+ implementation 'androidx.core:core:1.15.0'
+ implementation 'androidx.collection:collection:1.4.4'
+}
diff --git a/modules/chips/project.properties b/modules/chips/project.properties
new file mode 100644
index 0000000..91d2b02
--- /dev/null
+++ b/modules/chips/project.properties
@@ -0,0 +1,15 @@
+# This file is automatically generated by Android Tools.
+# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
+#
+# This file must be checked in Version Control Systems.
+#
+# To customize properties used by the Ant build system edit
+# "ant.properties", and override values to adapt the script to your
+# project structure.
+#
+# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home):
+#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt
+
+# Project target.
+target=android-19
+android.library=true
diff --git a/modules/chips/res/color/chips_dropdown_text.xml b/modules/chips/res/color/chips_dropdown_text.xml
new file mode 100644
index 0000000..7d333bc
--- /dev/null
+++ b/modules/chips/res/color/chips_dropdown_text.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/chips/res/drawable-hdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-hdpi/chips_ic_close_24dp.png
new file mode 100644
index 0000000..9ab350e
Binary files /dev/null and b/modules/chips/res/drawable-hdpi/chips_ic_close_24dp.png differ
diff --git a/modules/chips/res/drawable-hdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-hdpi/ic_cancel_wht_24dp.png
new file mode 100644
index 0000000..7dac7b0
Binary files /dev/null and b/modules/chips/res/drawable-hdpi/ic_cancel_wht_24dp.png differ
diff --git a/modules/chips/res/drawable-hdpi/ic_contact_picture.png b/modules/chips/res/drawable-hdpi/ic_contact_picture.png
new file mode 100644
index 0000000..4c0e35e
Binary files /dev/null and b/modules/chips/res/drawable-hdpi/ic_contact_picture.png differ
diff --git a/modules/chips/res/drawable-mdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-mdpi/chips_ic_close_24dp.png
new file mode 100644
index 0000000..73faf52
Binary files /dev/null and b/modules/chips/res/drawable-mdpi/chips_ic_close_24dp.png differ
diff --git a/modules/chips/res/drawable-mdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-mdpi/ic_cancel_wht_24dp.png
new file mode 100644
index 0000000..48343c9
Binary files /dev/null and b/modules/chips/res/drawable-mdpi/ic_cancel_wht_24dp.png differ
diff --git a/modules/chips/res/drawable-mdpi/ic_contact_picture.png b/modules/chips/res/drawable-mdpi/ic_contact_picture.png
new file mode 100644
index 0000000..ead9718
Binary files /dev/null and b/modules/chips/res/drawable-mdpi/ic_contact_picture.png differ
diff --git a/modules/chips/res/drawable-xhdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-xhdpi/chips_ic_close_24dp.png
new file mode 100644
index 0000000..a3896c5
Binary files /dev/null and b/modules/chips/res/drawable-xhdpi/chips_ic_close_24dp.png differ
diff --git a/modules/chips/res/drawable-xhdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-xhdpi/ic_cancel_wht_24dp.png
new file mode 100644
index 0000000..3dc3615
Binary files /dev/null and b/modules/chips/res/drawable-xhdpi/ic_cancel_wht_24dp.png differ
diff --git a/modules/chips/res/drawable-xhdpi/ic_contact_picture.png b/modules/chips/res/drawable-xhdpi/ic_contact_picture.png
new file mode 100644
index 0000000..05a65f6
Binary files /dev/null and b/modules/chips/res/drawable-xhdpi/ic_contact_picture.png differ
diff --git a/modules/chips/res/drawable-xxhdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-xxhdpi/chips_ic_close_24dp.png
new file mode 100644
index 0000000..22d7aa5
Binary files /dev/null and b/modules/chips/res/drawable-xxhdpi/chips_ic_close_24dp.png differ
diff --git a/modules/chips/res/drawable-xxhdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-xxhdpi/ic_cancel_wht_24dp.png
new file mode 100644
index 0000000..2ba1978
Binary files /dev/null and b/modules/chips/res/drawable-xxhdpi/ic_cancel_wht_24dp.png differ
diff --git a/modules/chips/res/drawable-xxhdpi/ic_contact_picture.png b/modules/chips/res/drawable-xxhdpi/ic_contact_picture.png
new file mode 100644
index 0000000..05a65f6
Binary files /dev/null and b/modules/chips/res/drawable-xxhdpi/ic_contact_picture.png differ
diff --git a/modules/chips/res/drawable-xxxhdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-xxxhdpi/chips_ic_close_24dp.png
new file mode 100644
index 0000000..7d1c061
Binary files /dev/null and b/modules/chips/res/drawable-xxxhdpi/chips_ic_close_24dp.png differ
diff --git a/modules/chips/res/drawable-xxxhdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-xxxhdpi/ic_cancel_wht_24dp.png
new file mode 100644
index 0000000..89ad2b5
Binary files /dev/null and b/modules/chips/res/drawable-xxxhdpi/ic_cancel_wht_24dp.png differ
diff --git a/modules/chips/res/drawable/chip_autocomplete_divider_background.xml b/modules/chips/res/drawable/chip_autocomplete_divider_background.xml
new file mode 100644
index 0000000..c013b57
--- /dev/null
+++ b/modules/chips/res/drawable/chip_autocomplete_divider_background.xml
@@ -0,0 +1,6 @@
+
+
+
\ No newline at end of file
diff --git a/modules/chips/res/drawable/chips_dropdown_background.xml b/modules/chips/res/drawable/chips_dropdown_background.xml
new file mode 100644
index 0000000..6a7eeec
--- /dev/null
+++ b/modules/chips/res/drawable/chips_dropdown_background.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/chips/res/drawable/list_item_font_primary.xml b/modules/chips/res/drawable/list_item_font_primary.xml
new file mode 100644
index 0000000..4351905
--- /dev/null
+++ b/modules/chips/res/drawable/list_item_font_primary.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/chips/res/drawable/list_item_font_secondary.xml b/modules/chips/res/drawable/list_item_font_secondary.xml
new file mode 100644
index 0000000..78c4066
--- /dev/null
+++ b/modules/chips/res/drawable/list_item_font_secondary.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/chips/res/layout/chips_autocomplete_recipient_dropdown_item.xml b/modules/chips/res/layout/chips_autocomplete_recipient_dropdown_item.xml
new file mode 100644
index 0000000..127be4a
--- /dev/null
+++ b/modules/chips/res/layout/chips_autocomplete_recipient_dropdown_item.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/chips/res/layout/chips_recipient_dropdown_item.xml b/modules/chips/res/layout/chips_recipient_dropdown_item.xml
new file mode 100644
index 0000000..7cf0357
--- /dev/null
+++ b/modules/chips/res/layout/chips_recipient_dropdown_item.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/chips/res/layout/more_item.xml b/modules/chips/res/layout/more_item.xml
new file mode 100644
index 0000000..20693d6
--- /dev/null
+++ b/modules/chips/res/layout/more_item.xml
@@ -0,0 +1,25 @@
+
+
+
\ No newline at end of file
diff --git a/modules/chips/res/values-land/dimen.xml b/modules/chips/res/values-land/dimen.xml
new file mode 100644
index 0000000..1ee6608
--- /dev/null
+++ b/modules/chips/res/values-land/dimen.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ 26dip
+
\ No newline at end of file
diff --git a/modules/chips/res/values-sw600dp/styles.xml b/modules/chips/res/values-sw600dp/styles.xml
new file mode 100644
index 0000000..5ca5ad9
--- /dev/null
+++ b/modules/chips/res/values-sw600dp/styles.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/modules/chips/res/values-v17/styles.xml b/modules/chips/res/values-v17/styles.xml
new file mode 100644
index 0000000..0efaf62
--- /dev/null
+++ b/modules/chips/res/values-v17/styles.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/chips/res/values/attrs.xml b/modules/chips/res/values/attrs.xml
new file mode 100644
index 0000000..ddc69c7
--- /dev/null
+++ b/modules/chips/res/values/attrs.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/chips/res/values/colors.xml b/modules/chips/res/values/colors.xml
new file mode 100644
index 0000000..1f32b60
--- /dev/null
+++ b/modules/chips/res/values/colors.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+ #4285f4
+ #ededed
+
+ #cccccc
+ #40000000
+
+ #f6f6f6
+ #db4437
+
+ #212121
+ @android:color/white
+ #757575
+ #4285f4
+
+
diff --git a/modules/chips/res/values/dimen.xml b/modules/chips/res/values/dimen.xml
new file mode 100644
index 0000000..6c56b30
--- /dev/null
+++ b/modules/chips/res/values/dimen.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ -1dp
+ 12dp
+
+
+ 8dp
+ 32dp
+ 14sp
+ 3dp
+ -1
+
+
+ 12dp
+ 72dp
+ 0.5dp
+ 8dp
+
+ 16dp
+ 16dp
+ 16dp
+ 16dp
+
diff --git a/modules/chips/res/values/strings.xml b/modules/chips/res/values/strings.xml
new file mode 100644
index 0000000..6929417
--- /dev/null
+++ b/modules/chips/res/values/strings.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ \u002B%1$d
+
+ Copy email address
+
+ Copy phone number
+
+ Return
+
+ Contact suggestions opened
+
+ No suggestions available
+
+ Remove %s from recipients
+
+ Copy
+
+ Cancel
+
+ Allow contacts suggestions
+
diff --git a/modules/chips/res/values/styles.xml b/modules/chips/res/values/styles.xml
new file mode 100644
index 0000000..60536ac
--- /dev/null
+++ b/modules/chips/res/values/styles.xml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/chips/src/com/android/ex/chips/AccountSpecifier.java b/modules/chips/src/com/android/ex/chips/AccountSpecifier.java
new file mode 100644
index 0000000..5eb8314
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/AccountSpecifier.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.accounts.Account;
+
+/**
+ * The AccountSpecificAdapter interface describes an Adapter
+ * that can take an account to retrieve information tied to
+ * a specific account.
+ */
+public interface AccountSpecifier {
+ public void setAccount(Account account);
+}
diff --git a/modules/chips/src/com/android/ex/chips/BaseRecipientAdapter.java b/modules/chips/src/com/android/ex/chips/BaseRecipientAdapter.java
new file mode 100644
index 0000000..72f42f2
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/BaseRecipientAdapter.java
@@ -0,0 +1,1034 @@
+/*
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.accounts.Account;
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Message;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Directory;
+import androidx.annotation.Nullable;
+import android.text.TextUtils;
+import android.text.util.Rfc822Token;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AutoCompleteTextView;
+import android.widget.BaseAdapter;
+import android.widget.Filter;
+import android.widget.Filterable;
+
+import com.android.ex.chips.ChipsUtil.PermissionsCheckListener;
+import com.android.ex.chips.DropdownChipLayouter.AdapterType;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Adapter for showing a recipient list.
+ *
+ *
It checks whether all permissions are granted before doing
+ * query. If not all permissions in {@link ChipsUtil#REQUIRED_PERMISSIONS} are granted and
+ * {@link #mShowRequestPermissionsItem} is true it will return single entry that asks user to grant
+ * permissions to the app. Any app that uses this library should set this when it wants us to
+ * display that entry but then it should set
+ * {@link RecipientEditTextView.PermissionsRequestItemClickedListener} on
+ * {@link RecipientEditTextView} as well.
+ */
+public class BaseRecipientAdapter extends BaseAdapter implements Filterable, AccountSpecifier,
+ PhotoManager.PhotoManagerCallback {
+ private static final String TAG = "BaseRecipientAdapter";
+
+ private static final boolean DEBUG = false;
+
+ /**
+ * The preferred number of results to be retrieved. This number may be
+ * exceeded if there are several directories configured, because we will use
+ * the same limit for all directories.
+ */
+ private static final int DEFAULT_PREFERRED_MAX_RESULT_COUNT = 10;
+
+ /**
+ * The number of extra entries requested to allow for duplicates. Duplicates
+ * are removed from the overall result.
+ */
+ static final int ALLOWANCE_FOR_DUPLICATES = 5;
+
+ // This is ContactsContract.PRIMARY_ACCOUNT_NAME. Available from ICS as hidden
+ static final String PRIMARY_ACCOUNT_NAME = "name_for_primary_account";
+ // This is ContactsContract.PRIMARY_ACCOUNT_TYPE. Available from ICS as hidden
+ static final String PRIMARY_ACCOUNT_TYPE = "type_for_primary_account";
+
+ /**
+ * The "Waiting for more contacts" message will be displayed if search is not complete
+ * within this many milliseconds.
+ */
+ private static final int MESSAGE_SEARCH_PENDING_DELAY = 1000;
+ /** Used to prepare "Waiting for more contacts" message. */
+ private static final int MESSAGE_SEARCH_PENDING = 1;
+
+ public static final int QUERY_TYPE_EMAIL = 0;
+ public static final int QUERY_TYPE_PHONE = 1;
+
+ private final Queries.Query mQueryMode;
+ private final int mQueryType;
+
+ /**
+ * Model object for a {@link Directory} row.
+ */
+ public final static class DirectorySearchParams {
+ public long directoryId;
+ public String directoryType;
+ public String displayName;
+ public String accountName;
+ public String accountType;
+ public CharSequence constraint;
+ public DirectoryFilter filter;
+ }
+
+ protected static class DirectoryListQuery {
+
+ public static final Uri URI =
+ Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories");
+ public static final String[] PROJECTION = {
+ Directory._ID, // 0
+ Directory.ACCOUNT_NAME, // 1
+ Directory.ACCOUNT_TYPE, // 2
+ Directory.DISPLAY_NAME, // 3
+ Directory.PACKAGE_NAME, // 4
+ Directory.TYPE_RESOURCE_ID, // 5
+ };
+
+ public static final int ID = 0;
+ public static final int ACCOUNT_NAME = 1;
+ public static final int ACCOUNT_TYPE = 2;
+ public static final int DISPLAY_NAME = 3;
+ public static final int PACKAGE_NAME = 4;
+ public static final int TYPE_RESOURCE_ID = 5;
+ }
+
+ /** Used to temporarily hold results in Cursor objects. */
+ protected static class TemporaryEntry {
+ public final String displayName;
+ public final String destination;
+ public final int destinationType;
+ public final String destinationLabel;
+ public final long contactId;
+ public final Long directoryId;
+ public final long dataId;
+ public final String thumbnailUriString;
+ public final int displayNameSource;
+ public final String lookupKey;
+
+ public TemporaryEntry(
+ String displayName,
+ String destination,
+ int destinationType,
+ String destinationLabel,
+ long contactId,
+ Long directoryId,
+ long dataId,
+ String thumbnailUriString,
+ int displayNameSource,
+ String lookupKey) {
+ this.displayName = displayName;
+ this.destination = destination;
+ this.destinationType = destinationType;
+ this.destinationLabel = destinationLabel;
+ this.contactId = contactId;
+ this.directoryId = directoryId;
+ this.dataId = dataId;
+ this.thumbnailUriString = thumbnailUriString;
+ this.displayNameSource = displayNameSource;
+ this.lookupKey = lookupKey;
+ }
+
+ public TemporaryEntry(Cursor cursor, Long directoryId) {
+ this.displayName = cursor.getString(Queries.Query.NAME);
+ this.destination = cursor.getString(Queries.Query.DESTINATION);
+ this.destinationType = cursor.getInt(Queries.Query.DESTINATION_TYPE);
+ this.destinationLabel = cursor.getString(Queries.Query.DESTINATION_LABEL);
+ this.contactId = cursor.getLong(Queries.Query.CONTACT_ID);
+ this.directoryId = directoryId;
+ this.dataId = cursor.getLong(Queries.Query.DATA_ID);
+ this.thumbnailUriString = cursor.getString(Queries.Query.PHOTO_THUMBNAIL_URI);
+ this.displayNameSource = cursor.getInt(Queries.Query.DISPLAY_NAME_SOURCE);
+ this.lookupKey = cursor.getString(Queries.Query.LOOKUP_KEY);
+ }
+ }
+
+ /**
+ * Used to pass results from {@link DefaultFilter#performFiltering(CharSequence)} to
+ * {@link DefaultFilter#publishResults(CharSequence, android.widget.Filter.FilterResults)}
+ */
+ private static class DefaultFilterResult {
+ public final List entries;
+ public final LinkedHashMap> entryMap;
+ public final List nonAggregatedEntries;
+ public final Set existingDestinations;
+ public final List paramsList;
+
+ public DefaultFilterResult(List entries,
+ LinkedHashMap> entryMap,
+ List nonAggregatedEntries,
+ Set existingDestinations,
+ List paramsList) {
+ this.entries = entries;
+ this.entryMap = entryMap;
+ this.nonAggregatedEntries = nonAggregatedEntries;
+ this.existingDestinations = existingDestinations;
+ this.paramsList = paramsList;
+ }
+
+ private static DefaultFilterResult createResultWithNonAggregatedEntry(
+ RecipientEntry entry) {
+ return new DefaultFilterResult(
+ Collections.singletonList(entry),
+ new LinkedHashMap>() /* entryMap */,
+ Collections.singletonList(entry) /* nonAggregatedEntries */,
+ Collections.emptySet() /* existingDestinations */,
+ null /* paramsList */);
+ }
+ }
+
+ /**
+ * An asynchronous filter used for loading two data sets: email rows from the local
+ * contact provider and the list of {@link Directory}'s.
+ */
+ private final class DefaultFilter extends Filter {
+
+ @Override
+ protected FilterResults performFiltering(CharSequence constraint) {
+ if (DEBUG) {
+ Log.d(TAG, "start filtering. constraint: " + constraint + ", thread:"
+ + Thread.currentThread());
+ }
+
+ final FilterResults results = new FilterResults();
+
+ if (TextUtils.isEmpty(constraint)) {
+ clearTempEntries();
+ // Return empty results.
+ return results;
+ }
+
+ if (!ChipsUtil.hasPermissions(mContext, mPermissionsCheckListener)) {
+ if (DEBUG) {
+ Log.d(TAG, "No Contacts permission. mShowRequestPermissionsItem: "
+ + mShowRequestPermissionsItem);
+ }
+ clearTempEntries();
+ if (!mShowRequestPermissionsItem) {
+ // App doesn't want to show request permission entry. Returning empty results.
+ return results;
+ }
+
+ // Return result with only permission request entry.
+ results.values = DefaultFilterResult.createResultWithNonAggregatedEntry(
+ RecipientEntry.constructPermissionEntry(ChipsUtil.REQUIRED_PERMISSIONS));
+ results.count = 1;
+ return results;
+ }
+
+ Cursor defaultDirectoryCursor = null;
+
+ try {
+ defaultDirectoryCursor = doQuery(constraint, mPreferredMaxResultCount,
+ null /* directoryId */);
+
+ if (defaultDirectoryCursor == null) {
+ if (DEBUG) {
+ Log.w(TAG, "null cursor returned for default Email filter query.");
+ }
+ } else {
+ // These variables will become mEntries, mEntryMap, mNonAggregatedEntries, and
+ // mExistingDestinations. Here we shouldn't use those member variables directly
+ // since this method is run outside the UI thread.
+ final LinkedHashMap> entryMap =
+ new LinkedHashMap>();
+ final List nonAggregatedEntries =
+ new ArrayList();
+ final Set existingDestinations = new HashSet();
+
+ while (defaultDirectoryCursor.moveToNext()) {
+ // Note: At this point each entry doesn't contain any photo
+ // (thus getPhotoBytes() returns null).
+ putOneEntry(new TemporaryEntry(defaultDirectoryCursor,
+ null /* directoryId */),
+ true, entryMap, nonAggregatedEntries, existingDestinations);
+ }
+
+ // We'll copy this result to mEntry in publicResults() (run in the UX thread).
+ final List entries = constructEntryList(
+ entryMap, nonAggregatedEntries);
+
+ final List paramsList =
+ searchOtherDirectories(existingDestinations);
+
+ results.values = new DefaultFilterResult(
+ entries, entryMap, nonAggregatedEntries,
+ existingDestinations, paramsList);
+ results.count = entries.size();
+ }
+ } finally {
+ if (defaultDirectoryCursor != null) {
+ defaultDirectoryCursor.close();
+ }
+ }
+ return results;
+ }
+
+ @Override
+ protected void publishResults(final CharSequence constraint, FilterResults results) {
+ mCurrentConstraint = constraint;
+
+ clearTempEntries();
+
+ if (results.values != null) {
+ DefaultFilterResult defaultFilterResult = (DefaultFilterResult) results.values;
+ mEntryMap = defaultFilterResult.entryMap;
+ mNonAggregatedEntries = defaultFilterResult.nonAggregatedEntries;
+ mExistingDestinations = defaultFilterResult.existingDestinations;
+
+ cacheCurrentEntriesIfNeeded(defaultFilterResult.entries.size(),
+ defaultFilterResult.paramsList == null ? 0 :
+ defaultFilterResult.paramsList.size());
+
+ updateEntries(defaultFilterResult.entries);
+
+ // We need to search other remote directories, doing other Filter requests.
+ if (defaultFilterResult.paramsList != null) {
+ final int limit = mPreferredMaxResultCount -
+ defaultFilterResult.existingDestinations.size();
+ startSearchOtherDirectories(constraint, defaultFilterResult.paramsList, limit);
+ }
+ } else {
+ updateEntries(Collections.emptyList());
+ }
+ }
+
+ @Override
+ public CharSequence convertResultToString(Object resultValue) {
+ final RecipientEntry entry = (RecipientEntry)resultValue;
+ final String displayName = entry.getDisplayName();
+ final String emailAddress = entry.getDestination();
+ if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) {
+ return emailAddress;
+ } else {
+ return new Rfc822Token(displayName, emailAddress, null).toString();
+ }
+ }
+ }
+
+ /**
+ * Returns the list of models for directory search (using {@link DirectoryFilter}) or
+ * {@code null} when we don't need or can't search other directories.
+ */
+ protected List searchOtherDirectories(Set existingDestinations) {
+ if (!ChipsUtil.hasPermissions(mContext, mPermissionsCheckListener)) {
+ // If we don't have permissions we can't search other directories.
+ if (DEBUG) {
+ Log.d(TAG, "Not searching other directories because we don't have required "
+ + "permissions.");
+ }
+ return null;
+ }
+
+ // After having local results, check the size of results. If the results are
+ // not enough, we search remote directories, which will take longer time.
+ final int limit = mPreferredMaxResultCount - existingDestinations.size();
+ if (limit > 0) {
+ if (DEBUG) {
+ Log.d(TAG, "More entries should be needed (current: "
+ + existingDestinations.size()
+ + ", remaining limit: " + limit + ") ");
+ }
+ Cursor directoryCursor = null;
+ try {
+ directoryCursor = mContentResolver.query(
+ DirectoryListQuery.URI, DirectoryListQuery.PROJECTION,
+ null, null, null);
+ return setupOtherDirectories(mContext, directoryCursor, mAccount);
+ } finally {
+ if (directoryCursor != null) {
+ directoryCursor.close();
+ }
+ }
+ } else {
+ // We don't need to search other directories.
+ return null;
+ }
+ }
+
+ /**
+ * An asynchronous filter that performs search in a particular directory.
+ */
+ protected class DirectoryFilter extends Filter {
+ private final DirectorySearchParams mParams;
+ private int mLimit;
+
+ public DirectoryFilter(DirectorySearchParams params) {
+ mParams = params;
+ }
+
+ public synchronized void setLimit(int limit) {
+ this.mLimit = limit;
+ }
+
+ public synchronized int getLimit() {
+ return this.mLimit;
+ }
+
+ @Override
+ protected FilterResults performFiltering(CharSequence constraint) {
+ if (DEBUG) {
+ Log.d(TAG, "DirectoryFilter#performFiltering. directoryId: " + mParams.directoryId
+ + ", constraint: " + constraint + ", thread: " + Thread.currentThread());
+ }
+ final FilterResults results = new FilterResults();
+ results.values = null;
+ results.count = 0;
+
+ if (!TextUtils.isEmpty(constraint)) {
+ final ArrayList tempEntries = new ArrayList();
+
+ Cursor cursor = null;
+ try {
+ // We don't want to pass this Cursor object to UI thread (b/5017608).
+ // Assuming the result should contain fairly small results (at most ~10),
+ // We just copy everything to local structure.
+ cursor = doQuery(constraint, getLimit(), mParams.directoryId);
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ tempEntries.add(new TemporaryEntry(cursor, mParams.directoryId));
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ if (!tempEntries.isEmpty()) {
+ results.values = tempEntries;
+ results.count = tempEntries.size();
+ }
+ }
+
+ if (DEBUG) {
+ Log.v(TAG, "finished loading directory \"" + mParams.displayName + "\"" +
+ " with query " + constraint);
+ }
+
+ return results;
+ }
+
+ @Override
+ protected void publishResults(final CharSequence constraint, FilterResults results) {
+ if (DEBUG) {
+ Log.d(TAG, "DirectoryFilter#publishResult. constraint: " + constraint
+ + ", mCurrentConstraint: " + mCurrentConstraint);
+ }
+ mDelayedMessageHandler.removeDelayedLoadMessage();
+ // Check if the received result matches the current constraint
+ // If not - the user must have continued typing after the request was issued, which
+ // means several member variables (like mRemainingDirectoryLoad) are already
+ // overwritten so shouldn't be touched here anymore.
+ if (TextUtils.equals(constraint, mCurrentConstraint)) {
+ if (results.count > 0) {
+ @SuppressWarnings("unchecked")
+ final ArrayList tempEntries =
+ (ArrayList) results.values;
+
+ for (TemporaryEntry tempEntry : tempEntries) {
+ putOneEntry(tempEntry, mParams.directoryId == Directory.DEFAULT);
+ }
+ }
+
+ // If there are remaining directories, set up delayed message again.
+ mRemainingDirectoryCount--;
+ if (mRemainingDirectoryCount > 0) {
+ if (DEBUG) {
+ Log.d(TAG, "Resend delayed load message. Current mRemainingDirectoryLoad: "
+ + mRemainingDirectoryCount);
+ }
+ mDelayedMessageHandler.sendDelayedLoadMessage();
+ }
+
+ // If this directory result has some items, or there are no more directories that
+ // we are waiting for, clear the temp results
+ if (results.count > 0 || mRemainingDirectoryCount == 0) {
+ // Clear the temp entries
+ clearTempEntries();
+ }
+ }
+
+ // Show the list again without "waiting" message.
+ updateEntries(constructEntryList());
+ }
+ }
+
+ private final Context mContext;
+ private final ContentResolver mContentResolver;
+ private Account mAccount;
+ protected final int mPreferredMaxResultCount;
+ private DropdownChipLayouter mDropdownChipLayouter;
+
+ /**
+ * {@link #mEntries} is responsible for showing every result for this Adapter. To
+ * construct it, we use {@link #mEntryMap}, {@link #mNonAggregatedEntries}, and
+ * {@link #mExistingDestinations}.
+ *
+ * First, each destination (an email address or a phone number) with a valid contactId is
+ * inserted into {@link #mEntryMap} and grouped by the contactId. Destinations without valid
+ * contactId (possible if they aren't in local storage) are stored in
+ * {@link #mNonAggregatedEntries}.
+ * Duplicates are removed using {@link #mExistingDestinations}.
+ *
+ * After having all results from Cursor objects, all destinations in mEntryMap are copied to
+ * {@link #mEntries}. If the number of destinations is not enough (i.e. less than
+ * {@link #mPreferredMaxResultCount}), destinations in mNonAggregatedEntries are also used.
+ *
+ * These variables are only used in UI thread, thus should not be touched in
+ * performFiltering() methods.
+ */
+ private LinkedHashMap> mEntryMap;
+ private List mNonAggregatedEntries;
+ private Set mExistingDestinations;
+ /** Note: use {@link #updateEntries(List)} to update this variable. */
+ private List mEntries;
+ private List mTempEntries;
+
+ /** The number of directories this adapter is waiting for results. */
+ private int mRemainingDirectoryCount;
+
+ /**
+ * Used to ignore asynchronous queries with a different constraint, which may happen when
+ * users type characters quickly.
+ */
+ protected CharSequence mCurrentConstraint;
+
+ /**
+ * Performs all photo querying as well as caching for repeated lookups.
+ */
+ private PhotoManager mPhotoManager;
+
+ protected boolean mShowRequestPermissionsItem;
+
+ private PermissionsCheckListener mPermissionsCheckListener;
+
+ /**
+ * Handler specific for maintaining "Waiting for more contacts" message, which will be shown
+ * when:
+ * - there are directories to be searched
+ * - results from directories are slow to come
+ */
+ private final class DelayedMessageHandler extends Handler {
+ @Override
+ public void handleMessage(Message msg) {
+ if (mRemainingDirectoryCount > 0) {
+ updateEntries(constructEntryList());
+ }
+ }
+
+ public void sendDelayedLoadMessage() {
+ sendMessageDelayed(obtainMessage(MESSAGE_SEARCH_PENDING, 0, 0, null),
+ MESSAGE_SEARCH_PENDING_DELAY);
+ }
+
+ public void removeDelayedLoadMessage() {
+ removeMessages(MESSAGE_SEARCH_PENDING);
+ }
+ }
+
+ private final DelayedMessageHandler mDelayedMessageHandler = new DelayedMessageHandler();
+
+ private EntriesUpdatedObserver mEntriesUpdatedObserver;
+
+ /**
+ * Constructor for email queries.
+ */
+ public BaseRecipientAdapter(Context context) {
+ this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, QUERY_TYPE_EMAIL);
+ }
+
+ public BaseRecipientAdapter(Context context, int preferredMaxResultCount) {
+ this(context, preferredMaxResultCount, QUERY_TYPE_EMAIL);
+ }
+
+ public BaseRecipientAdapter(int queryMode, Context context) {
+ this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, queryMode);
+ }
+
+ public BaseRecipientAdapter(int queryMode, Context context, int preferredMaxResultCount) {
+ this(context, preferredMaxResultCount, queryMode);
+ }
+
+ public BaseRecipientAdapter(Context context, int preferredMaxResultCount, int queryMode) {
+ mContext = context;
+ mContentResolver = context.getContentResolver();
+ mPreferredMaxResultCount = preferredMaxResultCount;
+ mPhotoManager = new DefaultPhotoManager(mContentResolver);
+ mQueryType = queryMode;
+
+ if (queryMode == QUERY_TYPE_EMAIL) {
+ mQueryMode = Queries.EMAIL;
+ } else if (queryMode == QUERY_TYPE_PHONE) {
+ mQueryMode = Queries.PHONE;
+ } else {
+ mQueryMode = Queries.EMAIL;
+ Log.e(TAG, "Unsupported query type: " + queryMode);
+ }
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ public int getQueryType() {
+ return mQueryType;
+ }
+
+ public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) {
+ mDropdownChipLayouter = dropdownChipLayouter;
+ mDropdownChipLayouter.setQuery(mQueryMode);
+ }
+
+ public DropdownChipLayouter getDropdownChipLayouter() {
+ return mDropdownChipLayouter;
+ }
+
+ public void setPermissionsCheckListener(PermissionsCheckListener permissionsCheckListener) {
+ mPermissionsCheckListener = permissionsCheckListener;
+ }
+
+ @Nullable
+ public PermissionsCheckListener getPermissionsCheckListener() {
+ return mPermissionsCheckListener;
+ }
+
+ /**
+ * Enables overriding the default photo manager that is used.
+ */
+ public void setPhotoManager(PhotoManager photoManager) {
+ mPhotoManager = photoManager;
+ }
+
+ public PhotoManager getPhotoManager() {
+ return mPhotoManager;
+ }
+
+ /**
+ * If true, forces using the {@link com.android.ex.chips.SingleRecipientArrayAdapter}
+ * instead of {@link com.android.ex.chips.RecipientAlternatesAdapter} when
+ * clicking on a chip. Default implementation returns {@code false}.
+ */
+ public boolean forceShowAddress() {
+ return false;
+ }
+
+ /**
+ * Used to replace email addresses with chips. Default behavior
+ * queries the ContactsProvider for contact information about the contact.
+ * Derived classes should override this method if they wish to use a
+ * new data source.
+ * @param inAddresses addresses to query
+ * @param callback callback to return results in case of success or failure
+ */
+ public void getMatchingRecipients(ArrayList inAddresses,
+ RecipientAlternatesAdapter.RecipientMatchCallback callback) {
+ RecipientAlternatesAdapter.getMatchingRecipients(
+ getContext(), this, inAddresses, getAccount(), callback, mPermissionsCheckListener);
+ }
+
+ /**
+ * Set the account when known. Causes the search to prioritize contacts from that account.
+ */
+ @Override
+ public void setAccount(Account account) {
+ mAccount = account;
+ }
+
+ /**
+ * Returns permissions that this adapter needs in order to provide results.
+ */
+ public String[] getRequiredPermissions() {
+ return ChipsUtil.REQUIRED_PERMISSIONS;
+ }
+
+ /**
+ * Sets whether to ask user to grant permission if they are missing.
+ */
+ public void setShowRequestPermissionsItem(boolean show) {
+ mShowRequestPermissionsItem = show;
+ }
+
+ /** Will be called from {@link AutoCompleteTextView} to prepare auto-complete list. */
+ @Override
+ public Filter getFilter() {
+ return new DefaultFilter();
+ }
+
+ /**
+ * An extension to {@link RecipientAlternatesAdapter#getMatchingRecipients} that allows
+ * additional sources of contacts to be considered as matching recipients.
+ * @param addresses A set of addresses to be matched
+ * @return A list of matches or null if none found
+ */
+ public Map getMatchingRecipients(Set addresses) {
+ return null;
+ }
+
+ public static List setupOtherDirectories(Context context,
+ Cursor directoryCursor, Account account) {
+ final PackageManager packageManager = context.getPackageManager();
+ final List paramsList = new ArrayList();
+ DirectorySearchParams preferredDirectory = null;
+ while (directoryCursor.moveToNext()) {
+ final long id = directoryCursor.getLong(DirectoryListQuery.ID);
+
+ // Skip the local invisible directory, because the default directory already includes
+ // all local results.
+ if (id == Directory.LOCAL_INVISIBLE) {
+ continue;
+ }
+
+ final DirectorySearchParams params = new DirectorySearchParams();
+ final String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME);
+ final int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID);
+ params.directoryId = id;
+ params.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME);
+ params.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME);
+ params.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE);
+ if (packageName != null && resourceId != 0) {
+ try {
+ final Resources resources =
+ packageManager.getResourcesForApplication(packageName);
+ params.directoryType = resources.getString(resourceId);
+ if (params.directoryType == null) {
+ Log.e(TAG, "Cannot resolve directory name: "
+ + resourceId + "@" + packageName);
+ }
+ } catch (NameNotFoundException e) {
+ Log.e(TAG, "Cannot resolve directory name: "
+ + resourceId + "@" + packageName, e);
+ }
+ }
+
+ // If an account has been provided and we found a directory that
+ // corresponds to that account, place that directory second, directly
+ // underneath the local contacts.
+ if (preferredDirectory == null && account != null
+ && account.name.equals(params.accountName)
+ && account.type.equals(params.accountType)) {
+ preferredDirectory = params;
+ } else {
+ paramsList.add(params);
+ }
+ }
+
+ if (preferredDirectory != null) {
+ paramsList.add(1, preferredDirectory);
+ }
+
+ return paramsList;
+ }
+
+ /**
+ * Starts search in other directories using {@link Filter}. Results will be handled in
+ * {@link DirectoryFilter}.
+ */
+ protected void startSearchOtherDirectories(
+ CharSequence constraint, List paramsList, int limit) {
+ final int count = paramsList.size();
+ // Note: skipping the default partition (index 0), which has already been loaded
+ for (int i = 1; i < count; i++) {
+ final DirectorySearchParams params = paramsList.get(i);
+ params.constraint = constraint;
+ if (params.filter == null) {
+ params.filter = new DirectoryFilter(params);
+ }
+ params.filter.setLimit(limit);
+ params.filter.filter(constraint);
+ }
+
+ // Directory search started. We may show "waiting" message if directory results are slow
+ // enough.
+ mRemainingDirectoryCount = count - 1;
+ mDelayedMessageHandler.sendDelayedLoadMessage();
+ }
+
+ /**
+ * Called whenever {@link com.android.ex.chips.BaseRecipientAdapter.DirectoryFilter}
+ * wants to add an additional entry to the results. Derived classes should override
+ * this method if they are not using the default data structures provided by
+ * {@link com.android.ex.chips.BaseRecipientAdapter} and are instead using their
+ * own data structures to store and collate data.
+ * @param entry the entry being added
+ * @param isAggregatedEntry
+ */
+ protected void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry) {
+ putOneEntry(entry, isAggregatedEntry,
+ mEntryMap, mNonAggregatedEntries, mExistingDestinations);
+ }
+
+ private static void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry,
+ LinkedHashMap> entryMap,
+ List nonAggregatedEntries,
+ Set existingDestinations) {
+ if (existingDestinations.contains(entry.destination)) {
+ return;
+ }
+
+ existingDestinations.add(entry.destination);
+
+ if (!isAggregatedEntry) {
+ nonAggregatedEntries.add(RecipientEntry.constructTopLevelEntry(
+ entry.displayName,
+ entry.displayNameSource,
+ entry.destination, entry.destinationType, entry.destinationLabel,
+ entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
+ true, entry.lookupKey));
+ } else if (entryMap.containsKey(entry.contactId)) {
+ // We already have a section for the person.
+ final List entryList = entryMap.get(entry.contactId);
+ entryList.add(RecipientEntry.constructSecondLevelEntry(
+ entry.displayName,
+ entry.displayNameSource,
+ entry.destination, entry.destinationType, entry.destinationLabel,
+ entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
+ true, entry.lookupKey));
+ } else {
+ final List entryList = new ArrayList();
+ entryList.add(RecipientEntry.constructTopLevelEntry(
+ entry.displayName,
+ entry.displayNameSource,
+ entry.destination, entry.destinationType, entry.destinationLabel,
+ entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
+ true, entry.lookupKey));
+ entryMap.put(entry.contactId, entryList);
+ }
+ }
+
+ /**
+ * Returns the actual list to use for this Adapter. Derived classes
+ * should override this method if overriding how the adapter stores and collates
+ * data.
+ */
+ protected List constructEntryList() {
+ return constructEntryList(mEntryMap, mNonAggregatedEntries);
+ }
+
+ /**
+ * Constructs an actual list for this Adapter using {@link #mEntryMap}. Also tries to
+ * fetch a cached photo for each contact entry (other than separators), or request another
+ * thread to get one from directories.
+ */
+ private List constructEntryList(
+ LinkedHashMap> entryMap,
+ List nonAggregatedEntries) {
+ final List entries = new ArrayList();
+ int validEntryCount = 0;
+ for (Map.Entry> mapEntry : entryMap.entrySet()) {
+ final List entryList = mapEntry.getValue();
+ final int size = entryList.size();
+ for (int i = 0; i < size; i++) {
+ RecipientEntry entry = entryList.get(i);
+ entries.add(entry);
+ mPhotoManager.populatePhotoBytesAsync(entry, this);
+ validEntryCount++;
+ }
+ if (validEntryCount > mPreferredMaxResultCount) {
+ break;
+ }
+ }
+ if (validEntryCount <= mPreferredMaxResultCount) {
+ for (RecipientEntry entry : nonAggregatedEntries) {
+ if (validEntryCount > mPreferredMaxResultCount) {
+ break;
+ }
+ entries.add(entry);
+ mPhotoManager.populatePhotoBytesAsync(entry, this);
+ validEntryCount++;
+ }
+ }
+
+ return entries;
+ }
+
+
+ public interface EntriesUpdatedObserver {
+ public void onChanged(List entries);
+ }
+
+ public void registerUpdateObserver(EntriesUpdatedObserver observer) {
+ mEntriesUpdatedObserver = observer;
+ }
+
+ /** Resets {@link #mEntries} and notify the event to its parent ListView. */
+ protected void updateEntries(List newEntries) {
+ mEntries = newEntries;
+ mEntriesUpdatedObserver.onChanged(newEntries);
+ notifyDataSetChanged();
+ }
+
+ /**
+ * If there are no local results and we are searching alternate results,
+ * in the new result set, cache off what had been shown to the user for use until
+ * the first directory result is returned
+ * @param newEntryCount number of newly loaded entries
+ * @param paramListCount number of alternate filters it will search (including the current one).
+ */
+ protected void cacheCurrentEntriesIfNeeded(int newEntryCount, int paramListCount) {
+ if (newEntryCount == 0 && paramListCount > 1) {
+ cacheCurrentEntries();
+ }
+ }
+
+ protected void cacheCurrentEntries() {
+ mTempEntries = mEntries;
+ }
+
+ protected void clearTempEntries() {
+ mTempEntries = null;
+ }
+
+ protected List getEntries() {
+ return mTempEntries != null ? mTempEntries : mEntries;
+ }
+
+ protected void fetchPhoto(final RecipientEntry entry, PhotoManager.PhotoManagerCallback cb) {
+ mPhotoManager.populatePhotoBytesAsync(entry, cb);
+ }
+
+ private Cursor doQuery(CharSequence constraint, int limit, Long directoryId) {
+ if (!ChipsUtil.hasPermissions(mContext, mPermissionsCheckListener)) {
+ if (DEBUG) {
+ Log.d(TAG, "Not doing query because we don't have required permissions.");
+ }
+ return null;
+ }
+
+ final Uri.Builder builder = mQueryMode.getContentFilterUri().buildUpon()
+ .appendPath(constraint.toString())
+ .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
+ String.valueOf(limit + ALLOWANCE_FOR_DUPLICATES));
+ if (directoryId != null) {
+ builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
+ String.valueOf(directoryId));
+ }
+ if (mAccount != null) {
+ builder.appendQueryParameter(PRIMARY_ACCOUNT_NAME, mAccount.name);
+ builder.appendQueryParameter(PRIMARY_ACCOUNT_TYPE, mAccount.type);
+ }
+ final long start = System.currentTimeMillis();
+ final Cursor cursor = mContentResolver.query(
+ builder.build(), mQueryMode.getProjection(), null, null, null);
+ final long end = System.currentTimeMillis();
+ if (DEBUG) {
+ Log.d(TAG, "Time for autocomplete (query: " + constraint
+ + ", directoryId: " + directoryId + ", num_of_results: "
+ + (cursor != null ? cursor.getCount() : "null") + "): "
+ + (end - start) + " ms");
+ }
+ return cursor;
+ }
+
+ // TODO: This won't be used at all. We should find better way to quit the thread..
+ /*public void close() {
+ mEntries = null;
+ mPhotoCacheMap.evictAll();
+ if (!sPhotoHandlerThread.quit()) {
+ Log.w(TAG, "Failed to quit photo handler thread, ignoring it.");
+ }
+ }*/
+
+ @Override
+ public int getCount() {
+ final List entries = getEntries();
+ return entries != null ? entries.size() : 0;
+ }
+
+ @Override
+ public RecipientEntry getItem(int position) {
+ return getEntries().get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return RecipientEntry.ENTRY_TYPE_SIZE;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return getEntries().get(position).getEntryType();
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ return getEntries().get(position).isSelectable();
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final RecipientEntry entry = getEntries().get(position);
+
+ final String constraint = mCurrentConstraint == null ? null :
+ mCurrentConstraint.toString();
+
+ return mDropdownChipLayouter.bindView(convertView, parent, entry, position,
+ AdapterType.BASE_RECIPIENT, constraint);
+ }
+
+ public Account getAccount() {
+ return mAccount;
+ }
+
+ @Override
+ public void onPhotoBytesPopulated() {
+ // Default implementation does nothing
+ }
+
+ @Override
+ public void onPhotoBytesAsynchronouslyPopulated() {
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onPhotoBytesAsyncLoadFailed() {
+ // Default implementation does nothing
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/ChipsUtil.java b/modules/chips/src/com/android/ex/chips/ChipsUtil.java
new file mode 100644
index 0000000..ea31b20
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/ChipsUtil.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Process;
+import androidx.annotation.Nullable;
+
+public class ChipsUtil {
+
+ /**
+ * Listener that gets notified when we check whether we have permission.
+ */
+ public interface PermissionsCheckListener {
+ void onPermissionCheck(String permission, boolean granted);
+ }
+
+ /**
+ * Permissions required by Chips library.
+ */
+ public static final String[] REQUIRED_PERMISSIONS =
+ new String[] { Manifest.permission.READ_CONTACTS };
+
+ /**
+ * Returns true when the caller can use Chips UI in its environment.
+ */
+ public static boolean supportsChipsUi() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH;
+ }
+
+ /**
+ * Whether we are running on M or later version.
+ *
+ *
This is interesting for us because new permission model is introduced in M and we need to
+ * check if we have {@link #REQUIRED_PERMISSIONS}.
+ */
+ public static boolean isRunningMOrLater() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M;
+ }
+
+ /**
+ * Returns {@link PackageManager#PERMISSION_GRANTED} if given permission is granted, or
+ * {@link PackageManager#PERMISSION_DENIED} if not.
+ */
+ public static int checkPermission(Context context, String permission) {
+ if (isRunningMOrLater()) {
+ // TODO: Use "context.checkSelfPermission(permission)" once it's safe to move to M sdk
+ return context.checkPermission(permission, Process.myPid(), Process.myUid());
+ } else {
+ // Assume that we have permission before M.
+ return PackageManager.PERMISSION_GRANTED;
+ }
+ }
+
+ /**
+ * Returns true if all permissions in {@link #REQUIRED_PERMISSIONS} are granted.
+ *
+ *
If {@link PermissionsCheckListener} is specified it will be called for every
+ * {@link #checkPermission} call.
+ */
+ public static boolean hasPermissions(Context context,
+ @Nullable PermissionsCheckListener permissionsCheckListener) {
+ for (String permission : REQUIRED_PERMISSIONS) {
+ final boolean granted =
+ checkPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
+ if (permissionsCheckListener != null) {
+ permissionsCheckListener.onPermissionCheck(permission, granted);
+ }
+ if (!granted) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/CircularImageView.java b/modules/chips/src/com/android/ex/chips/CircularImageView.java
new file mode 100644
index 0000000..246cc96
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/CircularImageView.java
@@ -0,0 +1,114 @@
+package com.android.ex.chips;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.Shader;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+/**
+ * An ImageView class with a circle mask so that all images are drawn in a
+ * circle instead of a square.
+ */
+public class CircularImageView extends ImageView {
+ private static float circularImageBorder = 1f;
+
+ private final Matrix matrix;
+ private final RectF source;
+ private final RectF destination;
+ private final Paint bitmapPaint;
+ private final Paint borderPaint;
+
+ public CircularImageView(Context context) {
+ this(context, null, 0);
+ }
+
+ public CircularImageView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public CircularImageView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ matrix = new Matrix();
+ source = new RectF();
+ destination = new RectF();
+
+ bitmapPaint = new Paint();
+ bitmapPaint.setAntiAlias(true);
+ bitmapPaint.setFilterBitmap(true);
+ bitmapPaint.setDither(true);
+
+ borderPaint = new Paint();
+ borderPaint.setColor(Color.TRANSPARENT);
+ borderPaint.setStyle(Paint.Style.STROKE);
+ borderPaint.setStrokeWidth(circularImageBorder);
+ borderPaint.setAntiAlias(true);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ Drawable drawable = getDrawable();
+ BitmapDrawable bitmapDrawable = null;
+ // support state list drawable by getting the current state
+ if (drawable instanceof StateListDrawable) {
+ if (((StateListDrawable) drawable).getCurrent() != null) {
+ bitmapDrawable = (BitmapDrawable) drawable.getCurrent();
+ }
+ } else {
+ bitmapDrawable = (BitmapDrawable) drawable;
+ }
+
+ if (bitmapDrawable == null) {
+ return;
+ }
+ Bitmap bitmap = bitmapDrawable.getBitmap();
+ if (bitmap == null) {
+ return;
+ }
+
+ source.set(0, 0, bitmap.getWidth(), bitmap.getHeight());
+ destination.set(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(),
+ getHeight() - getPaddingBottom());
+
+ drawBitmapWithCircleOnCanvas(bitmap, canvas, source, destination);
+ }
+
+ /**
+ * Given the source bitmap and a canvas, draws the bitmap through a circular
+ * mask. Only draws a circle with diameter equal to the destination width.
+ *
+ * @param bitmap The source bitmap to draw.
+ * @param canvas The canvas to draw it on.
+ * @param source The source bound of the bitmap.
+ * @param dest The destination bound on the canvas.
+ */
+ public void drawBitmapWithCircleOnCanvas(Bitmap bitmap, Canvas canvas,
+ RectF source, RectF dest) {
+ // Draw bitmap through shader first.
+ BitmapShader shader = new BitmapShader(bitmap, Shader.TileMode.CLAMP,
+ Shader.TileMode.CLAMP);
+ matrix.reset();
+
+ // Fit bitmap to bounds.
+ matrix.setRectToRect(source, dest, Matrix.ScaleToFit.FILL);
+
+ shader.setLocalMatrix(matrix);
+ bitmapPaint.setShader(shader);
+ canvas.drawCircle(dest.centerX(), dest.centerY(), dest.width() / 2f,
+ bitmapPaint);
+
+ // Then draw the border.
+ canvas.drawCircle(dest.centerX(), dest.centerY(),
+ dest.width() / 2f - circularImageBorder / 2, borderPaint);
+ }
+}
\ No newline at end of file
diff --git a/modules/chips/src/com/android/ex/chips/CopyDialog.java b/modules/chips/src/com/android/ex/chips/CopyDialog.java
new file mode 100644
index 0000000..631b488
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/CopyDialog.java
@@ -0,0 +1,51 @@
+package com.android.ex.chips;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+
+/**
+ * Simple dialog fragment for copying the contents of a chip.
+ */
+public class CopyDialog extends DialogFragment implements DialogInterface.OnClickListener {
+
+ public static final String TAG = "chips-copy-dialog";
+
+ private static final String ARG_TEXT = "text";
+
+ private String mText;
+
+ public static CopyDialog newInstance(String text) {
+ final CopyDialog fragment = new CopyDialog();
+ final Bundle args = new Bundle(1);
+ args.putString(ARG_TEXT, text);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final Bundle args = getArguments();
+ mText = args.getString(ARG_TEXT);
+
+ return new AlertDialog.Builder(getActivity())
+ .setMessage(mText)
+ .setPositiveButton(R.string.chips_action_copy, this)
+ .setNegativeButton(R.string.chips_action_cancel, null)
+ .create();
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (which == DialogInterface.BUTTON_POSITIVE) {
+ final ClipboardManager clipboard = (ClipboardManager)
+ getActivity().getSystemService(Context.CLIPBOARD_SERVICE);
+ clipboard.setPrimaryClip(ClipData.newPlainText(null, mText));
+ }
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/DefaultPhotoManager.java b/modules/chips/src/com/android/ex/chips/DefaultPhotoManager.java
new file mode 100644
index 0000000..321e54b
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/DefaultPhotoManager.java
@@ -0,0 +1,145 @@
+/*
+ * 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.ex.chips;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.provider.ContactsContract;
+import androidx.collection.LruCache;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Default implementation of {@link com.android.ex.chips.PhotoManager} that
+ * queries for photo bytes by using the {@link com.android.ex.chips.RecipientEntry}'s
+ * photoThumbnailUri.
+ */
+public class DefaultPhotoManager implements PhotoManager {
+ private static final String TAG = "DefaultPhotoManager";
+
+ private static final boolean DEBUG = false;
+
+ /**
+ * For reading photos for directory contacts, this is the chunk size for
+ * copying from the {@link InputStream} to the output stream.
+ */
+ private static final int BUFFER_SIZE = 1024*16;
+
+ private static class PhotoQuery {
+ public static final String[] PROJECTION = {
+ ContactsContract.CommonDataKinds.Photo.PHOTO
+ };
+
+ public static final int PHOTO = 0;
+ }
+
+ private final ContentResolver mContentResolver;
+ private final LruCache mPhotoCacheMap;
+
+ public DefaultPhotoManager(ContentResolver contentResolver) {
+ mContentResolver = contentResolver;
+ mPhotoCacheMap = new LruCache(PHOTO_CACHE_SIZE);
+ }
+
+ @Override
+ public void populatePhotoBytesAsync(RecipientEntry entry, PhotoManagerCallback callback) {
+ final Uri photoThumbnailUri = entry.getPhotoThumbnailUri();
+ if (photoThumbnailUri != null) {
+ final byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri);
+ if (photoBytes != null) {
+ entry.setPhotoBytes(photoBytes);
+ if (callback != null) {
+ callback.onPhotoBytesPopulated();
+ }
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "No photo cache for " + entry.getDisplayName()
+ + ". Fetch one asynchronously");
+ }
+ fetchPhotoAsync(entry, photoThumbnailUri, callback);
+ }
+ } else if (callback != null) {
+ callback.onPhotoBytesAsyncLoadFailed();
+ }
+ }
+
+ private void fetchPhotoAsync(final RecipientEntry entry, final Uri photoThumbnailUri,
+ final PhotoManagerCallback callback) {
+ final AsyncTask photoLoadTask = new AsyncTask() {
+ @Override
+ protected byte[] doInBackground(Void... params) {
+ // First try running a query. Images for local contacts are
+ // loaded by sending a query to the ContactsProvider.
+ final Cursor photoCursor = mContentResolver.query(
+ photoThumbnailUri, PhotoQuery.PROJECTION, null, null, null);
+ if (photoCursor != null) {
+ try {
+ if (photoCursor.moveToFirst()) {
+ return photoCursor.getBlob(PhotoQuery.PHOTO);
+ }
+ } finally {
+ photoCursor.close();
+ }
+ } else {
+ // If the query fails, try streaming the URI directly.
+ // For remote directory images, this URI resolves to the
+ // directory provider and the images are loaded by sending
+ // an openFile call to the provider.
+ try {
+ InputStream is = mContentResolver.openInputStream(
+ photoThumbnailUri);
+ if (is != null) {
+ byte[] buffer = new byte[BUFFER_SIZE];
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try {
+ int size;
+ while ((size = is.read(buffer)) != -1) {
+ baos.write(buffer, 0, size);
+ }
+ } finally {
+ is.close();
+ }
+ return baos.toByteArray();
+ }
+ } catch (IOException ex) {
+ // ignore
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final byte[] photoBytes) {
+ entry.setPhotoBytes(photoBytes);
+ if (photoBytes != null) {
+ mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
+ if (callback != null) {
+ callback.onPhotoBytesAsynchronouslyPopulated();
+ }
+ } else if (callback != null) {
+ callback.onPhotoBytesAsyncLoadFailed();
+ }
+ }
+ };
+ photoLoadTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/DropdownChipLayouter.java b/modules/chips/src/com/android/ex/chips/DropdownChipLayouter.java
new file mode 100644
index 0000000..77415c1
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/DropdownChipLayouter.java
@@ -0,0 +1,569 @@
+package com.android.ex.chips;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.net.Uri;
+import androidx.annotation.DrawableRes;
+import androidx.annotation.IdRes;
+import androidx.annotation.LayoutRes;
+import androidx.annotation.Nullable;
+import androidx.core.view.MarginLayoutParamsCompat;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.style.ForegroundColorSpan;
+import android.text.util.Rfc822Tokenizer;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewGroup.MarginLayoutParams;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.ex.chips.Queries.Query;
+
+/**
+ * A class that inflates and binds the views in the dropdown list from
+ * RecipientEditTextView.
+ */
+public class DropdownChipLayouter {
+ /**
+ * The type of adapter that is requesting a chip layout.
+ */
+ public enum AdapterType {
+ BASE_RECIPIENT,
+ RECIPIENT_ALTERNATES,
+ SINGLE_RECIPIENT
+ }
+
+ public interface ChipDeleteListener {
+ void onChipDelete();
+ }
+
+ /**
+ * Listener that handles the dismisses of the entries of the
+ * {@link RecipientEntry#ENTRY_TYPE_PERMISSION_REQUEST} type.
+ */
+ public interface PermissionRequestDismissedListener {
+
+ /**
+ * Callback that occurs when user dismisses the item that asks user to grant permissions to
+ * the app.
+ */
+ void onPermissionRequestDismissed();
+ }
+
+ private final LayoutInflater mInflater;
+ private final Context mContext;
+ private ChipDeleteListener mDeleteListener;
+ private PermissionRequestDismissedListener mPermissionRequestDismissedListener;
+ private Query mQuery;
+ private int mAutocompleteDividerMarginStart;
+
+ public DropdownChipLayouter(LayoutInflater inflater, Context context) {
+ mInflater = inflater;
+ mContext = context;
+ mAutocompleteDividerMarginStart =
+ context.getResources().getDimensionPixelOffset(R.dimen.chip_wrapper_start_padding);
+ }
+
+ public void setQuery(Query query) {
+ mQuery = query;
+ }
+
+ public void setDeleteListener(ChipDeleteListener listener) {
+ mDeleteListener = listener;
+ }
+
+ public void setPermissionRequestDismissedListener(PermissionRequestDismissedListener listener) {
+ mPermissionRequestDismissedListener = listener;
+ }
+
+ public void setAutocompleteDividerMarginStart(int autocompleteDividerMarginStart) {
+ mAutocompleteDividerMarginStart = autocompleteDividerMarginStart;
+ }
+
+ /**
+ * Layouts and binds recipient information to the view. If convertView is null, inflates a new
+ * view with getItemLaytout().
+ *
+ * @param convertView The view to bind information to.
+ * @param parent The parent to bind the view to if we inflate a new view.
+ * @param entry The recipient entry to get information from.
+ * @param position The position in the list.
+ * @param type The adapter type that is requesting the bind.
+ * @param constraint The constraint typed in the auto complete view.
+ *
+ * @return A view ready to be shown in the drop down list.
+ */
+ public View bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position,
+ AdapterType type, String constraint) {
+ return bindView(convertView, parent, entry, position, type, constraint, null);
+ }
+
+ /**
+ * See {@link #bindView(View, ViewGroup, RecipientEntry, int, AdapterType, String)}
+ * @param deleteDrawable a {@link android.graphics.drawable.StateListDrawable} representing
+ * the delete icon. android.R.attr.state_activated should map to the delete icon, and the
+ * default state can map to a drawable of your choice (or null for no drawable).
+ */
+ public View bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position,
+ AdapterType type, String constraint, StateListDrawable deleteDrawable) {
+ // Default to show all the information
+ CharSequence[] styledResults = getStyledResults(constraint, entry);
+ CharSequence displayName = styledResults[0];
+ CharSequence destination = styledResults[1];
+ boolean showImage = true;
+ CharSequence destinationType = getDestinationType(entry);
+
+ final View itemView = reuseOrInflateView(convertView, parent, type);
+
+ final ViewHolder viewHolder = new ViewHolder(itemView);
+
+ // Hide some information depending on the adapter type.
+ switch (type) {
+ case BASE_RECIPIENT:
+ if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, destination)) {
+ displayName = destination;
+
+ // We only show the destination for secondary entries, so clear it only for the
+ // first level.
+ if (entry.isFirstLevel()) {
+ destination = null;
+ }
+ }
+
+ if (!entry.isFirstLevel()) {
+ displayName = null;
+ showImage = false;
+ }
+
+ // For BASE_RECIPIENT set all top dividers except for the first one to be GONE.
+ if (viewHolder.topDivider != null) {
+ viewHolder.topDivider.setVisibility(position == 0 ? View.VISIBLE : View.GONE);
+ MarginLayoutParamsCompat.setMarginStart(
+ (MarginLayoutParams) viewHolder.topDivider.getLayoutParams(),
+ mAutocompleteDividerMarginStart);
+ }
+ if (viewHolder.bottomDivider != null) {
+ MarginLayoutParamsCompat.setMarginStart(
+ (MarginLayoutParams) viewHolder.bottomDivider.getLayoutParams(),
+ mAutocompleteDividerMarginStart);
+ }
+ break;
+ case RECIPIENT_ALTERNATES:
+ if (position != 0) {
+ displayName = null;
+ showImage = false;
+ }
+ break;
+ case SINGLE_RECIPIENT:
+ if (!PhoneUtil.isPhoneNumber(entry.getDestination())) {
+ destination = Rfc822Tokenizer.tokenize(entry.getDestination())[0].getAddress();
+ }
+ destinationType = null;
+ }
+
+ // Bind the information to the view
+ bindTextToView(displayName, viewHolder.displayNameView);
+ bindTextToView(destination, viewHolder.destinationView);
+ bindTextToView(destinationType, viewHolder.destinationTypeView);
+ bindIconToView(showImage, entry, viewHolder.imageView, type);
+ bindDrawableToDeleteView(deleteDrawable, entry.getDisplayName(), viewHolder.deleteView);
+ bindIndicatorToView(
+ entry.getIndicatorIconId(), entry.getIndicatorText(), viewHolder.indicatorView);
+ bindPermissionRequestDismissView(viewHolder.permissionRequestDismissView);
+
+ // Hide some view groups depending on the entry type
+ final int entryType = entry.getEntryType();
+ if (entryType == RecipientEntry.ENTRY_TYPE_PERSON) {
+ setViewVisibility(viewHolder.personViewGroup, View.VISIBLE);
+ setViewVisibility(viewHolder.permissionViewGroup, View.GONE);
+ setViewVisibility(viewHolder.permissionBottomDivider, View.GONE);
+ } else if (entryType == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) {
+ setViewVisibility(viewHolder.personViewGroup, View.GONE);
+ setViewVisibility(viewHolder.permissionViewGroup, View.VISIBLE);
+ setViewVisibility(viewHolder.permissionBottomDivider, View.VISIBLE);
+ }
+
+ return itemView;
+ }
+
+ /**
+ * Returns a new view with {@link #getItemLayoutResId(AdapterType)}.
+ */
+ public View newView(AdapterType type) {
+ return mInflater.inflate(getItemLayoutResId(type), null);
+ }
+
+ /**
+ * Returns the same view, or inflates a new one if the given view was null.
+ */
+ protected View reuseOrInflateView(View convertView, ViewGroup parent, AdapterType type) {
+ int itemLayout = getItemLayoutResId(type);
+ switch (type) {
+ case BASE_RECIPIENT:
+ case RECIPIENT_ALTERNATES:
+ break;
+ case SINGLE_RECIPIENT:
+ itemLayout = getAlternateItemLayoutResId(type);
+ break;
+ }
+ return convertView != null ? convertView : mInflater.inflate(itemLayout, parent, false);
+ }
+
+ /**
+ * Binds the text to the given text view. If the text was null, hides the text view.
+ */
+ protected void bindTextToView(CharSequence text, TextView view) {
+ if (view == null) {
+ return;
+ }
+
+ if (text != null) {
+ view.setText(text);
+ view.setVisibility(View.VISIBLE);
+ } else {
+ view.setVisibility(View.GONE);
+ }
+ }
+
+ /**
+ * Binds the avatar icon to the image view. If we don't want to show the image, hides the
+ * image view.
+ */
+ protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view,
+ AdapterType type) {
+ if (view == null) {
+ return;
+ }
+
+ if (showImage) {
+ switch (type) {
+ case BASE_RECIPIENT:
+ byte[] photoBytes = entry.getPhotoBytes();
+ if (photoBytes != null && photoBytes.length > 0) {
+ final Bitmap photo = BitmapFactory.decodeByteArray(photoBytes, 0,
+ photoBytes.length);
+ view.setImageBitmap(photo);
+ } else {
+ view.setImageResource(getDefaultPhotoResId());
+ }
+ break;
+ case RECIPIENT_ALTERNATES:
+ Uri thumbnailUri = entry.getPhotoThumbnailUri();
+ if (thumbnailUri != null) {
+ // TODO: see if this needs to be done outside the main thread
+ // as it may be too slow to get immediately.
+ view.setImageURI(thumbnailUri);
+ } else {
+ view.setImageResource(getDefaultPhotoResId());
+ }
+ break;
+ case SINGLE_RECIPIENT:
+ default:
+ break;
+ }
+ view.setVisibility(View.VISIBLE);
+ } else {
+ view.setVisibility(View.GONE);
+ }
+ }
+
+ protected void bindDrawableToDeleteView(final StateListDrawable drawable, String recipient,
+ ImageView view) {
+ if (view == null) {
+ return;
+ }
+ if (drawable == null) {
+ view.setVisibility(View.GONE);
+ } else {
+ final Resources res = mContext.getResources();
+ view.setImageDrawable(drawable);
+ view.setContentDescription(
+ res.getString(R.string.dropdown_delete_button_desc, recipient));
+ if (mDeleteListener != null) {
+ view.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (drawable.getCurrent() != null) {
+ mDeleteListener.onChipDelete();
+ }
+ }
+ });
+ }
+ }
+ }
+
+ protected void bindIndicatorToView(
+ @DrawableRes int indicatorIconId, String indicatorText, TextView view) {
+ if (view != null) {
+ if (indicatorText != null || indicatorIconId != 0) {
+ view.setText(indicatorText);
+ view.setVisibility(View.VISIBLE);
+ final Drawable indicatorIcon;
+ if (indicatorIconId != 0) {
+ indicatorIcon = mContext.getDrawable(indicatorIconId).mutate();
+ indicatorIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
+ } else {
+ indicatorIcon = null;
+ }
+ view.setCompoundDrawablesRelativeWithIntrinsicBounds(
+ indicatorIcon, null, null, null);
+ } else {
+ view.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ protected void bindPermissionRequestDismissView(ImageView view) {
+ if (view == null) {
+ return;
+ }
+ view.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mPermissionRequestDismissedListener != null) {
+ mPermissionRequestDismissedListener.onPermissionRequestDismissed();
+ }
+ }
+ });
+ }
+
+ protected void setViewVisibility(View view, int visibility) {
+ if (view != null) {
+ view.setVisibility(visibility);
+ }
+ }
+
+ protected CharSequence getDestinationType(RecipientEntry entry) {
+ return mQuery.getTypeLabel(mContext.getResources(), entry.getDestinationType(),
+ entry.getDestinationLabel()).toString().toUpperCase();
+ }
+
+ /**
+ * Returns a layout id for each item inside auto-complete list.
+ *
+ * Each View must contain two TextViews (for display name and destination) and one ImageView
+ * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
+ * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
+ */
+ protected @LayoutRes int getItemLayoutResId(AdapterType type) {
+ switch (type) {
+ case BASE_RECIPIENT:
+ return R.layout.chips_autocomplete_recipient_dropdown_item;
+ case RECIPIENT_ALTERNATES:
+ return R.layout.chips_recipient_dropdown_item;
+ default:
+ return R.layout.chips_recipient_dropdown_item;
+ }
+ }
+
+ /**
+ * Returns a layout id for each item inside alternate auto-complete list.
+ *
+ * Each View must contain two TextViews (for display name and destination) and one ImageView
+ * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
+ * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
+ */
+ protected @LayoutRes int getAlternateItemLayoutResId(AdapterType type) {
+ switch (type) {
+ case BASE_RECIPIENT:
+ return R.layout.chips_autocomplete_recipient_dropdown_item;
+ case RECIPIENT_ALTERNATES:
+ return R.layout.chips_recipient_dropdown_item;
+ default:
+ return R.layout.chips_recipient_dropdown_item;
+ }
+ }
+
+ /**
+ * Returns a resource ID representing an image which should be shown when ther's no relevant
+ * photo is available.
+ */
+ protected @DrawableRes int getDefaultPhotoResId() {
+ return R.drawable.ic_contact_picture;
+ }
+
+ /**
+ * Returns an id for the ViewGroup in an item View that contains the person ui elements.
+ */
+ protected @IdRes int getPersonGroupResId() {
+ return R.id.chip_person_wrapper;
+ }
+
+ /**
+ * Returns an id for TextView in an item View for showing a display name. By default
+ * {@link android.R.id#title} is returned.
+ */
+ protected @IdRes int getDisplayNameResId() {
+ return android.R.id.title;
+ }
+
+ /**
+ * Returns an id for TextView in an item View for showing a destination
+ * (an email address or a phone number).
+ * By default {@link android.R.id#text1} is returned.
+ */
+ protected @IdRes int getDestinationResId() {
+ return android.R.id.text1;
+ }
+
+ /**
+ * Returns an id for TextView in an item View for showing the type of the destination.
+ * By default {@link android.R.id#text2} is returned.
+ */
+ protected @IdRes int getDestinationTypeResId() {
+ return android.R.id.text2;
+ }
+
+ /**
+ * Returns an id for ImageView in an item View for showing photo image for a person. In default
+ * {@link android.R.id#icon} is returned.
+ */
+ protected @IdRes int getPhotoResId() {
+ return android.R.id.icon;
+ }
+
+ /**
+ * Returns an id for ImageView in an item View for showing the delete button. In default
+ * {@link android.R.id#icon1} is returned.
+ */
+ protected @IdRes int getDeleteResId() { return android.R.id.icon1; }
+
+ /**
+ * Returns an id for the ViewGroup in an item View that contains the request permission ui
+ * elements.
+ */
+ protected @IdRes int getPermissionGroupResId() {
+ return R.id.chip_permission_wrapper;
+ }
+
+ /**
+ * Returns an id for ImageView in an item View for dismissing the permission request. In default
+ * {@link android.R.id#icon2} is returned.
+ */
+ protected @IdRes int getPermissionRequestDismissResId() {
+ return android.R.id.icon2;
+ }
+
+ /**
+ * Given a constraint and a recipient entry, tries to find the constraint in the name and
+ * destination in the recipient entry. A foreground font color style will be applied to the
+ * section that matches the constraint. As soon as a match has been found, no further matches
+ * are attempted.
+ *
+ * @param constraint A string that we will attempt to find within the results.
+ * @param entry The recipient entry to style results for.
+ *
+ * @return An array of CharSequences, the length determined by the length of results. Each
+ * CharSequence will either be a styled SpannableString or just the input String.
+ */
+ protected CharSequence[] getStyledResults(@Nullable String constraint, RecipientEntry entry) {
+ return getStyledResults(constraint, entry.getDisplayName(), entry.getDestination());
+ }
+
+ /**
+ * Given a constraint and results, tries to find the constraint in those results, one at a time.
+ * A foreground font color style will be applied to the section that matches the constraint. As
+ * soon as a match has been found, no further matches are attempted.
+ *
+ * @param constraint A string that we will attempt to find within the results.
+ * @param results Strings that may contain the constraint. The order given is the order used to
+ * search for the constraint.
+ *
+ * @return An array of CharSequences, the length determined by the length of results. Each
+ * CharSequence will either be a styled SpannableString or just the input String.
+ */
+ protected CharSequence[] getStyledResults(@Nullable String constraint, String... results) {
+ if (isAllWhitespace(constraint)) {
+ return results;
+ }
+
+ CharSequence[] styledResults = new CharSequence[results.length];
+ boolean foundMatch = false;
+ for (int i = 0; i < results.length; i++) {
+ String result = results[i];
+ if (result == null) {
+ continue;
+ }
+
+ if (!foundMatch) {
+ int index = result.toLowerCase().indexOf(constraint.toLowerCase());
+ if (index != -1) {
+ SpannableStringBuilder styled = SpannableStringBuilder.valueOf(result);
+ ForegroundColorSpan highlightSpan =
+ new ForegroundColorSpan(mContext.getResources().getColor(
+ R.color.chips_dropdown_text_highlighted));
+ styled.setSpan(highlightSpan,
+ index, index + constraint.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ styledResults[i] = styled;
+ foundMatch = true;
+ continue;
+ }
+ }
+ styledResults[i] = result;
+ }
+ return styledResults;
+ }
+
+ private static boolean isAllWhitespace(@Nullable String string) {
+ if (TextUtils.isEmpty(string)) {
+ return true;
+ }
+
+ for (int i = 0; i < string.length(); ++i) {
+ if (!Character.isWhitespace(string.charAt(i))) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * A holder class the view. Uses the getters in DropdownChipLayouter to find the id of the
+ * corresponding views.
+ */
+ protected class ViewHolder {
+ public final ViewGroup personViewGroup;
+ public final TextView displayNameView;
+ public final TextView destinationView;
+ public final TextView destinationTypeView;
+ public final TextView indicatorView;
+ public final ImageView imageView;
+ public final ImageView deleteView;
+ public final View topDivider;
+ public final View bottomDivider;
+ public final View permissionBottomDivider;
+
+ public final ViewGroup permissionViewGroup;
+ public final ImageView permissionRequestDismissView;
+
+ public ViewHolder(View view) {
+ personViewGroup = (ViewGroup) view.findViewById(getPersonGroupResId());
+ displayNameView = (TextView) view.findViewById(getDisplayNameResId());
+ destinationView = (TextView) view.findViewById(getDestinationResId());
+ destinationTypeView = (TextView) view.findViewById(getDestinationTypeResId());
+ imageView = (ImageView) view.findViewById(getPhotoResId());
+ deleteView = (ImageView) view.findViewById(getDeleteResId());
+ topDivider = view.findViewById(R.id.chip_autocomplete_top_divider);
+
+ bottomDivider = view.findViewById(R.id.chip_autocomplete_bottom_divider);
+ permissionBottomDivider = view.findViewById(R.id.chip_permission_bottom_divider);
+
+ indicatorView = (TextView) view.findViewById(R.id.chip_indicator_text);
+
+ permissionViewGroup = (ViewGroup) view.findViewById(getPermissionGroupResId());
+ permissionRequestDismissView =
+ (ImageView) view.findViewById(getPermissionRequestDismissResId());
+ }
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/PhoneUtil.java b/modules/chips/src/com/android/ex/chips/PhoneUtil.java
new file mode 100644
index 0000000..512d275
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/PhoneUtil.java
@@ -0,0 +1,52 @@
+/*
+
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.text.TextUtils;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A utility class for chips using phone numbers.
+ */
+public class PhoneUtil {
+
+ // This pattern comes from android.util.Patterns. It has been tweaked to handle a "1" before
+ // parens, so numbers such as "1 (425) 222-2342" match.
+ private static final Pattern PHONE_PATTERN
+ = Pattern.compile( // sdd = space, dot, or dash
+ "(\\+[0-9]+[\\- \\.]*)?" // +*
+ + "(1?[ ]*\\([0-9]+\\)[\\- \\.]*)?" // 1()*
+ + "([0-9][0-9\\- \\.][0-9\\- \\.]+[0-9])"); // +
+
+ /**
+ * Returns true if the string is a phone number.
+ */
+ public static boolean isPhoneNumber(String number) {
+ // TODO: replace this function with libphonenumber's isPossibleNumber (see
+ // PhoneNumberUtil). One complication is that it requires the sender's region which
+ // comes from the CurrentCountryIso. For now, let's just do this simple match.
+ if (TextUtils.isEmpty(number)) {
+ return false;
+ }
+
+ Matcher match = PHONE_PATTERN.matcher(number);
+ return match.matches();
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/PhotoManager.java b/modules/chips/src/com/android/ex/chips/PhotoManager.java
new file mode 100644
index 0000000..66a64c2
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/PhotoManager.java
@@ -0,0 +1,46 @@
+/*
+ * 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.ex.chips;
+
+/**
+ * Used by the {@link com.android.ex.chips.BaseRecipientAdapter} to handle fetching
+ * photos from external sources and caching them for faster lookup later.
+ */
+public interface PhotoManager {
+
+ /** The number of photos cached in this Adapter. */
+ public static final int PHOTO_CACHE_SIZE = 20;
+
+ /**
+ * Sets the {@link com.android.ex.chips.RecipientEntry}'s photo bytes. If the photo bytes
+ * are cached, this action happens immediately. Otherwise, the work to fetch the photo
+ * bytes is performed asynchronously before setting the value on the UI thread.
+ *
+ * If the photo bytes were fetched asynchronously,
+ * {@link PhotoManagerCallback#onPhotoBytesAsynchronouslyPopulated()} is called. This
+ * method is not called if the photo bytes have been cached previously (because no
+ * asynchronous work was performed). In that case,
+ * {@link PhotoManagerCallback#onPhotoBytesPopulated()} is called.
+ */
+ void populatePhotoBytesAsync(RecipientEntry entry, PhotoManagerCallback callback);
+
+ interface PhotoManagerCallback {
+ void onPhotoBytesPopulated();
+ void onPhotoBytesAsynchronouslyPopulated();
+ void onPhotoBytesAsyncLoadFailed();
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/Queries.java b/modules/chips/src/com/android/ex/chips/Queries.java
new file mode 100644
index 0000000..1e66b96
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/Queries.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2012 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.ex.chips;
+
+import android.content.res.Resources;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Contacts;
+
+/**
+ * Phone and Email queries for supporting Chips UI.
+ */
+/* package */ class Queries {
+
+ public static final Query PHONE = new Query(new String[] {
+ Contacts.DISPLAY_NAME, // 0
+ Phone.NUMBER, // 1
+ Phone.TYPE, // 2
+ Phone.LABEL, // 3
+ Phone.CONTACT_ID, // 4
+ Phone._ID, // 5
+ Contacts.PHOTO_THUMBNAIL_URI, // 6
+ Contacts.DISPLAY_NAME_SOURCE, // 7
+ Contacts.LOOKUP_KEY, // 8
+ ContactsContract.CommonDataKinds.Email.MIMETYPE // 9
+ }, Phone.CONTENT_FILTER_URI, Phone.CONTENT_URI) {
+
+ @Override
+ public CharSequence getTypeLabel(Resources res, int type, CharSequence label) {
+ return Phone.getTypeLabel(res, type, label);
+ }
+
+ };
+
+ public static final Query EMAIL = new Query(new String[]{
+ Contacts.DISPLAY_NAME, // 0
+ Email.DATA, // 1
+ Email.TYPE, // 2
+ Email.LABEL, // 3
+ Email.CONTACT_ID, // 4
+ Email._ID, // 5
+ Contacts.PHOTO_THUMBNAIL_URI, // 6
+ Contacts.DISPLAY_NAME_SOURCE, // 7
+ Contacts.LOOKUP_KEY, // 8
+ ContactsContract.CommonDataKinds.Email.MIMETYPE // 9
+ }, Email.CONTENT_FILTER_URI, Email.CONTENT_URI) {
+
+ @Override
+ public CharSequence getTypeLabel(Resources res, int type, CharSequence label) {
+ return Email.getTypeLabel(res, type, label);
+ }
+
+ };
+
+ static abstract class Query {
+ private final String[] mProjection;
+ private final Uri mContentFilterUri;
+ private final Uri mContentUri;
+
+ public static final int NAME = 0; // String
+ public static final int DESTINATION = 1; // String
+ public static final int DESTINATION_TYPE = 2; // int
+ public static final int DESTINATION_LABEL = 3; // String
+ public static final int CONTACT_ID = 4; // long
+ public static final int DATA_ID = 5; // long
+ public static final int PHOTO_THUMBNAIL_URI = 6; // String
+ public static final int DISPLAY_NAME_SOURCE = 7; // int
+ public static final int LOOKUP_KEY = 8; // String
+ public static final int MIME_TYPE = 9; // String
+
+ public Query(String[] projection, Uri contentFilter, Uri content) {
+ mProjection = projection;
+ mContentFilterUri = contentFilter;
+ mContentUri = content;
+ }
+
+ public String[] getProjection() {
+ return mProjection;
+ }
+
+ public Uri getContentFilterUri() {
+ return mContentFilterUri;
+ }
+
+ public Uri getContentUri() {
+ return mContentUri;
+ }
+
+ public abstract CharSequence getTypeLabel(Resources res, int type, CharSequence label);
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/RecipientAlternatesAdapter.java b/modules/chips/src/com/android/ex/chips/RecipientAlternatesAdapter.java
new file mode 100644
index 0000000..cc19700
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/RecipientAlternatesAdapter.java
@@ -0,0 +1,621 @@
+/*
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.graphics.drawable.StateListDrawable;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.text.TextUtils;
+import android.text.util.Rfc822Token;
+import android.text.util.Rfc822Tokenizer;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+
+import com.android.ex.chips.BaseRecipientAdapter.DirectoryListQuery;
+import com.android.ex.chips.BaseRecipientAdapter.DirectorySearchParams;
+import com.android.ex.chips.DropdownChipLayouter.AdapterType;
+import com.android.ex.chips.Queries.Query;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * RecipientAlternatesAdapter backs the RecipientEditTextView for managing contacts
+ * queried by email or by phone number.
+ */
+public class RecipientAlternatesAdapter extends CursorAdapter {
+ public static final int MAX_LOOKUPS = 50;
+
+ private final long mCurrentId;
+
+ private int mCheckedItemPosition = -1;
+
+ private OnCheckedItemChangedListener mCheckedItemChangedListener;
+
+ private static final String TAG = "RecipAlternates";
+
+ public static final int QUERY_TYPE_EMAIL = 0;
+ public static final int QUERY_TYPE_PHONE = 1;
+ private final Long mDirectoryId;
+ private DropdownChipLayouter mDropdownChipLayouter;
+ private final StateListDrawable mDeleteDrawable;
+
+ private static final Map sCorrectedPhotoUris = new HashMap();
+
+ public interface RecipientMatchCallback {
+ public void matchesFound(Map results);
+ /**
+ * Called with all addresses that could not be resolved to valid recipients.
+ */
+ public void matchesNotFound(Set unfoundAddresses);
+ }
+
+ public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter,
+ ArrayList inAddresses, Account account, RecipientMatchCallback callback,
+ ChipsUtil.PermissionsCheckListener permissionsCheckListener) {
+ getMatchingRecipients(context, adapter, inAddresses, QUERY_TYPE_EMAIL, account, callback,
+ permissionsCheckListener);
+ }
+
+ /**
+ * Get a HashMap of address to RecipientEntry that contains all contact
+ * information for a contact with the provided address, if one exists. This
+ * may block the UI, so run it in an async task.
+ *
+ * @param context Context.
+ * @param inAddresses Array of addresses on which to perform the lookup.
+ * @param callback RecipientMatchCallback called when a match or matches are found.
+ */
+ public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter,
+ ArrayList inAddresses, int addressType, Account account,
+ RecipientMatchCallback callback,
+ ChipsUtil.PermissionsCheckListener permissionsCheckListener) {
+ Queries.Query query;
+ if (addressType == QUERY_TYPE_EMAIL) {
+ query = Queries.EMAIL;
+ } else {
+ query = Queries.PHONE;
+ }
+ int addressesSize = Math.min(MAX_LOOKUPS, inAddresses.size());
+ HashSet addresses = new HashSet();
+ StringBuilder bindString = new StringBuilder();
+ // Create the "?" string and set up arguments.
+ for (int i = 0; i < addressesSize; i++) {
+ Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase());
+ addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i));
+ bindString.append("?");
+ if (i < addressesSize - 1) {
+ bindString.append(",");
+ }
+ }
+
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Doing reverse lookup for " + addresses.toString());
+ }
+
+ String[] addressArray = new String[addresses.size()];
+ addresses.toArray(addressArray);
+ HashMap recipientEntries = null;
+ Cursor c = null;
+
+ try {
+ if (ChipsUtil.hasPermissions(context, permissionsCheckListener)) {
+ c = context.getContentResolver().query(
+ query.getContentUri(),
+ query.getProjection(),
+ query.getProjection()[Queries.Query.DESTINATION] + " IN ("
+ + bindString.toString() + ")", addressArray, null);
+ }
+ recipientEntries = processContactEntries(c, null /* directoryId */);
+ callback.matchesFound(recipientEntries);
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+
+ final Set matchesNotFound = new HashSet();
+
+ getMatchingRecipientsFromDirectoryQueries(context, recipientEntries,
+ addresses, account, matchesNotFound, query, callback, permissionsCheckListener);
+
+ getMatchingRecipientsFromExtensionMatcher(adapter, matchesNotFound, callback);
+ }
+
+ public static void getMatchingRecipientsFromDirectoryQueries(Context context,
+ Map recipientEntries, Set addresses,
+ Account account, Set matchesNotFound,
+ RecipientMatchCallback callback,
+ ChipsUtil.PermissionsCheckListener permissionsCheckListener) {
+ getMatchingRecipientsFromDirectoryQueries(
+ context, recipientEntries, addresses, account,
+ matchesNotFound, Queries.EMAIL, callback, permissionsCheckListener);
+ }
+
+ private static void getMatchingRecipientsFromDirectoryQueries(Context context,
+ Map recipientEntries, Set addresses,
+ Account account, Set matchesNotFound, Queries.Query query,
+ RecipientMatchCallback callback,
+ ChipsUtil.PermissionsCheckListener permissionsCheckListener) {
+ // See if any entries did not resolve; if so, we need to check other
+ // directories
+
+ if (recipientEntries.size() < addresses.size()) {
+ // Run a directory query for each unmatched recipient.
+ HashSet unresolvedAddresses = new HashSet();
+ for (String address : addresses) {
+ if (!recipientEntries.containsKey(address)) {
+ unresolvedAddresses.add(address);
+ }
+ }
+ matchesNotFound.addAll(unresolvedAddresses);
+
+ final List paramsList;
+ Cursor directoryCursor = null;
+ try {
+ if (ChipsUtil.hasPermissions(context, permissionsCheckListener)) {
+ directoryCursor = context.getContentResolver().query(
+ DirectoryListQuery.URI, DirectoryListQuery.PROJECTION,
+ null, null, null);
+ }
+ if (directoryCursor == null) {
+ return;
+ }
+ paramsList = BaseRecipientAdapter.setupOtherDirectories(
+ context, directoryCursor, account);
+ } finally {
+ if (directoryCursor != null) {
+ directoryCursor.close();
+ }
+ }
+
+ if (paramsList != null) {
+ Cursor directoryContactsCursor = null;
+ for (String unresolvedAddress : unresolvedAddresses) {
+ for (int i = 0; i < paramsList.size(); i++) {
+ final long directoryId = paramsList.get(i).directoryId;
+ try {
+ directoryContactsCursor = doQuery(unresolvedAddress, 1 /* limit */,
+ directoryId, account, context, query, permissionsCheckListener);
+ if (directoryContactsCursor != null
+ && directoryContactsCursor.getCount() != 0) {
+ // We found the directory with at least one contact
+ final Map entries =
+ processContactEntries(directoryContactsCursor, directoryId);
+
+ for (final String address : entries.keySet()) {
+ matchesNotFound.remove(address);
+ }
+
+ callback.matchesFound(entries);
+ break;
+ }
+ } finally {
+ if (directoryContactsCursor != null) {
+ directoryContactsCursor.close();
+ directoryContactsCursor = null;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public static void getMatchingRecipientsFromExtensionMatcher(BaseRecipientAdapter adapter,
+ Set matchesNotFound, RecipientMatchCallback callback) {
+ // If no matches found in contact provider or the directories, try the extension
+ // matcher.
+ // todo (aalbert): This whole method needs to be in the adapter?
+ if (adapter != null) {
+ final Map entries =
+ adapter.getMatchingRecipients(matchesNotFound);
+ if (entries != null && entries.size() > 0) {
+ callback.matchesFound(entries);
+ for (final String address : entries.keySet()) {
+ matchesNotFound.remove(address);
+ }
+ }
+ }
+ callback.matchesNotFound(matchesNotFound);
+ }
+
+ private static HashMap processContactEntries(Cursor c,
+ Long directoryId) {
+ HashMap recipientEntries = new HashMap();
+ if (c != null && c.moveToFirst()) {
+ do {
+ String address = c.getString(Queries.Query.DESTINATION);
+
+ final RecipientEntry newRecipientEntry = RecipientEntry.constructTopLevelEntry(
+ c.getString(Queries.Query.NAME),
+ c.getInt(Queries.Query.DISPLAY_NAME_SOURCE),
+ c.getString(Queries.Query.DESTINATION),
+ c.getInt(Queries.Query.DESTINATION_TYPE),
+ c.getString(Queries.Query.DESTINATION_LABEL),
+ c.getLong(Queries.Query.CONTACT_ID),
+ directoryId,
+ c.getLong(Queries.Query.DATA_ID),
+ c.getString(Queries.Query.PHOTO_THUMBNAIL_URI),
+ true,
+ c.getString(Queries.Query.LOOKUP_KEY));
+
+ /*
+ * In certain situations, we may have two results for one address, where one of the
+ * results is just the email address, and the other has a name and photo, so we want
+ * to use the better one.
+ */
+ final RecipientEntry recipientEntry =
+ getBetterRecipient(recipientEntries.get(address), newRecipientEntry);
+
+ recipientEntries.put(address, recipientEntry);
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Received reverse look up information for " + address
+ + " RESULTS: "
+ + " NAME : " + c.getString(Queries.Query.NAME)
+ + " CONTACT ID : " + c.getLong(Queries.Query.CONTACT_ID)
+ + " ADDRESS :" + c.getString(Queries.Query.DESTINATION));
+ }
+ } while (c.moveToNext());
+ }
+ return recipientEntries;
+ }
+
+ /**
+ * Given two {@link RecipientEntry}s for the same email address, this will return the one that
+ * contains more complete information for display purposes. Defaults to entry2 if
+ * no significant differences are found.
+ */
+ static RecipientEntry getBetterRecipient(final RecipientEntry entry1,
+ final RecipientEntry entry2) {
+ // If only one has passed in, use it
+ if (entry2 == null) {
+ return entry1;
+ }
+
+ if (entry1 == null) {
+ return entry2;
+ }
+
+ // If only one has a display name, use it
+ if (!TextUtils.isEmpty(entry1.getDisplayName())
+ && TextUtils.isEmpty(entry2.getDisplayName())) {
+ return entry1;
+ }
+
+ if (!TextUtils.isEmpty(entry2.getDisplayName())
+ && TextUtils.isEmpty(entry1.getDisplayName())) {
+ return entry2;
+ }
+
+ // If only one has a display name that is not the same as the destination, use it
+ if (!TextUtils.equals(entry1.getDisplayName(), entry1.getDestination())
+ && TextUtils.equals(entry2.getDisplayName(), entry2.getDestination())) {
+ return entry1;
+ }
+
+ if (!TextUtils.equals(entry2.getDisplayName(), entry2.getDestination())
+ && TextUtils.equals(entry1.getDisplayName(), entry1.getDestination())) {
+ return entry2;
+ }
+
+ // If only one has a photo, use it
+ if ((entry1.getPhotoThumbnailUri() != null || entry1.getPhotoBytes() != null)
+ && (entry2.getPhotoThumbnailUri() == null && entry2.getPhotoBytes() == null)) {
+ return entry1;
+ }
+
+ if ((entry2.getPhotoThumbnailUri() != null || entry2.getPhotoBytes() != null)
+ && (entry1.getPhotoThumbnailUri() == null && entry1.getPhotoBytes() == null)) {
+ return entry2;
+ }
+
+ // Go with the second option as a default
+ return entry2;
+ }
+
+ private static Cursor doQuery(CharSequence constraint, int limit, Long directoryId,
+ Account account, Context context, Query query,
+ ChipsUtil.PermissionsCheckListener permissionsCheckListener) {
+ if (!ChipsUtil.hasPermissions(context, permissionsCheckListener)) {
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Not doing query because we don't have required permissions.");
+ }
+ return null;
+ }
+ final Uri.Builder builder = query
+ .getContentFilterUri()
+ .buildUpon()
+ .appendPath(constraint.toString())
+ .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
+ String.valueOf(limit + BaseRecipientAdapter.ALLOWANCE_FOR_DUPLICATES));
+ if (directoryId != null) {
+ builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
+ String.valueOf(directoryId));
+ }
+ if (account != null) {
+ builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_NAME, account.name);
+ builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_TYPE, account.type);
+ }
+ return context.getContentResolver()
+ .query(builder.build(), query.getProjection(), null, null, null);
+ }
+
+ public RecipientAlternatesAdapter(Context context, long contactId, Long directoryId,
+ String lookupKey, long currentId, int queryMode, OnCheckedItemChangedListener listener,
+ DropdownChipLayouter dropdownChipLayouter,
+ ChipsUtil.PermissionsCheckListener permissionsCheckListener) {
+ this(context, contactId, directoryId, lookupKey, currentId, queryMode, listener,
+ dropdownChipLayouter, null, permissionsCheckListener);
+ }
+
+ public RecipientAlternatesAdapter(Context context, long contactId, Long directoryId,
+ String lookupKey, long currentId, int queryMode, OnCheckedItemChangedListener listener,
+ DropdownChipLayouter dropdownChipLayouter, StateListDrawable deleteDrawable,
+ ChipsUtil.PermissionsCheckListener permissionsCheckListener) {
+ super(context,
+ getCursorForConstruction(context, contactId, directoryId, lookupKey, queryMode,
+ permissionsCheckListener),
+ 0);
+ mCurrentId = currentId;
+ mDirectoryId = directoryId;
+ mCheckedItemChangedListener = listener;
+
+ mDropdownChipLayouter = dropdownChipLayouter;
+ mDeleteDrawable = deleteDrawable;
+ }
+
+ private static Cursor getCursorForConstruction(Context context, long contactId,
+ Long directoryId, String lookupKey, int queryType,
+ ChipsUtil.PermissionsCheckListener permissionsCheckListener) {
+ final Uri uri;
+ final String desiredMimeType;
+ final String[] projection;
+
+ if (queryType == QUERY_TYPE_EMAIL) {
+ projection = Queries.EMAIL.getProjection();
+
+ if (directoryId == null || lookupKey == null) {
+ uri = Queries.EMAIL.getContentUri();
+ desiredMimeType = null;
+ } else {
+ uri = Contacts.getLookupUri(contactId, lookupKey)
+ .buildUpon()
+ .appendPath(Contacts.Entity.CONTENT_DIRECTORY)
+ .appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId))
+ .build();
+ desiredMimeType = ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE;
+ }
+ } else {
+ projection = Queries.PHONE.getProjection();
+
+ if (lookupKey == null) {
+ uri = Queries.PHONE.getContentUri();
+ desiredMimeType = null;
+ } else {
+ uri = Contacts.getLookupUri(contactId, lookupKey)
+ .buildUpon()
+ .appendPath(Contacts.Entity.CONTENT_DIRECTORY)
+ .appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId))
+ .build();
+ desiredMimeType = ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE;
+ }
+ }
+
+ final String selection = new StringBuilder()
+ .append(projection[Queries.Query.CONTACT_ID])
+ .append(" = ?")
+ .toString();
+ final Cursor cursor;
+ if (ChipsUtil.hasPermissions(context, permissionsCheckListener)) {
+ cursor = context.getContentResolver().query(
+ uri, projection, selection, new String[] {String.valueOf(contactId)}, null);
+ } else {
+ cursor = new MatrixCursor(projection);
+ }
+
+ final Cursor resultCursor = removeUndesiredDestinations(cursor, desiredMimeType, lookupKey);
+ cursor.close();
+
+ return resultCursor;
+ }
+
+ /**
+ * @return a new cursor based on the given cursor with all duplicate destinations removed.
+ *
+ * It's only intended to use for the alternate list, so...
+ * - This method ignores all other fields and dedupe solely on the destination. Normally,
+ * if a cursor contains multiple contacts and they have the same destination, we'd still want
+ * to show both.
+ * - This method creates a MatrixCursor, so all data will be kept in memory. We wouldn't want
+ * to do this if the original cursor is large, but it's okay here because the alternate list
+ * won't be that big.
+ *
+ * @param desiredMimeType If this is non-null, only entries with this mime type
+ * will be added to the cursor
+ * @param lookupKey The lookup key used for this contact if there isn't one in the cursor. This
+ * should be the same one used in the query that returned the cursor
+ */
+ // Visible for testing
+ static Cursor removeUndesiredDestinations(final Cursor original, final String desiredMimeType,
+ final String lookupKey) {
+ final MatrixCursor result = new MatrixCursor(
+ original.getColumnNames(), original.getCount());
+ final HashSet destinationsSeen = new HashSet();
+
+ String defaultDisplayName = null;
+ String defaultPhotoThumbnailUri = null;
+ int defaultDisplayNameSource = 0;
+
+ // Find some nice defaults in case we need them
+ original.moveToPosition(-1);
+ while (original.moveToNext()) {
+ final String mimeType = original.getString(Query.MIME_TYPE);
+
+ if (ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals(
+ mimeType)) {
+ // Store this data
+ defaultDisplayName = original.getString(Query.NAME);
+ defaultPhotoThumbnailUri = original.getString(Query.PHOTO_THUMBNAIL_URI);
+ defaultDisplayNameSource = original.getInt(Query.DISPLAY_NAME_SOURCE);
+ break;
+ }
+ }
+
+ original.moveToPosition(-1);
+ while (original.moveToNext()) {
+ if (desiredMimeType != null) {
+ final String mimeType = original.getString(Query.MIME_TYPE);
+ if (!desiredMimeType.equals(mimeType)) {
+ continue;
+ }
+ }
+ final String destination = original.getString(Query.DESTINATION);
+ if (destinationsSeen.contains(destination)) {
+ continue;
+ }
+ destinationsSeen.add(destination);
+
+ final Object[] row = new Object[] {
+ original.getString(Query.NAME),
+ original.getString(Query.DESTINATION),
+ original.getInt(Query.DESTINATION_TYPE),
+ original.getString(Query.DESTINATION_LABEL),
+ original.getLong(Query.CONTACT_ID),
+ original.getLong(Query.DATA_ID),
+ original.getString(Query.PHOTO_THUMBNAIL_URI),
+ original.getInt(Query.DISPLAY_NAME_SOURCE),
+ original.getString(Query.LOOKUP_KEY),
+ original.getString(Query.MIME_TYPE)
+ };
+
+ if (row[Query.NAME] == null) {
+ row[Query.NAME] = defaultDisplayName;
+ }
+ if (row[Query.PHOTO_THUMBNAIL_URI] == null) {
+ row[Query.PHOTO_THUMBNAIL_URI] = defaultPhotoThumbnailUri;
+ }
+ if ((Integer) row[Query.DISPLAY_NAME_SOURCE] == 0) {
+ row[Query.DISPLAY_NAME_SOURCE] = defaultDisplayNameSource;
+ }
+ if (row[Query.LOOKUP_KEY] == null) {
+ row[Query.LOOKUP_KEY] = lookupKey;
+ }
+
+ // Ensure we don't have two '?' like content://.../...?account_name=...?sz=...
+ final String photoThumbnailUri = (String) row[Query.PHOTO_THUMBNAIL_URI];
+ if (photoThumbnailUri != null) {
+ if (sCorrectedPhotoUris.containsKey(photoThumbnailUri)) {
+ row[Query.PHOTO_THUMBNAIL_URI] = sCorrectedPhotoUris.get(photoThumbnailUri);
+ } else if (photoThumbnailUri.indexOf('?') != photoThumbnailUri.lastIndexOf('?')) {
+ final String[] parts = photoThumbnailUri.split("\\?");
+ final StringBuilder correctedUriBuilder = new StringBuilder();
+ for (int i = 0; i < parts.length; i++) {
+ if (i == 1) {
+ correctedUriBuilder.append("?"); // We only want one of these
+ } else if (i > 1) {
+ correctedUriBuilder.append("&"); // And we want these elsewhere
+ }
+ correctedUriBuilder.append(parts[i]);
+ }
+
+ final String correctedUri = correctedUriBuilder.toString();
+ sCorrectedPhotoUris.put(photoThumbnailUri, correctedUri);
+ row[Query.PHOTO_THUMBNAIL_URI] = correctedUri;
+ }
+ }
+
+ result.addRow(row);
+ }
+
+ return result;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ Cursor c = getCursor();
+ if (c.moveToPosition(position)) {
+ c.getLong(Queries.Query.DATA_ID);
+ }
+ return -1;
+ }
+
+ public RecipientEntry getRecipientEntry(int position) {
+ Cursor c = getCursor();
+ c.moveToPosition(position);
+ return RecipientEntry.constructTopLevelEntry(
+ c.getString(Queries.Query.NAME),
+ c.getInt(Queries.Query.DISPLAY_NAME_SOURCE),
+ c.getString(Queries.Query.DESTINATION),
+ c.getInt(Queries.Query.DESTINATION_TYPE),
+ c.getString(Queries.Query.DESTINATION_LABEL),
+ c.getLong(Queries.Query.CONTACT_ID),
+ mDirectoryId,
+ c.getLong(Queries.Query.DATA_ID),
+ c.getString(Queries.Query.PHOTO_THUMBNAIL_URI),
+ true,
+ c.getString(Queries.Query.LOOKUP_KEY));
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Cursor cursor = getCursor();
+ cursor.moveToPosition(position);
+ if (convertView == null) {
+ convertView = mDropdownChipLayouter.newView(AdapterType.RECIPIENT_ALTERNATES);
+ }
+ if (cursor.getLong(Queries.Query.DATA_ID) == mCurrentId) {
+ mCheckedItemPosition = position;
+ if (mCheckedItemChangedListener != null) {
+ mCheckedItemChangedListener.onCheckedItemChanged(mCheckedItemPosition);
+ }
+ }
+ bindView(convertView, convertView.getContext(), cursor);
+ return convertView;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ int position = cursor.getPosition();
+ RecipientEntry entry = getRecipientEntry(position);
+
+ mDropdownChipLayouter.bindView(view, null, entry, position,
+ AdapterType.RECIPIENT_ALTERNATES, null, mDeleteDrawable);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return mDropdownChipLayouter.newView(AdapterType.RECIPIENT_ALTERNATES);
+ }
+
+ /*package*/ static interface OnCheckedItemChangedListener {
+ public void onCheckedItemChanged(int position);
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/RecipientEditTextView.java b/modules/chips/src/com/android/ex/chips/RecipientEditTextView.java
new file mode 100644
index 0000000..bc55bd4
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/RecipientEditTextView.java
@@ -0,0 +1,3516 @@
+/*
+
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.DialogFragment;
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Shader.TileMode;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Parcelable;
+import androidx.annotation.NonNull;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.Layout;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.text.method.QwertyKeyListener;
+import android.text.util.Rfc822Token;
+import android.text.util.Rfc822Tokenizer;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.ActionMode;
+import android.view.ActionMode.Callback;
+import android.view.DragEvent;
+import android.view.GestureDetector;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewParent;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.Filterable;
+import android.widget.ListAdapter;
+import android.widget.ListPopupWindow;
+import android.widget.ListView;
+import android.widget.MultiAutoCompleteTextView;
+import android.widget.PopupWindow;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import com.android.ex.chips.DropdownChipLayouter.PermissionRequestDismissedListener;
+import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback;
+import com.android.ex.chips.recipientchip.DrawableRecipientChip;
+import com.android.ex.chips.recipientchip.InvisibleRecipientChip;
+import com.android.ex.chips.recipientchip.ReplacementDrawableSpan;
+import com.android.ex.chips.recipientchip.VisibleRecipientChip;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * RecipientEditTextView is an auto complete text view for use with applications
+ * that use the new Chips UI for addressing a message to recipients.
+ */
+public class RecipientEditTextView extends MultiAutoCompleteTextView implements
+ OnItemClickListener, Callback, RecipientAlternatesAdapter.OnCheckedItemChangedListener,
+ GestureDetector.OnGestureListener, TextView.OnEditorActionListener,
+ DropdownChipLayouter.ChipDeleteListener, PermissionRequestDismissedListener {
+ private static final String TAG = "RecipientEditTextView";
+
+ private static final char COMMIT_CHAR_COMMA = ',';
+ private static final char COMMIT_CHAR_SEMICOLON = ';';
+ private static final char COMMIT_CHAR_SPACE = ' ';
+ private static final String SEPARATOR = String.valueOf(COMMIT_CHAR_COMMA)
+ + String.valueOf(COMMIT_CHAR_SPACE);
+
+ private static final int DISMISS = "dismiss".hashCode();
+ private static final long DISMISS_DELAY = 300;
+
+ // TODO: get correct number/ algorithm from with UX.
+ // Visible for testing.
+ /*package*/ static final int CHIP_LIMIT = 2;
+
+ private static final int MAX_CHIPS_PARSED = 50;
+ public static final String STATE_TEXT_VIEW = "savedTextView";
+ public static final String STATE_CURRENT_WARNING_TEXT = "savedCurrentWarningText";
+
+ private int mUnselectedChipTextColor;
+ private int mUnselectedChipBackgroundColor;
+
+ // Work variables to avoid re-allocation on every typed character.
+ private final Rect mRect = new Rect();
+ private final int[] mCoords = new int[2];
+
+ // Resources for displaying chips.
+ private Drawable mChipBackground = null;
+ private Drawable mChipDelete = null;
+ private Drawable mInvalidChipBackground;
+
+ // Possible attr overrides
+ private float mChipHeight;
+ private float mChipFontSize;
+ private float mLineSpacingExtra;
+ private int mChipTextStartPadding;
+ private int mChipTextEndPadding;
+ private final int mTextHeight;
+ private boolean mDisableDelete;
+ private int mMaxLines;
+ private int mWarningIconHeight;
+
+ /**
+ * Enumerator for avatar position. See attr.xml for more details.
+ * 0 for end, 1 for start.
+ */
+ private int mAvatarPosition;
+ private static final int AVATAR_POSITION_END = 0;
+ private static final int AVATAR_POSITION_START = 1;
+
+ private Paint mWorkPaint = new Paint();
+
+ private Tokenizer mTokenizer;
+ private Validator mValidator;
+ private Handler mHandler;
+ private TextWatcher mTextWatcher;
+ protected DropdownChipLayouter mDropdownChipLayouter;
+
+ private View mDropdownAnchor = this;
+ private ListPopupWindow mAlternatesPopup;
+ private ListPopupWindow mAddressPopup;
+ private View mAlternatePopupAnchor;
+ private OnItemClickListener mAlternatesListener;
+
+ private DrawableRecipientChip mSelectedChip;
+ private Bitmap mDefaultContactPhoto;
+ private Bitmap mWarningIcon;
+ private ReplacementDrawableSpan mMoreChip;
+ private TextView mMoreItem;
+
+ private int mCurrentSuggestionCount;
+
+ // VisibleForTesting
+ final ArrayList mPendingChips = new ArrayList();
+
+ private int mPendingChipsCount = 0;
+ private int mCheckedItem;
+ private boolean mNoChipMode = false;
+ private boolean mShouldShrink = true;
+ private boolean mRequiresShrinkWhenNotGone = false;
+
+ // VisibleForTesting
+ ArrayList mTemporaryRecipients;
+
+ private ArrayList mHiddenSpans;
+
+ // Chip copy fields.
+ private GestureDetector mGestureDetector;
+
+ // Obtain the enclosing scroll view, if it exists, so that the view can be
+ // scrolled to show the last line of chips content.
+ private ScrollView mScrollView;
+ private boolean mTriedGettingScrollView;
+ private boolean mDragEnabled = false;
+
+ private boolean mAttachedToWindow;
+
+ private final Runnable mAddTextWatcher = new Runnable() {
+ @Override
+ public void run() {
+ if (mTextWatcher == null) {
+ mTextWatcher = new RecipientTextWatcher();
+ addTextChangedListener(mTextWatcher);
+ }
+ }
+ };
+
+ private IndividualReplacementTask mIndividualReplacements;
+
+ private Runnable mHandlePendingChips = new Runnable() {
+
+ @Override
+ public void run() {
+ handlePendingChips();
+ }
+
+ };
+
+ private Runnable mDelayedShrink = new Runnable() {
+
+ @Override
+ public void run() {
+ shrink();
+ }
+
+ };
+
+ private RecipientEntryItemClickedListener mRecipientEntryItemClickedListener;
+
+ private RecipientChipAddedListener mRecipientChipAddedListener;
+ private RecipientChipDeletedListener mRecipientChipDeletedListener;
+
+ // A set of recipient addresses that are untrusted because they are outside of the user's
+ // domain. We will show a warning for these addresses in the recipient chips.
+ private Set mUntrustedAddresses = new HashSet<>();
+
+ private String mWarningTextTemplate = "";
+ private String mWarningTitle = "";
+ // Text of the warning dialog currently being displayed. Empty if no dialog currently displayed.
+ private String mCurrentWarningText = "";
+
+ /**
+ * Sets this recipient edit text view to display warning icons in chips for the given addresses.
+ *
+ * @param untrustedAddresses The addresses to display warning icons for.
+ * @param warningIcon The icon to show for each address.
+ * @param warningIconHeight Height of the warning icon in
+ * @param warningTextTemplate Text to display when warning icon is clicked.
+ * @param warningTitle Title to display for text when warning icon is clicked.
+ */
+ public void setUntrustedAddressWarning(
+ Set untrustedAddresses,
+ Bitmap warningIcon,
+ int warningIconHeight,
+ String warningTextTemplate,
+ String warningTitle) {
+ mUntrustedAddresses = untrustedAddresses;
+ mWarningIcon = warningIcon;
+ mWarningIconHeight = warningIconHeight;
+ mWarningTextTemplate = warningTextTemplate;
+ mWarningTitle = warningTitle;
+ }
+
+ public interface RecipientEntryItemClickedListener {
+ /**
+ * Callback that occurs whenever an auto-complete suggestion is clicked.
+ * @param charactersTyped the number of characters typed by the user to provide the
+ * auto-complete suggestions.
+ * @param position the position in the dropdown list that the user clicked
+ */
+ void onRecipientEntryItemClicked(int charactersTyped, int position);
+ }
+
+ private PermissionsRequestItemClickedListener mPermissionsRequestItemClickedListener;
+
+ /**
+ * Listener for handling clicks on the {@link RecipientEntry} that have
+ * {@link RecipientEntry#ENTRY_TYPE_PERMISSION_REQUEST} type.
+ */
+ public interface PermissionsRequestItemClickedListener {
+
+ /**
+ * Callback that occurs when user clicks the item that asks user to grant permissions to
+ * the app.
+ *
+ * @param view View that asks for permission.
+ */
+ void onPermissionsRequestItemClicked(RecipientEditTextView view, String[] permissions);
+
+ /**
+ * Callback that occurs when user dismisses the item that asks user to grant permissions to
+ * the app.
+ */
+ void onPermissionRequestDismissed();
+ }
+
+ /**
+ * Listener for handling deletion of chips in the recipient edit text.
+ */
+ public interface RecipientChipDeletedListener {
+ /**
+ * Callback that occurs when a chip is deleted.
+ * @param entry RecipientEntry that contains information about the chip.
+ */
+ void onRecipientChipDeleted(RecipientEntry entry);
+ }
+
+ /**
+ * Listener for handling addition of chips in the recipient edit text.
+ */
+ public interface RecipientChipAddedListener {
+ /**
+ * Callback that occurs when a chip is added.
+ *
+ * @param entry RecipientEntry that contains information about the chip.
+ */
+ void onRecipientChipAdded(RecipientEntry entry);
+ }
+
+ public RecipientEditTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setChipDimensions(context, attrs);
+ mTextHeight = calculateTextHeight();
+ mAlternatesPopup = new ListPopupWindow(context);
+ setupPopupWindow(mAlternatesPopup);
+ mAddressPopup = new ListPopupWindow(context);
+ setupPopupWindow(mAddressPopup);
+ mAlternatesListener = new OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView> adapterView,View view, int position,
+ long rowId) {
+ mAlternatesPopup.setOnItemClickListener(null);
+ replaceChip(mSelectedChip, ((RecipientAlternatesAdapter) adapterView.getAdapter())
+ .getRecipientEntry(position));
+ Message delayed = Message.obtain(mHandler, DISMISS);
+ delayed.obj = mAlternatesPopup;
+ mHandler.sendMessageDelayed(delayed, DISMISS_DELAY);
+ clearComposingText();
+ }
+ };
+ setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ setOnItemClickListener(this);
+ setCustomSelectionActionModeCallback(this);
+ mHandler = new Handler() {
+ @Override
+ public void handleMessage(Message msg) {
+ if (msg.what == DISMISS) {
+ ((ListPopupWindow) msg.obj).dismiss();
+ return;
+ }
+ super.handleMessage(msg);
+ }
+ };
+ mTextWatcher = new RecipientTextWatcher();
+ addTextChangedListener(mTextWatcher);
+ mGestureDetector = new GestureDetector(context, this);
+ setOnEditorActionListener(this);
+
+ setDropdownChipLayouter(new DropdownChipLayouter(LayoutInflater.from(context), context));
+ }
+
+ private void setupPopupWindow(ListPopupWindow popup) {
+ popup.setOnDismissListener(new PopupWindow.OnDismissListener() {
+ @Override
+ public void onDismiss() {
+ clearSelectedChip();
+ }
+ });
+ }
+
+ private int calculateTextHeight() {
+ final TextPaint paint = getPaint();
+
+ mRect.setEmpty();
+ // First measure the bounds of a sample text.
+ final String textHeightSample = "a";
+ paint.getTextBounds(textHeightSample, 0, textHeightSample.length(), mRect);
+
+ mRect.left = 0;
+ mRect.right = 0;
+
+ return mRect.height();
+ }
+
+ public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) {
+ mDropdownChipLayouter = dropdownChipLayouter;
+ mDropdownChipLayouter.setDeleteListener(this);
+ mDropdownChipLayouter.setPermissionRequestDismissedListener(this);
+ }
+
+ public void setRecipientEntryItemClickedListener(RecipientEntryItemClickedListener listener) {
+ mRecipientEntryItemClickedListener = listener;
+ }
+
+ public void setPermissionsRequestItemClickedListener(
+ PermissionsRequestItemClickedListener listener) {
+ mPermissionsRequestItemClickedListener = listener;
+ }
+
+ public void setRecipientChipAddedListener(RecipientChipAddedListener listener) {
+ mRecipientChipAddedListener = listener;
+ }
+
+ public void setRecipientChipDeletedListener(RecipientChipDeletedListener listener) {
+ mRecipientChipDeletedListener = listener;
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mAttachedToWindow = false;
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mAttachedToWindow = true;
+
+ final int anchorId = getDropDownAnchor();
+ if (anchorId != View.NO_ID) {
+ mDropdownAnchor = getRootView().findViewById(anchorId);
+ }
+ }
+
+ @Override
+ public void setDropDownAnchor(int anchorId) {
+ super.setDropDownAnchor(anchorId);
+ if (anchorId != View.NO_ID) {
+ mDropdownAnchor = getRootView().findViewById(anchorId);
+ }
+ }
+
+ @Override
+ public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) {
+ if (action == EditorInfo.IME_ACTION_DONE) {
+ if (commitDefault()) {
+ return true;
+ }
+ if (mSelectedChip != null) {
+ clearSelectedChip();
+ return true;
+ } else if (hasFocus()) {
+ if (focusNext()) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) {
+ InputConnection connection = super.onCreateInputConnection(outAttrs);
+ int imeActions = outAttrs.imeOptions&EditorInfo.IME_MASK_ACTION;
+ if ((imeActions&EditorInfo.IME_ACTION_DONE) != 0) {
+ // clear the existing action
+ outAttrs.imeOptions ^= imeActions;
+ // set the DONE action
+ outAttrs.imeOptions |= EditorInfo.IME_ACTION_DONE;
+ }
+ if ((outAttrs.imeOptions&EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) {
+ outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
+ }
+
+ outAttrs.actionId = EditorInfo.IME_ACTION_DONE;
+
+ // Custom action labels are discouraged in L; a checkmark icon is shown in place of the
+ // custom text in this case.
+ outAttrs.actionLabel = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? null :
+ getContext().getString(R.string.action_label);
+ return connection;
+ }
+
+ /*package*/ DrawableRecipientChip getLastChip() {
+ DrawableRecipientChip last = null;
+ DrawableRecipientChip[] chips = getSortedRecipients();
+ if (chips != null && chips.length > 0) {
+ last = chips[chips.length - 1];
+ }
+ return last;
+ }
+
+ /**
+ * @return The list of {@link RecipientEntry}s that have been selected by the user.
+ */
+ public List getSelectedRecipients() {
+ DrawableRecipientChip[] chips =
+ getText().getSpans(0, getText().length(), DrawableRecipientChip.class);
+ List results = new ArrayList();
+ if (chips == null) {
+ return results;
+ }
+
+ for (DrawableRecipientChip c : chips) {
+ results.add(c.getEntry());
+ }
+
+ return results;
+ }
+
+ /**
+ * @return The list of {@link RecipientEntry}s that have been selected by the user and also
+ * hidden due to {@link #mMoreChip} span.
+ */
+ public List getAllRecipients() {
+ List results = getSelectedRecipients();
+
+ if (mHiddenSpans != null) {
+ for (DrawableRecipientChip chip : mHiddenSpans) {
+ results.add(chip.getEntry());
+ }
+ }
+
+ return results;
+ }
+
+ @Override
+ public void onSelectionChanged(int start, int end) {
+ // When selection changes, see if it is inside the chips area.
+ // If so, move the cursor back after the chips again.
+ // Only exception is when we change the selection due to a selected chip.
+ DrawableRecipientChip last = getLastChip();
+ if (mSelectedChip == null && last != null && start < getSpannable().getSpanEnd(last)) {
+ // Grab the last chip and set the cursor to after it.
+ setSelection(Math.min(getSpannable().getSpanEnd(last) + 1, getText().length()));
+ }
+ super.onSelectionChanged(start, end);
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+ Bundle savedInstanceState = (Bundle) state;
+ if (!TextUtils.isEmpty(getText())) {
+ super.onRestoreInstanceState(null);
+ } else {
+ super.onRestoreInstanceState(
+ savedInstanceState.getParcelable(STATE_TEXT_VIEW));
+ }
+ String savedWarningText = savedInstanceState.getString(
+ STATE_CURRENT_WARNING_TEXT);
+ if (!savedWarningText.isEmpty()) {
+ showWarningDialog(savedWarningText);
+ }
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ // If the user changes orientation while they are editing, just roll back the selection.
+ clearSelectedChip();
+ Bundle savedInstanceState = new Bundle();
+ savedInstanceState.putParcelable(STATE_TEXT_VIEW, super.onSaveInstanceState());
+ savedInstanceState.putString(STATE_CURRENT_WARNING_TEXT, mCurrentWarningText);
+ return savedInstanceState;
+ }
+
+ /**
+ * Convenience method: Append the specified text slice to the TextView's
+ * display buffer, upgrading it to BufferType.EDITABLE if it was
+ * not already editable. Commas are excluded as they are added automatically
+ * by the view.
+ */
+ @Override
+ public void append(CharSequence text, int start, int end) {
+ // We don't care about watching text changes while appending.
+ if (mTextWatcher != null) {
+ removeTextChangedListener(mTextWatcher);
+ }
+ super.append(text, start, end);
+ if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) {
+ String displayString = text.toString();
+
+ if (!displayString.trim().endsWith(String.valueOf(COMMIT_CHAR_COMMA))) {
+ // We have no separator, so we should add it
+ super.append(SEPARATOR, 0, SEPARATOR.length());
+ displayString += SEPARATOR;
+ }
+
+ if (!TextUtils.isEmpty(displayString)
+ && TextUtils.getTrimmedLength(displayString) > 0) {
+ mPendingChipsCount++;
+ mPendingChips.add(displayString);
+ }
+ }
+ // Put a message on the queue to make sure we ALWAYS handle pending
+ // chips.
+ if (mPendingChipsCount > 0) {
+ postHandlePendingChips();
+ }
+ mHandler.post(mAddTextWatcher);
+ }
+
+ @Override
+ public void onFocusChanged(boolean hasFocus, int direction, Rect previous) {
+ super.onFocusChanged(hasFocus, direction, previous);
+ if (!hasFocus) {
+ shrink();
+ } else {
+ expand();
+ }
+ }
+
+ @Override
+ public void setAdapter(@NonNull T adapter) {
+ super.setAdapter(adapter);
+ BaseRecipientAdapter baseAdapter = (BaseRecipientAdapter) adapter;
+ baseAdapter.registerUpdateObserver(new BaseRecipientAdapter.EntriesUpdatedObserver() {
+ @Override
+ public void onChanged(List entries) {
+ int suggestionCount = entries == null ? 0 : entries.size();
+
+ // Scroll the chips field to the top of the screen so
+ // that the user can see as many results as possible.
+ if (entries != null && entries.size() > 0) {
+ scrollBottomIntoView();
+ // Here the current suggestion count is still the old one since we update
+ // the count at the bottom of this function.
+ if (mCurrentSuggestionCount == 0) {
+ // Announce the new number of possible choices for accessibility.
+ announceForAccessibilityCompat(
+ getSuggestionDropdownOpenedVerbalization(suggestionCount));
+ }
+ }
+
+ // Is the dropdown closing?
+ if ((entries == null || entries.size() == 0)
+ // Here the current suggestion count is still the old one since we update
+ // the count at the bottom of this function.
+ && mCurrentSuggestionCount != 0
+ // If there is no text, there's no need to know if no suggestions are
+ // available.
+ && getText().length() > 0) {
+ announceForAccessibilityCompat(getResources().getString(
+ R.string.accessbility_suggestion_dropdown_closed));
+ }
+
+ if ((entries != null)
+ && (entries.size() == 1)
+ && (entries.get(0).getEntryType() ==
+ RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST)) {
+ // Do nothing; showing a single permissions entry. Resizing not required.
+ } else {
+ // Set the dropdown height to be the remaining height from the anchor to the
+ // bottom.
+ mDropdownAnchor.getLocationOnScreen(mCoords);
+ getWindowVisibleDisplayFrame(mRect);
+ setDropDownHeight(mRect.bottom - mCoords[1] - mDropdownAnchor.getHeight() -
+ getDropDownVerticalOffset());
+ }
+
+ mCurrentSuggestionCount = suggestionCount;
+ }
+ });
+ baseAdapter.setDropdownChipLayouter(mDropdownChipLayouter);
+ }
+
+ /**
+ * Return the accessibility verbalization when the suggestion dropdown is opened.
+ */
+ public String getSuggestionDropdownOpenedVerbalization(int suggestionCount) {
+ return getResources().getString(R.string.accessbility_suggestion_dropdown_opened);
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ private void announceForAccessibilityCompat(String text) {
+ final AccessibilityManager accessibilityManager =
+ (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
+ final boolean isAccessibilityOn = accessibilityManager.isEnabled();
+
+ if (isAccessibilityOn && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ final ViewParent parent = getParent();
+ if (parent != null) {
+ AccessibilityEvent event = AccessibilityEvent.obtain(
+ AccessibilityEvent.TYPE_ANNOUNCEMENT);
+ onInitializeAccessibilityEvent(event);
+ event.getText().add(text);
+ event.setContentDescription(null);
+ parent.requestSendAccessibilityEvent(this, event);
+ }
+ }
+ }
+
+ protected void scrollBottomIntoView() {
+ if (mScrollView != null && mShouldShrink) {
+ getLocationInWindow(mCoords);
+ // Desired position shows at least 1 line of chips below the action
+ // bar. We add excess padding to make sure this is always below other
+ // content.
+ final int height = getHeight();
+ final int currentPos = mCoords[1] + height;
+ mScrollView.getLocationInWindow(mCoords);
+ final int desiredPos = mCoords[1] + height / getLineCount();
+ if (currentPos > desiredPos) {
+ mScrollView.scrollBy(0, currentPos - desiredPos);
+ }
+ }
+ }
+
+ protected ScrollView getScrollView() {
+ return mScrollView;
+ }
+
+ @Override
+ public void performValidation() {
+ // Do nothing. Chips handles its own validation.
+ }
+
+ private void shrink() {
+ if (mTokenizer == null) {
+ return;
+ }
+ long contactId = mSelectedChip != null ? mSelectedChip.getEntry().getContactId() : -1;
+ if (mSelectedChip != null && contactId != RecipientEntry.INVALID_CONTACT
+ && (!isPhoneQuery() && contactId != RecipientEntry.GENERATED_CONTACT)) {
+ clearSelectedChip();
+ } else {
+ if (getWidth() <= 0) {
+ mHandler.removeCallbacks(mDelayedShrink);
+
+ if (getVisibility() == GONE) {
+ // We aren't going to have a width any time soon, so defer
+ // this until we're not GONE.
+ mRequiresShrinkWhenNotGone = true;
+ } else {
+ // We don't have the width yet which means the view hasn't been drawn yet
+ // and there is no reason to attempt to commit chips yet.
+ // This focus lost must be the result of an orientation change
+ // or an initial rendering.
+ // Re-post the shrink for later.
+ mHandler.post(mDelayedShrink);
+ }
+ return;
+ }
+ // Reset any pending chips as they would have been handled
+ // when the field lost focus.
+ if (mPendingChipsCount > 0) {
+ postHandlePendingChips();
+ } else {
+ Editable editable = getText();
+ int end = getSelectionEnd();
+ int start = mTokenizer.findTokenStart(editable, end);
+ DrawableRecipientChip[] chips =
+ getSpannable().getSpans(start, end, DrawableRecipientChip.class);
+ if ((chips == null || chips.length == 0)) {
+ Editable text = getText();
+ int whatEnd = mTokenizer.findTokenEnd(text, start);
+ // This token was already tokenized, so skip past the ending token.
+ if (whatEnd < text.length() && text.charAt(whatEnd) == ',') {
+ whatEnd = movePastTerminators(whatEnd);
+ }
+ // In the middle of chip; treat this as an edit
+ // and commit the whole token.
+ int selEnd = getSelectionEnd();
+ if (whatEnd != selEnd) {
+ handleEdit(start, whatEnd);
+ } else {
+ commitChip(start, end, editable);
+ }
+ }
+ }
+ mHandler.post(mAddTextWatcher);
+ }
+ createMoreChip();
+ }
+
+ private void expand() {
+ if (mShouldShrink) {
+ setMaxLines(Integer.MAX_VALUE);
+ }
+ removeMoreChip();
+ setCursorVisible(true);
+ Editable text = getText();
+ setSelection(text != null && text.length() > 0 ? text.length() : 0);
+ // If there are any temporary chips, try replacing them now that the user
+ // has expanded the field.
+ if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0) {
+ new RecipientReplacementTask().execute();
+ mTemporaryRecipients = null;
+ }
+ }
+
+ private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) {
+ paint.setTextSize(mChipFontSize);
+ if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Max width is negative: " + maxWidth);
+ }
+ return TextUtils.ellipsize(text, paint, maxWidth,
+ TextUtils.TruncateAt.END);
+ }
+
+ /**
+ * Creates a bitmap of the given contact on a selected chip.
+ *
+ * @param contact The recipient entry to pull data from.
+ * @param paint The paint to use to draw the bitmap.
+ */
+ private ChipBitmapContainer createChipBitmap(RecipientEntry contact, TextPaint paint) {
+ paint.setColor(getDefaultChipTextColor(contact));
+ ChipBitmapContainer bitmapContainer = createChipBitmap(contact, paint,
+ getChipBackground(contact), getDefaultChipBackgroundColor(contact));
+
+ if (bitmapContainer.loadIcon) {
+ loadAvatarIcon(contact, bitmapContainer);
+ }
+ return bitmapContainer;
+ }
+
+ private ChipBitmapContainer createChipBitmap(RecipientEntry contact, TextPaint paint,
+ Drawable overrideBackgroundDrawable, int backgroundColor) {
+ final ChipBitmapContainer result = new ChipBitmapContainer();
+
+ Drawable indicatorIcon = null;
+ int indicatorPadding = 0;
+ if (contact.getIndicatorIconId() != 0) {
+ indicatorIcon = getContext().getDrawable(contact.getIndicatorIconId());
+ indicatorIcon.setBounds(0, 0,
+ indicatorIcon.getIntrinsicWidth(), indicatorIcon.getIntrinsicHeight());
+ indicatorPadding = indicatorIcon.getBounds().width() + mChipTextEndPadding;
+ }
+
+ Rect backgroundPadding = new Rect();
+ if (overrideBackgroundDrawable != null) {
+ overrideBackgroundDrawable.getPadding(backgroundPadding);
+ }
+
+ // Ellipsize the text so that it takes AT MOST the entire width of the
+ // autocomplete text entry area. Make sure to leave space for padding
+ // on the sides.
+ int height = (int) mChipHeight;
+ // Since the icon is a square, it's width is equal to the maximum height it can be inside
+ // the chip. Don't include iconWidth for invalid contacts and when not displaying photos.
+ boolean displayIcon = contact.isValid() && contact.shouldDisplayIcon();
+ int iconWidth = displayIcon ?
+ height - backgroundPadding.top - backgroundPadding.bottom : 0;
+
+ final boolean shouldDisplayWarningIcon = mUntrustedAddresses.contains(
+ contact.getDestination());
+ final float warningIconWidth = shouldDisplayWarningIcon ? mWarningIconHeight : 0;
+ final float warningIconTopMargin = (mChipHeight - mWarningIconHeight) / 2f;
+ final float warningIconEndMargin = shouldDisplayWarningIcon ? mChipTextEndPadding : 0;
+
+ float[] widths = new float[1];
+ paint.getTextWidths(" ", widths);
+ CharSequence ellipsizedText = ellipsizeText(createChipDisplayText(contact), paint,
+ calculateAvailableWidth()
+ - iconWidth
+ - warningIconWidth
+ - warningIconEndMargin
+ - widths[0]
+ - backgroundPadding.left
+ - backgroundPadding.right
+ - indicatorPadding);
+ int textWidth = (int) paint.measureText(ellipsizedText, 0, ellipsizedText.length());
+
+ // Chip start padding is the same as the end padding if there is no contact image.
+ final int startPadding = displayIcon ? mChipTextStartPadding : mChipTextEndPadding;
+ // Make sure there is a minimum chip width so the user can ALWAYS
+ // tap a chip without difficulty.
+ int width = Math.max(iconWidth * 2,
+ textWidth
+ + startPadding
+ + mChipTextEndPadding
+ + iconWidth
+ + (int) warningIconWidth
+ + (int) warningIconEndMargin
+ + backgroundPadding.left
+ + backgroundPadding.right
+ + indicatorPadding);
+
+ // Create the background of the chip.
+ result.bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(result.bitmap);
+
+ // Check if the background drawable is set via attr
+ if (overrideBackgroundDrawable != null) {
+ overrideBackgroundDrawable.setBounds(0, 0, width, height);
+ overrideBackgroundDrawable.draw(canvas);
+ } else {
+ // Draw the default chip background
+ mWorkPaint.reset();
+ mWorkPaint.setColor(backgroundColor);
+ final float radius = height / 2;
+ canvas.drawRoundRect(new RectF(0, 0, width, height), radius, radius,
+ mWorkPaint);
+ }
+
+ // Draw the text vertically aligned
+ int textX = shouldPositionAvatarOnRight() ?
+ mChipTextEndPadding
+ + backgroundPadding.left
+ + indicatorPadding
+ + (int) warningIconWidth
+ + (int) warningIconEndMargin :
+ width
+ - backgroundPadding.right
+ - mChipTextEndPadding
+ - textWidth
+ - indicatorPadding
+ - (int) warningIconWidth
+ - (int) warningIconEndMargin;
+ canvas.drawText(ellipsizedText, 0, ellipsizedText.length(),
+ textX, getTextYOffset(height), paint);
+
+ if (indicatorIcon != null) {
+ int indicatorX = shouldPositionAvatarOnRight()
+ ? backgroundPadding.left + mChipTextEndPadding
+ : width - backgroundPadding.right - indicatorIcon.getBounds().width()
+ - mChipTextEndPadding;
+ int indicatorY = height / 2 - indicatorIcon.getBounds().height() / 2;
+ indicatorIcon.getBounds().offsetTo(indicatorX, indicatorY);
+ indicatorIcon.draw(canvas);
+ }
+
+ // Set the variables that are needed to draw the icon bitmap once it's loaded
+ final int iconX = shouldPositionAvatarOnRight() ?
+ width - backgroundPadding.right - iconWidth :
+ backgroundPadding.left;
+ result.left = iconX;
+ result.top = backgroundPadding.top;
+ result.right = iconX + iconWidth;
+ result.bottom = height - backgroundPadding.bottom;
+ result.loadIcon = displayIcon;
+
+ // Set the variables needed to draw the warning icon bitmap once it's loaded.
+ final float warningIconX = shouldPositionAvatarOnRight() ?
+ backgroundPadding.left + warningIconEndMargin :
+ width - backgroundPadding.right - warningIconWidth - warningIconEndMargin;
+ final float warningIconY = warningIconTopMargin;
+ result.warningIconLeft = warningIconX;
+ result.warningIconTop = warningIconY;
+ result.warningIconRight = warningIconX + warningIconWidth;
+ result.warningIconBottom = warningIconY + mWarningIconHeight;
+
+ return result;
+ }
+
+ /**
+ * Helper function that draws the loaded icon bitmap into the chips bitmap
+ */
+ private void drawIcon(ChipBitmapContainer bitMapResult, Bitmap icon) {
+ if (icon == null) {
+ return;
+ }
+ final Canvas canvas = new Canvas(bitMapResult.bitmap);
+ final RectF src = new RectF(0, 0, icon.getWidth(), icon.getHeight());
+ final RectF dst = new RectF(bitMapResult.left, bitMapResult.top, bitMapResult.right,
+ bitMapResult.bottom);
+ drawCircularIconOnCanvas(icon, canvas, src, dst);
+ }
+
+ /**
+ * Draws the warning icon onto the chip's bitmap and returns the rectangle it drew on.
+ */
+ private RectF drawWarningIcon(ChipBitmapContainer bitMapResult) {
+ if (mWarningIcon == null) {
+ return new RectF(0, 0, 0, 0);
+ }
+ final Canvas canvas = new Canvas(bitMapResult.bitmap);
+ final RectF src = new RectF(0, 0, mWarningIcon.getWidth(), mWarningIcon.getHeight());
+ final RectF dst = new RectF(bitMapResult.warningIconLeft, bitMapResult.warningIconTop,
+ bitMapResult.warningIconRight, bitMapResult.warningIconBottom);
+ drawRectanglularIconOnCanvas(mWarningIcon, canvas, src, dst);
+ return dst;
+ }
+
+ /**
+ * Returns true if the avatar should be positioned at the right edge of the chip.
+ * Takes into account both the set avatar position (start or end) as well as whether
+ * the layout direction is LTR or RTL.
+ */
+ private boolean shouldPositionAvatarOnRight() {
+ final boolean isRtl = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 &&
+ getLayoutDirection() == LAYOUT_DIRECTION_RTL;
+ final boolean assignedPosition = mAvatarPosition == AVATAR_POSITION_END;
+ // If in Rtl mode, the position should be flipped.
+ return isRtl ? !assignedPosition : assignedPosition;
+ }
+
+ /**
+ * Returns the avatar icon to use for this recipient entry. Returns null if we don't want to
+ * draw an icon for this recipient.
+ */
+ private void loadAvatarIcon(final RecipientEntry contact,
+ final ChipBitmapContainer bitmapContainer) {
+ // Don't draw photos for recipients that have been typed in OR generated on the fly.
+ long contactId = contact.getContactId();
+ boolean drawPhotos = isPhoneQuery() ?
+ contactId != RecipientEntry.INVALID_CONTACT
+ : (contactId != RecipientEntry.INVALID_CONTACT
+ && contactId != RecipientEntry.GENERATED_CONTACT);
+
+ if (drawPhotos) {
+ final byte[] origPhotoBytes = contact.getPhotoBytes();
+ // There may not be a photo yet if anything but the first contact address
+ // was selected.
+ if (origPhotoBytes == null) {
+ // TODO: cache this in the recipient entry?
+ getAdapter().fetchPhoto(contact, new PhotoManager.PhotoManagerCallback() {
+ @Override
+ public void onPhotoBytesPopulated() {
+ // Call through to the async version which will ensure
+ // proper threading.
+ onPhotoBytesAsynchronouslyPopulated();
+ }
+
+ @Override
+ public void onPhotoBytesAsynchronouslyPopulated() {
+ final byte[] loadedPhotoBytes = contact.getPhotoBytes();
+ final Bitmap icon = BitmapFactory.decodeByteArray(loadedPhotoBytes, 0,
+ loadedPhotoBytes.length);
+ tryDrawAndInvalidate(icon);
+ }
+
+ @Override
+ public void onPhotoBytesAsyncLoadFailed() {
+ // TODO: can the scaled down default photo be cached?
+ tryDrawAndInvalidate(mDefaultContactPhoto);
+ }
+
+ private void tryDrawAndInvalidate(Bitmap icon) {
+ drawIcon(bitmapContainer, icon);
+ // The caller might originated from a background task. However, if the
+ // background task has already completed, the view might be already drawn
+ // on the UI but the callback would happen on the background thread.
+ // So if we are on a background thread, post an invalidate call to the UI.
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ // The view might not redraw itself since it's loaded asynchronously
+ invalidate();
+ } else {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ invalidate();
+ }
+ });
+ }
+ }
+ });
+ } else {
+ final Bitmap icon = BitmapFactory.decodeByteArray(origPhotoBytes, 0,
+ origPhotoBytes.length);
+ drawIcon(bitmapContainer, icon);
+ }
+ }
+ }
+
+ /**
+ * Get the background drawable for a RecipientChip.
+ */
+ // Visible for testing.
+ /* package */Drawable getChipBackground(RecipientEntry contact) {
+ return contact.isValid() ? mChipBackground : mInvalidChipBackground;
+ }
+
+ private int getDefaultChipTextColor(RecipientEntry contact) {
+ return contact.isValid() ? mUnselectedChipTextColor :
+ getResources().getColor(android.R.color.black);
+ }
+
+ private int getDefaultChipBackgroundColor(RecipientEntry contact) {
+ return contact.isValid() ? mUnselectedChipBackgroundColor :
+ getResources().getColor(R.color.chip_background_invalid);
+ }
+
+ /**
+ * Given a height, returns a Y offset that will draw the text in the middle of the height.
+ */
+ protected float getTextYOffset(int height) {
+ return height - ((height - mTextHeight) / 2);
+ }
+
+ /**
+ * Draws the icon onto the canvas given the source rectangle of the bitmap and the destination
+ * rectangle of the canvas.
+ *
+ *
The icon is drawn as a circle.
+ */
+ protected void drawCircularIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst) {
+ setWorkPaintForIcon(icon, src, dst);
+ canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f, mWorkPaint);
+
+ final float borderWidth = 1f;
+ setWorkPaintForBorder(borderWidth);
+ canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f - borderWidth / 2,
+ mWorkPaint);
+
+ mWorkPaint.reset();
+ }
+
+ /**
+ * Draws the icon onto the canvas given the source rectangle of the bitmap and the destination
+ * rectangle of the canvas.
+ *
+ *
The icon is drawn as a rectangle.
+ */
+ private void drawRectanglularIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst) {
+ setWorkPaintForIcon(icon, src, dst);
+ canvas.drawRect(dst, mWorkPaint);
+
+ final float borderWidth = 1f;
+ setWorkPaintForBorder(borderWidth);
+ canvas.drawRect(dst, mWorkPaint);
+
+ mWorkPaint.reset();
+ }
+
+ /**
+ * Sets WorkPaint for drawing the icon from src onto dst.
+ */
+ private void setWorkPaintForIcon(Bitmap icon, RectF src, RectF dst) {
+ final Matrix matrix = new Matrix();
+
+ // Draw bitmap through shader first.
+ final BitmapShader shader = new BitmapShader(icon, TileMode.CLAMP, TileMode.CLAMP);
+ matrix.reset();
+
+ // Fit bitmap to bounds.
+ matrix.setRectToRect(src, dst, Matrix.ScaleToFit.FILL);
+
+ shader.setLocalMatrix(matrix);
+ mWorkPaint.reset();
+ mWorkPaint.setShader(shader);
+ mWorkPaint.setAntiAlias(true);
+ mWorkPaint.setFilterBitmap(true);
+ mWorkPaint.setDither(true);
+ }
+
+ /**
+ * Sets WorkPaint for drawing the icon border with the given width.
+ */
+ private void setWorkPaintForBorder(float borderWidth) {
+ mWorkPaint.reset();
+ mWorkPaint.setColor(Color.TRANSPARENT);
+ mWorkPaint.setStyle(Style.STROKE);
+ mWorkPaint.setStrokeWidth(borderWidth);
+ mWorkPaint.setAntiAlias(true);
+ }
+
+ private DrawableRecipientChip constructChipSpan(RecipientEntry contact) {
+ TextPaint paint = getPaint();
+ float defaultSize = paint.getTextSize();
+ int defaultColor = paint.getColor();
+
+ ChipBitmapContainer bitmapContainer = createChipBitmap(contact, paint);
+ final Rect warningIconBounds = new Rect(0, 0, 0, 0);
+ if (mUntrustedAddresses.contains(contact.getDestination())) {
+ drawWarningIcon(bitmapContainer).round(warningIconBounds);
+ }
+ final Bitmap tmpBitmap = bitmapContainer.bitmap;
+
+ // Pass the full text, un-ellipsized, to the chip.
+ final int iconWidth = tmpBitmap != null ? tmpBitmap.getWidth() : 0;
+ final int iconHeight = tmpBitmap != null ? tmpBitmap.getHeight() : 0;
+ Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
+ result.setBounds(0, 0, iconWidth, iconHeight);
+ VisibleRecipientChip recipientChip =
+ new VisibleRecipientChip(result, contact);
+ recipientChip.setExtraMargin(mLineSpacingExtra);
+ // Return text to the original size.
+ paint.setTextSize(defaultSize);
+ paint.setColor(defaultColor);
+ // Put warning icon dimensions info in the chip
+ recipientChip.setWarningIconBounds(warningIconBounds);
+ return recipientChip;
+ }
+
+ /**
+ * Calculate the offset from bottom of the EditText to top of the provided line.
+ */
+ private int calculateOffsetFromBottomToTop(int line) {
+ return -(int) ((mChipHeight + (2 * mLineSpacingExtra)) * (Math
+ .abs(getLineCount() - line)) + getPaddingBottom());
+ }
+
+ /**
+ * Get the max amount of space a chip can take up. The formula takes into
+ * account the width of the EditTextView, any view padding, and padding
+ * that will be added to the chip.
+ */
+ private float calculateAvailableWidth() {
+ return getWidth() - getPaddingLeft() - getPaddingRight() - mChipTextStartPadding
+ - mChipTextEndPadding;
+ }
+
+
+ private void setChipDimensions(Context context, AttributeSet attrs) {
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecipientEditTextView, 0,
+ 0);
+ Resources r = getContext().getResources();
+
+ mChipBackground = a.getDrawable(R.styleable.RecipientEditTextView_chipBackground);
+ mInvalidChipBackground = a
+ .getDrawable(R.styleable.RecipientEditTextView_invalidChipBackground);
+ mChipDelete = a.getDrawable(R.styleable.RecipientEditTextView_chipDelete);
+ if (mChipDelete == null) {
+ mChipDelete = r.getDrawable(R.drawable.ic_cancel_wht_24dp);
+ }
+ mChipTextStartPadding = mChipTextEndPadding
+ = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipPadding, -1);
+ if (mChipTextStartPadding == -1) {
+ mChipTextStartPadding = mChipTextEndPadding =
+ (int) r.getDimension(R.dimen.chip_padding);
+ }
+ // xml-overrides for each individual padding
+ // TODO: add these to attr?
+ int overridePadding = (int) r.getDimension(R.dimen.chip_padding_start);
+ if (overridePadding >= 0) {
+ mChipTextStartPadding = overridePadding;
+ }
+ overridePadding = (int) r.getDimension(R.dimen.chip_padding_end);
+ if (overridePadding >= 0) {
+ mChipTextEndPadding = overridePadding;
+ }
+
+ mDefaultContactPhoto = BitmapFactory.decodeResource(r, R.drawable.ic_contact_picture);
+
+ mMoreItem = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.more_item, null);
+
+ mChipHeight = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipHeight, -1);
+ if (mChipHeight == -1) {
+ mChipHeight = r.getDimension(R.dimen.chip_height);
+ }
+ mChipFontSize = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipFontSize, -1);
+ if (mChipFontSize == -1) {
+ mChipFontSize = r.getDimension(R.dimen.chip_text_size);
+ }
+ mAvatarPosition =
+ a.getInt(R.styleable.RecipientEditTextView_avatarPosition, AVATAR_POSITION_START);
+ mDisableDelete = a.getBoolean(R.styleable.RecipientEditTextView_disableDelete, false);
+
+ mMaxLines = r.getInteger(R.integer.chips_max_lines);
+ mLineSpacingExtra = r.getDimensionPixelOffset(R.dimen.line_spacing_extra);
+
+ mUnselectedChipTextColor = a.getColor(
+ R.styleable.RecipientEditTextView_unselectedChipTextColor,
+ r.getColor(android.R.color.black));
+
+ mUnselectedChipBackgroundColor = a.getColor(
+ R.styleable.RecipientEditTextView_unselectedChipBackgroundColor,
+ r.getColor(R.color.chip_background));
+
+ a.recycle();
+ }
+
+ // Visible for testing.
+ /* package */ void setMoreItem(TextView moreItem) {
+ mMoreItem = moreItem;
+ }
+
+
+ // Visible for testing.
+ /* package */ void setChipBackground(Drawable chipBackground) {
+ mChipBackground = chipBackground;
+ }
+
+ // Visible for testing.
+ /* package */ void setChipHeight(int height) {
+ mChipHeight = height;
+ }
+
+ public float getChipHeight() {
+ return mChipHeight;
+ }
+
+ /** Returns whether view is in no-chip or chip mode. */
+ public boolean isNoChipMode() {
+ return mNoChipMode;
+ }
+
+ /**
+ * Set whether to shrink the recipients field such that at most
+ * one line of recipients chips are shown when the field loses
+ * focus. By default, the number of displayed recipients will be
+ * limited and a "more" chip will be shown when focus is lost.
+ * @param shrink
+ */
+ public void setOnFocusListShrinkRecipients(boolean shrink) {
+ mShouldShrink = shrink;
+ }
+
+ @Override
+ public void onSizeChanged(int width, int height, int oldw, int oldh) {
+ super.onSizeChanged(width, height, oldw, oldh);
+ if (width != 0 && height != 0) {
+ if (mPendingChipsCount > 0) {
+ postHandlePendingChips();
+ } else {
+ checkChipWidths();
+ }
+ }
+ // Try to find the scroll view parent, if it exists.
+ if (mScrollView == null && !mTriedGettingScrollView) {
+ ViewParent parent = getParent();
+ while (parent != null && !(parent instanceof ScrollView)) {
+ parent = parent.getParent();
+ }
+ if (parent != null) {
+ mScrollView = (ScrollView) parent;
+ }
+ mTriedGettingScrollView = true;
+ }
+ }
+
+ private void postHandlePendingChips() {
+ mHandler.removeCallbacks(mHandlePendingChips);
+ mHandler.post(mHandlePendingChips);
+ }
+
+ private void checkChipWidths() {
+ // Check the widths of the associated chips.
+ DrawableRecipientChip[] chips = getSortedRecipients();
+ if (chips != null) {
+ Rect bounds;
+ for (DrawableRecipientChip chip : chips) {
+ bounds = chip.getBounds();
+ if (getWidth() > 0 && bounds.right - bounds.left >
+ getWidth() - getPaddingLeft() - getPaddingRight()) {
+ // Need to redraw that chip.
+ replaceChip(chip, chip.getEntry());
+ }
+ }
+ }
+ }
+
+ // Visible for testing.
+ /*package*/ void handlePendingChips() {
+ if (getViewWidth() <= 0) {
+ // The widget has not been sized yet.
+ // This will be called as a result of onSizeChanged
+ // at a later point.
+ return;
+ }
+ if (mPendingChipsCount <= 0) {
+ return;
+ }
+
+ synchronized (mPendingChips) {
+ Editable editable = getText();
+ // Tokenize!
+ if (mPendingChipsCount <= MAX_CHIPS_PARSED) {
+ for (int i = 0; i < mPendingChips.size(); i++) {
+ String current = mPendingChips.get(i);
+ int tokenStart = editable.toString().indexOf(current);
+ // Always leave a space at the end between tokens.
+ int tokenEnd = tokenStart + current.length() - 1;
+ if (tokenStart >= 0) {
+ // When we have a valid token, include it with the token
+ // to the left.
+ if (tokenEnd < editable.length() - 2
+ && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) {
+ tokenEnd++;
+ }
+ createReplacementChip(tokenStart, tokenEnd, editable, i < CHIP_LIMIT
+ || !mShouldShrink);
+ }
+ mPendingChipsCount--;
+ }
+ sanitizeEnd();
+ } else {
+ mNoChipMode = true;
+ }
+
+ if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0
+ && mTemporaryRecipients.size() <= RecipientAlternatesAdapter.MAX_LOOKUPS) {
+ if (hasFocus() || mTemporaryRecipients.size() < CHIP_LIMIT) {
+ new RecipientReplacementTask().execute();
+ mTemporaryRecipients = null;
+ } else {
+ // Create the "more" chip
+ mIndividualReplacements = new IndividualReplacementTask();
+ mIndividualReplacements.execute(new ArrayList(
+ mTemporaryRecipients.subList(0, CHIP_LIMIT)));
+ if (mTemporaryRecipients.size() > CHIP_LIMIT) {
+ mTemporaryRecipients = new ArrayList(
+ mTemporaryRecipients.subList(CHIP_LIMIT,
+ mTemporaryRecipients.size()));
+ } else {
+ mTemporaryRecipients = null;
+ }
+ createMoreChip();
+ }
+ } else {
+ // There are too many recipients to look up, so just fall back
+ // to showing addresses for all of them.
+ mTemporaryRecipients = null;
+ createMoreChip();
+ }
+ mPendingChipsCount = 0;
+ mPendingChips.clear();
+ }
+ }
+
+ // Visible for testing.
+ /*package*/ int getViewWidth() {
+ return getWidth();
+ }
+
+ /**
+ * Remove any characters after the last valid chip.
+ */
+ // Visible for testing.
+ /*package*/ void sanitizeEnd() {
+ // Don't sanitize while we are waiting for pending chips to complete.
+ if (mPendingChipsCount > 0) {
+ return;
+ }
+ // Find the last chip; eliminate any commit characters after it.
+ DrawableRecipientChip[] chips = getSortedRecipients();
+ Spannable spannable = getSpannable();
+ if (chips != null && chips.length > 0) {
+ int end;
+ mMoreChip = getMoreChip();
+ if (mMoreChip != null) {
+ end = spannable.getSpanEnd(mMoreChip);
+ } else {
+ end = getSpannable().getSpanEnd(getLastChip());
+ }
+ Editable editable = getText();
+ int length = editable.length();
+ if (length > end) {
+ // See what characters occur after that and eliminate them.
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "There were extra characters after the last tokenizable entry."
+ + editable);
+ }
+ editable.delete(end + 1, length);
+ }
+ }
+ }
+
+ /**
+ * Create a chip that represents just the email address of a recipient. At some later
+ * point, this chip will be attached to a real contact entry, if one exists.
+ */
+ // VisibleForTesting
+ void createReplacementChip(int tokenStart, int tokenEnd, Editable editable,
+ boolean visible) {
+ if (alreadyHasChip(tokenStart, tokenEnd)) {
+ // There is already a chip present at this location.
+ // Don't recreate it.
+ return;
+ }
+ String token = editable.toString().substring(tokenStart, tokenEnd);
+ final String trimmedToken = token.trim();
+ int commitCharIndex = trimmedToken.lastIndexOf(COMMIT_CHAR_COMMA);
+ if (commitCharIndex != -1 && commitCharIndex == trimmedToken.length() - 1) {
+ token = trimmedToken.substring(0, trimmedToken.length() - 1);
+ }
+ RecipientEntry entry = createTokenizedEntry(token);
+ if (entry != null) {
+ DrawableRecipientChip chip = null;
+ try {
+ if (!mNoChipMode) {
+ chip = visible ? constructChipSpan(entry) : new InvisibleRecipientChip(entry);
+ }
+ } catch (NullPointerException e) {
+ Log.e(TAG, e.getMessage(), e);
+ }
+ editable.setSpan(chip, tokenStart, tokenEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ // Add this chip to the list of entries "to replace"
+ if (chip != null) {
+ if (mTemporaryRecipients == null) {
+ mTemporaryRecipients = new ArrayList();
+ }
+ chip.setOriginalText(token);
+ mTemporaryRecipients.add(chip);
+ }
+ }
+ }
+
+ // VisibleForTesting
+ RecipientEntry createTokenizedEntry(final String token) {
+ if (TextUtils.isEmpty(token)) {
+ return null;
+ }
+ if (isPhoneQuery() && PhoneUtil.isPhoneNumber(token)) {
+ return RecipientEntry.constructFakePhoneEntry(token, true);
+ }
+ Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(token);
+ boolean isValid = isValid(token);
+ if (isValid && tokens != null && tokens.length > 0) {
+ // If we can get a name from tokenizing, then generate an entry from
+ // this.
+ String display = tokens[0].getName();
+ if (!TextUtils.isEmpty(display)) {
+ return RecipientEntry.constructGeneratedEntry(display, tokens[0].getAddress(),
+ isValid);
+ } else {
+ display = tokens[0].getAddress();
+ if (!TextUtils.isEmpty(display)) {
+ return RecipientEntry.constructFakeEntry(display, isValid);
+ }
+ }
+ }
+ // Unable to validate the token or to create a valid token from it.
+ // Just create a chip the user can edit.
+ String validatedToken = null;
+ if (mValidator != null && !isValid) {
+ // Try fixing up the entry using the validator.
+ validatedToken = mValidator.fixText(token).toString();
+ if (!TextUtils.isEmpty(validatedToken)) {
+ if (validatedToken.contains(token)) {
+ // protect against the case of a validator with a null
+ // domain,
+ // which doesn't add a domain to the token
+ Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(validatedToken);
+ if (tokenized.length > 0) {
+ validatedToken = tokenized[0].getAddress();
+ isValid = true;
+ }
+ } else {
+ // We ran into a case where the token was invalid and
+ // removed
+ // by the validator. In this case, just use the original
+ // token
+ // and let the user sort out the error chip.
+ validatedToken = null;
+ isValid = false;
+ }
+ }
+ }
+ // Otherwise, fallback to just creating an editable email address chip.
+ return RecipientEntry.constructFakeEntry(
+ !TextUtils.isEmpty(validatedToken) ? validatedToken : token, isValid);
+ }
+
+ private boolean isValid(String text) {
+ return mValidator == null ? true : mValidator.isValid(text);
+ }
+
+ private static String tokenizeAddress(String destination) {
+ Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(destination);
+ if (tokens != null && tokens.length > 0) {
+ return tokens[0].getAddress();
+ }
+ return destination;
+ }
+
+ @Override
+ public void setTokenizer(Tokenizer tokenizer) {
+ mTokenizer = tokenizer;
+ super.setTokenizer(mTokenizer);
+ }
+
+ @Override
+ public void setValidator(Validator validator) {
+ mValidator = validator;
+ super.setValidator(validator);
+ }
+
+ /**
+ * We cannot use the default mechanism for replaceText. Instead,
+ * we override onItemClickListener so we can get all the associated
+ * contact information including display text, address, and id.
+ */
+ @Override
+ protected void replaceText(CharSequence text) {
+ return;
+ }
+
+ /**
+ * Dismiss any selected chips when the back key is pressed.
+ */
+ @Override
+ public boolean onKeyPreIme(int keyCode, @NonNull KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK && mSelectedChip != null) {
+ clearSelectedChip();
+ return true;
+ }
+ return super.onKeyPreIme(keyCode, event);
+ }
+
+ /**
+ * Monitor key presses in this view to see if the user types
+ * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER.
+ * If the user has entered text that has contact matches and types
+ * a commit key, create a chip from the topmost matching contact.
+ * If the user has entered text that has no contact matches and types
+ * a commit key, then create a chip from the text they have entered.
+ */
+ @Override
+ public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_TAB:
+ if (event.hasNoModifiers()) {
+ if (mSelectedChip != null) {
+ clearSelectedChip();
+ } else {
+ commitDefault();
+ }
+ }
+ break;
+ }
+ return super.onKeyUp(keyCode, event);
+ }
+
+ private boolean focusNext() {
+ View next = focusSearch(View.FOCUS_DOWN);
+ if (next != null) {
+ next.requestFocus();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Create a chip from the default selection. If the popup is showing, the
+ * default is the selected item (if one is selected), or the first item, in the popup
+ * suggestions list. Otherwise, it is whatever the user had typed in. End represents where the
+ * tokenizer should search for a token to turn into a chip.
+ * @return If a chip was created from a real contact.
+ */
+ private boolean commitDefault() {
+ // If there is no tokenizer, don't try to commit.
+ if (mTokenizer == null) {
+ return false;
+ }
+ Editable editable = getText();
+ int end = getSelectionEnd();
+ int start = mTokenizer.findTokenStart(editable, end);
+
+ if (shouldCreateChip(start, end)) {
+ int whatEnd = mTokenizer.findTokenEnd(getText(), start);
+ // In the middle of chip; treat this as an edit
+ // and commit the whole token.
+ whatEnd = movePastTerminators(whatEnd);
+ if (whatEnd != getSelectionEnd()) {
+ handleEdit(start, whatEnd);
+ return true;
+ }
+ return commitChip(start, end , editable);
+ }
+ return false;
+ }
+
+ private void commitByCharacter() {
+ // We can't possibly commit by character if we can't tokenize.
+ if (mTokenizer == null) {
+ return;
+ }
+ Editable editable = getText();
+ int end = getSelectionEnd();
+ int start = mTokenizer.findTokenStart(editable, end);
+ if (shouldCreateChip(start, end)) {
+ commitChip(start, end, editable);
+ }
+ setSelection(getText().length());
+ }
+
+ private boolean commitChip(int start, int end, Editable editable) {
+ int position = positionOfFirstEntryWithTypePerson();
+ if (position != -1 && enoughToFilter()
+ && end == getSelectionEnd() && !isPhoneQuery()
+ && !isValidEmailAddress(editable.toString().substring(start, end).trim())) {
+ // let's choose the selected or first entry if only the input text is NOT an email
+ // address so we won't try to replace the user's potentially correct but
+ // new/unencountered email input
+ final int selectedPosition = getListSelection();
+ if (selectedPosition == -1 || !isEntryAtPositionTypePerson(selectedPosition)) {
+ // Nothing is selected or selected item is not type person; use the first item
+ submitItemAtPosition(position);
+ } else {
+ submitItemAtPosition(selectedPosition);
+ }
+ dismissDropDown();
+ return true;
+ } else {
+ int tokenEnd = mTokenizer.findTokenEnd(editable, start);
+ if (editable.length() > tokenEnd + 1) {
+ char charAt = editable.charAt(tokenEnd + 1);
+ if (charAt == COMMIT_CHAR_COMMA || charAt == COMMIT_CHAR_SEMICOLON) {
+ tokenEnd++;
+ }
+ }
+ String text = editable.toString().substring(start, tokenEnd).trim();
+ clearComposingText();
+ if (text.length() > 0 && !text.equals(" ")) {
+ RecipientEntry entry = createTokenizedEntry(text);
+ if (entry != null) {
+ QwertyKeyListener.markAsReplaced(editable, start, end, "");
+ CharSequence chipText = createChip(entry);
+ if (chipText != null && start > -1 && end > -1) {
+ editable.replace(start, end, chipText);
+ }
+ }
+ // Only dismiss the dropdown if it is related to the text we
+ // just committed.
+ // For paste, it may not be as there are possibly multiple
+ // tokens being added.
+ if (end == getSelectionEnd()) {
+ dismissDropDown();
+ }
+ sanitizeBetween();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private int positionOfFirstEntryWithTypePerson() {
+ ListAdapter adapter = getAdapter();
+ int itemCount = adapter != null ? adapter.getCount() : 0;
+ for (int i = 0; i < itemCount; i++) {
+ if (isEntryAtPositionTypePerson(i)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private boolean isEntryAtPositionTypePerson(int position) {
+ return getAdapter().getItem(position).getEntryType() == RecipientEntry.ENTRY_TYPE_PERSON;
+ }
+
+ // Visible for testing.
+ /* package */ void sanitizeBetween() {
+ // Don't sanitize while we are waiting for content to chipify.
+ if (mPendingChipsCount > 0) {
+ return;
+ }
+ // Find the last chip.
+ DrawableRecipientChip[] recips = getSortedRecipients();
+ if (recips != null && recips.length > 0) {
+ DrawableRecipientChip last = recips[recips.length - 1];
+ DrawableRecipientChip beforeLast = null;
+ if (recips.length > 1) {
+ beforeLast = recips[recips.length - 2];
+ }
+ int startLooking = 0;
+ int end = getSpannable().getSpanStart(last);
+ if (beforeLast != null) {
+ startLooking = getSpannable().getSpanEnd(beforeLast);
+ Editable text = getText();
+ if (startLooking == -1 || startLooking > text.length() - 1) {
+ // There is nothing after this chip.
+ return;
+ }
+ if (text.charAt(startLooking) == ' ') {
+ startLooking++;
+ }
+ }
+ if (startLooking >= 0 && end >= 0 && startLooking < end) {
+ getText().delete(startLooking, end);
+ }
+ }
+ }
+
+ private boolean shouldCreateChip(int start, int end) {
+ return !mNoChipMode && hasFocus() && enoughToFilter() && !alreadyHasChip(start, end);
+ }
+
+ private boolean alreadyHasChip(int start, int end) {
+ if (mNoChipMode) {
+ return true;
+ }
+ DrawableRecipientChip[] chips =
+ getSpannable().getSpans(start, end, DrawableRecipientChip.class);
+ return chips != null && chips.length > 0;
+ }
+
+ private void handleEdit(int start, int end) {
+ if (start == -1 || end == -1) {
+ // This chip no longer exists in the field.
+ dismissDropDown();
+ return;
+ }
+ // This is in the middle of a chip, so select out the whole chip
+ // and commit it.
+ Editable editable = getText();
+ setSelection(end);
+ String text = getText().toString().substring(start, end);
+ if (!TextUtils.isEmpty(text)) {
+ RecipientEntry entry = RecipientEntry.constructFakeEntry(text, isValid(text));
+ QwertyKeyListener.markAsReplaced(editable, start, end, "");
+ CharSequence chipText = createChip(entry);
+ int selEnd = getSelectionEnd();
+ if (chipText != null && start > -1 && selEnd > -1) {
+ editable.replace(start, selEnd, chipText);
+ }
+ }
+ dismissDropDown();
+ }
+
+ /**
+ * If there is a selected chip, delegate the key events
+ * to the selected chip.
+ */
+ @Override
+ public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) {
+ if (mSelectedChip != null && keyCode == KeyEvent.KEYCODE_DEL) {
+ if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
+ mAlternatesPopup.dismiss();
+ }
+ removeChip(mSelectedChip);
+ }
+
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_ENTER:
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ if (event.hasNoModifiers()) {
+ if (commitDefault()) {
+ return true;
+ }
+ if (mSelectedChip != null) {
+ clearSelectedChip();
+ return true;
+ } else if (focusNext()) {
+ return true;
+ }
+ }
+ break;
+ }
+
+ final DrawableRecipientChip lastRecipientChip = getLastChip();
+ boolean isHandled = super.onKeyDown(keyCode, event);
+
+ /*
+ * Hacky way to report a deleted chip:
+ * In some devices/configurations, {@link KeyEvent#KEYCODE_DEL} character is causing
+ * onKeyDown() to be called, which in turns handles the chip deletion instead of
+ * {@link RecipientTextWatcher#onTextChanged}. We want to call
+ * {@link RecipientChipDeletedListener#onRecipientChipDeleted} callback for these cases.
+ */
+ if (keyCode == KeyEvent.KEYCODE_DEL && isHandled && lastRecipientChip != null) {
+ final RecipientEntry entry = lastRecipientChip.getEntry();
+ if (!mNoChipMode && mRecipientChipDeletedListener != null && entry != null) {
+ mRecipientChipDeletedListener.onRecipientChipDeleted(entry);
+ }
+ }
+
+ return isHandled;
+ }
+
+ // Visible for testing.
+ /* package */ Spannable getSpannable() {
+ return getText();
+ }
+
+ private int getChipStart(DrawableRecipientChip chip) {
+ return getSpannable().getSpanStart(chip);
+ }
+
+ private int getChipEnd(DrawableRecipientChip chip) {
+ return getSpannable().getSpanEnd(chip);
+ }
+
+ /**
+ * Instead of filtering on the entire contents of the edit box,
+ * this subclass method filters on the range from
+ * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd}
+ * if the length of that range meets or exceeds {@link #getThreshold}
+ * and makes sure that the range is not already a Chip.
+ */
+ @Override
+ public void performFiltering(@NonNull CharSequence text, int keyCode) {
+ boolean isCompletedToken = isCompletedToken(text);
+ if (enoughToFilter() && !isCompletedToken) {
+ int end = getSelectionEnd();
+ int start = mTokenizer.findTokenStart(text, end);
+ // If this is a RecipientChip, don't filter
+ // on its contents.
+ Spannable span = getSpannable();
+ DrawableRecipientChip[] chips = span.getSpans(start, end, DrawableRecipientChip.class);
+ if (chips != null && chips.length > 0) {
+ dismissDropDown();
+ return;
+ }
+ } else if (isCompletedToken) {
+ dismissDropDown();
+ return;
+ }
+ super.performFiltering(text, keyCode);
+ }
+
+ // Visible for testing.
+ /*package*/ boolean isCompletedToken(CharSequence text) {
+ if (TextUtils.isEmpty(text)) {
+ return false;
+ }
+ // Check to see if this is a completed token before filtering.
+ int end = text.length();
+ int start = mTokenizer.findTokenStart(text, end);
+ String token = text.toString().substring(start, end).trim();
+ if (!TextUtils.isEmpty(token)) {
+ char atEnd = token.charAt(token.length() - 1);
+ return atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON;
+ }
+ return false;
+ }
+
+ /**
+ * Clears the selected chip if there is one (and dismissing any popups related to the selected
+ * chip in the process).
+ */
+ public void clearSelectedChip() {
+ if (mSelectedChip != null) {
+ unselectChip(mSelectedChip);
+ mSelectedChip = null;
+ }
+ setCursorVisible(true);
+ setSelection(getText().length());
+ }
+
+ /**
+ * Monitor touch events in the RecipientEditTextView.
+ * If the view does not have focus, any tap on the view
+ * will just focus the view. If the view has focus, determine
+ * if the touch target is a recipient chip. If it is and the chip
+ * is not selected, select it and clear any other selected chips.
+ * If it isn't, then select that chip.
+ */
+ @Override
+ public boolean onTouchEvent(@NonNull MotionEvent event) {
+ boolean handled;
+ int action = event.getAction();
+ final float x = event.getX();
+ final float y = event.getY();
+ final int offset = putOffsetInRange(x, y);
+ final DrawableRecipientChip currentChip = findChip(offset);
+ if (action == MotionEvent.ACTION_UP) {
+ boolean touchedWarningIcon = touchedWarningIcon(x, y, currentChip);
+ if (touchedWarningIcon) {
+ String warningText = String.format(mWarningTextTemplate,
+ currentChip.getEntry().getDestination());
+ showWarningDialog(warningText);
+ return true;
+ }
+ if (!isFocused()) {
+ // Ignore further chip taps until this view is focused.
+ return touchedWarningIcon || super.onTouchEvent(event);
+ }
+ handled = super.onTouchEvent(event);
+ if (mSelectedChip == null) {
+ mGestureDetector.onTouchEvent(event);
+ }
+ boolean chipWasSelected = false;
+ if (currentChip != null) {
+ if (mSelectedChip != null && mSelectedChip != currentChip) {
+ clearSelectedChip();
+ selectChip(currentChip);
+ } else if (mSelectedChip == null) {
+ commitDefault();
+ selectChip(currentChip);
+ } else {
+ onClick(mSelectedChip);
+ }
+ chipWasSelected = true;
+ handled = true;
+ } else if (mSelectedChip != null && shouldShowEditableText(mSelectedChip)) {
+ chipWasSelected = true;
+ }
+ if (!chipWasSelected) {
+ clearSelectedChip();
+ }
+ } else {
+ boolean touchedWarningIcon = touchedWarningIcon(x, y, currentChip);
+ if (touchedWarningIcon) {
+ return true;
+ }
+ handled = super.onTouchEvent(event);
+ if (!isFocused()) {
+ return handled;
+ }
+ if (mSelectedChip == null) {
+ mGestureDetector.onTouchEvent(event);
+ }
+ }
+ return handled;
+ }
+
+ private boolean touchedWarningIcon(float x, float y, DrawableRecipientChip currentChip) {
+ boolean touchedWarningIcon = false;
+ if (currentChip != null) {
+ Rect outOfDomainWarningBounds = currentChip.getWarningIconBounds();
+ if (outOfDomainWarningBounds != null) {
+ int chipLeftOffset = shouldPositionAvatarOnRight()
+ ? getChipEnd(currentChip) : getChipStart(currentChip);
+ float chipLeftPosition = this.getLayout().getPrimaryHorizontal(chipLeftOffset);
+ float chipTopPosition = this.getLayout().getLineTop(
+ this.getLayout().getLineForOffset(chipLeftOffset)) + getTotalPaddingTop();
+ final RectF touchOutOfDomainWarning = new RectF(
+ chipLeftPosition + outOfDomainWarningBounds.left,
+ chipTopPosition + outOfDomainWarningBounds.top,
+ chipLeftPosition + outOfDomainWarningBounds.right,
+ chipTopPosition + outOfDomainWarningBounds.bottom);
+ touchedWarningIcon = touchOutOfDomainWarning.contains(x, y);
+ }
+ }
+ return touchedWarningIcon;
+ }
+
+ private void showWarningDialog(String warningText) {
+ mCurrentWarningText = warningText;
+ new AlertDialog.Builder(RecipientEditTextView.this.getContext())
+ .setTitle(mWarningTitle)
+ .setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ mCurrentWarningText = "";
+ }
+ })
+ .setMessage(mCurrentWarningText)
+ .show();
+ }
+
+ private void showAlternates(final DrawableRecipientChip currentChip,
+ final ListPopupWindow alternatesPopup) {
+ new AsyncTask() {
+ @Override
+ protected ListAdapter doInBackground(final Void... params) {
+ return createAlternatesAdapter(currentChip);
+ }
+
+ @Override
+ protected void onPostExecute(final ListAdapter result) {
+ if (!mAttachedToWindow) {
+ return;
+ }
+ int line = getLayout().getLineForOffset(getChipStart(currentChip));
+ int bottomOffset = calculateOffsetFromBottomToTop(line);
+
+ // Align the alternates popup with the left side of the View,
+ // regardless of the position of the chip tapped.
+ alternatesPopup.setAnchorView((mAlternatePopupAnchor != null) ?
+ mAlternatePopupAnchor : RecipientEditTextView.this);
+ alternatesPopup.setVerticalOffset(bottomOffset);
+ alternatesPopup.setAdapter(result);
+ alternatesPopup.setOnItemClickListener(mAlternatesListener);
+ // Clear the checked item.
+ mCheckedItem = -1;
+ alternatesPopup.show();
+ ListView listView = alternatesPopup.getListView();
+ listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
+ // Checked item would be -1 if the adapter has not
+ // loaded the view that should be checked yet. The
+ // variable will be set correctly when onCheckedItemChanged
+ // is called in a separate thread.
+ if (mCheckedItem != -1) {
+ listView.setItemChecked(mCheckedItem, true);
+ mCheckedItem = -1;
+ }
+ }
+ }.execute((Void[]) null);
+ }
+
+ protected ListAdapter createAlternatesAdapter(DrawableRecipientChip chip) {
+ return new RecipientAlternatesAdapter(getContext(), chip.getContactId(),
+ chip.getDirectoryId(), chip.getLookupKey(), chip.getDataId(),
+ getAdapter().getQueryType(), this, mDropdownChipLayouter,
+ constructStateListDeleteDrawable(), getAdapter().getPermissionsCheckListener());
+ }
+
+ private ListAdapter createSingleAddressAdapter(DrawableRecipientChip currentChip) {
+ return new SingleRecipientArrayAdapter(getContext(), currentChip.getEntry(),
+ mDropdownChipLayouter, constructStateListDeleteDrawable());
+ }
+
+ private StateListDrawable constructStateListDeleteDrawable() {
+ // Construct the StateListDrawable from deleteDrawable
+ StateListDrawable deleteDrawable = new StateListDrawable();
+ if (!mDisableDelete) {
+ deleteDrawable.addState(new int[]{android.R.attr.state_activated}, mChipDelete);
+ }
+ deleteDrawable.addState(new int[0], null);
+ return deleteDrawable;
+ }
+
+ @Override
+ public void onCheckedItemChanged(int position) {
+ ListView listView = mAlternatesPopup.getListView();
+ if (listView != null && listView.getCheckedItemCount() == 0) {
+ listView.setItemChecked(position, true);
+ }
+ mCheckedItem = position;
+ }
+
+ private int putOffsetInRange(final float x, final float y) {
+ final int offset;
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ offset = getOffsetForPosition(x, y);
+ } else {
+ offset = supportGetOffsetForPosition(x, y);
+ }
+
+ return putOffsetInRange(offset);
+ }
+
+ // TODO: This algorithm will need a lot of tweaking after more people have used
+ // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
+ // what comes before the finger.
+ private int putOffsetInRange(int o) {
+ int offset = o;
+ Editable text = getText();
+ int length = text.length();
+ // Remove whitespace from end to find "real end"
+ int realLength = length;
+ for (int i = length - 1; i >= 0; i--) {
+ if (text.charAt(i) == ' ') {
+ realLength--;
+ } else {
+ break;
+ }
+ }
+
+ // If the offset is beyond or at the end of the text,
+ // leave it alone.
+ if (offset >= realLength) {
+ return offset;
+ }
+ Editable editable = getText();
+ while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) {
+ // Keep walking backward!
+ offset--;
+ }
+ return offset;
+ }
+
+ private static int findText(Editable text, int offset) {
+ if (text.charAt(offset) != ' ') {
+ return offset;
+ }
+ return -1;
+ }
+
+ private DrawableRecipientChip findChip(int offset) {
+ final Spannable span = getSpannable();
+ final DrawableRecipientChip[] chips =
+ span.getSpans(0, span.length(), DrawableRecipientChip.class);
+ // Find the chip that contains this offset.
+ for (DrawableRecipientChip chip : chips) {
+ int start = getChipStart(chip);
+ int end = getChipEnd(chip);
+ if (offset >= start && offset <= end) {
+ return chip;
+ }
+ }
+ return null;
+ }
+
+ // Visible for testing.
+ // Use this method to generate text to add to the list of addresses.
+ /* package */String createAddressText(RecipientEntry entry) {
+ String display = entry.getDisplayName();
+ String address = entry.getDestination();
+ if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) {
+ display = null;
+ }
+ String trimmedDisplayText;
+ if (isPhoneQuery() && PhoneUtil.isPhoneNumber(address)) {
+ trimmedDisplayText = address.trim();
+ } else {
+ if (address != null) {
+ // Tokenize out the address in case the address already
+ // contained the username as well.
+ Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(address);
+ if (tokenized != null && tokenized.length > 0) {
+ address = tokenized[0].getAddress();
+ }
+ }
+ Rfc822Token token = new Rfc822Token(display, address, null);
+ trimmedDisplayText = token.toString().trim();
+ }
+ int index = trimmedDisplayText.indexOf(",");
+ return mTokenizer != null && !TextUtils.isEmpty(trimmedDisplayText)
+ && index < trimmedDisplayText.length() - 1 ? (String) mTokenizer
+ .terminateToken(trimmedDisplayText) : trimmedDisplayText;
+ }
+
+ // Visible for testing.
+ // Use this method to generate text to display in a chip.
+ /*package*/ String createChipDisplayText(RecipientEntry entry) {
+ String display = entry.getDisplayName();
+ String address = entry.getDestination();
+ if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) {
+ display = null;
+ }
+ if (!TextUtils.isEmpty(display)) {
+ return display;
+ } else if (!TextUtils.isEmpty(address)){
+ return address;
+ } else {
+ return new Rfc822Token(display, address, null).toString();
+ }
+ }
+
+ private CharSequence createChip(RecipientEntry entry) {
+ final String displayText = createAddressText(entry);
+ if (TextUtils.isEmpty(displayText)) {
+ return null;
+ }
+ // Always leave a blank space at the end of a chip.
+ final int textLength = displayText.length() - 1;
+ final SpannableString chipText = new SpannableString(displayText);
+ if (!mNoChipMode) {
+ try {
+ DrawableRecipientChip chip = constructChipSpan(entry);
+ chipText.setSpan(chip, 0, textLength,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ chip.setOriginalText(chipText.toString());
+ } catch (NullPointerException e) {
+ Log.e(TAG, e.getMessage(), e);
+ return null;
+ }
+ }
+ onChipCreated(entry);
+ return chipText;
+ }
+
+ /**
+ * A callback for subclasses to use to know when a chip was created with the
+ * given RecipientEntry.
+ */
+ protected void onChipCreated(RecipientEntry entry) {
+ if (!mNoChipMode && mRecipientChipAddedListener != null) {
+ mRecipientChipAddedListener.onRecipientChipAdded(entry);
+ }
+ }
+
+ /**
+ * When an item in the suggestions list has been clicked, create a chip from the
+ * contact information of the selected item.
+ */
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+ if (position < 0) {
+ return;
+ }
+
+ final RecipientEntry entry = getAdapter().getItem(position);
+ if (entry.getEntryType() == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) {
+ if (mPermissionsRequestItemClickedListener != null) {
+ mPermissionsRequestItemClickedListener
+ .onPermissionsRequestItemClicked(this, entry.getPermissions());
+ }
+ return;
+ }
+
+ final int charactersTyped = submitItemAtPosition(position);
+ if (charactersTyped > -1 && mRecipientEntryItemClickedListener != null) {
+ mRecipientEntryItemClickedListener
+ .onRecipientEntryItemClicked(charactersTyped, position);
+ }
+ }
+
+ private int submitItemAtPosition(int position) {
+ RecipientEntry entry = createValidatedEntry(getAdapter().getItem(position));
+ if (entry == null) {
+ return -1;
+ }
+ clearComposingText();
+
+ int end = getSelectionEnd();
+ int start = mTokenizer.findTokenStart(getText(), end);
+
+ Editable editable = getText();
+ QwertyKeyListener.markAsReplaced(editable, start, end, "");
+ CharSequence chip = createChip(entry);
+ if (chip != null && start >= 0 && end >= 0) {
+ editable.replace(start, end, chip);
+ }
+ sanitizeBetween();
+
+ return end - start;
+ }
+
+ private RecipientEntry createValidatedEntry(RecipientEntry item) {
+ if (item == null) {
+ return null;
+ }
+ final RecipientEntry entry;
+ // If the display name and the address are the same, or if this is a
+ // valid contact, but the destination is invalid, then make this a fake
+ // recipient that is editable.
+ String destination = item.getDestination();
+ if (!isPhoneQuery() && item.getContactId() == RecipientEntry.GENERATED_CONTACT) {
+ entry = RecipientEntry.constructGeneratedEntry(item.getDisplayName(),
+ destination, item.isValid());
+ } else if (RecipientEntry.isCreatedRecipient(item.getContactId())
+ && (TextUtils.isEmpty(item.getDisplayName())
+ || TextUtils.equals(item.getDisplayName(), destination)
+ || (mValidator != null && !mValidator.isValid(destination)))) {
+ entry = RecipientEntry.constructFakeEntry(destination, item.isValid());
+ } else {
+ entry = item;
+ }
+ return entry;
+ }
+
+ // Visible for testing.
+ /* package */DrawableRecipientChip[] getSortedRecipients() {
+ DrawableRecipientChip[] recips = getSpannable()
+ .getSpans(0, getText().length(), DrawableRecipientChip.class);
+ ArrayList recipientsList = new ArrayList(
+ Arrays.asList(recips));
+ final Spannable spannable = getSpannable();
+ Collections.sort(recipientsList, new Comparator() {
+
+ @Override
+ public int compare(DrawableRecipientChip first, DrawableRecipientChip second) {
+ int firstStart = spannable.getSpanStart(first);
+ int secondStart = spannable.getSpanStart(second);
+ if (firstStart < secondStart) {
+ return -1;
+ } else if (firstStart > secondStart) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+ });
+ return recipientsList.toArray(new DrawableRecipientChip[recipientsList.size()]);
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ return false;
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
+
+ /**
+ * No chips are selectable.
+ */
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
+
+ // Visible for testing.
+ /* package */ReplacementDrawableSpan getMoreChip() {
+ MoreImageSpan[] moreSpans = getSpannable().getSpans(0, getText().length(),
+ MoreImageSpan.class);
+ return moreSpans != null && moreSpans.length > 0 ? moreSpans[0] : null;
+ }
+
+ private MoreImageSpan createMoreSpan(int count) {
+ String moreText = String.format(mMoreItem.getText().toString(), count);
+ mWorkPaint.set(getPaint());
+ mWorkPaint.setTextSize(mMoreItem.getTextSize());
+ mWorkPaint.setColor(mMoreItem.getCurrentTextColor());
+ final int width = (int) mWorkPaint.measureText(moreText) + mMoreItem.getPaddingLeft()
+ + mMoreItem.getPaddingRight();
+ final int height = (int) mChipHeight;
+ Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(drawable);
+ int adjustedHeight = height;
+ Layout layout = getLayout();
+ if (layout != null) {
+ adjustedHeight -= layout.getLineDescent(0);
+ }
+ canvas.drawText(moreText, 0, moreText.length(), 0, adjustedHeight, mWorkPaint);
+
+ Drawable result = new BitmapDrawable(getResources(), drawable);
+ result.setBounds(0, 0, width, height);
+ return new MoreImageSpan(result);
+ }
+
+ // Visible for testing.
+ /*package*/ void createMoreChipPlainText() {
+ // Take the first <= CHIP_LIMIT addresses and get to the end of the second one.
+ Editable text = getText();
+ int start = 0;
+ int end = start;
+ for (int i = 0; i < CHIP_LIMIT; i++) {
+ end = movePastTerminators(mTokenizer.findTokenEnd(text, start));
+ start = end; // move to the next token and get its end.
+ }
+ // Now, count total addresses.
+ int tokenCount = countTokens(text);
+ MoreImageSpan moreSpan = createMoreSpan(tokenCount - CHIP_LIMIT);
+ SpannableString chipText = new SpannableString(text.subSequence(end, text.length()));
+ chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ text.replace(end, text.length(), chipText);
+ mMoreChip = moreSpan;
+ }
+
+ // Visible for testing.
+ /* package */int countTokens(Editable text) {
+ int tokenCount = 0;
+ int start = 0;
+ while (start < text.length()) {
+ start = movePastTerminators(mTokenizer.findTokenEnd(text, start));
+ tokenCount++;
+ if (start >= text.length()) {
+ break;
+ }
+ }
+ return tokenCount;
+ }
+
+ /**
+ * Create the more chip. The more chip is text that replaces any chips that
+ * do not fit in the pre-defined available space when the
+ * RecipientEditTextView loses focus.
+ */
+ // Visible for testing.
+ /* package */ void createMoreChip() {
+ if (mNoChipMode) {
+ createMoreChipPlainText();
+ return;
+ }
+
+ if (!mShouldShrink) {
+ return;
+ }
+ ReplacementDrawableSpan[] tempMore = getSpannable().getSpans(0, getText().length(),
+ MoreImageSpan.class);
+ if (tempMore.length > 0) {
+ getSpannable().removeSpan(tempMore[0]);
+ }
+ DrawableRecipientChip[] recipients = getSortedRecipients();
+
+ if (recipients == null || recipients.length <= CHIP_LIMIT) {
+ mMoreChip = null;
+ return;
+ }
+ Spannable spannable = getSpannable();
+ int numRecipients = recipients.length;
+ int overage = numRecipients - CHIP_LIMIT;
+ MoreImageSpan moreSpan = createMoreSpan(overage);
+ mHiddenSpans = new ArrayList();
+ int totalReplaceStart = 0;
+ int totalReplaceEnd = 0;
+ Editable text = getText();
+ for (int i = numRecipients - overage; i < recipients.length; i++) {
+ mHiddenSpans.add(recipients[i]);
+ if (i == numRecipients - overage) {
+ totalReplaceStart = spannable.getSpanStart(recipients[i]);
+ }
+ if (i == recipients.length - 1) {
+ totalReplaceEnd = spannable.getSpanEnd(recipients[i]);
+ }
+ if (mTemporaryRecipients == null || !mTemporaryRecipients.contains(recipients[i])) {
+ int spanStart = spannable.getSpanStart(recipients[i]);
+ int spanEnd = spannable.getSpanEnd(recipients[i]);
+ recipients[i].setOriginalText(text.toString().substring(spanStart, spanEnd));
+ }
+ spannable.removeSpan(recipients[i]);
+ }
+ if (totalReplaceEnd < text.length()) {
+ totalReplaceEnd = text.length();
+ }
+ int end = Math.max(totalReplaceStart, totalReplaceEnd);
+ int start = Math.min(totalReplaceStart, totalReplaceEnd);
+ SpannableString chipText = new SpannableString(text.subSequence(start, end));
+ chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ text.replace(start, end, chipText);
+ mMoreChip = moreSpan;
+ // If adding the +more chip goes over the limit, resize accordingly.
+ if (!isPhoneQuery() && getLineCount() > mMaxLines) {
+ setMaxLines(getLineCount());
+ }
+ }
+
+ /**
+ * Replace the more chip, if it exists, with all of the recipient chips it had
+ * replaced when the RecipientEditTextView gains focus.
+ */
+ // Visible for testing.
+ /*package*/ void removeMoreChip() {
+ if (mMoreChip != null) {
+ Spannable span = getSpannable();
+ span.removeSpan(mMoreChip);
+ mMoreChip = null;
+ // Re-add the spans that were hidden.
+ if (mHiddenSpans != null && mHiddenSpans.size() > 0) {
+ // Recreate each hidden span.
+ DrawableRecipientChip[] recipients = getSortedRecipients();
+ // Start the search for tokens after the last currently visible
+ // chip.
+ if (recipients == null || recipients.length == 0) {
+ return;
+ }
+ int end = span.getSpanEnd(recipients[recipients.length - 1]);
+ Editable editable = getText();
+ for (DrawableRecipientChip chip : mHiddenSpans) {
+ int chipStart;
+ int chipEnd;
+ String token;
+ // Need to find the location of the chip, again.
+ token = (String) chip.getOriginalText();
+ // As we find the matching recipient for the hidden spans,
+ // reduce the size of the string we need to search.
+ // That way, if there are duplicates, we always find the correct
+ // recipient.
+ chipStart = editable.toString().indexOf(token, end);
+ end = chipEnd = Math.min(editable.length(), chipStart + token.length());
+ // Only set the span if we found a matching token.
+ if (chipStart != -1) {
+ editable.setSpan(chip, chipStart, chipEnd,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ mHiddenSpans.clear();
+ }
+ }
+ }
+
+ /**
+ * Show specified chip as selected. If the RecipientChip is just an email address,
+ * selecting the chip will take the contents of the chip and place it at
+ * the end of the RecipientEditTextView for inline editing. If the
+ * RecipientChip is a complete contact, then selecting the chip
+ * will show a popup window with the address in use highlighted and any other
+ * alternate addresses for the contact.
+ * @param currentChip Chip to select.
+ */
+ private void selectChip(DrawableRecipientChip currentChip) {
+ if (shouldShowEditableText(currentChip)) {
+ CharSequence text = currentChip.getValue();
+ Editable editable = getText();
+ Spannable spannable = getSpannable();
+ int spanStart = spannable.getSpanStart(currentChip);
+ int spanEnd = spannable.getSpanEnd(currentChip);
+ spannable.removeSpan(currentChip);
+ // Don't need leading space if it's the only chip
+ if (spanEnd - spanStart == editable.length() - 1) {
+ spanEnd++;
+ }
+ editable.delete(spanStart, spanEnd);
+ setCursorVisible(true);
+ setSelection(editable.length());
+ editable.append(text);
+ mSelectedChip = constructChipSpan(
+ RecipientEntry.constructFakeEntry((String) text, isValid(text.toString())));
+
+ /*
+ * Because chip is destroyed and converted into an editable text, we call
+ * {@link RecipientChipDeletedListener#onRecipientChipDeleted}. For the cases where
+ * editable text is not shown (i.e. chip is in user's contact list), chip is focused
+ * and below callback is not called.
+ */
+ if (!mNoChipMode && mRecipientChipDeletedListener != null) {
+ mRecipientChipDeletedListener.onRecipientChipDeleted(currentChip.getEntry());
+ }
+ } else {
+ final boolean showAddress =
+ currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT ||
+ getAdapter().forceShowAddress();
+ if (showAddress && mNoChipMode) {
+ return;
+ }
+
+ if (isTouchExplorationEnabled()) {
+ // The chips cannot be touch-explored. However, doing a double-tap results in
+ // the popup being shown for the last chip, which is of no value.
+ return;
+ }
+
+ mSelectedChip = currentChip;
+ setSelection(getText().getSpanEnd(mSelectedChip));
+ setCursorVisible(false);
+
+ if (showAddress) {
+ showAddress(currentChip, mAddressPopup);
+ } else {
+ showAlternates(currentChip, mAlternatesPopup);
+ }
+ }
+ }
+
+ private boolean isTouchExplorationEnabled() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ return false;
+ }
+
+ final AccessibilityManager accessibilityManager = (AccessibilityManager)
+ getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
+ return accessibilityManager.isTouchExplorationEnabled();
+ }
+
+ private boolean shouldShowEditableText(DrawableRecipientChip currentChip) {
+ long contactId = currentChip.getContactId();
+ return contactId == RecipientEntry.INVALID_CONTACT
+ || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT);
+ }
+
+ private void showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup) {
+ if (!mAttachedToWindow) {
+ return;
+ }
+ int line = getLayout().getLineForOffset(getChipStart(currentChip));
+ int bottomOffset = calculateOffsetFromBottomToTop(line);
+ // Align the alternates popup with the left side of the View,
+ // regardless of the position of the chip tapped.
+ popup.setAnchorView((mAlternatePopupAnchor != null) ? mAlternatePopupAnchor : this);
+ popup.setVerticalOffset(bottomOffset);
+ popup.setAdapter(createSingleAddressAdapter(currentChip));
+ popup.setOnItemClickListener(new OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+ unselectChip(currentChip);
+ popup.dismiss();
+ }
+ });
+ popup.show();
+ ListView listView = popup.getListView();
+ listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
+ listView.setItemChecked(0, true);
+ }
+
+ /**
+ * Remove selection from this chip. Unselecting a RecipientChip will render
+ * the chip without a delete icon and with an unfocused background. This is
+ * called when the RecipientChip no longer has focus.
+ */
+ private void unselectChip(DrawableRecipientChip chip) {
+ int start = getChipStart(chip);
+ int end = getChipEnd(chip);
+ Editable editable = getText();
+ mSelectedChip = null;
+ if (start == -1 || end == -1) {
+ Log.w(TAG, "The chip doesn't exist or may be a chip a user was editing");
+ setSelection(editable.length());
+ commitDefault();
+ } else {
+ getSpannable().removeSpan(chip);
+ QwertyKeyListener.markAsReplaced(editable, start, end, "");
+ editable.removeSpan(chip);
+ try {
+ if (!mNoChipMode) {
+ editable.setSpan(constructChipSpan(chip.getEntry()),
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ } catch (NullPointerException e) {
+ Log.e(TAG, e.getMessage(), e);
+ }
+ }
+ setCursorVisible(true);
+ setSelection(editable.length());
+ if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
+ mAlternatesPopup.dismiss();
+ }
+ }
+
+ @Override
+ public void onChipDelete() {
+ if (mSelectedChip != null) {
+ if (!mNoChipMode && mRecipientChipDeletedListener != null) {
+ mRecipientChipDeletedListener.onRecipientChipDeleted(mSelectedChip.getEntry());
+ }
+ removeChip(mSelectedChip);
+ }
+ dismissPopups();
+ }
+
+ @Override
+ public void onPermissionRequestDismissed() {
+ if (mPermissionsRequestItemClickedListener != null) {
+ mPermissionsRequestItemClickedListener.onPermissionRequestDismissed();
+ }
+ dismissDropDown();
+ }
+
+ private void dismissPopups() {
+ if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
+ mAlternatesPopup.dismiss();
+ }
+ if (mAddressPopup != null && mAddressPopup.isShowing()) {
+ mAddressPopup.dismiss();
+ }
+ setSelection(getText().length());
+ }
+
+ /**
+ * Remove the chip and any text associated with it from the RecipientEditTextView.
+ */
+ // Visible for testing.
+ /* package */void removeChip(DrawableRecipientChip chip) {
+ Spannable spannable = getSpannable();
+ int spanStart = spannable.getSpanStart(chip);
+ int spanEnd = spannable.getSpanEnd(chip);
+ Editable text = getText();
+ int toDelete = spanEnd;
+ boolean wasSelected = chip == mSelectedChip;
+ // Clear that there is a selected chip before updating any text.
+ if (wasSelected) {
+ mSelectedChip = null;
+ }
+ // Always remove trailing spaces when removing a chip.
+ while (toDelete >= 0 && toDelete < text.length() && text.charAt(toDelete) == ' ') {
+ toDelete++;
+ }
+ spannable.removeSpan(chip);
+ if (spanStart >= 0 && toDelete > 0) {
+ text.delete(spanStart, toDelete);
+ }
+ if (wasSelected) {
+ clearSelectedChip();
+ }
+ }
+
+ /**
+ * Replace this currently selected chip with a new chip
+ * that uses the contact data provided.
+ */
+ // Visible for testing.
+ /*package*/ void replaceChip(DrawableRecipientChip chip, RecipientEntry entry) {
+ boolean wasSelected = chip == mSelectedChip;
+ if (wasSelected) {
+ mSelectedChip = null;
+ }
+ int start = getChipStart(chip);
+ int end = getChipEnd(chip);
+ getSpannable().removeSpan(chip);
+ Editable editable = getText();
+ entry.setInReplacedChip(true);
+ CharSequence chipText = createChip(entry);
+ if (chipText != null) {
+ if (start == -1 || end == -1) {
+ Log.e(TAG, "The chip to replace does not exist but should.");
+ editable.insert(0, chipText);
+ } else {
+ if (!TextUtils.isEmpty(chipText)) {
+ // There may be a space to replace with this chip's new
+ // associated space. Check for it
+ int toReplace = end;
+ while (toReplace >= 0 && toReplace < editable.length()
+ && editable.charAt(toReplace) == ' ') {
+ toReplace++;
+ }
+ editable.replace(start, toReplace, chipText);
+ }
+ }
+ }
+ setCursorVisible(true);
+ if (wasSelected) {
+ clearSelectedChip();
+ }
+ }
+
+ /**
+ * Handle click events for a chip. When a selected chip receives a click
+ * event, see if that event was in the delete icon. If so, delete it.
+ * Otherwise, unselect the chip.
+ */
+ public void onClick(DrawableRecipientChip chip) {
+ if (chip.isSelected()) {
+ clearSelectedChip();
+ }
+ }
+
+ private boolean chipsPending() {
+ return mPendingChipsCount > 0 || (mHiddenSpans != null && mHiddenSpans.size() > 0);
+ }
+
+ @Override
+ public void removeTextChangedListener(TextWatcher watcher) {
+ mTextWatcher = null;
+ super.removeTextChangedListener(watcher);
+ }
+
+ private boolean isValidEmailAddress(String input) {
+ return !TextUtils.isEmpty(input) && mValidator != null &&
+ mValidator.isValid(input);
+ }
+
+ private class RecipientTextWatcher implements TextWatcher {
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ // If the text has been set to null or empty, make sure we remove
+ // all the spans we applied.
+ if (TextUtils.isEmpty(s)) {
+ // Remove all the chips spans.
+ Spannable spannable = getSpannable();
+ DrawableRecipientChip[] chips = spannable.getSpans(0, getText().length(),
+ DrawableRecipientChip.class);
+ for (DrawableRecipientChip chip : chips) {
+ spannable.removeSpan(chip);
+ }
+ if (mMoreChip != null) {
+ spannable.removeSpan(mMoreChip);
+ }
+ clearSelectedChip();
+ return;
+ }
+ // Get whether there are any recipients pending addition to the
+ // view. If there are, don't do anything in the text watcher.
+ if (chipsPending()) {
+ return;
+ }
+ // If the user is editing a chip, don't clear it.
+ if (mSelectedChip != null) {
+ if (!isGeneratedContact(mSelectedChip)) {
+ setCursorVisible(true);
+ setSelection(getText().length());
+ clearSelectedChip();
+ } else {
+ return;
+ }
+ }
+ int length = s.length();
+ // Make sure there is content there to parse and that it is
+ // not just the commit character.
+ if (length > 1) {
+ if (lastCharacterIsCommitCharacter(s)) {
+ commitByCharacter();
+ return;
+ }
+ char last;
+ int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1;
+ int len = length() - 1;
+ if (end != len) {
+ last = s.charAt(end);
+ } else {
+ last = s.charAt(len);
+ }
+ if (last == COMMIT_CHAR_SPACE) {
+ if (!isPhoneQuery()) {
+ // Check if this is a valid email address. If it is,
+ // commit it.
+ String text = getText().toString();
+ int tokenStart = mTokenizer.findTokenStart(text, getSelectionEnd());
+ String sub = text.substring(tokenStart, mTokenizer.findTokenEnd(text,
+ tokenStart));
+ if (isValidEmailAddress(sub)) {
+ commitByCharacter();
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ // The user deleted some text OR some text was replaced; check to
+ // see if the insertion point is on a space
+ // following a chip.
+ if (before - count == 1) {
+ // If the item deleted is a space, and the thing before the
+ // space is a chip, delete the entire span.
+ int selStart = getSelectionStart();
+ DrawableRecipientChip[] repl = getSpannable().getSpans(selStart, selStart,
+ DrawableRecipientChip.class);
+ if (repl.length > 0) {
+ // There is a chip there! Just remove it.
+ DrawableRecipientChip toDelete = repl[0];
+ Editable editable = getText();
+ // Add the separator token.
+ int deleteStart = editable.getSpanStart(toDelete);
+ int deleteEnd = editable.getSpanEnd(toDelete) + 1;
+ if (deleteEnd > editable.length()) {
+ deleteEnd = editable.length();
+ }
+ if (!mNoChipMode && mRecipientChipDeletedListener != null) {
+ mRecipientChipDeletedListener.onRecipientChipDeleted(toDelete.getEntry());
+ }
+ editable.removeSpan(toDelete);
+ editable.delete(deleteStart, deleteEnd);
+ }
+ } else if (count > before) {
+ if (mSelectedChip != null
+ && isGeneratedContact(mSelectedChip)) {
+ if (lastCharacterIsCommitCharacter(s)) {
+ commitByCharacter();
+ return;
+ }
+ }
+ }
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ // Do nothing.
+ }
+ }
+
+ public boolean lastCharacterIsCommitCharacter(CharSequence s) {
+ char last;
+ int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1;
+ int len = length() - 1;
+ if (end != len) {
+ last = s.charAt(end);
+ } else {
+ last = s.charAt(len);
+ }
+ return last == COMMIT_CHAR_COMMA || last == COMMIT_CHAR_SEMICOLON;
+ }
+
+ public boolean isGeneratedContact(DrawableRecipientChip chip) {
+ long contactId = chip.getContactId();
+ return contactId == RecipientEntry.INVALID_CONTACT
+ || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT);
+ }
+
+ /**
+ * Handles pasting a {@link ClipData} to this {@link RecipientEditTextView}.
+ */
+ // Visible for testing.
+ void handlePasteClip(ClipData clip) {
+ if (clip == null) {
+ // Do nothing.
+ return;
+ }
+
+ final ClipDescription clipDesc = clip.getDescription();
+ boolean containsSupportedType = clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)
+ || clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML);
+ if (!containsSupportedType) {
+ return;
+ }
+
+ removeTextChangedListener(mTextWatcher);
+
+ final ClipDescription clipDescription = clip.getDescription();
+ for (int i = 0; i < clip.getItemCount(); i++) {
+ final String mimeType = clipDescription.getMimeType(i);
+ final boolean supportedType = ClipDescription.MIMETYPE_TEXT_PLAIN.equals(mimeType)
+ || ClipDescription.MIMETYPE_TEXT_HTML.equals(mimeType);
+ if (!supportedType) {
+ // Only plain text and html can be pasted.
+ continue;
+ }
+
+ final CharSequence pastedItem = clip.getItemAt(i).getText();
+ if (!TextUtils.isEmpty(pastedItem)) {
+ final Editable editable = getText();
+ final int start = getSelectionStart();
+ final int end = getSelectionEnd();
+ if (start < 0 || end < 1) {
+ // No selection.
+ editable.append(pastedItem);
+ } else if (start == end) {
+ // Insert at position.
+ editable.insert(start, pastedItem);
+ } else {
+ editable.append(pastedItem, start, end);
+ }
+ handlePasteAndReplace();
+ }
+ }
+
+ mHandler.post(mAddTextWatcher);
+ }
+
+ @Override
+ public boolean onTextContextMenuItem(int id) {
+ if (id == android.R.id.paste) {
+ ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(
+ Context.CLIPBOARD_SERVICE);
+ handlePasteClip(clipboard.getPrimaryClip());
+ return true;
+ }
+ return super.onTextContextMenuItem(id);
+ }
+
+ private void handlePasteAndReplace() {
+ ArrayList created = handlePaste();
+ if (created != null && created.size() > 0) {
+ // Perform reverse lookups on the pasted contacts.
+ IndividualReplacementTask replace = new IndividualReplacementTask();
+ replace.execute(created);
+ }
+ }
+
+ // Visible for testing.
+ /* package */ArrayList handlePaste() {
+ String text = getText().toString();
+ int originalTokenStart = mTokenizer.findTokenStart(text, getSelectionEnd());
+ String lastAddress = text.substring(originalTokenStart);
+ int tokenStart = originalTokenStart;
+ int prevTokenStart = 0;
+ DrawableRecipientChip findChip = null;
+ ArrayList created = new ArrayList();
+ if (tokenStart != 0) {
+ // There are things before this!
+ while (tokenStart != 0 && findChip == null && tokenStart != prevTokenStart) {
+ prevTokenStart = tokenStart;
+ tokenStart = mTokenizer.findTokenStart(text, tokenStart);
+ findChip = findChip(tokenStart);
+ if (tokenStart == originalTokenStart && findChip == null) {
+ break;
+ }
+ }
+ if (tokenStart != originalTokenStart) {
+ if (findChip != null) {
+ tokenStart = prevTokenStart;
+ }
+ int tokenEnd;
+ DrawableRecipientChip createdChip;
+ while (tokenStart < originalTokenStart) {
+ tokenEnd = movePastTerminators(mTokenizer.findTokenEnd(getText().toString(),
+ tokenStart));
+ commitChip(tokenStart, tokenEnd, getText());
+ createdChip = findChip(tokenStart);
+ if (createdChip == null) {
+ break;
+ }
+ // +1 for the space at the end.
+ tokenStart = getSpannable().getSpanEnd(createdChip) + 1;
+ created.add(createdChip);
+ }
+ }
+ }
+ // Take a look at the last token. If the token has been completed with a
+ // commit character, create a chip.
+ if (isCompletedToken(lastAddress)) {
+ Editable editable = getText();
+ tokenStart = editable.toString().indexOf(lastAddress, originalTokenStart);
+ commitChip(tokenStart, editable.length(), editable);
+ created.add(findChip(tokenStart));
+ }
+ return created;
+ }
+
+ // Visible for testing.
+ /* package */int movePastTerminators(int tokenEnd) {
+ if (tokenEnd >= length()) {
+ return tokenEnd;
+ }
+ char atEnd = getText().toString().charAt(tokenEnd);
+ if (atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON) {
+ tokenEnd++;
+ }
+ // This token had not only an end token character, but also a space
+ // separating it from the next token.
+ if (tokenEnd < length() && getText().toString().charAt(tokenEnd) == ' ') {
+ tokenEnd++;
+ }
+ return tokenEnd;
+ }
+
+ private class RecipientReplacementTask extends AsyncTask {
+ private DrawableRecipientChip createFreeChip(RecipientEntry entry) {
+ try {
+ if (mNoChipMode) {
+ return null;
+ }
+ return constructChipSpan(entry);
+ } catch (NullPointerException e) {
+ Log.e(TAG, e.getMessage(), e);
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPreExecute() {
+ // Ensure everything is in chip-form already, so we don't have text that slowly gets
+ // replaced
+ final List originalRecipients =
+ new ArrayList();
+ final DrawableRecipientChip[] existingChips = getSortedRecipients();
+ Collections.addAll(originalRecipients, existingChips);
+ if (mHiddenSpans != null) {
+ originalRecipients.addAll(mHiddenSpans);
+ }
+
+ final List replacements =
+ new ArrayList(originalRecipients.size());
+
+ for (final DrawableRecipientChip chip : originalRecipients) {
+ if (RecipientEntry.isCreatedRecipient(chip.getEntry().getContactId())
+ && getSpannable().getSpanStart(chip) != -1) {
+ replacements.add(createFreeChip(chip.getEntry()));
+ } else {
+ replacements.add(null);
+ }
+ }
+
+ processReplacements(originalRecipients, replacements);
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (mIndividualReplacements != null) {
+ mIndividualReplacements.cancel(true);
+ }
+ // For each chip in the list, look up the matching contact.
+ // If there is a match, replace that chip with the matching
+ // chip.
+ final ArrayList recipients =
+ new ArrayList();
+ DrawableRecipientChip[] existingChips = getSortedRecipients();
+ Collections.addAll(recipients, existingChips);
+ if (mHiddenSpans != null) {
+ recipients.addAll(mHiddenSpans);
+ }
+ ArrayList addresses = new ArrayList();
+ for (DrawableRecipientChip chip : recipients) {
+ if (chip != null) {
+ addresses.add(createAddressText(chip.getEntry()));
+ }
+ }
+ final BaseRecipientAdapter adapter = getAdapter();
+ adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() {
+ @Override
+ public void matchesFound(Map entries) {
+ final ArrayList replacements =
+ new ArrayList();
+ for (final DrawableRecipientChip temp : recipients) {
+ RecipientEntry entry = null;
+ if (temp != null && RecipientEntry.isCreatedRecipient(
+ temp.getEntry().getContactId())
+ && getSpannable().getSpanStart(temp) != -1) {
+ // Replace this.
+ entry = createValidatedEntry(
+ entries.get(tokenizeAddress(temp.getEntry()
+ .getDestination())));
+ }
+ if (entry != null) {
+ replacements.add(createFreeChip(entry));
+ } else {
+ replacements.add(null);
+ }
+ }
+ processReplacements(recipients, replacements);
+ }
+
+ @Override
+ public void matchesNotFound(final Set unfoundAddresses) {
+ final List replacements =
+ new ArrayList(unfoundAddresses.size());
+
+ for (final DrawableRecipientChip temp : recipients) {
+ if (temp != null && RecipientEntry.isCreatedRecipient(
+ temp.getEntry().getContactId())
+ && getSpannable().getSpanStart(temp) != -1) {
+ if (unfoundAddresses.contains(
+ temp.getEntry().getDestination())) {
+ replacements.add(createFreeChip(temp.getEntry()));
+ } else {
+ replacements.add(null);
+ }
+ } else {
+ replacements.add(null);
+ }
+ }
+
+ processReplacements(recipients, replacements);
+ }
+ });
+ return null;
+ }
+
+ private void processReplacements(final List recipients,
+ final List replacements) {
+ if (replacements != null && replacements.size() > 0) {
+ final Runnable runnable = new Runnable() {
+ @Override
+ public void run() {
+ final Editable text = new SpannableStringBuilder(getText());
+ int i = 0;
+ for (final DrawableRecipientChip chip : recipients) {
+ final DrawableRecipientChip replacement = replacements.get(i);
+ if (replacement != null) {
+ final RecipientEntry oldEntry = chip.getEntry();
+ final RecipientEntry newEntry = replacement.getEntry();
+ final boolean isBetter =
+ RecipientAlternatesAdapter.getBetterRecipient(
+ oldEntry, newEntry) == newEntry;
+
+ if (isBetter) {
+ // Find the location of the chip in the text currently shown.
+ final int start = text.getSpanStart(chip);
+ if (start != -1) {
+ // Replacing the entirety of what the chip represented,
+ // including the extra space dividing it from other chips.
+ final int end =
+ Math.min(text.getSpanEnd(chip) + 1, text.length());
+ text.removeSpan(chip);
+ // Make sure we always have just 1 space at the end to
+ // separate this chip from the next chip.
+ final SpannableString displayText =
+ new SpannableString(createAddressText(
+ replacement.getEntry()).trim() + " ");
+ displayText.setSpan(replacement, 0,
+ displayText.length() - 1,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ // Replace the old text we found with with the new display
+ // text, which now may also contain the display name of the
+ // recipient.
+ text.replace(start, end, displayText);
+ replacement.setOriginalText(displayText.toString());
+ replacements.set(i, null);
+
+ recipients.set(i, replacement);
+ }
+ }
+ }
+ i++;
+ }
+ setText(text);
+ }
+ };
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ runnable.run();
+ } else {
+ mHandler.post(runnable);
+ }
+ }
+ }
+ }
+
+ private class IndividualReplacementTask
+ extends AsyncTask, Void, Void> {
+ @Override
+ protected Void doInBackground(ArrayList... params) {
+ // For each chip in the list, look up the matching contact.
+ // If there is a match, replace that chip with the matching
+ // chip.
+ final ArrayList originalRecipients = params[0];
+ ArrayList addresses = new ArrayList();
+ for (DrawableRecipientChip chip : originalRecipients) {
+ if (chip != null) {
+ addresses.add(createAddressText(chip.getEntry()));
+ }
+ }
+ final BaseRecipientAdapter adapter = getAdapter();
+ adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() {
+
+ @Override
+ public void matchesFound(Map entries) {
+ for (final DrawableRecipientChip temp : originalRecipients) {
+ if (RecipientEntry.isCreatedRecipient(temp.getEntry()
+ .getContactId())
+ && getSpannable().getSpanStart(temp) != -1) {
+ // Replace this.
+ final RecipientEntry entry = createValidatedEntry(entries
+ .get(tokenizeAddress(temp.getEntry().getDestination())
+ .toLowerCase()));
+ if (entry != null) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ replaceChip(temp, entry);
+ }
+ });
+ }
+ }
+ }
+ }
+
+ @Override
+ public void matchesNotFound(final Set unfoundAddresses) {
+ // No action required
+ }
+ });
+ return null;
+ }
+ }
+
+
+ /**
+ * MoreImageSpan is a simple class created for tracking the existence of a
+ * more chip across activity restarts/
+ */
+ private class MoreImageSpan extends ReplacementDrawableSpan {
+ public MoreImageSpan(Drawable b) {
+ super(b);
+ setExtraMargin(mLineSpacingExtra);
+ }
+ }
+
+ @Override
+ public boolean onDown(MotionEvent e) {
+ return false;
+ }
+
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ // Do nothing.
+ return false;
+ }
+
+ @Override
+ public void onLongPress(MotionEvent event) {
+ if (mSelectedChip != null) {
+ return;
+ }
+ float x = event.getX();
+ float y = event.getY();
+ final int offset = putOffsetInRange(x, y);
+ DrawableRecipientChip currentChip = findChip(offset);
+ if (currentChip != null) {
+ if (mDragEnabled) {
+ // Start drag-and-drop for the selected chip.
+ startDrag(currentChip);
+ } else {
+ // Copy the selected chip email address.
+ showCopyDialog(currentChip.getEntry().getDestination());
+ }
+ }
+ }
+
+ // The following methods are used to provide some functionality on older versions of Android
+ // These methods were copied out of JB MR2's TextView
+ /////////////////////////////////////////////////
+ private int supportGetOffsetForPosition(float x, float y) {
+ if (getLayout() == null) return -1;
+ final int line = supportGetLineAtCoordinate(y);
+ return supportGetOffsetAtCoordinate(line, x);
+ }
+
+ private float supportConvertToLocalHorizontalCoordinate(float x) {
+ x -= getTotalPaddingLeft();
+ // Clamp the position to inside of the view.
+ x = Math.max(0.0f, x);
+ x = Math.min(getWidth() - getTotalPaddingRight() - 1, x);
+ x += getScrollX();
+ return x;
+ }
+
+ private int supportGetLineAtCoordinate(float y) {
+ y -= getTotalPaddingLeft();
+ // Clamp the position to inside of the view.
+ y = Math.max(0.0f, y);
+ y = Math.min(getHeight() - getTotalPaddingBottom() - 1, y);
+ y += getScrollY();
+ return getLayout().getLineForVertical((int) y);
+ }
+
+ private int supportGetOffsetAtCoordinate(int line, float x) {
+ x = supportConvertToLocalHorizontalCoordinate(x);
+ return getLayout().getOffsetForHorizontal(line, x);
+ }
+ /////////////////////////////////////////////////
+
+ /**
+ * Enables drag-and-drop for chips.
+ */
+ public void enableDrag() {
+ mDragEnabled = true;
+ }
+
+ /**
+ * Starts drag-and-drop for the selected chip.
+ */
+ private void startDrag(DrawableRecipientChip currentChip) {
+ String address = currentChip.getEntry().getDestination();
+ ClipData data = ClipData.newPlainText(address, address + COMMIT_CHAR_COMMA);
+
+ // Start drag mode.
+ startDrag(data, new RecipientChipShadow(currentChip), null, 0);
+
+ // Remove the current chip, so drag-and-drop will result in a move.
+ // TODO (phamm): consider readd this chip if it's dropped outside a target.
+ removeChip(currentChip);
+ }
+
+ /**
+ * Handles drag event.
+ */
+ @Override
+ public boolean onDragEvent(@NonNull DragEvent event) {
+ switch (event.getAction()) {
+ case DragEvent.ACTION_DRAG_STARTED:
+ // Only handle plain text drag and drop.
+ return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN);
+ case DragEvent.ACTION_DRAG_ENTERED:
+ requestFocus();
+ return true;
+ case DragEvent.ACTION_DROP:
+ handlePasteClip(event.getClipData());
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Drag shadow for a {@link DrawableRecipientChip}.
+ */
+ private final class RecipientChipShadow extends DragShadowBuilder {
+ private final DrawableRecipientChip mChip;
+
+ public RecipientChipShadow(DrawableRecipientChip chip) {
+ mChip = chip;
+ }
+
+ @Override
+ public void onProvideShadowMetrics(@NonNull Point shadowSize,
+ @NonNull Point shadowTouchPoint) {
+ Rect rect = mChip.getBounds();
+ shadowSize.set(rect.width(), rect.height());
+ shadowTouchPoint.set(rect.centerX(), rect.centerY());
+ }
+
+ @Override
+ public void onDrawShadow(@NonNull Canvas canvas) {
+ mChip.draw(canvas);
+ }
+ }
+
+ private void showCopyDialog(final String address) {
+ final Context context = getContext();
+ if (!mAttachedToWindow || context == null || !(context instanceof Activity)) {
+ return;
+ }
+
+ final DialogFragment fragment = CopyDialog.newInstance(address);
+ fragment.show(((Activity) context).getFragmentManager(), CopyDialog.TAG);
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+ // Do nothing.
+ return false;
+ }
+
+ @Override
+ public void onShowPress(MotionEvent e) {
+ // Do nothing.
+ }
+
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ // Do nothing.
+ return false;
+ }
+
+ protected boolean isPhoneQuery() {
+ return getAdapter() != null
+ && getAdapter().getQueryType() == BaseRecipientAdapter.QUERY_TYPE_PHONE;
+ }
+
+ @Override
+ public BaseRecipientAdapter getAdapter() {
+ return (BaseRecipientAdapter) super.getAdapter();
+ }
+
+ /**
+ * Append a new {@link RecipientEntry} to the end of the recipient chips, leaving any
+ * unfinished text at the end.
+ */
+ public void appendRecipientEntry(final RecipientEntry entry) {
+ clearComposingText();
+
+ final Editable editable = getText();
+ int chipInsertionPoint = 0;
+
+ // Find the end of last chip and see if there's any unchipified text.
+ final DrawableRecipientChip[] recips = getSortedRecipients();
+ if (recips != null && recips.length > 0) {
+ final DrawableRecipientChip last = recips[recips.length - 1];
+ // The chip will be inserted at the end of last chip + 1. All the unfinished text after
+ // the insertion point will be kept untouched.
+ chipInsertionPoint = editable.getSpanEnd(last) + 1;
+ }
+
+ final CharSequence chip = createChip(entry);
+ if (chip != null) {
+ editable.insert(chipInsertionPoint, chip);
+ }
+ }
+
+ /**
+ * Remove all chips matching the given RecipientEntry.
+ */
+ public void removeRecipientEntry(final RecipientEntry entry) {
+ final DrawableRecipientChip[] recips = getText()
+ .getSpans(0, getText().length(), DrawableRecipientChip.class);
+
+ for (final DrawableRecipientChip recipient : recips) {
+ final RecipientEntry existingEntry = recipient.getEntry();
+ if (existingEntry != null && existingEntry.isValid() &&
+ existingEntry.isSamePerson(entry)) {
+ removeChip(recipient);
+ }
+ }
+ }
+
+ public void setAlternatePopupAnchor(View v) {
+ mAlternatePopupAnchor = v;
+ }
+
+ @Override
+ public void setVisibility(int visibility) {
+ super.setVisibility(visibility);
+
+ if (visibility != GONE && mRequiresShrinkWhenNotGone) {
+ mRequiresShrinkWhenNotGone = false;
+ mHandler.post(mDelayedShrink);
+ }
+ }
+
+ private static class ChipBitmapContainer {
+ Bitmap bitmap;
+ // information used for positioning the loaded icon
+ boolean loadIcon = true;
+ float left;
+ float top;
+ float right;
+ float bottom;
+ // information used for positioning the warning icon
+ float warningIconLeft;
+ float warningIconTop;
+ float warningIconRight;
+ float warningIconBottom;
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/RecipientEntry.java b/modules/chips/src/com/android/ex/chips/RecipientEntry.java
new file mode 100644
index 0000000..a42794f
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/RecipientEntry.java
@@ -0,0 +1,377 @@
+/*
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.net.Uri;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.DisplayNameSources;
+import androidx.annotation.DrawableRes;
+import android.text.util.Rfc822Token;
+import android.text.util.Rfc822Tokenizer;
+
+/**
+ * Represents one entry inside recipient auto-complete list.
+ */
+public class RecipientEntry {
+ /* package */ static final int INVALID_CONTACT = -1;
+ /**
+ * A GENERATED_CONTACT is one that was created based entirely on
+ * information passed in to the RecipientEntry from an external source
+ * that is not a real contact.
+ */
+ /* package */ static final int GENERATED_CONTACT = -2;
+
+ /** Used when {@link #mDestinationType} is invalid and thus shouldn't be used for display. */
+ public static final int INVALID_DESTINATION_TYPE = -1;
+
+ public static final int ENTRY_TYPE_PERSON = 0;
+
+ /**
+ * Entry of this type represents the item in auto-complete that asks user to grant permissions
+ * to the app. This permission model is introduced in M platform.
+ *
+ *
Entries of this type should have {@link #mPermissions} set as well.
+ */
+ public static final int ENTRY_TYPE_PERMISSION_REQUEST = 1;
+
+ public static final int ENTRY_TYPE_SIZE = 2;
+
+ private final int mEntryType;
+
+ /**
+ * True when this entry is the first entry in a group, which should have a photo and display
+ * name, while the second or later entries won't.
+ */
+ private boolean mIsFirstLevel;
+ private final String mDisplayName;
+
+ /** Destination for this contact entry. Would be an email address or a phone number. */
+ private final String mDestination;
+ /** Type of the destination like {@link Email#TYPE_HOME} */
+ private final int mDestinationType;
+ /**
+ * Label of the destination which will be used when type was {@link Email#TYPE_CUSTOM}.
+ * Can be null when {@link #mDestinationType} is {@link #INVALID_DESTINATION_TYPE}.
+ */
+ private final String mDestinationLabel;
+ /** ID for the person */
+ private final long mContactId;
+ /** ID for the directory this contact came from, or null */
+ private final Long mDirectoryId;
+ /** ID for the destination */
+ private final long mDataId;
+
+ private final Uri mPhotoThumbnailUri;
+ /** Configures showing the icon in the chip */
+ private final boolean mShouldDisplayIcon;
+
+ private boolean mIsValid;
+ /**
+ * This can be updated after this object being constructed, when the photo is fetched
+ * from remote directories.
+ */
+ private byte[] mPhotoBytes;
+
+ @DrawableRes private int mIndicatorIconId;
+ private String mIndicatorText;
+
+ /** See {@link android.provider.ContactsContract.ContactsColumns#LOOKUP_KEY} */
+ private final String mLookupKey;
+
+ /** Should be used when type is {@link #ENTRY_TYPE_PERMISSION_REQUEST}. */
+ private final String[] mPermissions;
+
+ /** Whether RecipientEntry is in a replaced chip or not. */
+ private boolean mInReplacedChip;
+
+ protected RecipientEntry(int entryType, String displayName, String destination,
+ int destinationType, String destinationLabel, long contactId, Long directoryId,
+ long dataId, Uri photoThumbnailUri, boolean isFirstLevel, boolean isValid,
+ String lookupKey, String[] permissions) {
+ this(entryType, displayName, destination, destinationType,
+ destinationLabel, contactId, directoryId, dataId, photoThumbnailUri,
+ true /* shouldDisplayIcon */, isFirstLevel, isValid, lookupKey, permissions);
+ }
+
+ protected RecipientEntry(int entryType, String displayName, String destination,
+ int destinationType, String destinationLabel, long contactId, Long directoryId,
+ long dataId, Uri photoThumbnailUri, boolean shouldDisplayIcon,
+ boolean isFirstLevel, boolean isValid, String lookupKey, String[] permissions) {
+ mEntryType = entryType;
+ mIsFirstLevel = isFirstLevel;
+ mDisplayName = displayName;
+ mDestination = destination;
+ mDestinationType = destinationType;
+ mDestinationLabel = destinationLabel;
+ mContactId = contactId;
+ mDirectoryId = directoryId;
+ mDataId = dataId;
+ mPhotoThumbnailUri = photoThumbnailUri;
+ mShouldDisplayIcon = shouldDisplayIcon;
+ mPhotoBytes = null;
+ mIsValid = isValid;
+ mLookupKey = lookupKey;
+ mIndicatorIconId = 0;
+ mIndicatorText = null;
+ mPermissions = permissions;
+ }
+
+ protected RecipientEntry(int entryType, String displayName, String destination,
+ int destinationType, String destinationLabel, long contactId, Long directoryId,
+ long dataId, Uri photoThumbnailUri, boolean isFirstLevel, boolean isValid,
+ String lookupKey) {
+ this(entryType, displayName, destination, destinationType, destinationLabel,
+ contactId, directoryId, dataId, photoThumbnailUri, isFirstLevel, isValid,
+ lookupKey, null);
+ }
+
+ public boolean isValid() {
+ return mIsValid;
+ }
+
+ /**
+ * Determine if this was a RecipientEntry created from recipient info or
+ * an entry from contacts.
+ */
+ public static boolean isCreatedRecipient(long id) {
+ return id == RecipientEntry.INVALID_CONTACT || id == RecipientEntry.GENERATED_CONTACT;
+ }
+
+ /**
+ * Construct a RecipientEntry from just an address that has been entered.
+ * This address has not been resolved to a contact and therefore does not
+ * have a contact id or photo.
+ */
+ public static RecipientEntry constructFakeEntry(final String address, final boolean isValid) {
+ final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
+ final String tokenizedAddress = tokens.length > 0 ? tokens[0].getAddress() : address;
+
+ return new RecipientEntry(ENTRY_TYPE_PERSON, tokenizedAddress, tokenizedAddress,
+ INVALID_DESTINATION_TYPE, null, INVALID_CONTACT, null /* directoryId */,
+ INVALID_CONTACT, null, true, isValid, null /* lookupKey */, null /* permissions */);
+ }
+
+ /**
+ * Construct a RecipientEntry from just a phone number.
+ */
+ public static RecipientEntry constructFakePhoneEntry(final String phoneNumber,
+ final boolean isValid) {
+ return new RecipientEntry(ENTRY_TYPE_PERSON, phoneNumber, phoneNumber,
+ INVALID_DESTINATION_TYPE, null, INVALID_CONTACT, null /* directoryId */,
+ INVALID_CONTACT, null, true, isValid, null /* lookupKey */, null /* permissions */);
+ }
+
+ /**
+ * Construct a RecipientEntry from just an address that has been entered
+ * with both an associated display name. This address has not been resolved
+ * to a contact and therefore does not have a contact id or photo.
+ */
+ public static RecipientEntry constructGeneratedEntry(String display, String address,
+ boolean isValid) {
+ return new RecipientEntry(ENTRY_TYPE_PERSON, display, address, INVALID_DESTINATION_TYPE,
+ null, GENERATED_CONTACT, null /* directoryId */, GENERATED_CONTACT, null, true,
+ isValid, null /* lookupKey */, null /* permissions */);
+ }
+
+ public static RecipientEntry constructTopLevelEntry(String displayName, int displayNameSource,
+ String destination, int destinationType, String destinationLabel, long contactId,
+ Long directoryId, long dataId, Uri photoThumbnailUri, boolean isValid,
+ String lookupKey) {
+ return new RecipientEntry(ENTRY_TYPE_PERSON, pickDisplayName(displayNameSource,
+ displayName, destination), destination, destinationType, destinationLabel,
+ contactId, directoryId, dataId, photoThumbnailUri, true, isValid, lookupKey,
+ null /* permissions */);
+ }
+
+ public static RecipientEntry constructTopLevelEntry(String displayName, int displayNameSource,
+ String destination, int destinationType, String destinationLabel, long contactId,
+ Long directoryId, long dataId, String thumbnailUriAsString, boolean isValid,
+ String lookupKey) {
+ return new RecipientEntry(ENTRY_TYPE_PERSON, pickDisplayName(displayNameSource,
+ displayName, destination), destination, destinationType, destinationLabel,
+ contactId, directoryId, dataId, (thumbnailUriAsString != null
+ ? Uri.parse(thumbnailUriAsString) : null), true, isValid, lookupKey,
+ null /* permissions */);
+ }
+
+ public static RecipientEntry constructSecondLevelEntry(String displayName,
+ int displayNameSource, String destination, int destinationType,
+ String destinationLabel, long contactId, Long directoryId, long dataId,
+ String thumbnailUriAsString, boolean isValid, String lookupKey) {
+ return new RecipientEntry(ENTRY_TYPE_PERSON, pickDisplayName(displayNameSource,
+ displayName, destination), destination, destinationType, destinationLabel,
+ contactId, directoryId, dataId, (thumbnailUriAsString != null
+ ? Uri.parse(thumbnailUriAsString) : null), false, isValid, lookupKey,
+ null /* permissions */);
+ }
+
+ public static RecipientEntry constructPermissionEntry(String[] permissions) {
+ return new RecipientEntry(
+ ENTRY_TYPE_PERMISSION_REQUEST,
+ "" /* displayName */,
+ "" /* destination */,
+ Email.TYPE_CUSTOM,
+ "" /* destinationLabel */,
+ INVALID_CONTACT,
+ null /* directoryId */,
+ INVALID_CONTACT,
+ null /* photoThumbnailUri */,
+ true /* isFirstLevel*/,
+ false /* isValid */,
+ null /* lookupKey */,
+ permissions);
+ }
+
+ /**
+ * @return the display name for the entry. If the display name source is larger than
+ * {@link DisplayNameSources#PHONE} we use the contact's display name, but if not,
+ * i.e. the display name came from an email address or a phone number, we don't use it
+ * to avoid confusion and just use the destination instead.
+ */
+ private static String pickDisplayName(int displayNameSource, String displayName,
+ String destination) {
+ return (displayNameSource > DisplayNameSources.PHONE) ? displayName : destination;
+ }
+
+ public int getEntryType() {
+ return mEntryType;
+ }
+
+ public String getDisplayName() {
+ return mDisplayName;
+ }
+
+ public String getDestination() {
+ return mDestination;
+ }
+
+ public int getDestinationType() {
+ return mDestinationType;
+ }
+
+ public String getDestinationLabel() {
+ return mDestinationLabel;
+ }
+
+ public long getContactId() {
+ return mContactId;
+ }
+
+ public Long getDirectoryId() {
+ return mDirectoryId;
+ }
+
+ public long getDataId() {
+ return mDataId;
+ }
+
+ public boolean isFirstLevel() {
+ return mIsFirstLevel;
+ }
+
+ public Uri getPhotoThumbnailUri() {
+ return mPhotoThumbnailUri;
+ }
+
+ /** Indicates whether the icon in the chip is displayed or not. */
+ public boolean shouldDisplayIcon() {
+ return mShouldDisplayIcon;
+ }
+
+ /** This can be called outside main Looper thread. */
+ public synchronized void setPhotoBytes(byte[] photoBytes) {
+ mPhotoBytes = photoBytes;
+ }
+
+ /** This can be called outside main Looper thread. */
+ public synchronized byte[] getPhotoBytes() {
+ return mPhotoBytes;
+ }
+
+ /**
+ * Used together with {@link #ENTRY_TYPE_PERMISSION_REQUEST} and indicates what permissions we
+ * need to ask user to grant.
+ */
+ public String[] getPermissions() {
+ return mPermissions;
+ }
+
+ public String getLookupKey() {
+ return mLookupKey;
+ }
+
+ public boolean isSelectable() {
+ return mEntryType == ENTRY_TYPE_PERSON || mEntryType == ENTRY_TYPE_PERMISSION_REQUEST;
+ }
+
+ @Override
+ public String toString() {
+ return mDisplayName + " <" + mDestination + ">, isValid=" + mIsValid;
+ }
+
+ /**
+ * Returns if entry represents the same person as this instance. The default implementation
+ * checks whether the contact ids are the same, and subclasses may opt to override this.
+ */
+ public boolean isSamePerson(final RecipientEntry entry) {
+ return entry != null && mContactId == entry.mContactId;
+ }
+
+ /**
+ * Returns the resource ID for the indicator icon, or 0 if no icon should be displayed.
+ */
+ @DrawableRes
+ public int getIndicatorIconId() {
+ return mIndicatorIconId;
+ }
+
+ /**
+ * Sets the indicator icon to the given resource ID. Set to 0 to display no icon.
+ */
+ public void setIndicatorIconId(@DrawableRes int indicatorIconId) {
+ mIndicatorIconId = indicatorIconId;
+ }
+
+ /**
+ * Get the indicator text, or null if no text should be displayed.
+ */
+ public String getIndicatorText() {
+ return mIndicatorText;
+ }
+
+ /**
+ * Set the indicator text. Set to null for no text to be displayed.
+ */
+ public void setIndicatorText(String indicatorText) {
+ mIndicatorText = indicatorText;
+ }
+
+ /**
+ * Get whether this RecipientEntry is in a replaced chip or not. Replaced chip only occurs
+ * if {@link RecipientEditTextView} uses a replacement chip for the entry.
+ */
+ public boolean getInReplacedChip() {
+ return mInReplacedChip;
+ }
+
+ /**
+ * Sets {@link #mInReplacedChip} to {@param inReplacedChip}.
+ */
+ public void setInReplacedChip(boolean inReplacedChip) {
+ mInReplacedChip = inReplacedChip;
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/SingleRecipientArrayAdapter.java b/modules/chips/src/com/android/ex/chips/SingleRecipientArrayAdapter.java
new file mode 100644
index 0000000..f08746e
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/SingleRecipientArrayAdapter.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.content.Context;
+import android.graphics.drawable.StateListDrawable;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+
+import com.android.ex.chips.DropdownChipLayouter.AdapterType;
+
+class SingleRecipientArrayAdapter extends ArrayAdapter {
+ private final DropdownChipLayouter mDropdownChipLayouter;
+ private final StateListDrawable mDeleteDrawable;
+
+ public SingleRecipientArrayAdapter(Context context, RecipientEntry entry,
+ DropdownChipLayouter dropdownChipLayouter) {
+ this(context, entry, dropdownChipLayouter, null);
+ }
+
+ public SingleRecipientArrayAdapter(Context context, RecipientEntry entry,
+ DropdownChipLayouter dropdownChipLayouter, StateListDrawable deleteDrawable) {
+ super(context,
+ dropdownChipLayouter.getAlternateItemLayoutResId(AdapterType.SINGLE_RECIPIENT),
+ new RecipientEntry[] { entry });
+
+ mDropdownChipLayouter = dropdownChipLayouter;
+ mDeleteDrawable = deleteDrawable;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ return mDropdownChipLayouter.bindView(convertView, parent, getItem(position), position,
+ AdapterType.SINGLE_RECIPIENT, null, mDeleteDrawable);
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/BaseRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/BaseRecipientChip.java
new file mode 100644
index 0000000..8012b5c
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/recipientchip/BaseRecipientChip.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2013 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.ex.chips.recipientchip;
+
+import com.android.ex.chips.RecipientEntry;
+
+/**
+ * BaseRecipientChip defines an object that contains information relevant to a
+ * particular recipient.
+ */
+interface BaseRecipientChip {
+
+ /**
+ * Set the selected state of the chip.
+ */
+ void setSelected(boolean selected);
+
+ /**
+ * Return true if the chip is selected.
+ */
+ boolean isSelected();
+
+ /**
+ * Get the text displayed in the chip.
+ */
+ CharSequence getDisplay();
+
+ /**
+ * Get the text value this chip represents.
+ */
+ CharSequence getValue();
+
+ /**
+ * Get the id of the contact associated with this chip.
+ */
+ long getContactId();
+
+ /**
+ * Get the directory id of the contact associated with this chip.
+ */
+ Long getDirectoryId();
+
+ /**
+ * Get the directory lookup key associated with this chip, or null.
+ */
+ String getLookupKey();
+
+ /**
+ * Get the id of the data associated with this chip.
+ */
+ long getDataId();
+
+ /**
+ * Get associated RecipientEntry.
+ */
+ RecipientEntry getEntry();
+
+ /**
+ * Set the text in the edittextview originally associated with this chip
+ * before any reverse lookups.
+ */
+ void setOriginalText(String text);
+
+ /**
+ * Set the text in the edittextview originally associated with this chip
+ * before any reverse lookups.
+ */
+ CharSequence getOriginalText();
+}
diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/DrawableRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/DrawableRecipientChip.java
new file mode 100644
index 0000000..49c18ee
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/recipientchip/DrawableRecipientChip.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2011 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.ex.chips.recipientchip;
+
+import android.graphics.Canvas;
+import android.graphics.Rect;
+
+/**
+ * RecipientChip defines a drawable object that contains information relevant to a
+ * particular recipient.
+ */
+public interface DrawableRecipientChip extends BaseRecipientChip {
+ /**
+ * Get the bounds of the chip; may be 0,0 if it is not visibly rendered.
+ */
+ Rect getBounds();
+
+ /**
+ * Gets the bounds of the warning icon on the chip; may be 0,0 if not visibly rendered or if
+ * there is no warning icon.
+ */
+ Rect getWarningIconBounds();
+
+ /**
+ * Draw the chip.
+ */
+ void draw(Canvas canvas);
+}
diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/InvisibleRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/InvisibleRecipientChip.java
new file mode 100644
index 0000000..fed9dfe
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/recipientchip/InvisibleRecipientChip.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2011 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.ex.chips.recipientchip;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.text.style.ReplacementSpan;
+
+import com.android.ex.chips.RecipientEntry;
+
+/**
+ * RecipientChip defines a span that contains information relevant to a
+ * particular recipient.
+ */
+public class InvisibleRecipientChip extends ReplacementSpan implements DrawableRecipientChip {
+ private final SimpleRecipientChip mDelegate;
+ private static final Rect NULL_RECTANGLE = new Rect(0, 0, 0, 0);
+
+ public InvisibleRecipientChip(final RecipientEntry entry) {
+ super();
+
+ mDelegate = new SimpleRecipientChip(entry);
+ }
+
+ @Override
+ public void setSelected(final boolean selected) {
+ mDelegate.setSelected(selected);
+ }
+
+ @Override
+ public boolean isSelected() {
+ return mDelegate.isSelected();
+ }
+
+ @Override
+ public CharSequence getDisplay() {
+ return mDelegate.getDisplay();
+ }
+
+ @Override
+ public CharSequence getValue() {
+ return mDelegate.getValue();
+ }
+
+ @Override
+ public long getContactId() {
+ return mDelegate.getContactId();
+ }
+
+ @Override
+ public Long getDirectoryId() {
+ return mDelegate.getDirectoryId();
+ }
+
+ @Override
+ public String getLookupKey() {
+ return mDelegate.getLookupKey();
+ }
+
+ @Override
+ public long getDataId() {
+ return mDelegate.getDataId();
+ }
+
+ @Override
+ public RecipientEntry getEntry() {
+ return mDelegate.getEntry();
+ }
+
+ @Override
+ public void setOriginalText(final String text) {
+ mDelegate.setOriginalText(text);
+ }
+
+ @Override
+ public CharSequence getOriginalText() {
+ return mDelegate.getOriginalText();
+ }
+
+ @Override
+ public void draw(final Canvas canvas, final CharSequence text, final int start, final int end,
+ final float x, final int top, final int y, final int bottom, final Paint paint) {
+ // Do nothing.
+ }
+
+ @Override
+ public int getSize(final Paint paint, final CharSequence text, final int start, final int end,
+ final Paint.FontMetricsInt fm) {
+ return 0;
+ }
+
+ @Override
+ public Rect getBounds() {
+ return NULL_RECTANGLE;
+ }
+
+ @Override
+ public Rect getWarningIconBounds() {
+ return NULL_RECTANGLE;
+ }
+
+ @Override
+ public void draw(final Canvas canvas) {
+ // do nothing.
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/ReplacementDrawableSpan.java b/modules/chips/src/com/android/ex/chips/recipientchip/ReplacementDrawableSpan.java
new file mode 100644
index 0000000..5ef445d
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/recipientchip/ReplacementDrawableSpan.java
@@ -0,0 +1,63 @@
+package com.android.ex.chips.recipientchip;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.text.style.ReplacementSpan;
+
+/**
+ * ReplacementSpan that properly draws the drawable that is centered around the text
+ * without changing the default text size or layout.
+ */
+public class ReplacementDrawableSpan extends ReplacementSpan {
+ protected static final Paint sWorkPaint = new Paint();
+
+ protected Drawable mDrawable;
+ private float mExtraMargin;
+
+ public ReplacementDrawableSpan(Drawable drawable) {
+ super();
+ mDrawable = drawable;
+ }
+
+ public void setExtraMargin(float margin) {
+ mExtraMargin = margin;
+ }
+
+ private void setupFontMetrics(Paint.FontMetricsInt fm, Paint paint) {
+ sWorkPaint.set(paint);
+ if (fm != null) {
+ sWorkPaint.getFontMetricsInt(fm);
+
+ final Rect bounds = getBounds();
+ final int textHeight = fm.descent - fm.ascent;
+ final int halfMargin = (int) mExtraMargin / 2;
+ fm.ascent = Math.min(fm.top, fm.top + (textHeight - bounds.bottom) / 2) - halfMargin;
+ fm.descent = Math.max(fm.bottom, fm.bottom + (bounds.bottom - textHeight) / 2)
+ + halfMargin;
+ fm.top = fm.ascent;
+ fm.bottom = fm.descent;
+ }
+ }
+
+ @Override
+ public int getSize(Paint paint, CharSequence text, int i, int i2, Paint.FontMetricsInt fm) {
+ setupFontMetrics(fm, paint);
+ return getBounds().right;
+ }
+
+ @Override
+ public void draw(Canvas canvas, CharSequence charSequence, int start, int end, float x, int top,
+ int y, int bottom, Paint paint) {
+ canvas.save();
+ int transY = (bottom - mDrawable.getBounds().bottom + top) / 2;
+ canvas.translate(x, transY);
+ mDrawable.draw(canvas);
+ canvas.restore();
+ }
+
+ protected Rect getBounds() {
+ return mDrawable.getBounds();
+ }
+}
diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/SimpleRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/SimpleRecipientChip.java
new file mode 100644
index 0000000..533f53f
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/recipientchip/SimpleRecipientChip.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2013 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.ex.chips.recipientchip;
+
+import com.android.ex.chips.RecipientEntry;
+
+import android.text.TextUtils;
+
+class SimpleRecipientChip implements BaseRecipientChip {
+ private final CharSequence mDisplay;
+
+ private final CharSequence mValue;
+
+ private final long mContactId;
+
+ private final Long mDirectoryId;
+
+ private final String mLookupKey;
+
+ private final long mDataId;
+
+ private final RecipientEntry mEntry;
+
+ private boolean mSelected = false;
+
+ private CharSequence mOriginalText;
+
+ public SimpleRecipientChip(final RecipientEntry entry) {
+ mDisplay = entry.getDisplayName();
+ mValue = entry.getDestination().trim();
+ mContactId = entry.getContactId();
+ mDirectoryId = entry.getDirectoryId();
+ mLookupKey = entry.getLookupKey();
+ mDataId = entry.getDataId();
+ mEntry = entry;
+ }
+
+ @Override
+ public void setSelected(final boolean selected) {
+ mSelected = selected;
+ }
+
+ @Override
+ public boolean isSelected() {
+ return mSelected;
+ }
+
+ @Override
+ public CharSequence getDisplay() {
+ return mDisplay;
+ }
+
+ @Override
+ public CharSequence getValue() {
+ return mValue;
+ }
+
+ @Override
+ public long getContactId() {
+ return mContactId;
+ }
+
+ @Override
+ public Long getDirectoryId() {
+ return mDirectoryId;
+ }
+
+ @Override
+ public String getLookupKey() {
+ return mLookupKey;
+ }
+
+ @Override
+ public long getDataId() {
+ return mDataId;
+ }
+
+ @Override
+ public RecipientEntry getEntry() {
+ return mEntry;
+ }
+
+ @Override
+ public void setOriginalText(final String text) {
+ if (TextUtils.isEmpty(text)) {
+ mOriginalText = text;
+ } else {
+ mOriginalText = text.trim();
+ }
+ }
+
+ @Override
+ public CharSequence getOriginalText() {
+ return !TextUtils.isEmpty(mOriginalText) ? mOriginalText : mEntry.getDestination();
+ }
+
+ @Override
+ public String toString() {
+ return mDisplay + " <" + mValue + ">";
+ }
+}
\ No newline at end of file
diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/VisibleRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/VisibleRecipientChip.java
new file mode 100644
index 0000000..7976a89
--- /dev/null
+++ b/modules/chips/src/com/android/ex/chips/recipientchip/VisibleRecipientChip.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2011 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.ex.chips.recipientchip;
+
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+
+import com.android.ex.chips.RecipientEntry;
+
+/**
+ * VisibleRecipientChip defines a ReplacementSpan that contains information relevant to a
+ * particular recipient and renders a background asset to go with it.
+ */
+public class VisibleRecipientChip extends ReplacementDrawableSpan implements DrawableRecipientChip {
+ private final SimpleRecipientChip mDelegate;
+ private Rect mWarningIconBounds = new Rect(0, 0, 0, 0);
+
+ public VisibleRecipientChip(final Drawable drawable, final RecipientEntry entry) {
+ super(drawable);
+ mDelegate = new SimpleRecipientChip(entry);
+ }
+
+ @Override
+ public void setSelected(final boolean selected) {
+ mDelegate.setSelected(selected);
+ }
+
+ @Override
+ public boolean isSelected() {
+ return mDelegate.isSelected();
+ }
+
+ @Override
+ public CharSequence getDisplay() {
+ return mDelegate.getDisplay();
+ }
+
+ @Override
+ public CharSequence getValue() {
+ return mDelegate.getValue();
+ }
+
+ @Override
+ public long getContactId() {
+ return mDelegate.getContactId();
+ }
+
+ @Override
+ public Long getDirectoryId() {
+ return mDelegate.getDirectoryId();
+ }
+
+ @Override
+ public String getLookupKey() {
+ return mDelegate.getLookupKey();
+ }
+
+ @Override
+ public long getDataId() {
+ return mDelegate.getDataId();
+ }
+
+ @Override
+ public RecipientEntry getEntry() {
+ return mDelegate.getEntry();
+ }
+
+ @Override
+ public void setOriginalText(final String text) {
+ mDelegate.setOriginalText(text);
+ }
+
+ @Override
+ public CharSequence getOriginalText() {
+ return mDelegate.getOriginalText();
+ }
+
+ @Override
+ public Rect getBounds() {
+ return super.getBounds();
+ }
+
+ @Override
+ public void draw(final Canvas canvas) {
+ mDrawable.draw(canvas);
+ }
+
+ @Override
+ public String toString() {
+ return mDelegate.toString();
+ }
+
+ @Override
+ public Rect getWarningIconBounds() {
+ return mWarningIconBounds;
+ }
+
+ public void setWarningIconBounds(Rect warningIconBounds) {
+ mWarningIconBounds = warningIconBounds;
+ }
+}
diff --git a/modules/chips/tests/Android.mk b/modules/chips/tests/Android.mk
new file mode 100644
index 0000000..fa11974
--- /dev/null
+++ b/modules/chips/tests/Android.mk
@@ -0,0 +1,29 @@
+# Copyright (C) 2011 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.
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+LOCAL_PACKAGE_NAME := libChipsTests
+LOCAL_MODULE_TAGS := tests
+LOCAL_SDK_VERSION := current
+LOCAL_CERTIFICATE := platform
+LOCAL_STATIC_JAVA_LIBRARIES := libchips legacy-android-test junit
+LOCAL_RESOURCE_DIR := frameworks/opt/chips/res/
+LOCAL_AAPT_FLAGS := --auto-add-overlay
+LOCAL_AAPT_FLAGS += --extra-packages com.android.ex.chips
+
+include $(BUILD_PACKAGE)
+
diff --git a/modules/chips/tests/AndroidManifest.xml b/modules/chips/tests/AndroidManifest.xml
new file mode 100644
index 0000000..b2b307e
--- /dev/null
+++ b/modules/chips/tests/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/chips/tests/src/com/android/ex/chips/ChipsTest.java b/modules/chips/tests/src/com/android/ex/chips/ChipsTest.java
new file mode 100644
index 0000000..6e48677
--- /dev/null
+++ b/modules/chips/tests/src/com/android/ex/chips/ChipsTest.java
@@ -0,0 +1,1056 @@
+/*
+ * Copyright (C) 2011 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.ex.chips;
+
+import android.annotation.TargetApi;
+import android.content.ClipData;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.text.Editable;
+import android.text.SpannableStringBuilder;
+import android.text.util.Rfc822Tokenizer;
+import android.widget.TextView;
+
+import com.android.ex.chips.recipientchip.DrawableRecipientChip;
+import com.android.ex.chips.recipientchip.ReplacementDrawableSpan;
+import com.android.ex.chips.recipientchip.VisibleRecipientChip;
+
+import java.util.regex.Pattern;
+
+@SmallTest
+public class ChipsTest extends AndroidTestCase {
+ private DrawableRecipientChip[] mMockRecips;
+
+ private RecipientEntry[] mMockEntries;
+
+ private Rfc822Tokenizer mTokenizer;
+
+ private Editable mEditable;
+
+ class BaseMockRecipientEditTextView extends RecipientEditTextView {
+
+ public BaseMockRecipientEditTextView(Context context) {
+ super(context, null);
+ mTokenizer = new Rfc822Tokenizer();
+ setTokenizer(mTokenizer);
+ }
+
+ @Override
+ public DrawableRecipientChip[] getSortedRecipients() {
+ return mMockRecips;
+ }
+
+ @Override
+ public int getLineHeight() {
+ return 48;
+ }
+
+ @Override
+ Drawable getChipBackground(RecipientEntry contact) {
+ return createChipBackground();
+ }
+
+ @Override
+ public int getViewWidth() {
+ return 100;
+ }
+ }
+
+ class MockRecipientEditTextView extends BaseMockRecipientEditTextView {
+
+ public MockRecipientEditTextView(Context context) {
+ super(context);
+ mTokenizer = new Rfc822Tokenizer();
+ setTokenizer(mTokenizer);
+ }
+
+ @Override
+ public DrawableRecipientChip[] getSortedRecipients() {
+ return mMockRecips;
+ }
+
+ @Override
+ public Editable getText() {
+ return mEditable;
+ }
+
+ @Override
+ public Editable getSpannable() {
+ return mEditable;
+ }
+
+ @Override
+ public int getLineHeight() {
+ return 48;
+ }
+
+ @Override
+ Drawable getChipBackground(RecipientEntry contact) {
+ return createChipBackground();
+ }
+
+ @Override
+ public int length() {
+ return mEditable != null ? mEditable.length() : 0;
+ }
+
+ @Override
+ public String toString() {
+ return mEditable != null ? mEditable.toString() : "";
+ }
+
+ @Override
+ public int getViewWidth() {
+ return 100;
+ }
+ }
+
+ private class TestBaseRecipientAdapter extends BaseRecipientAdapter {
+ public TestBaseRecipientAdapter(final Context context) {
+ super(context);
+ }
+
+ public TestBaseRecipientAdapter(final Context context, final int preferredMaxResultCount,
+ final int queryMode) {
+ super(context, preferredMaxResultCount, queryMode);
+ }
+ }
+
+ private MockRecipientEditTextView createViewForTesting() {
+ mEditable = new SpannableStringBuilder();
+ MockRecipientEditTextView view = new MockRecipientEditTextView(getContext());
+ view.setAdapter(new TestBaseRecipientAdapter(getContext()));
+ return view;
+ }
+
+ public void testCreateDisplayText() {
+ RecipientEditTextView view = createViewForTesting();
+ RecipientEntry entry = RecipientEntry.constructGeneratedEntry("User Name, Jr",
+ "user@username.com", true);
+ String testAddress = view.createAddressText(entry);
+ String testDisplay = view.createChipDisplayText(entry);
+ assertEquals("Expected a properly formatted RFC email address",
+ "\"User Name, Jr\" , ", testAddress);
+ assertEquals("Expected a displayable name", "User Name, Jr", testDisplay);
+
+ RecipientEntry alreadyFormatted =
+ RecipientEntry.constructFakeEntry("user@username.com, ", true);
+ testAddress = view.createAddressText(alreadyFormatted);
+ testDisplay = view.createChipDisplayText(alreadyFormatted);
+ assertEquals("Expected a properly formatted RFC email address", ", ",
+ testAddress);
+ assertEquals("Expected a displayable name", "user@username.com", testDisplay);
+
+ RecipientEntry alreadyFormattedNoSpace = RecipientEntry
+ .constructFakeEntry("user@username.com,", true);
+ testAddress = view.createAddressText(alreadyFormattedNoSpace);
+ assertEquals("Expected a properly formatted RFC email address", ", ",
+ testAddress);
+
+ RecipientEntry alreadyNamed = RecipientEntry.constructGeneratedEntry("User Name",
+ "\"User Name, Jr\" ", true);
+ testAddress = view.createAddressText(alreadyNamed);
+ testDisplay = view.createChipDisplayText(alreadyNamed);
+ assertEquals(
+ "Expected address that used the name not the excess address name",
+ "User Name , ", testAddress);
+ assertEquals("Expected a displayable name", "User Name", testDisplay);
+ }
+
+ public void testSanitizeBetween() {
+ // First, add 2 chips and then make sure we remove
+ // the extra content between them correctly.
+ populateMocks(2);
+ MockRecipientEditTextView view = createViewForTesting();
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String extra = "EXTRA";
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + extra + second);
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], secondStart, secondEnd, 0);
+ view.sanitizeBetween();
+ String editableString = mEditable.toString();
+ assertEquals(editableString.indexOf(extra), -1);
+ assertEquals(editableString.indexOf(first), firstStart);
+ assertEquals(editableString.indexOf(second), secondStart - extra.length());
+ assertEquals(editableString, (first + second));
+
+ // Add 1 chip and make sure that we remove the extra stuff before it correctly.
+ mEditable = new SpannableStringBuilder();
+ populateMocks(1);
+ mEditable.append(extra);
+ mEditable.append(first);
+ firstStart = mEditable.toString().indexOf(first);
+ firstEnd = firstStart + first.length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], firstStart, firstEnd, 0);
+ view.sanitizeBetween();
+ assertEquals(mEditable.toString(), first);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), firstStart
+ - extra.length());
+ }
+
+ public void testSanitizeEnd() {
+ // First, add 2 chips and then make sure we remove
+ // the extra content between them correctly.
+ populateMocks(2);
+ MockRecipientEditTextView view = createViewForTesting();
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String extra = "EXTRA";
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second);
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], secondStart, secondEnd, 0);
+ view.sanitizeEnd();
+ String editableString = mEditable.toString();
+ assertEquals(editableString.indexOf(extra), -1);
+ assertEquals(editableString.indexOf(first), firstStart);
+ assertEquals(editableString.indexOf(second), secondStart);
+ assertEquals(editableString, (first + second));
+ mEditable.append(extra);
+ editableString = mEditable.toString();
+ assertEquals(mEditable.toString(), (first + second + extra));
+ view.sanitizeEnd();
+ assertEquals(mEditable.toString(), (first + second));
+ }
+
+ public void testMoreChipPlainText() {
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first+second+third);
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.trim().length();
+ view.createMoreChipPlainText();
+ ReplacementDrawableSpan moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), thirdStart);
+ assertEquals(mEditable.getSpanEnd(moreChip), thirdEnd + 1);
+ }
+
+ public void testCountTokens() {
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ String fourth = "FOURTH,";
+ String fifth = "FIFTH,";
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first+second+third+fourth+fifth);
+ assertEquals(view.countTokens(mEditable), 5);
+ }
+
+ public void testTooManyRecips() {
+ BaseMockRecipientEditTextView view = new BaseMockRecipientEditTextView(getContext());
+ view.setMoreItem(createTestMoreItem());
+ for (int i = 0; i < 100; i++) {
+ view.append(mTokenizer.terminateToken(i + ""));
+ }
+ assertEquals(view.countTokens(view.getText()), 100);
+ view.handlePendingChips();
+ view.createMoreChip();
+ ReplacementDrawableSpan moreChip = view.getMoreChip();
+ // We show 2 chips then place a more chip.
+ int secondStart = view.getText().toString().indexOf(
+ (String) mTokenizer.terminateToken(RecipientEditTextView.CHIP_LIMIT + ""));
+ assertEquals(view.getText().getSpanStart(moreChip), secondStart);
+ assertEquals(view.getText().getSpanEnd(moreChip), view.length());
+ assertEquals(view.getSortedRecipients(), null);
+ }
+
+ public void testMoreChip() {
+ // Add 3 chips: this is the trigger point at which the more chip will be created.
+ // Test that adding the chips and then creating and removing the more chip, as if
+ // the user were focusing/ removing focus from the chips field.
+ populateMocks(3);
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first+second+third);
+
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+
+ view.createMoreChip();
+ assertEquals(mEditable.toString(), first+second+third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart);
+ // Find the more chip.
+ ReplacementDrawableSpan moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), thirdStart);
+ assertEquals(mEditable.getSpanEnd(moreChip), thirdEnd + 1);
+
+ view.removeMoreChip();
+ assertEquals(mEditable.toString(), first+second+third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), thirdStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), thirdEnd);
+ moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), -1);
+
+ // Rinse and repeat, just in case!
+ view.createMoreChip();
+ assertEquals(mEditable.toString(), first+second+third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart);
+ // Find the more chip.
+ moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), thirdStart);
+ assertEquals(mEditable.getSpanEnd(moreChip), thirdEnd + 1);
+
+ view.removeMoreChip();
+ assertEquals(mEditable.toString(), first+second+third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), thirdStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), thirdEnd);
+ moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), -1);
+ }
+
+ public void testMoreChipLotsOfUsers() {
+ // Test adding and removing the more chip in the case where we have a lot of users.
+ populateMocks(10);
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ String fourth = (String) mTokenizer.terminateToken("FOURTH");
+ String fifth = (String) mTokenizer.terminateToken("FIFTH");
+ String sixth = (String) mTokenizer.terminateToken("SIXTH");
+ String seventh = (String) mTokenizer.terminateToken("SEVENTH");
+ String eigth = (String) mTokenizer.terminateToken("EIGHTH");
+ String ninth = (String) mTokenizer.terminateToken("NINTH");
+ String tenth = (String) mTokenizer.terminateToken("TENTH");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first+second+third+fourth+fifth+sixth+seventh+eigth+ninth+tenth);
+
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.trim().length();
+ int fourthStart = mEditable.toString().indexOf(fourth);
+ int fourthEnd = fourthStart + fourth.trim().length();
+ int fifthStart = mEditable.toString().indexOf(fifth);
+ int fifthEnd = fifthStart + fifth.trim().length();
+ int sixthStart = mEditable.toString().indexOf(sixth);
+ int sixthEnd = sixthStart + sixth.trim().length();
+ int seventhStart = mEditable.toString().indexOf(seventh);
+ int seventhEnd = seventhStart + seventh.trim().length();
+ int eighthStart = mEditable.toString().indexOf(eigth);
+ int eighthEnd = eighthStart + eigth.trim().length();
+ int ninthStart = mEditable.toString().indexOf(ninth);
+ int ninthEnd = ninthStart + ninth.trim().length();
+ int tenthStart = mEditable.toString().indexOf(tenth);
+ int tenthEnd = tenthStart + tenth.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 10], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 9], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 8], thirdStart, thirdEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 7], fourthStart, fourthEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 6], fifthStart, fifthEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 5], sixthStart, sixthEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 4], seventhStart, seventhEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], eighthStart, eighthEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], ninthStart, ninthEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], tenthStart, tenthEnd, 0);
+
+ view.createMoreChip();
+ assertEquals(mEditable.toString(), first + second + third + fourth + fifth + sixth
+ + seventh + eigth + ninth + tenth);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 10]), firstStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 9]), secondStart);
+ // Find the more chip.
+ ReplacementDrawableSpan moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), thirdStart);
+ assertEquals(mEditable.getSpanEnd(moreChip), tenthEnd + 1);
+
+ view.removeMoreChip();
+ assertEquals(mEditable.toString(), first + second + third + fourth + fifth + sixth
+ + seventh + eigth + ninth + tenth);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 10]), firstStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 9]), secondStart);
+
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 8]), thirdStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 7]), fourthStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 6]), fifthStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 5]), sixthStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 4]), seventhStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), eighthStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), ninthStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), tenthStart);
+ moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), -1);
+
+ }
+
+ public void testMoreChipSpecialChars() {
+ // Make sure the more chip correctly handles extra tokenizer characters in the middle
+ // of chip text.
+ populateMocks(3);
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ String first = (String) mTokenizer.terminateToken("FI,RST");
+ String second = (String) mTokenizer.terminateToken("SE,COND");
+ String third = (String) mTokenizer.terminateToken("THI,RD");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first+second+third);
+
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+
+ view.createMoreChip();
+ assertEquals(mEditable.toString(), first+second+third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart);
+ // Find the more chip.
+ ReplacementDrawableSpan moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), thirdStart);
+ assertEquals(mEditable.getSpanEnd(moreChip), thirdEnd + 1);
+
+ view.removeMoreChip();
+ assertEquals(mEditable.toString(), first+second+third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), thirdStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), thirdEnd);
+ moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), -1);
+ }
+
+ public void testMoreChipDupes() {
+ // Make sure the more chip is correctly added and removed when we have duplicate chips.
+ populateMocks(4);
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first+second+third+third);
+
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.trim().length();
+ int thirdNextStart = mEditable.toString().indexOf(third, thirdEnd);
+ int thirdNextEnd = thirdNextStart + third.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 4], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], thirdStart, thirdEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdNextStart, thirdNextEnd, 0);
+
+ view.createMoreChip();
+ assertEquals(mEditable.toString(), first+second+third+third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 4]), firstStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), secondStart);
+ // Find the more chip.
+ ReplacementDrawableSpan moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), thirdStart);
+ assertEquals(mEditable.getSpanEnd(moreChip), thirdNextEnd + 1);
+
+ view.removeMoreChip();
+ assertEquals(mEditable.toString(), first+second+third+third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 4]), firstStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 4]), firstEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), secondStart);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), thirdStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), thirdEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), thirdNextStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), thirdNextEnd);
+ moreChip = view.getMoreChip();
+ assertEquals(mEditable.getSpanStart(moreChip), -1);
+ }
+
+ public void testRemoveChip() {
+ // Create 3 chips to start and test removing chips in various postions.
+ populateMocks(3);
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.length();
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+ assertEquals(mEditable.toString(), first + second + third);
+ // Test removing the middle chip.
+ view.removeChip(mMockRecips[mMockRecips.length - 2]);
+ assertEquals(mEditable.toString(), first + third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), -1);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), -1);
+ int newThirdStart = mEditable.toString().indexOf(third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), newThirdStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), newThirdStart
+ + third.length());
+
+ // Test removing the first chip.
+ populateMocks(3);
+ view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+
+ firstStart = mEditable.toString().indexOf(first);
+ firstEnd = firstStart + first.length();
+ secondStart = mEditable.toString().indexOf(second);
+ secondEnd = secondStart + second.length();
+ thirdStart = mEditable.toString().indexOf(third);
+ thirdEnd = thirdStart + third.length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+ assertEquals(mEditable.toString(), first + second + third);
+ view.removeChip(mMockRecips[mMockRecips.length - 3]);
+ assertEquals(mEditable.toString(), second + third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), -1);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), -1);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), 0);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), second.length());
+ newThirdStart = mEditable.toString().indexOf(third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), newThirdStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), newThirdStart
+ + third.length());
+
+ // Test removing the last chip.
+ populateMocks(3);
+ view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+
+ firstStart = mEditable.toString().indexOf(first);
+ firstEnd = firstStart + first.length();
+ secondStart = mEditable.toString().indexOf(second);
+ secondEnd = secondStart + second.length();
+ thirdStart = mEditable.toString().indexOf(third);
+ thirdEnd = thirdStart + third.length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+ assertEquals(mEditable.toString(), first + second + third);
+ view.removeChip(mMockRecips[mMockRecips.length - 1]);
+ assertEquals(mEditable.toString(), first + second);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), secondEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), -1);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), -1);
+ }
+
+ public void testReplaceChip() {
+ populateMocks(3);
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ view.setChipBackground(createChipBackground());
+ view.setChipHeight(48);
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+
+ // Test replacing the first chip with a new chip.
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+ assertEquals(mEditable.toString(), first + second + third);
+ view.replaceChip(mMockRecips[mMockRecips.length - 3], RecipientEntry
+ .constructGeneratedEntry("replacement", "replacement@replacement.com", true));
+ assertEquals(mEditable.toString(), mTokenizer
+ .terminateToken("replacement ")
+ + second + third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), -1);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), -1);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), mEditable
+ .toString().indexOf(second));
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), mEditable
+ .toString().indexOf(second)
+ + second.trim().length());
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), mEditable
+ .toString().indexOf(third));
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), mEditable
+ .toString().indexOf(third)
+ + third.trim().length());
+ DrawableRecipientChip[] spans =
+ mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class);
+ assertEquals(spans.length, 3);
+ spans = mEditable
+ .getSpans(0, mEditable.toString().indexOf(second) - 1, DrawableRecipientChip.class);
+ assertEquals((String) spans[0].getDisplay(), "replacement");
+
+
+ // Test replacing the middle chip with a new chip.
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+ firstStart = mEditable.toString().indexOf(first);
+ firstEnd = firstStart + first.trim().length();
+ secondStart = mEditable.toString().indexOf(second);
+ secondEnd = secondStart + second.trim().length();
+ thirdStart = mEditable.toString().indexOf(third);
+ thirdEnd = thirdStart + third.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+ assertEquals(mEditable.toString(), first + second + third);
+ view.replaceChip(mMockRecips[mMockRecips.length - 2], RecipientEntry
+ .constructGeneratedEntry("replacement", "replacement@replacement.com", true));
+ assertEquals(mEditable.toString(), first + mTokenizer
+ .terminateToken("replacement ") + third);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), -1);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), -1);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), mEditable
+ .toString().indexOf(third));
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), mEditable
+ .toString().indexOf(third)
+ + third.trim().length());
+ spans = mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class);
+ assertEquals(spans.length, 3);
+ spans = mEditable.getSpans(firstEnd, mEditable.toString().indexOf(third) - 1,
+ DrawableRecipientChip.class);
+ assertEquals((String) spans[0].getDisplay(), "replacement");
+
+
+ // Test replacing the last chip with a new chip.
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+ firstStart = mEditable.toString().indexOf(first);
+ firstEnd = firstStart + first.trim().length();
+ secondStart = mEditable.toString().indexOf(second);
+ secondEnd = secondStart + second.trim().length();
+ thirdStart = mEditable.toString().indexOf(third);
+ thirdEnd = thirdStart + third.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+ assertEquals(mEditable.toString(), first + second + third);
+ view.replaceChip(mMockRecips[mMockRecips.length - 1], RecipientEntry
+ .constructGeneratedEntry("replacement", "replacement@replacement.com", true));
+ assertEquals(mEditable.toString(), first + second + mTokenizer
+ .terminateToken("replacement "));
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), secondEnd);
+ assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), -1);
+ assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), -1);
+ spans = mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class);
+ assertEquals(spans.length, 3);
+ spans = mEditable
+ .getSpans(secondEnd, mEditable.length(), DrawableRecipientChip.class);
+ assertEquals((String) spans[0].getDisplay(), "replacement");
+ }
+
+ public void testHandlePaste() {
+ // Start with an empty edit field.
+ // Add an address; the text should be left as is.
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ view.setChipBackground(createChipBackground());
+ view.setChipHeight(48);
+ mEditable = new SpannableStringBuilder();
+ mEditable.append("user@user.com");
+ view.setSelection(mEditable.length());
+ view.handlePaste();
+ assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 0);
+ assertEquals(mEditable.toString(), "user@user.com");
+
+ // Test adding a single address to an empty chips field with a space at
+ // the end of it. The address should stay as text.
+ mEditable = new SpannableStringBuilder();
+ String tokenizedUser = "user@user.com" + " ";
+ mEditable.append(tokenizedUser);
+ view.setSelection(mEditable.length());
+ view.handlePaste();
+ assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 0);
+ assertEquals(mEditable.toString(), tokenizedUser);
+
+ // Test adding a single address to an empty chips field with a semicolon at
+ // the end of it. The address should become a chip
+ mEditable = new SpannableStringBuilder();
+ tokenizedUser = "user@user.com;";
+ mEditable.append(tokenizedUser);
+ view.setSelection(mEditable.length());
+ view.handlePaste();
+ assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 1);
+
+ // Test adding 2 address to an empty chips field. The second to last
+ // address should become a chip and the last address should stay as
+ // text.
+ mEditable = new SpannableStringBuilder();
+ mEditable.append("user1,user2@user.com");
+ view.setSelection(mEditable.length());
+ view.handlePaste();
+ assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 1);
+ assertEquals(mEditable.getSpans(0, mEditable.toString().indexOf("user2@user.com"),
+ DrawableRecipientChip.class).length, 1);
+ assertEquals(mEditable.toString(), ", user2@user.com");
+
+ // Test adding a single address to the end of existing chips. The existing
+ // chips should remain, and the last address should stay as text.
+ populateMocks(3);
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+ view.setSelection(mEditable.length());
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+
+ mEditable.append("user@user.com");
+ view.setSelection(mEditable.length());
+ view.handlePaste();
+ assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length,
+ mMockRecips.length);
+ assertEquals(mEditable.toString(), first + second + third + "user@user.com");
+
+ // Paste 2 addresses after existing chips. We expect the first address to be turned into
+ // a chip and the second to be left as text.
+ populateMocks(3);
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+
+ mEditable.append("user1, user2@user.com");
+ view.setSelection(mEditable.length());
+ view.handlePaste();
+ assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length,
+ mMockRecips.length + 1);
+ assertEquals(mEditable.getSpans(mEditable.toString().indexOf(""), mEditable
+ .toString().indexOf("user2@user.com") - 1, DrawableRecipientChip.class).length, 1);
+ assertEquals(mEditable.getSpans(mEditable.toString().indexOf("user2@user.com"), mEditable
+ .length(), DrawableRecipientChip.class).length, 0);
+ assertEquals(mEditable.toString(), first + second + third + ", user2@user.com");
+
+ // Paste 2 addresses after existing chips. We expect the first address to be turned into
+ // a chip and the second to be left as text. This removes the space seperator char between
+ // addresses.
+ populateMocks(3);
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+
+ mEditable.append("user1,user2@user.com");
+ view.setSelection(mEditable.length());
+ view.handlePaste();
+ assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length,
+ mMockRecips.length + 1);
+ assertEquals(mEditable.getSpans(mEditable.toString().indexOf(""), mEditable
+ .toString().indexOf("user2@user.com") - 1, DrawableRecipientChip.class).length, 1);
+ assertEquals(mEditable.getSpans(mEditable.toString().indexOf("user2@user.com"), mEditable
+ .length(), DrawableRecipientChip.class).length, 0);
+ assertEquals(mEditable.toString(), first + second + third + ", user2@user.com");
+
+ // Test a complete token pasted in at the end. It should be turned into a chip.
+ mEditable = new SpannableStringBuilder();
+ mEditable.append("user1, user2@user.com,");
+ view.setSelection(mEditable.length());
+ view.handlePaste();
+ assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 2);
+ assertEquals(mEditable.getSpans(mEditable.toString().indexOf(""), mEditable
+ .toString().indexOf("user2@user.com") - 1, DrawableRecipientChip.class).length, 1);
+ assertEquals(mEditable.getSpans(mEditable.toString().indexOf("user2@user.com"), mEditable
+ .length(), DrawableRecipientChip.class).length, 1);
+ assertEquals(mEditable.toString(), ", , ");
+ }
+
+ @TargetApi(16)
+ public void testHandlePasteClip() {
+ MockRecipientEditTextView view = createViewForTesting();
+
+ ClipData clipData = null;
+ mEditable = new SpannableStringBuilder();
+ view.handlePasteClip(clipData);
+ assertEquals("", view.getText().toString());
+
+ clipData = ClipData.newPlainText("user label", "");
+ mEditable = new SpannableStringBuilder();
+ view.handlePasteClip(clipData);
+ assertEquals("", view.getText().toString());
+
+ clipData = ClipData.newHtmlText("user label",
+ "", "email");
+ mEditable = new SpannableStringBuilder();
+ view.handlePasteClip(clipData);
+ assertEquals("", view.getText().toString());
+
+ ClipData.Item clipImageData = new ClipData.Item(Uri.parse("content://my/image"));
+ clipData = new ClipData("user label", new String[]{"image/jpeg"}, clipImageData);
+ mEditable = new SpannableStringBuilder();
+ view.handlePasteClip(clipData);
+ assertEquals("", view.getText().toString()
+ );
+ }
+
+ public void testGetPastTerminators() {
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ view.setChipBackground(createChipBackground());
+ view.setChipHeight(48);
+ String test = "test";
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(test);
+ assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)),
+ test.length());
+
+ test = "test,";
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(test);
+ assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)),
+ test.length());
+
+ test = "test, ";
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(test);
+ assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)),
+ test.length());
+
+ test = "test;";
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(test);
+ assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)),
+ test.length());
+
+ test = "test; ";
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(test);
+ assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)),
+ test.length());
+ }
+
+ public void testIsCompletedToken() {
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ view.setChipBackground(createChipBackground());
+ view.setChipHeight(48);
+ assertTrue(view.isCompletedToken("test;"));
+ assertTrue(view.isCompletedToken("test,"));
+ assertFalse(view.isCompletedToken("test"));
+ assertFalse(view.isCompletedToken("test "));
+ }
+
+ public void testGetLastChip() {
+ populateMocks(3);
+ MockRecipientEditTextView view = createViewForTesting();
+ view.setMoreItem(createTestMoreItem());
+ view.setChipBackground(createChipBackground());
+ view.setChipHeight(48);
+ String first = (String) mTokenizer.terminateToken("FIRST");
+ String second = (String) mTokenizer.terminateToken("SECOND");
+ String third = (String) mTokenizer.terminateToken("THIRD");
+ mEditable = new SpannableStringBuilder();
+ mEditable.append(first + second + third);
+
+ // Test replacing the first chip with a new chip.
+ int firstStart = mEditable.toString().indexOf(first);
+ int firstEnd = firstStart + first.trim().length();
+ int secondStart = mEditable.toString().indexOf(second);
+ int secondEnd = secondStart + second.trim().length();
+ int thirdStart = mEditable.toString().indexOf(third);
+ int thirdEnd = thirdStart + third.trim().length();
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0);
+ mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0);
+ assertEquals(view.getLastChip(), mMockRecips[mMockRecips.length - 1]);
+ mEditable.append("extra");
+ assertEquals(view.getLastChip(), mMockRecips[mMockRecips.length - 1]);
+ }
+
+ private Drawable createChipBackground() {
+ Bitmap drawable = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+ return new BitmapDrawable(getContext().getResources(), drawable);
+ }
+
+ private TextView createTestMoreItem() {
+ TextView view = new TextView(getContext());
+ view.setText("%1$s more...");
+ return view;
+ }
+
+ private void populateMocks(int size) {
+ mMockEntries = new RecipientEntry[size];
+ for (int i = 0; i < size; i++) {
+ mMockEntries[i] = RecipientEntry.constructGeneratedEntry("user",
+ "user@username.com", true);
+ }
+ mMockRecips = new DrawableRecipientChip[size];
+ for (int i = 0; i < size; i++) {
+ mMockRecips[i] = new VisibleRecipientChip(null, mMockEntries[i]);
+ }
+ }
+
+ /**
+ *
+ * Ensure the original text is always accurate, regardless of the type of email. The original
+ * text is used to determine where to display the chip span. If this test fails, it means some
+ * text that should be turned into one whole chip may behave unexpectedly.
+ *
+ *
+ * For example, a bug was seen where
+ *
+ *
+ * "Android User"
+ *
+ *
+ * was converted to
+ *
+ *
+ * Android User [android@example.com]
+ *
+ *
+ * where text inside [] is a chip.
+ *
+ */
+ public void testCreateReplacementChipOriginalText() {
+ // Name in quotes + email address
+ testCreateReplacementChipOriginalText("\"Android User\" ,");
+ // Name in quotes + email address without brackets
+ testCreateReplacementChipOriginalText("\"Android User\" android@example.com,");
+ // Name in quotes
+ testCreateReplacementChipOriginalText("\"Android User\",");
+ // Name without quotes + email address
+ testCreateReplacementChipOriginalText("Android User ,");
+ // Name without quotes
+ testCreateReplacementChipOriginalText("Android User,");
+ // Email address
+ testCreateReplacementChipOriginalText(",");
+ // Email address without brackets
+ testCreateReplacementChipOriginalText("android@example.com,");
+ }
+
+ private void testCreateReplacementChipOriginalText(final String email) {
+ // No trailing space
+ attemptCreateReplacementChipOriginalText(email.trim());
+ // Trailing space
+ attemptCreateReplacementChipOriginalText(email.trim() + " ");
+ }
+
+ private void attemptCreateReplacementChipOriginalText(final String email) {
+ final RecipientEditTextView view = new RecipientEditTextView(getContext(), null);
+
+ view.setText(email);
+ view.mPendingChips.add(email);
+
+ view.createReplacementChip(0, email.length(), view.getText(), true);
+ // The "original text" should be the email without the comma or space(s)
+ assertEquals(email.replaceAll(",\\s*$", ""),
+ view.mTemporaryRecipients.get(0).getOriginalText().toString().trim());
+ }
+
+ public void testCreateTokenizedEntryForPhone() {
+ final String phonePattern = "[^\\d]*888[^\\d]*555[^\\d]*1234[^\\d]*";
+ final String phone1 = "8885551234";
+ final String phone2 = "888-555-1234";
+ final String phone3 = "(888) 555-1234";
+
+ final RecipientEditTextView view = new RecipientEditTextView(getContext(), null);
+ final BaseRecipientAdapter adapter = new TestBaseRecipientAdapter(getContext(), 10,
+ BaseRecipientAdapter.QUERY_TYPE_PHONE);
+ view.setAdapter(adapter);
+
+ final RecipientEntry entry1 = view.createTokenizedEntry(phone1);
+ final String destination1 = entry1.getDestination();
+ assertTrue(phone1 + " failed with " + destination1,
+ Pattern.matches(phonePattern, destination1));
+
+ final RecipientEntry entry2 = view.createTokenizedEntry(phone2);
+ final String destination2 = entry2.getDestination();
+ assertTrue(phone2 + " failed with " + destination2,
+ Pattern.matches(phonePattern, destination2));
+
+ final RecipientEntry entry3 = view.createTokenizedEntry(phone3);
+ final String destination3 = entry3.getDestination();
+ assertTrue(phone3 + " failed with " + destination3,
+ Pattern.matches(phonePattern, destination3));
+ }
+}
diff --git a/modules/chips/tests/src/com/android/ex/chips/RecipientAlternatesAdapterTest.java b/modules/chips/tests/src/com/android/ex/chips/RecipientAlternatesAdapterTest.java
new file mode 100644
index 0000000..afb6a00
--- /dev/null
+++ b/modules/chips/tests/src/com/android/ex/chips/RecipientAlternatesAdapterTest.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2012 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.ex.chips;
+
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.provider.ContactsContract.DisplayNameSources;
+import android.test.AndroidTestCase;
+
+import com.android.ex.chips.RecipientAlternatesAdapter;
+import com.android.ex.chips.RecipientEntry;
+
+public class RecipientAlternatesAdapterTest extends AndroidTestCase {
+
+ public void testRemoveUndesiredDestinations() {
+ MatrixCursor c = new MatrixCursor(Queries.EMAIL.getProjection());
+ Cursor result;
+
+ // Test: Empty input
+ assertEquals(0, RecipientAlternatesAdapter.removeUndesiredDestinations(c,
+ null /* desiredMimeType */, null /* lookupKey */).getCount());
+
+
+ // Test: One row
+ addRow(c, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0);
+
+ result = RecipientAlternatesAdapter.removeUndesiredDestinations(c,
+ null /* desiredMimeType */, null /* lookupKey */);
+ assertEquals(1, result.getCount());
+ assertRow(result, 0, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0);
+
+ // Test: two unique rows, different destinations
+ addRow(c, "a", "2@android.com", 1, "home", 1000, 2000, "x", 0);
+
+ result = RecipientAlternatesAdapter.removeUndesiredDestinations(c,
+ null /* desiredMimeType */, null /* lookupKey */);
+ assertEquals(2, result.getCount());
+ assertRow(result, 0, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0);
+ assertRow(result, 1, "a", "2@android.com", 1, "home", 1000, 2000, "x", 0);
+
+ // Test: add a third row with a non-unique destination.
+ addRow(c, "ax", "1@android.com", 11, "homex", 10001, 2000, "xx", 1);
+
+ // Third row should be removed.
+ result = RecipientAlternatesAdapter.removeUndesiredDestinations(c,
+ null /* desiredMimeType */, null /* lookupKey */);
+ assertEquals(2, result.getCount());
+ assertRow(result, 0, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0);
+ assertRow(result, 1, "a", "2@android.com", 1, "home", 1000, 2000, "x", 0);
+
+ // Test: add a forth row with a non-unique destination again.
+ addRow(c, "ax", "2@android.com", 11, "homex", 10001, 2000, "xx", 1);
+
+ // Forth row should also be removed.
+ result = RecipientAlternatesAdapter.removeUndesiredDestinations(c,
+ null /* desiredMimeType */, null /* lookupKey */);
+ assertEquals(2, result.getCount());
+ assertRow(result, 0, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0);
+ assertRow(result, 1, "a", "2@android.com", 1, "home", 1000, 2000, "x", 0);
+ }
+
+ private static MatrixCursor addRow(MatrixCursor c,
+ String displayName,
+ String destination,
+ int destinationType,
+ String destinationLabel,
+ long contactId,
+ long dataId,
+ String photoUri,
+ int displayNameSource
+ ) {
+ c.addRow(new Object[] {displayName, destination, destinationType, destinationLabel,
+ contactId, dataId, photoUri, displayNameSource});
+ return c;
+ }
+
+ private static void assertRow(Cursor c, int position,
+ String displayName,
+ String destination,
+ int destinationType,
+ String destinationLabel,
+ long contactId,
+ long dataId,
+ String photoUri,
+ int displayNameSource
+ ) {
+ assertTrue(c.moveToPosition(position));
+ assertEquals(displayName, c.getString(0));
+ assertEquals(destination, c.getString(1));
+ assertEquals(destinationType, c.getInt(2));
+ assertEquals(destinationLabel, c.getString(3));
+ assertEquals(contactId, c.getLong(4));
+ assertEquals(dataId, c.getLong(5));
+ assertEquals(photoUri, c.getString(6));
+ assertEquals(displayNameSource, c.getInt(7));
+ }
+
+ public void testGetBetterRecipient() {
+ // Ensure that if either (but not both) parameters are null, the other is returned
+ {
+ final RecipientEntry entry1 =
+ RecipientEntry.constructFakeEntry("1@android.com", true);
+ final RecipientEntry entry2 = null;
+
+ assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry1, entry2), entry1);
+ assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry2, entry1), entry1);
+ }
+
+ // Ensure that if only one has a display name, it is used
+ {
+ final RecipientEntry entry1 =
+ RecipientEntry.constructTopLevelEntry("Android", DisplayNameSources.NICKNAME,
+ "1@android.com", 0, null, 0, null /* directoryId */, 0, (Uri) null,
+ true, null /* lookupKey */);
+ final RecipientEntry entry2 = RecipientEntry.constructFakeEntry("1@android.com", true);
+
+ assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry1, entry2), entry1);
+ assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry2, entry1), entry1);
+ }
+
+ // Ensure that if one has a display name different from its destination, and the other's
+ // is equal to its destination, we use the unique one
+ {
+ final RecipientEntry entry1 =
+ RecipientEntry.constructTopLevelEntry("Android", DisplayNameSources.NICKNAME,
+ "1@android.com", 0, null, 0, null /* directoryId */, 0, (Uri) null,
+ true, null /* lookupKey */);
+ final RecipientEntry entry2 =
+ RecipientEntry.constructTopLevelEntry("2@android.com", DisplayNameSources.EMAIL,
+ "2@android.com", 0, null, 0, null /* directoryId */, 0, (Uri) null,
+ true, null /* lookupKey */);
+
+ assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry1, entry2), entry1);
+ assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry2, entry1), entry1);
+ }
+
+ // Ensure that if only one has a photo, it is used
+ {
+ final RecipientEntry entry1 =
+ RecipientEntry.constructTopLevelEntry("Android", DisplayNameSources.NICKNAME,
+ "1@android.com", 0, null, 0, null /* directoryId */, 0,
+ Uri.parse("http://www.android.com"), true, null /* lookupKey */);
+ final RecipientEntry entry2 =
+ RecipientEntry.constructTopLevelEntry("Android", DisplayNameSources.EMAIL,
+ "2@android.com", 0, null, 0, null /* directoryId */,
+ 0, (Uri) null, true, null /* lookupKey */);
+
+ assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry1, entry2), entry1);
+ assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry2, entry1), entry1);
+ }
+ }
+}
diff --git a/modules/colorpicker b/modules/colorpicker
deleted file mode 160000
index 34d5137..0000000
--- a/modules/colorpicker
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 34d5137f881a0ffc9f6bb93c5d2e5bbe99265b00
diff --git a/modules/colorpicker/.gitignore b/modules/colorpicker/.gitignore
new file mode 100644
index 0000000..7792e06
--- /dev/null
+++ b/modules/colorpicker/.gitignore
@@ -0,0 +1,2 @@
+bin/
+gen/
diff --git a/modules/colorpicker/Android.bp b/modules/colorpicker/Android.bp
new file mode 100644
index 0000000..a25be55
--- /dev/null
+++ b/modules/colorpicker/Android.bp
@@ -0,0 +1,27 @@
+// Copyright (C) 2013 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+ name: "colorpicker",
+ sdk_version: "15",
+ srcs: [
+ "src/**/*.java",
+ "src/**/*.logtags",
+ ],
+ resource_dirs: ["res"],
+}
diff --git a/modules/colorpicker/AndroidManifest.xml b/modules/colorpicker/AndroidManifest.xml
new file mode 100644
index 0000000..555ef99
--- /dev/null
+++ b/modules/colorpicker/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
\ No newline at end of file
diff --git a/modules/colorpicker/build.gradle b/modules/colorpicker/build.gradle
new file mode 100644
index 0000000..4bbcfbd
--- /dev/null
+++ b/modules/colorpicker/build.gradle
@@ -0,0 +1,25 @@
+plugins {
+ id 'com.android.library'
+}
+
+android {
+ namespace 'com.android.colorpicker'
+ compileSdk 35
+
+ defaultConfig {
+ minSdk 33
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src']
+ res.srcDirs = ['res']
+ }
+ }
+}
diff --git a/modules/colorpicker/project.properties b/modules/colorpicker/project.properties
new file mode 100644
index 0000000..36f1594
--- /dev/null
+++ b/modules/colorpicker/project.properties
@@ -0,0 +1,15 @@
+# This file is automatically generated by Android Tools.
+# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
+#
+# This file must be checked in Version Control Systems.
+#
+# To customize properties used by the Ant build system edit
+# "ant.properties", and override values to adapt the script to your
+# project structure.
+#
+# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home):
+#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt
+
+# Project target.
+target=android-15
+android.library=true
diff --git a/modules/colorpicker/res/drawable-hdpi/ic_colorpicker_swatch_selected.png b/modules/colorpicker/res/drawable-hdpi/ic_colorpicker_swatch_selected.png
new file mode 100644
index 0000000..3cbfe1a
Binary files /dev/null and b/modules/colorpicker/res/drawable-hdpi/ic_colorpicker_swatch_selected.png differ
diff --git a/modules/colorpicker/res/drawable-mdpi/ic_colorpicker_swatch_selected.png b/modules/colorpicker/res/drawable-mdpi/ic_colorpicker_swatch_selected.png
new file mode 100644
index 0000000..acbdeca
Binary files /dev/null and b/modules/colorpicker/res/drawable-mdpi/ic_colorpicker_swatch_selected.png differ
diff --git a/modules/colorpicker/res/drawable-xhdpi/ic_colorpicker_swatch_selected.png b/modules/colorpicker/res/drawable-xhdpi/ic_colorpicker_swatch_selected.png
new file mode 100644
index 0000000..812ff2c
Binary files /dev/null and b/modules/colorpicker/res/drawable-xhdpi/ic_colorpicker_swatch_selected.png differ
diff --git a/modules/colorpicker/res/drawable/color_picker_swatch.xml b/modules/colorpicker/res/drawable/color_picker_swatch.xml
new file mode 100644
index 0000000..db71091
--- /dev/null
+++ b/modules/colorpicker/res/drawable/color_picker_swatch.xml
@@ -0,0 +1,16 @@
+
+
+
\ No newline at end of file
diff --git a/modules/colorpicker/res/layout/color_picker_dialog.xml b/modules/colorpicker/res/layout/color_picker_dialog.xml
new file mode 100644
index 0000000..75e0366
--- /dev/null
+++ b/modules/colorpicker/res/layout/color_picker_dialog.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/colorpicker/res/layout/color_picker_swatch.xml b/modules/colorpicker/res/layout/color_picker_swatch.xml
new file mode 100644
index 0000000..1495820
--- /dev/null
+++ b/modules/colorpicker/res/layout/color_picker_swatch.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/colorpicker/res/values/dimens.xml b/modules/colorpicker/res/values/dimens.xml
new file mode 100644
index 0000000..fc78f2f
--- /dev/null
+++ b/modules/colorpicker/res/values/dimens.xml
@@ -0,0 +1,21 @@
+
+
+
+ 64dip
+ 48dip
+ 8dip
+ 4dip
+
\ No newline at end of file
diff --git a/modules/colorpicker/res/values/strings.xml b/modules/colorpicker/res/values/strings.xml
new file mode 100644
index 0000000..a9bf37a
--- /dev/null
+++ b/modules/colorpicker/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ Select a Color
+
+ Color %1$d
+
+ Color %1$d selected
+
\ No newline at end of file
diff --git a/modules/colorpicker/src/com/android/colorpicker/ColorPickerDialog.java b/modules/colorpicker/src/com/android/colorpicker/ColorPickerDialog.java
new file mode 100644
index 0000000..0d24b49
--- /dev/null
+++ b/modules/colorpicker/src/com/android/colorpicker/ColorPickerDialog.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2013 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.colorpicker;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ProgressBar;
+
+import com.android.colorpicker.ColorPickerSwatch.OnColorSelectedListener;
+
+/**
+ * A dialog which takes in as input an array of colors and creates a palette allowing the user to
+ * select a specific color swatch, which invokes a listener.
+ */
+public class ColorPickerDialog extends DialogFragment implements OnColorSelectedListener {
+
+ public static final int SIZE_LARGE = 1;
+ public static final int SIZE_SMALL = 2;
+
+ protected AlertDialog mAlertDialog;
+
+ protected static final String KEY_TITLE_ID = "title_id";
+ protected static final String KEY_COLORS = "colors";
+ protected static final String KEY_COLOR_CONTENT_DESCRIPTIONS = "color_content_descriptions";
+ protected static final String KEY_SELECTED_COLOR = "selected_color";
+ protected static final String KEY_COLUMNS = "columns";
+ protected static final String KEY_SIZE = "size";
+
+ protected int mTitleResId = R.string.color_picker_default_title;
+ protected int[] mColors = null;
+ protected String[] mColorContentDescriptions = null;
+ protected int mSelectedColor;
+ protected int mColumns;
+ protected int mSize;
+
+ private ColorPickerPalette mPalette;
+ private ProgressBar mProgress;
+
+ protected OnColorSelectedListener mListener;
+
+ public ColorPickerDialog() {
+ // Empty constructor required for dialog fragments.
+ }
+
+ public static ColorPickerDialog newInstance(int titleResId, int[] colors, int selectedColor,
+ int columns, int size) {
+ ColorPickerDialog ret = new ColorPickerDialog();
+ ret.initialize(titleResId, colors, selectedColor, columns, size);
+ return ret;
+ }
+
+ public void initialize(int titleResId, int[] colors, int selectedColor, int columns, int size) {
+ setArguments(titleResId, columns, size);
+ setColors(colors, selectedColor);
+ }
+
+ public void setArguments(int titleResId, int columns, int size) {
+ Bundle bundle = new Bundle();
+ bundle.putInt(KEY_TITLE_ID, titleResId);
+ bundle.putInt(KEY_COLUMNS, columns);
+ bundle.putInt(KEY_SIZE, size);
+ setArguments(bundle);
+ }
+
+ public void setOnColorSelectedListener(OnColorSelectedListener listener) {
+ mListener = listener;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (getArguments() != null) {
+ mTitleResId = getArguments().getInt(KEY_TITLE_ID);
+ mColumns = getArguments().getInt(KEY_COLUMNS);
+ mSize = getArguments().getInt(KEY_SIZE);
+ }
+
+ if (savedInstanceState != null) {
+ mColors = savedInstanceState.getIntArray(KEY_COLORS);
+ mSelectedColor = (Integer) savedInstanceState.getSerializable(KEY_SELECTED_COLOR);
+ mColorContentDescriptions = savedInstanceState.getStringArray(
+ KEY_COLOR_CONTENT_DESCRIPTIONS);
+ }
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final Activity activity = getActivity();
+
+ View view = LayoutInflater.from(getActivity()).inflate(R.layout.color_picker_dialog, null);
+ mProgress = (ProgressBar) view.findViewById(android.R.id.progress);
+ mPalette = (ColorPickerPalette) view.findViewById(R.id.color_picker);
+ mPalette.init(mSize, mColumns, this);
+
+ if (mColors != null) {
+ showPaletteView();
+ }
+
+ mAlertDialog = new AlertDialog.Builder(activity)
+ .setTitle(mTitleResId)
+ .setView(view)
+ .create();
+
+ return mAlertDialog;
+ }
+
+ @Override
+ public void onColorSelected(int color) {
+ if (mListener != null) {
+ mListener.onColorSelected(color);
+ }
+
+ if (getTargetFragment() instanceof OnColorSelectedListener) {
+ final OnColorSelectedListener listener =
+ (OnColorSelectedListener) getTargetFragment();
+ listener.onColorSelected(color);
+ }
+
+ if (color != mSelectedColor) {
+ mSelectedColor = color;
+ // Redraw palette to show checkmark on newly selected color before dismissing.
+ mPalette.drawPalette(mColors, mSelectedColor);
+ }
+
+ dismiss();
+ }
+
+ public void showPaletteView() {
+ if (mProgress != null && mPalette != null) {
+ mProgress.setVisibility(View.GONE);
+ refreshPalette();
+ mPalette.setVisibility(View.VISIBLE);
+ }
+ }
+
+ public void showProgressBarView() {
+ if (mProgress != null && mPalette != null) {
+ mProgress.setVisibility(View.VISIBLE);
+ mPalette.setVisibility(View.GONE);
+ }
+ }
+
+ public void setColors(int[] colors, int selectedColor) {
+ if (mColors != colors || mSelectedColor != selectedColor) {
+ mColors = colors;
+ mSelectedColor = selectedColor;
+ refreshPalette();
+ }
+ }
+
+ public void setColors(int[] colors) {
+ if (mColors != colors) {
+ mColors = colors;
+ refreshPalette();
+ }
+ }
+
+ public void setSelectedColor(int color) {
+ if (mSelectedColor != color) {
+ mSelectedColor = color;
+ refreshPalette();
+ }
+ }
+
+ public void setColorContentDescriptions(String[] colorContentDescriptions) {
+ if (mColorContentDescriptions != colorContentDescriptions) {
+ mColorContentDescriptions = colorContentDescriptions;
+ refreshPalette();
+ }
+ }
+
+ private void refreshPalette() {
+ if (mPalette != null && mColors != null) {
+ mPalette.drawPalette(mColors, mSelectedColor, mColorContentDescriptions);
+ }
+ }
+
+ public int[] getColors() {
+ return mColors;
+ }
+
+ public int getSelectedColor() {
+ return mSelectedColor;
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putIntArray(KEY_COLORS, mColors);
+ outState.putSerializable(KEY_SELECTED_COLOR, mSelectedColor);
+ outState.putStringArray(KEY_COLOR_CONTENT_DESCRIPTIONS, mColorContentDescriptions);
+ }
+}
diff --git a/modules/colorpicker/src/com/android/colorpicker/ColorPickerPalette.java b/modules/colorpicker/src/com/android/colorpicker/ColorPickerPalette.java
new file mode 100644
index 0000000..9ca46c3
--- /dev/null
+++ b/modules/colorpicker/src/com/android/colorpicker/ColorPickerPalette.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2013 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.colorpicker;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TableLayout;
+import android.widget.TableRow;
+
+import com.android.colorpicker.ColorPickerSwatch.OnColorSelectedListener;
+
+/**
+ * A color picker custom view which creates an grid of color squares. The number of squares per
+ * row (and the padding between the squares) is determined by the user.
+ */
+public class ColorPickerPalette extends TableLayout {
+
+ public OnColorSelectedListener mOnColorSelectedListener;
+
+ private String mDescription;
+ private String mDescriptionSelected;
+
+ private int mSwatchLength;
+ private int mMarginSize;
+ private int mNumColumns;
+
+ public ColorPickerPalette(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ColorPickerPalette(Context context) {
+ super(context);
+ }
+
+ /**
+ * Initialize the size, columns, and listener. Size should be a pre-defined size (SIZE_LARGE
+ * or SIZE_SMALL) from ColorPickerDialogFragment.
+ */
+ public void init(int size, int columns, OnColorSelectedListener listener) {
+ mNumColumns = columns;
+ Resources res = getResources();
+ if (size == ColorPickerDialog.SIZE_LARGE) {
+ mSwatchLength = res.getDimensionPixelSize(R.dimen.color_swatch_large);
+ mMarginSize = res.getDimensionPixelSize(R.dimen.color_swatch_margins_large);
+ } else {
+ mSwatchLength = res.getDimensionPixelSize(R.dimen.color_swatch_small);
+ mMarginSize = res.getDimensionPixelSize(R.dimen.color_swatch_margins_small);
+ }
+ mOnColorSelectedListener = listener;
+
+ mDescription = res.getString(R.string.color_swatch_description);
+ mDescriptionSelected = res.getString(R.string.color_swatch_description_selected);
+ }
+
+ private TableRow createTableRow() {
+ TableRow row = new TableRow(getContext());
+ ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(LayoutParams.WRAP_CONTENT,
+ LayoutParams.WRAP_CONTENT);
+ row.setLayoutParams(params);
+ return row;
+ }
+
+ /**
+ * Adds swatches to table in a serpentine format.
+ */
+ public void drawPalette(int[] colors, int selectedColor) {
+ drawPalette(colors, selectedColor, null);
+ }
+
+ /**
+ * Adds swatches to table in a serpentine format.
+ */
+ public void drawPalette(int[] colors, int selectedColor, String[] colorContentDescriptions) {
+ if (colors == null) {
+ return;
+ }
+
+ this.removeAllViews();
+ int tableElements = 0;
+ int rowElements = 0;
+ int rowNumber = 0;
+
+ // Fills the table with swatches based on the array of colors.
+ TableRow row = createTableRow();
+ for (int color : colors) {
+ View colorSwatch = createColorSwatch(color, selectedColor);
+ setSwatchDescription(rowNumber, tableElements, rowElements, color == selectedColor,
+ colorSwatch, colorContentDescriptions);
+ addSwatchToRow(row, colorSwatch, rowNumber);
+
+ tableElements++;
+ rowElements++;
+ if (rowElements == mNumColumns) {
+ addView(row);
+ row = createTableRow();
+ rowElements = 0;
+ rowNumber++;
+ }
+ }
+
+ // Create blank views to fill the row if the last row has not been filled.
+ if (rowElements > 0) {
+ while (rowElements != mNumColumns) {
+ addSwatchToRow(row, createBlankSpace(), rowNumber);
+ rowElements++;
+ }
+ addView(row);
+ }
+ }
+
+ /**
+ * Appends a swatch to the end of the row for even-numbered rows (starting with row 0),
+ * to the beginning of a row for odd-numbered rows.
+ */
+ private static void addSwatchToRow(TableRow row, View swatch, int rowNumber) {
+ if (rowNumber % 2 == 0) {
+ row.addView(swatch);
+ } else {
+ row.addView(swatch, 0);
+ }
+ }
+
+ /**
+ * Add a content description to the specified swatch view. Because the colors get added in a
+ * snaking form, every other row will need to compensate for the fact that the colors are added
+ * in an opposite direction from their left->right/top->bottom order, which is how the system
+ * will arrange them for accessibility purposes.
+ */
+ private void setSwatchDescription(int rowNumber, int index, int rowElements, boolean selected,
+ View swatch, String[] contentDescriptions) {
+ String description;
+ if (contentDescriptions != null && contentDescriptions.length > index) {
+ description = contentDescriptions[index];
+ } else {
+ int accessibilityIndex;
+ if (rowNumber % 2 == 0) {
+ // We're in a regular-ordered row
+ accessibilityIndex = index + 1;
+ } else {
+ // We're in a backwards-ordered row.
+ int rowMax = ((rowNumber + 1) * mNumColumns);
+ accessibilityIndex = rowMax - rowElements;
+ }
+
+ if (selected) {
+ description = String.format(mDescriptionSelected, accessibilityIndex);
+ } else {
+ description = String.format(mDescription, accessibilityIndex);
+ }
+ }
+ swatch.setContentDescription(description);
+ }
+
+ /**
+ * Creates a blank space to fill the row.
+ */
+ private ImageView createBlankSpace() {
+ ImageView view = new ImageView(getContext());
+ TableRow.LayoutParams params = new TableRow.LayoutParams(mSwatchLength, mSwatchLength);
+ params.setMargins(mMarginSize, mMarginSize, mMarginSize, mMarginSize);
+ view.setLayoutParams(params);
+ return view;
+ }
+
+ /**
+ * Creates a color swatch.
+ */
+ private ColorPickerSwatch createColorSwatch(int color, int selectedColor) {
+ ColorPickerSwatch view = new ColorPickerSwatch(getContext(), color,
+ color == selectedColor, mOnColorSelectedListener);
+ TableRow.LayoutParams params = new TableRow.LayoutParams(mSwatchLength, mSwatchLength);
+ params.setMargins(mMarginSize, mMarginSize, mMarginSize, mMarginSize);
+ view.setLayoutParams(params);
+ return view;
+ }
+}
diff --git a/modules/colorpicker/src/com/android/colorpicker/ColorPickerSwatch.java b/modules/colorpicker/src/com/android/colorpicker/ColorPickerSwatch.java
new file mode 100644
index 0000000..a204c64
--- /dev/null
+++ b/modules/colorpicker/src/com/android/colorpicker/ColorPickerSwatch.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2013 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.colorpicker;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+
+/**
+ * Creates a circular swatch of a specified color. Adds a checkmark if marked as checked.
+ */
+public class ColorPickerSwatch extends FrameLayout implements View.OnClickListener {
+ private int mColor;
+ private ImageView mSwatchImage;
+ private ImageView mCheckmarkImage;
+ private OnColorSelectedListener mOnColorSelectedListener;
+
+ /**
+ * Interface for a callback when a color square is selected.
+ */
+ public interface OnColorSelectedListener {
+
+ /**
+ * Called when a specific color square has been selected.
+ */
+ public void onColorSelected(int color);
+ }
+
+ public ColorPickerSwatch(Context context, int color, boolean checked,
+ OnColorSelectedListener listener) {
+ super(context);
+ mColor = color;
+ mOnColorSelectedListener = listener;
+
+ LayoutInflater.from(context).inflate(R.layout.color_picker_swatch, this);
+ mSwatchImage = (ImageView) findViewById(R.id.color_picker_swatch);
+ mCheckmarkImage = (ImageView) findViewById(R.id.color_picker_checkmark);
+ setColor(color);
+ setChecked(checked);
+ setOnClickListener(this);
+ }
+
+ protected void setColor(int color) {
+ Drawable[] colorDrawable = new Drawable[]
+ {getContext().getResources().getDrawable(R.drawable.color_picker_swatch)};
+ mSwatchImage.setImageDrawable(new ColorStateDrawable(colorDrawable, color));
+ }
+
+ private void setChecked(boolean checked) {
+ if (checked) {
+ mCheckmarkImage.setVisibility(View.VISIBLE);
+ } else {
+ mCheckmarkImage.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (mOnColorSelectedListener != null) {
+ mOnColorSelectedListener.onColorSelected(mColor);
+ }
+ }
+}
diff --git a/modules/colorpicker/src/com/android/colorpicker/ColorStateDrawable.java b/modules/colorpicker/src/com/android/colorpicker/ColorStateDrawable.java
new file mode 100644
index 0000000..496f46c
--- /dev/null
+++ b/modules/colorpicker/src/com/android/colorpicker/ColorStateDrawable.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2013 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.colorpicker;
+
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+
+/**
+ * A drawable which sets its color filter to a color specified by the user, and changes to a
+ * slightly darker color when pressed or focused.
+ */
+public class ColorStateDrawable extends LayerDrawable {
+
+ private static final float PRESSED_STATE_MULTIPLIER = 0.70f;
+
+ private int mColor;
+
+ public ColorStateDrawable(Drawable[] layers, int color) {
+ super(layers);
+ mColor = color;
+ }
+
+ @Override
+ protected boolean onStateChange(int[] states) {
+ boolean pressedOrFocused = false;
+ for (int state : states) {
+ if (state == android.R.attr.state_pressed || state == android.R.attr.state_focused) {
+ pressedOrFocused = true;
+ break;
+ }
+ }
+
+ if (pressedOrFocused) {
+ super.setColorFilter(getPressedColor(mColor), PorterDuff.Mode.SRC_ATOP);
+ } else {
+ super.setColorFilter(mColor, PorterDuff.Mode.SRC_ATOP);
+ }
+
+ return super.onStateChange(states);
+ }
+
+ /**
+ * Given a particular color, adjusts its value by a multiplier.
+ */
+ private static int getPressedColor(int color) {
+ float[] hsv = new float[3];
+ Color.colorToHSV(color, hsv);
+ hsv[2] = hsv[2] * PRESSED_STATE_MULTIPLIER;
+ return Color.HSVToColor(hsv);
+ }
+
+ @Override
+ public boolean isStateful() {
+ return true;
+ }
+}
diff --git a/modules/colorpicker/src/com/android/colorpicker/HsvColorComparator.java b/modules/colorpicker/src/com/android/colorpicker/HsvColorComparator.java
new file mode 100644
index 0000000..c44648f
--- /dev/null
+++ b/modules/colorpicker/src/com/android/colorpicker/HsvColorComparator.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2013 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.colorpicker;
+
+import android.graphics.Color;
+
+import java.util.Comparator;
+
+/**
+ * A color comparator which compares based on hue, saturation, and value.
+ */
+public class HsvColorComparator implements Comparator {
+
+ @Override
+ public int compare(Integer lhs, Integer rhs) {
+ float[] hsv = new float[3];
+ Color.colorToHSV(lhs, hsv);
+ float hue1 = hsv[0];
+ float sat1 = hsv[1];
+ float val1 = hsv[2];
+
+ float[] hsv2 = new float[3];
+ Color.colorToHSV(rhs, hsv2);
+ float hue2 = hsv2[0];
+ float sat2 = hsv2[1];
+ float val2 = hsv2[2];
+
+ if (hue1 < hue2) {
+ return 1;
+ } else if (hue1 > hue2) {
+ return -1;
+ } else {
+ if (sat1 < sat2) {
+ return 1;
+ } else if (sat1 > sat2) {
+ return -1;
+ } else {
+ if (val1 < val2) {
+ return 1;
+ } else if (val1 > val2) {
+ return -1;
+ }
+ }
+ }
+ return 0;
+ }
+}
diff --git a/modules/photoviewer b/modules/photoviewer
deleted file mode 160000
index fb5c100..0000000
--- a/modules/photoviewer
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit fb5c100b4a415e69e507962dd6c32660d71dfc66
diff --git a/modules/photoviewer/.gitignore b/modules/photoviewer/.gitignore
new file mode 100644
index 0000000..8d584b8
--- /dev/null
+++ b/modules/photoviewer/.gitignore
@@ -0,0 +1,7 @@
+*~
+*.bak
+*.class
+bin/
+gen/
+.classpath
+.project
diff --git a/modules/photoviewer/Android.bp b/modules/photoviewer/Android.bp
new file mode 100644
index 0000000..7ac2e3c
--- /dev/null
+++ b/modules/photoviewer/Android.bp
@@ -0,0 +1,74 @@
+// Copyright (C) 2012 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.
+
+//#################################################
+// Build appcompat library
+
+package {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+ name: "libphotoviewer_appcompat",
+
+ libs: ["androidx.annotation_annotation"],
+
+ static_libs: [
+ "androidx.core_core",
+ "androidx.legacy_legacy-support-core-ui",
+ "androidx.legacy_legacy-support-core-utils",
+ "androidx.fragment_fragment",
+ "androidx.appcompat_appcompat",
+ ],
+
+ sdk_version: "current",
+ srcs: [
+ "src/**/*.java",
+ "appcompat/src/**/*.java",
+ "src/**/*.logtags",
+ ],
+
+ resource_dirs: [
+ "appcompat/res",
+ "res",
+ ],
+}
+
+//#################################################
+// Build non-appcompat library
+
+android_library {
+ name: "libphotoviewer",
+
+ libs: ["androidx.annotation_annotation"],
+
+ static_libs: [
+ "androidx.core_core",
+ "androidx.legacy_legacy-support-core-ui",
+ "androidx.legacy_legacy-support-core-utils",
+ "androidx.fragment_fragment",
+ ],
+
+ sdk_version: "current",
+ srcs: [
+ "src/**/*.java",
+ "activity/src/**/*.java",
+ "src/**/*.logtags",
+ ],
+
+ resource_dirs: [
+ "activity/res",
+ "res",
+ ]
+}
diff --git a/modules/photoviewer/AndroidManifest.xml b/modules/photoviewer/AndroidManifest.xml
new file mode 100644
index 0000000..3af8a1c
--- /dev/null
+++ b/modules/photoviewer/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
diff --git a/modules/photoviewer/OWNERS b/modules/photoviewer/OWNERS
new file mode 100644
index 0000000..87560f9
--- /dev/null
+++ b/modules/photoviewer/OWNERS
@@ -0,0 +1 @@
+asapperstein@google.com
diff --git a/modules/photoviewer/activity/res/values/themes.xml b/modules/photoviewer/activity/res/values/themes.xml
new file mode 100644
index 0000000..20b23f9
--- /dev/null
+++ b/modules/photoviewer/activity/res/values/themes.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/photoviewer/activity/src/com/android/ex/photo/ActionBarWrapper.java b/modules/photoviewer/activity/src/com/android/ex/photo/ActionBarWrapper.java
new file mode 100644
index 0000000..6d4d4d2
--- /dev/null
+++ b/modules/photoviewer/activity/src/com/android/ex/photo/ActionBarWrapper.java
@@ -0,0 +1,76 @@
+package com.android.ex.photo;
+
+import android.app.ActionBar;
+import android.graphics.drawable.Drawable;
+
+/**
+ * Wrapper around {@link ActionBar}.
+ */
+public class ActionBarWrapper implements ActionBarInterface {
+
+ private final ActionBar mActionBar;
+
+ private class MenuVisiblityListenerWrapper implements ActionBar.OnMenuVisibilityListener {
+
+ private final ActionBarInterface.OnMenuVisibilityListener mWrapped;
+
+ public MenuVisiblityListenerWrapper(ActionBarInterface.OnMenuVisibilityListener wrapped) {
+ mWrapped = wrapped;
+ }
+
+ @Override
+ public void onMenuVisibilityChanged(boolean isVisible) {
+ mWrapped.onMenuVisibilityChanged(isVisible);
+ }
+ }
+
+ public ActionBarWrapper(ActionBar actionBar) {
+ mActionBar = actionBar;
+ }
+
+ @Override
+ public void setDisplayHomeAsUpEnabled(boolean showHomeAsUp) {
+ mActionBar.setDisplayHomeAsUpEnabled(showHomeAsUp);
+ }
+
+ @Override
+ public void addOnMenuVisibilityListener(OnMenuVisibilityListener listener) {
+ mActionBar.addOnMenuVisibilityListener(new MenuVisiblityListenerWrapper(listener));
+ }
+
+ @Override
+ public void setDisplayOptionsShowTitle() {
+ mActionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE, ActionBar.DISPLAY_SHOW_TITLE);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return mActionBar.getTitle();
+ }
+
+ @Override
+ public void setTitle(CharSequence title) {
+ mActionBar.setTitle(title);
+ }
+
+ @Override
+ public void setSubtitle(CharSequence subtitle) {
+ mActionBar.setSubtitle(subtitle);
+ }
+
+ @Override
+ public void show() {
+ mActionBar.show();
+ }
+
+ @Override
+ public void hide() {
+ mActionBar.hide();
+ }
+
+ @Override
+ public void setLogo(Drawable logo) {
+ mActionBar.setLogo(logo);
+ }
+
+}
diff --git a/modules/photoviewer/activity/src/com/android/ex/photo/PhotoViewActivity.java b/modules/photoviewer/activity/src/com/android/ex/photo/PhotoViewActivity.java
new file mode 100644
index 0000000..5dd8171
--- /dev/null
+++ b/modules/photoviewer/activity/src/com/android/ex/photo/PhotoViewActivity.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.ex.photo;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+import android.view.Menu;
+import android.view.MenuItem;
+
+/**
+ * Activity to view the contents of an album.
+ */
+public class PhotoViewActivity extends FragmentActivity
+ implements PhotoViewController.ActivityInterface {
+
+ private PhotoViewController mController;
+ private ActionBarWrapper mActionBar;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mController = createController();
+ mController.onCreate(savedInstanceState);
+ }
+
+ public PhotoViewController createController() {
+ return new PhotoViewController(this);
+ }
+
+ @Override
+ public PhotoViewController getController() {
+ return mController;
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ mController.onStart();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mController.onResume();
+ }
+
+ @Override
+ protected void onPause() {
+ mController.onPause();
+ super.onPause();
+ }
+
+ @Override
+ protected void onStop() {
+ mController.onStop();
+ super.onStop();
+ }
+
+ @Override
+ protected void onDestroy() {
+ mController.onDestroy();
+ super.onDestroy();
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (!mController.onBackPressed()) {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ mController.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ return mController.onCreateOptionsMenu(menu) || super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ return mController.onPrepareOptionsMenu(menu) || super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ return mController.onOptionsItemSelected(item) || super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ mController.onActivityResult(requestCode, resultCode, data);
+ }
+
+ @Override
+ public Context getContext() {
+ return this;
+ }
+
+ @Override
+ public ActionBarInterface getActionBarInterface() {
+ if (mActionBar == null) {
+ mActionBar = new ActionBarWrapper(getActionBar());
+ }
+ return mActionBar;
+ }
+
+}
diff --git a/modules/photoviewer/appcompat/res/values/themes.xml b/modules/photoviewer/appcompat/res/values/themes.xml
new file mode 100644
index 0000000..b04c0c7
--- /dev/null
+++ b/modules/photoviewer/appcompat/res/values/themes.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/photoviewer/appcompat/src/com/android/ex/photo/ActionBarWrapper.java b/modules/photoviewer/appcompat/src/com/android/ex/photo/ActionBarWrapper.java
new file mode 100644
index 0000000..621e2a8
--- /dev/null
+++ b/modules/photoviewer/appcompat/src/com/android/ex/photo/ActionBarWrapper.java
@@ -0,0 +1,77 @@
+package com.android.ex.photo;
+
+
+import android.graphics.drawable.Drawable;
+import androidx.appcompat.app.ActionBar;
+
+/**
+ * Wrapper around {@link ActionBar}.
+ */
+public class ActionBarWrapper implements ActionBarInterface {
+
+ private final ActionBar mActionBar;
+
+ private class MenuVisiblityListenerWrapper implements ActionBar.OnMenuVisibilityListener {
+
+ private final ActionBarInterface.OnMenuVisibilityListener mWrapped;
+
+ public MenuVisiblityListenerWrapper(ActionBarInterface.OnMenuVisibilityListener wrapped) {
+ mWrapped = wrapped;
+ }
+
+ @Override
+ public void onMenuVisibilityChanged(boolean isVisible) {
+ mWrapped.onMenuVisibilityChanged(isVisible);
+ }
+ }
+
+ public ActionBarWrapper(ActionBar actionBar) {
+ mActionBar = actionBar;
+ }
+
+ @Override
+ public void setDisplayHomeAsUpEnabled(boolean showHomeAsUp) {
+ mActionBar.setDisplayHomeAsUpEnabled(showHomeAsUp);
+ }
+
+ @Override
+ public void addOnMenuVisibilityListener(OnMenuVisibilityListener listener) {
+ mActionBar.addOnMenuVisibilityListener(new MenuVisiblityListenerWrapper(listener));
+ }
+
+ @Override
+ public void setDisplayOptionsShowTitle() {
+ mActionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE, ActionBar.DISPLAY_SHOW_TITLE);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return mActionBar.getTitle();
+ }
+
+ @Override
+ public void setTitle(CharSequence title) {
+ mActionBar.setTitle(title);
+ }
+
+ @Override
+ public void setSubtitle(CharSequence subtitle) {
+ mActionBar.setSubtitle(subtitle);
+ }
+
+ @Override
+ public void show() {
+ mActionBar.show();
+ }
+
+ @Override
+ public void hide() {
+ mActionBar.hide();
+ }
+
+ @Override
+ public void setLogo(Drawable logo) {
+ mActionBar.setLogo(logo);
+ }
+
+}
diff --git a/modules/photoviewer/appcompat/src/com/android/ex/photo/PhotoViewActivity.java b/modules/photoviewer/appcompat/src/com/android/ex/photo/PhotoViewActivity.java
new file mode 100644
index 0000000..919badd
--- /dev/null
+++ b/modules/photoviewer/appcompat/src/com/android/ex/photo/PhotoViewActivity.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.ex.photo;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import android.view.Menu;
+import android.view.MenuItem;
+
+/**
+ * Activity to view the contents of an album.
+ */
+public class PhotoViewActivity extends AppCompatActivity
+ implements PhotoViewController.ActivityInterface {
+
+ private PhotoViewController mController;
+ private ActionBarWrapper mActionBar;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mController = createController();
+ mController.onCreate(savedInstanceState);
+ }
+
+ protected PhotoViewController createController() {
+ return new PhotoViewController(this);
+ }
+
+ @Override
+ public PhotoViewController getController() {
+ return mController;
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ mController.onStart();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mController.onResume();
+ }
+
+ @Override
+ protected void onPause() {
+ mController.onPause();
+ super.onPause();
+ }
+
+ @Override
+ protected void onStop() {
+ mController.onStop();
+ super.onStop();
+ }
+
+ @Override
+ protected void onDestroy() {
+ mController.onDestroy();
+ super.onDestroy();
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (!mController.onBackPressed()) {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ mController.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ return mController.onCreateOptionsMenu(menu) || super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ return mController.onPrepareOptionsMenu(menu) || super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ return mController.onOptionsItemSelected(item) || super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ mController.onActivityResult(requestCode, resultCode, data);
+ }
+
+ @Override
+ public Context getContext() {
+ return this;
+ }
+
+ @Override
+ public ActionBarInterface getActionBarInterface() {
+ if (mActionBar == null) {
+ mActionBar = new ActionBarWrapper(getSupportActionBar());
+ }
+ return mActionBar;
+ }
+
+}
diff --git a/modules/photoviewer/build.gradle b/modules/photoviewer/build.gradle
new file mode 100644
index 0000000..8285c43
--- /dev/null
+++ b/modules/photoviewer/build.gradle
@@ -0,0 +1,33 @@
+plugins {
+ id 'com.android.library'
+}
+
+android {
+ namespace 'com.android.ex.photo'
+ compileSdk 35
+
+ defaultConfig {
+ minSdk 33
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src', 'activity/src']
+ res.srcDirs = ['res', 'activity/res']
+ }
+ }
+}
+
+dependencies {
+ implementation 'androidx.annotation:annotation:1.8.2'
+ implementation 'androidx.core:core:1.15.0'
+ implementation 'androidx.fragment:fragment:1.8.5'
+ implementation 'androidx.loader:loader:1.1.0'
+ implementation 'androidx.viewpager:viewpager:1.0.0'
+}
diff --git a/modules/photoviewer/project.properties b/modules/photoviewer/project.properties
new file mode 100644
index 0000000..91d2b02
--- /dev/null
+++ b/modules/photoviewer/project.properties
@@ -0,0 +1,15 @@
+# This file is automatically generated by Android Tools.
+# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
+#
+# This file must be checked in Version Control Systems.
+#
+# To customize properties used by the Ant build system edit
+# "ant.properties", and override values to adapt the script to your
+# project structure.
+#
+# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home):
+#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt
+
+# Project target.
+target=android-19
+android.library=true
diff --git a/modules/photoviewer/res/drawable-hdpi/actionbar_translucent.9.png b/modules/photoviewer/res/drawable-hdpi/actionbar_translucent.9.png
new file mode 100644
index 0000000..4b40967
Binary files /dev/null and b/modules/photoviewer/res/drawable-hdpi/actionbar_translucent.9.png differ
diff --git a/modules/photoviewer/res/drawable-hdpi/ic_menu_refresh_holo_dark.png b/modules/photoviewer/res/drawable-hdpi/ic_menu_refresh_holo_dark.png
new file mode 100644
index 0000000..69ac31b
Binary files /dev/null and b/modules/photoviewer/res/drawable-hdpi/ic_menu_refresh_holo_dark.png differ
diff --git a/modules/photoviewer/res/drawable-mdpi/actionbar_translucent.9.png b/modules/photoviewer/res/drawable-mdpi/actionbar_translucent.9.png
new file mode 100644
index 0000000..a995d44
Binary files /dev/null and b/modules/photoviewer/res/drawable-mdpi/actionbar_translucent.9.png differ
diff --git a/modules/photoviewer/res/drawable-mdpi/ic_menu_refresh_holo_dark.png b/modules/photoviewer/res/drawable-mdpi/ic_menu_refresh_holo_dark.png
new file mode 100644
index 0000000..f68aacf
Binary files /dev/null and b/modules/photoviewer/res/drawable-mdpi/ic_menu_refresh_holo_dark.png differ
diff --git a/modules/photoviewer/res/drawable-xhdpi/actionbar_translucent.9.png b/modules/photoviewer/res/drawable-xhdpi/actionbar_translucent.9.png
new file mode 100644
index 0000000..f4ed5fa
Binary files /dev/null and b/modules/photoviewer/res/drawable-xhdpi/actionbar_translucent.9.png differ
diff --git a/modules/photoviewer/res/drawable-xhdpi/ic_menu_refresh_holo_dark.png b/modules/photoviewer/res/drawable-xhdpi/ic_menu_refresh_holo_dark.png
new file mode 100644
index 0000000..3db90ee
Binary files /dev/null and b/modules/photoviewer/res/drawable-xhdpi/ic_menu_refresh_holo_dark.png differ
diff --git a/modules/photoviewer/res/drawable-xxhdpi/actionbar_translucent.9.png b/modules/photoviewer/res/drawable-xxhdpi/actionbar_translucent.9.png
new file mode 100644
index 0000000..13e4dbc
Binary files /dev/null and b/modules/photoviewer/res/drawable-xxhdpi/actionbar_translucent.9.png differ
diff --git a/modules/photoviewer/res/drawable-xxhdpi/ic_menu_refresh_holo_dark.png b/modules/photoviewer/res/drawable-xxhdpi/ic_menu_refresh_holo_dark.png
new file mode 100644
index 0000000..088d76b
Binary files /dev/null and b/modules/photoviewer/res/drawable-xxhdpi/ic_menu_refresh_holo_dark.png differ
diff --git a/modules/photoviewer/res/drawable/default_image.xml b/modules/photoviewer/res/drawable/default_image.xml
new file mode 100644
index 0000000..0a4369a
--- /dev/null
+++ b/modules/photoviewer/res/drawable/default_image.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/photoviewer/res/layout-v11/photo_retry_button.xml b/modules/photoviewer/res/layout-v11/photo_retry_button.xml
new file mode 100644
index 0000000..c7c7d64
--- /dev/null
+++ b/modules/photoviewer/res/layout-v11/photo_retry_button.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/modules/photoviewer/res/layout/photo_activity_view.xml b/modules/photoviewer/res/layout/photo_activity_view.xml
new file mode 100644
index 0000000..070a93c
--- /dev/null
+++ b/modules/photoviewer/res/layout/photo_activity_view.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/photoviewer/res/layout/photo_fragment_view.xml b/modules/photoviewer/res/layout/photo_fragment_view.xml
new file mode 100644
index 0000000..9fcd6ee
--- /dev/null
+++ b/modules/photoviewer/res/layout/photo_fragment_view.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/photoviewer/res/layout/photo_retry_button.xml b/modules/photoviewer/res/layout/photo_retry_button.xml
new file mode 100644
index 0000000..29a2508
--- /dev/null
+++ b/modules/photoviewer/res/layout/photo_retry_button.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/modules/photoviewer/res/values/colors.xml b/modules/photoviewer/res/values/colors.xml
new file mode 100644
index 0000000..50ef5de
--- /dev/null
+++ b/modules/photoviewer/res/values/colors.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ #ff000000
+ #d9000000
+ #cc000000
+ #fff
+
diff --git a/modules/photoviewer/res/values/config.xml b/modules/photoviewer/res/values/config.xml
new file mode 100644
index 0000000..250b687
--- /dev/null
+++ b/modules/photoviewer/res/values/config.xml
@@ -0,0 +1,5 @@
+
+
+
+ true
+
\ No newline at end of file
diff --git a/modules/photoviewer/res/values/constants.xml b/modules/photoviewer/res/values/constants.xml
new file mode 100644
index 0000000..0fefe83
--- /dev/null
+++ b/modules/photoviewer/res/values/constants.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ 5000
+
diff --git a/modules/photoviewer/res/values/dimen.xml b/modules/photoviewer/res/values/dimen.xml
new file mode 100644
index 0000000..754c99b
--- /dev/null
+++ b/modules/photoviewer/res/values/dimen.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ 280dip
+ 1dip
+ 200dip
+ 48dip
+ 32dip
+
diff --git a/modules/photoviewer/res/values/strings.xml b/modules/photoviewer/res/values/strings.xml
new file mode 100644
index 0000000..d60afdb
--- /dev/null
+++ b/modules/photoviewer/res/values/strings.xml
@@ -0,0 +1,26 @@
+
+
+
+
+ %1$d of %2$d
+ Retry
+
+ Couldn\'t load image
+
+ %1$s, %2$s
+
diff --git a/modules/photoviewer/src/com/android/ex/photo/ActionBarInterface.java b/modules/photoviewer/src/com/android/ex/photo/ActionBarInterface.java
new file mode 100644
index 0000000..3c21b56
--- /dev/null
+++ b/modules/photoviewer/src/com/android/ex/photo/ActionBarInterface.java
@@ -0,0 +1,47 @@
+package com.android.ex.photo;
+
+import android.graphics.drawable.Drawable;
+import androidx.annotation.Nullable;
+
+
+/**
+ * Wrapper activity for an action bar. This wraps either a {@link android.app.ActionBar} or
+ * {@link androidx.appcompat.app.ActionBar}.
+ */
+public interface ActionBarInterface {
+
+ public interface OnMenuVisibilityListener {
+ /**
+ * Called when an action bar menu is shown or hidden. Applications may want to use
+ * this to tune auto-hiding behavior for the action bar or pause/resume video playback,
+ * gameplay, or other activity within the main content area.
+ *
+ * @param isVisible True if an action bar menu is now visible, false if no action bar
+ * menus are visible.
+ */
+ public void onMenuVisibilityChanged(boolean isVisible);
+ }
+
+ public void setDisplayHomeAsUpEnabled(boolean showHomeAsUp);
+
+ public void addOnMenuVisibilityListener(OnMenuVisibilityListener listener);
+
+ /**
+ * Wrapper for {@code setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE,
+ * ActionBar.DISPLAY_SHOW_TITLE)}.
+ */
+ public void setDisplayOptionsShowTitle();
+
+ @Nullable
+ public CharSequence getTitle();
+
+ public void setTitle(@Nullable CharSequence title);
+
+ public void setSubtitle(@Nullable CharSequence subtitle);
+
+ public void show();
+
+ public void hide();
+
+ public void setLogo(@Nullable Drawable logo);
+}
diff --git a/modules/photoviewer/src/com/android/ex/photo/Intents.java b/modules/photoviewer/src/com/android/ex/photo/Intents.java
new file mode 100644
index 0000000..61e9382
--- /dev/null
+++ b/modules/photoviewer/src/com/android/ex/photo/Intents.java
@@ -0,0 +1,351 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.ex.photo;
+
+import android.app.Activity;
+import android.content.ContentProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+import com.android.ex.photo.fragments.PhotoViewFragment;
+
+/**
+ * Build intents to start app activities
+ */
+
+public class Intents {
+ // Intent extras
+ public static final String EXTRA_PHOTO_INDEX = "photo_index";
+ public static final String EXTRA_INITIAL_PHOTO_URI = "initial_photo_uri";
+ public static final String EXTRA_PHOTOS_URI = "photos_uri";
+ public static final String EXTRA_RESOLVED_PHOTO_URI = "resolved_photo_uri";
+ public static final String EXTRA_PROJECTION = "projection";
+ public static final String EXTRA_THUMBNAIL_URI = "thumbnail_uri";
+ public static final String EXTRA_CONTENT_DESCRIPTION = "content_description";
+ public static final String EXTRA_MAX_INITIAL_SCALE = "max_scale";
+ public static final String EXTRA_WATCH_NETWORK = "watch_network";
+ public static final String EXTRA_ENABLE_TIMER_LIGHTS_OUT = "enable_timer_lights_out";
+
+
+ // Parameters affecting the intro/exit animation
+ public static final String EXTRA_SCALE_UP_ANIMATION = "scale_up_animation";
+ public static final String EXTRA_ANIMATION_START_X = "start_x_extra";
+ public static final String EXTRA_ANIMATION_START_Y = "start_y_extra";
+ public static final String EXTRA_ANIMATION_START_WIDTH = "start_width_extra";
+ public static final String EXTRA_ANIMATION_START_HEIGHT = "start_height_extra";
+
+ // Parameters affecting the display and features
+ public static final String EXTRA_ACTION_BAR_HIDDEN_INITIALLY = "action_bar_hidden_initially";
+ public static final String EXTRA_DISPLAY_THUMBS_FULLSCREEN = "display_thumbs_fullscreen";
+
+ /**
+ * Gets a photo view intent builder to display the photos from phone activity.
+ *
+ * @param context The context
+ * @return The intent builder
+ */
+ public static PhotoViewIntentBuilder newPhotoViewActivityIntentBuilder(Context context) {
+ return new PhotoViewIntentBuilder(context, PhotoViewActivity.class);
+ }
+
+ /**
+ * Gets a photo view intent builder to display the photo view fragment
+ *
+ * @param context The context
+ * @return The intent builder
+ */
+ public static PhotoViewIntentBuilder newPhotoViewFragmentIntentBuilder(Context context) {
+ return newPhotoViewFragmentIntentBuilder(context, PhotoViewFragment.class);
+ }
+
+ /**
+ * Gets a photo view intent builder to display the photo view fragment with a custom fragment
+ * subclass.
+ *
+ * @param context The context
+ * @param clazz Subclass of PhotoViewFragment to use
+ * @return The intent builder
+ */
+ public static PhotoViewIntentBuilder newPhotoViewFragmentIntentBuilder(Context context,
+ Class extends PhotoViewFragment> clazz) {
+ return new PhotoViewIntentBuilder(context, clazz);
+ }
+
+ /** Gets a new photo view intent builder */
+ public static PhotoViewIntentBuilder newPhotoViewIntentBuilder(
+ Context context, Class extends Activity> cls) {
+ return new PhotoViewIntentBuilder(context, cls);
+ }
+
+ /** Gets a new photo view intent builder */
+ public static PhotoViewIntentBuilder newPhotoViewIntentBuilder(
+ Context context, String activityName) {
+ return new PhotoViewIntentBuilder(context, activityName);
+ }
+
+ /** Builder to create a photo view intent */
+ public static class PhotoViewIntentBuilder {
+ private final Intent mIntent;
+
+ /** The index of the photo to show */
+ private Integer mPhotoIndex;
+ /** The URI of the initial photo to show */
+ private String mInitialPhotoUri;
+ /** The URI of the initial thumbnail to show */
+ private String mInitialThumbnailUri;
+ /** The URI of the group of photos to display */
+ private String mPhotosUri;
+ /** The URL of the photo to display */
+ private String mResolvedPhotoUri;
+ /** The projection for the query to use; optional */
+ private String[] mProjection;
+ /** The URI of a thumbnail of the photo to display */
+ private String mThumbnailUri;
+ /** The content Description for the photo to show */
+ private String mContentDescription;
+ /** The maximum scale to display images at before */
+ private Float mMaxInitialScale;
+ /** True if lights out should automatically be invoked on a timer basis */
+ private boolean mEnableTimerLightsOut;
+ /**
+ * True if the PhotoViewFragments should watch for network changes to restart their loaders
+ */
+ private boolean mWatchNetwork;
+ /** true we want to run the image scale animation */
+ private boolean mScaleAnimation;
+ /** The parameters for performing the scale up/scale down animations
+ * upon enter and exit. StartX and StartY represent the screen coordinates
+ * of the upper left corner of the start rectangle, startWidth and startHeight
+ * represent the width and height of the start rectangle.
+ */
+ private int mStartX;
+ private int mStartY;
+ private int mStartWidth;
+ private int mStartHeight;
+
+ private boolean mActionBarHiddenInitially;
+ private boolean mDisplayFullScreenThumbs;
+
+ private PhotoViewIntentBuilder(Context context, Class> cls) {
+ mIntent = new Intent(context, cls);
+ initialize();
+ }
+
+ private PhotoViewIntentBuilder(Context context, String activityName) {
+ mIntent = new Intent();
+ mIntent.setClassName(context, activityName);
+ initialize();
+ }
+
+ private void initialize() {
+ mScaleAnimation = false;
+ mActionBarHiddenInitially = false;
+ mDisplayFullScreenThumbs = false;
+ mEnableTimerLightsOut = true;
+ }
+
+ /** Sets auto lights out */
+ public PhotoViewIntentBuilder setEnableTimerLightsOut(boolean enable) {
+ mEnableTimerLightsOut = enable;
+ return this;
+ }
+
+ /** Sets the photo index */
+ public PhotoViewIntentBuilder setPhotoIndex(Integer photoIndex) {
+ mPhotoIndex = photoIndex;
+ return this;
+ }
+
+ /** Sets the initial photo URI */
+ public PhotoViewIntentBuilder setInitialPhotoUri(String initialPhotoUri) {
+ mInitialPhotoUri = initialPhotoUri;
+ return this;
+ }
+
+ /** Sets the photos URI */
+ public PhotoViewIntentBuilder setPhotosUri(String photosUri) {
+ mPhotosUri = photosUri;
+ return this;
+ }
+
+ /** Sets the query projection */
+ public PhotoViewIntentBuilder setProjection(String[] projection) {
+ mProjection = projection;
+ return this;
+ }
+
+ /** Sets the resolved photo URI. This method is for the case
+ * where the URI given to {@link PhotoViewActivity} points directly
+ * to a single image and does not need to be resolved via a query
+ * to the {@link ContentProvider}. If this value is set, it supersedes
+ * {@link #setPhotosUri(String)}. */
+ public PhotoViewIntentBuilder setResolvedPhotoUri(String resolvedPhotoUri) {
+ mResolvedPhotoUri = resolvedPhotoUri;
+ return this;
+ }
+
+ /**
+ * Sets the URI for a thumbnail preview of the photo.
+ */
+ public PhotoViewIntentBuilder setThumbnailUri(String thumbnailUri) {
+ mThumbnailUri = thumbnailUri;
+ return this;
+ }
+
+ /**
+ * Sets the content Description for the photo
+ */
+ public PhotoViewIntentBuilder setContentDescription(String contentDescription) {
+ mContentDescription = contentDescription;
+ return this;
+ }
+
+ /**
+ * Sets the maximum scale which an image is initially displayed at
+ */
+ public PhotoViewIntentBuilder setMaxInitialScale(float maxScale) {
+ mMaxInitialScale = maxScale;
+ return this;
+ }
+
+ /**
+ * Enable watching the network for connectivity changes.
+ *
+ * When a change is detected, bitmap loaders will be restarted if required.
+ */
+ public PhotoViewIntentBuilder watchNetworkConnectivityChanges() {
+ mWatchNetwork = true;
+ return this;
+ }
+
+ /**
+ * Enable a scale animation that animates the initial photo URI passed in using
+ * {@link #setInitialPhotoUri}.
+ *
+ * Note: To avoid janky transitions, particularly when exiting the photoviewer, ensure the
+ * following system UI flags are set on the root view of the relying app's activity
+ * (via @{link View.setSystemUiVisibility(int)}):
+ * {@code View.SYSTEM_UI_FLAG_VISIBLE | View.SYSTEM_UI_FLAG_LAYOUT_STABLE}
+ * As well, client should ensure {@code android:fitsSystemWindows} is set on the root
+ * content view.
+ */
+ public PhotoViewIntentBuilder setScaleAnimation(int startX, int startY,
+ int startWidth, int startHeight) {
+ mScaleAnimation = true;
+ mStartX = startX;
+ mStartY = startY;
+ mStartWidth = startWidth;
+ mStartHeight = startHeight;
+ return this;
+ }
+
+ // If this option is turned on, then the photoViewer will be initially
+ // displayed with the action bar hidden. This is as opposed to the default
+ // behavior, where the actionBar is initially shown.
+ public PhotoViewIntentBuilder setActionBarHiddenInitially(
+ boolean actionBarHiddenInitially) {
+ mActionBarHiddenInitially = actionBarHiddenInitially;
+ return this;
+ }
+
+ // If this option is turned on, then the small, lo-res thumbnail will
+ // be scaled up to the maximum size to cover as much of the screen as
+ // possible while still maintaining the correct aspect ratio. This means
+ // that the image may appear blurry until the the full-res image is
+ // loaded.
+ // This is as opposed to the default behavior, where only part of the
+ // thumbnail is displayed in a small view in the center of the screen,
+ // and a loading spinner is displayed until the full-res image is loaded.
+ public PhotoViewIntentBuilder setDisplayThumbsFullScreen(
+ boolean displayFullScreenThumbs) {
+ mDisplayFullScreenThumbs = displayFullScreenThumbs;
+ return this;
+ }
+
+ /** Build the intent */
+ public Intent build() {
+ mIntent.setAction(Intent.ACTION_VIEW);
+
+ // In Lollipop, each list of photos should appear as a document in the "Recents"
+ // list. In earlier versions, this flag was Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET.
+ mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT
+ // FLAG_ACTIVITY_CLEAR_TOP is needed for the case where the app tries to
+ // display a different photo while there is an existing activity instance
+ // for that list of photos. Since the initial photo is specified as an
+ // extra, without FLAG_ACTIVITY_CLEAR_TOP, the activity instance would
+ // just get restarted and it would display whatever photo it was last
+ // displaying. FLAG_ACTIVITY_CLEAR_TOP causes a new instance to be created,
+ // and it will display the new initial photo.
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ if (mPhotoIndex != null) {
+ mIntent.putExtra(EXTRA_PHOTO_INDEX, (int) mPhotoIndex);
+ }
+
+ if (mInitialPhotoUri != null) {
+ mIntent.putExtra(EXTRA_INITIAL_PHOTO_URI, mInitialPhotoUri);
+ }
+
+ if (mInitialPhotoUri != null && mPhotoIndex != null) {
+ throw new IllegalStateException(
+ "specified both photo index and photo uri");
+ }
+
+ if (mPhotosUri != null) {
+ mIntent.putExtra(EXTRA_PHOTOS_URI, mPhotosUri);
+ mIntent.setData(Uri.parse(mPhotosUri));
+ }
+
+ if (mResolvedPhotoUri != null) {
+ mIntent.putExtra(EXTRA_RESOLVED_PHOTO_URI, mResolvedPhotoUri);
+ }
+
+ if (mProjection != null) {
+ mIntent.putExtra(EXTRA_PROJECTION, mProjection);
+ }
+
+ if (mThumbnailUri != null) {
+ mIntent.putExtra(EXTRA_THUMBNAIL_URI, mThumbnailUri);
+ }
+
+ if (mContentDescription != null) {
+ mIntent.putExtra(EXTRA_CONTENT_DESCRIPTION, mContentDescription);
+ }
+
+ if (mMaxInitialScale != null) {
+ mIntent.putExtra(EXTRA_MAX_INITIAL_SCALE, mMaxInitialScale);
+ }
+
+ mIntent.putExtra(EXTRA_WATCH_NETWORK, mWatchNetwork);
+
+ mIntent.putExtra(EXTRA_SCALE_UP_ANIMATION, mScaleAnimation);
+ if (mScaleAnimation) {
+ mIntent.putExtra(EXTRA_ANIMATION_START_X, mStartX);
+ mIntent.putExtra(EXTRA_ANIMATION_START_Y, mStartY);
+ mIntent.putExtra(EXTRA_ANIMATION_START_WIDTH, mStartWidth);
+ mIntent.putExtra(EXTRA_ANIMATION_START_HEIGHT, mStartHeight);
+ }
+
+ mIntent.putExtra(EXTRA_ACTION_BAR_HIDDEN_INITIALLY, mActionBarHiddenInitially);
+ mIntent.putExtra(EXTRA_DISPLAY_THUMBS_FULLSCREEN, mDisplayFullScreenThumbs);
+ mIntent.putExtra(EXTRA_ENABLE_TIMER_LIGHTS_OUT, mEnableTimerLightsOut);
+ return mIntent;
+ }
+ }
+}
diff --git a/modules/photoviewer/src/com/android/ex/photo/PhotoViewCallbacks.java b/modules/photoviewer/src/com/android/ex/photo/PhotoViewCallbacks.java
new file mode 100644
index 0000000..0d2d8d3
--- /dev/null
+++ b/modules/photoviewer/src/com/android/ex/photo/PhotoViewCallbacks.java
@@ -0,0 +1,101 @@
+package com.android.ex.photo;
+
+import android.database.Cursor;
+import android.os.Bundle;
+import androidx.fragment.app.Fragment;
+import androidx.loader.content.Loader;
+
+import com.android.ex.photo.adapters.PhotoPagerAdapter;
+import com.android.ex.photo.fragments.PhotoViewFragment;
+import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult;
+
+public interface PhotoViewCallbacks {
+
+ public static final int BITMAP_LOADER_AVATAR = 1;
+ public static final int BITMAP_LOADER_THUMBNAIL = 2;
+ public static final int BITMAP_LOADER_PHOTO = 3;
+
+ /**
+ * Listener to be invoked for screen events.
+ */
+ public static interface OnScreenListener {
+
+ /**
+ * The full screen state has changed.
+ */
+ public void onFullScreenChanged(boolean fullScreen);
+
+ /**
+ * A new view has been activated and the previous view de-activated.
+ */
+ public void onViewActivated();
+
+ /**
+ * This view is a candidate for being the next view.
+ *
+ * This will be called when the view is focused completely on the view immediately before
+ * or after this one, so that this view can reset itself if nessecary.
+ */
+ public void onViewUpNext();
+
+ /**
+ * Called when a right-to-left touch move intercept is about to occur.
+ *
+ * @param origX the raw x coordinate of the initial touch
+ * @param origY the raw y coordinate of the initial touch
+ * @return {@code true} if the touch should be intercepted.
+ */
+ public boolean onInterceptMoveLeft(float origX, float origY);
+
+ /**
+ * Called when a left-to-right touch move intercept is about to occur.
+ *
+ * @param origX the raw x coordinate of the initial touch
+ * @param origY the raw y coordinate of the initial touch
+ * @return {@code true} if the touch should be intercepted.
+ */
+ public boolean onInterceptMoveRight(float origX, float origY);
+ }
+
+ public static interface CursorChangedListener {
+ /**
+ * Called when the cursor that contains the photo list data
+ * is updated. Note that there is no guarantee that the cursor
+ * will be at the proper position.
+ * @param cursor the cursor containing the photo list data
+ */
+ public void onCursorChanged(Cursor cursor);
+ }
+
+ public void addScreenListener(int position, OnScreenListener listener);
+
+ public void removeScreenListener(int position);
+
+ public void addCursorListener(CursorChangedListener listener);
+
+ public void removeCursorListener(CursorChangedListener listener);
+
+ public void setViewActivated(int position);
+
+ public void onNewPhotoLoaded(int position);
+
+ public void onFragmentPhotoLoadComplete(PhotoViewFragment fragment,
+ boolean success);
+
+ public void toggleFullScreen();
+
+ public boolean isFragmentActive(Fragment fragment);
+
+ public void onFragmentVisible(PhotoViewFragment fragment);
+
+ public boolean isFragmentFullScreen(Fragment fragment);
+
+ public void onCursorChanged(PhotoViewFragment fragment, Cursor cursor);
+
+ public Loader onCreateBitmapLoader(int id, Bundle args, String uri);
+
+ /**
+ * Returns the adapter associated with this activity.
+ */
+ public PhotoPagerAdapter getAdapter();
+}
diff --git a/modules/photoviewer/src/com/android/ex/photo/PhotoViewController.java b/modules/photoviewer/src/com/android/ex/photo/PhotoViewController.java
new file mode 100644
index 0000000..0face96
--- /dev/null
+++ b/modules/photoviewer/src/com/android/ex/photo/PhotoViewController.java
@@ -0,0 +1,1380 @@
+package com.android.ex.photo;
+
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Process;
+import androidx.annotation.IdRes;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.loader.app.LoaderManager;
+import androidx.loader.content.Loader;
+import androidx.viewpager.widget.ViewPager.OnPageChangeListener;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewPropertyAnimator;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.view.WindowManager;
+import android.view.accessibility.AccessibilityManager;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.Animation.AnimationListener;
+import android.view.animation.AnimationSet;
+import android.view.animation.ScaleAnimation;
+import android.view.animation.TranslateAnimation;
+import android.widget.ImageView;
+
+import com.android.ex.photo.ActionBarInterface.OnMenuVisibilityListener;
+import com.android.ex.photo.PhotoViewPager.InterceptType;
+import com.android.ex.photo.PhotoViewPager.OnInterceptTouchListener;
+import com.android.ex.photo.adapters.PhotoPagerAdapter;
+import com.android.ex.photo.fragments.PhotoViewFragment;
+import com.android.ex.photo.loaders.PhotoBitmapLoader;
+import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult;
+import com.android.ex.photo.loaders.PhotoPagerLoader;
+import com.android.ex.photo.provider.PhotoContract;
+import com.android.ex.photo.util.ImageUtils;
+import com.android.ex.photo.util.Util;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * This class implements all the logic of the photo view activity. An activity should use this class
+ * calling through from relevant activity methods to the methods of the same name here.
+ *
+ * To customize the photo viewer activity, you should subclass this and implement your
+ * customizations here. Then subclass {@link PhotoViewActivity} and override just
+ * {@link PhotoViewActivity#createController createController} to instantiate your controller
+ * subclass.
+ */
+public class PhotoViewController implements
+ LoaderManager.LoaderCallbacks, OnPageChangeListener, OnInterceptTouchListener,
+ OnMenuVisibilityListener, PhotoViewCallbacks {
+
+ /**
+ * Defines the interface between the Activity and this class.
+ *
+ * The activity itself must delegate all appropriate method calls into this class, to the
+ * methods of the same name.
+ */
+ public interface ActivityInterface {
+ public Context getContext();
+ public Context getApplicationContext();
+ public Intent getIntent();
+ public void setContentView(int resId);
+ public T findViewById(int id);
+ public Resources getResources();
+ public FragmentManager getSupportFragmentManager();
+ public LoaderManager getSupportLoaderManager();
+ public ActionBarInterface getActionBarInterface();
+ public boolean onOptionsItemSelected(MenuItem item);
+ public void finish();
+ public void overridePendingTransition(int enterAnim, int exitAnim);
+ public PhotoViewController getController();
+ }
+
+ private final static String TAG = "PhotoViewController";
+
+ private final static String STATE_INITIAL_URI_KEY =
+ "com.android.ex.PhotoViewFragment.INITIAL_URI";
+ private final static String STATE_CURRENT_URI_KEY =
+ "com.android.ex.PhotoViewFragment.CURRENT_URI";
+ private final static String STATE_CURRENT_INDEX_KEY =
+ "com.android.ex.PhotoViewFragment.CURRENT_INDEX";
+ private final static String STATE_FULLSCREEN_KEY =
+ "com.android.ex.PhotoViewFragment.FULLSCREEN";
+ private final static String STATE_ACTIONBARTITLE_KEY =
+ "com.android.ex.PhotoViewFragment.ACTIONBARTITLE";
+ private final static String STATE_ACTIONBARSUBTITLE_KEY =
+ "com.android.ex.PhotoViewFragment.ACTIONBARSUBTITLE";
+ private final static String STATE_ENTERANIMATIONFINISHED_KEY =
+ "com.android.ex.PhotoViewFragment.SCALEANIMATIONFINISHED";
+
+ protected final static String ARG_IMAGE_URI = "image_uri";
+
+ public static final int LOADER_PHOTO_LIST = 100;
+
+ /** Count used when the real photo count is unknown [but, may be determined] */
+ public static final int ALBUM_COUNT_UNKNOWN = -1;
+
+ public static final int ENTER_ANIMATION_DURATION_MS = 250;
+ public static final int EXIT_ANIMATION_DURATION_MS = 250;
+
+ /** Argument key for the dialog message */
+ public static final String KEY_MESSAGE = "dialog_message";
+
+ public static int sMemoryClass;
+ public static int sMaxPhotoSize; // The maximum size (either width or height)
+
+ private final ActivityInterface mActivity;
+
+ private int mLastFlags;
+
+ private final View.OnSystemUiVisibilityChangeListener mSystemUiVisibilityChangeListener;
+
+ /** The URI of the photos we're viewing; may be {@code null} */
+ private String mPhotosUri;
+ /** The uri of the initial photo */
+ private String mInitialPhotoUri;
+ /** The index of the currently viewed photo */
+ private int mCurrentPhotoIndex;
+ /** The uri of the currently viewed photo */
+ private String mCurrentPhotoUri;
+ /** The query projection to use; may be {@code null} */
+ private String[] mProjection;
+ /** The total number of photos; only valid if {@link #mIsEmpty} is {@code false}. */
+ protected int mAlbumCount = ALBUM_COUNT_UNKNOWN;
+ /** {@code true} if the view is empty. Otherwise, {@code false}. */
+ protected boolean mIsEmpty;
+ /** the main root view */
+ protected View mRootView;
+ /** Background image that contains nothing, so it can be alpha faded from
+ * transparent to black without affecting any other views. */
+ @Nullable
+ protected View mBackground;
+ /** The main pager; provides left/right swipe between photos */
+ protected PhotoViewPager mViewPager;
+ /** The temporary image so that we can quickly scale up the fullscreen thumbnail */
+ @Nullable
+ protected ImageView mTemporaryImage;
+ /** Adapter to create pager views */
+ protected PhotoPagerAdapter mAdapter;
+ /** Whether or not we're in "full screen" mode */
+ protected boolean mFullScreen;
+ /** The listeners wanting full screen state for each screen position */
+ private final Map
+ mScreenListeners = new HashMap();
+ /** The set of listeners wanting full screen state */
+ private final Set mCursorListeners = new HashSet();
+ /** When {@code true}, restart the loader when the activity becomes active */
+ private boolean mKickLoader;
+ /** Don't attempt operations that may trigger a fragment transaction when the activity is
+ * destroyed */
+ private boolean mIsDestroyedCompat;
+ /** Whether or not this activity is paused */
+ protected boolean mIsPaused = true;
+ /** The maximum scale factor applied to images when they are initially displayed */
+ protected float mMaxInitialScale;
+ /** The title in the actionbar */
+ protected String mActionBarTitle;
+ /** The subtitle in the actionbar */
+ protected String mActionBarSubtitle;
+
+ private boolean mEnterAnimationFinished;
+ protected boolean mScaleAnimationEnabled;
+ protected int mAnimationStartX;
+ protected int mAnimationStartY;
+ protected int mAnimationStartWidth;
+ protected int mAnimationStartHeight;
+
+ /** Whether lights out should invoked based on timer */
+ protected boolean mIsTimerLightsOutEnabled;
+ protected boolean mActionBarHiddenInitially;
+ protected boolean mDisplayThumbsFullScreen;
+
+ private final AccessibilityManager mAccessibilityManager;
+
+ protected BitmapCallback mBitmapCallback;
+ protected final Handler mHandler = new Handler();
+
+ // TODO Find a better way to do this. We basically want the activity to display the
+ // "loading..." progress until the fragment takes over and shows it's own "loading..."
+ // progress [located in photo_header_view.xml]. We could potentially have all status displayed
+ // by the activity, but, that gets tricky when it comes to screen rotation. For now, we
+ // track the loading by this variable which is fragile and may cause phantom "loading..."
+ // text.
+ private long mEnterFullScreenDelayTime;
+
+ private int lastAnnouncedTitle = -1;
+
+ public PhotoViewController(ActivityInterface activity) {
+ mActivity = activity;
+
+ // View.OnSystemUiVisibilityChangeListener is an API that was introduced in API level 11.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) {
+ mSystemUiVisibilityChangeListener = null;
+ } else {
+ mSystemUiVisibilityChangeListener = new View.OnSystemUiVisibilityChangeListener() {
+ @Override
+ public void onSystemUiVisibilityChange(int visibility) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT &&
+ visibility == 0 && mLastFlags == 3846) {
+ setFullScreen(false /* fullscreen */, true /* setDelayedRunnable */);
+ }
+ }
+ };
+ }
+
+ mAccessibilityManager = (AccessibilityManager)
+ activity.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
+ }
+
+ public PhotoPagerAdapter createPhotoPagerAdapter(Context context,
+ androidx.fragment.app.FragmentManager fm, Cursor c, float maxScale) {
+ return new PhotoPagerAdapter(context, fm, c, maxScale, mDisplayThumbsFullScreen);
+ }
+
+ public PhotoViewController.ActivityInterface getActivity() {
+ return mActivity;
+ }
+
+ public void onCreate(Bundle savedInstanceState) {
+ initMaxPhotoSize();
+ final ActivityManager mgr = (ActivityManager) mActivity.getApplicationContext().
+ getSystemService(Activity.ACTIVITY_SERVICE);
+ sMemoryClass = mgr.getMemoryClass();
+
+ final Intent intent = mActivity.getIntent();
+ // uri of the photos to view; optional
+ if (intent.hasExtra(Intents.EXTRA_PHOTOS_URI)) {
+ mPhotosUri = intent.getStringExtra(Intents.EXTRA_PHOTOS_URI);
+ }
+
+ mIsTimerLightsOutEnabled = intent.getBooleanExtra(
+ Intents.EXTRA_ENABLE_TIMER_LIGHTS_OUT, true);
+
+ if (intent.getBooleanExtra(Intents.EXTRA_SCALE_UP_ANIMATION, false)) {
+ mScaleAnimationEnabled = true;
+ mAnimationStartX = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_X, 0);
+ mAnimationStartY = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_Y, 0);
+ mAnimationStartWidth = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_WIDTH, 0);
+ mAnimationStartHeight = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_HEIGHT, 0);
+ }
+ mActionBarHiddenInitially = intent.getBooleanExtra(
+ Intents.EXTRA_ACTION_BAR_HIDDEN_INITIALLY, false)
+ && !Util.isTouchExplorationEnabled(mAccessibilityManager);
+ mDisplayThumbsFullScreen = intent.getBooleanExtra(
+ Intents.EXTRA_DISPLAY_THUMBS_FULLSCREEN, false);
+
+ // projection for the query; optional
+ // If not set, the default projection is used.
+ // This projection must include the columns from the default projection.
+ if (intent.hasExtra(Intents.EXTRA_PROJECTION)) {
+ mProjection = intent.getStringArrayExtra(Intents.EXTRA_PROJECTION);
+ } else {
+ mProjection = null;
+ }
+
+ // Set the max initial scale, defaulting to 1x
+ mMaxInitialScale = intent.getFloatExtra(Intents.EXTRA_MAX_INITIAL_SCALE, 1.0f);
+ mCurrentPhotoUri = null;
+ mCurrentPhotoIndex = -1;
+
+ // We allow specifying the current photo by either index or uri.
+ // This is because some users may have live datasets that can change,
+ // adding new items to either the beginning or end of the set. For clients
+ // that do not need that capability, ability to specify the current photo
+ // by index is offered as a convenience.
+ if (intent.hasExtra(Intents.EXTRA_PHOTO_INDEX)) {
+ mCurrentPhotoIndex = intent.getIntExtra(Intents.EXTRA_PHOTO_INDEX, -1);
+ }
+ if (intent.hasExtra(Intents.EXTRA_INITIAL_PHOTO_URI)) {
+ mInitialPhotoUri = intent.getStringExtra(Intents.EXTRA_INITIAL_PHOTO_URI);
+ mCurrentPhotoUri = mInitialPhotoUri;
+ }
+ mIsEmpty = true;
+
+ if (savedInstanceState != null) {
+ mInitialPhotoUri = savedInstanceState.getString(STATE_INITIAL_URI_KEY);
+ mCurrentPhotoUri = savedInstanceState.getString(STATE_CURRENT_URI_KEY);
+ mCurrentPhotoIndex = savedInstanceState.getInt(STATE_CURRENT_INDEX_KEY);
+ mFullScreen = savedInstanceState.getBoolean(STATE_FULLSCREEN_KEY, false)
+ && !Util.isTouchExplorationEnabled(mAccessibilityManager);
+ mActionBarTitle = savedInstanceState.getString(STATE_ACTIONBARTITLE_KEY);
+ mActionBarSubtitle = savedInstanceState.getString(STATE_ACTIONBARSUBTITLE_KEY);
+ mEnterAnimationFinished = savedInstanceState.getBoolean(
+ STATE_ENTERANIMATIONFINISHED_KEY, false);
+ } else {
+ mFullScreen = mActionBarHiddenInitially;
+ }
+
+ mActivity.setContentView(getContentViewId());
+
+ // Create the adapter and add the view pager
+ mAdapter = createPhotoPagerAdapter(mActivity.getContext(),
+ mActivity.getSupportFragmentManager(), null, mMaxInitialScale);
+ final Resources resources = mActivity.getResources();
+ mRootView = findViewById(getRootViewId());
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
+ mRootView.setOnSystemUiVisibilityChangeListener(getSystemUiVisibilityChangeListener());
+ }
+ mBackground = getBackground();
+ mTemporaryImage = getTemporaryImage();
+ mViewPager = (PhotoViewPager) findViewById(R.id.photo_view_pager);
+ mViewPager.setAdapter(mAdapter);
+ mViewPager.setOnPageChangeListener(this);
+ mViewPager.setOnInterceptTouchListener(this);
+ mViewPager.setPageMargin(resources.getDimensionPixelSize(R.dimen.photo_page_margin));
+
+ mBitmapCallback = new BitmapCallback();
+ if (!mScaleAnimationEnabled || mEnterAnimationFinished) {
+ // We are not running the scale up animation. Just let the fragments
+ // display and handle the animation.
+ mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this);
+ // Make the background opaque immediately so that we don't see the activity
+ // behind this one.
+ if (hasBackground()) {
+ mBackground.setVisibility(View.VISIBLE);
+ }
+ } else {
+ // Attempt to load the initial image thumbnail. Once we have the
+ // image, animate it up. Once the animation is complete, we can kick off
+ // loading the ViewPager. After the primary fullres image is loaded, we will
+ // make our temporary image invisible and display the ViewPager.
+ mViewPager.setVisibility(View.GONE);
+ Bundle args = new Bundle();
+ args.putString(ARG_IMAGE_URI, mInitialPhotoUri);
+ mActivity.getSupportLoaderManager().initLoader(
+ BITMAP_LOADER_THUMBNAIL, args, mBitmapCallback);
+ }
+
+ mEnterFullScreenDelayTime =
+ resources.getInteger(R.integer.reenter_fullscreen_delay_time_in_millis);
+
+ final ActionBarInterface actionBar = mActivity.getActionBarInterface();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.addOnMenuVisibilityListener(this);
+ actionBar.setDisplayOptionsShowTitle();
+ // Set the title and subtitle immediately here, rather than waiting
+ // for the fragment to be initialized.
+ setActionBarTitles(actionBar);
+ }
+
+ if (!mScaleAnimationEnabled) {
+ setLightsOutMode(mFullScreen);
+ } else {
+ // Keep lights out mode as false. This is to prevent jank cause by concurrent
+ // animations during the enter animation.
+ setLightsOutMode(false);
+ }
+ }
+
+ private void initMaxPhotoSize() {
+ if (sMaxPhotoSize == 0) {
+ final DisplayMetrics metrics = new DisplayMetrics();
+ final WindowManager wm = (WindowManager)
+ mActivity.getContext().getSystemService(Context.WINDOW_SERVICE);
+ final ImageUtils.ImageSize imageSize = ImageUtils.sUseImageSize;
+ wm.getDefaultDisplay().getMetrics(metrics);
+ switch (imageSize) {
+ case EXTRA_SMALL:
+ // Use a photo that's 80% of the "small" size
+ sMaxPhotoSize = (Math.min(metrics.heightPixels, metrics.widthPixels) * 800) / 1000;
+ break;
+ case SMALL:
+ // Fall through.
+ case NORMAL:
+ // Fall through.
+ default:
+ sMaxPhotoSize = Math.min(metrics.heightPixels, metrics.widthPixels);
+ break;
+ }
+ }
+ }
+
+ public boolean onCreateOptionsMenu(Menu menu) {
+ return true;
+ }
+
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ return true;
+ }
+
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {}
+
+ protected View findViewById(int id) {
+ return mActivity.findViewById(id);
+ }
+
+ /**
+ * Returns the android id of the viewer's root view. Subclasses should override this method if
+ * they provide their own layout.
+ */
+ @IdRes
+ protected int getRootViewId() {
+ return R.id.photo_activity_root_view;
+ }
+
+ /**
+ * Returns the android layout id of the root layout that should be inflated for the viewer.
+ * Subclasses should override this method if they provide their own layout.
+ */
+ @IdRes
+ protected int getContentViewId() {
+ return R.layout.photo_activity_view;
+ }
+
+ /**
+ * Returns the android view for the viewer's background view, if it has one. Subclasses should
+ * override this if they have a different (or no) background view.
+ */
+ @Nullable
+ protected View getBackground() {
+ return findViewById(R.id.photo_activity_background);
+ }
+
+ /**
+ * Returns whether or not the view has a background object. Subclasses should override this if
+ * they do not contain a background object.
+ */
+ protected boolean hasBackground() {
+ return mBackground != null;
+ }
+
+ /**
+ * Returns the android view for the viewer's temporary image, if it has one. Subclasses should
+ * override this if they have a different (or no) temporary image view.
+ */
+ @Nullable
+ protected ImageView getTemporaryImage() {
+ return (ImageView) findViewById(R.id.photo_activity_temporary_image);
+ }
+
+ /**
+ * Returns whether or not the view has a temporary image view. Subclasses should override this
+ * if they do not use a temporary image.
+ */
+ protected boolean hasTemporaryImage() {
+ return mTemporaryImage != null;
+ }
+
+ public void onStart() {}
+
+ public void onResume() {
+ setFullScreen(mFullScreen, false);
+
+ mIsPaused = false;
+ if (mKickLoader) {
+ mKickLoader = false;
+ mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this);
+ }
+ }
+
+ public void onPause() {
+ mIsPaused = true;
+ }
+
+ public void onStop() {}
+
+ public void onDestroy() {
+ mIsDestroyedCompat = true;
+ }
+
+ private boolean isDestroyedCompat() {
+ return mIsDestroyedCompat;
+ }
+
+ public boolean onBackPressed() {
+ // If we are in fullscreen mode, and the default is not full screen, then
+ // switch back to actionBar display mode.
+ if (mFullScreen && !mActionBarHiddenInitially) {
+ toggleFullScreen();
+ } else {
+ if (mScaleAnimationEnabled) {
+ runExitAnimation();
+ } else {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public void onSaveInstanceState(Bundle outState) {
+ outState.putString(STATE_INITIAL_URI_KEY, mInitialPhotoUri);
+ outState.putString(STATE_CURRENT_URI_KEY, mCurrentPhotoUri);
+ outState.putInt(STATE_CURRENT_INDEX_KEY, mCurrentPhotoIndex);
+ outState.putBoolean(STATE_FULLSCREEN_KEY, mFullScreen);
+ outState.putString(STATE_ACTIONBARTITLE_KEY, mActionBarTitle);
+ outState.putString(STATE_ACTIONBARSUBTITLE_KEY, mActionBarSubtitle);
+ outState.putBoolean(STATE_ENTERANIMATIONFINISHED_KEY, mEnterAnimationFinished);
+ }
+
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ mActivity.finish();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public void addScreenListener(int position, OnScreenListener listener) {
+ mScreenListeners.put(position, listener);
+ }
+
+ @Override
+ public void removeScreenListener(int position) {
+ mScreenListeners.remove(position);
+ }
+
+ @Override
+ public synchronized void addCursorListener(CursorChangedListener listener) {
+ mCursorListeners.add(listener);
+ }
+
+ @Override
+ public synchronized void removeCursorListener(CursorChangedListener listener) {
+ mCursorListeners.remove(listener);
+ }
+
+ @Override
+ public boolean isFragmentFullScreen(Fragment fragment) {
+ if (mViewPager == null || mAdapter == null || mAdapter.getCount() == 0) {
+ return mFullScreen;
+ }
+ return mFullScreen || (mViewPager.getCurrentItem() != mAdapter.getItemPosition(fragment));
+ }
+
+ @Override
+ public void toggleFullScreen() {
+ setFullScreen(!mFullScreen, true);
+ }
+
+ public void onPhotoRemoved(long photoId) {
+ final Cursor data = mAdapter.getCursor();
+ if (data == null) {
+ // Huh?! How would this happen?
+ return;
+ }
+
+ final int dataCount = data.getCount();
+ if (dataCount <= 1) {
+ mActivity.finish();
+ return;
+ }
+
+ mActivity.getSupportLoaderManager().restartLoader(LOADER_PHOTO_LIST, null, this);
+ }
+
+ @Override
+ public Loader onCreateLoader(int id, Bundle args) {
+ if (id == LOADER_PHOTO_LIST) {
+ return new PhotoPagerLoader(mActivity.getContext(), Uri.parse(mPhotosUri), mProjection);
+ }
+ return null;
+ }
+
+ @Override
+ public Loader onCreateBitmapLoader(int id, Bundle args, String uri) {
+ switch (id) {
+ case BITMAP_LOADER_AVATAR:
+ case BITMAP_LOADER_THUMBNAIL:
+ case BITMAP_LOADER_PHOTO:
+ return new PhotoBitmapLoader(mActivity.getContext(), uri);
+ default:
+ return null;
+ }
+ }
+
+ @Override
+ public void onLoadFinished(Loader loader, Cursor data) {
+ final int id = loader.getId();
+ if (id == LOADER_PHOTO_LIST) {
+ if (data == null || data.getCount() == 0) {
+ mIsEmpty = true;
+ mAdapter.swapCursor(null);
+ } else {
+ mAlbumCount = data.getCount();
+ if (mCurrentPhotoUri != null) {
+ int index = 0;
+ // Clear query params. Compare only the path.
+ final int uriIndex = data.getColumnIndex(PhotoContract.PhotoViewColumns.URI);
+ final Uri currentPhotoUri;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
+ currentPhotoUri = Uri.parse(mCurrentPhotoUri).buildUpon()
+ .clearQuery().build();
+ } else {
+ currentPhotoUri = Uri.parse(mCurrentPhotoUri).buildUpon()
+ .query(null).build();
+ }
+ // Rewind data cursor to the start if it has already advanced.
+ data.moveToPosition(-1);
+ while (data.moveToNext()) {
+ final String uriString = data.getString(uriIndex);
+ final Uri uri;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
+ uri = Uri.parse(uriString).buildUpon().clearQuery().build();
+ } else {
+ uri = Uri.parse(uriString).buildUpon().query(null).build();
+ }
+ if (currentPhotoUri != null && currentPhotoUri.equals(uri)) {
+ mCurrentPhotoIndex = index;
+ break;
+ }
+ index++;
+ }
+ }
+
+ // We're paused; don't do anything now, we'll get re-invoked
+ // when the activity becomes active again
+ if (mIsPaused) {
+ mKickLoader = true;
+ mAdapter.swapCursor(null);
+ return;
+ }
+ boolean wasEmpty = mIsEmpty;
+ mIsEmpty = false;
+
+ mAdapter.swapCursor(data);
+ if (mViewPager.getAdapter() == null) {
+ mViewPager.setAdapter(mAdapter);
+ }
+ notifyCursorListeners(data);
+
+ // Use an index of 0 if the index wasn't specified or couldn't be found
+ if (mCurrentPhotoIndex < 0) {
+ mCurrentPhotoIndex = 0;
+ }
+
+ mViewPager.setCurrentItem(mCurrentPhotoIndex, false);
+ if (wasEmpty) {
+ setViewActivated(mCurrentPhotoIndex);
+ }
+ }
+ // Update the any action items
+ updateActionItems();
+ }
+ }
+
+ @Override
+ public void onLoaderReset(androidx.loader.content.Loader loader) {
+ // If the loader is reset, remove the reference in the adapter to this cursor
+ if (!isDestroyedCompat()) {
+ // This will cause a fragment transaction which can't happen if we're destroyed,
+ // but we don't care in that case because we're destroyed anyways.
+ mAdapter.swapCursor(null);
+ }
+ }
+
+ public void updateActionItems() {
+ // Do nothing, but allow extending classes to do work
+ }
+
+ private synchronized void notifyCursorListeners(Cursor data) {
+ // tell all of the objects listening for cursor changes
+ // that the cursor has changed
+ for (CursorChangedListener listener : mCursorListeners) {
+ listener.onCursorChanged(data);
+ }
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ if (positionOffset < 0.0001) {
+ OnScreenListener before = mScreenListeners.get(position - 1);
+ if (before != null) {
+ before.onViewUpNext();
+ }
+ OnScreenListener after = mScreenListeners.get(position + 1);
+ if (after != null) {
+ after.onViewUpNext();
+ }
+ }
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ mCurrentPhotoIndex = position;
+ setViewActivated(position);
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ }
+
+ @Override
+ public boolean isFragmentActive(Fragment fragment) {
+ if (mViewPager == null || mAdapter == null) {
+ return false;
+ }
+ return mViewPager.getCurrentItem() == mAdapter.getItemPosition(fragment);
+ }
+
+ @Override
+ public void onFragmentVisible(PhotoViewFragment fragment) {
+ // Do nothing, we handle this in setViewActivated
+ }
+
+ @Override
+ public InterceptType onTouchIntercept(float origX, float origY) {
+ boolean interceptLeft = false;
+ boolean interceptRight = false;
+
+ for (OnScreenListener listener : mScreenListeners.values()) {
+ if (!interceptLeft) {
+ interceptLeft = listener.onInterceptMoveLeft(origX, origY);
+ }
+ if (!interceptRight) {
+ interceptRight = listener.onInterceptMoveRight(origX, origY);
+ }
+ }
+
+ if (interceptLeft) {
+ if (interceptRight) {
+ return InterceptType.BOTH;
+ }
+ return InterceptType.LEFT;
+ } else if (interceptRight) {
+ return InterceptType.RIGHT;
+ }
+ return InterceptType.NONE;
+ }
+
+ /**
+ * Updates the title bar according to the value of {@link #mFullScreen}.
+ */
+ protected void setFullScreen(boolean fullScreen, boolean setDelayedRunnable) {
+ if (Util.isTouchExplorationEnabled(mAccessibilityManager)) {
+ // Disallow full screen mode when accessibility is enabled so that the action bar
+ // stays accessible.
+ fullScreen = false;
+ setDelayedRunnable = false;
+ }
+
+ final boolean fullScreenChanged = (fullScreen != mFullScreen);
+ mFullScreen = fullScreen;
+
+ if (mFullScreen) {
+ setLightsOutMode(true);
+ cancelEnterFullScreenRunnable();
+ } else {
+ setLightsOutMode(false);
+ if (setDelayedRunnable) {
+ postEnterFullScreenRunnableWithDelay();
+ }
+ }
+
+ if (fullScreenChanged) {
+ for (OnScreenListener listener : mScreenListeners.values()) {
+ listener.onFullScreenChanged(mFullScreen);
+ }
+ }
+ }
+
+ /**
+ * Posts a runnable to enter full screen after mEnterFullScreenDelayTime. This method is a
+ * no-op if mIsTimerLightsOutEnabled is set to false.
+ */
+ private void postEnterFullScreenRunnableWithDelay() {
+ if (mIsTimerLightsOutEnabled) {
+ mHandler.postDelayed(mEnterFullScreenRunnable, mEnterFullScreenDelayTime);
+ }
+ }
+
+ private void cancelEnterFullScreenRunnable() {
+ mHandler.removeCallbacks(mEnterFullScreenRunnable);
+ }
+
+ protected void setLightsOutMode(boolean enabled) {
+ setImmersiveMode(enabled);
+ }
+
+ private final Runnable mEnterFullScreenRunnable = new Runnable() {
+ @Override
+ public void run() {
+ setFullScreen(true, true);
+ }
+ };
+
+ @Override
+ public void setViewActivated(int position) {
+ OnScreenListener listener = mScreenListeners.get(position);
+ if (listener != null) {
+ listener.onViewActivated();
+ }
+ final Cursor cursor = getCursorAtProperPosition();
+ mCurrentPhotoIndex = position;
+ // FLAG: get the column indexes once in onLoadFinished().
+ // That would make this more efficient, instead of looking these up
+ // repeatedly whenever we want them.
+ int uriIndex = cursor.getColumnIndex(PhotoContract.PhotoViewColumns.URI);
+ mCurrentPhotoUri = cursor.getString(uriIndex);
+ updateActionBar();
+ if (mAccessibilityManager.isEnabled() && lastAnnouncedTitle != position) {
+ String announcement = getPhotoAccessibilityAnnouncement(position);
+ if (announcement != null) {
+ Util.announceForAccessibility(mRootView, mAccessibilityManager, announcement);
+ lastAnnouncedTitle = position;
+ }
+ }
+
+ // Restart the timer to return to fullscreen.
+ cancelEnterFullScreenRunnable();
+ postEnterFullScreenRunnableWithDelay();
+ }
+
+ /**
+ * Adjusts the activity title and subtitle to reflect the photo name and count.
+ */
+ public void updateActionBar() {
+ final int position = mViewPager.getCurrentItem() + 1;
+ final boolean hasAlbumCount = mAlbumCount >= 0;
+
+ final Cursor cursor = getCursorAtProperPosition();
+ if (cursor != null) {
+ // FLAG: We should grab the indexes when we first get the cursor
+ // and store them so we don't need to do it each time.
+ final int photoNameIndex = cursor.getColumnIndex(PhotoContract.PhotoViewColumns.NAME);
+ mActionBarTitle = cursor.getString(photoNameIndex);
+ } else {
+ mActionBarTitle = null;
+ }
+
+ if (mIsEmpty || !hasAlbumCount || position <= 0) {
+ mActionBarSubtitle = null;
+ } else {
+ mActionBarSubtitle = mActivity.getResources().getString(
+ R.string.photo_view_count, position, mAlbumCount);
+ }
+
+ setActionBarTitles(mActivity.getActionBarInterface());
+ }
+
+ /**
+ * Returns a string used as an announcement for accessibility after the user moves to a new
+ * photo. It will be called after {@link #updateActionBar} has been called.
+ * @param position the index in the album of the currently active photo
+ * @return announcement for accessibility
+ */
+ protected String getPhotoAccessibilityAnnouncement(int position) {
+ String announcement = mActionBarTitle;
+ if (mActionBarSubtitle != null) {
+ announcement = mActivity.getContext().getResources().getString(
+ R.string.titles, mActionBarTitle, mActionBarSubtitle);
+ }
+ return announcement;
+ }
+
+ /**
+ * Sets the Action Bar title to {@link #mActionBarTitle} and the subtitle to
+ * {@link #mActionBarSubtitle}
+ */
+ protected final void setActionBarTitles(ActionBarInterface actionBar) {
+ if (actionBar == null) {
+ return;
+ }
+ actionBar.setTitle(getInputOrEmpty(mActionBarTitle));
+ actionBar.setSubtitle(getInputOrEmpty(mActionBarSubtitle));
+ }
+
+ /**
+ * If the input string is non-null, it is returned, otherwise an empty string is returned;
+ * @param in
+ * @return
+ */
+ private static final String getInputOrEmpty(String in) {
+ if (in == null) {
+ return "";
+ }
+ return in;
+ }
+
+ /**
+ * Utility method that will return the cursor that contains the data
+ * at the current position so that it refers to the current image on screen.
+ * @return the cursor at the current position or
+ * null if no cursor exists or if the {@link PhotoViewPager} is null.
+ */
+ public Cursor getCursorAtProperPosition() {
+ if (mViewPager == null) {
+ return null;
+ }
+
+ final int position = mViewPager.getCurrentItem();
+ final Cursor cursor = mAdapter.getCursor();
+
+ if (cursor == null) {
+ return null;
+ }
+
+ cursor.moveToPosition(position);
+
+ return cursor;
+ }
+
+ public Cursor getCursor() {
+ return (mAdapter == null) ? null : mAdapter.getCursor();
+ }
+
+ @Override
+ public void onMenuVisibilityChanged(boolean isVisible) {
+ if (isVisible) {
+ cancelEnterFullScreenRunnable();
+ } else {
+ postEnterFullScreenRunnableWithDelay();
+ }
+ }
+
+ @Override
+ public void onNewPhotoLoaded(int position) {
+ // do nothing
+ }
+
+ protected void setPhotoIndex(int index) {
+ mCurrentPhotoIndex = index;
+ }
+
+ @Override
+ public void onFragmentPhotoLoadComplete(PhotoViewFragment fragment, boolean success) {
+ if (hasTemporaryImage() && mTemporaryImage.getVisibility() != View.GONE &&
+ TextUtils.equals(fragment.getPhotoUri(), mCurrentPhotoUri)) {
+ if (success) {
+ // The fragment for the current image is now ready for display.
+ if (hasTemporaryImage()) {
+ mTemporaryImage.setVisibility(View.GONE);
+ }
+ mViewPager.setVisibility(View.VISIBLE);
+ } else {
+ // This means that we are unable to load the fragment's photo.
+ // I'm not sure what the best thing to do here is, but at least if
+ // we display the viewPager, the fragment itself can decide how to
+ // display the failure of its own image.
+ Log.w(TAG, "Failed to load fragment image");
+ if (hasTemporaryImage()) {
+ mTemporaryImage.setVisibility(View.GONE);
+ }
+ mViewPager.setVisibility(View.VISIBLE);
+ }
+ mActivity.getSupportLoaderManager().destroyLoader(
+ PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL);
+ }
+ }
+
+ protected boolean isFullScreen() {
+ return mFullScreen;
+ }
+
+ @Override
+ public void onCursorChanged(PhotoViewFragment fragment, Cursor cursor) {
+ // do nothing
+ }
+
+ @Override
+ public PhotoPagerAdapter getAdapter() {
+ return mAdapter;
+ }
+
+ public void onEnterAnimationComplete() {
+ mEnterAnimationFinished = true;
+ mViewPager.setVisibility(View.VISIBLE);
+ setLightsOutMode(mFullScreen);
+ }
+
+ private void onExitAnimationComplete() {
+ mActivity.finish();
+ mActivity.overridePendingTransition(0, 0);
+ }
+
+ private void runEnterAnimation() {
+ final int totalWidth = mRootView.getMeasuredWidth();
+ final int totalHeight = mRootView.getMeasuredHeight();
+
+ // FLAG: Need to handle the aspect ratio of the bitmap. If it's a portrait
+ // bitmap, then we need to position the view higher so that the middle
+ // pixels line up.
+ if (hasTemporaryImage()) {
+ mTemporaryImage.setVisibility(View.VISIBLE);
+ }
+ // We need to take a full screen image, and scale/translate it so that
+ // it appears at exactly the same location onscreen as it is in the
+ // prior activity.
+ // The final image will take either the full screen width or height (or both).
+
+ final float scaleW = (float) mAnimationStartWidth / totalWidth;
+ final float scaleY = (float) mAnimationStartHeight / totalHeight;
+ final float scale = Math.max(scaleW, scaleY);
+
+ final int translateX = calculateTranslate(mAnimationStartX, mAnimationStartWidth,
+ totalWidth, scale);
+ final int translateY = calculateTranslate(mAnimationStartY, mAnimationStartHeight,
+ totalHeight, scale);
+
+ final int version = android.os.Build.VERSION.SDK_INT;
+ if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ if (hasBackground()) {
+ mBackground.setAlpha(0f);
+ mBackground.animate().alpha(1f).setDuration(ENTER_ANIMATION_DURATION_MS).start();
+ mBackground.setVisibility(View.VISIBLE);
+ }
+
+ if (hasTemporaryImage()) {
+ mTemporaryImage.setScaleX(scale);
+ mTemporaryImage.setScaleY(scale);
+ mTemporaryImage.setTranslationX(translateX);
+ mTemporaryImage.setTranslationY(translateY);
+
+ Runnable endRunnable = new Runnable() {
+ @Override
+ public void run() {
+ PhotoViewController.this.onEnterAnimationComplete();
+ }
+ };
+ ViewPropertyAnimator animator = mTemporaryImage.animate().scaleX(1f).scaleY(1f)
+ .translationX(0).translationY(0).setDuration(ENTER_ANIMATION_DURATION_MS);
+ if (version >= Build.VERSION_CODES.JELLY_BEAN) {
+ animator.withEndAction(endRunnable);
+ } else {
+ mHandler.postDelayed(endRunnable, ENTER_ANIMATION_DURATION_MS);
+ }
+ animator.start();
+ }
+ } else {
+ if (hasBackground()) {
+ final Animation alphaAnimation = new AlphaAnimation(0f, 1f);
+ alphaAnimation.setDuration(ENTER_ANIMATION_DURATION_MS);
+ mBackground.startAnimation(alphaAnimation);
+ mBackground.setVisibility(View.VISIBLE);
+ }
+
+ if (hasTemporaryImage()) {
+ final Animation translateAnimation = new TranslateAnimation(translateX,
+ translateY, 0, 0);
+ translateAnimation.setDuration(ENTER_ANIMATION_DURATION_MS);
+ Animation scaleAnimation = new ScaleAnimation(scale, scale, 0, 0);
+ scaleAnimation.setDuration(ENTER_ANIMATION_DURATION_MS);
+
+ AnimationSet animationSet = new AnimationSet(true);
+ animationSet.addAnimation(translateAnimation);
+ animationSet.addAnimation(scaleAnimation);
+ AnimationListener listener = new AnimationListener() {
+ @Override
+ public void onAnimationEnd(Animation arg0) {
+ PhotoViewController.this.onEnterAnimationComplete();
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation arg0) {
+ }
+
+ @Override
+ public void onAnimationStart(Animation arg0) {
+ }
+ };
+ animationSet.setAnimationListener(listener);
+ mTemporaryImage.startAnimation(animationSet);
+ }
+ }
+ }
+
+ private void runExitAnimation() {
+ Intent intent = mActivity.getIntent();
+ // FLAG: should just fall back to a standard animation if either:
+ // 1. images have been added or removed since we've been here, or
+ // 2. we are currently looking at some image other than the one we
+ // started on.
+
+ final int totalWidth = mRootView.getMeasuredWidth();
+ final int totalHeight = mRootView.getMeasuredHeight();
+
+ // We need to take a full screen image, and scale/translate it so that
+ // it appears at exactly the same location onscreen as it is in the
+ // prior activity.
+ // The final image will take either the full screen width or height (or both).
+ final float scaleW = (float) mAnimationStartWidth / totalWidth;
+ final float scaleY = (float) mAnimationStartHeight / totalHeight;
+ final float scale = Math.max(scaleW, scaleY);
+
+ final int translateX = calculateTranslate(mAnimationStartX, mAnimationStartWidth,
+ totalWidth, scale);
+ final int translateY = calculateTranslate(mAnimationStartY, mAnimationStartHeight,
+ totalHeight, scale);
+ final int version = android.os.Build.VERSION.SDK_INT;
+ if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ if (hasBackground()) {
+ mBackground.animate().alpha(0f).setDuration(EXIT_ANIMATION_DURATION_MS).start();
+ mBackground.setVisibility(View.VISIBLE);
+ }
+
+ Runnable endRunnable = new Runnable() {
+ @Override
+ public void run() {
+ PhotoViewController.this.onExitAnimationComplete();
+ }
+ };
+ // If the temporary image is still visible it means that we have
+ // not yet loaded the fullres image, so we need to animate
+ // the temporary image out.
+ ViewPropertyAnimator animator = null;
+ if (hasTemporaryImage() && mTemporaryImage.getVisibility() == View.VISIBLE) {
+ animator = mTemporaryImage.animate().scaleX(scale).scaleY(scale)
+ .translationX(translateX).translationY(translateY)
+ .setDuration(EXIT_ANIMATION_DURATION_MS);
+ } else {
+ animator = mViewPager.animate().scaleX(scale).scaleY(scale)
+ .translationX(translateX).translationY(translateY)
+ .setDuration(EXIT_ANIMATION_DURATION_MS);
+ }
+ // If the user has swiped to a different photo, fade out the current photo
+ // along with the scale animation.
+ if (!mInitialPhotoUri.equals(mCurrentPhotoUri)) {
+ animator.alpha(0f);
+ }
+ if (version >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
+ animator.withEndAction(endRunnable);
+ } else {
+ mHandler.postDelayed(endRunnable, EXIT_ANIMATION_DURATION_MS);
+ }
+ animator.start();
+ } else {
+ if (hasBackground()) {
+ final Animation alphaAnimation = new AlphaAnimation(1f, 0f);
+ alphaAnimation.setDuration(EXIT_ANIMATION_DURATION_MS);
+ mBackground.startAnimation(alphaAnimation);
+ mBackground.setVisibility(View.VISIBLE);
+ }
+
+ final Animation scaleAnimation = new ScaleAnimation(1f, 1f, scale, scale);
+ scaleAnimation.setDuration(EXIT_ANIMATION_DURATION_MS);
+ AnimationListener listener = new AnimationListener() {
+ @Override
+ public void onAnimationEnd(Animation arg0) {
+ PhotoViewController.this.onExitAnimationComplete();
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation arg0) {
+ }
+
+ @Override
+ public void onAnimationStart(Animation arg0) {
+ }
+ };
+ scaleAnimation.setAnimationListener(listener);
+ // If the temporary image is still visible it means that we have
+ // not yet loaded the fullres image, so we need to animate
+ // the temporary image out.
+ if (hasTemporaryImage() && mTemporaryImage.getVisibility() == View.VISIBLE) {
+ mTemporaryImage.startAnimation(scaleAnimation);
+ } else {
+ mViewPager.startAnimation(scaleAnimation);
+ }
+ }
+ }
+
+ private int calculateTranslate(int start, int startSize, int totalSize, float scale) {
+ // Translation takes precedence over scale. What this means is that if
+ // we want an view's upper left corner to be a particular spot on screen,
+ // but that view is scaled to something other than 1, we need to take into
+ // account the pixels lost to scaling.
+ // So if we have a view that is 200x300, and we want it's upper left corner
+ // to be at 50x50, but it's scaled by 50%, we can't just translate it to 50x50.
+ // If we were to do that, the view's *visible* upper left corner would be at
+ // 100x200. We need to take into account the difference between the outside
+ // size of the view (i.e. the size prior to scaling) and the scaled size.
+ // scaleFromEdge is the difference between the visible left edge and the
+ // actual left edge, due to scaling.
+ // scaleFromTop is the difference between the visible top edge, and the
+ // actual top edge, due to scaling.
+ int scaleFromEdge = Math.round((totalSize - totalSize * scale) / 2);
+
+ // The imageView is fullscreen, regardless of the aspect ratio of the actual image.
+ // This means that some portion of the imageView will be blank. We need to
+ // take into account the size of the blank area so that the actual image
+ // lines up with the starting image.
+ int blankSize = Math.round((totalSize * scale - startSize) / 2);
+
+ return start - scaleFromEdge - blankSize;
+ }
+
+ private void initTemporaryImage(Drawable drawable) {
+ if (mEnterAnimationFinished) {
+ // Forget this, we've already run the animation.
+ return;
+ }
+ if (hasTemporaryImage()) {
+ mTemporaryImage.setImageDrawable(drawable);
+ }
+ if (drawable != null) {
+ // We have not yet run the enter animation. Start it now.
+ int totalWidth = mRootView.getMeasuredWidth();
+ if (totalWidth == 0) {
+ // the measure pass has not yet finished. We can't properly
+ // run out animation until that is done. Listen for the layout
+ // to occur, then fire the animation.
+ final View base = mRootView;
+ base.getViewTreeObserver().addOnGlobalLayoutListener(
+ new OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ int version = android.os.Build.VERSION.SDK_INT;
+ if (version >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
+ base.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ } else {
+ base.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+ }
+ runEnterAnimation();
+ }
+ });
+ } else {
+ // initiate the animation
+ runEnterAnimation();
+ }
+ }
+ // Kick off the photo list loader
+ mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this);
+ }
+
+ public void showActionBar() {
+ mActivity.getActionBarInterface().show();
+ }
+
+ public void hideActionBar() {
+ mActivity.getActionBarInterface().hide();
+ }
+
+ public boolean isScaleAnimationEnabled() {
+ return mScaleAnimationEnabled;
+ }
+
+ public boolean isEnterAnimationFinished() {
+ return mEnterAnimationFinished;
+ }
+
+ public View getRootView() {
+ return mRootView;
+ }
+
+ private class BitmapCallback implements LoaderManager.LoaderCallbacks {
+
+ @Override
+ public Loader onCreateLoader(int id, Bundle args) {
+ String uri = args.getString(ARG_IMAGE_URI);
+ switch (id) {
+ case PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL:
+ return onCreateBitmapLoader(PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL,
+ args, uri);
+ case PhotoViewCallbacks.BITMAP_LOADER_AVATAR:
+ return onCreateBitmapLoader(PhotoViewCallbacks.BITMAP_LOADER_AVATAR,
+ args, uri);
+ }
+ return null;
+ }
+
+ @Override
+ public void onLoadFinished(Loader loader, BitmapResult result) {
+ Drawable drawable = result.getDrawable(mActivity.getResources());
+ final ActionBarInterface actionBar = mActivity.getActionBarInterface();
+ switch (loader.getId()) {
+ case PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL:
+ // We just loaded the initial thumbnail that we can display
+ // while waiting for the full viewPager to get initialized.
+ initTemporaryImage(drawable);
+ break;
+ case PhotoViewCallbacks.BITMAP_LOADER_AVATAR:
+ if (drawable == null) {
+ actionBar.setLogo(null);
+ } else {
+ actionBar.setLogo(drawable);
+ }
+ break;
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader loader) {
+ // Do nothing
+ }
+ }
+
+ public void setImmersiveMode(boolean enabled) {
+ int flags = 0;
+ final int version = Build.VERSION.SDK_INT;
+ final boolean manuallyUpdateActionBar = version < Build.VERSION_CODES.JELLY_BEAN;
+ if (enabled &&
+ (!isScaleAnimationEnabled() || isEnterAnimationFinished())) {
+ // Turning on immersive mode causes an animation. If the scale animation is enabled and
+ // the enter animation isn't yet complete, then an immersive mode animation should not
+ // occur, since two concurrent animations are very janky.
+
+ // Disable immersive mode for seconary users to prevent b/12015090 (freezing crash)
+ // This is fixed in KK_MR2 but there is no way to differentiate between KK and KK_MR2.
+ if (version > Build.VERSION_CODES.KITKAT ||
+ version == Build.VERSION_CODES.KITKAT && !kitkatIsSecondaryUser()) {
+ flags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_IMMERSIVE;
+ } else if (version >= Build.VERSION_CODES.JELLY_BEAN) {
+ // Clients that use the scale animation should set the following system UI flags to
+ // prevent janky animations on exit when the status bar is hidden:
+ // View.SYSTEM_UI_FLAG_VISIBLE | View.SYSTEM_UI_FLAG_STABLE
+ // As well, client should ensure `android:fitsSystemWindows` is set on the root
+ // content view.
+ flags = View.SYSTEM_UI_FLAG_LOW_PROFILE
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | View.SYSTEM_UI_FLAG_FULLSCREEN;
+ } else if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ flags = View.SYSTEM_UI_FLAG_LOW_PROFILE;
+ } else if (version >= Build.VERSION_CODES.HONEYCOMB) {
+ flags = View.STATUS_BAR_HIDDEN;
+ }
+
+ if (manuallyUpdateActionBar) {
+ hideActionBar();
+ }
+ } else {
+ if (version >= Build.VERSION_CODES.KITKAT) {
+ flags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
+ } else if (version >= Build.VERSION_CODES.JELLY_BEAN) {
+ flags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
+ } else if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ flags = View.SYSTEM_UI_FLAG_VISIBLE;
+ } else if (version >= Build.VERSION_CODES.HONEYCOMB) {
+ flags = View.STATUS_BAR_VISIBLE;
+ }
+
+ if (manuallyUpdateActionBar) {
+ showActionBar();
+ }
+ }
+
+ if (version >= Build.VERSION_CODES.HONEYCOMB) {
+ mLastFlags = flags;
+ getRootView().setSystemUiVisibility(flags);
+ }
+ }
+
+ /**
+ * Return true iff the app is being run as a secondary user on kitkat.
+ *
+ * This is a hack which we only know to work on kitkat.
+ */
+ private boolean kitkatIsSecondaryUser() {
+ if (Build.VERSION.SDK_INT != Build.VERSION_CODES.KITKAT) {
+ throw new IllegalStateException("kitkatIsSecondary user is only callable on KitKat");
+ }
+ return Process.myUid() > 100000;
+ }
+
+ /**
+ * Note: This should only be called when API level is 11 or above.
+ */
+ public View.OnSystemUiVisibilityChangeListener getSystemUiVisibilityChangeListener() {
+ return mSystemUiVisibilityChangeListener;
+ }
+}
diff --git a/modules/photoviewer/src/com/android/ex/photo/PhotoViewPager.java b/modules/photoviewer/src/com/android/ex/photo/PhotoViewPager.java
new file mode 100644
index 0000000..4004a2a
--- /dev/null
+++ b/modules/photoviewer/src/com/android/ex/photo/PhotoViewPager.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.ex.photo;
+
+import android.content.Context;
+import androidx.core.view.MotionEventCompat;
+import androidx.viewpager.widget.ViewPager;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+/**
+ * View pager for photo view fragments. Define our own class so we can specify the
+ * view pager in XML.
+ */
+public class PhotoViewPager extends ViewPager {
+ /**
+ * A type of intercept that should be performed
+ */
+ public static enum InterceptType { NONE, LEFT, RIGHT, BOTH }
+
+ /**
+ * Provides an ability to intercept touch events.
+ *
+ * {@link ViewPager} intercepts all touch events and we need to be able to override this
+ * behavior. Instead, we could perform a similar function by declaring a custom
+ * {@link android.view.ViewGroup} to contain the pager and intercept touch events at a higher
+ * level.
+ */
+ public static interface OnInterceptTouchListener {
+ /**
+ * Called when a touch intercept is about to occur.
+ *
+ * @param origX the raw x coordinate of the initial touch
+ * @param origY the raw y coordinate of the initial touch
+ * @return Which type of touch, if any, should should be intercepted.
+ */
+ public InterceptType onTouchIntercept(float origX, float origY);
+ }
+
+ private static final int INVALID_POINTER = -1;
+
+ private float mLastMotionX;
+ private int mActivePointerId;
+ /** The x coordinate where the touch originated */
+ private float mActivatedX;
+ /** The y coordinate where the touch originated */
+ private float mActivatedY;
+ private OnInterceptTouchListener mListener;
+
+ public PhotoViewPager(Context context) {
+ super(context);
+ initialize();
+ }
+
+ public PhotoViewPager(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize();
+ }
+
+ private void initialize() {
+ // Set the page transformer to perform the transition animation
+ // for each page in the view.
+ setPageTransformer(true, new PageTransformer() {
+ @Override
+ public void transformPage(View page, float position) {
+
+ // The >= 1 is needed so that the page
+ // (page A) that transitions behind the newly visible
+ // page (page B) that comes in from the left does not
+ // get the touch events because it is still on screen
+ // (page A is still technically on screen despite being
+ // invisible). This makes sure that when the transition
+ // has completely finished, we revert it to its default
+ // behavior and move it off of the screen.
+ if (position < 0 || position >= 1.f) {
+ page.setTranslationX(0);
+ page.setAlpha(1.f);
+ page.setScaleX(1);
+ page.setScaleY(1);
+ } else {
+ page.setTranslationX(-position * page.getWidth());
+ page.setAlpha(Math.max(0,1.f - position));
+ final float scale = Math.max(0, 1.f - position * 0.3f);
+ page.setScaleX(scale);
+ page.setScaleY(scale);
+ }
+ }
+ });
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * We intercept touch event intercepts so we can prevent switching views when the
+ * current view is internally scrollable.
+ */
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent ev) {
+ final InterceptType intercept = (mListener != null)
+ ? mListener.onTouchIntercept(mActivatedX, mActivatedY)
+ : InterceptType.NONE;
+ final boolean ignoreScrollLeft =
+ (intercept == InterceptType.BOTH || intercept == InterceptType.LEFT);
+ final boolean ignoreScrollRight =
+ (intercept == InterceptType.BOTH || intercept == InterceptType.RIGHT);
+
+ // Only check ability to page if we can't scroll in one / both directions
+ final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
+
+ if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
+ mActivePointerId = INVALID_POINTER;
+ }
+
+ switch (action) {
+ case MotionEvent.ACTION_MOVE: {
+ if (ignoreScrollLeft || ignoreScrollRight) {
+ final int activePointerId = mActivePointerId;
+ if (activePointerId == INVALID_POINTER) {
+ // If we don't have a valid id, the touch down wasn't on content.
+ break;
+ }
+
+ final int pointerIndex =
+ MotionEventCompat.findPointerIndex(ev, activePointerId);
+ final float x = MotionEventCompat.getX(ev, pointerIndex);
+
+ if (ignoreScrollLeft && ignoreScrollRight) {
+ mLastMotionX = x;
+ return false;
+ } else if (ignoreScrollLeft && (x > mLastMotionX)) {
+ mLastMotionX = x;
+ return false;
+ } else if (ignoreScrollRight && (x < mLastMotionX)) {
+ mLastMotionX = x;
+ return false;
+ }
+ }
+ break;
+ }
+
+ case MotionEvent.ACTION_DOWN: {
+ mLastMotionX = ev.getX();
+ // Use the raw x/y as the children can be located anywhere and there isn't a
+ // single offset that would be meaningful
+ mActivatedX = ev.getRawX();
+ mActivatedY = ev.getRawY();
+ mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+ break;
+ }
+
+ case MotionEventCompat.ACTION_POINTER_UP: {
+ final int pointerIndex = MotionEventCompat.getActionIndex(ev);
+ final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
+ if (pointerId == mActivePointerId) {
+ // Our active pointer going up; select a new active pointer
+ final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
+ mLastMotionX = MotionEventCompat.getX(ev, newPointerIndex);
+ mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
+ }
+ break;
+ }
+ }
+
+ return super.onInterceptTouchEvent(ev);
+ }
+
+ /**
+ * sets the intercept touch listener.
+ */
+ public void setOnInterceptTouchListener(OnInterceptTouchListener l) {
+ mListener = l;
+ }
+}
diff --git a/modules/photoviewer/src/com/android/ex/photo/adapters/BaseCursorPagerAdapter.java b/modules/photoviewer/src/com/android/ex/photo/adapters/BaseCursorPagerAdapter.java
new file mode 100644
index 0000000..47cde79
--- /dev/null
+++ b/modules/photoviewer/src/com/android/ex/photo/adapters/BaseCursorPagerAdapter.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.ex.photo.adapters;
+
+import android.content.Context;
+import android.database.Cursor;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import android.util.Log;
+import android.util.SparseIntArray;
+import android.view.View;
+
+import com.android.ex.photo.provider.PhotoContract;
+
+import java.util.HashMap;
+
+/**
+ * Page adapter for use with an BaseCursorLoader. Unlike other cursor adapters, this has no
+ * observers for automatic refresh. Instead, it depends upon external mechanisms to provide
+ * the update signal.
+ */
+public abstract class BaseCursorPagerAdapter extends BaseFragmentPagerAdapter {
+ private static final String TAG = "BaseCursorPagerAdapter";
+
+ protected Context mContext;
+ protected Cursor mCursor;
+ protected int mRowIDColumn;
+ /** Mapping of row ID to cursor position */
+ protected SparseIntArray mItemPosition;
+ /** Mapping of instantiated object to row ID */
+ protected final HashMap