use a SessionExpired event instead of a retry loop

This commit is contained in:
Rahul Patel
2026-05-25 03:03:18 +05:30
parent bae9233b27
commit 0bcb087be2
17 changed files with 98 additions and 144 deletions

View File

@@ -15,7 +15,6 @@ 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
@@ -24,27 +23,18 @@ 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()
@@ -99,20 +89,6 @@ class ComposeActivity : ComponentActivity() {
private fun defaultStart(): Screen = when {
!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()
}
else -> Screen.Splash()
}
}

View File

@@ -15,7 +15,7 @@ import com.aurora.store.data.room.update.Update
* Screens emit one of these via a single `onNavigateTo: (Destination) -> Unit` callback.
*/
sealed class Destination {
data object Splash : Destination()
data class Splash(val packageName: String? = null) : Destination()
data class Main(val initialTab: Int) : Destination()
data class AppDetails(val packageName: String) : Destination()

View File

@@ -58,6 +58,7 @@ import com.aurora.store.compose.ui.preferences.updates.UpdatesPreferenceScreen
import com.aurora.store.compose.ui.search.SearchScreen
import com.aurora.store.compose.ui.splash.SplashScreen
import com.aurora.store.compose.ui.spoof.SpoofScreen
import com.aurora.store.data.event.AuthEvent
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.model.AccountType
import com.aurora.store.data.providers.AccountProvider
@@ -101,12 +102,24 @@ fun NavDisplay(startDestination: NavKey) {
}
}
// Send the user back to Splash whenever a ViewModel reports the saved Play
// token was rejected. SplashScreen re-validates via a live Play call and
// rebuilds auth on failure, then routes to AppDetails if a packageName was attached.
LaunchedEffect(Unit) {
AuroraApp.events.authEvent.collect { event ->
if (event is AuthEvent.SessionExpired) {
backstack.clear()
backstack.add(Screen.Splash(event.packageName))
}
}
}
fun navigate(destination: Destination) {
when (destination) {
Destination.Splash -> {
is Destination.Splash -> {
// Clear the backstack when navigating to Splash to prevent going back to the previous screen when the user is sent back to the splash screen (e.g. after logout).
backstack.clear()
backstack.add(Screen.Splash)
backstack.add(Screen.Splash(destination.packageName))
}
is Destination.Main -> {
@@ -249,7 +262,12 @@ fun NavDisplay(startDestination: NavKey) {
}
) { SearchScreen() }
entry<Screen.Splash> { SplashScreen(onNavigateTo = ::navigate) }
entry<Screen.Splash> { screen ->
SplashScreen(
deepLinkPackageName = screen.packageName,
onNavigateTo = ::navigate
)
}
entry<Screen.Onboarding> { OnboardingScreen() }
entry<Screen.Blacklist> { BlacklistScreen() }
entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) }

View File

@@ -96,7 +96,7 @@ sealed class Screen : NavKey, Parcelable {
data object SourceFilters : Screen()
@Serializable
data object Splash : Screen()
data class Splash(val packageName: String? = null) : Screen()
@Serializable
data class Main(val initialTab: Int = 0) : Screen()

View File

@@ -81,7 +81,7 @@ fun AccountsScreen(
onConfirm = {
shouldShowLogoutDialog = false
AccountProvider.logout(context)
onNavigateTo(Destination.Splash)
onNavigateTo(Destination.Splash())
},
onDismiss = { shouldShowLogoutDialog = false }
)

View File

@@ -74,7 +74,7 @@ fun GoogleLoginScreen(
} else {
Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG)
.show()
onNavigateTo(Destination.Splash)
onNavigateTo(Destination.Splash())
}
}
}
@@ -87,7 +87,7 @@ fun GoogleLoginScreen(
Preferences.getInteger(context, Preferences.PREFERENCE_DEFAULT_SELECTED_TAB)
)
)
is AuthState.Failed -> onNavigateTo(Destination.Splash)
is AuthState.Failed -> onNavigateTo(Destination.Splash())
else -> Unit
}
}

View File

@@ -96,7 +96,7 @@ private fun ScreenContent(
when (result) {
SnackbarResult.ActionPerformed -> {
AccountProvider.logout(context)
onNavigateTo(Destination.Splash)
onNavigateTo(Destination.Splash())
}
else -> Unit

View File

@@ -30,6 +30,7 @@ sealed class BusEvent : Event() {
sealed class AuthEvent : Event() {
data class GoogleLogin(val success: Boolean, val email: String, val token: String) : AuthEvent()
data class SessionExpired(val packageName: String? = null) : AuthEvent()
}
open class InstallerEvent(open val packageName: String) : Event() {

View File

@@ -36,12 +36,6 @@ 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
@@ -73,45 +67,11 @@ 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

@@ -13,10 +13,12 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.ExpandedBrowseHelper
import com.aurora.store.AuroraApp
import com.aurora.store.data.PageResult
import com.aurora.store.data.event.AuthEvent
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
@@ -30,7 +32,6 @@ 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() {
@@ -55,7 +56,6 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
manualPager { page ->
val items = try {
authProvider.awaitReady()
when (page) {
1 -> {
val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl)
@@ -86,6 +86,10 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
}
}
}
} catch (exception: GooglePlayException.AuthException) {
Log.w(TAG, "Expanded stream returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
emptyList()
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch apps for page $page", exception)
emptyList()

View File

@@ -25,11 +25,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.Category
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.CategoryHelper
import com.aurora.gplayapi.helpers.contracts.CategoryContract
import com.aurora.store.AuroraApp
import com.aurora.store.CategoryStash
import com.aurora.store.data.event.AuthEvent
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
@@ -37,7 +39,6 @@ import kotlinx.coroutines.launch
@HiltViewModel
class CategoryViewModel @Inject constructor(
private val authProvider: AuthProvider,
private val categoryHelper: CategoryHelper
) : ViewModel() {
@@ -62,9 +63,11 @@ class CategoryViewModel @Inject constructor(
liveData.postValue(ViewState.Loading)
try {
authProvider.awaitReady()
stash[type] = contract().getAllCategories(type)
liveData.postValue(ViewState.Success(stash))
} catch (exception: GooglePlayException.AuthException) {
Log.w(TAG, "Categories fetch returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
} catch (exception: Exception) {
Log.e(TAG, "Failed fetching list of categories", exception)
liveData.postValue(ViewState.Error(exception.message))

View File

@@ -27,6 +27,7 @@ import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper
import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.AuroraApp
import com.aurora.store.BuildConfig
import com.aurora.store.data.event.AuthEvent
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.model.AppState
@@ -162,48 +163,36 @@ class AppDetailsViewModel @Inject constructor(
fun fetchAppDetails(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
var retried = false
while (true) {
try {
authProvider.awaitReady()
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
isInstalled = PackageUtil.isInstalled(context, packageName)
)
val existingDownload = downloadHelper.getDownload(packageName)
try {
_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
}
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
// 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
}
} catch (exception: GooglePlayException.AuthException) {
// The saved Play token has been rejected mid-session. Hand off to
// Splash to re-validate and rebuild auth, and ask it to bring the
// user back to this app's details once auth is good again.
Log.w(TAG, "App details fetch returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired(packageName))
} 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

View File

@@ -27,11 +27,13 @@ import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.data.models.details.DevStream
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.StreamHelper
import com.aurora.gplayapi.helpers.contracts.StreamContract
import com.aurora.store.AuroraApp
import com.aurora.store.data.event.AuthEvent
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
@@ -40,7 +42,6 @@ import kotlinx.coroutines.supervisorScope
@HiltViewModel
class DevProfileViewModel @Inject constructor(
private val authProvider: AuthProvider,
private val appDetailsHelper: AppDetailsHelper,
private val streamHelper: StreamHelper
) : ViewModel() {
@@ -56,10 +57,12 @@ class DevProfileViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
try {
authProvider.awaitReady()
devStream = appDetailsHelper.getDeveloperStream(devId)
streamBundle = devStream.streamBundle
liveData.postValue(ViewState.Success(devStream))
} catch (e: GooglePlayException.AuthException) {
Log.w(TAG, "Developer stream fetch returned ${e.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
} catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
}
@@ -72,7 +75,6 @@ class DevProfileViewModel @Inject constructor(
supervisorScope {
try {
if (streamCluster.hasNext()) {
authProvider.awaitReady()
val newCluster = streamHelper.getNextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl

View File

@@ -11,8 +11,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.AuroraApp
import com.aurora.store.data.event.AuthEvent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -25,7 +27,6 @@ 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() {
@@ -44,8 +45,10 @@ class MoreViewModel @AssistedInject constructor(
private fun fetchDependencies() {
viewModelScope.launch(Dispatchers.IO) {
try {
authProvider.awaitReady()
_dependentApps.value = appDetailsHelper.getAppByPackageName(dependencies)
} catch (exception: GooglePlayException.AuthException) {
Log.w(TAG, "Dependencies fetch returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch dependencies", exception)
_dependentApps.value = null

View File

@@ -13,10 +13,12 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.Review
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.store.AuroraApp
import com.aurora.store.data.PageResult
import com.aurora.store.data.event.AuthEvent
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
@@ -30,7 +32,6 @@ 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() {
@@ -51,7 +52,6 @@ class ReviewViewModel @AssistedInject constructor(
manualPager { page ->
val items = try {
authProvider.awaitReady()
when (page) {
1 -> reviewsHelper.getReviews(packageName, filter).also {
reviewsNextPageUrl = it.nextPageUrl
@@ -67,6 +67,10 @@ class ReviewViewModel @AssistedInject constructor(
}
}
}
} catch (exception: GooglePlayException.AuthException) {
Log.w(TAG, "Reviews fetch returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired(packageName))
emptyList()
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch reviews for $page: $reviewsNextPageUrl", exception)
emptyList()

View File

@@ -80,19 +80,8 @@ class StreamViewModel @Inject constructor(
streamContract.fetch(type, category)
}
// 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 + rekeyedNewClusters,
streamClusters = bundle.streamClusters + newBundle.streamClusters,
streamNextPageUrl = newBundle.streamNextPageUrl
)
stash[category] = mergedBundle

View File

@@ -18,9 +18,12 @@ 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.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.contracts.SearchContract
import com.aurora.gplayapi.helpers.web.WebSearchHelper
import com.aurora.store.AuroraApp
import com.aurora.store.data.PageResult
import com.aurora.store.data.event.AuthEvent
import com.aurora.store.data.model.SearchFilter
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
import com.aurora.store.data.providers.AuthProvider
@@ -81,7 +84,6 @@ class SearchViewModel @Inject constructor(
manualPager { page ->
val items = try {
authProvider.awaitReady()
when (page) {
1 -> contract.searchResults(query)
.also { nextBundleUrl = it.streamNextPageUrl }
@@ -107,6 +109,10 @@ class SearchViewModel @Inject constructor(
}
}
}
} catch (exception: GooglePlayException.AuthException) {
Log.w(TAG, "Search returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
emptyList()
} catch (exception: Exception) {
Log.e(TAG, "Failed to search results for $query", exception)
emptyList()
@@ -120,7 +126,6 @@ class SearchViewModel @Inject constructor(
fun fetchSuggestions(query: String) {
viewModelScope.launch(Dispatchers.IO) {
authProvider.awaitReady()
_suggestions.value = contract.searchSuggestions(query)
.filter { it.title.isNotBlank() }
.take(5)