Merge pull request #1536 from Naveen3Singh/vcf_exporter

Add VcfExporter and some reusable Contact classes from Simple contacts
This commit is contained in:
Tibor Kaputa 2022-11-02 20:07:13 +01:00 committed by GitHub
commit ba8ba0fd67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 520 additions and 22 deletions

View file

@ -1,7 +1,7 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
apply plugin: "kotlin-android-extensions"
apply plugin: "kotlin-kapt"
android {
compileSdkVersion propCompileSdkVersion
@ -14,35 +14,40 @@ android {
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
consumerProguardFiles 'proguard-rules.pro'
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
consumerProguardFiles "proguard-rules.pro"
}
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
main.java.srcDirs += "src/main/kotlin"
}
}
dependencies {
implementation 'com.andrognito.patternlockview:patternlockview:1.0.0'
implementation "com.andrognito.patternlockview:patternlockview:1.0.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.documentfile:documentfile:1.0.1"
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'
api 'com.github.tibbi:reprint:2cb206415d'
api 'androidx.core:core-ktx:1.8.0'
api 'androidx.appcompat:appcompat:1.5.0'
api 'com.google.android.material:material:1.6.1'
api 'com.google.code.gson:gson:2.9.1'
api 'com.duolingo.open:rtl-viewpager:2.0.0'
api "joda-time:joda-time:2.11.0"
api "com.github.tibbi:RecyclerView-FastScroller:5a95285b1f"
api "com.github.tibbi:reprint:2cb206415d"
api "androidx.core:core-ktx:1.8.0"
api "androidx.appcompat:appcompat:1.5.0"
api "com.google.android.material:material:1.6.1"
api "com.google.code.gson:gson:2.9.1"
api "com.duolingo.open:rtl-viewpager:2.0.0"
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 "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"
}

View file

@ -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()
}
}

View file

@ -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

View file

@ -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<Contact>,
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<VCard>()
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
}
}

View file

@ -0,0 +1,3 @@
package com.simplemobiletools.commons.models.contacts
data class Address(var value: String, var type: Int, var label: String)

View file

@ -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<PhoneNumber>,
var emails: ArrayList<Email>,
var addresses: ArrayList<Address>,
var events: ArrayList<Event>,
var source: String,
var starred: Int,
var contactId: Int,
var thumbnailUri: String,
var photo: Bitmap?,
var notes: String,
var groups: ArrayList<Group>,
var organization: Organization,
var websites: ArrayList<String>,
var IMs: ArrayList<IM>,
var mimetype: String,
var ringtone: String?
) : Comparable<Contact> {
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()
}

View file

@ -0,0 +1,3 @@
package com.simplemobiletools.commons.models.contacts
data class Email(var value: String, var type: Int, var label: String)

View file

@ -0,0 +1,3 @@
package com.simplemobiletools.commons.models.contacts
data class Event(var value: String, var type: Int)

View file

@ -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
}

View file

@ -0,0 +1,3 @@
package com.simplemobiletools.commons.models.contacts
data class IM(var value: String, var type: Int, var label: String)

View file

@ -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()
}