diff --git a/app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt b/app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt index 2596494dd..23ee3a936 100644 --- a/app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt @@ -3,6 +3,7 @@ package com.into.websoso.ui.accountInfo import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.into.websoso.data.account.AccountRepository +import com.into.websoso.data.feed.repository.UpdatedFeedRepository import com.into.websoso.data.filter.repository.MyLibraryFilterRepository import com.into.websoso.data.library.repository.MyLibraryRepository import com.into.websoso.data.repository.PushMessageRepository @@ -28,6 +29,7 @@ class AccountInfoViewModel private val accountRepository: AccountRepository, private val libraryRepository: MyLibraryRepository, private val filterRepository: MyLibraryFilterRepository, + private val feedRepository: UpdatedFeedRepository, ) : ViewModel() { private val _userEmail: MutableStateFlow = MutableStateFlow("") val userEmail: StateFlow get() = _userEmail.asStateFlow() @@ -55,6 +57,8 @@ class AccountInfoViewModel accountRepository .deleteTokens(userDeviceIdentifier) + // 서버 로그아웃 성공·실패와 관계없이 로그인 화면으로 가므로, 보내지 못한 좋아요 기록을 함께 버린다. + .also { feedRepository.discardPendingLikes() } .onSuccess { userRepository.removeTermsAgreementChecked() pushMessageRepository.clearFCMToken() diff --git a/app/src/main/java/com/into/websoso/ui/main/MainViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/MainViewModel.kt index c209a0059..d882dd112 100644 --- a/app/src/main/java/com/into/websoso/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/MainViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.into.websoso.data.account.AccountRepository +import com.into.websoso.data.feed.repository.UpdatedFeedRepository import com.into.websoso.data.repository.PushMessageRepository import com.into.websoso.data.repository.UserRepository import com.into.websoso.ui.main.model.MainUiState @@ -21,6 +22,7 @@ class MainViewModel private val userRepository: UserRepository, private val accountRepository: AccountRepository, private val pushMessageRepository: PushMessageRepository, + private val feedRepository: UpdatedFeedRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { private var userId: Long = DEFAULT_USER_ID @@ -34,6 +36,8 @@ class MainViewModel val isMyPageTooltipPending: LiveData get() = _isMyPageTooltipPending init { + // 새로 로그인해 메인에 들어왔을 수 있다. 사용자 정보로 계정을 확인할 때까지 이전 좋아요 기록을 보류한다. + feedRepository.beginAccountCheck() viewModelScope.launch { _isMyPageTooltipPending.value = !userRepository.fetchMyPageTooltipConfirmed() } @@ -59,6 +63,7 @@ class MainViewModel }.onSuccess { userInfo -> userId = userInfo.userId accountRepository.updateUserId(userId) + feedRepository.confirmAccount(userId) _mainUiState.value = mainUiState.value?.copy( nickname = userInfo.nickname, loading = false, diff --git a/app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt b/app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt index b9f52f082..a2ea6d08a 100644 --- a/app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.into.websoso.data.account.AccountRepository +import com.into.websoso.data.feed.repository.UpdatedFeedRepository import com.into.websoso.data.filter.repository.MyLibraryFilterRepository import com.into.websoso.data.library.repository.MyLibraryRepository import com.into.websoso.data.repository.PushMessageRepository @@ -23,6 +24,7 @@ class WithdrawSecondViewModel private val userRepository: UserRepository, private val libraryRepository: MyLibraryRepository, private val filterRepository: MyLibraryFilterRepository, + private val feedRepository: UpdatedFeedRepository, ) : ViewModel() { private val _withdrawReason: MutableLiveData = MutableLiveData("") val withdrawReason: LiveData get() = _withdrawReason @@ -85,6 +87,8 @@ class WithdrawSecondViewModel accountRepository .deleteAccount(reason.orEmpty()) .onSuccess { + // 탈퇴한 계정의 보내지 못한 좋아요 기록은 로그아웃과 같이 버린다. + feedRepository.discardPendingLikes() _isWithDrawSuccess.value = true userRepository.removeTermsAgreementChecked() pushMessageRepository.clearFCMToken() diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt index ffbbd72aa..f2dd1b7dd 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.preferencesOf import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey @@ -94,6 +95,22 @@ internal class DefaultPendingFeedLikeStore return deleted } + override suspend fun claimPendingLikes(accountId: Long) { + pendingFeedLikeDataStore.edit { preferences -> + preferences.setAsideUnreadableLikes() + if (preferences[PENDING_FEED_LIKES_ACCOUNT_ID_KEY] == accountId) return@edit + preferences.remove(PENDING_FEED_LIKES_KEY) + preferences[PENDING_FEED_LIKES_ACCOUNT_ID_KEY] = accountId + } + } + + override suspend fun clearPendingLikes() { + pendingFeedLikeDataStore.edit { preferences -> + preferences.remove(PENDING_FEED_LIKES_KEY) + preferences.remove(PENDING_FEED_LIKES_ACCOUNT_ID_KEY) + } + } + override suspend fun readResetNotice(): Boolean { var hasNotice = false pendingFeedLikeDataStore.edit { preferences -> @@ -146,6 +163,9 @@ internal class DefaultPendingFeedLikeStore private val PENDING_FEED_LIKES_KEY = stringPreferencesKey("PENDING_FEED_LIKES_KEY") private val PENDING_FEED_LIKES_RESET_KEY = booleanPreferencesKey("PENDING_FEED_LIKES_RESET_KEY") + // 기록을 남긴 계정의 서버 사용자 ID입니다. 없으면 계정 정보가 없던 이전 형식의 기록입니다. + private val PENDING_FEED_LIKES_ACCOUNT_ID_KEY = longPreferencesKey("PENDING_FEED_LIKES_ACCOUNT_ID_KEY") + // 형식 변경이나 해석 코드 문제를 고친 뒤 되살릴 수 있도록, 해석하지 못한 원본을 모아 둡니다. private val PENDING_FEED_LIKES_UNREADABLE_KEY = stringSetPreferencesKey("PENDING_FEED_LIKES_UNREADABLE_KEY") diff --git a/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt index 32db1a770..dda33b1de 100644 --- a/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt +++ b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt @@ -70,6 +70,46 @@ class DefaultPendingFeedLikeStoreTest { assertEquals(setOf(UNREADABLE_JSON), dataStore.data.first()[PENDING_FEED_LIKES_UNREADABLE_KEY]) } + @Test + fun `같은 계정이 기록을 다시 가져오면 유지하고 다른 계정이 가져오면 지운다`() = + runTest { + val store = createStore(createDataStore(temporaryFolder.newFile(FILE_NAME))) + store.claimPendingLikes(accountId = 1L) + store.updatePendingLike(feedId = 1L, isLiked = true) + + store.claimPendingLikes(accountId = 1L) + assertEquals(mapOf(1L to true), store.getPendingLikes()) + + store.claimPendingLikes(accountId = 2L) + assertTrue(store.getPendingLikes().isEmpty()) + } + + @Test + fun `계정 정보가 없는 이전 형식의 기록은 어느 계정이 가져와도 지운다`() = + runTest { + val dataStore = createDataStore(temporaryFolder.newFile(FILE_NAME)) + dataStore.edit { it[PENDING_FEED_LIKES_KEY] = """{"likes":[{"feedId":1,"isLiked":true}]}""" } + val store = createStore(dataStore) + assertEquals(mapOf(1L to true), store.getPendingLikes()) + + store.claimPendingLikes(accountId = 1L) + + assertTrue(store.getPendingLikes().isEmpty()) + } + + @Test + fun `기록을 모두 지우면 같은 계정이 다시 가져와도 남은 기록이 없다`() = + runTest { + val store = createStore(createDataStore(temporaryFolder.newFile(FILE_NAME))) + store.claimPendingLikes(accountId = 1L) + store.updatePendingLike(feedId = 1L, isLiked = true) + + store.clearPendingLikes() + store.claimPendingLikes(accountId = 1L) + + assertTrue(store.getPendingLikes().isEmpty()) + } + private fun TestScope.createDataStore(file: File): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler, diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt index 1188fc54b..952202493 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -69,6 +69,8 @@ class UpdatedFeedRepository // ponytail: all feeds share one sender; use per-feed serialization if slow requests delay other feeds excessively. private val likeSyncMutex = Mutex() private val restoreMutex = Mutex() + + @Volatile private var isLikesRestored = false private val _isLikeRestoreFailed = MutableStateFlow(false) val isLikeRestoreFailed = _isLikeRestoreFailed.asStateFlow() @@ -85,21 +87,34 @@ class UpdatedFeedRepository private val _likeSyncStates = MutableStateFlow>(emptyMap()) val likeSyncStates = _likeSyncStates.asStateFlow() - init { - scope.launch { ensureLikesRestored() } - } + // Like records belong to the account confirmed by the server user ID, not to the access token. + // null while the logged-in account is not confirmed yet (app start, right after a new login). + private var confirmedAccountId: Long? = null + + // The account that owns the in-memory like state. null when nothing is owned yet (app start, after sign-out). + private var likeSessionAccountId: Long? = null + + // Bumped whenever the in-memory like state is discarded; queued writes and syncs of an older session are dropped. + @Volatile + private var likeSessionGeneration = 0L + private var hasHeldLikeWrites = false /** * 저장된 좋아요를 아직 불러오지 못했다면 다시 불러옵니다. * 실패해도 예외를 던지지 않고 false를 반환해, 호출한 쪽이 저장·전송을 보류하게 합니다. */ private suspend fun ensureLikesRestored(): Boolean { + val (accountId, generation) = synchronized(likeStateLock) { + if (confirmedAccountId == null) hasHeldLikeWrites = true + confirmedAccountId to likeSessionGeneration + } + // Account not confirmed: hold writes and sends so they cannot reach another account. + if (accountId == null) return false var recovered = false val restored = restoreMutex.withLock { if (isLikesRestored) return@withLock true try { - restorePendingLikes() - isLikesRestored = true + if (!restorePendingLikes(accountId, generation)) return@withLock false // An earlier attempt failed, so selections made meanwhile may be held only in memory. recovered = _isLikeRestoreFailed.value _isLikeRestoreFailed.value = false @@ -111,10 +126,80 @@ class UpdatedFeedRepository } isLikesRestored } - if (recovered) persistHeldLikes() - return restored + if (!restored) return false + // Selections made while the account was unconfirmed were held only in memory. + val hasHeldWrites = synchronized(likeStateLock) { hasHeldLikeWrites.also { hasHeldLikeWrites = false } } + if (recovered || hasHeldWrites) persistHeldLikes(generation) + return true + } + + /** + * 로그인 직후처럼 계정이 바뀌었을 수 있을 때 호출합니다. + * [confirmAccount]가 호출될 때까지 남아 있는 좋아요 기록을 화면에 적용하거나 저장·전송하지 않습니다. + */ + fun beginAccountCheck() { + synchronized(likeStateLock) { confirmedAccountId = null } + } + + /** + * 서버에서 받은 사용자 ID로 현재 계정을 확인합니다. + * 같은 계정이면 기록을 이어서 쓰고, 다른 계정이면 이전 계정의 기록을 메모리와 저장소에서 버립니다. + */ + fun confirmAccount(accountId: Long) { + synchronized(likeStateLock) { + if (confirmedAccountId == accountId) return + val owner = likeSessionAccountId + if (owner != null && owner != accountId) resetLikeSessionLocked() + likeSessionAccountId = accountId + confirmedAccountId = accountId + // Re-apply the selections hidden while the account was being checked. + applyPendingLikeStatesToCachedFeeds() + } + scope.launch { ensureLikesRestored() } } + /** 로그아웃·탈퇴 시 호출합니다. 보내지 못한 좋아요 기록을 메모리와 저장소에서 모두 버립니다. */ + fun discardPendingLikes() { + synchronized(likeStateLock) { + resetLikeSessionLocked() + likeSessionAccountId = null + confirmedAccountId = null + } + scope.launch { + try { + pendingLikeStoreWriteMutex.withLock { pendingFeedLikeStore.clearPendingLikes() } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + // The records keep their account, so another account cannot restore or send them. + Log.e("UpdatedFeedRepository", "Failed to clear pending feed likes", error) + } + } + } + + private fun resetLikeSessionLocked() { + likeSessionGeneration++ + isLikesRestored = false + hasHeldLikeWrites = false + _isLikeRestoreFailed.value = false + pendingLikeStates.clear() + originalLikeStates.clear() + unconfirmedLikeIds.clear() + likeRevisions.clear() + lastLikeChanges.clear() + completedLikeAttempts.clear() + _likeSyncStates.value = emptyMap() + } + + private fun isLikeSession(generation: Long): Boolean = likeSessionGeneration == generation + + // Sending is allowed only for the current session while its account is confirmed. + private fun canSendLikes(generation: Long): Boolean = + synchronized(likeStateLock) { isLikeSession(generation) && confirmedAccountId != null } + + // While the account is being checked, the in-memory selections may belong to the previous account. + private fun isLikeSessionHidden(): Boolean = likeSessionAccountId != null && confirmedAccountId == null + /** 복원하지 못한 좋아요 안내를 보여 준 뒤 호출해, 같은 안내가 반복되지 않게 합니다. */ fun acknowledgeUnrestoredLikes() { _hasUnrestoredLikes.value = false @@ -131,19 +216,31 @@ class UpdatedFeedRepository } /** 복원에 실패한 동안 메모리에만 남은 선택을, 복원에 성공한 뒤 저장소에 저장합니다. */ - private suspend fun persistHeldLikes() { - val feedIds = synchronized(likeStateLock) { pendingLikeStates.keys.toList() } - feedIds.forEach { feedId -> persistPendingLikeOrMarkRetry(feedId) } - } - - private suspend fun restorePendingLikes() { - val pendingLikes = pendingFeedLikeStore.getPendingLikes() + private suspend fun persistHeldLikes(generation: Long) { + val feedIds = synchronized(likeStateLock) { + if (!isLikeSession(generation)) return + pendingLikeStates.keys.toList() + } + feedIds.forEach { feedId -> persistPendingLikeOrMarkRetry(feedId, generation) } + } + + /** 저장된 기록 중 [accountId]의 것만 복원합니다. 그 사이 기록이 버려졌다면 false를 반환합니다. */ + private suspend fun restorePendingLikes( + accountId: Long, + generation: Long, + ): Boolean { + val pendingLikes = pendingLikeStoreWriteMutex.withLock { + if (!isLikeSession(generation)) return false + pendingFeedLikeStore.claimPendingLikes(accountId) + pendingFeedLikeStore.getPendingLikes() + } if (pendingFeedLikeStore.readResetNotice()) { // Saved changes were corrupted or unreadable and were reset, so they could not be restored. Log.e("UpdatedFeedRepository", "Saved pending feed likes could not be restored and were reset") _hasUnrestoredLikes.value = true } synchronized(likeStateLock) { + if (!isLikeSession(generation)) return false pendingLikes.forEach { (id, isLiked) -> // A click made while storage was loading takes precedence, including a cancelled selection. pendingLikeStates[id] = if (id in likeRevisions) { @@ -155,7 +252,9 @@ class UpdatedFeedRepository setLikeSyncStatus(id, LikeSyncStatus.NEEDS_RETRY) } applyPendingLikeStatesToCachedFeeds() + isLikesRestored = true } + return true } // ============================================================================================ @@ -444,30 +543,43 @@ class UpdatedFeedRepository } else { pendingLikeStates[feedId] = new } + val generation = likeSessionGeneration scope.launch { - // Restore failed: keep the selection in memory and hold the write. + // Restore failed or account not confirmed: keep the selection in memory and hold the write. if (!ensureLikesRestored()) return@launch - persistPendingLikeOrMarkRetry(feedId) + persistPendingLikeOrMarkRetry(feedId, generation) } } - private suspend fun persistPendingLikeOrMarkRetry(feedId: Long) { + private suspend fun persistPendingLikeOrMarkRetry( + feedId: Long, + generation: Long, + ) { try { - persistPendingLike(feedId) + persistPendingLike(feedId, generation) } catch (error: CancellationException) { throw error } catch (error: Exception) { synchronized(likeStateLock) { - if (pendingLikeStates.containsKey(feedId)) setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) + if (isLikeSession(generation) && pendingLikeStates.containsKey(feedId)) { + setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) + } } Log.e("UpdatedFeedRepository", "Failed to save pending feed like $feedId", error) } } // Read the latest selection when the write runs; a delayed write must not resurrect an old selection. - private suspend fun persistPendingLike(feedId: Long) { + private suspend fun persistPendingLike( + feedId: Long, + generation: Long, + ) { pendingLikeStoreWriteMutex.withLock { - val latest = synchronized(likeStateLock) { pendingLikeStates[feedId] } + val latest = synchronized(likeStateLock) { + // A write left from a discarded session must not touch the records of the current account. + if (!isLikeSession(generation)) return + pendingLikeStates[feedId] + } if (latest == null) { pendingFeedLikeStore.deletePendingLike(feedId) } else { @@ -496,15 +608,16 @@ class UpdatedFeedRepository } private fun syncPendingLikes(feedIds: Set?) { - val requestedAt = synchronized(likeStateLock) { ++likeEventSequence } + val (requestedAt, generation) = synchronized(likeStateLock) { ++likeEventSequence to likeSessionGeneration } scope.launch { - // Restore failed: hold sending until a later sync or retry restores the saved likes. + // Restore failed or account not confirmed: hold sending until a later sync or retry. if (!ensureLikesRestored()) return@launch likeSyncMutex.withLock { val targets = synchronized(likeStateLock) { + if (!isLikeSession(generation)) return@launch pendingLikeStates.keys.filter { feedIds == null || it in feedIds } } - targets.forEach { id -> syncPendingLike(id, requestedAt) } + targets.forEach { id -> syncPendingLike(id, requestedAt, generation) } } } } @@ -512,8 +625,10 @@ class UpdatedFeedRepository private suspend fun syncPendingLike( feedId: Long, requestedAt: Long, + generation: Long, ) { val selection = synchronized(likeStateLock) { + if (!isLikeSession(generation) || confirmedAccountId == null) return val desired = pendingLikeStates[feedId] ?: return val revision = likeRevisions[feedId] ?: 0L val completed = completedLikeAttempts[feedId] @@ -526,12 +641,21 @@ class UpdatedFeedRepository } var serverConfirmed = !selection.needsNetwork try { - persistPendingLike(feedId) + persistPendingLike(feedId, generation) if (selection.needsNetwork) { + if (!canSendLikes(generation)) { + // The account changed or is being checked: keep the selection for its own account. + synchronized(likeStateLock) { + if (isLikeSession(generation)) setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) + } + return + } if (selection.isLiked) feedApi.postLikes(feedId) else feedApi.deleteLikes(feedId) serverConfirmed = true } synchronized(likeStateLock) { + // The session was discarded while the request was in flight. + if (!isLikeSession(generation)) return originalLikeStates[feedId] = selection.isLiked unconfirmedLikeIds.remove(feedId) if (pendingLikeStates[feedId] == selection.isLiked) { @@ -542,22 +666,24 @@ class UpdatedFeedRepository // An older success does not confirm an input made while the request was in flight. setLikeSyncStatus(feedId, if (pendingLikeStates.containsKey(feedId)) LikeSyncStatus.NEEDS_RETRY else null) } - persistPendingLike(feedId) + persistPendingLike(feedId, generation) synchronized(likeStateLock) { - if (!pendingLikeStates.containsKey(feedId)) originalLikeStates.remove(feedId) + if (isLikeSession(generation) && !pendingLikeStates.containsKey(feedId)) originalLikeStates.remove(feedId) } } catch (error: Exception) { synchronized(likeStateLock) { - // Even cancellation/timeout says nothing about whether the server applied the request. - if (!serverConfirmed) unconfirmedLikeIds += feedId - pendingLikeStates.putIfAbsent(feedId, findCurrentLikeState(feedId) ?: selection.isLiked) - setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) + if (isLikeSession(generation)) { + // Even cancellation/timeout says nothing about whether the server applied the request. + if (!serverConfirmed) unconfirmedLikeIds += feedId + pendingLikeStates.putIfAbsent(feedId, findCurrentLikeState(feedId) ?: selection.isLiked) + setLikeSyncStatus(feedId, LikeSyncStatus.NEEDS_RETRY) + } } if (error is CancellationException) throw error Log.e("UpdatedFeedRepository", "Failed to sync feed $feedId", error) } finally { synchronized(likeStateLock) { - completedLikeAttempts[feedId] = selection.revision to ++likeEventSequence + if (isLikeSession(generation)) completedLikeAttempts[feedId] = selection.revision to ++likeEventSequence } } } @@ -606,6 +732,7 @@ class UpdatedFeedRepository feed: FeedEntity, version: Long, ): FeedEntity { + if (isLikeSessionHidden()) return feed val change = likeChangeSince(feed.id, version) ?: return applyPendingLikeState(feed) // The like changed while this response was in flight, so keep what the user sees now. findCurrentLike(feed.id)?.let { return feed.copy(isLiked = it.isLiked, likeCount = it.likeCount) } @@ -620,6 +747,7 @@ class UpdatedFeedRepository feed: FeedDetailEntity, version: Long, ): FeedDetailEntity { + if (isLikeSessionHidden()) return feed val change = likeChangeSince(feed.id, version) ?: return applyPendingLikeStateToDetail(feed) // The like changed while this response was in flight, so keep what the user sees now. findCurrentLike(feed.id)?.let { return feed.copy(isLiked = it.isLiked, likeCount = it.likeCount) } diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt index b164dfde9..abca6f4dd 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -24,6 +24,15 @@ interface PendingFeedLikeStore { isLiked: Boolean, ): Boolean + /** + * 기록의 소유 계정을 [accountId]로 맞춥니다. + * 다른 계정의 기록이거나 소유 계정이 없는 이전 형식의 기록이면, 화면 적용·전송에 쓰지 않도록 모두 지웁니다. + */ + suspend fun claimPendingLikes(accountId: Long) + + /** 로그아웃·탈퇴 시 기록과 소유 계정을 모두 지웁니다. */ + suspend fun clearPendingLikes() + /** 저장된 기록을 복원하지 못해 비운 표시(파일 손상, 해석 불가)가 있는지 반환합니다. 표시는 지우지 않습니다. */ suspend fun readResetNotice(): Boolean diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt index 54cde0030..858504054 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt @@ -12,15 +12,17 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class UpdatedFeedRepositoryAccountTest { + // 계정 전환은 앱의 연결 지점과 같은 순서로 모의한다. + // 로그아웃: discardPendingLikes(), 새 로그인 뒤 메인 진입: beginAccountCheck(), 사용자 정보 수신: confirmAccount(). + // API 대역의 계정 컨텍스트는 요청이 어느 계정의 토큰으로 나갔는지 기록하는 용도다. + @Test - fun `현재 계정 결함 기록 - A에서 실패한 pending이 B 목록에 반영되고 B 요청으로 전송된다`() = + fun `로그인 만료 뒤 B가 로그인하면 A의 실패 기록을 B 목록에 적용하지도 B 요청으로 보내지도 않는다`() = runTest { - // 정상 기준은 계정 간 선택이 섞이지 않는 것이다. 아래는 현재 결함의 재현 예상값이다. - // 실제 로그인/로그아웃 대신 API 대역의 계정 컨텍스트를 전환한다. val feedApi = accountAwareFeedApi(12L) feedApi.accountApis.getValue("A").planLikeRequest("POST", 12L, LikeOutcome.FAIL_WITHOUT_APPLYING) - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) + val pendingStore = FakePendingFeedLikeStore(initialAccountId = null) + val repository = createRepository(feedApi, pendingStore, accountId = ACCOUNT_A) advanceUntilIdle() repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") @@ -32,116 +34,133 @@ class UpdatedFeedRepositoryAccountTest { assertEquals(mapOf(12L to true), pendingStore.currentPendingLikes()) assertFalse(feedApi.isLikedFor("A", 12L)) + // 로그인 만료로 로그인 화면에 갔다가 B가 로그인해 메인에 들어온다. feedApi.currentAccountId = null - assertTrue( - repository.sosoAllFeeds.value - .feed(12L) - .isLiked, - ) - assertEquals(mapOf(12L to true), pendingStore.currentPendingLikes()) feedApi.currentAccountId = "B" - assertFalse(feedApi.isLikedFor("B", 12L)) + repository.beginAccountCheck() + val beforeConfirm = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(12L) + assertFalse(beforeConfirm.isLiked) + assertEquals(0, beforeConfirm.likeCount) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(AccountLikeCall("A", 12L, true)), feedApi.likeCalls) + + repository.confirmAccount(ACCOUNT_B) + advanceUntilIdle() val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(12L) - // B의 서버 원본은 false인데 A의 메모리 pending이 목록에 적용된다. - assertTrue(refreshed.isLiked) - assertEquals(1, refreshed.likeCount) + assertFalse(refreshed.isLiked) + assertEquals(0, refreshed.likeCount) repository.syncPendingLikes() advanceUntilIdle() - assertEquals( - listOf(AccountLikeCall("A", 12L, true), AccountLikeCall("B", 12L, true)), - feedApi.likeCalls, - ) - assertFalse(feedApi.isLikedFor("A", 12L)) - assertTrue(feedApi.isLikedFor("B", 12L)) + assertEquals(listOf(AccountLikeCall("A", 12L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("B", 12L)) assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) } @Test - fun `현재 계정 결함 기록 - 저장소만 비워도 A의 메모리 pending이 B 요청으로 전송된다`() = + fun `로그아웃하면 A의 메모리 기록도 버려 B 요청으로 보내지 않는다`() = runTest { val feedApi = accountAwareFeedApi(13L) - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) + val pendingStore = FakePendingFeedLikeStore(initialAccountId = null) + val repository = createRepository(feedApi, pendingStore, accountId = ACCOUNT_A) advanceUntilIdle() repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") repository.toggleLikeLocal(13L) advanceUntilIdle() + assertEquals(mapOf(13L to true), pendingStore.currentPendingLikes()) feedApi.currentAccountId = null - // 실제 로그아웃 구현이 아니다. 저장소만 정리하는 대응으로 충분한지 분리해서 확인한다. - pendingStore.deletePendingLike(13L) + repository.discardPendingLikes() + advanceUntilIdle() assertTrue(pendingStore.currentPendingLikes().isEmpty()) feedApi.currentAccountId = "B" + repository.beginAccountCheck() + repository.confirmAccount(ACCOUNT_B) repository.syncPendingLikes() advanceUntilIdle() - assertEquals(listOf(AccountLikeCall("B", 13L, true)), feedApi.likeCalls) + assertTrue(feedApi.likeCalls.isEmpty()) assertFalse(feedApi.isLikedFor("A", 13L)) - assertTrue(feedApi.isLikedFor("B", 13L)) + assertFalse(feedApi.isLikedFor("B", 13L)) assertTrue(pendingStore.currentPendingLikes().isEmpty()) } @Test - fun `현재 계정 결함 기록 - B에서 Repository를 재생성해도 저장된 A pending이 복원되어 전송된다`() = + fun `B에서 Repository를 재생성하면 저장된 A 기록을 복원하지 않고 지운다`() = runTest { val feedApi = accountAwareFeedApi(14L) - val pendingStore = FakePendingFeedLikeStore() - val repositoryA = createRepository(feedApi, pendingStore) + val pendingStore = FakePendingFeedLikeStore(initialAccountId = null) + val repositoryA = createRepository(feedApi, pendingStore, accountId = ACCOUNT_A) advanceUntilIdle() repositoryA.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") repositoryA.toggleLikeLocal(14L) advanceUntilIdle() assertEquals(mapOf(14L to true), pendingStore.currentPendingLikes()) + assertEquals(ACCOUNT_A, pendingStore.accountId) assertTrue(feedApi.likeCalls.isEmpty()) feedApi.currentAccountId = null feedApi.currentAccountId = "B" // 같은 가짜 저장소를 읽는 새 인스턴스다. 실제 DataStore/프로세스 재실행 검증은 아니다. - val repositoryB = createRepository(feedApi, pendingStore) + val repositoryB = createRepository(feedApi, pendingStore, accountId = null) advanceUntilIdle() - val restored = repositoryB.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(14L) - assertTrue(restored.isLiked) - assertFalse(feedApi.isLikedFor("B", 14L)) + val beforeConfirm = repositoryB.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(14L) + assertFalse(beforeConfirm.isLiked) + repositoryB.syncPendingLikes() + advanceUntilIdle() + assertTrue(feedApi.likeCalls.isEmpty()) + repositoryB.confirmAccount(ACCOUNT_B) + advanceUntilIdle() + val restored = repositoryB.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(14L) + assertFalse(restored.isLiked) repositoryB.syncPendingLikes() advanceUntilIdle() - assertEquals(listOf(AccountLikeCall("B", 14L, true)), feedApi.likeCalls) - assertFalse(feedApi.isLikedFor("A", 14L)) - assertTrue(feedApi.isLikedFor("B", 14L)) + assertTrue(feedApi.likeCalls.isEmpty()) + assertFalse(feedApi.isLikedFor("B", 14L)) assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertEquals(ACCOUNT_B, pendingStore.accountId) } @Test - fun `현재 계정 결함 기록 - A에서 호출하고 실행을 기다리던 동기화가 B 요청으로 전송된다`() = + fun `A에서 호출하고 실행을 기다리던 동기화는 B 로그인 뒤 보내지 않는다`() = runTest { val feedApi = accountAwareFeedApi(15L) - val pendingStore = FakePendingFeedLikeStore() - val repository = createRepository(feedApi, pendingStore) + val pendingStore = FakePendingFeedLikeStore(initialAccountId = null) + val repository = createRepository(feedApi, pendingStore, accountId = ACCOUNT_A) advanceUntilIdle() repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") repository.toggleLikeLocal(15L) advanceUntilIdle() repository.syncPendingLikes() - // launch된 동기화는 아직 실행하지 않고, 먼저 계정 컨텍스트를 바꾼다. + // launch된 동기화는 아직 실행하지 않고, 먼저 계정을 바꾼다. assertTrue(feedApi.likeCalls.isEmpty()) feedApi.currentAccountId = null feedApi.currentAccountId = "B" + repository.beginAccountCheck() + advanceUntilIdle() + assertTrue(feedApi.likeCalls.isEmpty()) + + repository.confirmAccount(ACCOUNT_B) + advanceUntilIdle() + repository.syncPendingLikes() advanceUntilIdle() - assertEquals(listOf(AccountLikeCall("B", 15L, true)), feedApi.likeCalls) + assertTrue(feedApi.likeCalls.isEmpty()) assertFalse(feedApi.isLikedFor("A", 15L)) - assertTrue(feedApi.isLikedFor("B", 15L)) + assertFalse(feedApi.isLikedFor("B", 15L)) assertTrue(pendingStore.currentPendingLikes().isEmpty()) } @Test - fun `현재 계정 결함 기록 - 저장소 정리 뒤 실행된 A의 저장이 B에서 복원되어 전송된다`() = + fun `로그아웃 뒤 실행된 A의 저장 작업은 기록을 남기지 않아 B에서 복원되지 않는다`() = runTest { val feedApi = accountAwareFeedApi(16L) - val pendingStore = FakePendingFeedLikeStore() - val repositoryA = createRepository(feedApi, pendingStore) + val pendingStore = FakePendingFeedLikeStore(initialAccountId = null) + val repositoryA = createRepository(feedApi, pendingStore, accountId = ACCOUNT_A) advanceUntilIdle() repositoryA.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") repositoryA.toggleLikeLocal(16L) @@ -154,20 +173,119 @@ class UpdatedFeedRepositoryAccountTest { assertTrue(pendingStore.currentPendingLikes().isEmpty()) feedApi.currentAccountId = null - // 저장 작업 실행 전 저장소 정리를 모의한다. 실제 로그아웃 구현은 아니다. - pendingStore.deletePendingLike(16L) + repositoryA.discardPendingLikes() feedApi.currentAccountId = "B" advanceUntilIdle() - assertEquals(mapOf(16L to true), pendingStore.currentPendingLikes()) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) - val repositoryB = createRepository(feedApi, pendingStore) + val repositoryB = createRepository(feedApi, pendingStore, accountId = ACCOUNT_B) advanceUntilIdle() repositoryB.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") repositoryB.syncPendingLikes() advanceUntilIdle() - assertEquals(listOf(AccountLikeCall("B", 16L, true)), feedApi.likeCalls) + assertTrue(feedApi.likeCalls.isEmpty()) assertFalse(feedApi.isLikedFor("A", 16L)) - assertTrue(feedApi.isLikedFor("B", 16L)) + assertFalse(feedApi.isLikedFor("B", 16L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `로그인 만료 뒤 같은 계정이 다시 로그인하면 기록을 유지해 그 계정 요청으로 보낸다`() = + runTest { + val feedApi = accountAwareFeedApi(17L) + feedApi.accountApis.getValue("A").planLikeRequest("POST", 17L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val pendingStore = FakePendingFeedLikeStore(initialAccountId = null) + val repository = createRepository(feedApi, pendingStore, accountId = ACCOUNT_A) + advanceUntilIdle() + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(17L) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(mapOf(17L to true), pendingStore.currentPendingLikes()) + + feedApi.currentAccountId = null + feedApi.currentAccountId = "A" + repository.beginAccountCheck() + // 계정 확인 전에는 화면 적용과 전송을 보류한다. + assertFalse( + repository + .fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + .feeds + .feed(17L) + .isLiked, + ) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(AccountLikeCall("A", 17L, true)), feedApi.likeCalls) + + repository.confirmAccount(ACCOUNT_A) + advanceUntilIdle() + assertTrue( + repository.sosoAllFeeds.value + .feed(17L) + .isLiked, + ) + assertEquals(mapOf(17L to true), pendingStore.currentPendingLikes()) + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals( + listOf(AccountLikeCall("A", 17L, true), AccountLikeCall("A", 17L, true)), + feedApi.likeCalls, + ) + assertTrue(feedApi.isLikedFor("A", 17L)) + assertFalse(feedApi.isLikedFor("B", 17L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `앱을 다시 실행해 같은 계정이 확인되면 저장된 기록을 복원해 그 계정 요청으로 보낸다`() = + runTest { + val feedApi = accountAwareFeedApi(18L) + val pendingStore = FakePendingFeedLikeStore(initialAccountId = null) + val repositoryA = createRepository(feedApi, pendingStore, accountId = ACCOUNT_A) + advanceUntilIdle() + repositoryA.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repositoryA.toggleLikeLocal(18L) + advanceUntilIdle() + + val restartedRepository = createRepository(feedApi, pendingStore, accountId = null) + advanceUntilIdle() + restartedRepository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + restartedRepository.confirmAccount(ACCOUNT_A) + advanceUntilIdle() + assertTrue( + restartedRepository.sosoAllFeeds.value + .feed(18L) + .isLiked, + ) + restartedRepository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(AccountLikeCall("A", 18L, true)), feedApi.likeCalls) + assertTrue(feedApi.isLikedFor("A", 18L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `계정 정보가 없는 이전 형식의 기록은 복원하지도 보내지도 않는다`() = + runTest { + val feedApi = accountAwareFeedApi(19L) + val pendingStore = FakePendingFeedLikeStore(initial = mapOf(19L to true), initialAccountId = null) + val repository = createRepository(feedApi, pendingStore, accountId = ACCOUNT_A) + advanceUntilIdle() + + assertFalse( + repository + .fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + .feeds + .feed(19L) + .isLiked, + ) + repository.syncPendingLikes() + advanceUntilIdle() + + assertTrue(feedApi.likeCalls.isEmpty()) assertTrue(pendingStore.currentPendingLikes().isEmpty()) } @@ -182,6 +300,9 @@ class UpdatedFeedRepositoryAccountTest { ) } +private const val ACCOUNT_A = 1L +private const val ACCOUNT_B = 2L + private data class AccountLikeCall( val accountId: String, val feedId: Long, diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt index 125aff49d..d2a118144 100644 --- a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt @@ -27,6 +27,7 @@ import java.net.SocketTimeoutException internal fun TestScope.createRepository( feedApi: FeedApi, pendingFeedLikeStore: PendingFeedLikeStore, + accountId: Long? = TEST_ACCOUNT_ID, ): UpdatedFeedRepository = UpdatedFeedRepository( feedApi = feedApi, @@ -35,7 +36,12 @@ internal fun TestScope.createRepository( imageDownloader = ImageDownloader(ContextWrapper(null)), imageCompressor = ImageCompressor(ContextWrapper(null)), dispatcher = StandardTestDispatcher(testScheduler), - ) + ).apply { + // 앱에서는 MainViewModel이 서버에서 사용자 정보를 받은 뒤 계정을 확인한다. + if (accountId != null) confirmAccount(accountId) + } + +internal const val TEST_ACCOUNT_ID = 100L internal fun List.feed(id: Long): FeedEntity = first { it.id == id } @@ -235,9 +241,14 @@ internal class FakeFeedApi : FeedApi { internal class FakePendingFeedLikeStore( initial: Map = emptyMap(), + initialAccountId: Long? = TEST_ACCOUNT_ID, ) : PendingFeedLikeStore { private val state = MutableStateFlow(initial) + /** 기록의 소유 계정. null이면 계정 정보가 없는 이전 형식의 기록처럼 동작한다. */ + var accountId: Long? = initialAccountId + private set + override val pendingLikes: Flow> = state.asStateFlow() val updateCalls = mutableListOf>() @@ -274,6 +285,17 @@ internal class FakePendingFeedLikeStore( return true } + override suspend fun claimPendingLikes(accountId: Long) { + if (this.accountId == accountId) return + state.value = emptyMap() + this.accountId = accountId + } + + override suspend fun clearPendingLikes() { + state.value = emptyMap() + accountId = null + } + override suspend fun readResetNotice(): Boolean = resetNotice override suspend fun clearResetNotice() {