From 2bfeae72cf4051925368f2e5c1171da2984a1629 Mon Sep 17 00:00:00 2001 From: fatih ergin Date: Mon, 18 Sep 2023 23:51:34 +0300 Subject: [PATCH] imports contacts helper & extensions --- app/build.gradle.kts | 2 + .../contacts/pro/activities/MainActivity.kt | 74 +- .../pro/activities/ViewContactActivity.kt | 2 + .../pro/extensions/Context-contacts.kt | 400 ++++ .../contacts/pro/extensions/Context.kt | 1237 +++++++++++- .../contacts/pro/helpers/ContactsHelper.kt | 1652 +++++++++++++++++ build.gradle.kts | 2 + gradle/libs.versions.toml | 5 + 8 files changed, 3336 insertions(+), 38 deletions(-) create mode 100644 app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context-contacts.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/ContactsHelper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 227e0992..7d0e7920 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.kotlin.konan.properties.Properties plugins { alias(libs.plugins.android) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlinSerialization) alias(libs.plugins.ksp) } @@ -88,6 +89,7 @@ android { } dependencies { + implementation(libs.kotlinx.serialization.json) implementation(libs.simple.tools.commons) implementation(libs.androidx.swiperefreshlayout) implementation(libs.autofittextview) diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/activities/MainActivity.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/activities/MainActivity.kt index 1a0a3b3b..5e56a839 100644 --- a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/activities/MainActivity.kt @@ -24,6 +24,7 @@ import com.simplemobiletools.commons.dialogs.ConfirmationDialog import com.simplemobiletools.commons.dialogs.FilePickerDialog import com.simplemobiletools.commons.dialogs.RadioGroupDialog import com.simplemobiletools.commons.extensions.* +//import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.* import com.simplemobiletools.commons.models.FAQItem import com.simplemobiletools.commons.models.PhoneNumber @@ -38,11 +39,16 @@ import com.simplemobiletools.contacts.pro.dialogs.ChangeSortingDialog import com.simplemobiletools.contacts.pro.dialogs.ExportContactsDialog import com.simplemobiletools.contacts.pro.dialogs.FilterContactSourcesDialog import com.simplemobiletools.contacts.pro.dialogs.ImportContactsDialog -import com.simplemobiletools.contacts.pro.extensions.config -import com.simplemobiletools.contacts.pro.extensions.handleGenericContactClick +import com.simplemobiletools.contacts.pro.extensions.* +import com.simplemobiletools.contacts.pro.extensions.getTempFile +import com.simplemobiletools.contacts.pro.extensions.isPackageInstalled +import com.simplemobiletools.contacts.pro.extensions.showErrorToast +import com.simplemobiletools.contacts.pro.extensions.toast +import com.simplemobiletools.contacts.pro.extensions.updateBottomTabItemColors import com.simplemobiletools.contacts.pro.fragments.FavoritesFragment import com.simplemobiletools.contacts.pro.fragments.MyViewPagerFragment import com.simplemobiletools.contacts.pro.helpers.ALL_TABS_MASK +import com.simplemobiletools.contacts.pro.helpers.ContactsHelper import com.simplemobiletools.contacts.pro.helpers.VcfExporter import com.simplemobiletools.contacts.pro.helpers.tabsList import com.simplemobiletools.contacts.pro.interfaces.RefreshContactsListener @@ -624,44 +630,48 @@ class MainActivity : SimpleActivity(), RefreshContactsListener { binding.viewPager.currentItem = getDefaultTab() } - val initToLoad = System.currentTimeMillis() - timeInMs - ContactsHelper(this).getContacts { contacts -> - val diff = System.currentTimeMillis() - timeInMs - val msg = "loaded ${contacts.size} in $diff ms (init: $initToLoad + load: ${diff - initToLoad})"; - Log.e("TAGG", msg) - toast(msg) - isGettingContacts = false - if (isDestroyed || isFinishing) { - return@getContacts - } - if (refreshTabsMask and TAB_CONTACTS != 0) { - findViewById>(R.id.contacts_fragment)?.apply { - skipHashComparing = true - refreshContacts(contacts) + Handler(Looper.getMainLooper()).postDelayed({ + val initToLoad = System.currentTimeMillis() - timeInMs + ContactsHelper(this).getContacts { contacts -> + val diff = System.currentTimeMillis() - timeInMs + val msg = "loaded ${contacts.size} in $diff ms (init: $initToLoad + load: ${diff - initToLoad})"; + Log.e("TAGG", msg) + toast(msg) + isGettingContacts = false + if (isDestroyed || isFinishing) { + return@getContacts } - } - if (refreshTabsMask and TAB_FAVORITES != 0) { - findViewById>(R.id.favorites_fragment)?.apply { - skipHashComparing = true - refreshContacts(contacts) - } - } - - if (refreshTabsMask and TAB_GROUPS != 0) { - findViewById>(R.id.groups_fragment)?.apply { - if (refreshTabsMask == TAB_GROUPS) { + if (refreshTabsMask and TAB_CONTACTS != 0) { + findViewById>(R.id.contacts_fragment)?.apply { skipHashComparing = true + refreshContacts(contacts) } - refreshContacts(contacts) + } + + if (refreshTabsMask and TAB_FAVORITES != 0) { + findViewById>(R.id.favorites_fragment)?.apply { + skipHashComparing = true + refreshContacts(contacts) + } + } + + if (refreshTabsMask and TAB_GROUPS != 0) { + findViewById>(R.id.groups_fragment)?.apply { + if (refreshTabsMask == TAB_GROUPS) { + skipHashComparing = true + } + refreshContacts(contacts) + } + } + + if (binding.mainMenu.isSearchOpen) { + getCurrentFragment()?.onSearchQueryChanged(binding.mainMenu.getCurrentQuery()) } } - if (binding.mainMenu.isSearchOpen) { - getCurrentFragment()?.onSearchQueryChanged(binding.mainMenu.getCurrentQuery()) - } - } + }, 3000) } override fun contactClicked(contact: Contact) { diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/activities/ViewContactActivity.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/activities/ViewContactActivity.kt index fc0a3092..4e5ba6c6 100644 --- a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/activities/ViewContactActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/activities/ViewContactActivity.kt @@ -20,6 +20,7 @@ import com.simplemobiletools.commons.dialogs.CallConfirmationDialog import com.simplemobiletools.commons.dialogs.ConfirmationDialog import com.simplemobiletools.commons.dialogs.SelectAlarmSoundDialog import com.simplemobiletools.commons.extensions.* +import com.simplemobiletools.contacts.pro.helpers.* import com.simplemobiletools.commons.helpers.* import com.simplemobiletools.commons.models.PhoneNumber import com.simplemobiletools.commons.models.contacts.* @@ -32,6 +33,7 @@ import com.simplemobiletools.contacts.pro.extensions.editContact import com.simplemobiletools.contacts.pro.extensions.getPackageDrawable import com.simplemobiletools.contacts.pro.extensions.startCallIntent import com.simplemobiletools.contacts.pro.helpers.* +import com.simplemobiletools.contacts.pro.helpers.ContactsHelper class ViewContactActivity : ContactActivity() { private var isViewIntent = false diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context-contacts.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context-contacts.kt new file mode 100644 index 00000000..e034a8d1 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context-contacts.kt @@ -0,0 +1,400 @@ +package com.simplemobiletools.contacts.pro.extensions + +import android.annotation.TargetApi +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.ContactsContract +import android.telephony.PhoneNumberUtils +import com.simplemobiletools.commons.R +import com.simplemobiletools.commons.activities.BaseSimpleActivity +import com.simplemobiletools.commons.databases.ContactsDatabase +import com.simplemobiletools.commons.dialogs.CallConfirmationDialog +import com.simplemobiletools.commons.dialogs.RadioGroupDialog +import com.simplemobiletools.commons.extensions.getIntValue +import com.simplemobiletools.commons.extensions.getLongValue +import com.simplemobiletools.commons.extensions.getStringValue +import com.simplemobiletools.commons.helpers.* +import com.simplemobiletools.commons.interfaces.ContactsDao +import com.simplemobiletools.commons.interfaces.GroupsDao +import com.simplemobiletools.commons.models.RadioItem +import com.simplemobiletools.commons.models.contacts.Contact +import com.simplemobiletools.commons.models.contacts.ContactSource +import com.simplemobiletools.commons.models.contacts.Organization +import com.simplemobiletools.commons.models.contacts.SocialAction +import com.simplemobiletools.contacts.pro.helpers.ContactsHelper +import java.io.File + +val Context.contactsDB: ContactsDao get() = ContactsDatabase.getInstance(applicationContext).ContactsDao() + +val Context.groupsDB: GroupsDao get() = ContactsDatabase.getInstance(applicationContext).GroupsDao() + +fun Context.getEmptyContact(): Contact { + val originalContactSource = if (hasContactPermissions()) baseConfig.lastUsedContactSource else SMT_PRIVATE + val organization = Organization("", "") + return Contact( + 0, "", "", "", "", "", "", "", ArrayList(), ArrayList(), ArrayList(), ArrayList(), originalContactSource, 0, 0, "", + null, "", ArrayList(), organization, ArrayList(), ArrayList(), DEFAULT_MIMETYPE, null + ) +} + +fun Context.sendAddressIntent(address: String) { + val location = Uri.encode(address) + val uri = Uri.parse("geo:0,0?q=$location") + + Intent(Intent.ACTION_VIEW, uri).apply { + launchActivityIntent(this) + } +} + +fun Context.openWebsiteIntent(url: String) { + val website = if (url.startsWith("http")) { + url + } else { + "https://$url" + } + + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(website) + launchActivityIntent(this) + } +} + +fun Context.getLookupUriRawId(dataUri: Uri): Int { + val lookupKey = getLookupKeyFromUri(dataUri) + if (lookupKey != null) { + val uri = lookupContactUri(lookupKey, this) + if (uri != null) { + return getContactUriRawId(uri) + } + } + return -1 +} + +fun Context.getContactUriRawId(uri: Uri): Int { + val projection = arrayOf(ContactsContract.Contacts.NAME_RAW_CONTACT_ID) + var cursor: Cursor? = null + try { + cursor = contentResolver.query(uri, projection, null, null, null) + if (cursor!!.moveToFirst()) { + return cursor.getIntValue(ContactsContract.Contacts.NAME_RAW_CONTACT_ID) + } + } catch (ignored: Exception) { + } finally { + cursor?.close() + } + return -1 +} + +// from https://android.googlesource.com/platform/packages/apps/Dialer/+/68038172793ee0e2ab3e2e56ddfbeb82879d1f58/java/com/android/contacts/common/util/UriUtils.java +fun getLookupKeyFromUri(lookupUri: Uri): String? { + return if (!isEncodedContactUri(lookupUri)) { + val segments = lookupUri.pathSegments + if (segments.size < 3) null else Uri.encode(segments[2]) + } else { + null + } +} + +fun isEncodedContactUri(uri: Uri?): Boolean { + if (uri == null) { + return false + } + val lastPathSegment = uri.lastPathSegment ?: return false + return lastPathSegment == "encoded" +} + +fun lookupContactUri(lookup: String, context: Context): Uri? { + val lookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, lookup) + return try { + ContactsContract.Contacts.lookupContact(context.contentResolver, lookupUri) + } catch (e: Exception) { + null + } +} + +fun Context.getCachePhoto(): File { + val imagesFolder = File(cacheDir, "my_cache") + if (!imagesFolder.exists()) { + imagesFolder.mkdirs() + } + + val file = File(imagesFolder, "Photo_${System.currentTimeMillis()}.jpg") + file.createNewFile() + return file +} + +fun Context.getPhotoThumbnailSize(): Int { + val uri = ContactsContract.DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI + val projection = arrayOf(ContactsContract.DisplayPhoto.THUMBNAIL_MAX_DIM) + var cursor: Cursor? = null + try { + cursor = contentResolver.query(uri, projection, null, null, null) + if (cursor?.moveToFirst() == true) { + return cursor.getIntValue(ContactsContract.DisplayPhoto.THUMBNAIL_MAX_DIM) + } + } catch (ignored: Exception) { + } finally { + cursor?.close() + } + return 0 +} + +fun Context.hasContactPermissions() = hasPermission(PERMISSION_READ_CONTACTS) && hasPermission(PERMISSION_WRITE_CONTACTS) + +fun Context.getPublicContactSource(source: String, callback: (String) -> Unit) { + when (source) { + SMT_PRIVATE -> callback(getString(R.string.phone_storage_hidden)) + else -> { + ContactsHelper(this).getContactSources { + var newSource = source + for (contactSource in it) { + if (contactSource.name == source && contactSource.type == TELEGRAM_PACKAGE) { + newSource = getString(R.string.telegram) + break + } else if (contactSource.name == source && contactSource.type == VIBER_PACKAGE) { + newSource = getString(R.string.viber) + break + } + } + Handler(Looper.getMainLooper()).post { + callback(newSource) + } + } + } + } +} + +fun Context.getPublicContactSourceSync(source: String, contactSources: ArrayList): String { + return when (source) { + SMT_PRIVATE -> getString(R.string.phone_storage_hidden) + else -> { + var newSource = source + for (contactSource in contactSources) { + if (contactSource.name == source && contactSource.type == TELEGRAM_PACKAGE) { + newSource = getString(R.string.telegram) + break + } else if (contactSource.name == source && contactSource.type == VIBER_PACKAGE) { + newSource = getString(R.string.viber) + break + } + } + + return newSource + } + } +} + +fun Context.sendSMSToContacts(contacts: ArrayList) { + val numbers = StringBuilder() + contacts.forEach { + val number = it.phoneNumbers.firstOrNull { it.type == ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE } + ?: it.phoneNumbers.firstOrNull() + if (number != null) { + numbers.append("${Uri.encode(number.value)};") + } + } + + val uriString = "smsto:${numbers.toString().trimEnd(';')}" + Intent(Intent.ACTION_SENDTO, Uri.parse(uriString)).apply { + launchActivityIntent(this) + } +} + +fun Context.sendEmailToContacts(contacts: ArrayList) { + val emails = ArrayList() + contacts.forEach { + it.emails.forEach { + if (it.value.isNotEmpty()) { + emails.add(it.value) + } + } + } + + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_EMAIL, emails.toTypedArray()) + launchActivityIntent(this) + } +} + +fun Context.getTempFile(filename: String = DEFAULT_FILE_NAME): File? { + val folder = File(cacheDir, "contacts") + if (!folder.exists()) { + if (!folder.mkdir()) { + toast(R.string.unknown_error_occurred) + return null + } + } + + return File(folder, filename) +} + +fun Context.addContactsToGroup(contacts: ArrayList, groupId: Long) { + val publicContacts = contacts.filter { !it.isPrivate() }.toMutableList() as ArrayList + val privateContacts = contacts.filter { it.isPrivate() }.toMutableList() as ArrayList + if (publicContacts.isNotEmpty()) { + ContactsHelper(this).addContactsToGroup(publicContacts, groupId) + } + + if (privateContacts.isNotEmpty()) { + LocalContactsHelper(this).addContactsToGroup(privateContacts, groupId) + } +} + +fun Context.removeContactsFromGroup(contacts: ArrayList, groupId: Long) { + val publicContacts = contacts.filter { !it.isPrivate() }.toMutableList() as ArrayList + val privateContacts = contacts.filter { it.isPrivate() }.toMutableList() as ArrayList + if (publicContacts.isNotEmpty() && hasContactPermissions()) { + ContactsHelper(this).removeContactsFromGroup(publicContacts, groupId) + } + + if (privateContacts.isNotEmpty()) { + LocalContactsHelper(this).removeContactsFromGroup(privateContacts, groupId) + } +} + +fun Context.getContactPublicUri(contact: Contact): Uri { + val lookupKey = if (contact.isPrivate()) { + "local_${contact.id}" + } else { + SimpleContactsHelper(this).getContactLookupKey(contact.id.toString()) + } + return Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, lookupKey) +} + +fun Context.getVisibleContactSources(): ArrayList { + val sources = getAllContactSources() + val ignoredContactSources = baseConfig.ignoredContactSources + return ArrayList(sources).filter { !ignoredContactSources.contains(it.getFullIdentifier()) } + .map { it.name }.toMutableList() as ArrayList +} + +fun Context.getAllContactSources(): ArrayList { + val sources = ContactsHelper(this).getDeviceContactSources() + sources.add(getPrivateContactSource()) + return sources.toMutableList() as ArrayList +} + +fun Context.getPrivateContactSource() = ContactSource(SMT_PRIVATE, SMT_PRIVATE, getString(R.string.phone_storage_hidden)) + +fun Context.getSocialActions(id: Int): ArrayList { + val uri = ContactsContract.Data.CONTENT_URI + val projection = arrayOf( + ContactsContract.Data._ID, + ContactsContract.Data.DATA3, + ContactsContract.Data.MIMETYPE, + ContactsContract.Data.ACCOUNT_TYPE_AND_DATA_SET + ) + + val socialActions = ArrayList() + var curActionId = 0 + val selection = "${ContactsContract.Data.RAW_CONTACT_ID} = ?" + val selectionArgs = arrayOf(id.toString()) + queryCursor(uri, projection, selection, selectionArgs, null, true) { cursor -> + val mimetype = cursor.getStringValue(ContactsContract.Data.MIMETYPE) + val type = when (mimetype) { + // WhatsApp + "vnd.android.cursor.item/vnd.com.whatsapp.profile" -> SOCIAL_MESSAGE + "vnd.android.cursor.item/vnd.com.whatsapp.voip.call" -> SOCIAL_VOICE_CALL + "vnd.android.cursor.item/vnd.com.whatsapp.video.call" -> SOCIAL_VIDEO_CALL + + // Viber + "vnd.android.cursor.item/vnd.com.viber.voip.viber_number_call" -> SOCIAL_VOICE_CALL + "vnd.android.cursor.item/vnd.com.viber.voip.viber_out_call_viber" -> SOCIAL_VOICE_CALL + "vnd.android.cursor.item/vnd.com.viber.voip.viber_out_call_none_viber" -> SOCIAL_VOICE_CALL + "vnd.android.cursor.item/vnd.com.viber.voip.viber_number_message" -> SOCIAL_MESSAGE + + // Signal + "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact" -> SOCIAL_MESSAGE + "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call" -> SOCIAL_VOICE_CALL + + // Telegram + "vnd.android.cursor.item/vnd.org.telegram.messenger.android.call" -> SOCIAL_VOICE_CALL + "vnd.android.cursor.item/vnd.org.telegram.messenger.android.call.video" -> SOCIAL_VIDEO_CALL + "vnd.android.cursor.item/vnd.org.telegram.messenger.android.profile" -> SOCIAL_MESSAGE + + // Threema + "vnd.android.cursor.item/vnd.ch.threema.app.profile" -> SOCIAL_MESSAGE + "vnd.android.cursor.item/vnd.ch.threema.app.call" -> SOCIAL_VOICE_CALL + else -> return@queryCursor + } + + val label = cursor.getStringValue(ContactsContract.Data.DATA3) + val realID = cursor.getLongValue(ContactsContract.Data._ID) + val packageName = cursor.getStringValue(ContactsContract.Data.ACCOUNT_TYPE_AND_DATA_SET) + val socialAction = SocialAction(curActionId++, type, label, mimetype, realID, packageName) + socialActions.add(socialAction) + } + return socialActions +} + +fun BaseSimpleActivity.initiateCall(contact: Contact, onStartCallIntent: (phoneNumber: String) -> Unit) { + val numbers = contact.phoneNumbers + if (numbers.size == 1) { + onStartCallIntent(numbers.first().value) + } else if (numbers.size > 1) { + val primaryNumber = contact.phoneNumbers.find { it.isPrimary } + if (primaryNumber != null) { + onStartCallIntent(primaryNumber.value) + } else { + val items = ArrayList() + numbers.forEachIndexed { index, phoneNumber -> + items.add(RadioItem(index, "${phoneNumber.value} (${getPhoneNumberTypeText(phoneNumber.type, phoneNumber.label)})", phoneNumber.value)) + } + + RadioGroupDialog(this, items) { + onStartCallIntent(it as String) + } + } + } +} + +fun BaseSimpleActivity.tryInitiateCall(contact: Contact, onStartCallIntent: (phoneNumber: String) -> Unit) { + if (baseConfig.showCallConfirmation) { + CallConfirmationDialog(this, contact.getNameToDisplay()) { + initiateCall(contact, onStartCallIntent) + } + } else { + initiateCall(contact, onStartCallIntent) + } +} + +fun Context.isContactBlocked(contact: Contact, callback: (Boolean) -> Unit) { + val phoneNumbers = contact.phoneNumbers.map { PhoneNumberUtils.stripSeparators(it.value) } + getBlockedNumbersWithContact { blockedNumbersWithContact -> + val blockedNumbers = blockedNumbersWithContact.map { it.number } + val allNumbersBlocked = phoneNumbers.all { it in blockedNumbers } + callback(allNumbersBlocked) + } +} + +@TargetApi(Build.VERSION_CODES.N) +fun Context.blockContact(contact: Contact): Boolean { + var contactBlocked = true + ensureBackgroundThread { + contact.phoneNumbers.forEach { + val numberBlocked = addBlockedNumber(PhoneNumberUtils.stripSeparators(it.value)) + contactBlocked = contactBlocked && numberBlocked + } + } + + return contactBlocked +} + +@TargetApi(Build.VERSION_CODES.N) +fun Context.unblockContact(contact: Contact): Boolean { + var contactUnblocked = true + ensureBackgroundThread { + contact.phoneNumbers.forEach { + val numberUnblocked = deleteBlockedNumber(PhoneNumberUtils.stripSeparators(it.value)) + contactUnblocked = contactUnblocked && numberUnblocked + } + } + + return contactUnblocked +} diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context.kt index fa398ddd..ca8ab467 100644 --- a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/extensions/Context.kt @@ -6,20 +6,67 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.drawable.Drawable +import android.hardware.biometrics.BiometricManager import androidx.core.app.AlarmManagerCompat import androidx.core.content.FileProvider -import com.simplemobiletools.commons.extensions.* -import com.simplemobiletools.commons.helpers.* import com.simplemobiletools.contacts.pro.BuildConfig import com.simplemobiletools.contacts.pro.R -import com.simplemobiletools.contacts.pro.helpers.AUTOMATIC_BACKUP_REQUEST_CODE -import com.simplemobiletools.contacts.pro.helpers.Config -import com.simplemobiletools.contacts.pro.helpers.getNextAutoBackupTime -import com.simplemobiletools.contacts.pro.helpers.getPreviousAutoBackupTime import com.simplemobiletools.contacts.pro.receivers.AutomaticBackupReceiver import org.joda.time.DateTime import java.io.File import java.io.FileOutputStream +import android.Manifest +import android.annotation.TargetApi +import android.app.Activity +import android.app.NotificationManager +import android.app.role.RoleManager +import android.content.* +import android.content.pm.PackageManager +import android.content.pm.ShortcutManager +import android.content.res.Configuration +import android.database.Cursor +import android.graphics.BitmapFactory +import android.graphics.Point +import android.media.MediaMetadataRetriever +import android.media.RingtoneManager +import android.net.Uri +import android.os.* +import android.provider.BaseColumns +import android.provider.BlockedNumberContract.BlockedNumbers +import android.provider.ContactsContract.CommonDataKinds.BaseTypes +import android.provider.ContactsContract.CommonDataKinds.Phone +import android.provider.DocumentsContract +import android.provider.MediaStore.* +import android.provider.OpenableColumns +import android.provider.Settings +import android.telecom.TelecomManager +import android.telephony.PhoneNumberUtils +import android.view.View +import android.view.WindowManager +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.RequiresApi +//import androidx.biometric.BiometricManager +import androidx.core.content.ContextCompat +//import androidx.core.content.FileProvider +import androidx.core.content.res.ResourcesCompat +import androidx.core.os.bundleOf +import androidx.exifinterface.media.ExifInterface +import androidx.loader.content.CursorLoader +import com.github.ajalt.reprint.core.Reprint +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.simplemobiletools.commons.extensions.* +import com.simplemobiletools.commons.helpers.* +import com.simplemobiletools.commons.models.AlarmSound +import com.simplemobiletools.commons.models.BlockedNumber +import com.simplemobiletools.contacts.pro.helpers.* +import com.simplemobiletools.contacts.pro.helpers.ContactsHelper +import com.simplemobiletools.contacts.pro.helpers.KEY_MAILTO +import java.text.SimpleDateFormat +import java.util.* +import kotlin.text.toInt val Context.config: Config get() = Config.newInstance(applicationContext) fun Context.getCachePhotoUri(file: File = getCachePhoto()) = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.provider", file) @@ -146,3 +193,1181 @@ fun Context.backupContacts() { } } +fun Context.getSharedPrefs() = getSharedPreferences(PREFS_KEY, Context.MODE_PRIVATE) + +val Context.isRTLLayout: Boolean get() = resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL + +val Context.areSystemAnimationsEnabled: Boolean get() = Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) > 0f + +fun Context.toast(id: Int, length: Int = Toast.LENGTH_SHORT) { + toast(getString(id), length) +} + +fun Context.toast(msg: String, length: Int = Toast.LENGTH_SHORT) { + try { + if (isOnMainThread()) { + doToast(this, msg, length) + } else { + Handler(Looper.getMainLooper()).post { + doToast(this, msg, length) + } + } + } catch (e: Exception) { + } +} + +private fun doToast(context: Context, message: String, length: Int) { + if (context is Activity) { + if (!context.isFinishing && !context.isDestroyed) { + Toast.makeText(context, message, length).show() + } + } else { + Toast.makeText(context, message, length).show() + } +} + +fun Context.showErrorToast(msg: String, length: Int = Toast.LENGTH_LONG) { + toast(String.format(getString(com.simplemobiletools.commons.R.string.error), msg), length) +} + +fun Context.showErrorToast(exception: Exception, length: Int = Toast.LENGTH_LONG) { + showErrorToast(exception.toString(), length) +} + +val Context.baseConfig: BaseConfig get() = BaseConfig.newInstance(this) +val Context.sdCardPath: String get() = baseConfig.sdCardPath +val Context.internalStoragePath: String get() = baseConfig.internalStoragePath +val Context.otgPath: String get() = baseConfig.OTGPath + +fun Context.isFingerPrintSensorAvailable() = Reprint.isHardwarePresent() + +fun Context.getLatestMediaId(uri: Uri = Files.getContentUri("external")): Long { + val projection = arrayOf( + BaseColumns._ID + ) + try { + val cursor = queryCursorDesc(uri, projection, BaseColumns._ID, 1) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getLongValue(BaseColumns._ID) + } + } + } catch (ignored: Exception) { + } + return 0 +} + +private fun Context.queryCursorDesc( + uri: Uri, + projection: Array, + sortColumn: String, + limit: Int, +): Cursor? { + return if (isRPlus()) { + val queryArgs = bundleOf( + ContentResolver.QUERY_ARG_LIMIT to limit, + ContentResolver.QUERY_ARG_SORT_DIRECTION to ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + ContentResolver.QUERY_ARG_SORT_COLUMNS to arrayOf(sortColumn), + ) + contentResolver.query(uri, projection, queryArgs, null) + } else { + val sortOrder = "$sortColumn DESC LIMIT $limit" + contentResolver.query(uri, projection, null, null, sortOrder) + } +} + +fun Context.getLatestMediaByDateId(uri: Uri = Files.getContentUri("external")): Long { + val projection = arrayOf( + BaseColumns._ID + ) + try { + val cursor = queryCursorDesc(uri, projection, Images.ImageColumns.DATE_TAKEN, 1) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getLongValue(BaseColumns._ID) + } + } + } catch (ignored: Exception) { + } + return 0 +} + +// some helper functions were taken from https://github.com/iPaulPro/aFileChooser/blob/master/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java +fun Context.getRealPathFromURI(uri: Uri): String? { + if (uri.scheme == "file") { + return uri.path + } + + if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + if (id.areDigitsOnly()) { + val newUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), id.toLong()) + val path = getDataColumn(newUri) + if (path != null) { + return path + } + } + } else if (isExternalStorageDocument(uri)) { + val documentId = DocumentsContract.getDocumentId(uri) + val parts = documentId.split(":") + if (parts[0].equals("primary", true)) { + return "${Environment.getExternalStorageDirectory().absolutePath}/${parts[1]}" + } + } else if (isMediaDocument(uri)) { + val documentId = DocumentsContract.getDocumentId(uri) + val split = documentId.split(":").dropLastWhile { it.isEmpty() }.toTypedArray() + val type = split[0] + + val contentUri = when (type) { + "video" -> Video.Media.EXTERNAL_CONTENT_URI + "audio" -> Audio.Media.EXTERNAL_CONTENT_URI + else -> Images.Media.EXTERNAL_CONTENT_URI + } + + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + val path = getDataColumn(contentUri, selection, selectionArgs) + if (path != null) { + return path + } + } + + return getDataColumn(uri) +} + +fun Context.getDataColumn(uri: Uri, selection: String? = null, selectionArgs: Array? = null): String? { + try { + val projection = arrayOf(Files.FileColumns.DATA) + val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + val data = cursor.getStringValue(Files.FileColumns.DATA) + if (data != "null") { + return data + } + } + } + } catch (e: Exception) { + } + return null +} + +private fun isMediaDocument(uri: Uri) = uri.authority == "com.android.providers.media.documents" + +private fun isDownloadsDocument(uri: Uri) = uri.authority == "com.android.providers.downloads.documents" + +private fun isExternalStorageDocument(uri: Uri) = uri.authority == "com.android.externalstorage.documents" + +fun Context.hasPermission(permId: Int) = ContextCompat.checkSelfPermission(this, getPermissionString(permId)) == PackageManager.PERMISSION_GRANTED + +fun Context.hasAllPermissions(permIds: Collection) = permIds.all(this::hasPermission) + +fun Context.getPermissionString(id: Int) = when (id) { + PERMISSION_READ_STORAGE -> Manifest.permission.READ_EXTERNAL_STORAGE + PERMISSION_WRITE_STORAGE -> Manifest.permission.WRITE_EXTERNAL_STORAGE + PERMISSION_CAMERA -> Manifest.permission.CAMERA + PERMISSION_RECORD_AUDIO -> Manifest.permission.RECORD_AUDIO + PERMISSION_READ_CONTACTS -> Manifest.permission.READ_CONTACTS + PERMISSION_WRITE_CONTACTS -> Manifest.permission.WRITE_CONTACTS + PERMISSION_READ_CALENDAR -> Manifest.permission.READ_CALENDAR + PERMISSION_WRITE_CALENDAR -> Manifest.permission.WRITE_CALENDAR + PERMISSION_CALL_PHONE -> Manifest.permission.CALL_PHONE + PERMISSION_READ_CALL_LOG -> Manifest.permission.READ_CALL_LOG + PERMISSION_WRITE_CALL_LOG -> Manifest.permission.WRITE_CALL_LOG + PERMISSION_GET_ACCOUNTS -> Manifest.permission.GET_ACCOUNTS + PERMISSION_READ_SMS -> Manifest.permission.READ_SMS + PERMISSION_SEND_SMS -> Manifest.permission.SEND_SMS + PERMISSION_READ_PHONE_STATE -> Manifest.permission.READ_PHONE_STATE + PERMISSION_MEDIA_LOCATION -> if (isQPlus()) Manifest.permission.ACCESS_MEDIA_LOCATION else "" + PERMISSION_POST_NOTIFICATIONS -> Manifest.permission.POST_NOTIFICATIONS + PERMISSION_READ_MEDIA_IMAGES -> Manifest.permission.READ_MEDIA_IMAGES + PERMISSION_READ_MEDIA_VIDEO -> Manifest.permission.READ_MEDIA_VIDEO + PERMISSION_READ_MEDIA_AUDIO -> Manifest.permission.READ_MEDIA_AUDIO + PERMISSION_ACCESS_COARSE_LOCATION -> Manifest.permission.ACCESS_COARSE_LOCATION + PERMISSION_ACCESS_FINE_LOCATION -> Manifest.permission.ACCESS_FINE_LOCATION + PERMISSION_READ_MEDIA_VISUAL_USER_SELECTED -> Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + PERMISSION_READ_SYNC_SETTINGS -> Manifest.permission.READ_SYNC_SETTINGS + else -> "" +} + +fun Context.launchActivityIntent(intent: Intent) { + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + toast(com.simplemobiletools.commons.R.string.no_app_found) + } catch (e: Exception) { + showErrorToast(e) + } +} + +fun Context.getFilePublicUri(file: File, applicationId: String): Uri { + // for images/videos/gifs try getting a media content uri first, like content://media/external/images/media/438 + // if media content uri is null, get our custom uri like content://com.simplemobiletools.gallery.provider/external_files/emulated/0/DCIM/IMG_20171104_233915.jpg + var uri = if (file.isMediaFile()) { + getMediaContentUri(file.absolutePath) + } else { + getMediaContent(file.absolutePath, Files.getContentUri("external")) + } + + if (uri == null) { + uri = FileProvider.getUriForFile(this, "$applicationId.provider", file) + } + + return uri!! +} + +fun Context.getMediaContentUri(path: String): Uri? { + val uri = when { + path.isImageFast() -> Images.Media.EXTERNAL_CONTENT_URI + path.isVideoFast() -> Video.Media.EXTERNAL_CONTENT_URI + else -> Files.getContentUri("external") + } + + return getMediaContent(path, uri) +} + +fun Context.getMediaContent(path: String, uri: Uri): Uri? { + val projection = arrayOf(Images.Media._ID) + val selection = Images.Media.DATA + "= ?" + val selectionArgs = arrayOf(path) + try { + val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + val id = cursor.getIntValue(Images.Media._ID).toString() + return Uri.withAppendedPath(uri, id) + } + } + } catch (e: Exception) { + } + return null +} + +fun Context.queryCursor( + uri: Uri, + projection: Array, + selection: String? = null, + selectionArgs: Array? = null, + sortOrder: String? = null, + showErrors: Boolean = false, + callback: (cursor: Cursor) -> Unit +) { + try { + val cursor = contentResolver.query(uri, projection, selection, selectionArgs, sortOrder) + cursor?.use { + if (cursor.moveToFirst()) { + do { + callback(cursor) + } while (cursor.moveToNext()) + } + } + } catch (e: Exception) { + if (showErrors) { + showErrorToast(e) + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +fun Context.queryCursor( + uri: Uri, + projection: Array, + queryArgs: Bundle, + showErrors: Boolean = false, + callback: (cursor: Cursor) -> Unit +) { + try { + val cursor = contentResolver.query(uri, projection, queryArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + do { + callback(cursor) + } while (cursor.moveToNext()) + } + } + } catch (e: Exception) { + if (showErrors) { + showErrorToast(e) + } + } +} + +fun Context.getFilenameFromUri(uri: Uri): String { + return if (uri.scheme == "file") { + File(uri.toString()).name + } else { + getFilenameFromContentUri(uri) ?: uri.lastPathSegment ?: "" + } +} + +fun Context.getMimeTypeFromUri(uri: Uri): String { + var mimetype = uri.path?.getMimeType() ?: "" + if (mimetype.isEmpty()) { + try { + mimetype = contentResolver.getType(uri) ?: "" + } catch (e: IllegalStateException) { + } + } + return mimetype +} + +fun Context.ensurePublicUri(path: String, applicationId: String): Uri? { + return when { + hasProperStoredAndroidTreeUri(path) && isRestrictedSAFOnlyRoot(path) -> { + getAndroidSAFUri(path) + } + + hasProperStoredDocumentUriSdk30(path) && isAccessibleWithSAFSdk30(path) -> { + createDocumentUriUsingFirstParentTreeUri(path) + } + + isPathOnOTG(path) -> { + getDocumentFile(path)?.uri + } + + else -> { + val uri = Uri.parse(path) + if (uri.scheme == "content") { + uri + } else { + val newPath = if (uri.toString().startsWith("/")) uri.toString() else uri.path + val file = File(newPath) + getFilePublicUri(file, applicationId) + } + } + } +} + +fun Context.ensurePublicUri(uri: Uri, applicationId: String): Uri { + return if (uri.scheme == "content") { + uri + } else { + val file = File(uri.path) + getFilePublicUri(file, applicationId) + } +} + +fun Context.getFilenameFromContentUri(uri: Uri): String? { + val projection = arrayOf( + OpenableColumns.DISPLAY_NAME + ) + + try { + val cursor = contentResolver.query(uri, projection, null, null, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getStringValue(OpenableColumns.DISPLAY_NAME) + } + } + } catch (e: Exception) { + } + return null +} + +fun Context.getSizeFromContentUri(uri: Uri): Long { + val projection = arrayOf(OpenableColumns.SIZE) + try { + val cursor = contentResolver.query(uri, projection, null, null, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getLongValue(OpenableColumns.SIZE) + } + } + } catch (e: Exception) { + } + return 0L +} + +fun Context.getMyContentProviderCursorLoader() = CursorLoader(this, MyContentProvider.MY_CONTENT_URI, null, null, null, null) + +fun Context.getMyContactsCursor(favoritesOnly: Boolean, withPhoneNumbersOnly: Boolean) = try { + val getFavoritesOnly = if (favoritesOnly) "1" else "0" + val getWithPhoneNumbersOnly = if (withPhoneNumbersOnly) "1" else "0" + val args = arrayOf(getFavoritesOnly, getWithPhoneNumbersOnly) + CursorLoader(this, MyContactsContentProvider.CONTACTS_CONTENT_URI, null, null, args, null).loadInBackground() +} catch (e: Exception) { + null +} + +fun Context.getCurrentFormattedDateTime(): String { + val simpleDateFormat = SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.getDefault()) + return simpleDateFormat.format(Date(System.currentTimeMillis())) +} + +fun Context.updateSDCardPath() { + ensureBackgroundThread { + val oldPath = baseConfig.sdCardPath + baseConfig.sdCardPath = getSDCardPath() + if (oldPath != baseConfig.sdCardPath) { + baseConfig.sdTreeUri = "" + } + } +} + +fun Context.getUriMimeType(path: String, newUri: Uri): String { + var mimeType = path.getMimeType() + if (mimeType.isEmpty()) { + mimeType = getMimeTypeFromUri(newUri) + } + return mimeType +} + +fun Context.isThankYouInstalled() = isPackageInstalled("com.simplemobiletools.thankyou") + +fun Context.isOrWasThankYouInstalled(): Boolean { + return when { + resources.getBoolean(com.simplemobiletools.commons.R.bool.pretend_thank_you_installed) -> true + baseConfig.hadThankYouInstalled -> true + isThankYouInstalled() -> { + baseConfig.hadThankYouInstalled = true + true + } + + else -> false + } +} + +fun Context.isAProApp() = packageName.startsWith("com.simplemobiletools.") && packageName.removeSuffix(".debug").endsWith(".pro") + +fun Context.getCustomizeColorsString(): String { + val textId = if (isOrWasThankYouInstalled()) { + com.simplemobiletools.commons.R.string.customize_colors + } else { + com.simplemobiletools.commons.R.string.customize_colors_locked + } + + return getString(textId) +} + +fun Context.addLockedLabelIfNeeded(stringId: Int): String { + return if (isOrWasThankYouInstalled()) { + getString(stringId) + } else { + "${getString(stringId)} (${getString(com.simplemobiletools.commons.R.string.feature_locked)})" + } +} + +fun Context.isPackageInstalled(pkgName: String): Boolean { + return try { + packageManager.getPackageInfo(pkgName, 0) + true + } catch (e: Exception) { + false + } +} + +// format day bits to strings like "Mon, Tue, Wed" +fun Context.getSelectedDaysString(bitMask: Int): String { + val dayBits = arrayListOf(MONDAY_BIT, TUESDAY_BIT, WEDNESDAY_BIT, THURSDAY_BIT, FRIDAY_BIT, SATURDAY_BIT, SUNDAY_BIT) + val weekDays = resources.getStringArray(com.simplemobiletools.commons.R.array.week_days_short).toList() as ArrayList + + if (baseConfig.isSundayFirst) { + dayBits.moveLastItemToFront() + weekDays.moveLastItemToFront() + } + + var days = "" + dayBits.forEachIndexed { index, bit -> + if (bitMask and bit != 0) { + days += "${weekDays[index]}, " + } + } + return days.trim().trimEnd(',') +} + +fun Context.formatMinutesToTimeString(totalMinutes: Int) = formatSecondsToTimeString(totalMinutes * 60) + +fun Context.formatSecondsToTimeString(totalSeconds: Int): String { + val days = totalSeconds / DAY_SECONDS + val hours = (totalSeconds % DAY_SECONDS) / HOUR_SECONDS + val minutes = (totalSeconds % HOUR_SECONDS) / MINUTE_SECONDS + val seconds = totalSeconds % MINUTE_SECONDS + val timesString = StringBuilder() + if (days > 0) { + val daysString = String.format(resources.getQuantityString(com.simplemobiletools.commons.R.plurals.days, days, days)) + timesString.append("$daysString, ") + } + + if (hours > 0) { + val hoursString = String.format(resources.getQuantityString(com.simplemobiletools.commons.R.plurals.hours, hours, hours)) + timesString.append("$hoursString, ") + } + + if (minutes > 0) { + val minutesString = String.format(resources.getQuantityString(com.simplemobiletools.commons.R.plurals.minutes, minutes, minutes)) + timesString.append("$minutesString, ") + } + + if (seconds > 0) { + val secondsString = String.format(resources.getQuantityString(com.simplemobiletools.commons.R.plurals.seconds, seconds, seconds)) + timesString.append(secondsString) + } + + var result = timesString.toString().trim().trimEnd(',') + if (result.isEmpty()) { + result = String.format(resources.getQuantityString(com.simplemobiletools.commons.R.plurals.minutes, 0, 0)) + } + return result +} + +fun Context.getFormattedMinutes(minutes: Int, showBefore: Boolean = true) = getFormattedSeconds(if (minutes == -1) minutes else minutes * 60, showBefore) + +fun Context.getFormattedSeconds(seconds: Int, showBefore: Boolean = true) = when (seconds) { + -1 -> getString(com.simplemobiletools.commons.R.string.no_reminder) + 0 -> getString(com.simplemobiletools.commons.R.string.at_start) + else -> { + when { + seconds < 0 && seconds > -60 * 60 * 24 -> { + val minutes = -seconds / 60 + getString(com.simplemobiletools.commons.R.string.during_day_at).format(minutes / 60, minutes % 60) + } + + seconds % YEAR_SECONDS == 0 -> { + val base = if (showBefore) com.simplemobiletools.commons.R.plurals.years_before else com.simplemobiletools.commons.R.plurals.by_years + resources.getQuantityString(base, seconds / YEAR_SECONDS, seconds / YEAR_SECONDS) + } + + seconds % MONTH_SECONDS == 0 -> { + val base = if (showBefore) com.simplemobiletools.commons.R.plurals.months_before else com.simplemobiletools.commons.R.plurals.by_months + resources.getQuantityString(base, seconds / MONTH_SECONDS, seconds / MONTH_SECONDS) + } + + seconds % WEEK_SECONDS == 0 -> { + val base = if (showBefore) com.simplemobiletools.commons.R.plurals.weeks_before else com.simplemobiletools.commons.R.plurals.by_weeks + resources.getQuantityString(base, seconds / WEEK_SECONDS, seconds / WEEK_SECONDS) + } + + seconds % DAY_SECONDS == 0 -> { + val base = if (showBefore) com.simplemobiletools.commons.R.plurals.days_before else com.simplemobiletools.commons.R.plurals.by_days + resources.getQuantityString(base, seconds / DAY_SECONDS, seconds / DAY_SECONDS) + } + + seconds % HOUR_SECONDS == 0 -> { + val base = if (showBefore) com.simplemobiletools.commons.R.plurals.hours_before else com.simplemobiletools.commons.R.plurals.by_hours + resources.getQuantityString(base, seconds / HOUR_SECONDS, seconds / HOUR_SECONDS) + } + + seconds % MINUTE_SECONDS == 0 -> { + val base = if (showBefore) com.simplemobiletools.commons.R.plurals.minutes_before else com.simplemobiletools.commons.R.plurals.by_minutes + resources.getQuantityString(base, seconds / MINUTE_SECONDS, seconds / MINUTE_SECONDS) + } + + else -> { + val base = if (showBefore) com.simplemobiletools.commons.R.plurals.seconds_before else com.simplemobiletools.commons.R.plurals.by_seconds + resources.getQuantityString(base, seconds, seconds) + } + } + } +} + +fun Context.getDefaultAlarmTitle(type: Int): String { + val alarmString = getString(com.simplemobiletools.commons.R.string.alarm) + return try { + RingtoneManager.getRingtone(this, RingtoneManager.getDefaultUri(type))?.getTitle(this) ?: alarmString + } catch (e: Exception) { + alarmString + } +} + +fun Context.getDefaultAlarmSound(type: Int) = AlarmSound(0, getDefaultAlarmTitle(type), RingtoneManager.getDefaultUri(type).toString()) + +fun Context.grantReadUriPermission(uriString: String) { + try { + // ensure custom reminder sounds play well + grantUriPermission("com.android.systemui", Uri.parse(uriString), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (ignored: Exception) { + } +} + +fun Context.storeNewYourAlarmSound(resultData: Intent): AlarmSound { + val uri = resultData.data + var filename = getFilenameFromUri(uri!!) + if (filename.isEmpty()) { + filename = getString(com.simplemobiletools.commons.R.string.alarm) + } + + val token = object : TypeToken>() {}.type + val yourAlarmSounds = Gson().fromJson>(baseConfig.yourAlarmSounds, token) + ?: ArrayList() + val newAlarmSoundId = (yourAlarmSounds.maxByOrNull { it.id }?.id ?: YOUR_ALARM_SOUNDS_MIN_ID) + 1 + val newAlarmSound = AlarmSound(newAlarmSoundId, filename, uri.toString()) + if (yourAlarmSounds.firstOrNull { it.uri == uri.toString() } == null) { + yourAlarmSounds.add(newAlarmSound) + } + + baseConfig.yourAlarmSounds = Gson().toJson(yourAlarmSounds) + + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION + contentResolver.takePersistableUriPermission(uri, takeFlags) + + return newAlarmSound +} + +@RequiresApi(Build.VERSION_CODES.N) +fun Context.saveImageRotation(path: String, degrees: Int): Boolean { + if (!needsStupidWritePermissions(path)) { + saveExifRotation(ExifInterface(path), degrees) + return true + } else if (isNougatPlus()) { + val documentFile = getSomeDocumentFile(path) + if (documentFile != null) { + val parcelFileDescriptor = contentResolver.openFileDescriptor(documentFile.uri, "rw") + val fileDescriptor = parcelFileDescriptor!!.fileDescriptor + saveExifRotation(ExifInterface(fileDescriptor), degrees) + return true + } + } + return false +} + +fun Context.saveExifRotation(exif: ExifInterface, degrees: Int) { + val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + val orientationDegrees = (orientation.degreesFromOrientation() + degrees) % 360 + exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientationDegrees.orientationFromDegrees()) + exif.saveAttributes() +} + +fun Context.getLaunchIntent() = packageManager.getLaunchIntentForPackage(baseConfig.appId) + +fun Context.getCanAppBeUpgraded() = proPackages.contains(baseConfig.appId.removeSuffix(".debug").removePrefix("com.simplemobiletools.")) + +fun Context.getProUrl() = "https://play.google.com/store/apps/details?id=${baseConfig.appId.removeSuffix(".debug")}.pro" + +fun Context.getStoreUrl() = "https://play.google.com/store/apps/details?id=${packageName.removeSuffix(".debug")}" + +fun Context.getTimeFormat() = if (baseConfig.use24HourFormat) TIME_FORMAT_24 else TIME_FORMAT_12 + +fun Context.getResolution(path: String): Point? { + return if (path.isImageFast() || path.isImageSlow()) { + getImageResolution(path) + } else if (path.isVideoFast() || path.isVideoSlow()) { + getVideoResolution(path) + } else { + null + } +} + +fun Context.getImageResolution(path: String): Point? { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + if (isRestrictedSAFOnlyRoot(path)) { + BitmapFactory.decodeStream(contentResolver.openInputStream(getAndroidSAFUri(path)), null, options) + } else { + BitmapFactory.decodeFile(path, options) + } + + val width = options.outWidth + val height = options.outHeight + return if (width > 0 && height > 0) { + Point(options.outWidth, options.outHeight) + } else { + null + } +} + +fun Context.getVideoResolution(path: String): Point? { + var point = try { + val retriever = MediaMetadataRetriever() + if (isRestrictedSAFOnlyRoot(path)) { + retriever.setDataSource(this, getAndroidSAFUri(path)) + } else { + retriever.setDataSource(path) + } + + val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!.toInt() + val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!.toInt() + Point(width, height) + } catch (ignored: Exception) { + null + } + + if (point == null && path.startsWith("content://", true)) { + try { + val fd = contentResolver.openFileDescriptor(Uri.parse(path), "r")?.fileDescriptor + val retriever = MediaMetadataRetriever() + retriever.setDataSource(fd) + val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!.toInt() + val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!.toInt() + point = Point(width, height) + } catch (ignored: Exception) { + } + } + + return point +} + +fun Context.getDuration(path: String): Int? { + val projection = arrayOf( + MediaColumns.DURATION + ) + + val uri = getFileUri(path) + val selection = if (path.startsWith("content://")) "${BaseColumns._ID} = ?" else "${MediaColumns.DATA} = ?" + val selectionArgs = if (path.startsWith("content://")) arrayOf(path.substringAfterLast("/")) else arrayOf(path) + + try { + val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + return Math.round(cursor.getIntValue(MediaColumns.DURATION) / 1000.toDouble()).toInt() + } + } + } catch (ignored: Exception) { + } + + return try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(path) + Math.round(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!!.toInt() / 1000f) + } catch (ignored: Exception) { + null + } +} + +fun Context.getTitle(path: String): String? { + val projection = arrayOf( + MediaColumns.TITLE + ) + + val uri = getFileUri(path) + val selection = if (path.startsWith("content://")) "${BaseColumns._ID} = ?" else "${MediaColumns.DATA} = ?" + val selectionArgs = if (path.startsWith("content://")) arrayOf(path.substringAfterLast("/")) else arrayOf(path) + + try { + val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getStringValue(MediaColumns.TITLE) + } + } + } catch (ignored: Exception) { + } + + return try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(path) + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) + } catch (ignored: Exception) { + null + } +} + +fun Context.getArtist(path: String): String? { + val projection = arrayOf( + Audio.Media.ARTIST + ) + + val uri = getFileUri(path) + val selection = if (path.startsWith("content://")) "${BaseColumns._ID} = ?" else "${MediaColumns.DATA} = ?" + val selectionArgs = if (path.startsWith("content://")) arrayOf(path.substringAfterLast("/")) else arrayOf(path) + + try { + val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getStringValue(Audio.Media.ARTIST) + } + } + } catch (ignored: Exception) { + } + + return try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(path) + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) + } catch (ignored: Exception) { + null + } +} + +fun Context.getAlbum(path: String): String? { + val projection = arrayOf( + Audio.Media.ALBUM + ) + + val uri = getFileUri(path) + val selection = if (path.startsWith("content://")) "${BaseColumns._ID} = ?" else "${MediaColumns.DATA} = ?" + val selectionArgs = if (path.startsWith("content://")) arrayOf(path.substringAfterLast("/")) else arrayOf(path) + + try { + val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getStringValue(Audio.Media.ALBUM) + } + } + } catch (ignored: Exception) { + } + + return try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(path) + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM) + } catch (ignored: Exception) { + null + } +} + +fun Context.getMediaStoreLastModified(path: String): Long { + val projection = arrayOf( + MediaColumns.DATE_MODIFIED + ) + + val uri = getFileUri(path) + val selection = "${BaseColumns._ID} = ?" + val selectionArgs = arrayOf(path.substringAfterLast("/")) + + try { + val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getLongValue(MediaColumns.DATE_MODIFIED) * 1000 + } + } + } catch (ignored: Exception) { + } + return 0 +} + +fun Context.getStringsPackageName() = getString(com.simplemobiletools.commons.R.string.package_name) + +fun Context.getFontSizeText() = getString( + when (baseConfig.fontSize) { + FONT_SIZE_SMALL -> com.simplemobiletools.commons.R.string.small + FONT_SIZE_MEDIUM -> com.simplemobiletools.commons.R.string.medium + FONT_SIZE_LARGE -> com.simplemobiletools.commons.R.string.large + else -> com.simplemobiletools.commons.R.string.extra_large + } +) + +fun Context.getTextSize() = when (baseConfig.fontSize) { + FONT_SIZE_SMALL -> resources.getDimension(com.simplemobiletools.commons.R.dimen.smaller_text_size) + FONT_SIZE_MEDIUM -> resources.getDimension(com.simplemobiletools.commons.R.dimen.bigger_text_size) + FONT_SIZE_LARGE -> resources.getDimension(com.simplemobiletools.commons.R.dimen.big_text_size) + else -> resources.getDimension(com.simplemobiletools.commons.R.dimen.extra_big_text_size) +} + +val Context.telecomManager: TelecomManager get() = getSystemService(Context.TELECOM_SERVICE) as TelecomManager +val Context.windowManager: WindowManager get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager +val Context.notificationManager: NotificationManager get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager +val Context.portrait get() = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT +val Context.navigationBarOnSide: Boolean get() = usableScreenSize.x < realScreenSize.x && usableScreenSize.x > usableScreenSize.y +val Context.navigationBarOnBottom: Boolean get() = usableScreenSize.y < realScreenSize.y +val Context.navigationBarHeight: Int get() = if (navigationBarOnBottom && navigationBarSize.y != usableScreenSize.y) navigationBarSize.y else 0 +val Context.navigationBarWidth: Int get() = if (navigationBarOnSide) navigationBarSize.x else 0 + +val Context.navigationBarSize: Point + get() = when { + navigationBarOnSide -> Point(newNavigationBarHeight, usableScreenSize.y) + navigationBarOnBottom -> Point(usableScreenSize.x, newNavigationBarHeight) + else -> Point() + } + +val Context.newNavigationBarHeight: Int + get() { + var navigationBarHeight = 0 + val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") + if (resourceId > 0) { + navigationBarHeight = resources.getDimensionPixelSize(resourceId) + } + return navigationBarHeight + } + +val Context.statusBarHeight: Int + get() { + var statusBarHeight = 0 + val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") + if (resourceId > 0) { + statusBarHeight = resources.getDimensionPixelSize(resourceId) + } + return statusBarHeight + } + +val Context.actionBarHeight: Int + get() { + val styledAttributes = theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize)) + val actionBarHeight = styledAttributes.getDimension(0, 0f) + styledAttributes.recycle() + return actionBarHeight.toInt() + } + +val Context.usableScreenSize: Point + get() { + val size = Point() + windowManager.defaultDisplay.getSize(size) + return size + } + +val Context.realScreenSize: Point + get() { + val size = Point() + windowManager.defaultDisplay.getRealSize(size) + return size + } + +fun Context.isUsingGestureNavigation(): Boolean { + return try { + val resourceId = resources.getIdentifier("config_navBarInteractionMode", "integer", "android") + if (resourceId > 0) { + resources.getInteger(resourceId) == 2 + } else { + false + } + } catch (e: Exception) { + false + } +} + +fun Context.getCornerRadius() = resources.getDimension(com.simplemobiletools.commons.R.dimen.rounded_corner_radius_small) + +// we need the Default Dialer functionality only in Simple Dialer and in Simple Contacts for now +fun Context.isDefaultDialer(): Boolean { + return if (!packageName.startsWith("com.simplemobiletools.contacts") && !packageName.startsWith("com.simplemobiletools.dialer")) { + true + } else if ((packageName.startsWith("com.simplemobiletools.contacts") || packageName.startsWith("com.simplemobiletools.dialer")) && isQPlus()) { + val roleManager = getSystemService(RoleManager::class.java) + roleManager!!.isRoleAvailable(RoleManager.ROLE_DIALER) && roleManager.isRoleHeld(RoleManager.ROLE_DIALER) + } else { + telecomManager.defaultDialerPackage == packageName + } +} + +fun Context.getContactsHasMap(callback: (HashMap) -> Unit) { + ContactsHelper(this).getContacts(showOnlyContactsWithNumbers = true) { contactList -> + val privateContacts: HashMap = HashMap() + for (contact in contactList) { + for (phoneNumber in contact.phoneNumbers) { + privateContacts[PhoneNumberUtils.stripSeparators(phoneNumber.value)] = contact.name + } + } + callback(privateContacts) + } +} + +@TargetApi(Build.VERSION_CODES.N) +fun Context.getBlockedNumbersWithContact(callback: (ArrayList) -> Unit) { + getContactsHasMap { contacts -> + val blockedNumbers = ArrayList() + if (!isNougatPlus() || !isDefaultDialer()) { + callback(blockedNumbers) + } + + val uri = BlockedNumbers.CONTENT_URI + val projection = arrayOf( + BlockedNumbers.COLUMN_ID, + BlockedNumbers.COLUMN_ORIGINAL_NUMBER, + BlockedNumbers.COLUMN_E164_NUMBER, + ) + + queryCursor(uri, projection) { cursor -> + val id = cursor.getLongValue(BlockedNumbers.COLUMN_ID) + val number = cursor.getStringValue(BlockedNumbers.COLUMN_ORIGINAL_NUMBER) ?: "" + val normalizedNumber = cursor.getStringValue(BlockedNumbers.COLUMN_E164_NUMBER) ?: number + val comparableNumber = normalizedNumber.trimToComparableNumber() + + val contactName = contacts[number] + val blockedNumber = BlockedNumber(id, number, normalizedNumber, comparableNumber, contactName) + blockedNumbers.add(blockedNumber) + } + + val blockedNumbersPair = blockedNumbers.partition { it.contactName != null } + val blockedNumbersWithNameSorted = blockedNumbersPair.first.sortedBy { it.contactName } + val blockedNumbersNoNameSorted = blockedNumbersPair.second.sortedBy { it.number } + + callback(ArrayList(blockedNumbersWithNameSorted + blockedNumbersNoNameSorted)) + } +} + +@TargetApi(Build.VERSION_CODES.N) +fun Context.getBlockedNumbers(): ArrayList { + val blockedNumbers = ArrayList() + if (!isNougatPlus() || !isDefaultDialer()) { + return blockedNumbers + } + + val uri = BlockedNumbers.CONTENT_URI + val projection = arrayOf( + BlockedNumbers.COLUMN_ID, + BlockedNumbers.COLUMN_ORIGINAL_NUMBER, + BlockedNumbers.COLUMN_E164_NUMBER + ) + + queryCursor(uri, projection) { cursor -> + val id = cursor.getLongValue(BlockedNumbers.COLUMN_ID) + val number = cursor.getStringValue(BlockedNumbers.COLUMN_ORIGINAL_NUMBER) ?: "" + val normalizedNumber = cursor.getStringValue(BlockedNumbers.COLUMN_E164_NUMBER) ?: number + val comparableNumber = normalizedNumber.trimToComparableNumber() + val blockedNumber = BlockedNumber(id, number, normalizedNumber, comparableNumber) + blockedNumbers.add(blockedNumber) + } + + return blockedNumbers +} + +@TargetApi(Build.VERSION_CODES.N) +fun Context.addBlockedNumber(number: String): Boolean { + ContentValues().apply { + put(BlockedNumbers.COLUMN_ORIGINAL_NUMBER, number) + if (number.isPhoneNumber()) { + put(BlockedNumbers.COLUMN_E164_NUMBER, PhoneNumberUtils.normalizeNumber(number)) + } + try { + contentResolver.insert(BlockedNumbers.CONTENT_URI, this) + } catch (e: Exception) { + showErrorToast(e) + return false + } + } + return true +} + +@TargetApi(Build.VERSION_CODES.N) +fun Context.deleteBlockedNumber(number: String): Boolean { + val selection = "${BlockedNumbers.COLUMN_ORIGINAL_NUMBER} = ?" + val selectionArgs = arrayOf(number) + + return if (isNumberBlocked(number)) { + val deletedRowCount = contentResolver.delete(BlockedNumbers.CONTENT_URI, selection, selectionArgs) + + deletedRowCount > 0 + } else { + true + } +} + +fun Context.isNumberBlocked(number: String, blockedNumbers: ArrayList = getBlockedNumbers()): Boolean { + if (!isNougatPlus()) { + return false + } + + val numberToCompare = number.trimToComparableNumber() + + return blockedNumbers.any { + numberToCompare == it.numberToCompare || + numberToCompare == it.number || + PhoneNumberUtils.stripSeparators(number) == it.number + } || isNumberBlockedByPattern(number, blockedNumbers) +} + +fun Context.isNumberBlockedByPattern(number: String, blockedNumbers: ArrayList = getBlockedNumbers()): Boolean { + for (blockedNumber in blockedNumbers) { + val num = blockedNumber.number + if (num.isBlockedNumberPattern()) { + val pattern = num.replace("+", "\\+").replace("*", ".*") + if (number.matches(pattern.toRegex())) { + return true + } + } + } + return false +} + +fun Context.copyToClipboard(text: String) { + val clip = ClipData.newPlainText(getString(com.simplemobiletools.commons.R.string.simple_commons), text) + (getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip) + val toastText = String.format(getString(com.simplemobiletools.commons.R.string.value_copied_to_clipboard_show), text) + toast(toastText) +} + +fun Context.getPhoneNumberTypeText(type: Int, label: String): String { + return if (type == BaseTypes.TYPE_CUSTOM) { + label + } else { + getString( + when (type) { + Phone.TYPE_MOBILE -> com.simplemobiletools.commons.R.string.mobile + Phone.TYPE_HOME -> com.simplemobiletools.commons.R.string.home + Phone.TYPE_WORK -> com.simplemobiletools.commons.R.string.work + Phone.TYPE_MAIN -> com.simplemobiletools.commons.R.string.main_number + Phone.TYPE_FAX_WORK -> com.simplemobiletools.commons.R.string.work_fax + Phone.TYPE_FAX_HOME -> com.simplemobiletools.commons.R.string.home_fax + Phone.TYPE_PAGER -> com.simplemobiletools.commons.R.string.pager + else -> com.simplemobiletools.commons.R.string.other + } + ) + } +} + +fun Context.updateBottomTabItemColors(view: View?, isActive: Boolean, drawableId: Int? = null) { + val color = if (isActive) { + getProperPrimaryColor() + } else { + getProperTextColor() + } + + if (drawableId != null) { + val drawable = ResourcesCompat.getDrawable(resources, drawableId, theme) + view?.findViewById(com.simplemobiletools.commons.R.id.tab_item_icon)?.setImageDrawable(drawable) + } + + view?.findViewById(com.simplemobiletools.commons.R.id.tab_item_icon)?.applyColorFilter(color) + view?.findViewById(com.simplemobiletools.commons.R.id.tab_item_label)?.setTextColor(color) +} + +fun Context.sendEmailIntent(recipient: String) { + Intent(Intent.ACTION_SENDTO).apply { + data = Uri.fromParts(KEY_MAILTO, recipient, null) + launchActivityIntent(this) + } +} + +fun Context.openNotificationSettings() { + if (isOreoPlus()) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + startActivity(intent) + } else { + // For Android versions below Oreo, you can't directly open the app's notification settings. + // You can open the general notification settings instead. + val intent = Intent(Settings.ACTION_SETTINGS) + startActivity(intent) + } +} + +fun Context.getTempFile(folderName: String, filename: String): File? { + val folder = File(cacheDir, folderName) + if (!folder.exists()) { + if (!folder.mkdir()) { + toast(com.simplemobiletools.commons.R.string.unknown_error_occurred) + return null + } + } + + return File(folder, filename) +} + +fun Context.openDeviceSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + + try { + startActivity(intent) + } catch (e: Exception) { + showErrorToast(e) + } +} + +@RequiresApi(Build.VERSION_CODES.S) +fun Context.openRequestExactAlarmSettings(appId: String) { + if (isSPlus()) { + val uri = Uri.fromParts("package", appId, null) + val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + intent.data = uri + startActivity(intent) + } +} + +fun Context.canUseFullScreenIntent(): Boolean { + return !isUpsideDownCakePlus() || notificationManager.canUseFullScreenIntent() +} + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +fun Context.openFullScreenIntentSettings(appId: String) { + if (isUpsideDownCakePlus()) { + val uri = Uri.fromParts("package", appId, null) + val intent = Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT) + intent.data = uri + startActivity(intent) + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/ContactsHelper.kt b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/ContactsHelper.kt new file mode 100644 index 00000000..d3ef196f --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/contacts/pro/helpers/ContactsHelper.kt @@ -0,0 +1,1652 @@ +package com.simplemobiletools.contacts.pro.helpers + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.* +import android.graphics.Bitmap +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.ContactsContract +import android.provider.ContactsContract.* +import android.provider.MediaStore +import android.text.TextUtils +import android.util.Log +import android.util.SparseArray +import androidx.appcompat.app.AlertDialog +import com.simplemobiletools.commons.R +import com.simplemobiletools.commons.extensions.* +import com.simplemobiletools.commons.extensions.getIntValue +import com.simplemobiletools.commons.extensions.getLongValue +import com.simplemobiletools.commons.extensions.getStringValue +import com.simplemobiletools.contacts.pro.extensions.* +import com.simplemobiletools.commons.helpers.* +import com.simplemobiletools.commons.models.PhoneNumber +import com.simplemobiletools.commons.models.contacts.* +import com.simplemobiletools.commons.overloads.times +import com.simplemobiletools.contacts.pro.extensions.baseConfig +import com.simplemobiletools.contacts.pro.extensions.getAllContactSources +import com.simplemobiletools.contacts.pro.extensions.getPhotoThumbnailSize +import com.simplemobiletools.contacts.pro.extensions.getPrivateContactSource +import com.simplemobiletools.contacts.pro.extensions.getVisibleContactSources +import com.simplemobiletools.contacts.pro.extensions.groupsDB +import com.simplemobiletools.contacts.pro.extensions.hasContactPermissions +import com.simplemobiletools.contacts.pro.extensions.hasPermission +import com.simplemobiletools.contacts.pro.extensions.queryCursor +import com.simplemobiletools.contacts.pro.extensions.showErrorToast +import com.simplemobiletools.contacts.pro.extensions.toast +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.OutputStream +import java.util.Locale + +class ContactsHelper(val context: Context) { + private val BATCH_SIZE = 50 + private var displayContactSources = ArrayList() + + companion object { + var durations = mutableListOf("") + var startTime = 0L + } + + fun getContacts( + getAll: Boolean = false, + gettingDuplicates: Boolean = false, + ignoredContactSources: HashSet = HashSet(), + showOnlyContactsWithNumbers: Boolean = context.baseConfig.showOnlyContactsWithNumbers, + callback: (ArrayList) -> Unit + ) { + ensureBackgroundThread { + var now = System.currentTimeMillis() + startTime = now + val contacts = SparseArray() + displayContactSources = context.getVisibleContactSources() + durations.clear() + val step = "#1: ${System.currentTimeMillis() - now}ms. getVisibleContactSources" + durations.add(step) + Log.e("TAGG", step) + now = System.currentTimeMillis() + + + if (getAll) { + displayContactSources = if (ignoredContactSources.isEmpty()) { + context.getAllContactSources().map { it.name }.toMutableList() as ArrayList + } else { + context.getAllContactSources().filter { + it.getFullIdentifier().isNotEmpty() && !ignoredContactSources.contains(it.getFullIdentifier()) + }.map { it.name }.toMutableList() as ArrayList + } + } + + val step2 = "#2: ${System.currentTimeMillis() - now}ms. getAllContactSources" + durations.add(step2) + Log.e("TAGG", step2) + now = System.currentTimeMillis() + + getDeviceContacts(contacts, ignoredContactSources, gettingDuplicates) + val step3 = "#3: ${System.currentTimeMillis() - now}ms. getDeviceContacts" + durations.add(step3) + Log.e("TAGG", step3) + now = System.currentTimeMillis() + + if (displayContactSources.contains(SMT_PRIVATE)) { + LocalContactsHelper(context).getAllContacts().forEach { + contacts.put(it.id, it) + } + + val step4 = "#4: ${System.currentTimeMillis() - now}ms. getAllContacts - read from db" + durations.add(step4) + Log.e("TAGG", step4) + now = System.currentTimeMillis() + } + + val contactsSize = contacts.size() + val tempContacts = ArrayList(contactsSize) + val resultContacts = ArrayList(contactsSize) + + (0 until contactsSize).filter { + if (ignoredContactSources.isEmpty() && showOnlyContactsWithNumbers) { + contacts.valueAt(it).phoneNumbers.isNotEmpty() + } else { + true + } + }.mapTo(tempContacts) { + contacts.valueAt(it) + } + + + if (context.baseConfig.mergeDuplicateContacts && ignoredContactSources.isEmpty() && !getAll) { + tempContacts.filter { displayContactSources.contains(it.source) }.groupBy { it.getNameToDisplay().toLowerCase() }.values.forEach { it -> + if (it.size == 1) { + resultContacts.add(it.first()) + } else { + val sorted = it.sortedByDescending { it.getStringToCompare().length } + resultContacts.add(sorted.first()) + } + } + } else { + resultContacts.addAll(tempContacts) + } + val step5 = "#5: ${System.currentTimeMillis() - now}ms. local resultContacts" + durations.add(step5) + Log.e("TAGG", step5) + now = System.currentTimeMillis() + + // groups are obtained with contactID, not rawID, so assign them to proper contacts like this + val groups = getContactGroups(getStoredGroupsSync()) + val size = groups.size() + for (i in 0 until size) { + val key = groups.keyAt(i) + resultContacts.firstOrNull { it.contactId == key }?.groups = groups.valueAt(i) + } + + Contact.sorting = context.baseConfig.sorting + Contact.startWithSurname = context.baseConfig.startNameWithSurname + resultContacts.sort() + + val step6 = "#6: ${System.currentTimeMillis() - now}ms. local getContactGroups & sort res" + durations.add(step6) + Log.e("TAGG", step6) + + Handler(Looper.getMainLooper()).post { + callback(resultContacts) + showResultInDialog(context, resultContacts) + } + } + } + + private fun showResultInDialog(context: Context, contacts: java.util.ArrayList) { + val diff = System.currentTimeMillis() - startTime +// val msg = "loaded ${contacts.size} in $diff ms (init: $initToLoad + load: ${diff - initToLoad})"; +// Log.e("TAGG", msg) +// context.toast(msg) + val msgToShow = ContactsHelper.durations.joinToString(",\n") + "\n" + + val builder = AlertDialog.Builder(context) + builder.setTitle("${contacts.size} contacts loaded in $diff ms") + builder.setMessage(msgToShow) + + builder.show() + } + + private fun getContentResolverAccounts(): HashSet { + val sources = HashSet() + arrayOf(Groups.CONTENT_URI, Settings.CONTENT_URI, RawContacts.CONTENT_URI).forEach { + fillSourcesFromUri(it, sources) + } + + return sources + } + + private fun fillSourcesFromUri(uri: Uri, sources: HashSet) { + val projection = arrayOf( + RawContacts.ACCOUNT_NAME, + RawContacts.ACCOUNT_TYPE + ) + + context.queryCursor(uri, projection) { cursor -> + val name = cursor.getStringValue(RawContacts.ACCOUNT_NAME) ?: "" + val type = cursor.getStringValue(RawContacts.ACCOUNT_TYPE) ?: "" + var publicName = name + if (type == TELEGRAM_PACKAGE) { + publicName = context.getString(R.string.telegram) + } + + val source = ContactSource(name, type, publicName) + sources.add(source) + } + } + + private fun getDeviceContacts(contacts: SparseArray, ignoredContactSources: HashSet?, gettingDuplicates: Boolean) { + if (!context.hasPermission(PERMISSION_READ_CONTACTS)) { + return + } + + val ignoredSources = ignoredContactSources ?: context.baseConfig.ignoredContactSources + val uri = Data.CONTENT_URI + val projection = getContactProjection() + + arrayOf(CommonDataKinds.Organization.CONTENT_ITEM_TYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE).forEach { mimetype -> + val selection = "${Data.MIMETYPE} = ?" + val selectionArgs = arrayOf(mimetype) + val sortOrder = getSortString() + + context.queryCursor(uri, projection, selection, selectionArgs, sortOrder, true) { cursor -> + val accountName = cursor.getStringValue(RawContacts.ACCOUNT_NAME) ?: "" + val accountType = cursor.getStringValue(RawContacts.ACCOUNT_TYPE) ?: "" + + if (ignoredSources.contains("$accountName:$accountType")) { + return@queryCursor + } + + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + var prefix = "" + var firstName = "" + var middleName = "" + var surname = "" + var suffix = "" + + // ignore names at Organization type contacts + if (mimetype == CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) { + prefix = cursor.getStringValue(CommonDataKinds.StructuredName.PREFIX) ?: "" + firstName = cursor.getStringValue(CommonDataKinds.StructuredName.GIVEN_NAME) ?: "" + middleName = cursor.getStringValue(CommonDataKinds.StructuredName.MIDDLE_NAME) ?: "" + surname = cursor.getStringValue(CommonDataKinds.StructuredName.FAMILY_NAME) ?: "" + suffix = cursor.getStringValue(CommonDataKinds.StructuredName.SUFFIX) ?: "" + } + + var photoUri = "" + var starred = 0 + var contactId = 0 + var thumbnailUri = "" + var ringtone: String? = null + + if (!gettingDuplicates) { + photoUri = cursor.getStringValue(CommonDataKinds.StructuredName.PHOTO_URI) ?: "" + starred = cursor.getIntValue(CommonDataKinds.StructuredName.STARRED) + contactId = cursor.getIntValue(Data.CONTACT_ID) + thumbnailUri = cursor.getStringValue(CommonDataKinds.StructuredName.PHOTO_THUMBNAIL_URI) ?: "" + ringtone = cursor.getStringValue(CommonDataKinds.StructuredName.CUSTOM_RINGTONE) + } + + val nickname = "" + val numbers = ArrayList() // proper value is obtained below + val emails = ArrayList() + val addresses = ArrayList
() + val events = ArrayList() + val notes = "" + val groups = ArrayList() + val organization = Organization("", "") + val websites = ArrayList() + val ims = ArrayList() + val contact = Contact( + id, prefix, firstName, middleName, surname, suffix, nickname, photoUri, numbers, emails, addresses, + events, accountName, starred, contactId, thumbnailUri, null, notes, groups, organization, websites, ims, mimetype, ringtone + ) + + contacts.put(id, contact) + } + } + + val emails = getEmails() + var size = emails.size() + for (i in 0 until size) { + val key = emails.keyAt(i) + contacts[key]?.emails = emails.valueAt(i) + } + + val organizations = getOrganizations() + size = organizations.size() + for (i in 0 until size) { + val key = organizations.keyAt(i) + contacts[key]?.organization = organizations.valueAt(i) + } + + // no need to fetch some fields if we are only getting duplicates of the current contact + if (gettingDuplicates) { + return + } + + val phoneNumbers = getPhoneNumbers(null) + size = phoneNumbers.size() + for (i in 0 until size) { + val key = phoneNumbers.keyAt(i) + if (contacts[key] != null) { + val numbers = phoneNumbers.valueAt(i) + contacts[key].phoneNumbers = numbers + } + } + + val addresses = getAddresses() + size = addresses.size() + for (i in 0 until size) { + val key = addresses.keyAt(i) + contacts[key]?.addresses = addresses.valueAt(i) + } + + val IMs = getIMs() + size = IMs.size() + for (i in 0 until size) { + val key = IMs.keyAt(i) + contacts[key]?.IMs = IMs.valueAt(i) + } + + val events = getEvents() + size = events.size() + for (i in 0 until size) { + val key = events.keyAt(i) + contacts[key]?.events = events.valueAt(i) + } + + val notes = getNotes() + size = notes.size() + for (i in 0 until size) { + val key = notes.keyAt(i) + contacts[key]?.notes = notes.valueAt(i) + } + + val nicknames = getNicknames() + size = nicknames.size() + for (i in 0 until size) { + val key = nicknames.keyAt(i) + contacts[key]?.nickname = nicknames.valueAt(i) + } + + val websites = getWebsites() + size = websites.size() + for (i in 0 until size) { + val key = websites.keyAt(i) + contacts[key]?.websites = websites.valueAt(i) + } + } + + private fun getPhoneNumbers(contactId: Int? = null): SparseArray> { + val phoneNumbers = SparseArray>() + val uri = CommonDataKinds.Phone.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.Phone.NUMBER, + CommonDataKinds.Phone.NORMALIZED_NUMBER, + CommonDataKinds.Phone.TYPE, + CommonDataKinds.Phone.LABEL, + CommonDataKinds.Phone.IS_PRIMARY + ) + + val selection = if (contactId == null) getSourcesSelection() else "${Data.RAW_CONTACT_ID} = ?" + val selectionArgs = if (contactId == null) getSourcesSelectionArgs() else arrayOf(contactId.toString()) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val number = cursor.getStringValue(CommonDataKinds.Phone.NUMBER) ?: return@queryCursor + val normalizedNumber = cursor.getStringValue(CommonDataKinds.Phone.NORMALIZED_NUMBER) ?: number.normalizePhoneNumber() + val type = cursor.getIntValue(CommonDataKinds.Phone.TYPE) + val label = cursor.getStringValue(CommonDataKinds.Phone.LABEL) ?: "" + val isPrimary = cursor.getIntValue(CommonDataKinds.Phone.IS_PRIMARY) != 0 + + if (phoneNumbers[id] == null) { + phoneNumbers.put(id, ArrayList()) + } + + val phoneNumber = PhoneNumber(number, type, label, normalizedNumber, isPrimary) + phoneNumbers[id].add(phoneNumber) + } + + return phoneNumbers + } + + private fun getNicknames(contactId: Int? = null): SparseArray { + val nicknames = SparseArray() + val uri = Data.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.Nickname.NAME + ) + + val selection = getSourcesSelection(true, contactId != null) + val selectionArgs = getSourcesSelectionArgs(CommonDataKinds.Nickname.CONTENT_ITEM_TYPE, contactId) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val nickname = cursor.getStringValue(CommonDataKinds.Nickname.NAME) ?: return@queryCursor + nicknames.put(id, nickname) + } + + return nicknames + } + + private fun getEmails(contactId: Int? = null): SparseArray> { + val emails = SparseArray>() + val uri = CommonDataKinds.Email.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.Email.DATA, + CommonDataKinds.Email.TYPE, + CommonDataKinds.Email.LABEL + ) + + val selection = if (contactId == null) getSourcesSelection() else "${Data.RAW_CONTACT_ID} = ?" + val selectionArgs = if (contactId == null) getSourcesSelectionArgs() else arrayOf(contactId.toString()) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val email = cursor.getStringValue(CommonDataKinds.Email.DATA) ?: return@queryCursor + val type = cursor.getIntValue(CommonDataKinds.Email.TYPE) + val label = cursor.getStringValue(CommonDataKinds.Email.LABEL) ?: "" + + if (emails[id] == null) { + emails.put(id, ArrayList()) + } + + emails[id]!!.add(Email(email, type, label)) + } + + return emails + } + + private fun getAddresses(contactId: Int? = null): SparseArray> { + val addresses = SparseArray>() + val uri = CommonDataKinds.StructuredPostal.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, + CommonDataKinds.StructuredPostal.TYPE, + CommonDataKinds.StructuredPostal.LABEL + ) + + val selection = if (contactId == null) getSourcesSelection() else "${Data.RAW_CONTACT_ID} = ?" + val selectionArgs = if (contactId == null) getSourcesSelectionArgs() else arrayOf(contactId.toString()) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val address = cursor.getStringValue(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS) ?: return@queryCursor + val type = cursor.getIntValue(CommonDataKinds.StructuredPostal.TYPE) + val label = cursor.getStringValue(CommonDataKinds.StructuredPostal.LABEL) ?: "" + + if (addresses[id] == null) { + addresses.put(id, ArrayList()) + } + + addresses[id]!!.add(Address(address, type, label)) + } + + return addresses + } + + private fun getIMs(contactId: Int? = null): SparseArray> { + val IMs = SparseArray>() + val uri = Data.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.Im.DATA, + CommonDataKinds.Im.PROTOCOL, + CommonDataKinds.Im.CUSTOM_PROTOCOL + ) + + val selection = getSourcesSelection(true, contactId != null) + val selectionArgs = getSourcesSelectionArgs(CommonDataKinds.Im.CONTENT_ITEM_TYPE, contactId) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val IM = cursor.getStringValue(CommonDataKinds.Im.DATA) ?: return@queryCursor + val type = cursor.getIntValue(CommonDataKinds.Im.PROTOCOL) + val label = cursor.getStringValue(CommonDataKinds.Im.CUSTOM_PROTOCOL) ?: "" + + if (IMs[id] == null) { + IMs.put(id, ArrayList()) + } + + IMs[id]!!.add(IM(IM, type, label)) + } + + return IMs + } + + private fun getEvents(contactId: Int? = null): SparseArray> { + val events = SparseArray>() + val uri = Data.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.Event.START_DATE, + CommonDataKinds.Event.TYPE + ) + + val selection = getSourcesSelection(true, contactId != null) + val selectionArgs = getSourcesSelectionArgs(CommonDataKinds.Event.CONTENT_ITEM_TYPE, contactId) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val startDate = cursor.getStringValue(CommonDataKinds.Event.START_DATE) ?: return@queryCursor + val type = cursor.getIntValue(CommonDataKinds.Event.TYPE) + + if (events[id] == null) { + events.put(id, ArrayList()) + } + + events[id]!!.add(Event(startDate, type)) + } + + return events + } + + private fun getNotes(contactId: Int? = null): SparseArray { + val notes = SparseArray() + val uri = Data.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.Note.NOTE + ) + + val selection = getSourcesSelection(true, contactId != null) + val selectionArgs = getSourcesSelectionArgs(CommonDataKinds.Note.CONTENT_ITEM_TYPE, contactId) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val note = cursor.getStringValue(CommonDataKinds.Note.NOTE) ?: return@queryCursor + notes.put(id, note) + } + + return notes + } + + private fun getOrganizations(contactId: Int? = null): SparseArray { + val organizations = SparseArray() + val uri = Data.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.Organization.COMPANY, + CommonDataKinds.Organization.TITLE + ) + + val selection = getSourcesSelection(true, contactId != null) + val selectionArgs = getSourcesSelectionArgs(CommonDataKinds.Organization.CONTENT_ITEM_TYPE, contactId) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val company = cursor.getStringValue(CommonDataKinds.Organization.COMPANY) ?: "" + val title = cursor.getStringValue(CommonDataKinds.Organization.TITLE) ?: "" + if (company.isEmpty() && title.isEmpty()) { + return@queryCursor + } + + val organization = Organization(company, title) + organizations.put(id, organization) + } + + return organizations + } + + private fun getWebsites(contactId: Int? = null): SparseArray> { + val websites = SparseArray>() + val uri = Data.CONTENT_URI + val projection = arrayOf( + Data.RAW_CONTACT_ID, + CommonDataKinds.Website.URL + ) + + val selection = getSourcesSelection(true, contactId != null) + val selectionArgs = getSourcesSelectionArgs(CommonDataKinds.Website.CONTENT_ITEM_TYPE, contactId) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + val url = cursor.getStringValue(CommonDataKinds.Website.URL) ?: return@queryCursor + + if (websites[id] == null) { + websites.put(id, ArrayList()) + } + + websites[id]!!.add(url) + } + + return websites + } + + private fun getContactGroups(storedGroups: ArrayList, contactId: Int? = null): SparseArray> { + val groups = SparseArray>() + if (!context.hasPermission(PERMISSION_READ_CONTACTS)) { + return groups + } + + val uri = Data.CONTENT_URI + val projection = arrayOf( + Data.CONTACT_ID, + Data.DATA1 + ) + + val selection = getSourcesSelection(true, contactId != null, false) + val selectionArgs = getSourcesSelectionArgs(CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE, contactId) + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getIntValue(Data.CONTACT_ID) + val newRowId = cursor.getLongValue(Data.DATA1) + + val groupTitle = storedGroups.firstOrNull { it.id == newRowId }?.title ?: return@queryCursor + val group = Group(newRowId, groupTitle) + if (groups[id] == null) { + groups.put(id, ArrayList()) + } + groups[id]!!.add(group) + } + + return groups + } + + private fun getQuestionMarks() = ("?," * displayContactSources.filter { it.isNotEmpty() }.size).trimEnd(',') + + private fun getSourcesSelection(addMimeType: Boolean = false, addContactId: Boolean = false, useRawContactId: Boolean = true): String { + val strings = ArrayList() + if (addMimeType) { + strings.add("${Data.MIMETYPE} = ?") + } + + if (addContactId) { + strings.add("${if (useRawContactId) Data.RAW_CONTACT_ID else Data.CONTACT_ID} = ?") + } else { + // sometimes local device storage has null account_name, handle it properly + val accountnameString = StringBuilder() + if (displayContactSources.contains("")) { + accountnameString.append("(") + } + accountnameString.append("${RawContacts.ACCOUNT_NAME} IN (${getQuestionMarks()})") + if (displayContactSources.contains("")) { + accountnameString.append(" OR ${RawContacts.ACCOUNT_NAME} IS NULL)") + } + strings.add(accountnameString.toString()) + } + + return TextUtils.join(" AND ", strings) + } + + private fun getSourcesSelectionArgs(mimetype: String? = null, contactId: Int? = null): Array { + val args = ArrayList() + + if (mimetype != null) { + args.add(mimetype) + } + + if (contactId != null) { + args.add(contactId.toString()) + } else { + args.addAll(displayContactSources.filter { it.isNotEmpty() }) + } + + return args.toTypedArray() + } + + fun getStoredGroups(callback: (ArrayList) -> Unit) { + ensureBackgroundThread { + val groups = getStoredGroupsSync() + Handler(Looper.getMainLooper()).post { + callback(groups) + } + } + } + + fun getStoredGroupsSync(): ArrayList { + val groups = getDeviceStoredGroups() + groups.addAll(context.groupsDB.getGroups()) + return groups + } + + private fun getDeviceStoredGroups(): ArrayList { + val groups = ArrayList() + if (!context.hasPermission(PERMISSION_READ_CONTACTS)) { + return groups + } + + val uri = Groups.CONTENT_URI + val projection = arrayOf( + Groups._ID, + Groups.TITLE, + Groups.SYSTEM_ID + ) + + val selection = "${Groups.AUTO_ADD} = ? AND ${Groups.FAVORITES} = ?" + val selectionArgs = arrayOf("0", "0") + + context.queryCursor(uri, projection, selection, selectionArgs, showErrors = true) { cursor -> + val id = cursor.getLongValue(Groups._ID) + val title = cursor.getStringValue(Groups.TITLE) ?: return@queryCursor + + val systemId = cursor.getStringValue(Groups.SYSTEM_ID) + if (groups.map { it.title }.contains(title) && systemId != null) { + return@queryCursor + } + + groups.add(Group(id, title)) + } + return groups + } + + fun createNewGroup(title: String, accountName: String, accountType: String): Group? { + if (accountType == SMT_PRIVATE) { + val newGroup = Group(null, title) + val id = context.groupsDB.insertOrUpdate(newGroup) + newGroup.id = id + return newGroup + } + + val operations = ArrayList() + ContentProviderOperation.newInsert(Groups.CONTENT_URI).apply { + withValue(Groups.TITLE, title) + withValue(Groups.GROUP_VISIBLE, 1) + withValue(Groups.ACCOUNT_NAME, accountName) + withValue(Groups.ACCOUNT_TYPE, accountType) + operations.add(build()) + } + + try { + val results = context.contentResolver.applyBatch(AUTHORITY, operations) + val rawId = ContentUris.parseId(results[0].uri!!) + return Group(rawId, title) + } catch (e: Exception) { + context.showErrorToast(e) + } + return null + } + + fun renameGroup(group: Group) { + val operations = ArrayList() + ContentProviderOperation.newUpdate(Groups.CONTENT_URI).apply { + val selection = "${Groups._ID} = ?" + val selectionArgs = arrayOf(group.id.toString()) + withSelection(selection, selectionArgs) + withValue(Groups.TITLE, group.title) + operations.add(build()) + } + + try { + context.contentResolver.applyBatch(AUTHORITY, operations) + } catch (e: Exception) { + context.showErrorToast(e) + } + } + + fun deleteGroup(id: Long) { + val operations = ArrayList() + val uri = ContentUris.withAppendedId(Groups.CONTENT_URI, id).buildUpon() + .appendQueryParameter(CALLER_IS_SYNCADAPTER, "true") + .build() + + operations.add(ContentProviderOperation.newDelete(uri).build()) + + try { + context.contentResolver.applyBatch(AUTHORITY, operations) + } catch (e: Exception) { + context.showErrorToast(e) + } + } + + fun getContactWithId(id: Int, isLocalPrivate: Boolean): Contact? { + if (id == 0) { + return null + } else if (isLocalPrivate) { + return LocalContactsHelper(context).getContactWithId(id) + } + + val selection = "(${Data.MIMETYPE} = ? OR ${Data.MIMETYPE} = ?) AND ${Data.RAW_CONTACT_ID} = ?" + val selectionArgs = arrayOf(CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE, CommonDataKinds.Organization.CONTENT_ITEM_TYPE, id.toString()) + return parseContactCursor(selection, selectionArgs) + } + + fun getContactFromUri(uri: Uri): Contact? { + val key = getLookupKeyFromUri(uri) ?: return null + return getContactWithLookupKey(key) + } + + private fun getLookupKeyFromUri(lookupUri: Uri): String? { + val projection = arrayOf(ContactsContract.Contacts.LOOKUP_KEY) + val cursor = context.contentResolver.query(lookupUri, projection, null, null, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getStringValue(ContactsContract.Contacts.LOOKUP_KEY) + } + } + return null + } + + fun getContactWithLookupKey(key: String): Contact? { + val selection = "(${Data.MIMETYPE} = ? OR ${Data.MIMETYPE} = ?) AND ${Data.LOOKUP_KEY} = ?" + val selectionArgs = arrayOf(CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE, CommonDataKinds.Organization.CONTENT_ITEM_TYPE, key) + return parseContactCursor(selection, selectionArgs) + } + + private fun parseContactCursor(selection: String, selectionArgs: Array): Contact? { + val storedGroups = getStoredGroupsSync() + val uri = Data.CONTENT_URI + val projection = getContactProjection() + + val cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + val id = cursor.getIntValue(Data.RAW_CONTACT_ID) + + var prefix = "" + var firstName = "" + var middleName = "" + var surname = "" + var suffix = "" + var mimetype = cursor.getStringValue(Data.MIMETYPE) + + // if first line is an Organization type contact, go to next line if available + if (mimetype != CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) { + if (!cursor.isLast() && cursor.moveToNext()) { + mimetype = cursor.getStringValue(Data.MIMETYPE) + } + } + // ignore names at Organization type contacts + if (mimetype == CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) { + prefix = cursor.getStringValue(CommonDataKinds.StructuredName.PREFIX) ?: "" + firstName = cursor.getStringValue(CommonDataKinds.StructuredName.GIVEN_NAME) ?: "" + middleName = cursor.getStringValue(CommonDataKinds.StructuredName.MIDDLE_NAME) ?: "" + surname = cursor.getStringValue(CommonDataKinds.StructuredName.FAMILY_NAME) ?: "" + suffix = cursor.getStringValue(CommonDataKinds.StructuredName.SUFFIX) ?: "" + } + + val nickname = getNicknames(id)[id] ?: "" + val photoUri = cursor.getStringValueOrNull(CommonDataKinds.Phone.PHOTO_URI) ?: "" + val number = getPhoneNumbers(id)[id] ?: ArrayList() + val emails = getEmails(id)[id] ?: ArrayList() + val addresses = getAddresses(id)[id] ?: ArrayList() + val events = getEvents(id)[id] ?: ArrayList() + val notes = getNotes(id)[id] ?: "" + val accountName = cursor.getStringValue(RawContacts.ACCOUNT_NAME) ?: "" + val starred = cursor.getIntValue(CommonDataKinds.StructuredName.STARRED) + val ringtone = cursor.getStringValue(CommonDataKinds.StructuredName.CUSTOM_RINGTONE) + val contactId = cursor.getIntValue(Data.CONTACT_ID) + val groups = getContactGroups(storedGroups, contactId)[contactId] ?: ArrayList() + val thumbnailUri = cursor.getStringValue(CommonDataKinds.StructuredName.PHOTO_THUMBNAIL_URI) ?: "" + val organization = getOrganizations(id)[id] ?: Organization("", "") + val websites = getWebsites(id)[id] ?: ArrayList() + val ims = getIMs(id)[id] ?: ArrayList() + return Contact( + id, prefix, firstName, middleName, surname, suffix, nickname, photoUri, number, emails, addresses, events, + accountName, starred, contactId, thumbnailUri, null, notes, groups, organization, websites, ims, mimetype, ringtone + ) + } + } + + return null + } + + fun getContactSources(callback: (ArrayList) -> Unit) { + ensureBackgroundThread { + callback(getContactSourcesSync()) + } + } + + private fun getContactSourcesSync(): ArrayList { + val sources = getDeviceContactSources() + sources.add(context.getPrivateContactSource()) + return ArrayList(sources) + } + + fun getSaveableContactSources(callback: (ArrayList) -> Unit) { + ensureBackgroundThread { + val ignoredTypes = arrayListOf( + SIGNAL_PACKAGE, + TELEGRAM_PACKAGE, + WHATSAPP_PACKAGE, + THREEMA_PACKAGE + ) + + val contactSources = getContactSourcesSync() + val filteredSources = contactSources.filter { !ignoredTypes.contains(it.type) }.toMutableList() as ArrayList + callback(filteredSources) + } + } + + fun getDeviceContactSources(): LinkedHashSet { + val sources = LinkedHashSet() + if (!context.hasPermission(PERMISSION_READ_CONTACTS)) { + return sources + } + + if (!context.baseConfig.wasLocalAccountInitialized) { + initializeLocalPhoneAccount() + context.baseConfig.wasLocalAccountInitialized = true + } + + val accounts = AccountManager.get(context).accounts + + if (context.hasPermission(PERMISSION_READ_SYNC_SETTINGS)) { + accounts.forEach { + if (ContentResolver.getIsSyncable(it, AUTHORITY) == 1) { + var publicName = it.name + if (it.type == TELEGRAM_PACKAGE) { + publicName = context.getString(R.string.telegram) + } else if (it.type == VIBER_PACKAGE) { + publicName = context.getString(R.string.viber) + } + val contactSource = ContactSource(it.name, it.type, publicName) + sources.add(contactSource) + } + } + } + + var hadEmptyAccount = false + val allAccounts = getContentResolverAccounts() + val contentResolverAccounts = allAccounts.filter { + if (it.name.isEmpty() && it.type.isEmpty() && allAccounts.none { it.name.lowercase(Locale.getDefault()) == "phone" }) { + hadEmptyAccount = true + } + + it.name.isNotEmpty() && it.type.isNotEmpty() && !accounts.contains(Account(it.name, it.type)) + } + sources.addAll(contentResolverAccounts) + + if (hadEmptyAccount) { + sources.add(ContactSource("", "", context.getString(R.string.phone_storage))) + } + + return sources + } + + // make sure the local Phone contact source is initialized and available + // https://stackoverflow.com/a/6096508/1967672 + private fun initializeLocalPhoneAccount() { + try { + val operations = ArrayList() + ContentProviderOperation.newInsert(RawContacts.CONTENT_URI).apply { + withValue(RawContacts.ACCOUNT_NAME, null) + withValue(RawContacts.ACCOUNT_TYPE, null) + operations.add(build()) + } + + val results = context.contentResolver.applyBatch(AUTHORITY, operations) + val rawContactUri = results.firstOrNull()?.uri ?: return + context.contentResolver.delete(rawContactUri, null, null) + } catch (ignored: Exception) { + } + } + + private fun getContactSourceType(accountName: String) = getDeviceContactSources().firstOrNull { it.name == accountName }?.type ?: "" + + private fun getContactProjection() = arrayOf( + Data.MIMETYPE, + Data.CONTACT_ID, + Data.RAW_CONTACT_ID, + CommonDataKinds.StructuredName.PREFIX, + CommonDataKinds.StructuredName.GIVEN_NAME, + CommonDataKinds.StructuredName.MIDDLE_NAME, + CommonDataKinds.StructuredName.FAMILY_NAME, + CommonDataKinds.StructuredName.SUFFIX, + CommonDataKinds.StructuredName.PHOTO_URI, + CommonDataKinds.StructuredName.PHOTO_THUMBNAIL_URI, + CommonDataKinds.StructuredName.STARRED, + CommonDataKinds.StructuredName.CUSTOM_RINGTONE, + RawContacts.ACCOUNT_NAME, + RawContacts.ACCOUNT_TYPE + ) + + private fun getSortString(): String { + val sorting = context.baseConfig.sorting + return when { + sorting and SORT_BY_FIRST_NAME != 0 -> "${CommonDataKinds.StructuredName.GIVEN_NAME} COLLATE NOCASE" + sorting and SORT_BY_MIDDLE_NAME != 0 -> "${CommonDataKinds.StructuredName.MIDDLE_NAME} COLLATE NOCASE" + sorting and SORT_BY_SURNAME != 0 -> "${CommonDataKinds.StructuredName.FAMILY_NAME} COLLATE NOCASE" + sorting and SORT_BY_FULL_NAME != 0 -> CommonDataKinds.StructuredName.DISPLAY_NAME + else -> Data.RAW_CONTACT_ID + } + } + + private fun getRealContactId(id: Long): Int { + val uri = Data.CONTENT_URI + val projection = getContactProjection() + val selection = "(${Data.MIMETYPE} = ? OR ${Data.MIMETYPE} = ?) AND ${Data.RAW_CONTACT_ID} = ?" + val selectionArgs = arrayOf(CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE, CommonDataKinds.Organization.CONTENT_ITEM_TYPE, id.toString()) + + val cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getIntValue(Data.CONTACT_ID) + } + } + + return 0 + } + + fun updateContact(contact: Contact, photoUpdateStatus: Int): Boolean { + context.toast(R.string.updating) + if (contact.isPrivate()) { + return LocalContactsHelper(context).insertOrUpdateContact(contact) + } + + try { + val operations = ArrayList() + ContentProviderOperation.newUpdate(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ?" + val selectionArgs = arrayOf(contact.id.toString(), contact.mimetype) + withSelection(selection, selectionArgs) + withValue(CommonDataKinds.StructuredName.PREFIX, contact.prefix) + withValue(CommonDataKinds.StructuredName.GIVEN_NAME, contact.firstName) + withValue(CommonDataKinds.StructuredName.MIDDLE_NAME, contact.middleName) + withValue(CommonDataKinds.StructuredName.FAMILY_NAME, contact.surname) + withValue(CommonDataKinds.StructuredName.SUFFIX, contact.suffix) + operations.add(build()) + } + + // delete nickname + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Nickname.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add nickname + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Nickname.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Nickname.NAME, contact.nickname) + operations.add(build()) + } + + // delete phone numbers + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add phone numbers + contact.phoneNumbers.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Phone.NUMBER, it.value) + withValue(CommonDataKinds.Phone.NORMALIZED_NUMBER, it.normalizedNumber) + withValue(CommonDataKinds.Phone.TYPE, it.type) + withValue(CommonDataKinds.Phone.LABEL, it.label) + withValue(CommonDataKinds.Phone.IS_PRIMARY, it.isPrimary) + operations.add(build()) + } + } + + // delete emails + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Email.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add emails + contact.emails.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Email.DATA, it.value) + withValue(CommonDataKinds.Email.TYPE, it.type) + withValue(CommonDataKinds.Email.LABEL, it.label) + operations.add(build()) + } + } + + // delete addresses + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add addresses + contact.addresses.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, it.value) + withValue(CommonDataKinds.StructuredPostal.TYPE, it.type) + withValue(CommonDataKinds.StructuredPostal.LABEL, it.label) + operations.add(build()) + } + } + + // delete IMs + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Im.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add IMs + contact.IMs.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Im.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Im.DATA, it.value) + withValue(CommonDataKinds.Im.PROTOCOL, it.type) + withValue(CommonDataKinds.Im.CUSTOM_PROTOCOL, it.label) + operations.add(build()) + } + } + + // delete events + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Event.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add events + contact.events.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Event.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Event.START_DATE, it.value) + withValue(CommonDataKinds.Event.TYPE, it.type) + operations.add(build()) + } + } + + // delete notes + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Note.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add notes + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Note.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Note.NOTE, contact.notes) + operations.add(build()) + } + + // delete organization + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Organization.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add organization + if (contact.organization.isNotEmpty()) { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Organization.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Organization.COMPANY, contact.organization.company) + withValue(CommonDataKinds.Organization.TYPE, DEFAULT_ORGANIZATION_TYPE) + withValue(CommonDataKinds.Organization.TITLE, contact.organization.jobPosition) + withValue(CommonDataKinds.Organization.TYPE, DEFAULT_ORGANIZATION_TYPE) + operations.add(build()) + } + } + + // delete websites + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? " + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Website.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + // add websites + contact.websites.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Website.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Website.URL, it) + withValue(CommonDataKinds.Website.TYPE, DEFAULT_WEBSITE_TYPE) + operations.add(build()) + } + } + + // delete groups + val relevantGroupIDs = getStoredGroupsSync().map { it.id } + if (relevantGroupIDs.isNotEmpty()) { + val IDsString = TextUtils.join(",", relevantGroupIDs) + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? AND ${Data.DATA1} IN ($IDsString)" + val selectionArgs = arrayOf(contact.contactId.toString(), CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + } + + // add groups + contact.groups.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.GroupMembership.GROUP_ROW_ID, it.id) + operations.add(build()) + } + } + + // favorite, ringtone + try { + val uri = Uri.withAppendedPath(Contacts.CONTENT_URI, contact.contactId.toString()) + val contentValues = ContentValues(2) + contentValues.put(Contacts.STARRED, contact.starred) + contentValues.put(Contacts.CUSTOM_RINGTONE, contact.ringtone) + context.contentResolver.update(uri, contentValues, null, null) + } catch (e: Exception) { + context.showErrorToast(e) + } + + // photo + when (photoUpdateStatus) { + PHOTO_ADDED, PHOTO_CHANGED -> addPhoto(contact, operations) + PHOTO_REMOVED -> removePhoto(contact, operations) + } + + context.contentResolver.applyBatch(AUTHORITY, operations) + return true + } catch (e: Exception) { + context.showErrorToast(e) + return false + } + } + + private fun addPhoto(contact: Contact, operations: ArrayList): ArrayList { + if (contact.photoUri.isNotEmpty()) { + val photoUri = Uri.parse(contact.photoUri) + val bitmap = MediaStore.Images.Media.getBitmap(context.contentResolver, photoUri) + + val thumbnailSize = context.getPhotoThumbnailSize() + val scaledPhoto = Bitmap.createScaledBitmap(bitmap, thumbnailSize, thumbnailSize, false) + val scaledSizePhotoData = scaledPhoto.getByteArray() + scaledPhoto.recycle() + + val fullSizePhotoData = bitmap.getByteArray() + bitmap.recycle() + + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, contact.id) + withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Photo.PHOTO, scaledSizePhotoData) + operations.add(build()) + } + + addFullSizePhoto(contact.id.toLong(), fullSizePhotoData) + } + return operations + } + + private fun removePhoto(contact: Contact, operations: ArrayList): ArrayList { + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ?" + val selectionArgs = arrayOf(contact.id.toString(), CommonDataKinds.Photo.CONTENT_ITEM_TYPE) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + return operations + } + + fun addContactsToGroup(contacts: ArrayList, groupId: Long) { + try { + val operations = ArrayList() + contacts.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValue(Data.RAW_CONTACT_ID, it.id) + withValue(Data.MIMETYPE, CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.GroupMembership.GROUP_ROW_ID, groupId) + operations.add(build()) + } + + if (operations.size % BATCH_SIZE == 0) { + context.contentResolver.applyBatch(AUTHORITY, operations) + operations.clear() + } + } + + context.contentResolver.applyBatch(AUTHORITY, operations) + } catch (e: Exception) { + context.showErrorToast(e) + } + } + + fun removeContactsFromGroup(contacts: ArrayList, groupId: Long) { + try { + val operations = ArrayList() + contacts.forEach { + ContentProviderOperation.newDelete(Data.CONTENT_URI).apply { + val selection = "${Data.CONTACT_ID} = ? AND ${Data.MIMETYPE} = ? AND ${Data.DATA1} = ?" + val selectionArgs = arrayOf(it.contactId.toString(), CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + if (operations.size % BATCH_SIZE == 0) { + context.contentResolver.applyBatch(AUTHORITY, operations) + operations.clear() + } + } + context.contentResolver.applyBatch(AUTHORITY, operations) + } catch (e: Exception) { + context.showErrorToast(e) + } + } + + fun insertContact(contact: Contact): Boolean { + if (contact.isPrivate()) { + return LocalContactsHelper(context).insertOrUpdateContact(contact) + } + + try { + val operations = ArrayList() + ContentProviderOperation.newInsert(RawContacts.CONTENT_URI).apply { + withValue(RawContacts.ACCOUNT_NAME, contact.source) + withValue(RawContacts.ACCOUNT_TYPE, getContactSourceType(contact.source)) + operations.add(build()) + } + + // names + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.StructuredName.PREFIX, contact.prefix) + withValue(CommonDataKinds.StructuredName.GIVEN_NAME, contact.firstName) + withValue(CommonDataKinds.StructuredName.MIDDLE_NAME, contact.middleName) + withValue(CommonDataKinds.StructuredName.FAMILY_NAME, contact.surname) + withValue(CommonDataKinds.StructuredName.SUFFIX, contact.suffix) + operations.add(build()) + } + + // nickname + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.Nickname.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Nickname.NAME, contact.nickname) + operations.add(build()) + } + + // phone numbers + contact.phoneNumbers.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Phone.NUMBER, it.value) + withValue(CommonDataKinds.Phone.NORMALIZED_NUMBER, it.normalizedNumber) + withValue(CommonDataKinds.Phone.TYPE, it.type) + withValue(CommonDataKinds.Phone.LABEL, it.label) + withValue(CommonDataKinds.Phone.IS_PRIMARY, it.isPrimary) + operations.add(build()) + } + } + + // emails + contact.emails.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Email.DATA, it.value) + withValue(CommonDataKinds.Email.TYPE, it.type) + withValue(CommonDataKinds.Email.LABEL, it.label) + operations.add(build()) + } + } + + // addresses + contact.addresses.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, it.value) + withValue(CommonDataKinds.StructuredPostal.TYPE, it.type) + withValue(CommonDataKinds.StructuredPostal.LABEL, it.label) + operations.add(build()) + } + } + + // IMs + contact.IMs.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.Im.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Im.DATA, it.value) + withValue(CommonDataKinds.Im.PROTOCOL, it.type) + withValue(CommonDataKinds.Im.CUSTOM_PROTOCOL, it.label) + operations.add(build()) + } + } + + // events + contact.events.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.Event.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Event.START_DATE, it.value) + withValue(CommonDataKinds.Event.TYPE, it.type) + operations.add(build()) + } + } + + // notes + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.Note.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Note.NOTE, contact.notes) + operations.add(build()) + } + + // organization + if (contact.organization.isNotEmpty()) { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.Organization.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Organization.COMPANY, contact.organization.company) + withValue(CommonDataKinds.Organization.TYPE, DEFAULT_ORGANIZATION_TYPE) + withValue(CommonDataKinds.Organization.TITLE, contact.organization.jobPosition) + withValue(CommonDataKinds.Organization.TYPE, DEFAULT_ORGANIZATION_TYPE) + operations.add(build()) + } + } + + // websites + contact.websites.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.Website.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.Website.URL, it) + withValue(CommonDataKinds.Website.TYPE, DEFAULT_WEBSITE_TYPE) + operations.add(build()) + } + } + + // groups + contact.groups.forEach { + ContentProviderOperation.newInsert(Data.CONTENT_URI).apply { + withValueBackReference(Data.RAW_CONTACT_ID, 0) + withValue(Data.MIMETYPE, CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE) + withValue(CommonDataKinds.GroupMembership.GROUP_ROW_ID, it.id) + operations.add(build()) + } + } + + // photo (inspired by https://gist.github.com/slightfoot/5985900) + var fullSizePhotoData: ByteArray? = null + if (contact.photoUri.isNotEmpty()) { + val photoUri = Uri.parse(contact.photoUri) + fullSizePhotoData = context.contentResolver.openInputStream(photoUri)?.readBytes() + } + + val results = context.contentResolver.applyBatch(AUTHORITY, operations) + + // storing contacts on some devices seems to be messed up and they move on Phone instead, or disappear completely + // try storing a lighter contact version with this oldschool version too just so it wont disappear, future edits work well + if (getContactSourceType(contact.source).contains(".sim")) { + val simUri = Uri.parse("content://icc/adn") + ContentValues().apply { + put("number", contact.phoneNumbers.firstOrNull()?.value ?: "") + put("tag", contact.getNameToDisplay()) + context.contentResolver.insert(simUri, this) + } + } + + // fullsize photo + val rawId = ContentUris.parseId(results[0].uri!!) + if (contact.photoUri.isNotEmpty() && fullSizePhotoData != null) { + addFullSizePhoto(rawId, fullSizePhotoData) + } + + // favorite, ringtone + val userId = getRealContactId(rawId) + if (userId != 0) { + val uri = Uri.withAppendedPath(Contacts.CONTENT_URI, userId.toString()) + val contentValues = ContentValues(2) + contentValues.put(Contacts.STARRED, contact.starred) + contentValues.put(Contacts.CUSTOM_RINGTONE, contact.ringtone) + context.contentResolver.update(uri, contentValues, null, null) + } + + return true + } catch (e: Exception) { + context.showErrorToast(e) + return false + } + } + + private fun addFullSizePhoto(contactId: Long, fullSizePhotoData: ByteArray) { + val baseUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, contactId) + val displayPhotoUri = Uri.withAppendedPath(baseUri, RawContacts.DisplayPhoto.CONTENT_DIRECTORY) + val fileDescriptor = context.contentResolver.openAssetFileDescriptor(displayPhotoUri, "rw") + val photoStream = fileDescriptor!!.createOutputStream() + photoStream.write(fullSizePhotoData) + photoStream.close() + fileDescriptor.close() + } + + fun getContactMimeTypeId(contactId: String, mimeType: String): String { + val uri = Data.CONTENT_URI + val projection = arrayOf(Data._ID, Data.RAW_CONTACT_ID, Data.MIMETYPE) + val selection = "${Data.MIMETYPE} = ? AND ${Data.RAW_CONTACT_ID} = ?" + val selectionArgs = arrayOf(mimeType, contactId) + + + val cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null) + cursor?.use { + if (cursor.moveToFirst()) { + return cursor.getStringValue(Data._ID) + } + } + return "" + } + + fun addFavorites(contacts: ArrayList) { + ensureBackgroundThread { + toggleLocalFavorites(contacts, true) + if (context.hasContactPermissions()) { + toggleFavorites(contacts, true) + } + } + } + + fun removeFavorites(contacts: ArrayList) { + ensureBackgroundThread { + toggleLocalFavorites(contacts, false) + if (context.hasContactPermissions()) { + toggleFavorites(contacts, false) + } + } + } + + private fun toggleFavorites(contacts: ArrayList, addToFavorites: Boolean) { + try { + val operations = ArrayList() + contacts.filter { !it.isPrivate() }.map { it.contactId.toString() }.forEach { + val uri = Uri.withAppendedPath(Contacts.CONTENT_URI, it) + ContentProviderOperation.newUpdate(uri).apply { + withValue(Contacts.STARRED, if (addToFavorites) 1 else 0) + operations.add(build()) + } + + if (operations.size % BATCH_SIZE == 0) { + context.contentResolver.applyBatch(AUTHORITY, operations) + operations.clear() + } + } + context.contentResolver.applyBatch(AUTHORITY, operations) + } catch (e: Exception) { + context.showErrorToast(e) + } + } + + private fun toggleLocalFavorites(contacts: ArrayList, addToFavorites: Boolean) { + val localContacts = contacts.filter { it.isPrivate() }.map { it.id }.toTypedArray() + LocalContactsHelper(context).toggleFavorites(localContacts, addToFavorites) + } + + fun updateRingtone(contactId: String, newUri: String) { + try { + val operations = ArrayList() + val uri = Uri.withAppendedPath(Contacts.CONTENT_URI, contactId) + ContentProviderOperation.newUpdate(uri).apply { + withValue(Contacts.CUSTOM_RINGTONE, newUri) + operations.add(build()) + } + + context.contentResolver.applyBatch(AUTHORITY, operations) + } catch (e: Exception) { + context.showErrorToast(e) + } + } + + fun deleteContact(originalContact: Contact, deleteClones: Boolean = false, callback: (success: Boolean) -> Unit) { + ensureBackgroundThread { + if (deleteClones) { + getDuplicatesOfContact(originalContact, true) { contacts -> + ensureBackgroundThread { + if (deleteContacts(contacts)) { + callback(true) + } + } + } + } else { + if (deleteContacts(arrayListOf(originalContact))) { + callback(true) + } + } + } + } + + fun deleteContacts(contacts: ArrayList): Boolean { + val localContacts = contacts.filter { it.isPrivate() }.map { it.id.toLong() }.toMutableList() + LocalContactsHelper(context).deleteContactIds(localContacts) + + return try { + val operations = ArrayList() + val selection = "${RawContacts._ID} = ?" + contacts.filter { !it.isPrivate() }.forEach { + ContentProviderOperation.newDelete(RawContacts.CONTENT_URI).apply { + val selectionArgs = arrayOf(it.id.toString()) + withSelection(selection, selectionArgs) + operations.add(build()) + } + + if (operations.size % BATCH_SIZE == 0) { + context.contentResolver.applyBatch(AUTHORITY, operations) + operations.clear() + } + } + + if (context.hasPermission(PERMISSION_WRITE_CONTACTS)) { + context.contentResolver.applyBatch(AUTHORITY, operations) + } + true + } catch (e: Exception) { + context.showErrorToast(e) + false + } + } + + fun getDuplicatesOfContact(contact: Contact, addOriginal: Boolean, callback: (ArrayList) -> Unit) { + ensureBackgroundThread { + getContacts(true, true) { contacts -> + val duplicates = + contacts.filter { it.id != contact.id && it.getHashToCompare() == contact.getHashToCompare() }.toMutableList() as ArrayList + if (addOriginal) { + duplicates.add(contact) + } + callback(duplicates) + } + } + } + + fun getContactsToExport(selectedContactSources: Set, callback: (List) -> Unit) { + getContacts(getAll = true) { receivedContacts -> + val contacts = receivedContacts.filter { it.source in selectedContactSources } + callback(contacts) + } + } + + fun exportContacts(contacts: List, outputStream: OutputStream): ExportResult { + return try { + val jsonString = Json.encodeToString(contacts) + outputStream.use { + it.write(jsonString.toByteArray()) + } + ExportResult.EXPORT_OK + } catch (_: Error) { + ExportResult.EXPORT_FAIL + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9a736eea..58e61b87 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,6 @@ plugins { alias(libs.plugins.android).apply(false) alias(libs.plugins.kotlinAndroid).apply(false) alias(libs.plugins.ksp).apply(false) + alias(libs.plugins.kotlinSerialization).apply(false) + } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 116bfea4..66981556 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,8 @@ kotlin = "1.9.0" #KSP ksp = "1.9.0-1.0.12" +kotlinxSerializationJson = "1.5.1" + #AndroidX androidx-swiperefreshlayout = "1.1.0" #AutoFitTextView @@ -30,6 +32,8 @@ app-version-versionName = "6.22.7" [libraries] #AndroidX androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } + #AutoFitTextView autofittextview = { module = "me.grantland:autofittextview", version.ref = "autofittextview" } #EzVcard @@ -51,3 +55,4 @@ room = [ android = { id = "com.android.application", version.ref = "gradlePlugins-agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }