Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 136a0970 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

fix(auth): persist session credentials atomically

parent e3a86fff
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -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)
        }
+4 −0
Original line number Diff line number Diff line
@@ -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
        }
+4 −0
Original line number Diff line number Diff line
@@ -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
        }
+4 −0
Original line number Diff line number Diff line
@@ -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
        }
+67 −48
Original line number Diff line number Diff line
@@ -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
@@ -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,
        )
    }

@@ -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