package com.fsck.k9.view;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.List;
import android.annotation.SuppressLint;
import android.app.LoaderManager;
import android.app.LoaderManager.LoaderCallbacks;
import android.content.Context;
import android.content.Loader;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.provider.ContactsContract.Contacts;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Editable;
import android.text.TextUtils;
import android.util.AttributeSet;
import timber.log.Timber;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.ImageView;
import android.widget.ListPopupWindow;
import android.widget.ListView;
import android.widget.TextView;
import com.fsck.k9.K9;
import com.fsck.k9.R;
import com.fsck.k9.activity.AlternateRecipientAdapter;
import com.fsck.k9.activity.AlternateRecipientAdapter.AlternateRecipientListener;
import com.fsck.k9.activity.compose.RecipientAdapter;
import com.fsck.k9.activity.compose.RecipientLoader;
import com.fsck.k9.mail.Address;
import com.fsck.k9.view.RecipientSelectView.Recipient;
import com.tokenautocomplete.TokenCompleteTextView;
import org.apache.james.mime4j.util.CharsetUtil;
public class RecipientSelectView extends TokenCompleteTextView<Recipient> implements LoaderCallbacks<List<Recipient>>,
AlternateRecipientListener {
private static final int MINIMUM_LENGTH_FOR_FILTERING = 2;
private static final String ARG_QUERY = "query";
private static final int LOADER_ID_FILTERING = 0;
private static final int LOADER_ID_ALTERNATES = 1;
private RecipientAdapter adapter;
@Nullable
private String cryptoProvider;
@Nullable
private LoaderManager loaderManager;
private ListPopupWindow alternatesPopup;
private AlternateRecipientAdapter alternatesAdapter;
private Recipient alternatesPopupRecipient;
private TokenListener<Recipient> listener;
public RecipientSelectView(Context context) {
super(context);
initView(context);
}
public RecipientSelectView(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context);
}
public RecipientSelectView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initView(context);
}
private void initView(Context context) {
// TODO: validator?
alternatesPopup = new ListPopupWindow(context);
alternatesAdapter = new AlternateRecipientAdapter(context, this);
alternatesPopup.setAdapter(alternatesAdapter);
// don't allow duplicates, based on equality of recipient objects, which is e-mail addresses
allowDuplicates(false);
// if a token is completed, pick an entry based on best guess.
// Note that we override performCompletion, so this doesn't actually do anything
performBestGuess(true);
adapter = new RecipientAdapter(context);
setAdapter(adapter);
setLongClickable(true);
}
@Override
protected View getViewForObject(Recipient recipient) {
View view = inflateLayout();
RecipientTokenViewHolder holder = new RecipientTokenViewHolder(view);
view.setTag(holder);
bindObjectView(recipient, view);
return view;
}
@SuppressLint("InflateParams")
private View inflateLayout() {
LayoutInflater layoutInflater = LayoutInflater.from(getContext());
return layoutInflater.inflate(R.layout.recipient_token_item, null, false);
}
private void bindObjectView(Recipient recipient, View view) {
RecipientTokenViewHolder holder = (RecipientTokenViewHolder) view.getTag();
holder.vName.setText(recipient.getDisplayNameOrAddress());
RecipientAdapter.setContactPhotoOrPlaceholder(getContext(), holder.vContactPhoto, recipient);
boolean hasCryptoProvider = cryptoProvider != null;
if (!hasCryptoProvider) {
holder.cryptoStatusRed.setVisibility(View.GONE);
holder.cryptoStatusOrange.setVisibility(View.GONE);
holder.cryptoStatusGreen.setVisibility(View.GONE);
} else if (recipient.cryptoStatus == RecipientCryptoStatus.UNAVAILABLE) {
holder.cryptoStatusRed.setVisibility(View.VISIBLE);
holder.cryptoStatusOrange.setVisibility(View.GONE);
holder.cryptoStatusGreen.setVisibility(View.GONE);
} else if (recipient.cryptoStatus == RecipientCryptoStatus.AVAILABLE_UNTRUSTED) {
holder.cryptoStatusRed.setVisibility(View.GONE);
holder.cryptoStatusOrange.setVisibility(View.VISIBLE);
holder.cryptoStatusGreen.setVisibility(View.GONE);
} else if (recipient.cryptoStatus == RecipientCryptoStatus.AVAILABLE_TRUSTED) {
holder.cryptoStatusRed.setVisibility(View.GONE);
holder.cryptoStatusOrange.setVisibility(View.GONE);
holder.cryptoStatusGreen.setVisibility(View.VISIBLE);
}
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
int action = event.getActionMasked();
Editable text = getText();
if (text != null && action == MotionEvent.ACTION_UP) {
int offset = getOffsetForPosition(event.getX(), event.getY());
if (offset != -1) {
TokenImageSpan[] links = text.getSpans(offset, offset, RecipientTokenSpan.class);
if (links.length > 0) {
showAlternates(links[0].getToken());
return true;
}
}
}
return super.onTouchEvent(event);
}
@Override
protected Recipient defaultObject(String completionText) {
Address[] parsedAddresses = Address.parse(completionText);
if (!CharsetUtil.isASCII(completionText)) {
setError(getContext().getString(R.string.recipient_error_non_ascii));
return null;
}
if (parsedAddresses.length == 0 || parsedAddresses[0].getAddress() == null) {
setError(getContext().getString(R.string.recipient_error_parse_failed));
return null;
}
return new Recipient(parsedAddresses[0]);
}
public boolean isEmpty() {
return getObjects().isEmpty();
}
public void setLoaderManager(@Nullable LoaderManager loaderManager) {
this.loaderManager = loaderManager;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (loaderManager != null) {
loaderManager.destroyLoader(LOADER_ID_ALTERNATES);
loaderManager.destroyLoader(LOADER_ID_FILTERING);
loaderManager = null;
}
}
@Override
public void onFocusChanged(boolean hasFocus, int direction, Rect previous) {
super.onFocusChanged(hasFocus, direction, previous);
if (hasFocus) {
displayKeyboard();
}
}
private void displayKeyboard() {
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm == null) {
return;
}
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT);
}
@Override
public void showDropDown() {
boolean cursorIsValid = adapter != null;
if (!cursorIsValid) {
return;
}
super.showDropDown();
}
@Override
public void performCompletion() {
if (getListSelection() == ListView.INVALID_POSITION && enoughToFilter()) {
Object recipientText = defaultObject(currentCompletionText());
if (recipientText != null) {
replaceText(convertSelectionToString(recipientText));
}
} else {
super.performCompletion();
}
}
@Override
protected void performFiltering(@NonNull CharSequence text, int start, int end, int keyCode) {
if (loaderManager == null) {
return;
}
String query = text.subSequence(start, end).toString();
if (TextUtils.isEmpty(query) || query.length() < MINIMUM_LENGTH_FOR_FILTERING) {
loaderManager.destroyLoader(LOADER_ID_FILTERING);
return;
}
Bundle args = new Bundle();
args.putString(ARG_QUERY, query);
loaderManager.restartLoader(LOADER_ID_FILTERING, args, this);
}
public void setCryptoProvider(@Nullable String cryptoProvider) {
this.cryptoProvider = cryptoProvider;
}
public void addRecipients(Recipient... recipients) {
for (Recipient recipient : recipients) {
addObject(recipient);
}
}
public Address[] getAddresses() {
List<Recipient> recipients = getObjects();
Address[] address = new Address[recipients.size()];
for (int i = 0; i < address.length; i++) {
address[i] = recipients.get(i).address;
}
return address;
}
private void showAlternates(Recipient recipient) {
if (loaderManager == null) {
return;
}
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(getWindowToken(), 0);
alternatesPopupRecipient = recipient;
loaderManager.restartLoader(LOADER_ID_ALTERNATES, null, RecipientSelectView.this);
}
public void postShowAlternatesPopup(final List<Recipient> data) {
// We delay this call so the soft keyboard is gone by the time the popup is layouted
new Handler().post(new Runnable() {
@Override
public void run() {
showAlternatesPopup(data);
}
});
}
public void showAlternatesPopup(List<Recipient> data) {
if (loaderManager == null) {
return;
}
// Copy anchor settings from the autocomplete dropdown
View anchorView = getRootView().findViewById(getDropDownAnchor());
alternatesPopup.setAnchorView(anchorView);
alternatesPopup.setWidth(getDropDownWidth());
alternatesAdapter.setCurrentRecipient(alternatesPopupRecipient);
alternatesAdapter.setAlternateRecipientInfo(data);
// Clear the checked item.
alternatesPopup.show();
ListView listView = alternatesPopup.getListView();
listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
}
@Override
public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) {
alternatesPopup.dismiss();
return super.onKeyDown(keyCode, event);
}
@Override
public Loader<List<Recipient>> onCreateLoader(int id, Bundle args) {
switch (id) {
case LOADER_ID_FILTERING: {
String query = args != null && args.containsKey(ARG_QUERY) ? args.getString(ARG_QUERY) : "";
adapter.setHighlight(query);
return new RecipientLoader(getContext(), cryptoProvider, query);
}
case LOADER_ID_ALTERNATES: {
Uri contactLookupUri = alternatesPopupRecipient.getContactLookupUri();
if (contactLookupUri != null) {
return new RecipientLoader(getContext(), cryptoProvider, contactLookupUri, true);
} else {
return new RecipientLoader(getContext(), cryptoProvider, alternatesPopupRecipient.address);
}
}
}
throw new IllegalStateException("Unknown Loader ID: " + id);
}
@Override
public void onLoadFinished(Loader<List<Recipient>> loader, List<Recipient> data) {
if (loaderManager == null) {
return;
}
switch (loader.getId()) {
case LOADER_ID_FILTERING: {
adapter.setRecipients(data);
break;
}
case LOADER_ID_ALTERNATES: {
postShowAlternatesPopup(data);
loaderManager.destroyLoader(LOADER_ID_ALTERNATES);
break;
}
}
}
@Override
public void onLoaderReset(Loader<List<Recipient>> loader) {
if (loader.getId() == LOADER_ID_FILTERING) {
adapter.setHighlight(null);
adapter.setRecipients(null);
}
}
public boolean tryPerformCompletion() {
if (!hasUncompletedText()) {
return false;
}
int previousNumRecipients = getTokenCount();
performCompletion();
int numRecipients = getTokenCount();
return previousNumRecipients != numRecipients;
}
private int getTokenCount() {
return getObjects().size();
}
public boolean hasUncompletedText() {
String currentCompletionText = currentCompletionText();
return !TextUtils.isEmpty(currentCompletionText) && !isPlaceholderText(currentCompletionText);
}
static private boolean isPlaceholderText(String currentCompletionText) {
// TODO string matching here is sort of a hack, but it's somewhat reliable and the info isn't easily available
return currentCompletionText.startsWith("+") && currentCompletionText.substring(1).matches("[0-9]+");
}
@Override
public void onRecipientRemove(Recipient currentRecipient) {
alternatesPopup.dismiss();
removeObject(currentRecipient);
}
@Override
public void onRecipientChange(Recipient recipientToReplace, Recipient alternateAddress) {
alternatesPopup.dismiss();
List<Recipient> currentRecipients = getObjects();
int indexOfRecipient = currentRecipients.indexOf(recipientToReplace);
if (indexOfRecipient == -1) {
Timber.e("Tried to refresh invalid view token!");
return;
}
Recipient currentRecipient = currentRecipients.get(indexOfRecipient);
currentRecipient.address = alternateAddress.address;
currentRecipient.addressLabel = alternateAddress.addressLabel;
currentRecipient.cryptoStatus = alternateAddress.cryptoStatus;
View recipientTokenView = getTokenViewForRecipient(currentRecipient);
if (recipientTokenView == null) {
Timber.e("Tried to refresh invalid view token!");
return;
}
bindObjectView(currentRecipient, recipientTokenView);
if (listener != null) {
listener.onTokenChanged(currentRecipient);
}
invalidate();
}
/**
* This method builds the span given a recipient object. We override it with identical
* functionality, but using the custom RecipientTokenSpan class which allows us to
* retrieve the view for redrawing at a later point.
*/
@Override
protected TokenImageSpan buildSpanForObject(Recipient obj) {
if (obj == null) {
return null;
}
View tokenView = getViewForObject(obj);
return new RecipientTokenSpan(tokenView, obj, (int) maxTextWidth());
}
/**
* Find the token view tied to a given recipient. This method relies on spans to
* be of the RecipientTokenSpan class, as created by the buildSpanForObject method.
*/
private View getTokenViewForRecipient(Recipient currentRecipient) {
Editable text = getText();
if (text == null) {
return null;
}
RecipientTokenSpan[] recipientSpans = text.getSpans(0, text.length(), RecipientTokenSpan.class);
for (RecipientTokenSpan recipientSpan : recipientSpans) {
if (recipientSpan.getToken().equals(currentRecipient)) {
return recipientSpan.view;
}
}
return null;
}
/**
* We use a specialized version of TokenCompleteTextView.TokenListener as well,
* adding a callback for onTokenChanged.
*/
public void setTokenListener(TokenListener<Recipient> listener) {
super.setTokenListener(listener);
this.listener = listener;
}
public enum RecipientCryptoStatus {
UNDEFINED,
UNAVAILABLE,
AVAILABLE_UNTRUSTED,
AVAILABLE_TRUSTED;
public boolean isAvailable() {
return this == AVAILABLE_TRUSTED || this == AVAILABLE_UNTRUSTED;
}
}
public interface TokenListener<T> extends TokenCompleteTextView.TokenListener<T> {
void onTokenChanged(T token);
}
private class RecipientTokenSpan extends TokenImageSpan {
private final View view;
public RecipientTokenSpan(View view, Recipient recipient, int token) {
super(view, recipient, token);
this.view = view;
}
}
private static class RecipientTokenViewHolder {
public final TextView vName;
public final ImageView vContactPhoto;
public final View cryptoStatusRed;
public final View cryptoStatusOrange;
public final View cryptoStatusGreen;
RecipientTokenViewHolder(View view) {
vName = (TextView) view.findViewById(android.R.id.text1);
vContactPhoto = (ImageView) view.findViewById(R.id.contact_photo);
cryptoStatusRed = view.findViewById(R.id.contact_crypto_status_red);
cryptoStatusOrange = view.findViewById(R.id.contact_crypto_status_orange);
cryptoStatusGreen = view.findViewById(R.id.contact_crypto_status_green);
}
}
public static class Recipient implements Serializable {
@Nullable // null means the address is not associated with a contact
public final Long contactId;
public final String contactLookupKey;
@NonNull
public Address address;
public String addressLabel;
@Nullable // null if the contact has no photo. transient because we serialize this manually, see below.
public transient Uri photoThumbnailUri;
@NonNull
private RecipientCryptoStatus cryptoStatus;
public Recipient(@NonNull Address address) {
this.address = address;
this.contactId = null;
this.cryptoStatus = RecipientCryptoStatus.UNDEFINED;
this.contactLookupKey = null;
}
public Recipient(String name, String email, String addressLabel, long contactId, String lookupKey) {
this.address = new Address(email, name);
this.contactId = contactId;
this.addressLabel = addressLabel;
this.cryptoStatus = RecipientCryptoStatus.UNDEFINED;
this.contactLookupKey = lookupKey;
}
public String getDisplayNameOrAddress() {
String displayName = getDisplayName();
if (displayName != null) {
return displayName;
}
return address.getAddress();
}
public boolean isValidEmailAddress() {
return (address.getAddress() != null);
}
public String getDisplayNameOrUnknown(Context context) {
String displayName = getDisplayName();
if (displayName != null) {
return displayName;
}
return context.getString(R.string.unknown_recipient);
}
public String getNameOrUnknown(Context context) {
String name = address.getPersonal();
if (name != null) {
return name;
}
return context.getString(R.string.unknown_recipient);
}
private String getDisplayName() {
if (TextUtils.isEmpty(address.getPersonal())) {
return null;
}
String displayName = address.getPersonal();
if (addressLabel != null) {
displayName += " (" + addressLabel + ")";
}
return displayName;
}
@NonNull
public RecipientCryptoStatus getCryptoStatus() {
return cryptoStatus;
}
public void setCryptoStatus(@NonNull RecipientCryptoStatus cryptoStatus) {
this.cryptoStatus = cryptoStatus;
}
@Nullable
public Uri getContactLookupUri() {
if (contactId == null) {
return null;
}
return Contacts.getLookupUri(contactId, contactLookupKey);
}
@Override
public boolean equals(Object o) {
// Equality is entirely up to the address
return o instanceof Recipient && address.equals(((Recipient) o).address);
}
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();
// custom serialization, Android's Uri class is not serializable
if (photoThumbnailUri != null) {
oos.writeInt(1);
oos.writeUTF(photoThumbnailUri.toString());
} else {
oos.writeInt(0);
}
}
private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
ois.defaultReadObject();
// custom deserialization, Android's Uri class is not serializable
if (ois.readInt() != 0) {
String uriString = ois.readUTF();
photoThumbnailUri = Uri.parse(uriString);
}
}
}
}