From 3237651418f3795b1950defae5a8680783496a82 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 12:59:41 -0400 Subject: [PATCH] fix(invite): resolve invite channels off the main thread InternalInviteController called resolveChannels() in its constructor, and Hilt builds it on the main thread when MainActivity is injected. Loading the SMS and WhatsApp labels and icons reads those apps' APKs, which ANR'd on 2026.9.1 (three Bugsnag events on OPPO and Xiaomi devices, all stuck in ResolveInfo.loadLabel / getApplicationLabel under resolveChannels). Channels now start empty and resolve on the IO dispatcher. refresh() uses the same path. --- apps/flipcash/shared/invite/build.gradle.kts | 5 ++ .../app/invite/inject/InviteModule.kt | 3 + .../internal/InternalInviteController.kt | 17 +++++- .../internal/InternalInviteControllerTest.kt | 61 +++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/invite/src/test/kotlin/com/flipcash/app/invite/internal/InternalInviteControllerTest.kt diff --git a/apps/flipcash/shared/invite/build.gradle.kts b/apps/flipcash/shared/invite/build.gradle.kts index e5dd16d478..ab06609168 100644 --- a/apps/flipcash/shared/invite/build.gradle.kts +++ b/apps/flipcash/shared/invite/build.gradle.kts @@ -8,4 +8,9 @@ android { dependencies { implementation(project(":apps:flipcash:shared:shareable")) + + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) + testImplementation(testFixtures(project(":libs:coroutines"))) } diff --git a/apps/flipcash/shared/invite/src/main/kotlin/com/flipcash/app/invite/inject/InviteModule.kt b/apps/flipcash/shared/invite/src/main/kotlin/com/flipcash/app/invite/inject/InviteModule.kt index 4ddb603da0..05ab31a355 100644 --- a/apps/flipcash/shared/invite/src/main/kotlin/com/flipcash/app/invite/inject/InviteModule.kt +++ b/apps/flipcash/shared/invite/src/main/kotlin/com/flipcash/app/invite/inject/InviteModule.kt @@ -4,6 +4,7 @@ import android.content.Context import com.flipcash.app.invite.InviteController import com.flipcash.app.invite.internal.InternalInviteController import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.util.resources.ResourceHelper import dagger.Module import dagger.Provides @@ -22,9 +23,11 @@ object InviteModule { @ApplicationContext context: Context, resources: ResourceHelper, shareController: ShareSheetController, + dispatchers: DispatcherProvider, ): InviteController = InternalInviteController( context = context, resources = resources, shareController = shareController, + dispatchers = dispatchers, ) } diff --git a/apps/flipcash/shared/invite/src/main/kotlin/com/flipcash/app/invite/internal/InternalInviteController.kt b/apps/flipcash/shared/invite/src/main/kotlin/com/flipcash/app/invite/internal/InternalInviteController.kt index 620f4b9a4b..b1438996a4 100644 --- a/apps/flipcash/shared/invite/src/main/kotlin/com/flipcash/app/invite/internal/InternalInviteController.kt +++ b/apps/flipcash/shared/invite/src/main/kotlin/com/flipcash/app/invite/internal/InternalInviteController.kt @@ -13,10 +13,14 @@ import com.flipcash.app.invite.InviteController import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable import com.flipcash.shared.invite.R +import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking internal class InternalInviteController( @@ -24,13 +28,22 @@ internal class InternalInviteController( private val context: Context, private val resources: ResourceHelper, private val shareController: ShareSheetController, + dispatchers: DispatcherProvider, ) : InviteController { - private val _channels = MutableStateFlow(resolveChannels()) + // Hilt constructs this on the main thread when MainActivity is injected, and loading + // other apps' labels and icons reads their APKs, which ANRs on slow devices. + private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO) + + private val _channels = MutableStateFlow>(emptyList()) override val channels: StateFlow> = _channels + init { + refresh() + } + override fun refresh() { - _channels.value = resolveChannels() + scope.launch { _channels.value = resolveChannels() } } private fun resolveChannels(): List = buildList { diff --git a/apps/flipcash/shared/invite/src/test/kotlin/com/flipcash/app/invite/internal/InternalInviteControllerTest.kt b/apps/flipcash/shared/invite/src/test/kotlin/com/flipcash/app/invite/internal/InternalInviteControllerTest.kt new file mode 100644 index 0000000000..15ad820bd9 --- /dev/null +++ b/apps/flipcash/shared/invite/src/test/kotlin/com/flipcash/app/invite/internal/InternalInviteControllerTest.kt @@ -0,0 +1,61 @@ +package com.flipcash.app.invite.internal + +import android.content.Context +import android.content.pm.PackageManager +import com.flipcash.app.invite.InviteChannelType +import com.flipcash.libs.coroutines.TestDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class InternalInviteControllerTest { + + private val packageManager: PackageManager = mockk { + every { queryIntentActivities(any(), any()) } returns emptyList() + every { getApplicationInfo(any(), any()) } throws PackageManager.NameNotFoundException() + } + private val context: Context = mockk { + every { packageManager } returns this@InternalInviteControllerTest.packageManager + } + + @Test + fun `construction does not query the package manager`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + + val controller = controller(dispatcher) + + assertTrue(controller.channels.value.isEmpty()) + verify(exactly = 0) { context.packageManager } + } + + @Test + fun `channels resolve on the io dispatcher`() = runTest { + val controller = controller(StandardTestDispatcher(testScheduler)) + + advanceUntilIdle() + + assertEquals( + listOf(InviteChannelType.Sms, InviteChannelType.More), + controller.channels.value.map { it.type }, + ) + } + + private fun controller(dispatcher: TestDispatcher) = InternalInviteController( + context = context, + resources = mockk(relaxed = true), + shareController = mockk(relaxed = true), + dispatchers = TestDispatcherProvider(dispatcher), + ) +}