diff --git a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppAccountFeatureRepositories.kt b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppAccountFeatureRepositories.kt index 1058546..2f8337d 100644 --- a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppAccountFeatureRepositories.kt +++ b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppAccountFeatureRepositories.kt @@ -40,7 +40,7 @@ import kotlinx.coroutines.sync.withPermit class AppAccountFeatureRepositories( private val account: StateFlow, - private val saveAccount: suspend (Account) -> Unit, + private val saveAccount: suspend (Account?, Account) -> Unit, private val clearAccount: suspend () -> Unit, private val database: TiebaPureDatabase, private val repositories: TiebaRepositories, @@ -48,7 +48,7 @@ class AppAccountFeatureRepositories( private val accountRepository: AccountRepository, private val mutationRepository: AccountMutationRepository, ) { - private val favoritePostIds = ConcurrentHashMap() + private val favoritePostIds = ConcurrentHashMap, ULong>() private val historyEntries: Flow> = combine( database.browsingHistoryDao().observeAll(), database.blocklistDao().observeAll(), @@ -79,7 +79,7 @@ class AppAccountFeatureRepositories( } authenticationRepository.validateLogin(credentials).also { ensureUnchangedSession(initialAccount) - saveAccount(it) + saveAccount(initialAccount, it) } } @@ -175,10 +175,10 @@ class AppAccountFeatureRepositories( result.copy(favorites = result.favorites.filter { TiebaContentFilterPolicy.shouldKeep(it, blocklist) }) - } - }.also { result -> - result.favorites.forEach { favorite -> - favoritePostIds[favorite.threadId] = favorite.markedPostId ?: 0u + }.also { result -> + result.favorites.forEach { favorite -> + favoritePostIds[current.id to favorite.threadId] = favorite.markedPostId ?: 0u + } } } @@ -197,11 +197,11 @@ class AppAccountFeatureRepositories( mutationRepository.setThreadFavorite( account = current, threadId = threadId, - postId = favoritePostIds[threadId] ?: 0u, + postId = favoritePostIds[current.id to threadId] ?: 0u, favorited = false, ) ensureCurrentSession(current) - favoritePostIds.remove(threadId) + favoritePostIds.remove(current.id to threadId) removed += threadId } catch (error: CancellationException) { throw error diff --git a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppAccountSessionController.kt b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppAccountSessionController.kt new file mode 100644 index 0000000..b49a1dc --- /dev/null +++ b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppAccountSessionController.kt @@ -0,0 +1,100 @@ +package dev.infinityf4p.tiebapure + +import dev.infinityf4p.tiebapure.core.data.AccountCredentialState +import dev.infinityf4p.tiebapure.core.model.Account +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal class AppAccountSessionController( + initialState: AccountCredentialState, + private val saveState: (AccountCredentialState) -> Unit, + private val clearState: () -> Unit, + private val activateSession: suspend (Account) -> Unit, + private val invalidateAndDrain: suspend (Account) -> Unit, +) { + private val mutex = Mutex() + private var state = initialState + private val mutableAccount = MutableStateFlow(initialState.activeAccount) + private val mutableAccounts = MutableStateFlow(initialState.accounts) + + val account: StateFlow = mutableAccount.asStateFlow() + val accounts: StateFlow> = mutableAccounts.asStateFlow() + + suspend fun addOrReplace(account: Account, expectedCurrent: Account?): Boolean = mutex.withLock { + check(state.activeAccount?.sessionIdentity() == expectedCurrent?.sessionIdentity()) { + "登录状态已变化,请重试。" + } + transitionTo(state.addOrReplace(account)) + } + + suspend fun switchTo(accountId: String): Boolean = mutex.withLock { + transitionTo(state.switchTo(accountId)) + } + + suspend fun remove(accountId: String): Boolean = mutex.withLock { + transitionTo(state.remove(accountId)) + } + + suspend fun removeCurrent(expected: Account): Boolean = mutex.withLock { + if (state.activeAccount?.sessionIdentity() != expected.sessionIdentity()) return@withLock false + transitionTo(state.remove(expected.id)) + true + } + + private suspend fun transitionTo(nextState: AccountCredentialState): Boolean { + if (nextState == state) return false + val previousAccount = state.activeAccount + val nextAccount = nextState.activeAccount + val sessionChanged = previousAccount?.sessionIdentity() != nextAccount?.sessionIdentity() + if (!sessionChanged) { + persist(nextState) + publish(nextState) + return false + } + + var previousInvalidated = false + var nextActivated = false + try { + previousAccount?.let { + invalidateAndDrain(it) + previousInvalidated = true + } + nextAccount?.let { + activateSession(it) + nextActivated = true + } + persist(nextState) + publish(nextState) + return true + } catch (error: Throwable) { + withContext(NonCancellable) { + if (nextActivated && nextAccount != null) { + runCatching { invalidateAndDrain(nextAccount) } + .exceptionOrNull() + ?.let(error::addSuppressed) + } + if (previousInvalidated && previousAccount != null) { + runCatching { activateSession(previousAccount) } + .exceptionOrNull() + ?.let(error::addSuppressed) + } + } + throw error + } + } + + private fun persist(nextState: AccountCredentialState) { + if (nextState.accounts.isEmpty()) clearState() else saveState(nextState) + } + + private fun publish(nextState: AccountCredentialState) { + state = nextState + mutableAccounts.value = nextState.accounts + mutableAccount.value = nextState.activeAccount + } +} diff --git a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppContainer.kt b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppContainer.kt index 7b860a5..93d48d3 100644 --- a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppContainer.kt +++ b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppContainer.kt @@ -24,10 +24,8 @@ import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -37,14 +35,6 @@ import kotlinx.coroutines.launch class AppContainer(context: Context) { private val appContext = context.applicationContext private val credentialVault = AccountCredentialVault(appContext) - private val mutableAccount = MutableStateFlow(credentialVault.load()) - - val sessionExpiration = SessionExpirationCoordinator( - currentAccount = { mutableAccount.value }, - logOut = ::logOut, - ) - - val account: StateFlow = mutableAccount.asStateFlow() val settings = AppSettingsStore(appContext) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val currentSettings = settings.values.stateIn( @@ -63,6 +53,19 @@ class AppContainer(context: Context) { ) private val accountService = DefaultTiebaAccountService(transport, requestBuilder) private val writeService = DefaultTiebaWriteService(transport, accountService, requestBuilder) + private val accountSessions = AppAccountSessionController( + initialState = credentialVault.load(), + saveState = credentialVault::save, + clearState = credentialVault::clear, + activateSession = writeService::activateSession, + invalidateAndDrain = writeService::invalidateAndDrain, + ) + val account: StateFlow = accountSessions.account + val accounts: StateFlow> = accountSessions.accounts + val sessionExpiration = SessionExpirationCoordinator( + currentAccount = { account.value }, + logOut = ::removeCurrentAccount, + ) val authenticationRepository = NetworkAuthenticationRepository(accountService) val accountRepository = NetworkAccountRepository(accountService) .monitorSessions(sessionExpiration::report) @@ -74,14 +77,14 @@ class AppContainer(context: Context) { val savedThreads = AppSavedThreadRepository( database = database, repositories = repositories, - account = { mutableAccount.value }, + account = { account.value }, mediaStore = savedThreadMedia, context = appContext, ) val featureRepositories = AppFeatureRepositories( repositories = repositories, database = database, - account = { mutableAccount.value }, + account = { account.value }, accountRepository = accountRepository, mutationRepository = mutationRepository, settings = { currentSettings.value }, @@ -102,7 +105,7 @@ class AppContainer(context: Context) { account = account, accountRepository = accountRepository, mutationRepository = mutationRepository, - onLogout = ::clearStoredAccount, + onLogout = ::removeCurrentAccount, automaticSignStore = AutomaticSignStore(appContext), ) val composerRepository = AppComposerRepository( @@ -146,37 +149,37 @@ class AppContainer(context: Context) { profilePendingEdit = profile } - suspend fun replaceAccount(value: Account) { - val previous = mutableAccount.value - if (previous?.sessionIdentity() != value.sessionIdentity()) { - previous?.let { mutationRepository.invalidateAndDrain(it) } - } - mutationRepository.activateSession(value) - try { - credentialVault.save(value) - mutableAccount.value = value - } catch (error: Throwable) { - mutationRepository.invalidateAndDrain(value) - previous?.let { mutationRepository.activateSession(it) } - throw error - } + private suspend fun replaceAccount(expectedCurrent: Account?, value: Account) { + if (accountSessions.addOrReplace(value, expectedCurrent)) resetSessionState() } suspend fun logOut() { - val current = mutableAccount.value - try { - current?.let { mutationRepository.invalidateAndDrain(it) } - } finally { - if (current == null || mutableAccount.value?.sessionIdentity() == current.sessionIdentity()) { - clearStoredAccount() - } + account.value?.let { removeCurrentAccount(it) } + } + + suspend fun switchAccount(accountId: String) { + if (accountSessions.switchTo(accountId)) resetSessionState() + } + + suspend fun removeAccount(accountId: String) { + val sessionChanged = accountSessions.remove(accountId) + if (sessionChanged) { + resetSessionState() + clearBaiduWebSession() + } + } + + private suspend fun removeCurrentAccount(expected: Account) { + if (accountSessions.removeCurrent(expected)) { + resetSessionState() + clearBaiduWebSession() } } - private fun clearStoredAccount() { - credentialVault.clear() - mutableAccount.value = null - clearBaiduWebSession() + private fun resetSessionState() { + profilePendingEdit = null + featureRepositories.clearSessionCaches() + sessionExpiration.dismissNotice() } private fun deviceProfile(context: Context): TiebaDeviceProfile { diff --git a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppFeatureRepositories.kt b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppFeatureRepositories.kt index 03ee0e7..5af8751 100644 --- a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppFeatureRepositories.kt +++ b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppFeatureRepositories.kt @@ -60,9 +60,12 @@ class AppFeatureRepositories( private val threadSummariesById = ConcurrentHashMap() private val readingPositionMutex = Mutex() private val readingPositionGenerations = ConcurrentHashMap() + private val sessionCacheLock = Any() + private var sessionCacheGeneration = 0L val home: HomeRepository = object : HomeRepository { override suspend fun loadFeed(page: Int): HomeFeedPage { + val cacheGeneration = currentSessionCacheGeneration() var requestedPage = page.coerceAtLeast(1) repeat(MAXIMUM_FILTERED_PAGE_SCAN) { val raw = repositories.home.threads( @@ -70,7 +73,7 @@ class AppFeatureRepositories( page = requestedPage, loadType = if (requestedPage == 1) 1 else 2, ) - raw.forEach(::rememberThreadSummary) + raw.forEach { rememberThreadSummary(it, cacheGeneration) } val filtered = filterThreads(raw, blockedEntries()) if (filtered.isNotEmpty() || raw.isEmpty()) { return HomeFeedPage(filtered, requestedPage, raw.isNotEmpty()) @@ -135,10 +138,11 @@ class AppFeatureRepositories( } val forumThreads: ForumThreadsRepository = ForumThreadsRepository { forum, page, category -> + val cacheGeneration = currentSessionCacheGeneration() var requestedPage = page.coerceAtLeast(1) repeat(MAXIMUM_FILTERED_PAGE_SCAN) { val result = repositories.forum.threads(forum.name, requestedPage, category, account()) - result.threads.forEach(::rememberThreadSummary) + result.threads.forEach { rememberThreadSummary(it, cacheGeneration) } val filtered = filterThreads(result.threads, blockedEntries()) if (filtered.isNotEmpty() || result.threads.isEmpty() || !result.hasMore) { return@ForumThreadsRepository result.copy(threads = filtered, currentPage = requestedPage) @@ -224,6 +228,7 @@ class AppFeatureRepositories( sort: dev.infinityf4p.tiebapure.core.model.ThreadReplySort, onlyThreadAuthor: Boolean, ): dev.infinityf4p.tiebapure.core.model.ThreadPage { + val cacheGeneration = currentSessionCacheGeneration() val result = TiebaContentFilterPolicy.filter( repositories.thread.page( threadId = threadId, @@ -234,9 +239,7 @@ class AppFeatureRepositories( ), blockedEntries(), ) - rememberThreadSummary(result.thread) - result.forum.id.takeIf { it > 0 }?.let { forumIdsByThread[threadId] = it } - threadPagesById[threadId] = result + cacheThreadPage(threadId, result, cacheGeneration) if (page == 1) { database.browsingHistoryDao().upsert( BrowsingHistoryEntity( @@ -272,6 +275,7 @@ class AppFeatureRepositories( sort: dev.infinityf4p.tiebapure.core.model.ThreadReplySort, onlyThreadAuthor: Boolean, ): dev.infinityf4p.tiebapure.core.model.ThreadPage { + val cacheGeneration = currentSessionCacheGeneration() val result = TiebaContentFilterPolicy.filter( repositories.thread.pageAroundPost( threadId = threadId, @@ -282,9 +286,7 @@ class AppFeatureRepositories( ), blockedEntries(), ) - rememberThreadSummary(result.thread) - result.forum.id.takeIf { it > 0 }?.let { forumIdsByThread[threadId] = it } - threadPagesById[threadId] = result + cacheThreadPage(threadId, result, cacheGeneration) return result } @@ -343,13 +345,15 @@ class AppFeatureRepositories( fun rememberThreadSummary(thread: ThreadSummary) { if (thread.id <= 0) return - synchronized(threadSummariesById) { - val previous = threadSummariesById[thread.id] - threadSummariesById[thread.id] = when { - previous == null -> thread - previous.blocks.isNotEmpty() && thread.blocks.isEmpty() -> previous - else -> thread - } + synchronized(sessionCacheLock) { rememberThreadSummaryLocked(thread) } + } + + fun clearSessionCaches() { + synchronized(sessionCacheLock) { + sessionCacheGeneration += 1 + forumIdsByThread.clear() + threadPagesById.clear() + threadSummariesById.clear() } } @@ -398,6 +402,35 @@ class AppFeatureRepositories( private suspend fun blockedEntries(): TiebaBlocklistSnapshot = database.blocklistDao().observeAll().first().toBlocklistSnapshot() + private fun currentSessionCacheGeneration(): Long = synchronized(sessionCacheLock) { + sessionCacheGeneration + } + + private fun rememberThreadSummary(thread: ThreadSummary, expectedGeneration: Long) { + if (thread.id <= 0) return + synchronized(sessionCacheLock) { + if (sessionCacheGeneration == expectedGeneration) rememberThreadSummaryLocked(thread) + } + } + + private fun rememberThreadSummaryLocked(thread: ThreadSummary) { + val previous = threadSummariesById[thread.id] + threadSummariesById[thread.id] = when { + previous == null -> thread + previous.blocks.isNotEmpty() && thread.blocks.isEmpty() -> previous + else -> thread + } + } + + private fun cacheThreadPage(threadId: Long, page: ThreadPage, expectedGeneration: Long) { + synchronized(sessionCacheLock) { + if (sessionCacheGeneration != expectedGeneration) return + rememberThreadSummaryLocked(page.thread) + page.forum.id.takeIf { it > 0 }?.let { forumIdsByThread[threadId] = it } + threadPagesById[threadId] = page + } + } + private fun nextReadingPositionGeneration(threadId: Long): Long = synchronized(readingPositionGenerations) { val next = (readingPositionGenerations[threadId] ?: 0L) + 1L readingPositionGenerations[threadId] = next diff --git a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppSettingsComposerRepositories.kt b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppSettingsComposerRepositories.kt index 82f3004..62dbd9a 100644 --- a/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppSettingsComposerRepositories.kt +++ b/app/src/main/kotlin/dev/infinityf4p/tiebapure/AppSettingsComposerRepositories.kt @@ -134,7 +134,7 @@ class AppSettingsAccountActions( private val account: StateFlow, private val accountRepository: AccountRepository, private val mutationRepository: AccountMutationRepository, - private val onLogout: suspend () -> Unit, + private val onLogout: suspend (Account) -> Unit, private val automaticSignStore: AutomaticSignStore, ) : SettingsAccountActions { private val signMutex = Mutex() @@ -196,7 +196,7 @@ class AppSettingsAccountActions( val activeAccount = requireCurrentAccount(account) mutationRepository.invalidateAndDrain(activeAccount) ensureCurrentSession(account, activeAccount) - onLogout() + onLogout(activeAccount) } private data class SignRunResult(val message: String, val completed: Boolean) diff --git a/app/src/main/kotlin/dev/infinityf4p/tiebapure/SessionExpiration.kt b/app/src/main/kotlin/dev/infinityf4p/tiebapure/SessionExpiration.kt index c5df66d..d0e70f6 100644 --- a/app/src/main/kotlin/dev/infinityf4p/tiebapure/SessionExpiration.kt +++ b/app/src/main/kotlin/dev/infinityf4p/tiebapure/SessionExpiration.kt @@ -34,7 +34,7 @@ data class SessionExpirationNotice( class SessionExpirationCoordinator( private val currentAccount: () -> Account?, - private val logOut: suspend () -> Unit, + private val logOut: suspend (Account) -> Unit, ) { private val mutex = Mutex() private val handledSessions = linkedSetOf() @@ -50,7 +50,7 @@ class SessionExpirationCoordinator( } if (!shouldHandle) return@withContext - val message = runCatching { logOut() } + val message = runCatching { logOut(account) } .fold( onSuccess = { "登录状态已失效,已退出当前账号,请重新登录。" }, onFailure = { "登录状态已失效,本机登录信息已清除,请重新登录。" }, diff --git a/app/src/test/kotlin/dev/infinityf4p/tiebapure/AppAccountSessionControllerTest.kt b/app/src/test/kotlin/dev/infinityf4p/tiebapure/AppAccountSessionControllerTest.kt new file mode 100644 index 0000000..4f2765d --- /dev/null +++ b/app/src/test/kotlin/dev/infinityf4p/tiebapure/AppAccountSessionControllerTest.kt @@ -0,0 +1,163 @@ +package dev.infinityf4p.tiebapure + +import dev.infinityf4p.tiebapure.core.data.AccountCredentialState +import dev.infinityf4p.tiebapure.core.model.Account +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppAccountSessionControllerTest { + @Test + fun addingAndSwitchingAccountsDrainsPreviousSession() = runTest { + val first = account("1") + val second = account("2") + val events = mutableListOf() + val controller = controller(first, events) + + assertTrue(controller.addOrReplace(second, expectedCurrent = first)) + assertEquals(second, controller.account.value) + assertEquals(listOf(second, first), controller.accounts.value) + + assertTrue(controller.switchTo(first.id)) + assertEquals(first, controller.account.value) + assertEquals(listOf(first, second), controller.accounts.value) + assertEquals( + listOf( + "invalidate:1", + "activate:2", + "save:2,1", + "invalidate:2", + "activate:1", + "save:1,2", + ), + events, + ) + } + + @Test + fun replacingCurrentAccountMetadataDoesNotRestartSession() = runTest { + val first = account("1") + val refreshed = first.copy(displayName = "Refreshed") + val events = mutableListOf() + val controller = controller(first, events) + + assertFalse(controller.addOrReplace(refreshed, expectedCurrent = first)) + + assertEquals(refreshed, controller.account.value) + assertEquals(listOf("save:1"), events) + } + + @Test + fun removingCurrentAccountActivatesRemainingAccount() = runTest { + val first = account("1") + val second = account("2") + val events = mutableListOf() + val controller = controller(first, events) + controller.addOrReplace(second, expectedCurrent = first) + events.clear() + + assertTrue(controller.removeCurrent(second)) + + assertEquals(first, controller.account.value) + assertEquals(listOf(first), controller.accounts.value) + assertEquals(listOf("invalidate:2", "activate:1", "save:1"), events) + } + + @Test + fun removingOnlyAccountClearsPersistedState() = runTest { + val first = account("1") + val events = mutableListOf() + val controller = controller(first, events) + + assertTrue(controller.removeCurrent(first)) + + assertEquals(null, controller.account.value) + assertEquals(emptyList(), controller.accounts.value) + assertEquals(listOf("invalidate:1", "clear"), events) + } + + @Test + fun staleLogoutDoesNotRemoveNewCurrentAccount() = runTest { + val first = account("1") + val second = account("2") + val controller = controller(first, mutableListOf()) + controller.addOrReplace(second, expectedCurrent = first) + + assertFalse(controller.removeCurrent(first)) + + assertEquals(second, controller.account.value) + assertEquals(listOf(second, first), controller.accounts.value) + } + + @Test + fun persistenceFailureRestoresPreviousSession() = runTest { + val first = account("1") + val second = account("2") + val events = mutableListOf() + val controller = AppAccountSessionController( + initialState = AccountCredentialState().addOrReplace(first), + saveState = { + events += "save" + error("disk full") + }, + clearState = { events += "clear" }, + activateSession = { events += "activate:${it.id}" }, + invalidateAndDrain = { events += "invalidate:${it.id}" }, + ) + + val error = runCatching { controller.addOrReplace(second, expectedCurrent = first) }.exceptionOrNull() + + assertTrue(error is IllegalStateException) + assertEquals("disk full", error?.message) + assertEquals(first, controller.account.value) + assertEquals(listOf(first), controller.accounts.value) + assertEquals( + listOf("invalidate:1", "activate:2", "save", "invalidate:2", "activate:1"), + events, + ) + } + + @Test + fun activationFailureReactivatesPreviousSession() = runTest { + val first = account("1") + val second = account("2") + val events = mutableListOf() + val controller = AppAccountSessionController( + initialState = AccountCredentialState().addOrReplace(first), + saveState = { events += "save" }, + clearState = { events += "clear" }, + activateSession = { + events += "activate:${it.id}" + if (it.id == second.id) error("activation failed") + }, + invalidateAndDrain = { events += "invalidate:${it.id}" }, + ) + + val error = runCatching { controller.addOrReplace(second, expectedCurrent = first) }.exceptionOrNull() + + assertTrue(error is IllegalStateException) + assertEquals(first, controller.account.value) + assertEquals(listOf("invalidate:1", "activate:2", "activate:1"), events) + } + + private fun controller(first: Account, events: MutableList) = AppAccountSessionController( + initialState = AccountCredentialState().addOrReplace(first), + saveState = { events += "save:${it.accounts.joinToString(",") { account -> account.id }}" }, + clearState = { events += "clear" }, + activateSession = { events += "activate:${it.id}" }, + invalidateAndDrain = { events += "invalidate:${it.id}" }, + ) + + private fun account(uid: String) = Account( + uid = uid, + name = "name-$uid", + displayName = "display-$uid", + portrait = "portrait-$uid", + bduss = "bduss-$uid", + stoken = "stoken-$uid", + baiduId = "baiduid-$uid", + tbs = "tbs-$uid", + ) +} diff --git a/app/src/test/kotlin/dev/infinityf4p/tiebapure/SessionExpirationTest.kt b/app/src/test/kotlin/dev/infinityf4p/tiebapure/SessionExpirationTest.kt index b59ac0a..e296b7e 100644 --- a/app/src/test/kotlin/dev/infinityf4p/tiebapure/SessionExpirationTest.kt +++ b/app/src/test/kotlin/dev/infinityf4p/tiebapure/SessionExpirationTest.kt @@ -17,16 +17,22 @@ class SessionExpirationTest { fun coordinatorLogsOutCurrentSessionOnceAndPublishesNotice() = runTest { val account = account("current", "bduss-a") var current: Account? = account + var loggedOutAccount: Account? = null var logoutCount = 0 val coordinator = SessionExpirationCoordinator( currentAccount = { current }, - logOut = { logoutCount += 1; current = null }, + logOut = { + loggedOutAccount = it + logoutCount += 1 + current = null + }, ) coordinator.report(account) coordinator.report(account) assertEquals(1, logoutCount) + assertEquals(account, loggedOutAccount) assertNull(current) assertNotNull(coordinator.notice.value) } diff --git a/core/data/src/main/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialState.kt b/core/data/src/main/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialState.kt new file mode 100644 index 0000000..97c0022 --- /dev/null +++ b/core/data/src/main/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialState.kt @@ -0,0 +1,59 @@ +package dev.infinityf4p.tiebapure.core.data + +import dev.infinityf4p.tiebapure.core.model.Account + +data class AccountCredentialState( + val accounts: List = emptyList(), + val activeAccountId: String? = null, +) { + init { + require(accounts.size <= MAX_ACCOUNT_COUNT) { "最多只能保存 $MAX_ACCOUNT_COUNT 个账号。" } + require(accounts.map(Account::id).distinct().size == accounts.size) { "账号列表包含重复账号。" } + require(accounts.all { it.id.isNotBlank() && it.bduss.isNotBlank() && it.stoken.isNotBlank() }) { + "账号登录凭据不完整。" + } + require( + (accounts.isEmpty() && activeAccountId == null) || + (accounts.isNotEmpty() && accounts.any { it.id == activeAccountId }), + ) { "当前账号不在已保存账号列表中。" } + } + + val activeAccount: Account? + get() = accounts.firstOrNull { it.id == activeAccountId } + + fun addOrReplace(account: Account): AccountCredentialState { + val exists = accounts.any { it.id == account.id } + check(exists || accounts.size < MAX_ACCOUNT_COUNT) { + "最多只能保存 $MAX_ACCOUNT_COUNT 个账号,请先移除一个账号。" + } + return AccountCredentialState( + accounts = listOf(account) + accounts.filterNot { it.id == account.id }, + activeAccountId = account.id, + ) + } + + fun switchTo(accountId: String): AccountCredentialState { + val target = accounts.firstOrNull { it.id == accountId } + ?: error("找不到要切换的账号。") + if (target.id == activeAccountId) return this + return AccountCredentialState( + accounts = listOf(target) + accounts.filterNot { it.id == target.id }, + activeAccountId = target.id, + ) + } + + fun remove(accountId: String): AccountCredentialState { + check(accounts.any { it.id == accountId }) { "找不到要移除的账号。" } + val remaining = accounts.filterNot { it.id == accountId } + val nextActiveId = when { + remaining.isEmpty() -> null + activeAccountId == accountId -> remaining.first().id + else -> activeAccountId + } + return AccountCredentialState(remaining, nextActiveId) + } + + companion object { + const val MAX_ACCOUNT_COUNT = 2 + } +} diff --git a/core/data/src/main/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialVault.kt b/core/data/src/main/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialVault.kt index c0c2660..eab5610 100644 --- a/core/data/src/main/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialVault.kt +++ b/core/data/src/main/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialVault.kt @@ -18,21 +18,25 @@ class AccountCredentialVault(context: Context) { private val preferences = context.getSharedPreferences("encrypted_account", Context.MODE_PRIVATE) @Synchronized - fun load(): Account? { - val payload = preferences.getString(PAYLOAD_KEY, null)?.decodeHex() ?: return null - if (payload.size <= IV_BYTES) return null + fun load(): AccountCredentialState { + val payload = preferences.getString(PAYLOAD_KEY, null)?.decodeHex() ?: return AccountCredentialState() + if (payload.size <= IV_BYTES) return AccountCredentialState() return runCatching { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, payload, 0, IV_BYTES)) - decode(cipher.doFinal(payload, IV_BYTES, payload.size - IV_BYTES)) - }.getOrNull() + AccountCredentialCodec.decode(cipher.doFinal(payload, IV_BYTES, payload.size - IV_BYTES)) + }.getOrDefault(AccountCredentialState()) } @Synchronized - fun save(account: Account) { + fun save(state: AccountCredentialState) { + if (state.accounts.isEmpty()) { + clear() + return + } val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, key()) - val encrypted = cipher.doFinal(encode(account)) + val encrypted = cipher.doFinal(AccountCredentialCodec.encode(state)) val payload = cipher.iv + encrypted check(preferences.edit().putString(PAYLOAD_KEY, payload.toHex()).commit()) } @@ -63,28 +67,78 @@ class AccountCredentialVault(context: Context) { private fun keyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - private fun encode(account: Account): ByteArray = ByteArrayOutputStream().use { bytes -> + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun String.decodeHex(): ByteArray? { + if (length % 2 != 0 || length > MAX_HEX_LENGTH) return null + return runCatching { ByteArray(length / 2) { substring(it * 2, it * 2 + 2).toInt(16).toByte() } }.getOrNull() + } + + private companion object { + const val KEY_ALIAS = "dev.infinityf4p.tiebapure.account.v1" + const val PAYLOAD_KEY = "payload" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val IV_BYTES = 12 + const val TAG_BITS = 128 + const val MAX_HEX_LENGTH = 160_000 + } +} + +internal object AccountCredentialCodec { + private const val LEGACY_FORMAT_VERSION = 1 + private const val FORMAT_VERSION = 2 + private const val ACCOUNT_FIELD_COUNT = 8 + private const val MAX_FIELD_LENGTH = 4_096 + + fun encode(state: AccountCredentialState): ByteArray = ByteArrayOutputStream().use { bytes -> + require(state.accounts.isNotEmpty()) DataOutputStream(bytes).use { output -> output.writeInt(FORMAT_VERSION) - listOf( - account.uid, - account.name, - account.displayName, - account.portrait, - account.bduss, - account.stoken, - account.baiduId.orEmpty(), - account.tbs, - ).forEach(output::writeUTF) + output.writeUTF(state.activeAccountId.orEmpty()) + output.writeInt(state.accounts.size) + state.accounts.forEach { account -> writeAccount(output, account) } } bytes.toByteArray() } - private fun decode(payload: ByteArray): Account = DataInputStream(ByteArrayInputStream(payload)).use { input -> - require(input.readInt() == FORMAT_VERSION) - val values = List(8) { input.readUTF().also { require(it.length <= MAX_FIELD_LENGTH) } } + fun decode(payload: ByteArray): AccountCredentialState = DataInputStream(ByteArrayInputStream(payload)).use { input -> + val state = when (input.readInt()) { + LEGACY_FORMAT_VERSION -> readAccount(input).let { AccountCredentialState(listOf(it), it.id) } + FORMAT_VERSION -> { + val activeAccountId = input.readLimitedUtf() + val accountCount = input.readInt().also { + require(it in 1..AccountCredentialState.MAX_ACCOUNT_COUNT) + } + AccountCredentialState( + accounts = List(accountCount) { readAccount(input) }, + activeAccountId = activeAccountId, + ) + } + else -> error("不支持的账号凭据格式。") + } require(input.available() == 0) - Account( + state + } + + private fun writeAccount(output: DataOutputStream, account: Account) { + listOf( + account.uid, + account.name, + account.displayName, + account.portrait, + account.bduss, + account.stoken, + account.baiduId.orEmpty(), + account.tbs, + ).forEach { value -> + require(value.length <= MAX_FIELD_LENGTH) + output.writeUTF(value) + } + } + + private fun readAccount(input: DataInputStream): Account { + val values = List(ACCOUNT_FIELD_COUNT) { input.readLimitedUtf() } + return Account( uid = values[0], name = values[1], displayName = values[2], @@ -96,21 +150,7 @@ class AccountCredentialVault(context: Context) { ) } - private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it.toInt() and 0xff) } - - private fun String.decodeHex(): ByteArray? { - if (length % 2 != 0 || length > MAX_HEX_LENGTH) return null - return runCatching { ByteArray(length / 2) { substring(it * 2, it * 2 + 2).toInt(16).toByte() } }.getOrNull() - } - - private companion object { - const val KEY_ALIAS = "dev.infinityf4p.tiebapure.account.v1" - const val PAYLOAD_KEY = "payload" - const val TRANSFORMATION = "AES/GCM/NoPadding" - const val IV_BYTES = 12 - const val TAG_BITS = 128 - const val FORMAT_VERSION = 1 - const val MAX_FIELD_LENGTH = 4_096 - const val MAX_HEX_LENGTH = 80_000 + private fun DataInputStream.readLimitedUtf(): String = readUTF().also { + require(it.length <= MAX_FIELD_LENGTH) } } diff --git a/core/data/src/test/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialStateTest.kt b/core/data/src/test/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialStateTest.kt new file mode 100644 index 0000000..457a135 --- /dev/null +++ b/core/data/src/test/kotlin/dev/infinityf4p/tiebapure/core/data/AccountCredentialStateTest.kt @@ -0,0 +1,96 @@ +package dev.infinityf4p.tiebapure.core.data + +import dev.infinityf4p.tiebapure.core.model.Account +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class AccountCredentialStateTest { + @Test + fun supportsAddingSwitchingAndRemovingTwoAccounts() { + val first = account("1") + val second = account("2") + + val withTwo = AccountCredentialState().addOrReplace(first).addOrReplace(second) + assertEquals(listOf(second, first), withTwo.accounts) + assertEquals(second, withTwo.activeAccount) + + val switched = withTwo.switchTo(first.id) + assertEquals(listOf(first, second), switched.accounts) + assertEquals(first, switched.activeAccount) + + val removed = switched.remove(first.id) + assertEquals(listOf(second), removed.accounts) + assertEquals(second, removed.activeAccount) + } + + @Test + fun replacingSavedAccountDoesNotUseAnotherSlot() { + val first = account("1") + val refreshed = first.copy(displayName = "Refreshed", bduss = "new-bduss") + + val state = AccountCredentialState().addOrReplace(first).addOrReplace(refreshed) + + assertEquals(listOf(refreshed), state.accounts) + assertEquals(refreshed, state.activeAccount) + } + + @Test + fun rejectsThirdDistinctAccount() { + val state = AccountCredentialState().addOrReplace(account("1")).addOrReplace(account("2")) + + val error = assertThrows(IllegalStateException::class.java) { + state.addOrReplace(account("3")) + } + + assertEquals("最多只能保存 2 个账号,请先移除一个账号。", error.message) + } + + @Test + fun currentFormatRoundTripsTwoAccounts() { + val original = AccountCredentialState().addOrReplace(account("1")).addOrReplace(account("2")) + + val decoded = AccountCredentialCodec.decode(AccountCredentialCodec.encode(original)) + + assertEquals(original, decoded) + } + + @Test + fun legacySingleAccountPayloadMigratesToActiveAccount() { + val legacy = account("1") + + val decoded = AccountCredentialCodec.decode(legacyPayload(legacy)) + + assertEquals(AccountCredentialState(listOf(legacy), legacy.id), decoded) + } + + private fun legacyPayload(account: Account): ByteArray = ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use { output -> + output.writeInt(1) + listOf( + account.uid, + account.name, + account.displayName, + account.portrait, + account.bduss, + account.stoken, + account.baiduId.orEmpty(), + account.tbs, + ).forEach(output::writeUTF) + } + bytes.toByteArray() + } + + private fun account(uid: String) = Account( + uid = uid, + name = "name-$uid", + displayName = "display-$uid", + portrait = "portrait-$uid", + bduss = "bduss-$uid", + stoken = "stoken-$uid", + baiduId = "baiduid-$uid", + tbs = "tbs-$uid", + ) +}