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.core.content.IntentCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.aurora.extensions.getPackageName
import com.aurora.store.compose.composition.LocalNetworkStatus
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.theme.AuroraTheme
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.receiver.MigrationReceiver
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
@AndroidEntryPoint
class ComposeActivity : ComponentActivity() {
@Inject lateinit var networkProvider: NetworkProvider
@Inject lateinit var authProvider: AuthProvider
@Inject lateinit var okHttpClient: OkHttpClient
override fun onCreate(savedInstanceState: Bundle?) {
MigrationReceiver.runMigrationsIfRequired(this)
enableEdgeToEdge()
@@ -91,4 +101,18 @@ class ComposeActivity : ComponentActivity() {
!Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding
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.livedata.observeAsState
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.PreviewWrapper
import com.aurora.gplayapi.data.models.Category
import com.aurora.store.CategoryStash
import com.aurora.store.R
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.preview.ThemePreviewProvider
import com.aurora.store.data.model.ViewState
@@ -35,6 +39,17 @@ internal fun CategoriesContent(
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")
val categories = (state as? ViewState.Success<*>)?.data as? CategoryStash
val list = categories?.get(categoryType)

View File

@@ -43,7 +43,9 @@ fun CategoryBrowseScreen(
Placeholder(
modifier = Modifier.padding(paddingValues),
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 {
val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle

View File

@@ -57,7 +57,9 @@ fun ExpandedStreamBrowseScreen(
Placeholder(
modifier = Modifier.padding(paddingValues),
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(
modifier = Modifier.padding(paddingValues),
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"
) { currentState ->
when {
currentState is AppState.Loading || app == null ->
ScreenContentLoading()
currentState is AppState.Error ->
ScreenContentError(
message = currentState.message
message = currentState.message,
onRetry = { viewModel.fetchAppDetails(packageName) }
)
currentState is AppState.Loading || app == null ->
ScreenContentLoading()
else -> {
val loadedApp = app!!
ScreenContentApp(
@@ -199,8 +200,8 @@ fun AppDetailsScreen(
}
private fun stateKey(state: AppState, app: App?): String = when {
state is AppState.Loading || app == null -> "loading"
state is AppState.Error -> "error"
state is AppState.Loading || app == null -> "loading"
else -> "content"
}
@@ -220,14 +221,16 @@ private fun ScreenContentLoading() {
* Composable to display errors related to fetching app details
*/
@Composable
private fun ScreenContentError(message: String? = null) {
private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? = null) {
Scaffold(
topBar = { TopAppBar() }
) { paddingValues ->
Placeholder(
modifier = Modifier.padding(paddingValues),
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(
modifier = Modifier.padding(paddingValues),
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(
modifier = Modifier.padding(paddingValues),
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(
modifier = Modifier.padding(paddingValues),
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.Singleton
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.serialization.json.Json
@@ -67,11 +73,45 @@ class AuthProvider @Inject constructor(
val isAnonymous: Boolean
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
*/
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
* @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.store.data.PageResult
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
import com.aurora.store.data.providers.AuthProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
@HiltViewModel(assistedFactory = ExpandedStreamBrowseViewModel.Factory::class)
class ExpandedStreamBrowseViewModel @AssistedInject constructor(
@Assisted val browseUrl: String,
private val authProvider: AuthProvider,
private val streamHelper: ExpandedBrowseHelper
) : ViewModel() {
@@ -53,6 +55,7 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
manualPager { page ->
val items = try {
authProvider.awaitReady()
when (page) {
1 -> {
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.store.CategoryStash
import com.aurora.store.data.model.ViewState
import com.aurora.store.data.providers.AuthProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
@@ -36,6 +37,7 @@ import kotlinx.coroutines.launch
@HiltViewModel
class CategoryViewModel @Inject constructor(
private val authProvider: AuthProvider,
private val categoryHelper: CategoryHelper
) : ViewModel() {
@@ -57,11 +59,15 @@ class CategoryViewModel @Inject constructor(
return@launch
}
liveData.postValue(ViewState.Loading)
try {
authProvider.awaitReady()
stash[type] = contract().getAllCategories(type)
liveData.postValue(ViewState.Success(stash))
} catch (exception: 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) {
viewModelScope.launch(Dispatchers.IO) {
try {
authProvider.awaitReady()
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
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.contracts.StreamContract
import com.aurora.store.data.model.ViewState
import com.aurora.store.data.providers.AuthProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
@@ -39,6 +40,7 @@ import kotlinx.coroutines.supervisorScope
@HiltViewModel
class DevProfileViewModel @Inject constructor(
private val authProvider: AuthProvider,
private val appDetailsHelper: AppDetailsHelper,
private val streamHelper: StreamHelper
) : ViewModel() {
@@ -54,6 +56,7 @@ class DevProfileViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
try {
authProvider.awaitReady()
devStream = appDetailsHelper.getDeveloperStream(devId)
streamBundle = devStream.streamBundle
liveData.postValue(ViewState.Success(devStream))
@@ -69,6 +72,7 @@ class DevProfileViewModel @Inject constructor(
supervisorScope {
try {
if (streamCluster.hasNext()) {
authProvider.awaitReady()
val newCluster = streamHelper.getNextStreamCluster(
streamCluster.clusterNextPageUrl
)

View File

@@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.store.data.providers.AuthProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -24,6 +25,7 @@ import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = MoreViewModel.Factory::class)
class MoreViewModel @AssistedInject constructor(
@Assisted private val dependencies: List<String>,
private val authProvider: AuthProvider,
private val appDetailsHelper: AppDetailsHelper
) : ViewModel() {
@@ -42,6 +44,7 @@ class MoreViewModel @AssistedInject constructor(
private fun fetchDependencies() {
viewModelScope.launch(Dispatchers.IO) {
try {
authProvider.awaitReady()
_dependentApps.value = appDetailsHelper.getAppByPackageName(dependencies)
} catch (exception: 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.store.data.PageResult
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
import com.aurora.store.data.providers.AuthProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
@HiltViewModel(assistedFactory = ReviewViewModel.Factory::class)
class ReviewViewModel @AssistedInject constructor(
@Assisted private val packageName: String,
private val authProvider: AuthProvider,
private val reviewsHelper: ReviewsHelper
) : ViewModel() {
@@ -49,6 +51,7 @@ class ReviewViewModel @AssistedInject constructor(
manualPager { page ->
val items = try {
authProvider.awaitReady()
when (page) {
1 -> reviewsHelper.getReviews(packageName, filter).also {
reviewsNextPageUrl = it.nextPageUrl

View File

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