Loading app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt +4 −0 Original line number Diff line number Diff line Loading @@ -1224,6 +1224,10 @@ class UpdatesWorkerTest { override suspend fun resolveCurrentSession(): AuthSession = authSessionState.value override suspend fun transitionToAnonymous() { saveLoginIntent(PersistedLoginIntent.PLAY_ANONYMOUS) } override suspend fun clearSession() { saveLoginIntent(PersistedLoginIntent.NONE) } Loading app/src/test/java/foundation/e/apps/feature/main/MainActivitySessionManagerTest.kt +4 −0 Original line number Diff line number Diff line Loading @@ -141,6 +141,10 @@ class MainActivitySessionManagerTest { override suspend fun resolveCurrentSession(): AuthSession = sessionState.value override suspend fun transitionToAnonymous() { sessionState.value = AuthSession.PlayStoreSession(PlayStoreLoginMode.ANONYMOUS) } override suspend fun clearSession() { sessionState.value = AuthSession.Unauthenticated } Loading app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt +4 −0 Original line number Diff line number Diff line Loading @@ -491,6 +491,10 @@ class InstallationEnqueuerTest { sessionState.value = session } override suspend fun transitionToAnonymous() { sessionState.value = AuthSession.PlayStoreSession(PlayStoreLoginMode.ANONYMOUS) } override suspend fun clearSession() { sessionState.value = AuthSession.Unauthenticated } Loading app/src/test/java/foundation/e/apps/ui/settings/SettingsViewModelTest.kt +4 −0 Original line number Diff line number Diff line Loading @@ -288,6 +288,10 @@ class SettingsViewModelTest { sessionState.value = session } override suspend fun transitionToAnonymous() { sessionState.value = AuthSession.PlayStoreSession(PlayStoreLoginMode.ANONYMOUS) } override suspend fun clearSession() { sessionState.value = AuthSession.Unauthenticated } Loading data/src/main/java/foundation/e/apps/data/login/repository/AuthSessionRepositoryImpl.kt +67 −48 Original line number Diff line number Diff line Loading @@ -18,45 +18,49 @@ package foundation.e.apps.data.login.repository import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.aurora.gplayapi.data.models.AuthData import foundation.e.apps.data.di.qualifiers.IoCoroutineScope import foundation.e.apps.data.login.playstore.resolvePersistedPlayStoreLoginMode import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.data.preference.applyDestroyCredentials import foundation.e.apps.data.preference.applyLoginIntent import foundation.e.apps.data.preference.applyPlayStoreAuthSource import foundation.e.apps.data.preference.readAuthData import foundation.e.apps.data.preference.readLoginIntent import foundation.e.apps.data.preference.readOauthToken import foundation.e.apps.data.preference.readPlayStoreAuthSource import foundation.e.apps.domain.auth.AuthSession import foundation.e.apps.domain.auth.AuthSessionRepository import foundation.e.apps.domain.auth.PersistedLoginIntent import foundation.e.apps.domain.auth.PlayStoreLoginMode import foundation.e.apps.domain.model.PlayStoreAuthSource import foundation.e.apps.domain.preferences.SessionRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class AuthSessionRepositoryImpl @Inject constructor( private val sessionRepository: SessionRepository, private val playStoreAuthStore: PlayStoreAuthStore, private val dataStore: DataStore<Preferences>, private val json: Json, @IoCoroutineScope private val coroutineScope: CoroutineScope, ) : AuthSessionRepository { override val currentSession: StateFlow<AuthSession> = combine( sessionRepository.loginIntent, playStoreAuthStore.playStoreAuthSource, playStoreAuthStore.authData, playStoreAuthStore.oauthToken, ) { loginIntent, authSource, authData, oauthToken -> resolveSession( loginIntent = loginIntent, authSource = authSource, authData = authData, oauthToken = oauthToken, ) }.stateIn( // Sourcing the session from a single dataStore.data emission means every observer sees a // consistent snapshot of intent + auth source + credentials. A previous combine() of four // independently-mapped flows could expose intermediate states between writes. private val sessionFlow = dataStore.data.map { it.resolveSession() } override val currentSession: StateFlow<AuthSession> = sessionFlow.stateIn( coroutineScope, SharingStarted.Eagerly, AuthSession.Unauthenticated Loading @@ -67,50 +71,76 @@ class AuthSessionRepositoryImpl @Inject constructor( is AuthSession.PlayStoreSession -> { when (session.loginMode) { PlayStoreLoginMode.ANONYMOUS -> persistPlayStoreSession( editSessionMetadata( loginIntent = PersistedLoginIntent.PLAY_ANONYMOUS, authSource = null, ) PlayStoreLoginMode.GOOGLE -> persistPlayStoreSession( editSessionMetadata( loginIntent = PersistedLoginIntent.PLAY_GOOGLE, authSource = PlayStoreAuthSource.GOOGLE, ) PlayStoreLoginMode.MICROG -> persistPlayStoreSession( editSessionMetadata( loginIntent = PersistedLoginIntent.PLAY_MICROG, authSource = PlayStoreAuthSource.MICROG, ) } } AuthSession.OpenSourceSession -> { // Crash-window note: credential destruction and loginIntent persistence still span // separate DataStore edits. We keep credential eviction first here to avoid leaving // a usable Play credential set behind once open-source mode is committed. playStoreAuthStore.destroyCredentials() sessionRepository.saveLoginIntent(PersistedLoginIntent.OPEN_SOURCE) AuthSession.OpenSourceSession -> dataStore.edit { it.applyDestroyCredentials() it.applyLoginIntent(PersistedLoginIntent.OPEN_SOURCE) } AuthSession.Unauthenticated -> clearSession() } } override suspend fun transitionToAnonymous() { dataStore.edit { it.applyDestroyCredentials() it.applyPlayStoreAuthSource(null) it.applyLoginIntent(PersistedLoginIntent.PLAY_ANONYMOUS) } } override suspend fun clearSession() { // Crash-window note: logout intentionally clears credentials before publishing NONE so a // completed write sequence never leaves stale Play credentials behind for later reuse. playStoreAuthStore.destroyCredentials() sessionRepository.saveLoginIntent(PersistedLoginIntent.NONE) dataStore.edit { it.applyDestroyCredentials() it.applyLoginIntent(PersistedLoginIntent.NONE) } } override suspend fun resolveCurrentSession(): AuthSession = sessionFlow.first() private suspend fun editSessionMetadata( loginIntent: PersistedLoginIntent, authSource: PlayStoreAuthSource?, ) { // loginIntent is the authoritative published session signal. Persist the supporting Play // source first so a crash window cannot expose a new Play login intent without matching // source metadata. Both edits land in a single dataStore.edit so observers never see one // without the other. dataStore.edit { prefs: MutablePreferences -> prefs.applyPlayStoreAuthSource(authSource) prefs.applyLoginIntent(loginIntent) } } override suspend fun resolveCurrentSession(): AuthSession { private fun Preferences.resolveSession(): AuthSession { val loginIntent = readLoginIntent() val authSource = readPlayStoreAuthSource() val authData = readAuthData(json) val oauthToken = readOauthToken() return resolveSession( loginIntent = sessionRepository.awaitLoginIntent(), authSource = playStoreAuthStore.awaitPlayStoreAuthSource(), authData = playStoreAuthStore.awaitAuthData(), oauthToken = playStoreAuthStore.awaitOauthToken(), loginIntent = loginIntent, authSource = authSource, authData = authData, oauthToken = oauthToken, ) } Loading Loading @@ -151,15 +181,4 @@ class AuthSessionRepositoryImpl @Inject constructor( } } } private suspend fun persistPlayStoreSession( loginIntent: PersistedLoginIntent, authSource: PlayStoreAuthSource?, ) { // loginIntent is the authoritative published session signal. Persist the supporting Play // source first so a crash window cannot expose a new Play login intent without matching // source metadata. playStoreAuthStore.savePlayStoreAuthSource(authSource) sessionRepository.saveLoginIntent(loginIntent) } } Loading
app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt +4 −0 Original line number Diff line number Diff line Loading @@ -1224,6 +1224,10 @@ class UpdatesWorkerTest { override suspend fun resolveCurrentSession(): AuthSession = authSessionState.value override suspend fun transitionToAnonymous() { saveLoginIntent(PersistedLoginIntent.PLAY_ANONYMOUS) } override suspend fun clearSession() { saveLoginIntent(PersistedLoginIntent.NONE) } Loading
app/src/test/java/foundation/e/apps/feature/main/MainActivitySessionManagerTest.kt +4 −0 Original line number Diff line number Diff line Loading @@ -141,6 +141,10 @@ class MainActivitySessionManagerTest { override suspend fun resolveCurrentSession(): AuthSession = sessionState.value override suspend fun transitionToAnonymous() { sessionState.value = AuthSession.PlayStoreSession(PlayStoreLoginMode.ANONYMOUS) } override suspend fun clearSession() { sessionState.value = AuthSession.Unauthenticated } Loading
app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt +4 −0 Original line number Diff line number Diff line Loading @@ -491,6 +491,10 @@ class InstallationEnqueuerTest { sessionState.value = session } override suspend fun transitionToAnonymous() { sessionState.value = AuthSession.PlayStoreSession(PlayStoreLoginMode.ANONYMOUS) } override suspend fun clearSession() { sessionState.value = AuthSession.Unauthenticated } Loading
app/src/test/java/foundation/e/apps/ui/settings/SettingsViewModelTest.kt +4 −0 Original line number Diff line number Diff line Loading @@ -288,6 +288,10 @@ class SettingsViewModelTest { sessionState.value = session } override suspend fun transitionToAnonymous() { sessionState.value = AuthSession.PlayStoreSession(PlayStoreLoginMode.ANONYMOUS) } override suspend fun clearSession() { sessionState.value = AuthSession.Unauthenticated } Loading
data/src/main/java/foundation/e/apps/data/login/repository/AuthSessionRepositoryImpl.kt +67 −48 Original line number Diff line number Diff line Loading @@ -18,45 +18,49 @@ package foundation.e.apps.data.login.repository import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.aurora.gplayapi.data.models.AuthData import foundation.e.apps.data.di.qualifiers.IoCoroutineScope import foundation.e.apps.data.login.playstore.resolvePersistedPlayStoreLoginMode import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.data.preference.applyDestroyCredentials import foundation.e.apps.data.preference.applyLoginIntent import foundation.e.apps.data.preference.applyPlayStoreAuthSource import foundation.e.apps.data.preference.readAuthData import foundation.e.apps.data.preference.readLoginIntent import foundation.e.apps.data.preference.readOauthToken import foundation.e.apps.data.preference.readPlayStoreAuthSource import foundation.e.apps.domain.auth.AuthSession import foundation.e.apps.domain.auth.AuthSessionRepository import foundation.e.apps.domain.auth.PersistedLoginIntent import foundation.e.apps.domain.auth.PlayStoreLoginMode import foundation.e.apps.domain.model.PlayStoreAuthSource import foundation.e.apps.domain.preferences.SessionRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class AuthSessionRepositoryImpl @Inject constructor( private val sessionRepository: SessionRepository, private val playStoreAuthStore: PlayStoreAuthStore, private val dataStore: DataStore<Preferences>, private val json: Json, @IoCoroutineScope private val coroutineScope: CoroutineScope, ) : AuthSessionRepository { override val currentSession: StateFlow<AuthSession> = combine( sessionRepository.loginIntent, playStoreAuthStore.playStoreAuthSource, playStoreAuthStore.authData, playStoreAuthStore.oauthToken, ) { loginIntent, authSource, authData, oauthToken -> resolveSession( loginIntent = loginIntent, authSource = authSource, authData = authData, oauthToken = oauthToken, ) }.stateIn( // Sourcing the session from a single dataStore.data emission means every observer sees a // consistent snapshot of intent + auth source + credentials. A previous combine() of four // independently-mapped flows could expose intermediate states between writes. private val sessionFlow = dataStore.data.map { it.resolveSession() } override val currentSession: StateFlow<AuthSession> = sessionFlow.stateIn( coroutineScope, SharingStarted.Eagerly, AuthSession.Unauthenticated Loading @@ -67,50 +71,76 @@ class AuthSessionRepositoryImpl @Inject constructor( is AuthSession.PlayStoreSession -> { when (session.loginMode) { PlayStoreLoginMode.ANONYMOUS -> persistPlayStoreSession( editSessionMetadata( loginIntent = PersistedLoginIntent.PLAY_ANONYMOUS, authSource = null, ) PlayStoreLoginMode.GOOGLE -> persistPlayStoreSession( editSessionMetadata( loginIntent = PersistedLoginIntent.PLAY_GOOGLE, authSource = PlayStoreAuthSource.GOOGLE, ) PlayStoreLoginMode.MICROG -> persistPlayStoreSession( editSessionMetadata( loginIntent = PersistedLoginIntent.PLAY_MICROG, authSource = PlayStoreAuthSource.MICROG, ) } } AuthSession.OpenSourceSession -> { // Crash-window note: credential destruction and loginIntent persistence still span // separate DataStore edits. We keep credential eviction first here to avoid leaving // a usable Play credential set behind once open-source mode is committed. playStoreAuthStore.destroyCredentials() sessionRepository.saveLoginIntent(PersistedLoginIntent.OPEN_SOURCE) AuthSession.OpenSourceSession -> dataStore.edit { it.applyDestroyCredentials() it.applyLoginIntent(PersistedLoginIntent.OPEN_SOURCE) } AuthSession.Unauthenticated -> clearSession() } } override suspend fun transitionToAnonymous() { dataStore.edit { it.applyDestroyCredentials() it.applyPlayStoreAuthSource(null) it.applyLoginIntent(PersistedLoginIntent.PLAY_ANONYMOUS) } } override suspend fun clearSession() { // Crash-window note: logout intentionally clears credentials before publishing NONE so a // completed write sequence never leaves stale Play credentials behind for later reuse. playStoreAuthStore.destroyCredentials() sessionRepository.saveLoginIntent(PersistedLoginIntent.NONE) dataStore.edit { it.applyDestroyCredentials() it.applyLoginIntent(PersistedLoginIntent.NONE) } } override suspend fun resolveCurrentSession(): AuthSession = sessionFlow.first() private suspend fun editSessionMetadata( loginIntent: PersistedLoginIntent, authSource: PlayStoreAuthSource?, ) { // loginIntent is the authoritative published session signal. Persist the supporting Play // source first so a crash window cannot expose a new Play login intent without matching // source metadata. Both edits land in a single dataStore.edit so observers never see one // without the other. dataStore.edit { prefs: MutablePreferences -> prefs.applyPlayStoreAuthSource(authSource) prefs.applyLoginIntent(loginIntent) } } override suspend fun resolveCurrentSession(): AuthSession { private fun Preferences.resolveSession(): AuthSession { val loginIntent = readLoginIntent() val authSource = readPlayStoreAuthSource() val authData = readAuthData(json) val oauthToken = readOauthToken() return resolveSession( loginIntent = sessionRepository.awaitLoginIntent(), authSource = playStoreAuthStore.awaitPlayStoreAuthSource(), authData = playStoreAuthStore.awaitAuthData(), oauthToken = playStoreAuthStore.awaitOauthToken(), loginIntent = loginIntent, authSource = authSource, authData = authData, oauthToken = oauthToken, ) } Loading Loading @@ -151,15 +181,4 @@ class AuthSessionRepositoryImpl @Inject constructor( } } } private suspend fun persistPlayStoreSession( loginIntent: PersistedLoginIntent, authSource: PlayStoreAuthSource?, ) { // loginIntent is the authoritative published session signal. Persist the supporting Play // source first so a crash window cannot expose a new Play login intent without matching // source metadata. playStoreAuthStore.savePlayStoreAuthSource(authSource) sessionRepository.saveLoginIntent(loginIntent) } }