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 androidx.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 try (final Cursor lookupResult = 160 ContactUtil.lookupDestination( 161 getContext(), entry.getDestination()) 162 .performSynchronousQuery()) { 163 if (lookupResult != null && lookupResult.moveToNext()) { 164 // Found a match, remove the generated entry and replace with a 165 // better local entry. 166 publishProgress( 167 new ChipReplacementTuple( 168 recipient, 169 ContactUtil.createRecipientEntryForPhoneQuery( 170 lookupResult, true))); 171 } else if (PhoneUtils.isValidSmsMmsDestination( 172 entry.getDestination())) { 173 // No match was found, but we have a valid destination so let's 174 // at least create an entry that shows an avatar. 175 publishProgress( 176 new ChipReplacementTuple( 177 recipient, 178 ContactRecipientEntryUtils 179 .constructNumberWithAvatarEntry( 180 entry.getDestination()))); 181 } else { 182 // Not a valid contact. Remove and show an error. 183 publishProgress(new ChipReplacementTuple(recipient, null)); 184 invalidChipsRemoved++; 185 } 186 } 187 } 188 } else { 189 publishProgress(new ChipReplacementTuple(recipient, null)); 190 invalidChipsRemoved++; 191 } 192 } 193 } 194 return invalidChipsRemoved; 195 } 196 197 @Override onProgressUpdate(final ChipReplacementTuple... values)198 protected void onProgressUpdate(final ChipReplacementTuple... values) { 199 for (final ChipReplacementTuple tuple : values) { 200 if (tuple.removedChip != null) { 201 final Editable text = getText(); 202 final int chipStart = text.getSpanStart(tuple.removedChip); 203 final int chipEnd = text.getSpanEnd(tuple.removedChip); 204 if (chipStart >= 0 && chipEnd >= 0) { 205 text.delete(chipStart, chipEnd); 206 } 207 208 if (tuple.replacedChipEntry != null) { 209 appendRecipientEntry(tuple.replacedChipEntry); 210 } 211 } 212 } 213 } 214 215 @Override onPostExecute(final Integer invalidChipsRemoved)216 protected void onPostExecute(final Integer invalidChipsRemoved) { 217 mCurrentSanitizeTask = null; 218 if (invalidChipsRemoved > 0) { 219 mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved); 220 } 221 } 222 } 223 224 /** 225 * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that 226 * all sanitization tasks are serially executed so as not to interfere with each other. 227 */ 228 private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor(); 229 230 private AsyncContactChipSanitizeTask mCurrentSanitizeTask; 231 232 /** 233 * Whenever the caller wants to start a new conversation with the list of chips we have, 234 * make sure we asynchronously: 235 * 1. Remove invalid chips. 236 * 2. Attempt to resolve unknown contacts to known local contacts. 237 * 3. Convert still unknown chips to chips with generated avatar. 238 * 239 * Note that we don't need to perform this synchronously since we can 240 * resolve any unknown contacts to local contacts when needed. 241 */ sanitizeContactChips()242 private void sanitizeContactChips() { 243 if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) { 244 mCurrentSanitizeTask.cancel(false); 245 mCurrentSanitizeTask = null; 246 } 247 mCurrentSanitizeTask = new AsyncContactChipSanitizeTask(); 248 mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR); 249 } 250 251 /** 252 * Returns a list of ParticipantData from the entered chips in order to create 253 * new conversation. 254 */ getRecipientParticipantDataForConversationCreation()255 public ArrayList<ParticipantData> getRecipientParticipantDataForConversationCreation() { 256 final DrawableRecipientChip[] recips = getText() 257 .getSpans(0, getText().length(), DrawableRecipientChip.class); 258 final ArrayList<ParticipantData> contacts = 259 new ArrayList<ParticipantData>(recips.length); 260 for (final DrawableRecipientChip recipient : recips) { 261 final RecipientEntry entry = recipient.getEntry(); 262 if (entry != null && entry.isValid() && entry.getDestination() != null && 263 PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) { 264 contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry())); 265 } 266 } 267 sanitizeContactChips(); 268 return contacts; 269 } 270 271 /**c 272 * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the 273 * consumer with determining quickly whether a contact is currently selected. 274 */ getSelectedDestinations()275 public Set<String> getSelectedDestinations() { 276 Set<String> set = new HashSet<String>(); 277 final DrawableRecipientChip[] recips = getText() 278 .getSpans(0, getText().length(), DrawableRecipientChip.class); 279 280 for (final DrawableRecipientChip recipient : recips) { 281 final RecipientEntry entry = recipient.getEntry(); 282 if (entry != null && entry.isValid() && entry.getDestination() != null) { 283 set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale( 284 entry.getDestination())); 285 } 286 } 287 return set; 288 } 289 290 @Override onEditorAction(final TextView view, final int actionId, final KeyEvent event)291 public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { 292 if (actionId == EditorInfo.IME_ACTION_DONE) { 293 mChipsChangeListener.onEntryComplete(); 294 } 295 return super.onEditorAction(view, actionId, event); 296 } 297 } 298