diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt index 0b353148a..eeff5bb93 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt @@ -145,7 +145,7 @@ class UpdatedFeedDetailViewModel */ override fun onCleared() { super.onCleared() - feedRepository.syncDirtyFeeds() + feedRepository.syncPendingLikes() } /** diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 3e1e3df38..537747926 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -12,9 +12,9 @@ android { dependencies { implementation(projects.core.common) implementation(projects.data.account) + implementation(projects.data.feed) implementation(projects.data.library) implementation(projects.data.user) - implementation(libs.datastore.preferences) implementation(libs.serialization.json) } 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 new file mode 100644 index 000000000..07af7e3d9 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStore.kt @@ -0,0 +1,129 @@ +package com.into.websoso.core.datastore.datasource.feed + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.into.websoso.core.common.dispatchers.Dispatcher +import com.into.websoso.core.common.dispatchers.WebsosoDispatchers +import com.into.websoso.core.datastore.datasource.feed.model.PendingFeedLikePreferences +import com.into.websoso.core.datastore.datasource.feed.model.PendingFeedLikesPreferences +import com.into.websoso.core.datastore.di.PendingFeedLikeDataStore +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +internal class DefaultPendingFeedLikeStore + @Inject + constructor( + @param:PendingFeedLikeDataStore private val pendingFeedLikeDataStore: DataStore, + @param:Dispatcher(WebsosoDispatchers.DEFAULT) private val dispatcher: CoroutineDispatcher, + ) : PendingFeedLikeStore { + override val pendingLikes: Flow> + get() = pendingFeedLikeDataStore.data + .map { preferences -> + withContext(dispatcher) { + decodePendingLikes(preferences[PENDING_FEED_LIKES_KEY]) + } + }.distinctUntilChanged() + + override suspend fun getPendingLikes(): Map = pendingLikes.first() + + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + pendingLikes[feedId] = isLiked + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + } + + override suspend fun deletePendingLike(feedId: Long) { + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + + pendingLikes.remove(feedId) + if (pendingLikes.isEmpty()) { + preferences.remove(PENDING_FEED_LIKES_KEY) + } else { + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + } + } + + override suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean { + var deleted = false + + pendingFeedLikeDataStore.edit { preferences -> + val pendingLikes: MutableMap = decodePendingLikes( + preferences[PENDING_FEED_LIKES_KEY], + ).toMutableMap() + if (pendingLikes[feedId] != isLiked) return@edit + + pendingLikes.remove(feedId) + if (pendingLikes.isEmpty()) { + preferences.remove(PENDING_FEED_LIKES_KEY) + } else { + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + deleted = true + } + + return deleted + } + + private fun decodePendingLikes(jsonString: String?): Map { + if (jsonString == null) return emptyMap() + + return runCatching { + Json + .decodeFromString(jsonString) + .likes + .associate { pendingLike -> pendingLike.feedId to pendingLike.isLiked } + }.getOrDefault(emptyMap()) + } + + private fun encodePendingLikes(pendingLikes: Map): String = + Json.encodeToString( + PendingFeedLikesPreferences( + likes = pendingLikes.entries.map { pendingLike -> + PendingFeedLikePreferences( + feedId = pendingLike.key, + isLiked = pendingLike.value, + ) + }, + ), + ) + + companion object { + private val PENDING_FEED_LIKES_KEY = stringPreferencesKey("PENDING_FEED_LIKES_KEY") + } + } + +@Module +@InstallIn(SingletonComponent::class) +internal interface PendingFeedLikeStoreModule { + @Binds + @Singleton + fun bindPendingFeedLikeStore(defaultPendingFeedLikeStore: DefaultPendingFeedLikeStore): PendingFeedLikeStore +} diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt new file mode 100644 index 000000000..ade4a6ac1 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/feed/model/PendingFeedLikesPreferences.kt @@ -0,0 +1,14 @@ +package com.into.websoso.core.datastore.datasource.feed.model + +import kotlinx.serialization.Serializable + +@Serializable +internal data class PendingFeedLikesPreferences( + val likes: List, +) + +@Serializable +internal data class PendingFeedLikePreferences( + val feedId: Long, + val isLiked: Boolean, +) diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt index 6d14fbfe0..7c533b837 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt @@ -27,6 +27,11 @@ internal object DataStoreModule { name = USER_DATASTORE, ) + private const val PENDING_FEED_LIKE_DATASTORE = "PENDING_FEED_LIKE_DATASTORE" + private val Context.pendingFeedLikeDataStore: DataStore by preferencesDataStore( + name = PENDING_FEED_LIKE_DATASTORE, + ) + @Provides @Singleton @AccountDataStore @@ -47,4 +52,11 @@ internal object DataStoreModule { fun provideUserDataStore( @ApplicationContext context: Context, ): DataStore = context.userDataStore + + @Provides + @Singleton + @PendingFeedLikeDataStore + fun providePendingFeedLikeDataStore( + @ApplicationContext context: Context, + ): DataStore = context.pendingFeedLikeDataStore } diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt index 318c474ea..4369aa82d 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt @@ -13,3 +13,7 @@ internal annotation class LibraryFilterDataStore @Qualifier @Retention(AnnotationRetention.BINARY) internal annotation class UserDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class PendingFeedLikeDataStore 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 dcdfe4953..e9aeeeb35 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 @@ -15,8 +15,11 @@ import com.into.websoso.data.feed.model.CommentsEntity import com.into.websoso.data.feed.model.FeedDetailEntity import com.into.websoso.data.feed.model.FeedEntity import com.into.websoso.data.feed.model.FeedsEntity +import com.into.websoso.data.feed.repository.model.CachedFeedLikeState +import com.into.websoso.data.feed.store.PendingFeedLikeStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -24,6 +27,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -33,6 +38,7 @@ class UpdatedFeedRepository @Inject constructor( private val feedApi: FeedApi, + private val pendingFeedLikeStore: PendingFeedLikeStore, private val multiPartMapper: MultiPartMapper, private val imageDownloader: ImageDownloader, private val imageCompressor: ImageCompressor, @@ -52,8 +58,23 @@ class UpdatedFeedRepository private val _myFeeds = MutableStateFlow>(emptyList()) val myFeeds = _myFeeds.asStateFlow() - private val dirtyFeedStates = ConcurrentHashMap() - private val originalFeedStates = ConcurrentHashMap() + private val pendingLikeStates = ConcurrentHashMap() + private val originalLikeStates = ConcurrentHashMap() + private val feedDetailStates = ConcurrentHashMap() + private val pendingLikeStoreWriteMutex = Mutex() + + init { + restorePendingLikes() + } + + private fun restorePendingLikes() { + scope.launch { + val pendingLikes = pendingFeedLikeStore.getPendingLikes() + pendingLikeStates.clear() + pendingLikeStates.putAll(pendingLikes) + applyPendingLikeStatesToCachedFeeds() + } + } // ============================================================================================ // Feed Creation & Modification @@ -166,7 +187,7 @@ class UpdatedFeedRepository // ============================================================================================ /** - * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태(Dirty)를 병합하여 캐시(Flow)를 갱신합니다. + * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태를 병합하여 캐시(Flow)를 갱신합니다. */ suspend fun fetchFeeds( lastFeedId: Long, @@ -180,7 +201,7 @@ class UpdatedFeedRepository size = size, ).toData() - val mergedFeeds = result.feeds.map { feed -> applyDirtyState(feed) } + val mergedFeeds = result.feeds.map { feed -> applyPendingLikeState(feed) } val isRecommended = feedsOption == "RECOMMENDED" val targetFlow = if (isRecommended) _sosoRecommendedFeeds else _sosoAllFeeds @@ -204,7 +225,7 @@ class UpdatedFeedRepository feeds: List, isRefreshed: Boolean, ) { - val mergedFeeds = feeds.map { feed -> applyDirtyState(feed) } + val mergedFeeds = feeds.map { feed -> applyPendingLikeState(feed) } _myFeeds.update { current -> if (isRefreshed) mergedFeeds else (current + mergedFeeds).distinctBy { it.id } @@ -214,8 +235,9 @@ class UpdatedFeedRepository /** * 서버 데이터보다 로컬의 변경사항(좋아요)을 우선 적용하여 반환합니다. */ - private fun applyDirtyState(feed: FeedEntity): FeedEntity { - val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + private fun applyPendingLikeState(feed: FeedEntity): FeedEntity { + val localIsLiked = pendingLikeStates[feed.id] ?: return feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 @@ -227,6 +249,16 @@ class UpdatedFeedRepository return feed } + private fun applyPendingLikeStatesToCachedFeeds() { + val updateAction: (List) -> List = { list -> + list.map { feed -> applyPendingLikeState(feed) } + } + + _sosoAllFeeds.update(updateAction) + _sosoRecommendedFeeds.update(updateAction) + _myFeeds.update(updateAction) + } + // ============================================================================================ // Interaction (Like, Sync) // ============================================================================================ @@ -238,6 +270,10 @@ class UpdatedFeedRepository updateFeedInFlow(_sosoAllFeeds, feedId) updateFeedInFlow(_sosoRecommendedFeeds, feedId) updateFeedInFlow(_myFeeds, feedId) + + if (findCachedFeed(feedId) == null) { + updateFeedDetailState(feedId) + } } private fun updateFeedInFlow( @@ -252,7 +288,7 @@ class UpdatedFeedRepository val newLiked = !target.isLiked val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 - trackDirtyState(feedId, target.isLiked, newLiked) + trackPendingLikeState(feedId, target.isLiked, newLiked) val newList = list.toMutableList() newList[index] = target.copy(isLiked = newLiked, likeCount = newCount) @@ -260,36 +296,89 @@ class UpdatedFeedRepository } } + private fun updateFeedDetailState(feedId: Long) { + val target = feedDetailStates[feedId] ?: return + val newLiked = !target.isLiked + val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 + + trackPendingLikeState(feedId, target.isLiked, newLiked) + feedDetailStates[feedId] = CachedFeedLikeState( + isLiked = newLiked, + likeCount = newCount.coerceAtLeast(0), + ) + } + + private fun findCachedFeed(feedId: Long): FeedEntity? = + _sosoAllFeeds.value.find { it.id == feedId } + ?: _sosoRecommendedFeeds.value.find { it.id == feedId } + ?: _myFeeds.value.find { it.id == feedId } + /** - * 변경된 좋아요 상태를 추적합니다. + * 서버에 아직 반영되지 않은 마지막 좋아요 상태를 추적합니다. */ - private fun trackDirtyState( + private fun trackPendingLikeState( feedId: Long, original: Boolean, new: Boolean, ) { - originalFeedStates.putIfAbsent(feedId, original) - if (originalFeedStates[feedId] == new) { - dirtyFeedStates.remove(feedId) + originalLikeStates.putIfAbsent(feedId, original) + if (originalLikeStates[feedId] == new) { + pendingLikeStates.remove(feedId) + deletePendingLike(feedId) } else { - dirtyFeedStates[feedId] = new + pendingLikeStates[feedId] = new + updatePendingLike(feedId, new) } } - /** - * 기록된 변경 사항들을 서버와 동기화합니다. - */ - fun syncDirtyFeeds() { - if (dirtyFeedStates.isEmpty()) return + private fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + writePendingLikeStore(feedId, "save") { + pendingFeedLikeStore.updatePendingLike(feedId, isLiked) + } + } - val syncMap = dirtyFeedStates.toMap() - dirtyFeedStates.clear() - originalFeedStates.clear() + private fun deletePendingLike(feedId: Long) { + writePendingLikeStore(feedId, "delete") { + pendingFeedLikeStore.deletePendingLike(feedId) + } + } + + private fun writePendingLikeStore( + feedId: Long, + actionName: String, + action: suspend () -> Unit, + ) { + scope.launch { + pendingLikeStoreWriteMutex.withLock { + runCatching { + action() + }.onFailure { + Log.e( + "UpdatedFeedRepository", + "Failed to $actionName pending feed like $feedId", + it, + ) + } + } + } + } + /** + * 서버에 아직 반영되지 않은 좋아요 상태들을 동기화합니다. + */ + fun syncPendingLikes() { scope.launch { + val syncMap = pendingFeedLikeStore.getPendingLikes() + pendingLikeStates.toMap() + if (syncMap.isEmpty()) return@launch + syncMap.forEach { (id, isLiked) -> runCatching { if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) + }.onSuccess { + handleSyncedPendingLike(id, isLiked) }.onFailure { Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) } @@ -297,6 +386,45 @@ class UpdatedFeedRepository } } + private suspend fun handleSyncedPendingLike( + feedId: Long, + syncedIsLiked: Boolean, + ) { + val currentIsLiked = findCurrentLikeState(feedId) + if (currentIsLiked != null && currentIsLiked != syncedIsLiked) { + originalLikeStates[feedId] = syncedIsLiked + pendingLikeStates[feedId] = currentIsLiked + updatePendingLike(feedId, currentIsLiked) + return + } + + deleteSyncedPendingLikeFromStore(feedId, syncedIsLiked) + deleteSyncedPendingLikeFromMemory(feedId, syncedIsLiked) + } + + private fun findCurrentLikeState(feedId: Long): Boolean? = findCachedFeed(feedId)?.isLiked ?: feedDetailStates[feedId]?.isLiked + + private fun deleteSyncedPendingLikeFromMemory( + feedId: Long, + isLiked: Boolean, + ) { + pendingLikeStates.remove(feedId, isLiked) + if (!pendingLikeStates.containsKey(feedId)) { + originalLikeStates.remove(feedId) + } + } + + private suspend fun deleteSyncedPendingLikeFromStore( + feedId: Long, + isLiked: Boolean, + ) { + runCatching { + pendingFeedLikeStore.deletePendingLikeIfMatched(feedId, isLiked) + }.onFailure { + Log.e("UpdatedFeedRepository", "Failed to delete synced feed like $feedId", it) + } + } + // ============================================================================================ // Feed Actions (Remove, Report) // ============================================================================================ @@ -365,11 +493,17 @@ class UpdatedFeedRepository */ suspend fun fetchFeed(feedId: Long): FeedDetailEntity { val rawDetail = feedApi.getFeed(feedId).toData() - return applyDirtyStateToDetail(rawDetail) + val mergedDetail = applyPendingLikeStateToDetail(rawDetail) + feedDetailStates[feedId] = CachedFeedLikeState( + isLiked = mergedDetail.isLiked, + likeCount = mergedDetail.likeCount, + ) + return mergedDetail } - private fun applyDirtyStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { - val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + private fun applyPendingLikeStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { + val localIsLiked = pendingLikeStates[feed.id] ?: return feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) if (feed.isLiked != localIsLiked) { val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt new file mode 100644 index 000000000..5864dd34e --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/CachedFeedLikeState.kt @@ -0,0 +1,6 @@ +package com.into.websoso.data.feed.repository.model + +internal data class CachedFeedLikeState( + val isLiked: Boolean, + val likeCount: Int, +) 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 new file mode 100644 index 000000000..7bc64175a --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -0,0 +1,26 @@ +package com.into.websoso.data.feed.store + +import kotlinx.coroutines.flow.Flow + +interface PendingFeedLikeStore { + val pendingLikes: Flow> + + suspend fun getPendingLikes(): Map + + suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) + + suspend fun deletePendingLike(feedId: Long) + + /** + * 저장된 좋아요 상태가 [isLiked]와 일치하는 경우에만 삭제합니다. + * + * `@return` 매칭되어 삭제된 경우 true, 일치하지 않아 삭제하지 않은 경우 false + * */ + suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean +} diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt index 00fcaeaa2..56a0add78 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt @@ -202,7 +202,7 @@ class FeedViewModel override fun onCleared() { super.onCleared() - feedRepository.syncDirtyFeeds() + feedRepository.syncPendingLikes() } // --- 기타 탭/필터 로직 ---