diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a92d320..e6976962e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Setting for vCard Export Version ([#344]) ## [1.6.0] - 2026-01-30 ### Added @@ -124,6 +126,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#289]: https://github.com/FossifyOrg/Contacts/issues/289 [#321]: https://github.com/FossifyOrg/Contacts/issues/321 [#339]: https://github.com/FossifyOrg/Contacts/issues/339 +[#344]: https://github.com/FossifyOrg/Contacts/issues/344 [#360]: https://github.com/FossifyOrg/Contacts/issues/360 [#415]: https://github.com/FossifyOrg/Contacts/issues/415 [#452]: https://github.com/FossifyOrg/Contacts/issues/452 diff --git a/app/src/main/kotlin/org/fossify/contacts/activities/SettingsActivity.kt b/app/src/main/kotlin/org/fossify/contacts/activities/SettingsActivity.kt index e937a355d..e878a8476 100644 --- a/app/src/main/kotlin/org/fossify/contacts/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/contacts/activities/SettingsActivity.kt @@ -14,6 +14,7 @@ import org.fossify.contacts.R import org.fossify.contacts.databinding.ActivitySettingsBinding import org.fossify.contacts.dialogs.ExportContactsDialog import org.fossify.contacts.dialogs.ManageAutoBackupsDialog +import org.fossify.contacts.dialogs.ManageVCardVersionDialog import org.fossify.contacts.dialogs.ManageVisibleFieldsDialog import org.fossify.contacts.dialogs.ManageVisibleTabsDialog import org.fossify.contacts.extensions.* @@ -64,6 +65,7 @@ class SettingsActivity : SimpleActivity() { setupManageAutomaticBackups() setupExportContacts() setupImportContacts() + setupVCardVersion() updateTextColors(binding.settingsHolder) arrayOf( @@ -304,6 +306,12 @@ class SettingsActivity : SimpleActivity() { } } + private fun setupVCardVersion() { + binding.vcardVersionHolder.setOnClickListener { + ManageVCardVersionDialog(this) + } + } + private fun tryImportContacts() { if (isQPlus()) { Intent(Intent.ACTION_GET_CONTENT).apply { diff --git a/app/src/main/kotlin/org/fossify/contacts/dialogs/ManageVCardVersionDialog.kt b/app/src/main/kotlin/org/fossify/contacts/dialogs/ManageVCardVersionDialog.kt new file mode 100644 index 000000000..d0ca3d648 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/contacts/dialogs/ManageVCardVersionDialog.kt @@ -0,0 +1,41 @@ +package org.fossify.contacts.dialogs + +import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.contacts.R +import org.fossify.contacts.extensions.config +import org.fossify.contacts.helpers.DEFAULT_VCARD_VERSION + +class ManageVCardVersionDialog(val activity: BaseSimpleActivity) { + private var view = activity.layoutInflater.inflate(R.layout.dialog_vcard_version, null) + private val versionRadioButtons = LinkedHashMap() + + init { + versionRadioButtons.apply { + put("2.1", R.id.manage_vcard_version_2_1) + put("3", R.id.manage_vcard_version_3) + put("4", R.id.manage_vcard_version_4) + } + + val currentVersion = activity.config.vCardVersion + versionRadioButtons[currentVersion]?.let { radioButtonId -> + view.findViewById(radioButtonId).isChecked = true + } + + activity.getAlertDialogBuilder() + .setPositiveButton(org.fossify.commons.R.string.ok) { dialog, which -> dialogConfirmed() } + .setNegativeButton(org.fossify.commons.R.string.cancel, null) + .apply { + activity.setupDialogStuff(view, this) + } + } + + private fun dialogConfirmed() { + val selectedVersion = versionRadioButtons.entries.find { entry -> + view.findViewById(entry.value).isChecked + }?.key ?: DEFAULT_VCARD_VERSION + + activity.config.vCardVersion = selectedVersion + } +} diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/CardPropertyAdderAdditional.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/CardPropertyAdderAdditional.kt new file mode 100644 index 000000000..0a3b5957a --- /dev/null +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/CardPropertyAdderAdditional.kt @@ -0,0 +1,46 @@ +package org.fossify.contacts.helpers + +import android.content.Context +import androidx.core.net.toUri +import ezvcard.VCard +import ezvcard.parameter.ImageType +import ezvcard.property.Organization +import ezvcard.property.Title +import ezvcard.property.Photo +import ezvcard.property.Categories +import org.fossify.commons.models.contacts.Contact + +object CardPropertyAdderAdditional { + fun addProperties(context: Context, card: VCard, contact: Contact) { + addOrganization(card, contact) + addWebsites(card, contact) + addPhoto(context, card, contact) + addGroups(card, contact) + } + + private fun addOrganization(card: VCard, contact: Contact) { + if (contact.organization.isNotEmpty()) { + val org = Organization().apply { values.add(contact.organization.company) } + card.organization = org + card.titles.add(Title(contact.organization.jobPosition)) + } + } + + private fun addWebsites(card: VCard, contact: Contact) { + contact.websites.forEach { card.addUrl(it) } + } + + private fun addPhoto(context: Context, card: VCard, contact: Contact) { + try { + context.contentResolver.openInputStream(contact.photoUri.toUri())?.use { + card.addPhoto(Photo(it.readBytes(), ImageType.JPEG)) + } + } catch (_: Exception) {} + } + + private fun addGroups(card: VCard, contact: Contact) { + if (contact.groups.isNotEmpty()) { + card.categories = Categories().apply { contact.groups.forEach { values.add(it.title) } } + } + } +} diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/CardPropertyAdderMain.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/CardPropertyAdderMain.kt new file mode 100644 index 000000000..ce61e2695 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/CardPropertyAdderMain.kt @@ -0,0 +1,115 @@ +package org.fossify.contacts.helpers + +import android.content.Context +import android.provider.ContactsContract.CommonDataKinds.Event +import android.provider.ContactsContract.CommonDataKinds.Im +import ezvcard.VCard +import ezvcard.property.Telephone +import ezvcard.property.Email +import ezvcard.property.Birthday +import ezvcard.property.Anniversary +import ezvcard.util.PartialDate +import org.fossify.commons.extensions.getDateTimeFromDateString +import org.fossify.commons.models.contacts.Contact +import java.time.LocalDate + +object CardPropertyAdderMain { + fun addProperties(card: VCard, contact: Contact) { + addPhoneNumbers(card, contact) + addEmails(card, contact) + addEvents(card, contact) + addAddress(card, contact) + addIMs(card, contact) + addNotes(card, contact) + } + + private fun addPhoneNumbers(card: VCard, contact: Contact) { + contact.phoneNumbers.forEach { + val phone = Telephone(it.value).apply { + parameters.addType(TypeLabelMapper.getPhoneNumberTypeLabel(it.type, it.label)) + if (it.isPrimary) parameters.addType(TypeLabelMapper.getPreferredType(1)) + } + card.addTelephoneNumber(phone) + } + } + + private fun addEmails(card: VCard, contact: Contact) { + contact.emails.forEach { + card.addEmail(Email(it.value).apply { + parameters.addType(TypeLabelMapper.getEmailTypeLabel(it.type, it.label)) + }) + } + } + + private fun addEvents(card: VCard, contact: Contact) { + contact.events + .filter { it.type == Event.TYPE_BIRTHDAY || it.type == Event.TYPE_ANNIVERSARY } + .forEach { addEventToCard(card, it) } + } + + private fun addEventToCard(card: VCard, event: org.fossify.commons.models.contacts.Event) { + val dateTime = event.value.getDateTimeFromDateString(false) + val isBirthday = event.type == Event.TYPE_BIRTHDAY + + if (event.value.startsWith("--")) { + val partial = PartialDate.builder() + .month(dateTime.monthOfYear) + .date(dateTime.dayOfMonth) + .build() + if (isBirthday) card.birthdays.add(Birthday(partial)) + else card.anniversaries.add(Anniversary(partial)) + return + } + + val date = LocalDate.of(dateTime.year, dateTime.monthOfYear, dateTime.dayOfMonth) + if (isBirthday) card.birthdays.add(Birthday(date)) + else card.anniversaries.add(Anniversary(date)) + } + + private fun addAddress(card: VCard, contact: Contact) { + contact.addresses.forEach { addr -> + val address = ezvcard.property.Address().apply { + if (listOf( + addr.country, + addr.region, + addr.city, + addr.postcode, + addr.pobox, + addr.street, + addr.neighborhood) + .any { it.isNotEmpty() }) { + country = addr.country + region = addr.region + locality = addr.city + postalCode = addr.postcode + poBox = addr.pobox + streetAddress = addr.street + extendedAddress = addr.neighborhood + } else streetAddress = addr.value + parameters.addType(TypeLabelMapper.getAddressTypeLabel(addr.type, addr.label)) + } + card.addAddress(address) + } + } + + private fun addIMs(card: VCard, contact: Contact) { + contact.IMs.forEach { + val impp = when (it.type) { + Im.PROTOCOL_AIM -> ezvcard.property.Impp.aim(it.value) + Im.PROTOCOL_YAHOO -> ezvcard.property.Impp.yahoo(it.value) + Im.PROTOCOL_MSN -> ezvcard.property.Impp.msn(it.value) + Im.PROTOCOL_ICQ -> ezvcard.property.Impp.icq(it.value) + Im.PROTOCOL_SKYPE -> ezvcard.property.Impp.skype(it.value) + Im.PROTOCOL_GOOGLE_TALK -> ezvcard.property.Impp(HANGOUTS, it.value) + Im.PROTOCOL_QQ -> ezvcard.property.Impp(QQ, it.value) + Im.PROTOCOL_JABBER -> ezvcard.property.Impp(JABBER, it.value) + else -> ezvcard.property.Impp(it.label, it.value) + } + card.addImpp(impp) + } + } + + private fun addNotes(card: VCard, contact: Contact) { + if (contact.notes.isNotEmpty()) card.addNote(contact.notes) + } +} diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/Config.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/Config.kt index 5be4a497f..04cc56a83 100644 --- a/app/src/main/kotlin/org/fossify/contacts/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/Config.kt @@ -18,4 +18,7 @@ class Config(context: Context) : BaseConfig(context) { set(autoBackupContactSources) = prefs.edit().remove(AUTO_BACKUP_CONTACT_SOURCES).putStringSet(AUTO_BACKUP_CONTACT_SOURCES, autoBackupContactSources) .apply() + var vCardVersion: String + get() = prefs.getString(VCARD_VERSION, DEFAULT_VCARD_VERSION) ?: DEFAULT_VCARD_VERSION + set(vcardVersion) = prefs.edit().putString(VCARD_VERSION, vcardVersion).apply() } diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/Constants.kt index c6e8c5a45..e88a5ca61 100644 --- a/app/src/main/kotlin/org/fossify/contacts/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/Constants.kt @@ -58,7 +58,8 @@ const val SIGNAL = "signal" const val VIBER = "viber" const val TELEGRAM = "telegram" const val THREEMA = "threema" - +const val VCARD_VERSION = "4" +const val DEFAULT_VCARD_VERSION = "4" // 6 am is the hardcoded automatic backup time, intervals shorter than 1 day are not yet supported. fun getNextAutoBackupTime(): DateTime { diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/TypeLabelMapper.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/TypeLabelMapper.kt new file mode 100644 index 000000000..9b8a15fbc --- /dev/null +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/TypeLabelMapper.kt @@ -0,0 +1,32 @@ +package org.fossify.contacts.helpers + +object TypeLabelMapper { + fun getPhoneNumberTypeLabel(type: Int, label: String) = when (type) { + android.provider.ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE -> "CELL" + android.provider.ContactsContract.CommonDataKinds.Phone.TYPE_HOME -> "HOME" + android.provider.ContactsContract.CommonDataKinds.Phone.TYPE_WORK -> "WORK" + android.provider.ContactsContract.CommonDataKinds.Phone.TYPE_MAIN -> "MAIN" + android.provider.ContactsContract.CommonDataKinds.Phone.TYPE_FAX_WORK -> "WORK_FAX" + android.provider.ContactsContract.CommonDataKinds.Phone.TYPE_FAX_HOME -> "HOME_FAX" + android.provider.ContactsContract.CommonDataKinds.Phone.TYPE_PAGER -> "PAGER" + android.provider.ContactsContract.CommonDataKinds.Phone.TYPE_OTHER -> "OTHER" + else -> label + } + + fun getEmailTypeLabel(type: Int, label: String) = when (type) { + android.provider.ContactsContract.CommonDataKinds.Email.TYPE_HOME -> "HOME" + android.provider.ContactsContract.CommonDataKinds.Email.TYPE_WORK -> "WORK" + android.provider.ContactsContract.CommonDataKinds.Email.TYPE_MOBILE -> "MOBILE" + android.provider.ContactsContract.CommonDataKinds.Email.TYPE_OTHER -> "OTHER" + else -> label + } + + fun getAddressTypeLabel(type: Int, label: String) = when (type) { + android.provider.ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME -> "HOME" + android.provider.ContactsContract.CommonDataKinds.StructuredPostal.TYPE_WORK -> "WORK" + android.provider.ContactsContract.CommonDataKinds.StructuredPostal.TYPE_OTHER -> "OTHER" + else -> label + } + + fun getPreferredType(value: Int) = "PREF=$value" +} diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/VcfExporter.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/VcfExporter.kt index bfea1cccd..e88c494c8 100644 --- a/app/src/main/kotlin/org/fossify/contacts/helpers/VcfExporter.kt +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/VcfExporter.kt @@ -1,30 +1,17 @@ package org.fossify.contacts.helpers import android.content.Context -import android.provider.ContactsContract.CommonDataKinds.Email -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 androidx.core.net.toUri import ezvcard.Ezvcard import ezvcard.VCard import ezvcard.VCardVersion -import ezvcard.parameter.ImageType -import ezvcard.property.* -import ezvcard.util.PartialDate -import org.fossify.commons.extensions.getDateTimeFromDateString +import ezvcard.property.FormattedName import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.toast import org.fossify.commons.models.contacts.Contact -import org.fossify.contacts.helpers.VcfExporter.ExportResult.EXPORT_FAIL import java.io.OutputStream -import java.time.LocalDate class VcfExporter { - enum class ExportResult { - EXPORT_FAIL, EXPORT_OK, EXPORT_PARTIAL - } + enum class ExportResult { EXPORT_FAIL, EXPORT_OK, EXPORT_PARTIAL } private var contactsExported = 0 private var contactsFailed = 0 @@ -34,176 +21,28 @@ class VcfExporter { outputStream: OutputStream?, contacts: ArrayList, showExportingToast: Boolean, - version: VCardVersion = VCardVersion.V4_0, callback: (result: ExportResult) -> Unit, ) { - try { - if (outputStream == null) { - callback(EXPORT_FAIL) - return - } - - if (showExportingToast) { - context.toast(org.fossify.commons.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)) - if (it.isPrimary) { - phoneNumber.parameters.addType(getPreferredType(1)) - } - 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) - if (event.value.startsWith("--")) { - val partial = PartialDate.builder() - .month(dateTime.monthOfYear) - .date(dateTime.dayOfMonth) - .build() - - if (event.type == Event.TYPE_BIRTHDAY) { - card.birthdays.add(Birthday(partial)) - } else { - card.anniversaries.add(Anniversary(partial)) - } - } else { - val date = LocalDate - .of(dateTime.year, dateTime.monthOfYear, dateTime.dayOfMonth) - - if (event.type == Event.TYPE_BIRTHDAY) { - card.birthdays.add(Birthday(date)) - } else { - card.anniversaries.add(Anniversary(date)) - } - } - } - } - - contact.addresses.forEach { - val address = Address() - if ( - listOf( - it.country, - it.region, - it.city, - it.postcode, - it.pobox, - it.street, - it.neighborhood - ) - .map { it.isEmpty() } - .fold(false) { a, b -> a || b } - ) { - address.country = it.country - address.region = it.region - address.locality = it.city - address.postalCode = it.postcode - address.poBox = it.pobox - address.streetAddress = it.street - address.extendedAddress = it.neighborhood - } else { - 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) - } - - try { - val inputStream = - context.contentResolver.openInputStream(contact.photoUri.toUri()) - - if (inputStream != null) { - val photoByteArray = inputStream.readBytes() - val photo = Photo(photoByteArray, ImageType.JPEG) - card.addPhoto(photo) - inputStream.close() - } - } catch (e: Exception) { - e.printStackTrace() - } - - if (contact.groups.isNotEmpty()) { - val groupList = Categories() - contact.groups.forEach { - groupList.values.add(it.title) - } + if (outputStream == null) { + callback(ExportResult.EXPORT_FAIL) + return + } - card.categories = groupList - } + if (showExportingToast) { + context.toast(org.fossify.commons.R.string.exporting) + } - cards.add(card) - contactsExported++ - } + val version = getVCardVersion(context) + val cards = contacts.map { contact -> + val card = VCard() + card.formattedName = FormattedName(getFormattedName(contact)) + CardPropertyAdderMain.addProperties(card, contact) + CardPropertyAdderAdditional.addProperties(context, card, contact) + contactsExported++ + card + } + try { Ezvcard.write(cards).version(version).go(outputStream) } catch (e: Exception) { context.showErrorToast(e) @@ -211,39 +50,25 @@ class VcfExporter { callback( when { - contactsExported == 0 -> EXPORT_FAIL + 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 -> MAIN - 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) { - Email.TYPE_HOME -> HOME - Email.TYPE_WORK -> WORK - Email.TYPE_MOBILE -> MOBILE - 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 + private fun getVCardVersion(context: Context): VCardVersion { + val versionString = Config.newInstance(context).vCardVersion + return when (versionString) { + "2.1" -> VCardVersion.V2_1 + "3" -> VCardVersion.V3_0 + "4" -> VCardVersion.V4_0 + else -> VCardVersion.V4_0 + } } - private fun getPreferredType(value: Int) = "$PREF=$value" + private fun getFormattedName(contact: Contact): String = + arrayOf(contact.prefix, contact.firstName, contact.middleName, contact.surname, contact.suffix) + .filter { it.isNotEmpty() } + .joinToString(" ") } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 85d05441e..83a6bd766 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -421,6 +421,21 @@ android:layout_height="wrap_content" android:text="@string/migrating" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a536344f1..2c2574331 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,6 +56,10 @@ Show only contacts with phone numbers Show private contacts to Fossify Phone, Messages, and Calendar Merge duplicate contacts + vCard Version + vCard Version 2.1 + vCard Version 3.0 + vCard Version 4.0 Seems like you haven\'t added any favorite contacts yet.