1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.messaging.ui.contact; 17 18 import android.content.Context; 19 import android.database.Cursor; 20 import android.graphics.Rect; 21 import android.os.AsyncTask; 22 import android.support.v7.appcompat.R; 23 import android.text.Editable; 24 import android.text.TextPaint; 25 import android.text.TextWatcher; 26 import android.text.util.Rfc822Tokenizer; 27 import android.util.AttributeSet; 28 import android.view.ContextThemeWrapper; 29 import android.view.KeyEvent; 30 import android.view.inputmethod.EditorInfo; 31 import android.widget.TextView; 32 33 import com.android.ex.chips.RecipientEditTextView; 34 import com.android.ex.chips.RecipientEntry; 35 import com.android.ex.chips.recipientchip.DrawableRecipientChip; 36 import com.android.messaging.datamodel.data.ParticipantData; 37 import com.android.messaging.util.ContactRecipientEntryUtils; 38 import com.android.messaging.util.ContactUtil; 39 import com.android.messaging.util.PhoneUtils; 40 41 import java.util.ArrayList; 42 import java.util.HashSet; 43 import java.util.Set; 44 import java.util.concurrent.Executor; 45 import java.util.concurrent.Executors; 46 47 /** 48 * An extension for {@link RecipientEditTextView} which shows a list of Materialized contact chips. 49 * It uses Bugle's ContactUtil to perform contact lookup, and is able to return the list of 50 * recipients in the form of a ParticipantData list. 51 */ 52 public class ContactRecipientAutoCompleteView extends RecipientEditTextView { 53 public interface ContactChipsChangeListener { onContactChipsChanged(int oldCount, int newCount)54 void onContactChipsChanged(int oldCount, int newCount); onInvalidContactChipsPruned(int prunedCount)55 void onInvalidContactChipsPruned(int prunedCount); onEntryComplete()56 void onEntryComplete(); 57 } 58 59 private final int mTextHeight; 60 private ContactChipsChangeListener mChipsChangeListener; 61 62 /** 63 * Watches changes in contact chips to determine possible state transitions. 64 */ 65 private class ContactChipsWatcher implements TextWatcher { 66 /** 67 * Tracks the old chips count before text changes. Note that we currently don't compare 68 * the entire chip sets but just the cheaper-to-do before and after counts, because 69 * the chips view don't allow for replacing chips. 70 */ 71 private int mLastChipsCount = 0; 72 73 @Override onTextChanged(final CharSequence s, final int start, final int before, final int count)74 public void onTextChanged(final CharSequence s, final int start, final int before, 75 final int count) { 76 } 77 78 @Override beforeTextChanged(final CharSequence s, final int start, final int count, final int after)79 public void beforeTextChanged(final CharSequence s, final int start, final int count, 80 final int after) { 81 // We don't take mLastChipsCount from here but from the last afterTextChanged() run. 82 // The reason is because at this point, any chip spans to be removed is already removed 83 // from s in the chips text view. 84 } 85 86 @Override afterTextChanged(final Editable s)87 public void afterTextChanged(final Editable s) { 88 final int currentChipsCount = s.getSpans(0, s.length(), 89 DrawableRecipientChip.class).length; 90 if (currentChipsCount != mLastChipsCount) { 91 // When a sanitizing task is running, we don't want to notify any chips count 92 // change, but we do want to track the last chip count. 93 if (mChipsChangeListener != null && mCurrentSanitizeTask == null) { 94 mChipsChangeListener.onContactChipsChanged(mLastChipsCount, currentChipsCount); 95 } 96 mLastChipsCount = currentChipsCount; 97 } 98 } 99 } 100 101 private static final String TEXT_HEIGHT_SAMPLE = "a"; 102 ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs)103 public ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs) { 104 super(new ContextThemeWrapper(context, R.style.ColorAccentGrayOverrideStyle), attrs); 105 106 // Get the height of the text, given the currently set font face and size. 107 final Rect textBounds = new Rect(0, 0, 0, 0); 108 final TextPaint paint = getPaint(); 109 paint.getTextBounds(TEXT_HEIGHT_SAMPLE, 0, TEXT_HEIGHT_SAMPLE.length(), textBounds); 110 mTextHeight = textBounds.height(); 111 112 setTokenizer(new Rfc822Tokenizer()); 113 addTextChangedListener(new ContactChipsWatcher()); 114 setOnFocusListShrinkRecipients(false); 115 116 setBackground(context.getResources().getDrawable( 117 R.drawable.abc_textfield_search_default_mtrl_alpha)); 118 } 119 setContactChipsListener(final ContactChipsChangeListener listener)120 public void setContactChipsListener(final ContactChipsChangeListener listener) { 121 mChipsChangeListener = listener; 122 } 123 124 /** 125 * A tuple of chips which AsyncContactChipSanitizeTask reports as progress to have the 126 * chip actually replaced/removed on the UI thread. 127 */ 128 private class ChipReplacementTuple { 129 public final DrawableRecipientChip removedChip; 130 public final RecipientEntry replacedChipEntry; 131 ChipReplacementTuple(final DrawableRecipientChip removedChip, final RecipientEntry replacedChipEntry)132 public ChipReplacementTuple(final DrawableRecipientChip removedChip, 133 final RecipientEntry replacedChipEntry) { 134 this.removedChip = removedChip; 135 this.replacedChipEntry = replacedChipEntry; 136 } 137 } 138 139 /** 140 * An AsyncTask that cleans up contact chips on every chips commit (i.e. get or create a new 141 * conversation with the given chips). 142 */ 143 private class AsyncContactChipSanitizeTask extends 144 AsyncTask<Void, ChipReplacementTuple, Integer> { 145 146 @Override doInBackground(final Void... params)147 protected Integer doInBackground(final Void... params) { 148 final DrawableRecipientChip[] recips = getText() 149 .getSpans(0, getText().length(), DrawableRecipientChip.class); 150 int invalidChipsRemoved = 0; 151 for (final DrawableRecipientChip recipient : recips) { 152 final RecipientEntry entry = recipient.getEntry(); 153 if (entry != null) { 154 if (entry.isValid()) { 155 if (RecipientEntry.isCreatedRecipient(entry.getContactId()) || 156 ContactRecipientEntryUtils.isSendToDestinationContact(entry)) { 157 // This is a generated/send-to contact chip, try to look it up and 158 // display a chip for the corresponding local contact. 159 final Cursor lookupResult = ContactUtil.lookupDestination(getContext(), 160 entry.getDestination()).performSynchronousQuery(); 161 if (lookupResult != null && lookupResult.moveToNext()) { 162 // Found a match, remove the generated entry and replace with 163 // a better local entry. 164 publishProgress(new ChipReplacementTuple(recipient, 165 ContactUtil.createRecipientEntryForPhoneQuery( 166 lookupResult, true))); 167 } else if (PhoneUtils.isValidSmsMmsDestination( 168 entry.getDestination())){ 169 // No match was found, but we have a valid destination so let's at 170 // least create an entry that shows an avatar. 171 publishProgress(new ChipReplacementTuple(recipient, 172 ContactRecipientEntryUtils.constructNumberWithAvatarEntry( 173 entry.getDestination()))); 174 } else { 175 // Not a valid contact. Remove and show an error. 176 publishProgress(new ChipReplacementTuple(recipient, null)); 177 invalidChipsRemoved++; 178 } 179 } 180 } else { 181 publishProgress(new ChipReplacementTuple(recipient, null)); 182 invalidChipsRemoved++; 183 } 184 } 185 } 186 return invalidChipsRemoved; 187 } 188 189 @Override onProgressUpdate(final ChipReplacementTuple... values)190 protected void onProgressUpdate(final ChipReplacementTuple... values) { 191 for (final ChipReplacementTuple tuple : values) { 192 if (tuple.removedChip != null) { 193 final Editable text = getText(); 194 final int chipStart = text.getSpanStart(tuple.removedChip); 195 final int chipEnd = text.getSpanEnd(tuple.removedChip); 196 if (chipStart >= 0 && chipEnd >= 0) { 197 text.delete(chipStart, chipEnd); 198 } 199 200 if (tuple.replacedChipEntry != null) { 201 appendRecipientEntry(tuple.replacedChipEntry); 202 } 203 } 204 } 205 } 206 207 @Override onPostExecute(final Integer invalidChipsRemoved)208 protected void onPostExecute(final Integer invalidChipsRemoved) { 209 mCurrentSanitizeTask = null; 210 if (invalidChipsRemoved > 0) { 211 mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved); 212 } 213 } 214 } 215 216 /** 217 * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that 218 * all sanitization tasks are serially executed so as not to interfere with each other. 219 */ 220 private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor(); 221 222 private AsyncContactChipSanitizeTask mCurrentSanitizeTask; 223 224 /** 225 * Whenever the caller wants to start a new conversation with the list of chips we have, 226 * make sure we asynchronously: 227 * 1. Remove invalid chips. 228 * 2. Attempt to resolve unknown contacts to known local contacts. 229 * 3. Convert still unknown chips to chips with generated avatar. 230 * 231 * Note that we don't need to perform this synchronously since we can 232 * resolve any unknown contacts to local contacts when needed. 233 */ sanitizeContactChips()234 private void sanitizeContactChips() { 235 if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) { 236 mCurrentSanitizeTask.cancel(false); 237 mCurrentSanitizeTask = null; 238 } 239 mCurrentSanitizeTask = new AsyncContactChipSanitizeTask(); 240 mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR); 241 } 242 243 /** 244 * Returns a list of ParticipantData from the entered chips in order to create 245 * new conversation. 246 */ getRecipientParticipantDataForConversationCreation()247 public ArrayList<ParticipantData> getRecipientParticipantDataForConversationCreation() { 248 final DrawableRecipientChip[] recips = getText() 249 .getSpans(0, getText().length(), DrawableRecipientChip.class); 250 final ArrayList<ParticipantData> contacts = 251 new ArrayList<ParticipantData>(recips.length); 252 for (final DrawableRecipientChip recipient : recips) { 253 final RecipientEntry entry = recipient.getEntry(); 254 if (entry != null && entry.isValid() && entry.getDestination() != null && 255 PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) { 256 contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry())); 257 } 258 } 259 sanitizeContactChips(); 260 return contacts; 261 } 262 263 /**c 264 * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the 265 * consumer with determining quickly whether a contact is currently selected. 266 */ getSelectedDestinations()267 public Set<String> getSelectedDestinations() { 268 Set<String> set = new HashSet<String>(); 269 final DrawableRecipientChip[] recips = getText() 270 .getSpans(0, getText().length(), DrawableRecipientChip.class); 271 272 for (final DrawableRecipientChip recipient : recips) { 273 final RecipientEntry entry = recipient.getEntry(); 274 if (entry != null && entry.isValid() && entry.getDestination() != null) { 275 set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale( 276 entry.getDestination())); 277 } 278 } 279 return set; 280 } 281 282 @Override onEditorAction(final TextView view, final int actionId, final KeyEvent event)283 public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { 284 if (actionId == EditorInfo.IME_ACTION_DONE) { 285 mChipsChangeListener.onEntryComplete(); 286 } 287 return super.onEditorAction(view, actionId, event); 288 } 289 } 290