From c934c91381d9514e60f3cf3f3367090569d0b54c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 13:32:31 -0400 Subject: [PATCH] fix(requests): initialize deck fields before the collector runs Opening a group chat on 2026.10.5 (4951) crashed with an NPE in holdForEndedFade. viewModelScope runs on Main.immediate, so the deck collector started in init publishes before the constructor returns. shownNow, settle and settledDeck were declared after init and were still null then; shownNow went to holdForEndedFade's non-null parameter. Move the three fields above init. ChatRequestsViewModelTest builds the ViewModel with an unconfined main dispatcher, which reproduces the in-place emission and failed with the same NPE before the move. Bugsnag: 6ae37ae9c6c10d79fad03c42 --- .../internal/request/ChatRequestsViewModel.kt | 10 ++-- .../request/ChatRequestsViewModelTest.kt | 55 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModelTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt index f3764039e5..acaf7ebd4f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt @@ -66,6 +66,12 @@ internal class ChatRequestsViewModel @Inject constructor( /** The deck, front card first. Empty hides it. */ val deck: StateFlow> = _deck.asStateFlow() + // Above init: viewModelScope runs on Main.immediate, so the deck collector publishes before + // the constructor returns, and fields declared after init would still be null. + private var shownNow = _now.value + private var settle: Job? = null + private var settledDeck: List = emptyList() + init { viewModelScope.launch { combine( @@ -89,10 +95,6 @@ internal class ChatRequestsViewModel @Inject constructor( } } - private var shownNow = _now.value - private var settle: Job? = null - private var settledDeck: List = emptyList() - /** * Puts [next] on the deck. A request ending moves it in the sort order; the deck keeps its * order for the ended look's fade first, and only then lets the list slide it into place. diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModelTest.kt new file mode 100644 index 0000000000..5b143e3bfc --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModelTest.kt @@ -0,0 +1,55 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.PaymentRequests +import androidx.lifecycle.ViewModelStore +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * `viewModelScope` runs on `Main.immediate`, so the deck collector emits inside the constructor. + * An unconfined main dispatcher reproduces that: the ViewModel has to be fully initialized by then. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatRequestsViewModelTest { + + private val requests = mockk(relaxed = true) { + every { state } returns MutableStateFlow(emptyMap()) + every { missing } returns MutableStateFlow(emptySet()) + } + private val chatCoordinator = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `constructs when the deck collector emits in place`() = runTest { + val store = ViewModelStore() + val viewModel = ChatRequestsViewModel(requests, chatCoordinator, userManager) + store.put("requests", viewModel) + + assertTrue(viewModel.deck.value.isEmpty()) + // Cancels the minute ticker, which would otherwise keep runTest from going idle. + store.clear() + } +}