diff --git a/mail-contact/dagger/src/main/kotlin/ch/protonmail/android/mailcontact/dagger/MailContactModule.kt b/mail-contact/dagger/src/main/kotlin/ch/protonmail/android/mailcontact/dagger/MailContactModule.kt index 999e9a5130..1c02fa8eec 100644 --- a/mail-contact/dagger/src/main/kotlin/ch/protonmail/android/mailcontact/dagger/MailContactModule.kt +++ b/mail-contact/dagger/src/main/kotlin/ch/protonmail/android/mailcontact/dagger/MailContactModule.kt @@ -20,6 +20,7 @@ package ch.protonmail.android.mailcontact.dagger import ch.protonmail.android.mailcontact.data.ContactDetailRepositoryImpl import ch.protonmail.android.mailcontact.data.ContactGroupRepositoryImpl +import ch.protonmail.android.mailcontact.data.DeviceContactsRepositoryImpl import ch.protonmail.android.mailcontact.data.local.ContactDetailLocalDataSource import ch.protonmail.android.mailcontact.data.local.ContactDetailLocalDataSourceImpl import ch.protonmail.android.mailcontact.data.local.ContactGroupLocalDataSource @@ -30,6 +31,7 @@ import ch.protonmail.android.mailcontact.data.remote.ContactGroupRemoteDataSourc import ch.protonmail.android.mailcontact.data.remote.ContactGroupRemoteDataSourceImpl import ch.protonmail.android.mailcontact.domain.repository.ContactDetailRepository import ch.protonmail.android.mailcontact.domain.repository.ContactGroupRepository +import ch.protonmail.android.mailcontact.domain.repository.DeviceContactsRepository import dagger.Binds import dagger.Module import dagger.Reusable @@ -66,4 +68,8 @@ abstract class MailContactModule { @Reusable abstract fun bindContactGroupRepository(impl: ContactGroupRepositoryImpl): ContactGroupRepository + @Binds + @Reusable + abstract fun bindDeviceContactsRepository(impl: DeviceContactsRepositoryImpl): DeviceContactsRepository + } diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImpl.kt new file mode 100644 index 0000000000..d9be6093ab --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImpl.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data + +import android.content.Context +import android.provider.ContactsContract +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcontact.domain.model.DeviceContact +import ch.protonmail.android.mailcontact.domain.repository.DeviceContactsRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.withContext +import me.proton.core.util.kotlin.DispatcherProvider +import timber.log.Timber +import javax.inject.Inject + +class DeviceContactsRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val dispatcherProvider: DispatcherProvider +) : DeviceContactsRepository { + + override suspend fun getDeviceContacts( + query: String + ): Either> { + + val contentResolver = context.contentResolver + + val selectionArgs = arrayOf("%$query%", "%$query%", "%$query%") + + @Suppress("SwallowedException") + val contactEmails = try { + withContext(dispatcherProvider.Io) { + contentResolver.query( + ContactsContract.CommonDataKinds.Email.CONTENT_URI, + ANDROID_PROJECTION, + ANDROID_SELECTION, + selectionArgs, + ANDROID_ORDER_BY + ) + } + } catch (e: SecurityException) { + Timber.d("SearchDeviceContacts: contact permission is not granted") + null + } ?: return DeviceContactsRepository.DeviceContactsErrors.PermissionDenied.left() + + val deviceContacts = mutableListOf() + + val displayNameColumnIndex = contactEmails.getColumnIndex( + ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY + ).takeIf { + it >= 0 + } ?: 0 + + val emailColumnIndex = contactEmails.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS).takeIf { + it >= 0 + } ?: 0 + + contactEmails.use { cursor -> + for (position in 0 until cursor.count) { + cursor.moveToPosition(position) + deviceContacts.add( + DeviceContact( + name = contactEmails.getString(displayNameColumnIndex), + email = contactEmails.getString(emailColumnIndex) + ) + ) + } + } + + return deviceContacts.right() + } + + companion object { + + private const val ANDROID_ORDER_BY = ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY + " ASC" + + @Suppress("MaxLineLength") + private const val ANDROID_SELECTION = + "${ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY} LIKE ? OR ${ContactsContract.CommonDataKinds.Email.ADDRESS} LIKE ? OR ${ContactsContract.CommonDataKinds.Email.DATA} LIKE ?" + + private val ANDROID_PROJECTION = arrayOf( + ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY, + ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.CommonDataKinds.Email.DATA + ) + } + +} diff --git a/mail-contact/domain/src/test/kotlin/ch/protonmail/android/mailcontact/domain/usecase/SearchDeviceContactsTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImplTest.kt similarity index 70% rename from mail-contact/domain/src/test/kotlin/ch/protonmail/android/mailcontact/domain/usecase/SearchDeviceContactsTest.kt rename to mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImplTest.kt index 5ed88b8c26..469e382ba9 100644 --- a/mail-contact/domain/src/test/kotlin/ch/protonmail/android/mailcontact/domain/usecase/SearchDeviceContactsTest.kt +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImplTest.kt @@ -1,29 +1,11 @@ -/* - * Copyright (c) 2022 Proton Technologies AG - * This file is part of Proton Technologies AG and Proton Mail. - * - * Proton Mail is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Mail is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Mail. If not, see . - */ - -package ch.protonmail.android.mailcontact.domain.usecase +package ch.protonmail.android.mailcontact.data import android.content.ContentResolver import android.content.Context import android.database.Cursor import android.provider.ContactsContract import arrow.core.left -import ch.protonmail.android.mailcontact.domain.model.GetContactError +import ch.protonmail.android.mailcontact.domain.repository.DeviceContactsRepository import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -31,12 +13,12 @@ import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.test.runTest import me.proton.core.test.kotlin.TestDispatcherProvider -import org.junit.Assert.assertTrue -import org.junit.Test +import org.junit.Assert import kotlin.test.assertEquals import kotlin.test.assertNotNull -class SearchDeviceContactsTest { +@Suppress("MaxLineLength") +class DeviceContactsRepositoryImplTest { private val columnIndexDisplayName = 1 private val columnIndexEmail = 2 @@ -59,7 +41,7 @@ class SearchDeviceContactsTest { } private val testDispatcherProvider = TestDispatcherProvider() - private val searchDeviceContacts = SearchDeviceContacts( + private val deviceContactsRepository = DeviceContactsRepositoryImpl( contextMock, testDispatcherProvider ) @@ -80,7 +62,7 @@ class SearchDeviceContactsTest { every { cursorMock.count } returns count } - @Test + @org.junit.Test fun `when there are multiple matching contacts, they are emitted`() = runTest(testDispatcherProvider.Main) { // Given val query = "cont" @@ -89,16 +71,16 @@ class SearchDeviceContactsTest { expectContactsCount(2) // When - val actual = searchDeviceContacts(query).getOrNull() + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() // Then assertNotNull(actual) - assertTrue(actual.size == 2) + Assert.assertTrue(actual.size == 2) verify(exactly = 2) { cursorMock.getString(columnIndexDisplayName) } verify(exactly = 2) { cursorMock.getString(columnIndexEmail) } } - @Test + @org.junit.Test fun `when there are no matching contacts, empty list is emitted`() = runTest(testDispatcherProvider.Main) { // Given val query = "cont" @@ -107,16 +89,16 @@ class SearchDeviceContactsTest { expectContactsCount(0) // When - val actual = searchDeviceContacts(query).getOrNull() + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() // Then assertNotNull(actual) - assertTrue(actual.size == 0) + Assert.assertTrue(actual.size == 0) verify(exactly = 0) { cursorMock.getString(columnIndexDisplayName) } verify(exactly = 0) { cursorMock.getString(columnIndexEmail) } } - @Test + @org.junit.Test fun `when content resolver throws SecurityException, left is emitted`() = runTest(testDispatcherProvider.Main) { // Given val query = "cont" @@ -125,11 +107,12 @@ class SearchDeviceContactsTest { expectContactsCount(0) // When - val actual = searchDeviceContacts(query) + val actual = deviceContactsRepository.getDeviceContacts(query) // Then - assertEquals(GetContactError.left(), actual) + assertEquals(DeviceContactsRepository.DeviceContactsErrors.PermissionDenied.left(), actual) verify(exactly = 0) { cursorMock.getString(columnIndexDisplayName) } verify(exactly = 0) { cursorMock.getString(columnIndexEmail) } } + } diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/DeviceContactsRepository.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/DeviceContactsRepository.kt new file mode 100644 index 0000000000..fc2977d3db --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/DeviceContactsRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.repository + +import arrow.core.Either +import ch.protonmail.android.mailcontact.domain.model.DeviceContact + +interface DeviceContactsRepository { + + suspend fun getDeviceContacts(query: String): Either> + + sealed class DeviceContactsErrors { + data object PermissionDenied : DeviceContactsErrors() + } + +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/SearchDeviceContacts.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/SearchDeviceContacts.kt index 6990bbdbd0..6cdc2dbd27 100644 --- a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/SearchDeviceContacts.kt +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/SearchDeviceContacts.kt @@ -18,84 +18,20 @@ package ch.protonmail.android.mailcontact.domain.usecase -import android.content.Context -import android.provider.ContactsContract import arrow.core.Either -import arrow.core.left -import arrow.core.right import ch.protonmail.android.mailcontact.domain.model.DeviceContact import ch.protonmail.android.mailcontact.domain.model.GetContactError -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.withContext -import me.proton.core.util.kotlin.DispatcherProvider -import timber.log.Timber +import ch.protonmail.android.mailcontact.domain.repository.DeviceContactsRepository import javax.inject.Inject class SearchDeviceContacts @Inject constructor( - @ApplicationContext private val context: Context, - private val dispatcherProvider: DispatcherProvider + private val deviceContactsRepository: DeviceContactsRepository ) { suspend operator fun invoke(query: String): Either> { - - val contentResolver = context.contentResolver - - val selectionArgs = arrayOf("%$query%", "%$query%", "%$query%") - - @Suppress("SwallowedException") - val contactEmails = try { - withContext(dispatcherProvider.Io) { - contentResolver.query( - ContactsContract.CommonDataKinds.Email.CONTENT_URI, - ANDROID_PROJECTION, - ANDROID_SELECTION, - selectionArgs, - ANDROID_ORDER_BY - ) - } - } catch (e: SecurityException) { - Timber.d("SearchDeviceContacts: contact permission is not granted") - null - } ?: return GetContactError.left() - - val deviceContacts = mutableListOf() - - val displayNameColumnIndex = contactEmails.getColumnIndex( - ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY - ).takeIf { - it >= 0 - } ?: 0 - - val emailColumnIndex = contactEmails.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS).takeIf { - it >= 0 - } ?: 0 - - contactEmails.use { cursor -> - for (position in 0 until cursor.count) { - cursor.moveToPosition(position) - deviceContacts.add( - DeviceContact( - name = contactEmails.getString(displayNameColumnIndex), - email = contactEmails.getString(emailColumnIndex) - ) - ) - } + return deviceContactsRepository.getDeviceContacts(query).mapLeft { + GetContactError } - - return deviceContacts.right() } - companion object { - - private const val ANDROID_ORDER_BY = ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY + " ASC" - - @Suppress("MaxLineLength") - private const val ANDROID_SELECTION = "${ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY} LIKE ? OR ${ContactsContract.CommonDataKinds.Email.ADDRESS} LIKE ? OR ${ContactsContract.CommonDataKinds.Email.DATA} LIKE ?" - - private val ANDROID_PROJECTION = arrayOf( - ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY, - ContactsContract.CommonDataKinds.Email.ADDRESS, - ContactsContract.CommonDataKinds.Email.DATA - ) - } }