From ea4b695e091e6116b319d789ff2c7df8c398cc18 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Mon, 2 Mar 2026 20:10:56 +0600 Subject: [PATCH 1/2] refactor(search): resolve install button's label and action on onResume() callback To reflect the correct state and label on the install button, fragment updates the PagingData flow on its onResume() callback. So, when an app is uninstalled from system, the search results don't show it as installed any more, instead, the button's label and state are updated. --- .../e/apps/ui/search/v2/SearchFragmentV2.kt | 5 + .../e/apps/ui/search/v2/SearchViewModelV2.kt | 10 +- .../ui/search/v2/SearchViewModelV2Test.kt | 162 ++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index c9f2bd1e5..821f0fe5b 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -72,6 +72,11 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { setComposeContent(composeView) } + override fun onResume() { + super.onResume() + searchViewModel.refreshInstallStatuses() + } + private fun setupComposeView(view: View): ComposeView { return view.findViewById(R.id.composeView).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index a152850ff..3de94ff52 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -105,6 +105,7 @@ class SearchViewModelV2 @Inject constructor( private val _scrollPositions = MutableStateFlow>(emptyMap()) private val statusSnapshot = MutableStateFlow(null) private val downloadProgress = MutableStateFlow(null) + private val installStatusRefreshTick = MutableStateFlow(0L) private val _progressPercentByKey = MutableStateFlow>(emptyMap()) val progressPercentByKey: StateFlow> = _progressPercentByKey.asStateFlow() private val _statusByKey = MutableStateFlow>(emptyMap()) @@ -299,12 +300,17 @@ class SearchViewModelV2 @Inject constructor( downloadProgress.value = progress } + fun refreshInstallStatuses() { + installStatusRefreshTick.update { it + 1 } + } + private fun Flow>.withStatus(): Flow> = combine( this, statusSnapshot.filterNotNull(), - downloadProgress - ) { paging: PagingData, snapshot: StatusSnapshot, progress: DownloadProgress? -> + downloadProgress, + installStatusRefreshTick + ) { paging, snapshot, progress, _ -> paging.map { app -> reconcile(app, snapshot, progress) } } diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index f403ea961..31b27e398 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -51,6 +51,7 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -546,6 +547,167 @@ class SearchViewModelV2Test { assertNull(viewModel.getScrollPosition(SearchTabType.OPEN_SOURCE)) } + @Test + fun `refreshInstallStatuses reconciles existing results without new search`() = runTest { + val app = Application( + _id = "id-refresh", + package_name = "com.example.refresh", + status = Status.UNAVAILABLE, + ) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app))) + + var reconciledStatus = Status.INSTALLED + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers { + val reconciledApp = args[0] as Application + reconciledApp.status = reconciledStatus + InstallStatusReconciler.Result(reconciledApp) + } + + viewModel.onSearchSubmitted("apps") + collectApplications(viewModel.fossPagingFlow.first()) + assertEquals(Status.INSTALLED, viewModel.statusFor(app)) + + reconciledStatus = Status.UPDATABLE + viewModel.refreshInstallStatuses() + collectApplications(viewModel.fossPagingFlow.first()) + + assertEquals(Status.UPDATABLE, viewModel.statusFor(app)) + verify(exactly = 1) { searchPagingRepository.cleanApkSearch(any()) } + } + + @Test + fun `refreshInstallStatuses updates uninstalled app to unavailable`() = runTest { + val app = Application( + _id = "id-uninstall", + package_name = "com.example.uninstall", + status = Status.UPDATABLE, + ) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app))) + + var reconciledStatus = Status.UPDATABLE + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers { + val reconciledApp = args[0] as Application + reconciledApp.status = reconciledStatus + InstallStatusReconciler.Result(reconciledApp) + } + + viewModel.onSearchSubmitted("apps") + collectApplications(viewModel.fossPagingFlow.first()) + assertEquals(Status.UPDATABLE, viewModel.statusFor(app)) + + reconciledStatus = Status.UNAVAILABLE + viewModel.refreshInstallStatuses() + collectApplications(viewModel.fossPagingFlow.first()) + + assertEquals(Status.UNAVAILABLE, viewModel.statusFor(app)) + } + + @Test + fun `refreshInstallStatuses is safe when snapshot or progress is null`() = runTest { + every { installStatusStream.stream(any(), any(), any()) } returns emptyFlow() + buildViewModel() + + viewModel.refreshInstallStatuses() + viewModel.updateDownloadProgress(null) + + assertTrue(viewModel.statusByKey.value.isEmpty()) + assertTrue(viewModel.progressPercentByKey.value.isEmpty()) + } + + @Test + fun `refreshInstallStatuses multiple apps updates only affected app`() = runTest { + val appOne = Application( + _id = "id-one", + package_name = "com.example.one", + status = Status.INSTALLED, + ) + val appTwo = Application( + _id = "id-two", + package_name = "com.example.two", + status = Status.INSTALLED, + ) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf( + PagingData.from(listOf(appOne, appTwo)) + ) + + val statusByPackage = mutableMapOf( + appOne.package_name to Status.INSTALLED, + appTwo.package_name to Status.INSTALLED, + ) + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers { + val reconciledApp = args[0] as Application + reconciledApp.status = statusByPackage[reconciledApp.package_name] ?: Status.UNAVAILABLE + InstallStatusReconciler.Result(reconciledApp) + } + + viewModel.onSearchSubmitted("apps") + collectApplications(viewModel.fossPagingFlow.first()) + assertEquals(Status.INSTALLED, viewModel.statusFor(appOne)) + assertEquals(Status.INSTALLED, viewModel.statusFor(appTwo)) + + statusByPackage[appOne.package_name] = Status.UNAVAILABLE + viewModel.refreshInstallStatuses() + collectApplications(viewModel.fossPagingFlow.first()) + + assertEquals(Status.UNAVAILABLE, viewModel.statusFor(appOne)) + assertEquals(Status.INSTALLED, viewModel.statusFor(appTwo)) + } + + @Test + fun `refresh does not corrupt progressPercentByKey`() = runTest { + val app = Application( + _id = "id-progress-refresh", + package_name = "com.example.progress.refresh", + status = Status.DOWNLOADING, + ) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app))) + + var reconciledProgress = 18 + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers { + val reconciledApp = args[0] as Application + reconciledApp.status = Status.DOWNLOADING + InstallStatusReconciler.Result(reconciledApp, reconciledProgress) + } + + viewModel.onSearchSubmitted("apps") + collectApplications(viewModel.fossPagingFlow.first()) + assertEquals(18, viewModel.progressPercentFor(app)) + + reconciledProgress = 67 + viewModel.refreshInstallStatuses() + collectApplications(viewModel.fossPagingFlow.first()) + + assertEquals(67, viewModel.progressPercentFor(app)) + } + + @Test + fun `refresh does not break pendingInstalls cleanup`() = runTest { + val app = Application( + _id = "id-pending-refresh", + package_name = "com.example.pending.refresh", + status = Status.UNAVAILABLE, + ) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app))) + + var reconciledStatus = Status.UNAVAILABLE + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers { + val reconciledApp = args[0] as Application + reconciledApp.status = reconciledStatus + InstallStatusReconciler.Result(reconciledApp) + } + + viewModel.markPendingInstall(app) + viewModel.onSearchSubmitted("apps") + collectApplications(viewModel.fossPagingFlow.first()) + assertTrue(viewModel.pendingInstalls.value.contains(app.package_name)) + + reconciledStatus = Status.DOWNLOADING + viewModel.refreshInstallStatuses() + collectApplications(viewModel.fossPagingFlow.first()) + + assertFalse(viewModel.pendingInstalls.value.contains(app.package_name)) + } + @Test fun `progress percent keys by package name`() = runTest { val app = Application( -- GitLab From a52336fa914e61b8df5428d71a0c77c7a6049279 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Mon, 2 Mar 2026 22:58:18 +0600 Subject: [PATCH 2/2] refactor(search): resolve install button's action when it's stuck on indefinite progress indicator For error scenarios where install button's action was set to NoOp, there was no handling for it. Hence, clicking on the button wouldn't perform any action. This commit fixes the issue by canceling any pending operation when such scenario occurs. --- .../compose/components/SearchResultListItem.kt | 2 +- .../ui/compose/state/InstallButtonState.kt | 10 ++++++++-- .../compose/state/InstallButtonStateMapper.kt | 18 +++++++++--------- .../e/apps/ui/search/v2/SearchFragmentV2.kt | 7 ++++--- .../state/InstallButtonStateMapperTest.kt | 16 +++++++++------- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt index 618ccc878..31d77299d 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt @@ -290,7 +290,7 @@ data class PrimaryActionUiState( val isFilledStyle: Boolean, val isDisabledStyle: Boolean = false, val showMore: Boolean = false, - val actionIntent: InstallButtonAction = InstallButtonAction.NoOp, + val actionIntent: InstallButtonAction = InstallButtonAction.ShowUnsupportedDialog, val progressFraction: Float = 0f, ) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt index 6a9b07e3d..ebb456ae5 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt @@ -32,7 +32,7 @@ data class InstallButtonState( val showProgressBar: Boolean = false, val progressPercentText: String? = null, val progressFraction: Float = 0f, - val actionIntent: InstallButtonAction = InstallButtonAction.NoOp, + val actionIntent: InstallButtonAction = InstallButtonAction.ShowUnsupportedDialog, @StringRes val snackbarMessageId: Int? = null, val dialogType: InstallDialogType? = null, val statusTag: StatusTag = StatusTag.Unknown, @@ -58,7 +58,13 @@ enum class InstallButtonStyle { } enum class InstallButtonAction { - Install, CancelDownload, OpenAppOrPwa, UpdateSelfConfirm, ShowPaidDialog, ShowBlockedSnackbar, NoOp, + Install, + CancelDownload, + OpenAppOrPwa, + UpdateSelfConfirm, + ShowPaidDialog, + ShowBlockedSnackbar, + ShowUnsupportedDialog, } enum class InstallDialogType { diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt index 4152b2000..a624bfe87 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt @@ -58,7 +58,7 @@ private fun mapUpdatable(input: InstallButtonStateInput): InstallButtonState { enabled = true, style = buildStyleFor(status = Status.UPDATABLE, enabled = true), actionIntent = when { - unsupported -> InstallButtonAction.NoOp + unsupported -> InstallButtonAction.ShowUnsupportedDialog input.isSelfUpdate -> InstallButtonAction.UpdateSelfConfirm else -> InstallButtonAction.Install }, @@ -81,7 +81,7 @@ private fun mapUnavailableUnsupported(): InstallButtonState { label = ButtonLabel(resId = R.string.not_available), enabled = true, style = buildStyleFor(Status.UNAVAILABLE, enabled = true), - actionIntent = InstallButtonAction.NoOp, + actionIntent = InstallButtonAction.ShowUnsupportedDialog, statusTag = StatusTag.UnavailableUnsupported, ) } @@ -114,7 +114,7 @@ private fun mapUnavailablePaid(input: InstallButtonStateInput): InstallButtonSta enabled = false, style = buildStyleFor(Status.UNAVAILABLE, enabled = false), showProgressBar = true, - actionIntent = InstallButtonAction.NoOp, + actionIntent = InstallButtonAction.ShowUnsupportedDialog, statusTag = StatusTag.UnavailablePaid, ) @@ -169,9 +169,9 @@ private fun mapDownloading(input: InstallButtonStateInput, status: Status): Inst private fun mapInstalling(status: Status): InstallButtonState { return InstallButtonState( label = ButtonLabel(resId = R.string.installing), - enabled = true, - style = buildStyleFor(status, enabled = true), - actionIntent = InstallButtonAction.NoOp, + enabled = false, + style = buildStyleFor(status, enabled = false), + actionIntent = InstallButtonAction.ShowUnsupportedDialog, statusTag = StatusTag.Installing, rawStatus = status, ) @@ -208,9 +208,9 @@ private fun mapInstallationIssue(input: InstallButtonStateInput): InstallButtonS private fun mapUnknown(input: InstallButtonStateInput): InstallButtonState { return InstallButtonState( label = ButtonLabel(resId = R.string.install), - enabled = true, - style = InstallButtonStyle.AccentOutline, - actionIntent = InstallButtonAction.NoOp, + enabled = false, + style = InstallButtonStyle.Disabled, + actionIntent = InstallButtonAction.ShowUnsupportedDialog, statusTag = StatusTag.Unknown, rawStatus = input.app.status, ) diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index 821f0fe5b..ca59845a2 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -177,7 +177,7 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { enabled = true, style = InstallButtonStyle.Disabled, showProgressBar = true, - actionIntent = InstallButtonAction.NoOp, + actionIntent = InstallButtonAction.CancelDownload, ) } else { mapInstallButtonState( @@ -309,8 +309,9 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { InstallButtonAction.ShowBlockedSnackbar -> { showBlockedSnackbar() } - - InstallButtonAction.NoOp -> Unit + InstallButtonAction.ShowUnsupportedDialog -> { + mainActivityViewModel.checkUnsupportedApplication(app, requireContext()) + } } } diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt index 7e70eaf0b..134a9bdee 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt @@ -105,7 +105,7 @@ class InstallButtonStateMapperTest { } @Test - fun updatable_unsupported_is_noop() { + fun updatable_unsupported_shows_unsupported_dialog() { val state = mapAppToInstallState( input = defaultInput( app = baseApp(Status.UPDATABLE), @@ -113,7 +113,7 @@ class InstallButtonStateMapperTest { ), ) assertEquals(R.string.not_available, state.label.resId) - assertEquals(InstallButtonAction.NoOp, state.actionIntent) + assertEquals(InstallButtonAction.ShowUnsupportedDialog, state.actionIntent) assertEquals(StatusTag.Updatable, state.statusTag) } @@ -170,7 +170,7 @@ class InstallButtonStateMapperTest { } @Test - fun unavailable_unsupported_noop() { + fun unavailable_unsupported_shows_unsupported_dialog() { val state = mapAppToInstallState( input = defaultInput( app = baseApp(Status.UNAVAILABLE, isFree = false, price = PAID_PRICE), @@ -178,7 +178,7 @@ class InstallButtonStateMapperTest { ), ) assertEquals(R.string.not_available, state.label.resId) - assertEquals(InstallButtonAction.NoOp, state.actionIntent) + assertEquals(InstallButtonAction.ShowUnsupportedDialog, state.actionIntent) assertEquals(StatusTag.UnavailableUnsupported, state.statusTag) } @@ -245,7 +245,7 @@ class InstallButtonStateMapperTest { input = defaultInput(app = baseApp(Status.INSTALLING)), ) assertEquals(R.string.installing, state.label.resId) - assertTrue(state.enabled) + assertFalse(state.enabled) assertEquals(StatusTag.Installing, state.statusTag) } @@ -320,12 +320,14 @@ class InstallButtonStateMapperTest { } @Test - fun purchase_needed_status_defaults_to_noop() { + fun purchase_needed_status_defaults_to_unsupported_dialog_intent() { val app = baseApp(Status.PURCHASE_NEEDED) val state = mapAppToInstallState( input = defaultInput(app = app), ) - assertEquals(InstallButtonAction.NoOp, state.actionIntent) + assertFalse(state.enabled) + assertEquals(InstallButtonAction.ShowUnsupportedDialog, state.actionIntent) + assertFalse(state.actionIntent == InstallButtonAction.CancelDownload) assertEquals(StatusTag.Unknown, state.statusTag) } } -- GitLab