diff --git a/build.gradle b/build.gradle index e799d9812..b35d409be 100644 --- a/build.gradle +++ b/build.gradle @@ -120,6 +120,10 @@ dependencies { compile 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4' compile 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' compile 'net.zetetic:android-database-sqlcipher:3.5.9' + compile ('com.googlecode.ez-vcard:ez-vcard:0.9.11') { + exclude group: 'com.fasterxml.jackson.core' + exclude group: 'org.freemarker' + } testCompile 'junit:junit:4.12' testCompile 'org.assertj:assertj-core:1.7.1' @@ -306,6 +310,7 @@ android { 'proguard-klinker.pro', 'proguard-retrolambda.pro', 'proguard-okhttp.pro', + 'proguard-ez-vcard.pro', 'proguard.cfg' testProguardFiles 'proguard-automation.pro', 'proguard.cfg' diff --git a/proguard-ez-vcard.pro b/proguard-ez-vcard.pro new file mode 100644 index 000000000..39be6827b --- /dev/null +++ b/proguard-ez-vcard.pro @@ -0,0 +1 @@ +-dontwarn ezvcard.io.html.HCardPage diff --git a/src/org/thoughtcrime/securesms/ConversationActivity.java b/src/org/thoughtcrime/securesms/ConversationActivity.java index 557cb46c7..531b62544 100644 --- a/src/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationActivity.java @@ -1391,12 +1391,16 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private void setMedia(@Nullable Uri uri, @NonNull MediaType mediaType, int width, int height) { if (uri == null) return; - attachmentManager.setMedia(glideRequests, uri, mediaType, getCurrentMediaConstraints(), width, height); + + if (MediaType.VCARD.equals(mediaType) && isSecureText) { + openContactShareEditor(uri); + } else { + attachmentManager.setMedia(glideRequests, uri, mediaType, getCurrentMediaConstraints(), width, height); + } } private void openContactShareEditor(Uri contactUri) { - long id = ContactUtil.getContactIdFromUri(contactUri); - Intent intent = ContactShareEditActivity.getIntent(this, Collections.singletonList(id)); + Intent intent = ContactShareEditActivity.getIntent(this, Collections.singletonList(contactUri)); startActivityForResult(intent, GET_CONTACT_DETAILS); } diff --git a/src/org/thoughtcrime/securesms/contactshare/ContactRepository.java b/src/org/thoughtcrime/securesms/contactshare/ContactRepository.java index 9c7c6bb06..c6ed52217 100644 --- a/src/org/thoughtcrime/securesms/contactshare/ContactRepository.java +++ b/src/org/thoughtcrime/securesms/contactshare/ContactRepository.java @@ -17,8 +17,12 @@ import org.thoughtcrime.securesms.contactshare.Contact.Name; import org.thoughtcrime.securesms.contactshare.Contact.Phone; import org.thoughtcrime.securesms.contactshare.Contact.PostalAddress; import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; +import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; @@ -26,6 +30,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.Executor; +import ezvcard.Ezvcard; +import ezvcard.VCard; + import static org.thoughtcrime.securesms.contactshare.Contact.*; public class ContactRepository { @@ -45,11 +52,18 @@ public class ContactRepository { this.contactsDatabase = contactsDatabase; } - void getContacts(@NonNull List contactIds, @NonNull ValueCallback> callback) { + void getContacts(@NonNull List contactUris, @NonNull ValueCallback> callback) { executor.execute(() -> { - List contacts = new ArrayList<>(contactIds.size()); - for (long id : contactIds) { - Contact contact = getContact(id); + List contacts = new ArrayList<>(contactUris.size()); + for (Uri contactUri : contactUris) { + Contact contact; + + if (ContactsContract.AUTHORITY.equals(contactUri.getAuthority())) { + contact = getContactFromSystemContacts(ContactUtil.getContactIdFromUri(contactUri)); + } else { + contact = getContactFromVcard(contactUri); + } + if (contact != null) { contacts.add(contact); } @@ -59,7 +73,7 @@ public class ContactRepository { } @WorkerThread - private @Nullable Contact getContact(long contactId) { + private @Nullable Contact getContactFromSystemContacts(long contactId) { Name name = getName(contactId); if (name == null) { Log.w(TAG, "Couldn't find a name associated with the provided contact ID."); @@ -73,6 +87,79 @@ public class ContactRepository { return new Contact(name, null, phoneNumbers, getEmails(contactId), getPostalAddresses(contactId), avatar); } + @WorkerThread + private @Nullable Contact getContactFromVcard(@NonNull Uri uri) { + Contact contact = null; + + try (InputStream stream = PartAuthority.getAttachmentStream(context, uri)) { + VCard vcard = Ezvcard.parse(stream).first(); + + ezvcard.property.StructuredName vName = vcard.getStructuredName(); + List vPhones = vcard.getTelephoneNumbers(); + List vEmails = vcard.getEmails(); + List vPostalAddresses = vcard.getAddresses(); + + String organization = vcard.getOrganization() != null && !vcard.getOrganization().getValues().isEmpty() ? vcard.getOrganization().getValues().get(0) : null; + String displayName = vcard.getFormattedName() != null ? vcard.getFormattedName().getValue() : null; + + if (displayName == null && vName != null) { + displayName = vName.getGiven(); + } + + if (displayName == null && vcard.getOrganization() != null) { + displayName = organization; + } + + if (displayName == null) { + throw new IOException("No valid name."); + } + + Name name = new Name(displayName, + vName != null ? vName.getGiven() : null, + vName != null ? vName.getFamily() : null, + vName != null && !vName.getPrefixes().isEmpty() ? vName.getPrefixes().get(0) : null, + vName != null && !vName.getSuffixes().isEmpty() ? vName.getSuffixes().get(0) : null, + null); + + + List phoneNumbers = new ArrayList<>(vPhones.size()); + for (ezvcard.property.Telephone vEmail : vPhones) { + String label = !vEmail.getTypes().isEmpty() ? getCleanedVcardType(vEmail.getTypes().get(0).getValue()) : null; + phoneNumbers.add(new Phone(vEmail.getText(), phoneTypeFromVcardType(label), label)); + } + + List emails = new ArrayList<>(vEmails.size()); + for (ezvcard.property.Email vEmail : vEmails) { + String label = !vEmail.getTypes().isEmpty() ? getCleanedVcardType(vEmail.getTypes().get(0).getValue()) : null; + emails.add(new Email(vEmail.getValue(), emailTypeFromVcardType(label), label)); + } + + List postalAddresses = new ArrayList<>(vPostalAddresses.size()); + for (ezvcard.property.Address vPostalAddress : vPostalAddresses) { + String label = !vPostalAddress.getTypes().isEmpty() ? getCleanedVcardType(vPostalAddress.getTypes().get(0).getValue()) : null; + postalAddresses.add(new PostalAddress(postalAddressTypeFromVcardType(label), + label, + vPostalAddress.getStreetAddress(), + vPostalAddress.getPoBox(), + null, + vPostalAddress.getLocality(), + vPostalAddress.getRegion(), + vPostalAddress.getPostalCode(), + vPostalAddress.getCountry())); + } + + contact = new Contact(name, organization, phoneNumbers, emails, postalAddresses, null); + } catch (IOException e) { + Log.w(TAG, "Failed to parse the vcard.", e); + } + + if (PersistentBlobProvider.AUTHORITY.equals(uri.getAuthority())) { + PersistentBlobProvider.getInstance(context).delete(context, uri); + } + + return contact; + } + @WorkerThread private @Nullable Name getName(long contactId) { try (Cursor cursor = contactsDatabase.getNameDetails(contactId)) { @@ -225,6 +312,13 @@ public class ContactRepository { return Phone.Type.CUSTOM; } + private Phone.Type phoneTypeFromVcardType(@Nullable String type) { + if ("home".equalsIgnoreCase(type)) return Phone.Type.HOME; + else if ("cell".equalsIgnoreCase(type)) return Phone.Type.MOBILE; + else if ("work".equalsIgnoreCase(type)) return Phone.Type.WORK; + else return Phone.Type.CUSTOM; + } + private Email.Type emailTypeFromContactType(int type) { switch (type) { case ContactsContract.CommonDataKinds.Email.TYPE_HOME: @@ -237,6 +331,13 @@ public class ContactRepository { return Email.Type.CUSTOM; } + private Email.Type emailTypeFromVcardType(@Nullable String type) { + if ("home".equalsIgnoreCase(type)) return Email.Type.HOME; + else if ("cell".equalsIgnoreCase(type)) return Email.Type.MOBILE; + else if ("work".equalsIgnoreCase(type)) return Email.Type.WORK; + else return Email.Type.CUSTOM; + } + private PostalAddress.Type postalAddressTypeFromContactType(int type) { switch (type) { case ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME: @@ -247,6 +348,22 @@ public class ContactRepository { return PostalAddress.Type.CUSTOM; } + private PostalAddress.Type postalAddressTypeFromVcardType(@Nullable String type) { + if ("home".equalsIgnoreCase(type)) return PostalAddress.Type.HOME; + else if ("work".equalsIgnoreCase(type)) return PostalAddress.Type.WORK; + else return PostalAddress.Type.CUSTOM; + } + + private String getCleanedVcardType(@Nullable String type) { + if (TextUtils.isEmpty(type)) return ""; + + if (type.startsWith("x-") && type.length() > 2) { + return type.substring(2); + } + + return type; + } + interface ValueCallback { void onComplete(@NonNull T value); } diff --git a/src/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java b/src/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java index 4f6a932b6..bb6702765 100644 --- a/src/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java +++ b/src/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java @@ -4,6 +4,7 @@ import android.app.Activity; import android.arch.lifecycle.ViewModelProviders; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.support.annotation.NonNull; @@ -30,20 +31,20 @@ import static org.thoughtcrime.securesms.contactshare.ContactShareEditViewModel. public class ContactShareEditActivity extends PassphraseRequiredActionBarActivity implements ContactShareEditAdapter.EventListener { - public static final String KEY_CONTACTS = "contacts"; - private static final String KEY_CONTACT_IDS = "ids"; - private static final int CODE_NAME_EDIT = 55; + public static final String KEY_CONTACTS = "contacts"; + private static final String KEY_CONTACT_URIS = "contact_uris"; + private static final int CODE_NAME_EDIT = 55; private final DynamicTheme dynamicTheme = new DynamicTheme(); private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); private ContactShareEditViewModel viewModel; - public static Intent getIntent(@NonNull Context context, @NonNull List contactIds) { - ArrayList serializedIds = new ArrayList<>(Stream.of(contactIds).map(String::valueOf).toList()); + public static Intent getIntent(@NonNull Context context, @NonNull List contactUris) { + ArrayList contactUriList = new ArrayList<>(contactUris); Intent intent = new Intent(context, ContactShareEditActivity.class); - intent.putStringArrayListExtra(KEY_CONTACT_IDS, serializedIds); + intent.putParcelableArrayListExtra(KEY_CONTACT_URIS, contactUriList); return intent; } @@ -61,13 +62,11 @@ public class ContactShareEditActivity extends PassphraseRequiredActionBarActivit throw new IllegalStateException("You must supply extras to this activity. Please use the #getIntent() method."); } - List serializedIds = getIntent().getStringArrayListExtra(KEY_CONTACT_IDS); - if (serializedIds == null) { - throw new IllegalStateException("You must supply contact ID's to this activity. Please use the #getIntent() method."); + List contactUris = getIntent().getParcelableArrayListExtra(KEY_CONTACT_URIS); + if (contactUris == null) { + throw new IllegalStateException("You must supply contact Uri's to this activity. Please use the #getIntent() method."); } - List contactIds = Stream.of(serializedIds).map(Long::parseLong).toList(); - View sendButton = findViewById(R.id.contact_share_edit_send); sendButton.setOnClickListener(v -> onSendClicked(viewModel.getFinalizedContacts())); @@ -82,7 +81,7 @@ public class ContactShareEditActivity extends PassphraseRequiredActionBarActivit AsyncTask.THREAD_POOL_EXECUTOR, DatabaseFactory.getContactsDatabase(this)); - viewModel = ViewModelProviders.of(this, new Factory(contactIds, contactRepository)).get(ContactShareEditViewModel.class); + viewModel = ViewModelProviders.of(this, new Factory(contactUris, contactRepository)).get(ContactShareEditViewModel.class); viewModel.getContacts().observe(this, contacts -> { contactAdapter.setContacts(contacts); contactList.post(() -> contactList.scrollToPosition(0)); diff --git a/src/org/thoughtcrime/securesms/contactshare/ContactShareEditViewModel.java b/src/org/thoughtcrime/securesms/contactshare/ContactShareEditViewModel.java index 1c4caa884..a4ed05521 100644 --- a/src/org/thoughtcrime/securesms/contactshare/ContactShareEditViewModel.java +++ b/src/org/thoughtcrime/securesms/contactshare/ContactShareEditViewModel.java @@ -4,6 +4,7 @@ import android.arch.lifecycle.LiveData; import android.arch.lifecycle.MutableLiveData; import android.arch.lifecycle.ViewModel; import android.arch.lifecycle.ViewModelProvider; +import android.net.Uri; import android.support.annotation.NonNull; import com.annimon.stream.Stream; @@ -20,14 +21,14 @@ class ContactShareEditViewModel extends ViewModel { private final SingleLiveEvent events; private final ContactRepository repo; - ContactShareEditViewModel(@NonNull List contactIds, + ContactShareEditViewModel(@NonNull List contactUris, @NonNull ContactRepository contactRepository) { contacts = new MutableLiveData<>(); events = new SingleLiveEvent<>(); repo = contactRepository; - repo.getContacts(contactIds, retrieved -> { + repo.getContacts(contactUris, retrieved -> { if (retrieved.isEmpty()) { events.postValue(Event.BAD_CONTACT); } else { @@ -96,17 +97,17 @@ class ContactShareEditViewModel extends ViewModel { static class Factory extends ViewModelProvider.NewInstanceFactory { - private final List contactIds; + private final List contactUris; private final ContactRepository contactRepository; - Factory(@NonNull List contactIds, @NonNull ContactRepository contactRepository) { - this.contactIds = contactIds; + Factory(@NonNull List contactUris, @NonNull ContactRepository contactRepository) { + this.contactUris = contactUris; this.contactRepository = contactRepository; } @Override public @NonNull T create(@NonNull Class modelClass) { - return modelClass.cast(new ContactShareEditViewModel(contactIds, contactRepository)); + return modelClass.cast(new ContactShareEditViewModel(contactUris, contactRepository)); } } } diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java index addc7c738..2830f0b2c 100644 --- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -508,7 +508,7 @@ public class AttachmentManager { } public enum MediaType { - IMAGE, GIF, AUDIO, VIDEO, DOCUMENT; + IMAGE, GIF, AUDIO, VIDEO, DOCUMENT, VCARD; public @NonNull Slide createSlide(@NonNull Context context, @NonNull Uri uri, @@ -527,6 +527,7 @@ public class AttachmentManager { case GIF: return new GifSlide(context, uri, dataSize, width, height); case AUDIO: return new AudioSlide(context, uri, dataSize, false); case VIDEO: return new VideoSlide(context, uri, dataSize); + case VCARD: case DOCUMENT: return new DocumentSlide(context, uri, mimeType, dataSize, fileName); default: throw new AssertionError("unrecognized enum"); } @@ -538,6 +539,7 @@ public class AttachmentManager { if (MediaUtil.isImageType(mimeType)) return IMAGE; if (MediaUtil.isAudioType(mimeType)) return AUDIO; if (MediaUtil.isVideoType(mimeType)) return VIDEO; + if (MediaUtil.isVcard(mimeType)) return VCARD; return DOCUMENT; } diff --git a/src/org/thoughtcrime/securesms/util/MediaUtil.java b/src/org/thoughtcrime/securesms/util/MediaUtil.java index f3d2ce49c..0b1cf1361 100644 --- a/src/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/src/org/thoughtcrime/securesms/util/MediaUtil.java @@ -44,6 +44,7 @@ public class MediaUtil { public static final String AUDIO_AAC = "audio/aac"; public static final String AUDIO_UNSPECIFIED = "audio/*"; public static final String VIDEO_UNSPECIFIED = "video/*"; + public static final String VCARD = "text/x-vcard"; public static Slide getSlideForAttachment(Context context, Attachment attachment) { @@ -196,6 +197,10 @@ public class MediaUtil { return !TextUtils.isEmpty(contentType) && contentType.trim().startsWith("video/"); } + public static boolean isVcard(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(VCARD); + } + public static boolean isGif(String contentType) { return !TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif"); }