Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,18 @@
android:exported="false"
android:theme="@style/SplashTheme" />

<!--
Allows this activity to receive temporary access grants for URIs passed in an Intent.
When an external app (like the default Contacts app) shares a file, it does so
via a secure content:// URI and adds FLAG_GRANT_READ_URI_PERMISSION to the Intent.
This attribute is required to prevent a SecurityException, ensuring we can
read the content (e.g., a vCard file) shared by another application.
-->
<activity
android:name=".activities.MainActivity"
android:configChanges="orientation"
android:exported="true">
android:exported="true"
android:grantUriPermissions="true">

<intent-filter>
<action android:name="android.intent.action.VIEW" />
Expand All @@ -60,6 +68,15 @@
<data android:mimeType="text/vcard" />
<data android:mimeType="text/x-vcard" />

<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />

<data android:mimeType="text/x-vcard" />
<data android:mimeType="text/vcard" />

<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Uri> {
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<Uri>? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(key, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(key)
}
}

Expand Down
50 changes: 35 additions & 15 deletions app/src/main/kotlin/org/fossify/contacts/extensions/Activity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}

Expand Down
Loading