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 618ccc8788db5800ac5853981af68585da8ddcb4..31d77299d823a79a07ff4c208bda80ad8dd43fc6 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 6a9b07e3de4a440c3ac0fcfb1c1d5d722db56498..ebb456ae5b555623c69b697f96d8a20933e56d45 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 4152b2000b530d21b1a27d0f168e71fe8d77de00..a624bfe87672e8944ba2c8f5b9c67090447d2811 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 c9f2bd1e59b4a5aa24ecc5dbdc2287c01ab8caed..ca59845a25dab878e32873e580fb156d4321e4ca 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) @@ -172,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( @@ -304,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/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index a152850ff725fd89a0e8a902241daa6dd17088fb..3de94ff524859aeb89a4c7533c66430abe282cf5 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/compose/state/InstallButtonStateMapperTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt index 7e70eaf0bb0fcee01eb07f63d141c0ba966334b7..134a9bdeec74c01eb8cfd6831f74886a17e30cd1 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) } } 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 f403ea96105b19d5b73fac9eba98845159c73dbe..31b27e398fcff5a0da23b1262d3ecdcb101801f8 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(