Loading data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreAuthMutex.kt 0 → 100644 +43 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.login.playstore import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton /** * Single process-wide lock for Play Store auth mutations. * * Request-time validation (`AuthenticatorRepository.getValidatedAuthData`) and 401-recovery * refresh (`PlayStoreTokenRefreshHandler.refreshPlayStoreToken`) both mutate the same persisted * resource: the Play `AuthData` + AAS token in the DataStore, plus the cached state in * `PlayStoreStoredAuthPolicy`. Holding two independent mutexes lets the two flows run * concurrently and double-persist (or one stomps the other's freshly-written credentials). * * Both paths must serialize through this lock. Do not nest it: neither flow may call into the * other while holding the lock. */ @Singleton class PlayStoreAuthMutex @Inject constructor() { private val mutex = Mutex() suspend fun <T> withLock(action: suspend () -> T): T = mutex.withLock { action() } } data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreTokenRefreshHandler.kt +6 −4 Original line number Diff line number Diff line Loading @@ -25,8 +25,6 @@ import foundation.e.apps.domain.auth.AuthResult import foundation.e.apps.domain.auth.AuthStore import foundation.e.apps.domain.auth.TokenRefreshHandler import foundation.e.apps.domain.auth.describe import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject Loading @@ -43,6 +41,7 @@ class PlayStoreTokenRefreshHandler : TokenRefreshHandler { private val playStoreAuthPersistenceGuard: PlayStoreAuthPersistenceGuard private val playStoreAuthenticator: PlayStoreAuthenticator private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy private val playStoreAuthMutex: PlayStoreAuthMutex private val monotonicClockMs: () -> Long @Inject Loading @@ -51,11 +50,13 @@ class PlayStoreTokenRefreshHandler : TokenRefreshHandler { playStoreAuthPersistenceGuard: PlayStoreAuthPersistenceGuard, playStoreAuthenticator: PlayStoreAuthenticator, playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, playStoreAuthMutex: PlayStoreAuthMutex, ) : this( authDataCache = authDataCache, playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard, playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = playStoreAuthMutex, monotonicClockMs = { TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) }, ) Loading @@ -64,21 +65,22 @@ class PlayStoreTokenRefreshHandler : TokenRefreshHandler { playStoreAuthPersistenceGuard: PlayStoreAuthPersistenceGuard, playStoreAuthenticator: PlayStoreAuthenticator, playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, playStoreAuthMutex: PlayStoreAuthMutex, monotonicClockMs: () -> Long, ) { this.authDataCache = authDataCache this.playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard this.playStoreAuthenticator = playStoreAuthenticator this.playStoreStoredAuthPolicy = playStoreStoredAuthPolicy this.playStoreAuthMutex = playStoreAuthMutex this.monotonicClockMs = monotonicClockMs } private val refreshMutex = Mutex() private var lastSuccessfulAuthToken: String? = null private var lastSuccessfulRefreshTimestamp: Long = 0L override suspend fun refreshPlayStoreToken(): AuthResult<Unit> { return refreshMutex.withLock { return playStoreAuthMutex.withLock { val currentAuthData = authDataCache.awaitSavedAuthData() if (wasRecentlyRefreshed(currentAuthData)) { Timber.d("Skipping Play Store token refresh because a recent refresh already succeeded") Loading data/src/main/java/foundation/e/apps/data/login/repository/AuthenticatorRepository.kt +3 −4 Original line number Diff line number Diff line Loading @@ -25,6 +25,7 @@ import foundation.e.apps.data.login.core.StoreAuthResult import foundation.e.apps.data.login.core.StoreAuthenticator import foundation.e.apps.data.login.core.toAuthDataResult import foundation.e.apps.data.login.exceptions.GPlayLoginException import foundation.e.apps.data.login.playstore.PlayStoreAuthMutex import foundation.e.apps.data.login.playstore.PlayStoreAuthPersistenceGuard import foundation.e.apps.data.login.playstore.PlayStoreStoredAuthPolicy import foundation.e.apps.data.preference.PlayStoreAuthStore Loading @@ -33,8 +34,6 @@ import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.domain.auth.AuthStore import foundation.e.apps.domain.auth.toPlayStoreLoginModeOrNull import foundation.e.apps.domain.preferences.SessionRepository import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton Loading @@ -46,8 +45,8 @@ class AuthenticatorRepository @Inject constructor( private val playStoreAuthStore: PlayStoreAuthStore, private val playStoreAuthPersistenceGuard: PlayStoreAuthPersistenceGuard, private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, private val playStoreAuthMutex: PlayStoreAuthMutex, ) : PlayStoreAuthManager, StoreAuthCoordinator { private val validatedAuthMutex = Mutex() override suspend fun requireValidatedPlayStoreAuth(): AuthData { val result = getValidatedAuthData() Loading Loading @@ -101,7 +100,7 @@ class AuthenticatorRepository @Inject constructor( } override suspend fun getValidatedAuthData(): ResultSupreme<AuthData?> { return validatedAuthMutex.withLock { return playStoreAuthMutex.withLock { val authenticator = authenticators.firstOrNull { it.storeType == AuthStore.PLAY_STORE } ?: return@withLock ResultSupreme.Error("Play Store authenticator not available") Loading data/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreTokenRefreshHandlerTest.kt +7 −0 Original line number Diff line number Diff line Loading @@ -65,6 +65,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } returns successfulRefresh( Loading Loading @@ -93,6 +94,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } returns StoreAuthResult( Loading Loading @@ -122,6 +124,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } returns StoreAuthResult( Loading Loading @@ -166,6 +169,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } coAnswers { Loading Loading @@ -198,6 +202,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), monotonicClockMs = { nowMs }, ) Loading Loading @@ -235,6 +240,7 @@ class PlayStoreTokenRefreshHandlerTest { ), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } coAnswers { Loading Loading @@ -265,6 +271,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } coAnswers { Loading data/src/test/java/foundation/e/apps/data/login/repository/AuthenticatorRepositoryTest.kt +2 −0 Original line number Diff line number Diff line Loading @@ -25,6 +25,7 @@ import foundation.e.apps.data.login.api.PlayStoreAuthValidationResult import foundation.e.apps.data.login.api.PlayStoreAuthValidator import foundation.e.apps.data.login.core.StoreAuthResult import foundation.e.apps.data.login.core.StoreAuthenticator import foundation.e.apps.data.login.playstore.PlayStoreAuthMutex import foundation.e.apps.data.login.playstore.PlayStoreAuthPersistenceGuard import foundation.e.apps.data.login.playstore.PlayStoreStoredAuthPolicy import foundation.e.apps.data.preference.PlayStoreAuthStore Loading Loading @@ -71,6 +72,7 @@ class AuthenticatorRepositoryTest { playStoreAuthValidator = playStoreAuthValidator, monotonicClockMs = monotonicClockMs, ), playStoreAuthMutex = PlayStoreAuthMutex(), ) } Loading Loading
data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreAuthMutex.kt 0 → 100644 +43 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.login.playstore import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton /** * Single process-wide lock for Play Store auth mutations. * * Request-time validation (`AuthenticatorRepository.getValidatedAuthData`) and 401-recovery * refresh (`PlayStoreTokenRefreshHandler.refreshPlayStoreToken`) both mutate the same persisted * resource: the Play `AuthData` + AAS token in the DataStore, plus the cached state in * `PlayStoreStoredAuthPolicy`. Holding two independent mutexes lets the two flows run * concurrently and double-persist (or one stomps the other's freshly-written credentials). * * Both paths must serialize through this lock. Do not nest it: neither flow may call into the * other while holding the lock. */ @Singleton class PlayStoreAuthMutex @Inject constructor() { private val mutex = Mutex() suspend fun <T> withLock(action: suspend () -> T): T = mutex.withLock { action() } }
data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreTokenRefreshHandler.kt +6 −4 Original line number Diff line number Diff line Loading @@ -25,8 +25,6 @@ import foundation.e.apps.domain.auth.AuthResult import foundation.e.apps.domain.auth.AuthStore import foundation.e.apps.domain.auth.TokenRefreshHandler import foundation.e.apps.domain.auth.describe import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject Loading @@ -43,6 +41,7 @@ class PlayStoreTokenRefreshHandler : TokenRefreshHandler { private val playStoreAuthPersistenceGuard: PlayStoreAuthPersistenceGuard private val playStoreAuthenticator: PlayStoreAuthenticator private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy private val playStoreAuthMutex: PlayStoreAuthMutex private val monotonicClockMs: () -> Long @Inject Loading @@ -51,11 +50,13 @@ class PlayStoreTokenRefreshHandler : TokenRefreshHandler { playStoreAuthPersistenceGuard: PlayStoreAuthPersistenceGuard, playStoreAuthenticator: PlayStoreAuthenticator, playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, playStoreAuthMutex: PlayStoreAuthMutex, ) : this( authDataCache = authDataCache, playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard, playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = playStoreAuthMutex, monotonicClockMs = { TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) }, ) Loading @@ -64,21 +65,22 @@ class PlayStoreTokenRefreshHandler : TokenRefreshHandler { playStoreAuthPersistenceGuard: PlayStoreAuthPersistenceGuard, playStoreAuthenticator: PlayStoreAuthenticator, playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, playStoreAuthMutex: PlayStoreAuthMutex, monotonicClockMs: () -> Long, ) { this.authDataCache = authDataCache this.playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard this.playStoreAuthenticator = playStoreAuthenticator this.playStoreStoredAuthPolicy = playStoreStoredAuthPolicy this.playStoreAuthMutex = playStoreAuthMutex this.monotonicClockMs = monotonicClockMs } private val refreshMutex = Mutex() private var lastSuccessfulAuthToken: String? = null private var lastSuccessfulRefreshTimestamp: Long = 0L override suspend fun refreshPlayStoreToken(): AuthResult<Unit> { return refreshMutex.withLock { return playStoreAuthMutex.withLock { val currentAuthData = authDataCache.awaitSavedAuthData() if (wasRecentlyRefreshed(currentAuthData)) { Timber.d("Skipping Play Store token refresh because a recent refresh already succeeded") Loading
data/src/main/java/foundation/e/apps/data/login/repository/AuthenticatorRepository.kt +3 −4 Original line number Diff line number Diff line Loading @@ -25,6 +25,7 @@ import foundation.e.apps.data.login.core.StoreAuthResult import foundation.e.apps.data.login.core.StoreAuthenticator import foundation.e.apps.data.login.core.toAuthDataResult import foundation.e.apps.data.login.exceptions.GPlayLoginException import foundation.e.apps.data.login.playstore.PlayStoreAuthMutex import foundation.e.apps.data.login.playstore.PlayStoreAuthPersistenceGuard import foundation.e.apps.data.login.playstore.PlayStoreStoredAuthPolicy import foundation.e.apps.data.preference.PlayStoreAuthStore Loading @@ -33,8 +34,6 @@ import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.domain.auth.AuthStore import foundation.e.apps.domain.auth.toPlayStoreLoginModeOrNull import foundation.e.apps.domain.preferences.SessionRepository import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton Loading @@ -46,8 +45,8 @@ class AuthenticatorRepository @Inject constructor( private val playStoreAuthStore: PlayStoreAuthStore, private val playStoreAuthPersistenceGuard: PlayStoreAuthPersistenceGuard, private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, private val playStoreAuthMutex: PlayStoreAuthMutex, ) : PlayStoreAuthManager, StoreAuthCoordinator { private val validatedAuthMutex = Mutex() override suspend fun requireValidatedPlayStoreAuth(): AuthData { val result = getValidatedAuthData() Loading Loading @@ -101,7 +100,7 @@ class AuthenticatorRepository @Inject constructor( } override suspend fun getValidatedAuthData(): ResultSupreme<AuthData?> { return validatedAuthMutex.withLock { return playStoreAuthMutex.withLock { val authenticator = authenticators.firstOrNull { it.storeType == AuthStore.PLAY_STORE } ?: return@withLock ResultSupreme.Error("Play Store authenticator not available") Loading
data/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreTokenRefreshHandlerTest.kt +7 −0 Original line number Diff line number Diff line Loading @@ -65,6 +65,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } returns successfulRefresh( Loading Loading @@ -93,6 +94,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } returns StoreAuthResult( Loading Loading @@ -122,6 +124,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } returns StoreAuthResult( Loading Loading @@ -166,6 +169,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } coAnswers { Loading Loading @@ -198,6 +202,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), monotonicClockMs = { nowMs }, ) Loading Loading @@ -235,6 +240,7 @@ class PlayStoreTokenRefreshHandlerTest { ), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } coAnswers { Loading Loading @@ -265,6 +271,7 @@ class PlayStoreTokenRefreshHandlerTest { playStoreAuthPersistenceGuard = playStoreAuthPersistenceGuard(authStore), playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, playStoreAuthMutex = PlayStoreAuthMutex(), ) coEvery { playStoreAuthenticator.refreshLogin() } coAnswers { Loading
data/src/test/java/foundation/e/apps/data/login/repository/AuthenticatorRepositoryTest.kt +2 −0 Original line number Diff line number Diff line Loading @@ -25,6 +25,7 @@ import foundation.e.apps.data.login.api.PlayStoreAuthValidationResult import foundation.e.apps.data.login.api.PlayStoreAuthValidator import foundation.e.apps.data.login.core.StoreAuthResult import foundation.e.apps.data.login.core.StoreAuthenticator import foundation.e.apps.data.login.playstore.PlayStoreAuthMutex import foundation.e.apps.data.login.playstore.PlayStoreAuthPersistenceGuard import foundation.e.apps.data.login.playstore.PlayStoreStoredAuthPolicy import foundation.e.apps.data.preference.PlayStoreAuthStore Loading Loading @@ -71,6 +72,7 @@ class AuthenticatorRepositoryTest { playStoreAuthValidator = playStoreAuthValidator, monotonicClockMs = monotonicClockMs, ), playStoreAuthMutex = PlayStoreAuthMutex(), ) } Loading