auth: refresh anonymous session on activity resume

Long-backgrounded sessions (especially on aggressive vendors like Huawei)
could leave the Play token invalid and OkHttp's connection pool full of
dead sockets, producing a perpetual loading spinner with no error.

- ComposeActivity#onStart probes the saved token via isSavedAuthDataValid;
  on failure evicts the OkHttp pool and refreshes anonymous auth.
- AuthProvider gains authReady StateFlow, awaitReady(), and a mutex-guarded
  refreshAnonymousAuth() so concurrent callers don't double-fetch.
- Play-backed ViewModels (AppDetails, DevProfile, MoreApps, Reviews, Search,
  ExpandedStreamBrowse, Category) await refresh completion before issuing
  helper calls, closing the race between resume-time refresh and screen
  LaunchedEffect fetches.
- AppDetailsScreen rendered Error as Loading when app was null; reorder so
  Error wins and wire a Retry button through Placeholder.
- Stream/Review/Search/DevProfile/CategoryBrowse error placeholders gain
  Retry actions wired to PagingItems.retry() or the VM's fetch.
- CategoryViewModel was logging-only on failure; now emits ViewState.Error
  with a matching Retry path in CategoriesPage.
This commit is contained in:
Rahul Patel
2026-05-20 16:38:57 +05:30
parent ada4e8de2a
commit 272870ddd2
17 changed files with 129 additions and 13 deletions

View File

@@ -15,6 +15,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.aurora.extensions.getPackageName import com.aurora.extensions.getPackageName
import com.aurora.store.compose.composition.LocalNetworkStatus import com.aurora.store.compose.composition.LocalNetworkStatus
import com.aurora.store.compose.composition.LocalUI import com.aurora.store.compose.composition.LocalUI
@@ -23,18 +24,27 @@ import com.aurora.store.compose.navigation.NavDisplay
import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.theme.AuroraTheme import com.aurora.store.compose.theme.AuroraTheme
import com.aurora.store.data.model.NetworkStatus import com.aurora.store.data.model.NetworkStatus
import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.NetworkProvider import com.aurora.store.data.providers.NetworkProvider
import com.aurora.store.data.receiver.MigrationReceiver import com.aurora.store.data.receiver.MigrationReceiver
import com.aurora.store.util.PackageUtil import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
@AndroidEntryPoint @AndroidEntryPoint
class ComposeActivity : ComponentActivity() { class ComposeActivity : ComponentActivity() {
@Inject lateinit var networkProvider: NetworkProvider @Inject lateinit var networkProvider: NetworkProvider
@Inject lateinit var authProvider: AuthProvider
@Inject lateinit var okHttpClient: OkHttpClient
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
MigrationReceiver.runMigrationsIfRequired(this) MigrationReceiver.runMigrationsIfRequired(this)
enableEdgeToEdge() enableEdgeToEdge()
@@ -91,4 +101,18 @@ class ComposeActivity : ComponentActivity() {
!Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding !Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding
else -> Screen.Splash else -> Screen.Splash
} }
override fun onStart() {
super.onStart()
if (!AccountProvider.isLoggedIn(this)) return
lifecycleScope.launch(Dispatchers.IO) {
// Ask Play directly if the saved token still works. If it does,
// sockets are fine too and there's nothing to do. If it doesn't,
// evict the pool (the same backgrounded state that invalidates
// tokens also leaves dead sockets) and refresh anonymous auth.
if (authProvider.isSavedAuthDataValid()) return@launch
okHttpClient.connectionPool.evictAll()
if (authProvider.isAnonymous) authProvider.refreshAnonymousAuth()
}
}
} }

View File

@@ -12,11 +12,15 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.Category
import com.aurora.store.CategoryStash import com.aurora.store.CategoryStash
import com.aurora.store.R
import com.aurora.store.compose.composable.CategoryItem import com.aurora.store.compose.composable.CategoryItem
import com.aurora.store.compose.composable.Placeholder
import com.aurora.store.compose.composable.ShimmerCategoryRow import com.aurora.store.compose.composable.ShimmerCategoryRow
import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.ViewState import com.aurora.store.data.model.ViewState
@@ -35,6 +39,17 @@ internal fun CategoriesContent(
viewModel.getCategoryList(categoryType) viewModel.getCategoryList(categoryType)
} }
if (state is ViewState.Error) {
Placeholder(
modifier = Modifier.fillMaxSize(),
painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { viewModel.getCategoryList(categoryType) }
)
return
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val categories = (state as? ViewState.Success<*>)?.data as? CategoryStash val categories = (state as? ViewState.Success<*>)?.data as? CategoryStash
val list = categories?.get(categoryType) val list = categories?.get(categoryType)

View File

@@ -43,7 +43,9 @@ fun CategoryBrowseScreen(
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { viewModel.fetchNextPage() }
) )
} else { } else {
val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle

View File

@@ -57,7 +57,9 @@ fun ExpandedStreamBrowseScreen(
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { apps.retry() }
) )
} }

View File

@@ -76,7 +76,9 @@ private fun ScreenContent(
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { apps.retry() }
) )
} }

View File

@@ -148,14 +148,15 @@ fun AppDetailsScreen(
label = "AppDetailsState" label = "AppDetailsState"
) { currentState -> ) { currentState ->
when { when {
currentState is AppState.Loading || app == null ->
ScreenContentLoading()
currentState is AppState.Error -> currentState is AppState.Error ->
ScreenContentError( ScreenContentError(
message = currentState.message message = currentState.message,
onRetry = { viewModel.fetchAppDetails(packageName) }
) )
currentState is AppState.Loading || app == null ->
ScreenContentLoading()
else -> { else -> {
val loadedApp = app!! val loadedApp = app!!
ScreenContentApp( ScreenContentApp(
@@ -199,8 +200,8 @@ fun AppDetailsScreen(
} }
private fun stateKey(state: AppState, app: App?): String = when { private fun stateKey(state: AppState, app: App?): String = when {
state is AppState.Loading || app == null -> "loading"
state is AppState.Error -> "error" state is AppState.Error -> "error"
state is AppState.Loading || app == null -> "loading"
else -> "content" else -> "content"
} }
@@ -220,14 +221,16 @@ private fun ScreenContentLoading() {
* Composable to display errors related to fetching app details * Composable to display errors related to fetching app details
*/ */
@Composable @Composable
private fun ScreenContentError(message: String? = null) { private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? = null) {
Scaffold( Scaffold(
topBar = { TopAppBar() } topBar = { TopAppBar() }
) { paddingValues -> ) { paddingValues ->
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_apps_outage), painter = painterResource(R.drawable.ic_apps_outage),
message = message ?: stringResource(R.string.toast_app_unavailable) message = message ?: stringResource(R.string.toast_app_unavailable),
actionLabel = onRetry?.let { stringResource(R.string.action_retry) },
onAction = onRetry
) )
} }
} }

View File

@@ -117,7 +117,9 @@ private fun ScreenContent(
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { reviews.retry() }
) )
} }

View File

@@ -99,7 +99,9 @@ private fun ScreenContent(
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { apps.retry() }
) )
} }

View File

@@ -253,7 +253,9 @@ private fun ScreenContent(
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { results.retry() }
) )
} }

View File

@@ -36,6 +36,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -67,11 +73,45 @@ class AuthProvider @Inject constructor(
val isAnonymous: Boolean val isAnonymous: Boolean
get() = AccountProvider.getAccountType(context) == AccountType.ANONYMOUS get() = AccountProvider.getAccountType(context) == AccountType.ANONYMOUS
private val _authReady = MutableStateFlow(true)
/**
* Emits false while an auth refresh is in flight so callers can defer
* network work until fresh credentials are persisted.
*/
val authReady: StateFlow<Boolean> = _authReady.asStateFlow()
private val refreshMutex = Mutex()
/** /**
* Checks whether saved AuthData is valid or not * Checks whether saved AuthData is valid or not
*/ */
fun isSavedAuthDataValid(): Boolean = AuthHelper.isValid(authData!!) fun isSavedAuthDataValid(): Boolean = AuthHelper.isValid(authData!!)
/**
* Suspends until no auth refresh is in flight. ViewModels should call this
* before issuing Play requests so a foreground refresh doesn't race the fetch.
*/
suspend fun awaitReady() {
_authReady.first { it }
}
/**
* Rebuilds anonymous [AuthData] from a dispenser and persists it. Serialized
* so concurrent callers don't double-fetch. On failure, the previously saved
* auth is left in place so requests can still attempt with stale credentials.
*/
suspend fun refreshAnonymousAuth(): Result<AuthData> = refreshMutex.withLock {
try {
_authReady.value = false
val result = buildAnonymousAuthData()
result.onSuccess { saveAuthData(it) }
result
} finally {
_authReady.value = true
}
}
/** /**
* Builds [AuthData] for login using personal Google account * Builds [AuthData] for login using personal Google account
* @param email E-mail ID * @param email E-mail ID

View File

@@ -16,6 +16,7 @@ import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.helpers.ExpandedBrowseHelper import com.aurora.gplayapi.helpers.ExpandedBrowseHelper
import com.aurora.store.data.PageResult import com.aurora.store.data.PageResult
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
import com.aurora.store.data.providers.AuthProvider
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
@HiltViewModel(assistedFactory = ExpandedStreamBrowseViewModel.Factory::class) @HiltViewModel(assistedFactory = ExpandedStreamBrowseViewModel.Factory::class)
class ExpandedStreamBrowseViewModel @AssistedInject constructor( class ExpandedStreamBrowseViewModel @AssistedInject constructor(
@Assisted val browseUrl: String, @Assisted val browseUrl: String,
private val authProvider: AuthProvider,
private val streamHelper: ExpandedBrowseHelper private val streamHelper: ExpandedBrowseHelper
) : ViewModel() { ) : ViewModel() {
@@ -53,6 +55,7 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
manualPager { page -> manualPager { page ->
val items = try { val items = try {
authProvider.awaitReady()
when (page) { when (page) {
1 -> { 1 -> {
val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl) val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl)

View File

@@ -29,6 +29,7 @@ import com.aurora.gplayapi.helpers.CategoryHelper
import com.aurora.gplayapi.helpers.contracts.CategoryContract import com.aurora.gplayapi.helpers.contracts.CategoryContract
import com.aurora.store.CategoryStash import com.aurora.store.CategoryStash
import com.aurora.store.data.model.ViewState import com.aurora.store.data.model.ViewState
import com.aurora.store.data.providers.AuthProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -36,6 +37,7 @@ import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class CategoryViewModel @Inject constructor( class CategoryViewModel @Inject constructor(
private val authProvider: AuthProvider,
private val categoryHelper: CategoryHelper private val categoryHelper: CategoryHelper
) : ViewModel() { ) : ViewModel() {
@@ -57,11 +59,15 @@ class CategoryViewModel @Inject constructor(
return@launch return@launch
} }
liveData.postValue(ViewState.Loading)
try { try {
authProvider.awaitReady()
stash[type] = contract().getAllCategories(type) stash[type] = contract().getAllCategories(type)
liveData.postValue(ViewState.Success(stash)) liveData.postValue(ViewState.Success(stash))
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed fetching list of categories", exception) Log.e(TAG, "Failed fetching list of categories", exception)
liveData.postValue(ViewState.Error(exception.message))
} }
} }
} }

View File

@@ -168,6 +168,7 @@ class AppDetailsViewModel @Inject constructor(
fun fetchAppDetails(packageName: String) { fun fetchAppDetails(packageName: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
authProvider.awaitReady()
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy( _app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
isInstalled = PackageUtil.isInstalled(context, packageName) isInstalled = PackageUtil.isInstalled(context, packageName)
) )

View File

@@ -31,6 +31,7 @@ import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.StreamHelper import com.aurora.gplayapi.helpers.StreamHelper
import com.aurora.gplayapi.helpers.contracts.StreamContract import com.aurora.gplayapi.helpers.contracts.StreamContract
import com.aurora.store.data.model.ViewState import com.aurora.store.data.model.ViewState
import com.aurora.store.data.providers.AuthProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -39,6 +40,7 @@ import kotlinx.coroutines.supervisorScope
@HiltViewModel @HiltViewModel
class DevProfileViewModel @Inject constructor( class DevProfileViewModel @Inject constructor(
private val authProvider: AuthProvider,
private val appDetailsHelper: AppDetailsHelper, private val appDetailsHelper: AppDetailsHelper,
private val streamHelper: StreamHelper private val streamHelper: StreamHelper
) : ViewModel() { ) : ViewModel() {
@@ -54,6 +56,7 @@ class DevProfileViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
supervisorScope { supervisorScope {
try { try {
authProvider.awaitReady()
devStream = appDetailsHelper.getDeveloperStream(devId) devStream = appDetailsHelper.getDeveloperStream(devId)
streamBundle = devStream.streamBundle streamBundle = devStream.streamBundle
liveData.postValue(ViewState.Success(devStream)) liveData.postValue(ViewState.Success(devStream))
@@ -69,6 +72,7 @@ class DevProfileViewModel @Inject constructor(
supervisorScope { supervisorScope {
try { try {
if (streamCluster.hasNext()) { if (streamCluster.hasNext()) {
authProvider.awaitReady()
val newCluster = streamHelper.getNextStreamCluster( val newCluster = streamHelper.getNextStreamCluster(
streamCluster.clusterNextPageUrl streamCluster.clusterNextPageUrl
) )

View File

@@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.store.data.providers.AuthProvider
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -24,6 +25,7 @@ import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = MoreViewModel.Factory::class) @HiltViewModel(assistedFactory = MoreViewModel.Factory::class)
class MoreViewModel @AssistedInject constructor( class MoreViewModel @AssistedInject constructor(
@Assisted private val dependencies: List<String>, @Assisted private val dependencies: List<String>,
private val authProvider: AuthProvider,
private val appDetailsHelper: AppDetailsHelper private val appDetailsHelper: AppDetailsHelper
) : ViewModel() { ) : ViewModel() {
@@ -42,6 +44,7 @@ class MoreViewModel @AssistedInject constructor(
private fun fetchDependencies() { private fun fetchDependencies() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
authProvider.awaitReady()
_dependentApps.value = appDetailsHelper.getAppByPackageName(dependencies) _dependentApps.value = appDetailsHelper.getAppByPackageName(dependencies)
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch dependencies", exception) Log.e(TAG, "Failed to fetch dependencies", exception)

View File

@@ -16,6 +16,7 @@ import com.aurora.gplayapi.data.models.Review
import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.store.data.PageResult import com.aurora.store.data.PageResult
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
import com.aurora.store.data.providers.AuthProvider
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
@HiltViewModel(assistedFactory = ReviewViewModel.Factory::class) @HiltViewModel(assistedFactory = ReviewViewModel.Factory::class)
class ReviewViewModel @AssistedInject constructor( class ReviewViewModel @AssistedInject constructor(
@Assisted private val packageName: String, @Assisted private val packageName: String,
private val authProvider: AuthProvider,
private val reviewsHelper: ReviewsHelper private val reviewsHelper: ReviewsHelper
) : ViewModel() { ) : ViewModel() {
@@ -49,6 +51,7 @@ class ReviewViewModel @AssistedInject constructor(
manualPager { page -> manualPager { page ->
val items = try { val items = try {
authProvider.awaitReady()
when (page) { when (page) {
1 -> reviewsHelper.getReviews(packageName, filter).also { 1 -> reviewsHelper.getReviews(packageName, filter).also {
reviewsNextPageUrl = it.nextPageUrl reviewsNextPageUrl = it.nextPageUrl

View File

@@ -83,6 +83,7 @@ class SearchViewModel @Inject constructor(
manualPager { page -> manualPager { page ->
val items = try { val items = try {
authProvider.awaitReady()
when (page) { when (page) {
1 -> contract.searchResults(query) 1 -> contract.searchResults(query)
.also { nextBundleUrl = it.streamNextPageUrl } .also { nextBundleUrl = it.streamNextPageUrl }
@@ -121,6 +122,7 @@ class SearchViewModel @Inject constructor(
fun fetchSuggestions(query: String) { fun fetchSuggestions(query: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
authProvider.awaitReady()
_suggestions.value = contract.searchSuggestions(query) _suggestions.value = contract.searchSuggestions(query)
.filter { it.title.isNotBlank() } .filter { it.title.isNotBlank() }
.take(3) .take(3)