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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user