| /* |
| * Copyright (C) 2015 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.messaging.ui.contact; |
| |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.graphics.Rect; |
| import android.os.AsyncTask; |
| import androidx.appcompat.R; |
| import android.text.Editable; |
| import android.text.TextPaint; |
| import android.text.TextWatcher; |
| import android.text.util.Rfc822Tokenizer; |
| import android.util.AttributeSet; |
| import android.view.ContextThemeWrapper; |
| import android.view.KeyEvent; |
| import android.view.inputmethod.EditorInfo; |
| import android.widget.TextView; |
| |
| import com.android.ex.chips.RecipientEditTextView; |
| import com.android.ex.chips.RecipientEntry; |
| import com.android.ex.chips.recipientchip.DrawableRecipientChip; |
| import com.android.messaging.datamodel.data.ParticipantData; |
| import com.android.messaging.util.ContactRecipientEntryUtils; |
| import com.android.messaging.util.ContactUtil; |
| import com.android.messaging.util.PhoneUtils; |
| |
| import java.util.ArrayList; |
| import java.util.HashSet; |
| import java.util.Set; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.Executors; |
| |
| /** |
| * An extension for {@link RecipientEditTextView} which shows a list of Materialized contact chips. |
| * It uses Bugle's ContactUtil to perform contact lookup, and is able to return the list of |
| * recipients in the form of a ParticipantData list. |
| */ |
| public class ContactRecipientAutoCompleteView extends RecipientEditTextView { |
| public interface ContactChipsChangeListener { |
| void onContactChipsChanged(int oldCount, int newCount); |
| void onInvalidContactChipsPruned(int prunedCount); |
| void onEntryComplete(); |
| } |
| |
| private final int mTextHeight; |
| private ContactChipsChangeListener mChipsChangeListener; |
| |
| /** |
| * Watches changes in contact chips to determine possible state transitions. |
| */ |
| private class ContactChipsWatcher implements TextWatcher { |
| /** |
| * Tracks the old chips count before text changes. Note that we currently don't compare |
| * the entire chip sets but just the cheaper-to-do before and after counts, because |
| * the chips view don't allow for replacing chips. |
| */ |
| private int mLastChipsCount = 0; |
| |
| @Override |
| public void onTextChanged(final CharSequence s, final int start, final int before, |
| final int count) { |
| } |
| |
| @Override |
| public void beforeTextChanged(final CharSequence s, final int start, final int count, |
| final int after) { |
| // We don't take mLastChipsCount from here but from the last afterTextChanged() run. |
| // The reason is because at this point, any chip spans to be removed is already removed |
| // from s in the chips text view. |
| } |
| |
| @Override |
| public void afterTextChanged(final Editable s) { |
| final int currentChipsCount = s.getSpans(0, s.length(), |
| DrawableRecipientChip.class).length; |
| if (currentChipsCount != mLastChipsCount) { |
| // When a sanitizing task is running, we don't want to notify any chips count |
| // change, but we do want to track the last chip count. |
| if (mChipsChangeListener != null && mCurrentSanitizeTask == null) { |
| mChipsChangeListener.onContactChipsChanged(mLastChipsCount, currentChipsCount); |
| } |
| mLastChipsCount = currentChipsCount; |
| } |
| } |
| } |
| |
| private static final String TEXT_HEIGHT_SAMPLE = "a"; |
| |
| public ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs) { |
| super(new ContextThemeWrapper(context, R.style.ColorAccentGrayOverrideStyle), attrs); |
| |
| // Get the height of the text, given the currently set font face and size. |
| final Rect textBounds = new Rect(0, 0, 0, 0); |
| final TextPaint paint = getPaint(); |
| paint.getTextBounds(TEXT_HEIGHT_SAMPLE, 0, TEXT_HEIGHT_SAMPLE.length(), textBounds); |
| mTextHeight = textBounds.height(); |
| |
| setTokenizer(new Rfc822Tokenizer()); |
| addTextChangedListener(new ContactChipsWatcher()); |
| setOnFocusListShrinkRecipients(false); |
| |
| setBackground(context.getResources().getDrawable( |
| R.drawable.abc_textfield_search_default_mtrl_alpha)); |
| } |
| |
| public void setContactChipsListener(final ContactChipsChangeListener listener) { |
| mChipsChangeListener = listener; |
| } |
| |
| /** |
| * A tuple of chips which AsyncContactChipSanitizeTask reports as progress to have the |
| * chip actually replaced/removed on the UI thread. |
| */ |
| private class ChipReplacementTuple { |
| public final DrawableRecipientChip removedChip; |
| public final RecipientEntry replacedChipEntry; |
| |
| public ChipReplacementTuple(final DrawableRecipientChip removedChip, |
| final RecipientEntry replacedChipEntry) { |
| this.removedChip = removedChip; |
| this.replacedChipEntry = replacedChipEntry; |
| } |
| } |
| |
| /** |
| * An AsyncTask that cleans up contact chips on every chips commit (i.e. get or create a new |
| * conversation with the given chips). |
| */ |
| private class AsyncContactChipSanitizeTask extends |
| AsyncTask<Void, ChipReplacementTuple, Integer> { |
| |
| @Override |
| protected Integer doInBackground(final Void... params) { |
| final DrawableRecipientChip[] recips = getText() |
| .getSpans(0, getText().length(), DrawableRecipientChip.class); |
| int invalidChipsRemoved = 0; |
| for (final DrawableRecipientChip recipient : recips) { |
| final RecipientEntry entry = recipient.getEntry(); |
| if (entry != null) { |
| if (entry.isValid()) { |
| if (RecipientEntry.isCreatedRecipient(entry.getContactId()) || |
| ContactRecipientEntryUtils.isSendToDestinationContact(entry)) { |
| // This is a generated/send-to contact chip, try to look it up and |
| // display a chip for the corresponding local contact. |
| final Cursor lookupResult = ContactUtil.lookupDestination(getContext(), |
| entry.getDestination()).performSynchronousQuery(); |
| if (lookupResult != null && lookupResult.moveToNext()) { |
| // Found a match, remove the generated entry and replace with |
| // a better local entry. |
| publishProgress(new ChipReplacementTuple(recipient, |
| ContactUtil.createRecipientEntryForPhoneQuery( |
| lookupResult, true))); |
| } else if (PhoneUtils.isValidSmsMmsDestination( |
| entry.getDestination())){ |
| // No match was found, but we have a valid destination so let's at |
| // least create an entry that shows an avatar. |
| publishProgress(new ChipReplacementTuple(recipient, |
| ContactRecipientEntryUtils.constructNumberWithAvatarEntry( |
| entry.getDestination()))); |
| } else { |
| // Not a valid contact. Remove and show an error. |
| publishProgress(new ChipReplacementTuple(recipient, null)); |
| invalidChipsRemoved++; |
| } |
| } |
| } else { |
| publishProgress(new ChipReplacementTuple(recipient, null)); |
| invalidChipsRemoved++; |
| } |
| } |
| } |
| return invalidChipsRemoved; |
| } |
| |
| @Override |
| protected void onProgressUpdate(final ChipReplacementTuple... values) { |
| for (final ChipReplacementTuple tuple : values) { |
| if (tuple.removedChip != null) { |
| final Editable text = getText(); |
| final int chipStart = text.getSpanStart(tuple.removedChip); |
| final int chipEnd = text.getSpanEnd(tuple.removedChip); |
| if (chipStart >= 0 && chipEnd >= 0) { |
| text.delete(chipStart, chipEnd); |
| } |
| |
| if (tuple.replacedChipEntry != null) { |
| appendRecipientEntry(tuple.replacedChipEntry); |
| } |
| } |
| } |
| } |
| |
| @Override |
| protected void onPostExecute(final Integer invalidChipsRemoved) { |
| mCurrentSanitizeTask = null; |
| if (invalidChipsRemoved > 0) { |
| mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved); |
| } |
| } |
| } |
| |
| /** |
| * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that |
| * all sanitization tasks are serially executed so as not to interfere with each other. |
| */ |
| private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor(); |
| |
| private AsyncContactChipSanitizeTask mCurrentSanitizeTask; |
| |
| /** |
| * Whenever the caller wants to start a new conversation with the list of chips we have, |
| * make sure we asynchronously: |
| * 1. Remove invalid chips. |
| * 2. Attempt to resolve unknown contacts to known local contacts. |
| * 3. Convert still unknown chips to chips with generated avatar. |
| * |
| * Note that we don't need to perform this synchronously since we can |
| * resolve any unknown contacts to local contacts when needed. |
| */ |
| private void sanitizeContactChips() { |
| if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) { |
| mCurrentSanitizeTask.cancel(false); |
| mCurrentSanitizeTask = null; |
| } |
| mCurrentSanitizeTask = new AsyncContactChipSanitizeTask(); |
| mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR); |
| } |
| |
| /** |
| * Returns a list of ParticipantData from the entered chips in order to create |
| * new conversation. |
| */ |
| public ArrayList<ParticipantData> getRecipientParticipantDataForConversationCreation() { |
| final DrawableRecipientChip[] recips = getText() |
| .getSpans(0, getText().length(), DrawableRecipientChip.class); |
| final ArrayList<ParticipantData> contacts = |
| new ArrayList<ParticipantData>(recips.length); |
| for (final DrawableRecipientChip recipient : recips) { |
| final RecipientEntry entry = recipient.getEntry(); |
| if (entry != null && entry.isValid() && entry.getDestination() != null && |
| PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) { |
| contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry())); |
| } |
| } |
| sanitizeContactChips(); |
| return contacts; |
| } |
| |
| /**c |
| * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the |
| * consumer with determining quickly whether a contact is currently selected. |
| */ |
| public Set<String> getSelectedDestinations() { |
| Set<String> set = new HashSet<String>(); |
| final DrawableRecipientChip[] recips = getText() |
| .getSpans(0, getText().length(), DrawableRecipientChip.class); |
| |
| for (final DrawableRecipientChip recipient : recips) { |
| final RecipientEntry entry = recipient.getEntry(); |
| if (entry != null && entry.isValid() && entry.getDestination() != null) { |
| set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale( |
| entry.getDestination())); |
| } |
| } |
| return set; |
| } |
| |
| @Override |
| public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { |
| if (actionId == EditorInfo.IME_ACTION_DONE) { |
| mChipsChangeListener.onEntryComplete(); |
| } |
| return super.onEditorAction(view, actionId, event); |
| } |
| } |