diff --git a/commons/build.gradle b/commons/build.gradle index 052455789..bd6dc282a 100644 --- a/commons/build.gradle +++ b/commons/build.gradle @@ -32,6 +32,7 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation "androidx.exifinterface:exifinterface:1.3.3" implementation "androidx.biometric:biometric-ktx:1.2.0-alpha04" + implementation "com.googlecode.ez-vcard:ez-vcard:0.11.3" api 'joda-time:joda-time:2.11.0' api 'com.github.tibbi:RecyclerView-FastScroller:5a95285b1f' @@ -45,4 +46,8 @@ dependencies { api 'com.github.bumptech.glide:glide:4.13.2' kapt 'com.github.bumptech.glide:compiler:4.13.2' annotationProcessor 'com.github.bumptech.glide:compiler:4.13.2' + + api "androidx.room:room-runtime:2.4.3" + kapt "androidx.room:room-compiler:2.4.3" + annotationProcessor "androidx.room:room-compiler:2.4.3" } diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/extensions/Bitmap.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/extensions/Bitmap.kt new file mode 100644 index 000000000..9034b6bef --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/extensions/Bitmap.kt @@ -0,0 +1,15 @@ +package com.simplemobiletools.commons.extensions + +import android.graphics.Bitmap +import java.io.ByteArrayOutputStream + +fun Bitmap.getByteArray(): ByteArray { + var baos: ByteArrayOutputStream? = null + try { + baos = ByteArrayOutputStream() + compress(Bitmap.CompressFormat.JPEG, 80, baos) + return baos.toByteArray() + } finally { + baos?.close() + } +} diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/helpers/Constants.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/helpers/Constants.kt index e730e0a9c..16cc0140f 100644 --- a/commons/src/main/kotlin/com/simplemobiletools/commons/helpers/Constants.kt +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/helpers/Constants.kt @@ -38,6 +38,8 @@ const val KEY_PHONE = "phone" const val KEY_MAILTO = "mailto" const val CONTACT_ID = "contact_id" const val IS_PRIVATE = "is_private" +const val SMT_PRIVATE = "smt_private" // used at the contact source of local contacts hidden from other apps +const val FIRST_GROUP_ID = 10000L const val MD5 = "MD5" const val SHORT_ANIMATION_DURATION = 150L val DARK_GREY = 0xFF333333.toInt() @@ -163,6 +165,24 @@ const val FAVORITES = "favorites" const val SHOW_CALL_CONFIRMATION = "show_call_confirmation" internal const val COLOR_PICKER_RECENT_COLORS = "color_picker_recent_colors" +// phone number/email types +const val CELL = "CELL" +const val WORK = "WORK" +const val HOME = "HOME" +const val OTHER = "OTHER" +const val PREF = "PREF" +const val MAIN = "MAIN" +const val FAX = "FAX" +const val WORK_FAX = "WORK;FAX" +const val HOME_FAX = "HOME;FAX" +const val PAGER = "PAGER" +const val MOBILE = "MOBILE" + +// IMs not supported by Ez-vcard +const val HANGOUTS = "Hangouts" +const val QQ = "QQ" +const val JABBER = "Jabber" + // licenses internal const val LICENSE_KOTLIN = 1L const val LICENSE_SUBSAMPLING = 2L diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/helpers/VcfExporter.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/helpers/VcfExporter.kt new file mode 100644 index 000000000..65a9a42d8 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/helpers/VcfExporter.kt @@ -0,0 +1,203 @@ +package com.simplemobiletools.commons.helpers + +import android.net.Uri +import android.provider.ContactsContract.CommonDataKinds +import android.provider.ContactsContract.CommonDataKinds.Event +import android.provider.ContactsContract.CommonDataKinds.Im +import android.provider.ContactsContract.CommonDataKinds.Phone +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal +import android.provider.MediaStore +import com.simplemobiletools.commons.R +import com.simplemobiletools.commons.activities.BaseSimpleActivity +import com.simplemobiletools.commons.extensions.getByteArray +import com.simplemobiletools.commons.extensions.getDateTimeFromDateString +import com.simplemobiletools.commons.extensions.showErrorToast +import com.simplemobiletools.commons.extensions.toast +import com.simplemobiletools.commons.models.contacts.Contact +import ezvcard.Ezvcard +import ezvcard.VCard +import ezvcard.VCardVersion +import ezvcard.parameter.ImageType +import ezvcard.property.* +import java.io.OutputStream +import java.util.* + +class VcfExporter { + enum class ExportResult { + EXPORT_FAIL, EXPORT_OK, EXPORT_PARTIAL + } + + private var contactsExported = 0 + private var contactsFailed = 0 + + fun exportContacts( + activity: BaseSimpleActivity, + outputStream: OutputStream?, + contacts: ArrayList, + showExportingToast: Boolean, + callback: (result: ExportResult) -> Unit + ) { + try { + if (outputStream == null) { + callback(ExportResult.EXPORT_FAIL) + return + } + + if (showExportingToast) { + activity.toast(R.string.exporting) + } + + val cards = ArrayList() + for (contact in contacts) { + val card = VCard() + + val formattedName = arrayOf(contact.prefix, contact.firstName, contact.middleName, contact.surname, contact.suffix) + .filter { it.isNotEmpty() } + .joinToString(separator = " ") + card.formattedName = FormattedName(formattedName) + + StructuredName().apply { + prefixes.add(contact.prefix) + given = contact.firstName + additionalNames.add(contact.middleName) + family = contact.surname + suffixes.add(contact.suffix) + card.structuredName = this + } + + if (contact.nickname.isNotEmpty()) { + card.setNickname(contact.nickname) + } + + contact.phoneNumbers.forEach { + val phoneNumber = Telephone(it.value) + phoneNumber.parameters.addType(getPhoneNumberTypeLabel(it.type, it.label)) + card.addTelephoneNumber(phoneNumber) + } + + contact.emails.forEach { + val email = Email(it.value) + email.parameters.addType(getEmailTypeLabel(it.type, it.label)) + card.addEmail(email) + } + + contact.events.forEach { event -> + if (event.type == Event.TYPE_ANNIVERSARY || event.type == Event.TYPE_BIRTHDAY) { + val dateTime = event.value.getDateTimeFromDateString(false) + Calendar.getInstance().apply { + clear() + if (event.value.startsWith("--")) { + set(Calendar.YEAR, 1900) + } else { + set(Calendar.YEAR, dateTime.year) + + } + set(Calendar.MONTH, dateTime.monthOfYear - 1) + set(Calendar.DAY_OF_MONTH, dateTime.dayOfMonth) + if (event.type == Event.TYPE_BIRTHDAY) { + card.birthdays.add(Birthday(time)) + } else { + card.anniversaries.add(Anniversary(time)) + } + } + } + } + + contact.addresses.forEach { + val address = Address() + address.streetAddress = it.value + address.parameters.addType(getAddressTypeLabel(it.type, it.label)) + card.addAddress(address) + } + + contact.IMs.forEach { + val impp = when (it.type) { + Im.PROTOCOL_AIM -> Impp.aim(it.value) + Im.PROTOCOL_YAHOO -> Impp.yahoo(it.value) + Im.PROTOCOL_MSN -> Impp.msn(it.value) + Im.PROTOCOL_ICQ -> Impp.icq(it.value) + Im.PROTOCOL_SKYPE -> Impp.skype(it.value) + Im.PROTOCOL_GOOGLE_TALK -> Impp(HANGOUTS, it.value) + Im.PROTOCOL_QQ -> Impp(QQ, it.value) + Im.PROTOCOL_JABBER -> Impp(JABBER, it.value) + else -> Impp(it.label, it.value) + } + + card.addImpp(impp) + } + + if (contact.notes.isNotEmpty()) { + card.addNote(contact.notes) + } + + if (contact.organization.isNotEmpty()) { + val organization = Organization() + organization.values.add(contact.organization.company) + card.organization = organization + card.titles.add(Title(contact.organization.jobPosition)) + } + + contact.websites.forEach { + card.addUrl(it) + } + + if (contact.thumbnailUri.isNotEmpty()) { + val photoByteArray = MediaStore.Images.Media.getBitmap(activity.contentResolver, Uri.parse(contact.thumbnailUri)).getByteArray() + val photo = Photo(photoByteArray, ImageType.JPEG) + card.addPhoto(photo) + } + + if (contact.groups.isNotEmpty()) { + val groupList = Categories() + contact.groups.forEach { + groupList.values.add(it.title) + } + + card.categories = groupList + } + + cards.add(card) + contactsExported++ + } + + Ezvcard.write(cards).version(VCardVersion.V4_0).go(outputStream) + } catch (e: Exception) { + activity.showErrorToast(e) + } + + callback( + when { + contactsExported == 0 -> ExportResult.EXPORT_FAIL + contactsFailed > 0 -> ExportResult.EXPORT_PARTIAL + else -> ExportResult.EXPORT_OK + } + ) + } + + private fun getPhoneNumberTypeLabel(type: Int, label: String) = when (type) { + Phone.TYPE_MOBILE -> CELL + Phone.TYPE_HOME -> HOME + Phone.TYPE_WORK -> WORK + Phone.TYPE_MAIN -> PREF + Phone.TYPE_FAX_WORK -> WORK_FAX + Phone.TYPE_FAX_HOME -> HOME_FAX + Phone.TYPE_PAGER -> PAGER + Phone.TYPE_OTHER -> OTHER + else -> label + } + + private fun getEmailTypeLabel(type: Int, label: String) = when (type) { + CommonDataKinds.Email.TYPE_HOME -> HOME + CommonDataKinds.Email.TYPE_WORK -> WORK + CommonDataKinds.Email.TYPE_MOBILE -> MOBILE + CommonDataKinds.Email.TYPE_OTHER -> OTHER + else -> label + } + + private fun getAddressTypeLabel(type: Int, label: String) = when (type) { + StructuredPostal.TYPE_HOME -> HOME + StructuredPostal.TYPE_WORK -> WORK + StructuredPostal.TYPE_OTHER -> OTHER + else -> label + } +} diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Address.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Address.kt new file mode 100644 index 000000000..17b42faf3 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Address.kt @@ -0,0 +1,3 @@ +package com.simplemobiletools.commons.models.contacts + +data class Address(var value: String, var type: Int, var label: String) diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Contact.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Contact.kt new file mode 100644 index 000000000..1d7c6d77f --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Contact.kt @@ -0,0 +1,214 @@ +package com.simplemobiletools.commons.models.contacts + +import android.graphics.Bitmap +import android.telephony.PhoneNumberUtils +import com.simplemobiletools.commons.extensions.normalizePhoneNumber +import com.simplemobiletools.commons.extensions.normalizeString +import com.simplemobiletools.commons.helpers.* +import com.simplemobiletools.commons.models.PhoneNumber + +data class Contact( + var id: Int, + var prefix: String, + var firstName: String, + var middleName: String, + var surname: String, + var suffix: String, + var nickname: String, + var photoUri: String, + var phoneNumbers: ArrayList, + var emails: ArrayList, + var addresses: ArrayList
, + var events: ArrayList, + var source: String, + var starred: Int, + var contactId: Int, + var thumbnailUri: String, + var photo: Bitmap?, + var notes: String, + var groups: ArrayList, + var organization: Organization, + var websites: ArrayList, + var IMs: ArrayList, + var mimetype: String, + var ringtone: String? +) : Comparable { + companion object { + var sorting = 0 + var startWithSurname = false + } + + override fun compareTo(other: Contact): Int { + var result = when { + sorting and SORT_BY_FIRST_NAME != 0 -> { + val firstString = firstName.normalizeString() + val secondString = other.firstName.normalizeString() + compareUsingStrings(firstString, secondString, other) + } + sorting and SORT_BY_MIDDLE_NAME != 0 -> { + val firstString = middleName.normalizeString() + val secondString = other.middleName.normalizeString() + compareUsingStrings(firstString, secondString, other) + } + sorting and SORT_BY_SURNAME != 0 -> { + val firstString = surname.normalizeString() + val secondString = other.surname.normalizeString() + compareUsingStrings(firstString, secondString, other) + } + sorting and SORT_BY_FULL_NAME != 0 -> { + val firstString = getNameToDisplay().normalizeString() + val secondString = other.getNameToDisplay().normalizeString() + compareUsingStrings(firstString, secondString, other) + } + else -> compareUsingIds(other) + } + + if (sorting and SORT_DESCENDING != 0) { + result *= -1 + } + + return result + } + + private fun compareUsingStrings(firstString: String, secondString: String, other: Contact): Int { + var firstValue = firstString + var secondValue = secondString + + if (firstValue.isEmpty() && firstName.isEmpty() && middleName.isEmpty() && surname.isEmpty()) { + val fullCompany = getFullCompany() + if (fullCompany.isNotEmpty()) { + firstValue = fullCompany.normalizeString() + } else if (emails.isNotEmpty()) { + firstValue = emails.first().value + } + } + + if (secondValue.isEmpty() && other.firstName.isEmpty() && other.middleName.isEmpty() && other.surname.isEmpty()) { + val otherFullCompany = other.getFullCompany() + if (otherFullCompany.isNotEmpty()) { + secondValue = otherFullCompany.normalizeString() + } else if (other.emails.isNotEmpty()) { + secondValue = other.emails.first().value + } + } + + return if (firstValue.firstOrNull()?.isLetter() == true && secondValue.firstOrNull()?.isLetter() == false) { + -1 + } else if (firstValue.firstOrNull()?.isLetter() == false && secondValue.firstOrNull()?.isLetter() == true) { + 1 + } else { + if (firstValue.isEmpty() && secondValue.isNotEmpty()) { + 1 + } else if (firstValue.isNotEmpty() && secondValue.isEmpty()) { + -1 + } else { + if (firstValue.equals(secondValue, ignoreCase = true)) { + getNameToDisplay().compareTo(other.getNameToDisplay(), true) + } else { + firstValue.compareTo(secondValue, true) + } + } + } + } + + private fun compareUsingIds(other: Contact): Int { + val firstId = id + val secondId = other.id + return firstId.compareTo(secondId) + } + + fun getBubbleText() = when { + sorting and SORT_BY_FIRST_NAME != 0 -> firstName + sorting and SORT_BY_MIDDLE_NAME != 0 -> middleName + else -> surname + } + + fun getNameToDisplay(): String { + val firstMiddle = "$firstName $middleName".trim() + val firstPart = if (startWithSurname) { + if (surname.isNotEmpty() && firstMiddle.isNotEmpty()) { + "$surname," + } else { + surname + } + } else { + firstMiddle + } + val lastPart = if (startWithSurname) firstMiddle else surname + val suffixComma = if (suffix.isEmpty()) "" else ", $suffix" + val fullName = "$prefix $firstPart $lastPart$suffixComma".trim() + return if (fullName.isEmpty()) { + if (organization.isNotEmpty()) { + getFullCompany() + } else { + emails.firstOrNull()?.value?.trim() ?: "" + } + } else { + fullName + } + } + + // photos stored locally always have different hashcodes. Avoid constantly refreshing the contact lists as the app thinks something changed. + fun getHashWithoutPrivatePhoto(): Int { + val photoToUse = if (isPrivate()) null else photo + return copy(photo = photoToUse).hashCode() + } + + fun getStringToCompare(): String { + val photoToUse = if (isPrivate()) null else photo + return copy( + id = 0, + prefix = "", + firstName = getNameToDisplay().toLowerCase(), + middleName = "", + surname = "", + suffix = "", + nickname = "", + photoUri = "", + phoneNumbers = ArrayList(), + emails = ArrayList(), + events = ArrayList(), + source = "", + addresses = ArrayList(), + starred = 0, + contactId = 0, + thumbnailUri = "", + photo = photoToUse, + notes = "", + groups = ArrayList(), + websites = ArrayList(), + organization = Organization("", ""), + IMs = ArrayList(), + ringtone = "" + ).toString() + } + + fun getHashToCompare() = getStringToCompare().hashCode() + + fun getFullCompany(): String { + var fullOrganization = if (organization.company.isEmpty()) "" else "${organization.company}, " + fullOrganization += organization.jobPosition + return fullOrganization.trim().trimEnd(',') + } + + fun isABusinessContact() = + prefix.isEmpty() && firstName.isEmpty() && middleName.isEmpty() && surname.isEmpty() && suffix.isEmpty() && organization.isNotEmpty() + + fun doesContainPhoneNumber(text: String, convertLetters: Boolean): Boolean { + return if (text.isNotEmpty()) { + val normalizedText = if (convertLetters) text.normalizePhoneNumber() else text + phoneNumbers.any { + PhoneNumberUtils.compare(it.normalizedNumber, normalizedText) || + it.value.contains(text) || + it.normalizedNumber.contains(normalizedText) || + it.value.normalizePhoneNumber().contains(normalizedText) + } + } else { + false + } + } + + fun isPrivate() = source == SMT_PRIVATE + + fun getSignatureKey() = if (photoUri.isNotEmpty()) photoUri else hashCode() +} diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Email.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Email.kt new file mode 100644 index 000000000..f3a163706 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Email.kt @@ -0,0 +1,3 @@ +package com.simplemobiletools.commons.models.contacts + +data class Email(var value: String, var type: Int, var label: String) diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Event.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Event.kt new file mode 100644 index 000000000..b0c9ec9e2 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Event.kt @@ -0,0 +1,3 @@ +package com.simplemobiletools.commons.models.contacts + +data class Event(var value: String, var type: Int) diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Group.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Group.kt new file mode 100644 index 000000000..4bd797d6e --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Group.kt @@ -0,0 +1,22 @@ +package com.simplemobiletools.commons.models.contacts + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.simplemobiletools.commons.helpers.FIRST_GROUP_ID +import java.io.Serializable + +@Entity(tableName = "groups", indices = [(Index(value = ["id"], unique = true))]) +data class Group( + @PrimaryKey(autoGenerate = true) var id: Long?, + @ColumnInfo(name = "title") var title: String, + @ColumnInfo(name = "contacts_count") var contactsCount: Int = 0 +) : Serializable { + + fun addContact() = contactsCount++ + + fun getBubbleText() = title + + fun isPrivateSecretGroup() = id ?: 0 >= FIRST_GROUP_ID +} diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/IM.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/IM.kt new file mode 100644 index 000000000..317500498 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/IM.kt @@ -0,0 +1,3 @@ +package com.simplemobiletools.commons.models.contacts + +data class IM(var value: String, var type: Int, var label: String) diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Organization.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Organization.kt new file mode 100644 index 000000000..a60a7c06d --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/models/contacts/Organization.kt @@ -0,0 +1,7 @@ +package com.simplemobiletools.commons.models.contacts + +data class Organization(var company: String, var jobPosition: String) { + fun isEmpty() = company.isEmpty() && jobPosition.isEmpty() + + fun isNotEmpty() = !isEmpty() +}