diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 70211083b..b19c729f3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,10 +48,18 @@ android:exported="false" android:theme="@style/SplashTheme" /> + + android:exported="true" + android:grantUriPermissions="true"> @@ -60,6 +68,15 @@ + + + + + + + + + diff --git a/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt index a2022aa17..15855f2b8 100644 --- a/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt @@ -7,6 +7,8 @@ import android.content.pm.ShortcutInfo import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Icon import android.graphics.drawable.LayerDrawable +import android.net.Uri +import android.os.Build import android.os.Bundle import androidx.viewpager.widget.ViewPager import me.grantland.widget.AutofitHelper @@ -366,19 +368,90 @@ class MainActivity : SimpleActivity(), RefreshContactsListener { refreshMenuItems() } - if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { - tryImportContactsFromFile(intent.data!!) { - if (it) { - runOnUiThread { - refreshContacts(ALL_TABS_MASK) + handleContactImportIntent(intent) + + binding.mainDialpadButton.setOnClickListener { + launchDialpad() + } + } + + /** + * Handles incoming intents to import contacts. This function processes intents with actions + * ACTION_VIEW, ACTION_SEND, and ACTION_SEND_MULTIPLE for vCard MIME types. It extracts the + * relevant URIs and passes them to the contact import handler. + * + * @param intent The incoming intent to process. + */ + private fun handleContactImportIntent(intent: Intent?) { + if (intent?.action == null) { + return + } + + val contactUris = getUrisFromIntent(intent) + + if (contactUris.isNotEmpty()) { + for (uri in contactUris) { + tryImportContactsFromFile(uri) { isSuccess -> + if (isSuccess) { + runOnUiThread { + refreshContacts(ALL_TABS_MASK) + } } } } - intent.data = null + // Prevents the intent from being processed again on activity recreation (e.g., orientation change). + intent.action = null } + } - binding.mainDialpadButton.setOnClickListener { - launchDialpad() + /** + * Extracts a list of URIs from an intent, supporting various actions and data types. + * + * @param intent The intent to parse. + * @return A list of URIs found in the intent, or an empty list if none are found. + */ + private fun getUrisFromIntent(intent: Intent): List { + val supportedMimeTypes = listOf("text/x-vcard", "text/vcard") + + return when (intent.action) { + Intent.ACTION_VIEW -> { + intent.data?.let { listOf(it) } ?: emptyList() + } + + Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> { + // Ensure the MIME type is supported before proceeding. + if (!supportedMimeTypes.contains(intent.type)) { + return emptyList() + } + + if (intent.action == Intent.ACTION_SEND) { + getParcelableUri(intent, Intent.EXTRA_STREAM)?.let { listOf(it) } ?: emptyList() + } else { + getParcelableUriList(intent, Intent.EXTRA_STREAM) ?: emptyList() + } + } + + else -> emptyList() + } + } + + // Helper function to abstract away the version check for a single Parcelable. + private fun getParcelableUri(intent: Intent, key: String): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(key, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(key) + } + } + + // Helper function to abstract away the version check for a list of Parcelables. + private fun getParcelableUriList(intent: Intent, key: String): List? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(key, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(key) } } diff --git a/app/src/main/kotlin/org/fossify/contacts/extensions/Activity.kt b/app/src/main/kotlin/org/fossify/contacts/extensions/Activity.kt index 7c0af7236..48b28f7a8 100644 --- a/app/src/main/kotlin/org/fossify/contacts/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/contacts/extensions/Activity.kt @@ -3,6 +3,10 @@ package org.fossify.contacts.extensions import android.app.Activity import android.content.Intent import android.net.Uri +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.dialogs.RadioGroupDialog import org.fossify.commons.extensions.getFileOutputStream @@ -187,26 +191,42 @@ fun Activity.editContact(contact: Contact) { } fun SimpleActivity.tryImportContactsFromFile(uri: Uri, callback: (Boolean) -> Unit) { - when (uri.scheme) { - "file" -> showImportContactsDialog(uri.path!!, callback) - "content" -> { - val tempFile = getTempFile() - if (tempFile == null) { - toast(org.fossify.commons.R.string.unknown_error_occurred) - return + lifecycleScope.launch { + try { + val contactFilePath = when (uri.scheme) { + "file" -> uri.path + "content" -> saveContentUriToTempFile(uri) // Process content URI in the background + else -> null // Unsupported scheme } - try { - val inputStream = contentResolver.openInputStream(uri) - val out = FileOutputStream(tempFile) - inputStream!!.copyTo(out) - showImportContactsDialog(tempFile.absolutePath, callback) - } catch (e: Exception) { - showErrorToast(e) + if (contactFilePath != null) { + // Switch back to the Main thread to show a dialog + showImportContactsDialog(contactFilePath, callback) + } else { + toast(org.fossify.commons.R.string.invalid_file_format) } + } catch (e: Exception) { + showErrorToast(e) } + } +} + +private suspend fun SimpleActivity.saveContentUriToTempFile(uri: Uri): String? { + // Perform I/O operations on a background thread (Dispatchers.IO) + return withContext(Dispatchers.IO) { + val tempFile = getTempFile() ?: return@withContext null - else -> toast(org.fossify.commons.R.string.invalid_file_format) + try { + // 'use' automatically closes the streams, even if an exception occurs + contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(tempFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + tempFile.absolutePath + } catch (_: Exception) { + null // Return null on failure + } } }