Update viewmodel to reflect gplayapi changes
This commit is contained in:
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.compose.composition
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.neverEqualPolicy
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Collects a [Flow] as [State] starting from [initial], always triggering recomposition on
|
||||
* every emission regardless of structural equality, by using [neverEqualPolicy].
|
||||
*
|
||||
* Useful for non-state flows (e.g. [kotlinx.coroutines.flow.SharedFlow]) and for flows whose
|
||||
* values have broken `equals` implementations that would otherwise be conflated upstream.
|
||||
*/
|
||||
@Composable
|
||||
internal fun <T> Flow<T>.collectForced(initial: T): State<T> {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val state: MutableState<T> = remember(this) { mutableStateOf(initial, neverEqualPolicy()) }
|
||||
LaunchedEffect(this, lifecycleOwner) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
collect { state.value = it }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes a [LiveData] as [State], always triggering recomposition on every emission
|
||||
* regardless of structural equality, by using [neverEqualPolicy].
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Composable
|
||||
internal fun <T> LiveData<T>.observeForced(): State<T?> {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val state: MutableState<T?> = remember { mutableStateOf(value, neverEqualPolicy()) }
|
||||
DisposableEffect(this, lifecycleOwner) {
|
||||
val observer = Observer<T> { state.value = it }
|
||||
observe(lifecycleOwner, observer)
|
||||
onDispose { removeObserver(observer) }
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.gplayapi.data.models.StreamCluster
|
||||
import com.aurora.gplayapi.helpers.contracts.StreamContract
|
||||
import com.aurora.store.HomeStash
|
||||
import com.aurora.store.compose.composable.StreamCarousel
|
||||
import com.aurora.store.compose.composition.observeForced
|
||||
import com.aurora.store.data.model.ViewState
|
||||
import com.aurora.store.viewmodel.homestream.StreamViewModel
|
||||
|
||||
@@ -29,7 +29,7 @@ internal fun ForYouContent(
|
||||
onScrolledToEnd: () -> Unit
|
||||
) {
|
||||
val category = category(pageType)
|
||||
val state by viewModel.liveData.observeForced()
|
||||
val state by viewModel.liveData.observeAsState()
|
||||
|
||||
LaunchedEffect(category) {
|
||||
viewModel.getStreamBundle(category, StreamContract.Type.HOME)
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.gplayapi.data.models.StreamCluster
|
||||
import com.aurora.gplayapi.helpers.contracts.TopChartsContract
|
||||
@@ -41,7 +42,6 @@ import com.aurora.store.R
|
||||
import com.aurora.store.compose.composable.Placeholder
|
||||
import com.aurora.store.compose.composable.ShimmerAppRow
|
||||
import com.aurora.store.compose.composable.app.LargeAppListItem
|
||||
import com.aurora.store.compose.composition.collectForced
|
||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.data.model.ViewState
|
||||
@@ -72,7 +72,7 @@ internal fun TopChartsContent(
|
||||
if (pageType == 1) TopChartsContract.Type.GAME else TopChartsContract.Type.APPLICATION
|
||||
var selectedIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||
val selectedChart = charts[selectedIndex]
|
||||
val state by viewModel.state.collectForced(ViewState.Loading)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val cluster = state.getDataAs<StreamCluster?>()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -223,7 +223,7 @@ private fun TopChartsBodyLoadingPreview() {
|
||||
private fun TopChartsBodyLoadedPreview() {
|
||||
val app = AppPreviewProvider().values.first()
|
||||
val apps = List(5) { i -> app.copy(id = i + 1, packageName = "com.preview.app$i") }
|
||||
val cluster = StreamCluster(clusterAppList = apps)
|
||||
val cluster = StreamCluster(id = 1, clusterAppList = apps)
|
||||
TopChartsBody(
|
||||
selectedIndex = 0,
|
||||
chartTitles = previewChartTitles,
|
||||
@@ -236,7 +236,7 @@ private fun TopChartsBodyLoadedPreview() {
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TopChartsBodyEmptyPreview() {
|
||||
val cluster = StreamCluster(clusterAppList = emptyList())
|
||||
val cluster = StreamCluster(id = 1, clusterAppList = emptyList())
|
||||
TopChartsBody(
|
||||
selectedIndex = 1,
|
||||
chartTitles = previewChartTitles,
|
||||
|
||||
@@ -13,12 +13,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.aurora.gplayapi.data.models.StreamBundle
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.compose.composable.Placeholder
|
||||
import com.aurora.store.compose.composable.StreamCarousel
|
||||
import com.aurora.store.compose.composable.TopAppBar
|
||||
import com.aurora.store.compose.composition.collectForced
|
||||
import com.aurora.store.compose.navigation.Destination
|
||||
import com.aurora.store.data.model.ViewState
|
||||
import com.aurora.store.viewmodel.subcategory.CategoryStreamViewModel
|
||||
@@ -34,7 +34,7 @@ fun CategoryBrowseScreen(
|
||||
}
|
||||
)
|
||||
) {
|
||||
val uiState by viewModel.viewState.collectForced(ViewState.Loading)
|
||||
val uiState by viewModel.viewState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = title) }
|
||||
|
||||
@@ -72,7 +72,6 @@ import com.aurora.store.compose.composable.SectionHeader
|
||||
import com.aurora.store.compose.composable.ShimmerCarouselSection
|
||||
import com.aurora.store.compose.composable.StreamCarousel
|
||||
import com.aurora.store.compose.composable.TopAppBar
|
||||
import com.aurora.store.compose.composition.collectForced
|
||||
import com.aurora.store.compose.navigation.Destination
|
||||
import com.aurora.store.compose.navigation.Screen
|
||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||
@@ -126,7 +125,7 @@ fun AppDetailsScreen(
|
||||
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
|
||||
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
|
||||
val installError by viewModel.installError.collectAsStateWithLifecycle()
|
||||
val suggestionsBundle by viewModel.suggestionsBundle.collectForced(initial = null)
|
||||
val suggestionsBundle by viewModel.suggestionsBundle.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
|
||||
|
||||
@@ -694,6 +693,7 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class)
|
||||
app = app,
|
||||
isAnonymous = false,
|
||||
suggestionsBundle = StreamBundle(
|
||||
id = 1,
|
||||
streamClusters = mapOf(
|
||||
1 to StreamCluster(
|
||||
id = 1,
|
||||
|
||||
@@ -237,7 +237,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
else -> {
|
||||
val userMessage = exception.userReason()
|
||||
val userMessage = exception.message?.takeIf { it.isNotBlank() }
|
||||
?: context.getString(R.string.download_failed)
|
||||
notifyStatus(
|
||||
status = DownloadStatus.FAILED,
|
||||
@@ -261,22 +261,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a user-readable message from an exception. Falls back to reading the
|
||||
* `reason` property via reflection for GPlayApi's `InternalException` variants,
|
||||
* which expose their detail through a data-class field rather than [Throwable.message].
|
||||
*
|
||||
* TODO: Remove reflection and use a proper exception hierarchy once GPlayApi supports it.
|
||||
*/
|
||||
private fun Throwable.userReason(): String? {
|
||||
message?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return runCatching {
|
||||
javaClass.getDeclaredField("reason")
|
||||
.apply { isAccessible = true }
|
||||
.get(this) as? String
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchases the app to get the download URL of the required files
|
||||
* @param packageName The packageName of the app
|
||||
|
||||
@@ -39,13 +39,13 @@ import com.aurora.store.util.PackageUtil
|
||||
import com.aurora.store.util.Preferences
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
@@ -194,8 +194,8 @@ class AuthViewModel @Inject constructor(
|
||||
context,
|
||||
Preferences.PREFERENCE_AUTH_VIA_MICROG,
|
||||
accountType == AccountType.GOOGLE &&
|
||||
tokenType == AuthHelper.Token.AUTH &&
|
||||
PackageUtil.hasSupportedMicroGVariant(context)
|
||||
tokenType == AuthHelper.Token.AUTH &&
|
||||
PackageUtil.hasSupportedMicroGVariant(context)
|
||||
)
|
||||
_authState.value = AuthState.SignedIn
|
||||
} else {
|
||||
|
||||
@@ -61,7 +61,10 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
|
||||
val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl)
|
||||
if (browseResponse.hasBrowseTab()) {
|
||||
listUrl = browseResponse.browseTab.listUrl
|
||||
val cluster = streamHelper.getExpandedBrowseClusters(listUrl)
|
||||
val cluster = streamHelper.getExpandedBrowseClusters(
|
||||
id = listUrl.hashCode(),
|
||||
listUrl
|
||||
)
|
||||
_title.value = cluster.clusterTitle
|
||||
nextPageUrl = cluster.clusterNextPageUrl
|
||||
cluster.clusterAppList
|
||||
@@ -72,7 +75,10 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
|
||||
|
||||
else -> {
|
||||
if (nextPageUrl.isNotBlank()) {
|
||||
val cluster = streamHelper.getExpandedBrowseClusters(nextPageUrl)
|
||||
val cluster = streamHelper.getExpandedBrowseClusters(
|
||||
nextPageUrl.hashCode(),
|
||||
nextPageUrl
|
||||
)
|
||||
nextPageUrl = cluster.clusterNextPageUrl
|
||||
cluster.clusterAppList
|
||||
} else {
|
||||
|
||||
@@ -68,7 +68,10 @@ class StreamBrowseViewModel @AssistedInject constructor(
|
||||
|
||||
else -> {
|
||||
if (nextPageUrl.isNotBlank()) {
|
||||
streamHelper.nextStreamCluster(nextPageUrl).also {
|
||||
streamHelper.nextStreamCluster(
|
||||
nextPageUrl.hashCode(),
|
||||
nextPageUrl
|
||||
).also {
|
||||
nextPageUrl = it.clusterNextPageUrl
|
||||
}.clusterAppList
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.aurora.gplayapi.data.models.StreamBundle
|
||||
import com.aurora.gplayapi.data.models.StreamCluster
|
||||
import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport
|
||||
import com.aurora.gplayapi.data.models.details.TestingProgramStatus
|
||||
import com.aurora.gplayapi.exceptions.GooglePlayException
|
||||
import com.aurora.gplayapi.helpers.AppDetailsHelper
|
||||
import com.aurora.gplayapi.helpers.ReviewsHelper
|
||||
import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper
|
||||
@@ -46,12 +47,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
@@ -82,13 +80,10 @@ class AppDetailsViewModel @Inject constructor(
|
||||
private val _state = MutableStateFlow<AppState>(AppState.Loading)
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _suggestionsBundle = MutableSharedFlow<StreamBundle?>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val suggestionsBundle: SharedFlow<StreamBundle?> = _suggestionsBundle.asSharedFlow()
|
||||
private val _suggestionsBundle = MutableStateFlow<StreamBundle?>(null)
|
||||
val suggestionsBundle: StateFlow<StreamBundle?> = _suggestionsBundle.asStateFlow()
|
||||
|
||||
private var suggestionsState: StreamBundle = StreamBundle()
|
||||
private var suggestionsState: StreamBundle = StreamBundle.EMPTY
|
||||
|
||||
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
|
||||
val featuredReviews = _featuredReviews.asStateFlow()
|
||||
@@ -167,31 +162,48 @@ class AppDetailsViewModel @Inject constructor(
|
||||
|
||||
fun fetchAppDetails(packageName: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
authProvider.awaitReady()
|
||||
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
|
||||
isInstalled = PackageUtil.isInstalled(context, packageName)
|
||||
)
|
||||
val existingDownload = downloadHelper.getDownload(packageName)
|
||||
var retried = false
|
||||
while (true) {
|
||||
try {
|
||||
authProvider.awaitReady()
|
||||
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
|
||||
isInstalled = PackageUtil.isInstalled(context, packageName)
|
||||
)
|
||||
val existingDownload = downloadHelper.getDownload(packageName)
|
||||
|
||||
// A COMPLETED record for an app that is no longer installed means the app was
|
||||
// installed then removed while Aurora held a stale record.
|
||||
// Remove it so the live download observer doesn't lock the UI in Installing state
|
||||
// indefinitely.
|
||||
if (existingDownload?.status == DownloadStatus.COMPLETED && !isInstalled) {
|
||||
downloadHelper.removeDownload(packageName)
|
||||
_state.value = defaultAppState
|
||||
} else {
|
||||
// Seed state from any in-flight download for this package so reopening
|
||||
// the screen doesn't briefly flash the default install action while the
|
||||
// download flow catches up.
|
||||
_state.value =
|
||||
existingDownload?.let { stateFromDownload(it) } ?: defaultAppState
|
||||
// A COMPLETED record for an app that is no longer installed means the app was
|
||||
// installed then removed while Aurora held a stale record.
|
||||
// Remove it so the live download observer doesn't lock the UI in Installing state
|
||||
// indefinitely.
|
||||
if (existingDownload?.status == DownloadStatus.COMPLETED && !isInstalled) {
|
||||
downloadHelper.removeDownload(packageName)
|
||||
_state.value = defaultAppState
|
||||
} else {
|
||||
// Seed state from any in-flight download for this package so reopening
|
||||
// the screen doesn't briefly flash the default install action while the
|
||||
// download flow catches up.
|
||||
_state.value =
|
||||
existingDownload?.let { stateFromDownload(it) } ?: defaultAppState
|
||||
}
|
||||
break
|
||||
} catch (exception: Exception) {
|
||||
// gplayapi throws AppNotFound(code=401) when the saved token is
|
||||
// rejected mid-session; refresh anonymous auth once and retry.
|
||||
if (!retried &&
|
||||
authProvider.isAnonymous &&
|
||||
exception is GooglePlayException.NotFound &&
|
||||
exception.code == 401
|
||||
) {
|
||||
Log.w(TAG, "App details fetch returned 401, refreshing auth", exception)
|
||||
retried = true
|
||||
authProvider.refreshAnonymousAuth()
|
||||
continue
|
||||
}
|
||||
Log.e(TAG, "Failed to fetch app details", exception)
|
||||
_app.value = null
|
||||
_state.value = AppState.Error(exception.message)
|
||||
break
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to fetch app details", exception)
|
||||
_app.value = null
|
||||
_state.value = AppState.Error(exception.message)
|
||||
}
|
||||
}.invokeOnCompletion { throwable ->
|
||||
// Only proceed if there was no error while fetching the app details
|
||||
@@ -346,21 +358,21 @@ class AppDetailsViewModel @Inject constructor(
|
||||
|
||||
// Bail out if we got no suggestions to offer
|
||||
if (streamUrl.isNullOrBlank()) {
|
||||
_suggestionsBundle.tryEmit(StreamBundle.EMPTY)
|
||||
_suggestionsBundle.value = StreamBundle.EMPTY
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val pageBundle = appDetailsHelper.getDetailsStream(streamUrl)
|
||||
val pageBundle = appDetailsHelper.getDetailsStream(streamUrl.hashCode(), streamUrl)
|
||||
val pageClusters = pageBundle.streamClusters.filterValues {
|
||||
it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty()
|
||||
}
|
||||
suggestionsState = pageBundle.copy(streamClusters = pageClusters)
|
||||
_suggestionsBundle.tryEmit(suggestionsState)
|
||||
_suggestionsBundle.value = suggestionsState
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to fetch suggestions stream", exception)
|
||||
_suggestionsBundle.tryEmit(StreamBundle.EMPTY)
|
||||
_suggestionsBundle.value = StreamBundle.EMPTY
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,7 +382,10 @@ class AppDetailsViewModel @Inject constructor(
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val nextPage = appDetailsHelper.getNextStreamCluster(cluster.clusterNextPageUrl)
|
||||
val nextPage = appDetailsHelper.getNextStreamCluster(
|
||||
cluster.id,
|
||||
cluster.clusterNextPageUrl
|
||||
)
|
||||
val existing = suggestionsState.streamClusters[cluster.id] ?: return@launch
|
||||
val mergedCluster = existing.copy(
|
||||
clusterAppList = existing.clusterAppList + nextPage.clusterAppList,
|
||||
@@ -379,7 +394,7 @@ class AppDetailsViewModel @Inject constructor(
|
||||
suggestionsState = suggestionsState.copy(
|
||||
streamClusters = suggestionsState.streamClusters + (cluster.id to mergedCluster)
|
||||
)
|
||||
_suggestionsBundle.tryEmit(suggestionsState)
|
||||
_suggestionsBundle.value = suggestionsState
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to fetch next cluster page", exception)
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ class DevProfileViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
val liveData: MutableLiveData<ViewState> = MutableLiveData()
|
||||
var devStream: DevStream = DevStream()
|
||||
var streamBundle: StreamBundle = StreamBundle()
|
||||
var devStream: DevStream = DevStream.EMPTY
|
||||
var streamBundle: StreamBundle = StreamBundle.EMPTY
|
||||
|
||||
lateinit var type: StreamContract.Type
|
||||
lateinit var category: StreamContract.Category
|
||||
@@ -74,6 +74,7 @@ class DevProfileViewModel @Inject constructor(
|
||||
if (streamCluster.hasNext()) {
|
||||
authProvider.awaitReady()
|
||||
val newCluster = streamHelper.getNextStreamCluster(
|
||||
streamCluster.id,
|
||||
streamCluster.clusterNextPageUrl
|
||||
)
|
||||
updateCluster(newCluster)
|
||||
|
||||
@@ -72,6 +72,7 @@ class StreamViewModel @Inject constructor(
|
||||
// Fetch new stream bundle
|
||||
val newBundle = if (bundle.hasCluster()) {
|
||||
streamContract.nextStreamBundle(
|
||||
bundle.id,
|
||||
category,
|
||||
bundle.streamNextPageUrl
|
||||
)
|
||||
@@ -79,9 +80,19 @@ class StreamViewModel @Inject constructor(
|
||||
streamContract.fetch(type, category)
|
||||
}
|
||||
|
||||
// Update old bundle
|
||||
// gplayapi 3.6.1 stamps every cluster in a bundle with the bundle id,
|
||||
// so naive Map.plus drops the existing page's clusters. Re-key the new
|
||||
// clusters with synthetic ids past the current max so pagination appends.
|
||||
val baseKey = (bundle.streamClusters.keys.maxOrNull() ?: 0) + 1
|
||||
val rekeyedNewClusters = newBundle.streamClusters.values
|
||||
.mapIndexed { index, cluster ->
|
||||
val newId = baseKey + index
|
||||
newId to cluster.copy(id = newId)
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val mergedBundle = bundle.copy(
|
||||
streamClusters = bundle.streamClusters + newBundle.streamClusters,
|
||||
streamClusters = bundle.streamClusters + rekeyedNewClusters,
|
||||
streamNextPageUrl = newBundle.streamNextPageUrl
|
||||
)
|
||||
stash[category] = mergedBundle
|
||||
@@ -103,6 +114,7 @@ class StreamViewModel @Inject constructor(
|
||||
try {
|
||||
if (streamCluster.hasNext()) {
|
||||
val newCluster = streamContract.nextStreamCluster(
|
||||
streamCluster.id,
|
||||
streamCluster.clusterNextPageUrl
|
||||
)
|
||||
|
||||
@@ -158,6 +170,6 @@ class StreamViewModel @Inject constructor(
|
||||
|
||||
private fun targetBundle(category: StreamContract.Category): StreamBundle =
|
||||
stash.getOrPut(category) {
|
||||
StreamBundle()
|
||||
StreamBundle(id = category.value.hashCode())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import com.aurora.extensions.requiresGMS
|
||||
import com.aurora.gplayapi.SearchSuggestEntry
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.gplayapi.data.models.StreamCluster
|
||||
import com.aurora.gplayapi.helpers.SearchHelper
|
||||
import com.aurora.gplayapi.helpers.contracts.SearchContract
|
||||
import com.aurora.gplayapi.helpers.web.WebSearchHelper
|
||||
import com.aurora.store.data.PageResult
|
||||
@@ -41,12 +40,11 @@ import kotlinx.coroutines.launch
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
val authProvider: AuthProvider,
|
||||
private val searchHelper: SearchHelper,
|
||||
private val webSearchHelper: WebSearchHelper
|
||||
) : ViewModel() {
|
||||
|
||||
private val contract: SearchContract
|
||||
get() = if (authProvider.isAnonymous) webSearchHelper else searchHelper
|
||||
get() = webSearchHelper
|
||||
|
||||
private val _suggestions = MutableStateFlow<List<SearchSuggestEntry>>(emptyList())
|
||||
val suggestions = _suggestions.asStateFlow()
|
||||
@@ -125,7 +123,7 @@ class SearchViewModel @Inject constructor(
|
||||
authProvider.awaitReady()
|
||||
_suggestions.value = contract.searchSuggestions(query)
|
||||
.filter { it.title.isNotBlank() }
|
||||
.take(3)
|
||||
.take(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,9 @@ import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
|
||||
@@ -39,19 +38,13 @@ class CategoryStreamViewModel @AssistedInject constructor(
|
||||
fun create(browseUrl: String): CategoryStreamViewModel
|
||||
}
|
||||
|
||||
// SharedFlow (instead of StateFlow) because StreamBundle/StreamCluster override equals
|
||||
// to compare only id, which is preserved by copy(). StateFlow would conflate every update
|
||||
// and break paginated scroll loading.
|
||||
private val _viewState = MutableSharedFlow<ViewState>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val viewState: SharedFlow<ViewState> = _viewState.asSharedFlow()
|
||||
private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
|
||||
val viewState: StateFlow<ViewState> = _viewState.asStateFlow()
|
||||
|
||||
private val categoryStreamContract: CategoryStreamContract
|
||||
get() = webCategoryStreamHelper
|
||||
|
||||
private var streamBundle = StreamBundle()
|
||||
private var streamBundle = StreamBundle.EMPTY
|
||||
|
||||
init {
|
||||
fetchNextPage()
|
||||
@@ -60,16 +53,13 @@ class CategoryStreamViewModel @AssistedInject constructor(
|
||||
fun fetchNextPage() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
supervisorScope {
|
||||
if (streamBundle.streamClusters.isNotEmpty()) {
|
||||
_viewState.tryEmit(ViewState.Success(streamBundle))
|
||||
}
|
||||
|
||||
try {
|
||||
if (!streamBundle.hasCluster() || streamBundle.hasNext()) {
|
||||
val newBundle = if (streamBundle.streamClusters.isEmpty()) {
|
||||
categoryStreamContract.fetch(browseUrl)
|
||||
} else {
|
||||
categoryStreamContract.nextStreamBundle(
|
||||
streamBundle.id,
|
||||
StreamContract.Category.NONE,
|
||||
streamBundle.streamNextPageUrl
|
||||
)
|
||||
@@ -80,12 +70,12 @@ class CategoryStreamViewModel @AssistedInject constructor(
|
||||
streamNextPageUrl = newBundle.streamNextPageUrl
|
||||
)
|
||||
|
||||
_viewState.tryEmit(ViewState.Success(streamBundle))
|
||||
_viewState.value = ViewState.Success(streamBundle)
|
||||
} else {
|
||||
Log.i(TAG, "End of Bundle")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_viewState.tryEmit(ViewState.Error(e.message))
|
||||
_viewState.value = ViewState.Error(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +87,7 @@ class CategoryStreamViewModel @AssistedInject constructor(
|
||||
try {
|
||||
if (streamCluster.hasNext()) {
|
||||
val newCluster = categoryStreamContract.nextStreamCluster(
|
||||
streamCluster.id,
|
||||
streamCluster.clusterNextPageUrl
|
||||
)
|
||||
val mergedCluster = streamCluster.copy(
|
||||
@@ -109,7 +100,7 @@ class CategoryStreamViewModel @AssistedInject constructor(
|
||||
this[streamCluster.id] = mergedCluster
|
||||
}
|
||||
streamBundle = streamBundle.copy(streamClusters = newClusters)
|
||||
_viewState.tryEmit(ViewState.Success(streamBundle))
|
||||
_viewState.value = ViewState.Success(streamBundle)
|
||||
} else {
|
||||
Log.i(TAG, "End of cluster")
|
||||
}
|
||||
|
||||
@@ -29,10 +29,9 @@ import com.aurora.store.data.model.ViewState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
|
||||
@@ -43,14 +42,8 @@ class TopChartViewModel @Inject constructor(
|
||||
|
||||
private var stash: TopChartStash = mutableMapOf()
|
||||
|
||||
// SharedFlow (instead of StateFlow) because StreamCluster overrides equals to compare
|
||||
// only id, which is preserved by copy(). StateFlow would conflate paginated updates and
|
||||
// break scroll loading. See CategoryStreamViewModel for the same fix.
|
||||
private val _state = MutableSharedFlow<ViewState>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val state: SharedFlow<ViewState> = _state.asSharedFlow()
|
||||
private val _state = MutableStateFlow<ViewState>(ViewState.Loading)
|
||||
val state: StateFlow<ViewState> = _state.asStateFlow()
|
||||
|
||||
private val topChartsContract: TopChartsContract
|
||||
get() = webTopChartsHelper
|
||||
@@ -58,18 +51,18 @@ class TopChartViewModel @Inject constructor(
|
||||
fun getStreamCluster(type: TopChartsContract.Type, chart: TopChartsContract.Chart) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (targetCluster(type, chart).clusterAppList.isNotEmpty()) {
|
||||
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
|
||||
_state.value = ViewState.Success(targetCluster(type, chart))
|
||||
return@launch
|
||||
}
|
||||
|
||||
_state.tryEmit(ViewState.Loading)
|
||||
_state.value = ViewState.Loading
|
||||
|
||||
try {
|
||||
val cluster = topChartsContract.getCluster(type.value, chart.value)
|
||||
updateCluster(type, chart, cluster)
|
||||
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
|
||||
_state.value = ViewState.Success(targetCluster(type, chart))
|
||||
} catch (e: Exception) {
|
||||
_state.tryEmit(ViewState.Error(e.message))
|
||||
_state.value = ViewState.Error(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,12 +74,13 @@ class TopChartViewModel @Inject constructor(
|
||||
val target = targetCluster(type, chart)
|
||||
if (target.hasNext()) {
|
||||
val newCluster = topChartsContract.getNextStreamCluster(
|
||||
target.id,
|
||||
target.clusterNextPageUrl
|
||||
)
|
||||
|
||||
updateCluster(type, chart, newCluster)
|
||||
|
||||
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
|
||||
_state.value = ViewState.Success(targetCluster(type, chart))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
@@ -114,7 +108,7 @@ class TopChartViewModel @Inject constructor(
|
||||
): StreamCluster {
|
||||
val cluster = stash
|
||||
.getOrPut(type) { mutableMapOf() }
|
||||
.getOrPut(chart) { StreamCluster() }
|
||||
.getOrPut(chart) { StreamCluster.EMPTY }
|
||||
return cluster
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user