From 0c594d96061285d50299016a30af61de8f77aa71 Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Tue, 7 Apr 2026 09:58:05 +0200 Subject: [PATCH 01/11] feat: Add email contact picker when sending by email --- .../ui/images/icons/PersonsCircleAdd.kt | 133 ++++++++++++++++++ .../newtransfer/pickfiles/PickFilesScreen.kt | 86 ++++++++++- .../pickfiles/PickFilesViewModel.kt | 66 +++++++++ .../components/EmailAddressTextField.kt | 8 +- 4 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt new file mode 100644 index 000000000..5b0a911cd --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt @@ -0,0 +1,133 @@ +package com.infomaniak.swisstransfer.ui.images.icons + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.infomaniak.swisstransfer.ui.images.AppImages +import com.infomaniak.swisstransfer.ui.images.AppImages.AppIcons +import androidx.compose.ui.graphics.StrokeCap.Companion.Round as strokeCapRound +import androidx.compose.ui.graphics.StrokeJoin.Companion.Round as strokeJoinRound + +val AppIcons.PersonsCircleAdd: ImageVector + get() { + if (_personsCircleAdd != null) { + return _personsCircleAdd!! + } + _personsCircleAdd = Builder( + name = "PersonsCircleAdd", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f, + ).apply { + group { + path( + fill = SolidColor(Color(0xFF9F9F9F)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(18.0f, 12.0f) + curveTo(19.591f, 12.0f, 21.117f, 12.633f, 22.242f, 13.758f) + curveTo(23.367f, 14.883f, 24.0f, 16.409f, 24.0f, 18.0f) + curveTo(24.0f, 19.591f, 23.367f, 21.117f, 22.242f, 22.242f) + curveTo(21.117f, 23.367f, 19.591f, 24.0f, 18.0f, 24.0f) + curveTo(16.409f, 24.0f, 14.883f, 23.367f, 13.758f, 22.242f) + curveTo(12.633f, 21.117f, 12.0f, 19.591f, 12.0f, 18.0f) + curveTo(12.0f, 16.409f, 12.633f, 14.883f, 13.758f, 13.758f) + curveTo(14.883f, 12.633f, 16.409f, 12.0f, 18.0f, 12.0f) + close() + moveTo(18.0f, 14.0f) + curveTo(17.586f, 14.0f, 17.25f, 14.336f, 17.25f, 14.75f) + verticalLineTo(17.25f) + horizontalLineTo(14.75f) + curveTo(14.336f, 17.25f, 14.0f, 17.586f, 14.0f, 18.0f) + curveTo(14.0f, 18.414f, 14.336f, 18.75f, 14.75f, 18.75f) + horizontalLineTo(17.25f) + verticalLineTo(21.25f) + curveTo(17.25f, 21.664f, 17.586f, 22.0f, 18.0f, 22.0f) + curveTo(18.414f, 22.0f, 18.75f, 21.664f, 18.75f, 21.25f) + verticalLineTo(18.75f) + horizontalLineTo(21.25f) + curveTo(21.664f, 18.75f, 22.0f, 18.414f, 22.0f, 18.0f) + curveTo(22.0f, 17.586f, 21.664f, 17.25f, 21.25f, 17.25f) + horizontalLineTo(18.75f) + verticalLineTo(14.75f) + curveTo(18.75f, 14.336f, 18.414f, 14.0f, 18.0f, 14.0f) + close() + } + path( + fill = SolidColor(Color(0x00000000)), + stroke = SolidColor(Color(0xFF9F9F9F)), + strokeLineWidth = 1.5f, + strokeLineCap = strokeCapRound, + strokeLineJoin = strokeJoinRound, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(13.071f, 10.047f) + curveTo(13.58f, 9.824f, 14.134f, 9.705f, 14.7f, 9.705f) + curveTo(15.601f, 9.705f, 16.47f, 10.004f, 17.175f, 10.548f) + moveTo(2.85f, 4.046f) + curveTo(2.85f, 4.92f, 3.197f, 5.758f, 3.816f, 6.377f) + curveTo(4.435f, 6.995f, 5.274f, 7.342f, 6.15f, 7.342f) + curveTo(7.025f, 7.342f, 7.864f, 6.995f, 8.483f, 6.377f) + curveTo(9.102f, 5.758f, 9.449f, 4.92f, 9.449f, 4.046f) + curveTo(9.449f, 3.172f, 9.102f, 2.333f, 8.483f, 1.715f) + curveTo(7.864f, 1.097f, 7.025f, 0.75f, 6.15f, 0.75f) + curveTo(5.274f, 0.75f, 4.435f, 1.097f, 3.816f, 1.715f) + curveTo(3.197f, 2.333f, 2.85f, 3.172f, 2.85f, 4.046f) + close() + moveTo(12.225f, 6.216f) + curveTo(12.225f, 6.872f, 12.486f, 7.501f, 12.95f, 7.964f) + curveTo(13.414f, 8.428f, 14.044f, 8.688f, 14.7f, 8.688f) + curveTo(15.356f, 8.688f, 15.986f, 8.428f, 16.45f, 7.964f) + curveTo(16.914f, 7.501f, 17.175f, 6.872f, 17.175f, 6.216f) + curveTo(17.175f, 5.561f, 16.914f, 4.932f, 16.45f, 4.468f) + curveTo(15.986f, 4.005f, 15.356f, 3.744f, 14.7f, 3.744f) + curveTo(14.044f, 3.744f, 13.414f, 4.005f, 12.95f, 4.468f) + curveTo(12.486f, 4.932f, 12.225f, 5.561f, 12.225f, 6.216f) + close() + moveTo(0.75f, 13.75f) + curveTo(0.75f, 12.32f, 1.319f, 10.948f, 2.332f, 9.936f) + curveTo(3.344f, 8.925f, 4.718f, 8.357f, 6.15f, 8.357f) + curveTo(7.582f, 8.357f, 8.955f, 8.925f, 9.968f, 9.936f) + curveTo(10.98f, 10.948f, 11.549f, 12.32f, 11.549f, 13.75f) + horizontalLineTo(0.75f) + close() + } + } + }.build() + return _personsCircleAdd!! + } + +private var _personsCircleAdd: ImageVector? = null + +@Preview +@Composable +private fun Preview() { + Box { + Image( + imageVector = AppIcons.PersonsCircleAdd, + contentDescription = null, + modifier = Modifier.size(AppImages.previewSize) + ) + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index 92f767639..3e0422f74 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -18,8 +18,14 @@ package com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles import android.Manifest +import android.app.Activity import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_PICK +import android.net.Uri import android.os.Build.VERSION.SDK_INT +import android.provider.ContactsContract import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -27,13 +33,18 @@ import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -45,10 +56,13 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -71,10 +85,17 @@ import com.infomaniak.swisstransfer.R import com.infomaniak.swisstransfer.ui.LocalUser import com.infomaniak.swisstransfer.ui.MatomoSwissTransfer import com.infomaniak.swisstransfer.ui.components.ButtonType +import com.infomaniak.swisstransfer.ui.components.FabType import com.infomaniak.swisstransfer.ui.components.LargeButton +import com.infomaniak.swisstransfer.ui.components.SwissTransferFab import com.infomaniak.swisstransfer.ui.components.SwissTransferTextField import com.infomaniak.swisstransfer.ui.components.SwissTransferTopAppBar import com.infomaniak.swisstransfer.ui.components.TopAppBarButtons +import com.infomaniak.swisstransfer.ui.images.AppImages +import com.infomaniak.swisstransfer.ui.images.AppImages.AppIcons +import com.infomaniak.swisstransfer.ui.images.icons.EyeCrossed +import com.infomaniak.swisstransfer.ui.images.icons.Person +import com.infomaniak.swisstransfer.ui.images.icons.PersonsCircleAdd import com.infomaniak.swisstransfer.ui.previewparameter.filesPreviewData import com.infomaniak.swisstransfer.ui.screen.main.settings.DownloadLimitOption import com.infomaniak.swisstransfer.ui.screen.main.settings.EmailLanguageOption @@ -88,12 +109,14 @@ import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.T import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.TransferOptionsTypes import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.TransferTypeButtons import com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles.components.TransferTypeUi +import com.infomaniak.swisstransfer.ui.theme.Dimens import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme import com.infomaniak.swisstransfer.ui.utils.GetSetCallbacks import com.infomaniak.swisstransfer.ui.utils.isApiV2 import com.infomaniak.swisstransfer.upload.UploadForegroundService import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch import splitties.toast.longToast private val HORIZONTAL_PADDING = Margin.Medium @@ -197,6 +220,7 @@ fun PickFilesScreen( ), transferOptionsCallbacks = transferOptionsCallbacks, pickFiles = ::pickFiles, + selectContact = pickFilesViewModel::processContactPickerResultUri, exitNewTransfer = { exit() }, onSendButtonClick = { MatomoSwissTransfer.trackNewTransferDataEvent(pickFilesViewModel.selectedTransferTypeFlow.value.dbValue.matomoName) @@ -227,6 +251,7 @@ private fun PickFilesScreen( selectedTransferType: GetSetCallbacks, transferOptionsCallbacks: TransferOptionsCallbacks, pickFiles: () -> Unit, + selectContact: (Uri, Context) -> Unit, exitNewTransfer: () -> Unit, isAwaitingSend: () -> Boolean, onSendButtonClick: () -> Unit, @@ -264,6 +289,7 @@ private fun PickFilesScreen( emailTextFieldCallbacks = emailTextFieldCallbacks, transferMessageCallbacks = transferMessageCallbacks, shouldShowEmailAddressesFields = { shouldShowEmailAddressesFields }, + selectContact = selectContact, ) TransferOptions(modifier, transferOptionsCallbacks) } @@ -297,6 +323,7 @@ private fun ImportTextFields( emailTextFieldCallbacks: EmailTextFieldCallbacks, transferMessageCallbacks: GetSetCallbacks, shouldShowEmailAddressesFields: () -> Boolean, + selectContact: (Uri, Context) -> Unit, ) { val modifier = horizontalPaddingModifier.fillMaxWidth() @@ -312,7 +339,9 @@ private fun ImportTextFields( ) } - EmailAddressesTextFields(modifier, emailTextFieldCallbacks, shouldShowEmailAddressesFields, textFieldSpacing) + EmailAddressesTextFields( + modifier, emailTextFieldCallbacks, shouldShowEmailAddressesFields, textFieldSpacing, selectContact + ) SwissTransferTextField( modifier = modifier, @@ -331,7 +360,34 @@ private fun EmailAddressesTextFields( emailTextFieldCallbacks: EmailTextFieldCallbacks, shouldShowEmailAddressesFields: () -> Boolean, textFieldSpacing: Dp, + selectContact: (Uri, Context) -> Unit, ) = with(emailTextFieldCallbacks) { + val context = LocalContext.current + val coroutine = rememberCoroutineScope() + + val pickContact = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + val clipData = it.data?.clipData + + if (clipData != null) { + + val length = clipData.itemCount + val result = mutableListOf() + for (i in 0 until length) { + selectContact(clipData.getItemAt(i).uri, context) + } + } else { + val data = it.data?.data ?: return@rememberLauncherForActivityResult + } + + // if (resultUris != mutableListOf()) { + // coroutine.launch { + // selectContact(resultUris, context) + // } + // } + } + } + AnimatedVisibility(visible = shouldShowEmailAddressesFields(), modifier = modifier) { Column(verticalArrangement = Arrangement.spacedBy(textFieldSpacing)) { val isAuthorError = checkEmailError(isAuthor = true) @@ -358,11 +414,38 @@ private fun EmailAddressesTextFields( onValueChange = { recipientEmail.set(it.text) }, isError = isRecipientError, supportingText = getEmailError(isRecipientError), + trailingIcon = + { + TrailingButton( + AppIcons.PersonsCircleAdd, + { + try { + val pickContactIntent = + Intent(ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI).apply { + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + pickContact.launch(pickContactIntent) + } catch (_: ActivityNotFoundException) { + coroutine.launch { + longToast("The contact picker isn't available on your version of Android") + } + } + }) + }, ) } } } +@Composable +private fun TrailingButton(appIcon: ImageVector, onClick: () -> Unit) { + IconButton(onClick = onClick) { + val (contentDescription, icon) = stringResource(R.string.contentDescriptionButtonHidePassword) to appIcon + + Icon(icon, contentDescription, Modifier.size(Dimens.SmallIconSize)) + } +} + @Composable private fun getEmailError(isError: Boolean): @Composable (() -> Unit)? { val supportingText: @Composable (() -> Unit)? = if (isError) { @@ -546,6 +629,7 @@ private fun Preview(@PreviewParameter(UserListPreviewParameterProvider::class) u selectedTransferType = GetSetCallbacks(get = { TransferTypeUi.Mail }, set = {}), transferOptionsCallbacks = transferOptionsCallbacks, pickFiles = {}, + selectContact = { _, _ -> }, exitNewTransfer = {}, onSendButtonClick = {}, isAwaitingSend = { true }, diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt index 1256171f3..0dd4a34fd 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt @@ -20,7 +20,9 @@ package com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles +import android.content.Context import android.net.Uri +import android.provider.ContactsContract import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -60,6 +62,7 @@ import com.infomaniak.swisstransfer.upload.NewTransferParams import com.infomaniak.swisstransfer.upload.UploadForegroundService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.CONFLATED @@ -73,9 +76,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import splitties.coroutines.repeatWhileActive import splitties.experimental.ExperimentalSplittiesApi import javax.inject.Inject +import kotlin.collections.mutableListOf +import kotlin.collections.set @HiltViewModel class PickFilesViewModel @Inject constructor( @@ -404,6 +410,66 @@ class PickFilesViewModel @Inject constructor( is EmailLanguageOption -> selectTransferLanguage(option) } } + + data class Contact( + val lookupKey: String, + val emails: List, + ) + + fun processContactPickerResultUri( + sessionUris: Uri, + context: Context, + ) { + viewModelScope.launch { contactPickLaunch(sessionUris, context) } + } + + private suspend fun contactPickLaunch( + sessionUris: Uri, + context: Context, + ) = withContext(Dispatchers.IO) { + val projection = arrayOf( + ContactsContract.Contacts.LOOKUP_KEY, + ContactsContract.Data.MIMETYPE, + ContactsContract.Data.DATA1, + ) + + val contactsMap = mutableMapOf() + + context.contentResolver.query(sessionUris, projection, null, null, null)?.use { cursor -> + val lookupKeyIdx = cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY) + val mimeTypeIdx = cursor.getColumnIndex(ContactsContract.Data.MIMETYPE) + val data1Idx = cursor.getColumnIndex(ContactsContract.Data.DATA1) + + while (cursor.moveToNext()) { + val lookupKey = cursor.getString(lookupKeyIdx) + val mimeType = cursor.getString(mimeTypeIdx) + val data1 = cursor.getString(data1Idx) ?: "" + + val email = if (mimeType == ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE) data1 else null + + val existingContact = contactsMap[lookupKey] + if (existingContact != null) { + contactsMap[lookupKey] = existingContact.copy( + emails = if (email != null) existingContact.emails + email else existingContact.emails, + ) + } else { + contactsMap[lookupKey] = Contact( + lookupKey = lookupKey, + emails = if (email != null) listOf(email) else emptyList(), + ) + } + } + } + + val iterator = contactsMap.entries.iterator() + while (iterator.hasNext()) { + val (key, value) = iterator.next() + validatedRecipientsEmails = validatedRecipientsEmails.plus(value.emails) + + } + + } + //endregion companion object { diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/components/EmailAddressTextField.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/components/EmailAddressTextField.kt index 29af3f739..2524ef484 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/components/EmailAddressTextField.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/components/EmailAddressTextField.kt @@ -83,16 +83,18 @@ import com.infomaniak.swisstransfer.ui.previewparameter.EmailsPreviewParameter import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme import com.infomaniak.swisstransfer.ui.utils.GetSetCallbacks + @OptIn(ExperimentalLayoutApi::class) @Composable fun EmailAddressTextField( - modifier: Modifier = Modifier, label: String, initialValue: String, validatedRecipientsEmails: GetSetCallbacks>, onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, isError: Boolean = false, supportingText: (@Composable () -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, ) { val state = remember(validatedRecipientsEmails) { @@ -154,11 +156,13 @@ fun EmailAddressTextField( isError = isError, supportingText = supportingText, textFieldColors = SwissTransferTextFieldDefaults.colors(), + trailingIcon = trailingIcon, ) } ) } + /** * Copied from OutlineTextField so the BasicTextField can have the same spacing as OutlineTextField with a label */ @@ -289,6 +293,7 @@ private fun EmailAddressDecorationBox( isError: Boolean, supportingText: @Composable (() -> Unit)?, textFieldColors: TextFieldColors, + trailingIcon: @Composable (() -> Unit)? = null ) { OutlinedTextFieldDefaults.DecorationBox( value = text, @@ -313,6 +318,7 @@ private fun EmailAddressDecorationBox( supportingText = supportingText, label = { Text(label) }, colors = textFieldColors, + trailingIcon = trailingIcon, ) { OutlinedTextFieldDefaults.Container( enabled = true, From ad26e0315b4b6926e7e4fb44d8a4ce9fd39ebde8 Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Tue, 7 Apr 2026 10:15:32 +0200 Subject: [PATCH 02/11] fix: Delete comment and fix one case --- .../ui/screen/newtransfer/pickfiles/PickFilesScreen.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index 3e0422f74..f85f8ce22 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -117,6 +117,7 @@ import com.infomaniak.swisstransfer.upload.UploadForegroundService import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select import splitties.toast.longToast private val HORIZONTAL_PADDING = Margin.Medium @@ -378,13 +379,9 @@ private fun EmailAddressesTextFields( } } else { val data = it.data?.data ?: return@rememberLauncherForActivityResult + selectContact(data, context) } - // if (resultUris != mutableListOf()) { - // coroutine.launch { - // selectContact(resultUris, context) - // } - // } } } From 47ec65a95ac68c12058d0d744d9e7de489585f97 Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Tue, 7 Apr 2026 11:07:38 +0200 Subject: [PATCH 03/11] fix: Hardcoded string and wrong accessibility content description --- .../ui/screen/newtransfer/pickfiles/PickFilesScreen.kt | 4 ++-- app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fi/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nb/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-sv/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 +- 14 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index f85f8ce22..069df7d4b 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -424,7 +424,7 @@ private fun EmailAddressesTextFields( pickContact.launch(pickContactIntent) } catch (_: ActivityNotFoundException) { coroutine.launch { - longToast("The contact picker isn't available on your version of Android") + longToast(R.string.startActivityCantHandleAction) } } }) @@ -437,7 +437,7 @@ private fun EmailAddressesTextFields( @Composable private fun TrailingButton(appIcon: ImageVector, onClick: () -> Unit) { IconButton(onClick = onClick) { - val (contentDescription, icon) = stringResource(R.string.contentDescriptionButtonHidePassword) to appIcon + val (contentDescription, icon) = stringResource(R.string.contentDescriptionButtonSelectContact) to appIcon Icon(icon, contentDescription, Modifier.size(Dimens.SmallIconSize)) } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index b6f960d1c..893971f1b 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -39,6 +39,7 @@ Skjul adgangskode Fjern fil Fjern %s + Åbn kontaktlisten Vis adgangskode Ny overføring Indtast den adgangskode, du har modtaget, for at downloade disse filer. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a0a16b0a2..9e25d7925 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -39,6 +39,7 @@ Passwort verbergen Datei entfernen %s entfernen + Kontaktliste öffnen Passwort anzeigen Neuer Transfer Gib das Passwort ein, das du erhalten hast, um diese Dateien herunterzuladen. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 295ef3fcc..c14dd9369 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -39,6 +39,7 @@ Απόκρυψη κωδικού πρόσβασης Αφαίρεση αρχείου Αφαίρεση %s + Άνοιγμα λίστας επαφών Εμφάνιση κωδικού πρόσβασης Νέα μεταφορά Εισάγαλε τον κωδικό πρόσβασης που σου δόθηκε για να κατεβάσεις αυτά τα αρχεία. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c320dbd94..f15a52f3e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -39,6 +39,7 @@ Ocultar contraseña Eliminar archivo Suprimir %s + Abrir la lista de contactos Mostrar contraseña Nueva transferencia Introduzca la contraseña que se le ha dado para descargar estos archivos. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 9609b95d2..8282ccb95 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -39,6 +39,7 @@ Piilota salasana Poista tiedosto Poista %s + Avaa yhteysluettelo Näytä salasana Uusi siirto Syötä saamasi salasana ladataksesi nämä tiedostot. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ef4fdb9bd..ef5508189 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -39,6 +39,7 @@ Cacher le mot de passe Supprimer le fichier Supprimer %s + Ouvrir la liste des contacts Afficher le mot de passe Nouveau transfert Saisis le mot de passe qui t’a été fourni pour télécharger ces fichiers. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 270f4cd90..ac4b40c9b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -39,6 +39,7 @@ Nascondere la password Rimuovi il file Rimuove %s + Apri l\'elenco contatti Mostra password Nuovo trasferimento Inserite la password che vi è stata fornita per scaricare questi file. diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 446d4fed3..1dea6a500 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -39,6 +39,7 @@ Skjul passord Fjern fil Fjern %s + Åpne kontaktlisten Vis passord Ny overføring Skriv inn passordet du har fått for å laste ned disse filene. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 13db06fbe..8f9df21da 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -39,6 +39,7 @@ Wachtwoord verbergen Bestand verwijderen %s verwijderen + Contactenlijst openen Wachtwoord weergeven Nieuwe overdracht Voer het wachtwoord in dat je hebt ontvangen om deze bestanden te downloaden. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 35e047b3f..b546ea993 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -39,6 +39,7 @@ Ukryj hasło Usuń plik Usuń %s + Otwórz listę kontaktów Pokaż hasło Nowy transfer Wprowadź hasło, które otrzymałeś, aby pobrać te pliki. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index ffe049add..7656194a0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -39,6 +39,7 @@ Ocultar palavra-passe Remover ficheiro Remover %s + Abrir a lista de contactos Mostrar palavra-passe Nova transferência Introduz a palavra-passe que te foi fornecida para descarregar estes ficheiros. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 8a98e2f50..468fc58de 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -39,6 +39,7 @@ Dölj lösenord Ta bort fil Ta bort %s + Öppna kontaktlistan Visa lösenord Ny överföring Ange lösenordet du har fått för att ladda ner dessa filer. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e218287fa..615a583a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,7 +20,6 @@ Matomo Sentry upload_channel_id - Advanced settings Waiting for network Cancel the transfer @@ -44,6 +43,7 @@ Hide password Remove file Remove %s + Open the contacts list Show password New transfer Enter the password you have been given to download these files. From 8ca35f16f6001f71717e4769ac192a07e5f37a4c Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Tue, 7 Apr 2026 11:08:44 +0200 Subject: [PATCH 04/11] fix: Delete unused variable --- .../ui/screen/newtransfer/pickfiles/PickFilesScreen.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index 069df7d4b..a1288c47e 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -371,9 +371,7 @@ private fun EmailAddressesTextFields( val clipData = it.data?.clipData if (clipData != null) { - val length = clipData.itemCount - val result = mutableListOf() for (i in 0 until length) { selectContact(clipData.getItemAt(i).uri, context) } From 7e3ef4f1eb275b94803af4315dcb2bba5093190f Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Tue, 7 Apr 2026 11:13:37 +0200 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20Translation=20using=20'=20instead?= =?UTF-8?q?=20of=20=E2=80=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values-it/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ac4b40c9b..51a45644f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -39,7 +39,7 @@ Nascondere la password Rimuovi il file Rimuove %s - Apri l\'elenco contatti + Apri l’elenco contatti Mostra password Nuovo trasferimento Inserite la password che vi è stata fornita per scaricare questi file. From 7f5d2689cebcc090bb8e15d0376d6315fa08ec3a Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Thu, 9 Apr 2026 13:00:08 +0200 Subject: [PATCH 06/11] fix: Add exception handling when launching contact picker, null safety, improve performance and avoiding threading issues --- .../newtransfer/pickfiles/PickFilesScreen.kt | 4 +++- .../newtransfer/pickfiles/PickFilesViewModel.kt | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index a1288c47e..693b66dbc 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -373,7 +373,9 @@ private fun EmailAddressesTextFields( if (clipData != null) { val length = clipData.itemCount for (i in 0 until length) { - selectContact(clipData.getItemAt(i).uri, context) + clipData.getItemAt(i).uri?.let { uri -> + selectContact(uri,context) + } } } else { val data = it.data?.data ?: return@rememberLauncherForActivityResult diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt index 0dd4a34fd..6316172e3 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt @@ -420,7 +420,11 @@ class PickFilesViewModel @Inject constructor( sessionUris: Uri, context: Context, ) { - viewModelScope.launch { contactPickLaunch(sessionUris, context) } + try { + viewModelScope.launch { contactPickLaunch(sessionUris, context) } + }catch (e: Exception){ + SentryLog.e(TAG, "Error while launching contact picker", e) + } } private suspend fun contactPickLaunch( @@ -461,11 +465,9 @@ class PickFilesViewModel @Inject constructor( } } - val iterator = contactsMap.entries.iterator() - while (iterator.hasNext()) { - val (key, value) = iterator.next() - validatedRecipientsEmails = validatedRecipientsEmails.plus(value.emails) - + withContext(Dispatchers.Main) { + val allNewEmails = contactsMap.values.flatMap { it.emails }.toSet() + validatedRecipientsEmails = validatedRecipientsEmails.plus(allNewEmails) } } From bf12b649c0365093935385341dd1778e4fb19b8a Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Thu, 9 Apr 2026 13:21:47 +0200 Subject: [PATCH 07/11] chore: Clean code --- .../screen/newtransfer/pickfiles/PickFilesScreen.kt | 11 +---------- .../newtransfer/pickfiles/PickFilesViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index 693b66dbc..65eb238b5 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -33,9 +33,7 @@ import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding @@ -59,7 +57,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -85,16 +82,11 @@ import com.infomaniak.swisstransfer.R import com.infomaniak.swisstransfer.ui.LocalUser import com.infomaniak.swisstransfer.ui.MatomoSwissTransfer import com.infomaniak.swisstransfer.ui.components.ButtonType -import com.infomaniak.swisstransfer.ui.components.FabType import com.infomaniak.swisstransfer.ui.components.LargeButton -import com.infomaniak.swisstransfer.ui.components.SwissTransferFab import com.infomaniak.swisstransfer.ui.components.SwissTransferTextField import com.infomaniak.swisstransfer.ui.components.SwissTransferTopAppBar import com.infomaniak.swisstransfer.ui.components.TopAppBarButtons -import com.infomaniak.swisstransfer.ui.images.AppImages import com.infomaniak.swisstransfer.ui.images.AppImages.AppIcons -import com.infomaniak.swisstransfer.ui.images.icons.EyeCrossed -import com.infomaniak.swisstransfer.ui.images.icons.Person import com.infomaniak.swisstransfer.ui.images.icons.PersonsCircleAdd import com.infomaniak.swisstransfer.ui.previewparameter.filesPreviewData import com.infomaniak.swisstransfer.ui.screen.main.settings.DownloadLimitOption @@ -117,7 +109,6 @@ import com.infomaniak.swisstransfer.upload.UploadForegroundService import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.launch -import kotlinx.coroutines.selects.select import splitties.toast.longToast private val HORIZONTAL_PADDING = Margin.Medium @@ -374,7 +365,7 @@ private fun EmailAddressesTextFields( val length = clipData.itemCount for (i in 0 until length) { clipData.getItemAt(i).uri?.let { uri -> - selectContact(uri,context) + selectContact(uri, context) } } } else { diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt index 6316172e3..e8f753335 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt @@ -423,7 +423,7 @@ class PickFilesViewModel @Inject constructor( try { viewModelScope.launch { contactPickLaunch(sessionUris, context) } }catch (e: Exception){ - SentryLog.e(TAG, "Error while launching contact picker", e) + SentryLog.e(TAG, "Error while importing contacts from picker result", e) } } From c9db9840322fc5cf9de61f22f9451a91ba5ba7f4 Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Thu, 9 Apr 2026 13:33:39 +0200 Subject: [PATCH 08/11] fix: Add check getting column index --- .../ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt index e8f753335..11afd593c 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt @@ -444,6 +444,11 @@ class PickFilesViewModel @Inject constructor( val mimeTypeIdx = cursor.getColumnIndex(ContactsContract.Data.MIMETYPE) val data1Idx = cursor.getColumnIndex(ContactsContract.Data.DATA1) + if (lookupKeyIdx == -1 || mimeTypeIdx == -1 || data1Idx == -1) { + SentryLog.e(TAG, "Projection invalide, colonnes manquantes.") + return@use + } + while (cursor.moveToNext()) { val lookupKey = cursor.getString(lookupKeyIdx) val mimeType = cursor.getString(mimeTypeIdx) From aa79cf046e633c393e97f6d297b61ac35996b936 Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Fri, 24 Apr 2026 11:46:00 +0200 Subject: [PATCH 09/11] refactor: Split contact picker logic into smaller subfunctions --- .../newtransfer/pickfiles/PickFilesScreen.kt | 73 +++++++------- .../pickfiles/PickFilesViewModel.kt | 94 +++++++++++-------- 2 files changed, 93 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index 65eb238b5..2c314ef96 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -28,6 +28,8 @@ import android.os.Build.VERSION.SDK_INT import android.provider.ContactsContract import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility @@ -257,9 +259,7 @@ private fun PickFilesScreen( modifier = Modifier.imePadding(), topBar = { SwissTransferTopAppBar( - titleRes = R.string.importFilesScreenTitle, - actions = { TopAppBarButtons.Close(onClick = { exitNewTransfer() }) } - ) + titleRes = R.string.importFilesScreenTitle, actions = { TopAppBarButtons.Close(onClick = { exitNewTransfer() }) }) }, topButton = { modifier -> SendButton( @@ -357,25 +357,43 @@ private fun EmailAddressesTextFields( val context = LocalContext.current val coroutine = rememberCoroutineScope() - val pickContact = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) { - val clipData = it.data?.clipData + fun buildPickEmailContactIntent(): Intent = Intent(ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI).apply { + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + + fun handlePickedContacts(result: ActivityResult) { + if (result.resultCode != Activity.RESULT_OK) return - if (clipData != null) { - val length = clipData.itemCount - for (i in 0 until length) { - clipData.getItemAt(i).uri?.let { uri -> - selectContact(uri, context) - } + val dataIntent = result.data ?: return + val clipData = dataIntent.clipData + + if (clipData != null) { + for (i in 0 until clipData.itemCount) { + clipData.getItemAt(i).uri?.let { uri -> + selectContact(uri, context) } - } else { - val data = it.data?.data ?: return@rememberLauncherForActivityResult - selectContact(data, context) } + return + } + dataIntent.data?.let { uri -> + selectContact(uri, context) } } + fun launchPickContactSafely(launcher: ActivityResultLauncher) { + try { + launcher.launch(buildPickEmailContactIntent()) + } catch (_: ActivityNotFoundException) { + coroutine.launch { + longToast(R.string.startActivityCantHandleAction) + } + } + } + + val pickContactLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult(), ::handlePickedContacts) + AnimatedVisibility(visible = shouldShowEmailAddressesFields(), modifier = modifier) { Column(verticalArrangement = Arrangement.spacedBy(textFieldSpacing)) { val isAuthorError = checkEmailError(isAuthor = true) @@ -402,24 +420,10 @@ private fun EmailAddressesTextFields( onValueChange = { recipientEmail.set(it.text) }, isError = isRecipientError, supportingText = getEmailError(isRecipientError), - trailingIcon = - { - TrailingButton( - AppIcons.PersonsCircleAdd, - { - try { - val pickContactIntent = - Intent(ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI).apply { - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - } - pickContact.launch(pickContactIntent) - } catch (_: ActivityNotFoundException) { - coroutine.launch { - longToast(R.string.startActivityCantHandleAction) - } - } - }) - }, + trailingIcon = { + TrailingButton( + AppIcons.PersonsCircleAdd, onClick = { launchPickContactSafely(pickContactLauncher) }) + }, ) } } @@ -564,8 +568,7 @@ enum class PasswordTransferOption( override val imageVector: ImageVector? = null, override val imageVectorResId: Int? = null, ) : SettingOption { - NONE({ stringResource(R.string.settingsOptionNone) }), - ACTIVATED({ stringResource(R.string.settingsOptionActivated) }), + NONE({ stringResource(R.string.settingsOptionNone) }), ACTIVATED({ stringResource(R.string.settingsOptionActivated) }), } @PreviewAllWindows diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt index 11afd593c..4d0c513d4 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt @@ -21,6 +21,7 @@ package com.infomaniak.swisstransfer.ui.screen.newtransfer.pickfiles import android.content.Context +import android.database.Cursor import android.net.Uri import android.provider.ContactsContract import androidx.compose.runtime.derivedStateOf @@ -80,8 +81,6 @@ import kotlinx.coroutines.withContext import splitties.coroutines.repeatWhileActive import splitties.experimental.ExperimentalSplittiesApi import javax.inject.Inject -import kotlin.collections.mutableListOf -import kotlin.collections.set @HiltViewModel class PickFilesViewModel @Inject constructor( @@ -134,9 +133,7 @@ class PickFilesViewModel @Inject constructor( } enum class Email : Issue { - AuthorUnspecified, - AuthorInvalid, - NoValidatedRecipients, + AuthorUnspecified, AuthorInvalid, NoValidatedRecipients, } } } @@ -196,8 +193,7 @@ class PickFilesViewModel @Inject constructor( initialValue = FilesDetailsUiState.Success(emptyList()), ) - @OptIn(FlowPreview::class) - importedFilesDebounced = pickedFilesFlow.debounce(50).stateIn( + @OptIn(FlowPreview::class) importedFilesDebounced = pickedFilesFlow.debounce(50).stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = emptyList(), @@ -256,8 +252,7 @@ class PickFilesViewModel @Inject constructor( } private fun pickedFilesIssues( - maxFilesSize: Long = FileUtils.MAX_FILES_SIZE, - maxFilesCount: Int = FileUtils.MAX_FILE_COUNT + maxFilesSize: Long = FileUtils.MAX_FILES_SIZE, maxFilesCount: Int = FileUtils.MAX_FILE_COUNT ): StateFlow> = UploadForegroundService.pickedFilesFlow.mapSync { pickedFiles -> if (pickedFiles.isEmpty()) { setOf(Issue.Files.NonePicked) @@ -326,8 +321,7 @@ class PickFilesViewModel @Inject constructor( //region Transfer Options val selectedValidityPeriodOption = savedStateHandle.getStateFlow( - key = SELECTED_VALIDITY_PERIOD_KEY, - initialValue = ValidityPeriodOption.THIRTY + key = SELECTED_VALIDITY_PERIOD_KEY, initialValue = ValidityPeriodOption.THIRTY ) val selectedDownloadLimitOption = savedStateHandle.getStateFlow( @@ -422,7 +416,7 @@ class PickFilesViewModel @Inject constructor( ) { try { viewModelScope.launch { contactPickLaunch(sessionUris, context) } - }catch (e: Exception){ + } catch (e: Exception) { SentryLog.e(TAG, "Error while importing contacts from picker result", e) } } @@ -430,7 +424,17 @@ class PickFilesViewModel @Inject constructor( private suspend fun contactPickLaunch( sessionUris: Uri, context: Context, - ) = withContext(Dispatchers.IO) { + ) { + val contacts = queryContacts(sessionUris, context) + val newEmails = contacts.values.asSequence().flatMap { it.emails.asSequence() }.toSet() + + updateValidatedRecipientsEmails(newEmails) + } + + private suspend fun queryContacts( + sessionUris: Uri, + context: Context, + ): Map = withContext(Dispatchers.IO) { val projection = arrayOf( ContactsContract.Contacts.LOOKUP_KEY, ContactsContract.Data.MIMETYPE, @@ -440,43 +444,55 @@ class PickFilesViewModel @Inject constructor( val contactsMap = mutableMapOf() context.contentResolver.query(sessionUris, projection, null, null, null)?.use { cursor -> - val lookupKeyIdx = cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY) - val mimeTypeIdx = cursor.getColumnIndex(ContactsContract.Data.MIMETYPE) - val data1Idx = cursor.getColumnIndex(ContactsContract.Data.DATA1) - - if (lookupKeyIdx == -1 || mimeTypeIdx == -1 || data1Idx == -1) { + val indices = cursor.contactProjectionIndicesOrNull() + if (indices == null) { SentryLog.e(TAG, "Projection invalide, colonnes manquantes.") return@use } while (cursor.moveToNext()) { - val lookupKey = cursor.getString(lookupKeyIdx) - val mimeType = cursor.getString(mimeTypeIdx) - val data1 = cursor.getString(data1Idx) ?: "" - - val email = if (mimeType == ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE) data1 else null - - val existingContact = contactsMap[lookupKey] - if (existingContact != null) { - contactsMap[lookupKey] = existingContact.copy( - emails = if (email != null) existingContact.emails + email else existingContact.emails, - ) - } else { - contactsMap[lookupKey] = Contact( - lookupKey = lookupKey, - emails = if (email != null) listOf(email) else emptyList(), - ) - } + val lookupKey = cursor.getString(indices.lookupKeyIdx) + val email = cursor.emailOrNull(indices.mimeTypeIdx, indices.data1Idx) ?: continue + + val current = contactsMap[lookupKey] ?: Contact(lookupKey = lookupKey, emails = emptyList()) + contactsMap[lookupKey] = current.copy(emails = current.emails + email) } } + contactsMap + } - withContext(Dispatchers.Main) { - val allNewEmails = contactsMap.values.flatMap { it.emails }.toSet() - validatedRecipientsEmails = validatedRecipientsEmails.plus(allNewEmails) - } + private suspend fun updateValidatedRecipientsEmails(newEmails: Set) = withContext(Dispatchers.Main) { + validatedRecipientsEmails = validatedRecipientsEmails + newEmails + } + + private data class ContactProjectionIndices( + val lookupKeyIdx: Int, + val mimeTypeIdx: Int, + val data1Idx: Int, + ) + private fun Cursor.contactProjectionIndicesOrNull(): ContactProjectionIndices? { + val lookupKeyIdx = getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY) + val mimeTypeIdx = getColumnIndex(ContactsContract.Data.MIMETYPE) + val data1Idx = getColumnIndex(ContactsContract.Data.DATA1) + + return if (lookupKeyIdx == -1 || mimeTypeIdx == -1 || data1Idx == -1) { + null + } else { + ContactProjectionIndices( + lookupKeyIdx = lookupKeyIdx, + mimeTypeIdx = mimeTypeIdx, + data1Idx = data1Idx, + ) + } } + private fun Cursor.emailOrNull(mimeTypeIdx: Int, data1Idx: Int): String? { + val mimeType = getString(mimeTypeIdx) + if (mimeType != ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE) return null + + return getString(data1Idx) + } //endregion companion object { From c3b23e7967e3f566624d6fa5fe70521271880e08 Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Fri, 24 Apr 2026 11:48:52 +0200 Subject: [PATCH 10/11] chore: Remove useless imports --- .../swisstransfer/ui/images/icons/PersonsCircleAdd.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt index 5b0a911cd..17e7aec00 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt @@ -1,8 +1,7 @@ package com.infomaniak.swisstransfer.ui.images.icons -import androidx.compose.foundation.Image +simport androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier From f610f425e5c943d59abc4d4baa7f9172546528ff Mon Sep 17 00:00:00 2001 From: Aymeric MARIAUX Date: Fri, 24 Apr 2026 11:55:32 +0200 Subject: [PATCH 11/11] refactor: Fix dispatchers, remove unused imports and unnecessary coroutines --- .../ui/images/icons/PersonsCircleAdd.kt | 2 +- .../newtransfer/pickfiles/PickFilesScreen.kt | 7 +------ .../pickfiles/PickFilesViewModel.kt | 18 +++++++++--------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt index 17e7aec00..76d01438e 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/images/icons/PersonsCircleAdd.kt @@ -1,6 +1,6 @@ package com.infomaniak.swisstransfer.ui.images.icons -simport androidx.compose.foundation.Image +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt index 2c314ef96..812b41135 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesScreen.kt @@ -56,7 +56,6 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -110,7 +109,6 @@ import com.infomaniak.swisstransfer.ui.utils.isApiV2 import com.infomaniak.swisstransfer.upload.UploadForegroundService import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.launch import splitties.toast.longToast private val HORIZONTAL_PADDING = Margin.Medium @@ -355,7 +353,6 @@ private fun EmailAddressesTextFields( selectContact: (Uri, Context) -> Unit, ) = with(emailTextFieldCallbacks) { val context = LocalContext.current - val coroutine = rememberCoroutineScope() fun buildPickEmailContactIntent(): Intent = Intent(ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI).apply { putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) @@ -385,9 +382,7 @@ private fun EmailAddressesTextFields( try { launcher.launch(buildPickEmailContactIntent()) } catch (_: ActivityNotFoundException) { - coroutine.launch { - longToast(R.string.startActivityCantHandleAction) - } + longToast(R.string.startActivityCantHandleAction) } } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt index 4d0c513d4..fa050bc16 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/pickfiles/PickFilesViewModel.kt @@ -405,36 +405,36 @@ class PickFilesViewModel @Inject constructor( } } - data class Contact( + private data class Contact( val lookupKey: String, val emails: List, ) fun processContactPickerResultUri( - sessionUris: Uri, + sessionUri: Uri, context: Context, ) { try { - viewModelScope.launch { contactPickLaunch(sessionUris, context) } + viewModelScope.launch { contactPickLaunch(sessionUri, context) } } catch (e: Exception) { SentryLog.e(TAG, "Error while importing contacts from picker result", e) } } private suspend fun contactPickLaunch( - sessionUris: Uri, + sessionUri: Uri, context: Context, ) { - val contacts = queryContacts(sessionUris, context) + val contacts = queryContacts(sessionUri, context) val newEmails = contacts.values.asSequence().flatMap { it.emails.asSequence() }.toSet() updateValidatedRecipientsEmails(newEmails) } private suspend fun queryContacts( - sessionUris: Uri, + sessionUri: Uri, context: Context, - ): Map = withContext(Dispatchers.IO) { + ): Map = withContext(ioDispatcher) { val projection = arrayOf( ContactsContract.Contacts.LOOKUP_KEY, ContactsContract.Data.MIMETYPE, @@ -443,10 +443,10 @@ class PickFilesViewModel @Inject constructor( val contactsMap = mutableMapOf() - context.contentResolver.query(sessionUris, projection, null, null, null)?.use { cursor -> + context.contentResolver.query(sessionUri, projection, null, null, null)?.use { cursor -> val indices = cursor.contactProjectionIndicesOrNull() if (indices == null) { - SentryLog.e(TAG, "Projection invalide, colonnes manquantes.") + SentryLog.e(TAG, "Invalid projection, missing columns.") return@use }