Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.flipcash.app.contacts

import com.flipcash.app.contacts.device.DeviceContactLookup
import com.flipcash.app.persistence.sources.ContactDataSource
import com.flipcash.app.phone.PhoneUtils
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ContactResolver @Inject constructor(
private val contactDataSource: ContactDataSource,
private val deviceContactLookup: DeviceContactLookup,
private val phoneUtils: PhoneUtils,
) {
suspend fun resolveName(e164: String, fallback: String = e164): String =
contactDataSource.getDisplayName(e164)
?: deviceContactLookup.lookupDisplayName(e164)
?: runCatching { phoneUtils.formatNumber(e164) }.getOrDefault(fallback)

suspend fun resolvePhotoUri(e164: String): String? =
contactDataSource.getPhotoUri(e164)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.flipcash.app.contacts.device

interface DeviceContactLookup {
fun lookupDisplayName(e164: String): String?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.flipcash.app.contacts.device.internal

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import com.flipcash.app.contacts.device.DeviceContactLookup
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
internal class AndroidDeviceContactLookup @Inject constructor(
@param:ApplicationContext private val context: Context,
) : DeviceContactLookup {

override fun lookupDisplayName(e164: String): String? {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
!= PackageManager.PERMISSION_GRANTED
) return null

val uri = ContactsContract.PhoneLookup.CONTENT_FILTER_URI
.buildUpon()
.appendPath(e164)
.build()

return try {
context.contentResolver.query(
uri,
arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME),
null, null, null
)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotEmpty() } else null
}
} catch (e: Exception) {
null
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.flipcash.app.contacts.inject

import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.device.DeviceContactLookup
import com.flipcash.app.contacts.device.internal.AndroidDeviceContactLookup
import com.getcode.opencode.providers.SessionListener
import dagger.Binds
import dagger.Module
Expand All @@ -17,4 +19,9 @@ abstract class ContactModule {
abstract fun bindSessionListener(
coordinator: ContactCoordinator
): SessionListener

@Binds
internal abstract fun bindDeviceContactLookup(
impl: AndroidDeviceContactLookup
): DeviceContactLookup
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.flipcash.app.contacts

import com.flipcash.app.contacts.device.DeviceContactLookup
import com.flipcash.app.persistence.sources.ContactDataSource
import com.flipcash.app.phone.PhoneUtils
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class ContactResolverTest {

private val e164 = "+15551234567"

private fun resolver(
dbName: String? = null,
deviceName: String? = null,
formatted: String? = null,
formatThrows: Boolean = false,
photoUri: String? = null,
): ContactResolver {
val dataSource = mockk<ContactDataSource> {
coEvery { getDisplayName(e164) } returns dbName
coEvery { getPhotoUri(e164) } returns photoUri
}
val deviceLookup = mockk<DeviceContactLookup> {
every { lookupDisplayName(e164) } returns deviceName
}
val phoneUtils = mockk<PhoneUtils> {
if (formatThrows) {
every { formatNumber(any<String>()) } throws RuntimeException("parse error")
} else {
every { formatNumber(any<String>()) } returns (formatted ?: e164)
}
}
return ContactResolver(dataSource, deviceLookup, phoneUtils)
}

// region resolveName

@Test
fun `DB display name is returned when present`() = runTest {
val result = resolver(dbName = "Alice").resolveName(e164)
assertEquals("Alice", result)
}

@Test
fun `device lookup is used when DB returns null`() = runTest {
val result = resolver(deviceName = "Bob Device").resolveName(e164)
assertEquals("Bob Device", result)
}

@Test
fun `formatted number is used when DB and device both return null`() = runTest {
val result = resolver(formatted = "+1 (555) 123-4567").resolveName(e164)
assertEquals("+1 (555) 123-4567", result)
}

@Test
fun `fallback is returned when all sources fail`() = runTest {
val result = resolver(formatThrows = true).resolveName(e164, fallback = "unknown")
assertEquals("unknown", result)
}

@Test
fun `raw e164 is default fallback`() = runTest {
val result = resolver(formatThrows = true).resolveName(e164)
assertEquals(e164, result)
}

@Test
fun `DB takes priority over device lookup`() = runTest {
val result = resolver(dbName = "Alice DB", deviceName = "Alice Device").resolveName(e164)
assertEquals("Alice DB", result)
}

// endregion

// region resolvePhotoUri

@Test
fun `photo URI is returned from data source`() = runTest {
val result = resolver(photoUri = "content://photo/1").resolvePhotoUri(e164)
assertEquals("content://photo/1", result)
}

@Test
fun `null photo URI when data source has none`() = runTest {
val result = resolver().resolvePhotoUri(e164)
assertNull(result)
}

// endregion
}
2 changes: 2 additions & 0 deletions apps/flipcash/shared/notifications/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ dependencies {

testImplementation(kotlin("test"))
testImplementation(libs.robolectric)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
}
Loading
Loading