diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt index a44b1f11c..19922ff7a 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt @@ -1,6 +1,5 @@ package com.into.websoso.ui.feedDetail -import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.os.Bundle @@ -10,16 +9,16 @@ import android.view.LayoutInflater import android.view.View import android.view.WindowManager.LayoutParams.WRAP_CONTENT import android.widget.PopupWindow -import android.widget.TextView import androidx.activity.addCallback import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.databinding.ViewDataBinding -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.load import coil.transform.RoundedCornersTransformation -import com.into.websoso.R.id.tv_feed_thumb_up_count import com.into.websoso.R.layout.activity_feed_detail import com.into.websoso.core.common.ui.base.BaseActivity import com.into.websoso.core.common.ui.model.ResultFrom.BlockUser @@ -30,12 +29,12 @@ import com.into.websoso.core.common.ui.model.ResultFrom.FeedDetailRefreshed import com.into.websoso.core.common.ui.model.ResultFrom.NovelDetailBack import com.into.websoso.core.common.ui.model.ResultFrom.OtherUserProfileBack import com.into.websoso.core.common.ui.model.ResultFrom.WithdrawUser -import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.getS3ImageUrl import com.into.websoso.core.common.util.hideKeyboard import com.into.websoso.core.common.util.showWebsosoSnackBar import com.into.websoso.core.common.util.toFloatPxFromDp import com.into.websoso.core.common.util.tracker.Tracker +import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.resource.R.drawable.ic_blocked_user_snack_bar import com.into.websoso.core.resource.R.drawable.ic_novel_detail_check import com.into.websoso.core.resource.R.string.block_user_success_message @@ -44,10 +43,12 @@ import com.into.websoso.core.resource.R.string.feed_popup_menu_content_report_is import com.into.websoso.core.resource.R.string.feed_removed_feed_snackbar import com.into.websoso.core.resource.R.string.feed_server_error import com.into.websoso.core.resource.R.string.other_user_page_withdraw_user +import com.into.websoso.data.feed.repository.model.LikeSyncStatus import com.into.websoso.databinding.ActivityFeedDetailBinding import com.into.websoso.databinding.DialogRemovePopupMenuBinding import com.into.websoso.databinding.DialogReportPopupMenuBinding import com.into.websoso.databinding.MenuFeedPopupBinding +import com.into.websoso.feature.feed.component.FeedLikeSyncNotice import com.into.websoso.ui.createFeed.CreateFeedActivity import com.into.websoso.ui.expandedFeedImage.ExpandedFeedImageActivity import com.into.websoso.ui.feedDetail.FeedDetailActivity.MenuType.COMMENT @@ -88,7 +89,6 @@ class FeedDetailActivity : BaseActivity(activity_feed onCommentClick(), ) } - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } private lateinit var activityResultCallback: ActivityResultLauncher private val popupBinding: MenuFeedPopupBinding by lazy { MenuFeedPopupBinding.inflate(LayoutInflater.from(this)) @@ -99,30 +99,12 @@ class FeedDetailActivity : BaseActivity(activity_feed private fun onFeedContentClick(): FeedDetailClickListener = object : FeedDetailClickListener { - @SuppressLint("CutPasteId") override fun onLikeButtonClick( view: View, feedId: Long, ) { - val likeCount: Int = - view - .findViewById(tv_feed_thumb_up_count) - .text - .toString() - .toInt() - val updatedLikeCount: Int = when (view.isSelected) { - true -> if (likeCount > 0) likeCount - 1 else 0 - false -> likeCount + 1 - } - - view.findViewById(tv_feed_thumb_up_count).text = - updatedLikeCount.toString() - view.isSelected = !view.isSelected - - singleEventHandler.debounce(timeMillis = 100L, coroutineScope = lifecycleScope) { - tracker.trackEvent("feed_detail_like") - feedDetailViewModel.updateLike() - } + tracker.trackEvent("feed_detail_like") + feedDetailViewModel.updateLike() } override fun onNovelInfoClick(novelId: Long) { @@ -357,6 +339,21 @@ class FeedDetailActivity : BaseActivity(activity_feed super.onCreate(savedInstanceState) setupView() + binding.cvFeedLikeSync.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val states by feedDetailViewModel.likeSyncStates.collectAsStateWithLifecycle() + val isRestoreFailed by feedDetailViewModel.isLikeRestoreFailed.collectAsStateWithLifecycle() + WebsosoTheme { + FeedLikeSyncNotice( + isRestoreFailed = isRestoreFailed, + needsRetry = states[feedId] == LikeSyncStatus.NEEDS_RETRY, + isSyncing = states[feedId] == LikeSyncStatus.SYNCING, + onRetry = feedDetailViewModel::retryLike, + ) + } + } + } setupObserver() onFeedDetailClick() refreshView() @@ -371,7 +368,7 @@ class FeedDetailActivity : BaseActivity(activity_feed NovelDetailBack.RESULT_OK, CreateFeed.RESULT_OK, OtherUserProfileBack.RESULT_OK, - -> { + -> { feedDetailViewModel.updateFeedDetail(feedId, CreateFeed) } 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..56cd763c9 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 @@ -38,6 +38,12 @@ class UpdatedFeedDetailViewModel private val _feedDetailUiState: MutableLiveData = MutableLiveData(FeedDetailUiState()) val feedDetailUiState: LiveData get() = _feedDetailUiState + val likeSyncStates = feedRepository.likeSyncStates + val isLikeRestoreFailed = feedRepository.isLikeRestoreFailed + + fun retryLike() { + if (feedId != -1L) feedRepository.retryPendingLikes(setOf(feedId)) + } fun updateCommentId(newCommentId: Long) { this.commentId = newCommentId @@ -75,19 +81,25 @@ class UpdatedFeedDetailViewModel feedRepository.sosoAllFeeds, feedRepository.sosoRecommendedFeeds, feedRepository.myFeeds, - ) { sosoAllFeeds, sosoRecommendedFeeds, myFeeds -> - sosoAllFeeds.find { it.id == targetFeedId } + feedRepository.feedDetailLikeStates, + ) { sosoAllFeeds, sosoRecommendedFeeds, myFeeds, detailLikes -> + val feed = sosoAllFeeds.find { it.id == targetFeedId } ?: sosoRecommendedFeeds.find { it.id == targetFeedId } ?: myFeeds.find { it.id == targetFeedId } + feed to detailLikes[targetFeedId] }.distinctUntilChanged() - .collect { feedEntity -> - if (feedEntity != null) { - val currentUiState = _feedDetailUiState.value ?: FeedDetailUiState() + .collect { (feedEntity, likeState) -> + val currentUiState = _feedDetailUiState.value ?: FeedDetailUiState() + val feed = feedEntity?.toFeedModel() ?: currentUiState.feedDetail.feed + if (feed != null) { _feedDetailUiState.value = currentUiState.copy( loading = false, isRefreshed = true, feedDetail = currentUiState.feedDetail.copy( - feed = feedEntity.toFeedModel(), + feed = feed.copy( + isLiked = likeState?.isLiked ?: feed.isLiked, + likeCount = likeState?.likeCount ?: feed.likeCount, + ), novel = currentUiState.feedDetail.novel, ), ) @@ -145,7 +157,7 @@ class UpdatedFeedDetailViewModel */ override fun onCleared() { super.onCleared() - feedRepository.syncDirtyFeeds() + feedRepository.syncPendingLikes() } /** diff --git a/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt b/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt index 4793349b0..c7b9b8702 100644 --- a/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt @@ -11,8 +11,12 @@ import androidx.fragment.app.viewModels import com.into.websoso.R import com.into.websoso.R.layout.fragment_feed import com.into.websoso.core.common.ui.base.BaseFragment +import com.into.websoso.core.common.util.collectWithLifecycle +import com.into.websoso.core.common.util.showWebsosoToast import com.into.websoso.core.common.util.tracker.Tracker import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R.drawable.ic_novel_rating_alert +import com.into.websoso.core.resource.R.string.feed_like_unrestored_message import com.into.websoso.databinding.DialogRemovePopupMenuBinding import com.into.websoso.databinding.DialogReportPopupMenuBinding import com.into.websoso.databinding.FragmentFeedBinding @@ -189,6 +193,16 @@ class FeedFragment : BaseFragment(fragment_feed) { super.onViewCreated(view, savedInstanceState) tracker.trackEvent("feed_all") + collectUnrestoredLikes() + } + + private fun collectUnrestoredLikes() { + feedViewModel.hasUnrestoredLikes.collectWithLifecycle(viewLifecycleOwner) { hasUnrestoredLikes -> + if (hasUnrestoredLikes) { + showWebsosoToast(requireContext(), getString(feed_like_unrestored_message), ic_novel_rating_alert) + feedViewModel.acknowledgeUnrestoredLikes() + } + } } companion object { diff --git a/app/src/main/res/layout/activity_feed_detail.xml b/app/src/main/res/layout/activity_feed_detail.xml index 785256a69..3ac6a71f7 100644 --- a/app/src/main/res/layout/activity_feed_detail.xml +++ b/app/src/main/res/layout/activity_feed_detail.xml @@ -56,6 +56,14 @@ + + + app:layout_constraintTop_toBottomOf="@id/cv_feed_like_sync" /> , + @param:Dispatcher(WebsosoDispatchers.DEFAULT) private val dispatcher: CoroutineDispatcher, + ) : PendingFeedLikeStore { + override val pendingLikes: Flow> + get() = pendingFeedLikeDataStore.data + .map { preferences -> + withContext(dispatcher) { + preferences.readPendingLikes() + } + }.distinctUntilChanged() + + override suspend fun getPendingLikes(): Map = pendingLikes.first() + + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + pendingFeedLikeDataStore.edit { preferences -> + preferences.setAsideUnreadableLikes() + val pendingLikes: MutableMap = preferences.readPendingLikes().toMutableMap() + pendingLikes[feedId] = isLiked + preferences[PENDING_FEED_LIKES_KEY] = encodePendingLikes(pendingLikes) + } + } + + override suspend fun deletePendingLike(feedId: Long) { + pendingFeedLikeDataStore.edit { preferences -> + preferences.setAsideUnreadableLikes() + val pendingLikes: MutableMap = preferences.readPendingLikes().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 -> + preferences.setAsideUnreadableLikes() + val pendingLikes: MutableMap = preferences.readPendingLikes().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 + } + + override suspend fun readResetNotice(): Boolean { + var hasNotice = false + pendingFeedLikeDataStore.edit { preferences -> + preferences.setAsideUnreadableLikes() + hasNotice = preferences[PENDING_FEED_LIKES_RESET_KEY] == true + } + return hasNotice + } + + override suspend fun clearResetNotice() { + pendingFeedLikeDataStore.edit { preferences -> + preferences.remove(PENDING_FEED_LIKES_RESET_KEY) + } + } + + /** 해석할 수 없는 기록은 지우지 않고 보관함으로 옮긴 뒤, 복원하지 못했다는 표시를 남깁니다. */ + private fun MutablePreferences.setAsideUnreadableLikes() { + val jsonString = this[PENDING_FEED_LIKES_KEY] ?: return + if (decodePendingLikes(jsonString) != null) return + this[PENDING_FEED_LIKES_UNREADABLE_KEY] = this[PENDING_FEED_LIKES_UNREADABLE_KEY].orEmpty() + jsonString + remove(PENDING_FEED_LIKES_KEY) + this[PENDING_FEED_LIKES_RESET_KEY] = true + } + + private fun Preferences.readPendingLikes(): Map = + this[PENDING_FEED_LIKES_KEY]?.let { jsonString -> decodePendingLikes(jsonString) }.orEmpty() + + /** 해석하지 못하면 null을 반환해, 저장된 기록이 없는 경우와 구분합니다. */ + private fun decodePendingLikes(jsonString: String): Map? = + runCatching { + Json + .decodeFromString(jsonString) + .likes + .associate { pendingLike -> pendingLike.feedId to pendingLike.isLiked } + }.getOrNull() + + 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") + private val PENDING_FEED_LIKES_RESET_KEY = booleanPreferencesKey("PENDING_FEED_LIKES_RESET_KEY") + + // 형식 변경이나 해석 코드 문제를 고친 뒤 되살릴 수 있도록, 해석하지 못한 원본을 모아 둡니다. + private val PENDING_FEED_LIKES_UNREADABLE_KEY = stringSetPreferencesKey("PENDING_FEED_LIKES_UNREADABLE_KEY") + + /** 손상된 파일은 빈 기록으로 바꾸고, 초기화했다는 표시를 남깁니다. */ + internal val corruptionHandler = ReplaceFileCorruptionHandler { + preferencesOf(PENDING_FEED_LIKES_RESET_KEY to true) + } + } + } + +@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..d173fdd45 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 @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import com.into.websoso.core.datastore.datasource.feed.DefaultPendingFeedLikeStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,6 +28,12 @@ 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, + corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler, + ) + @Provides @Singleton @AccountDataStore @@ -47,4 +54,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/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 new file mode 100644 index 000000000..32db1a770 --- /dev/null +++ b/core/datastore/src/test/java/com/into/websoso/core/datastore/datasource/feed/DefaultPendingFeedLikeStoreTest.kt @@ -0,0 +1,93 @@ +package com.into.websoso.core.datastore.datasource.feed + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultPendingFeedLikeStoreTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `손상된 파일은 빈 기록으로 초기화하고 지울 때까지 초기화 표시를 남긴다`() = + runTest { + val file = temporaryFolder.newFile(FILE_NAME) + // 길이 127짜리 값이 온다고 적혀 있지만 내용이 없어 해석할 수 없는 파일 + file.writeBytes(byteArrayOf(0x0A, 0x7F)) + val store = createStore(createDataStore(file)) + + assertTrue(store.getPendingLikes().isEmpty()) + assertTrue(store.readResetNotice()) + assertTrue(store.readResetNotice()) + store.clearResetNotice() + assertFalse(store.readResetNotice()) + + store.updatePendingLike(feedId = 1L, isLiked = true) + assertEquals(mapOf(1L to true), store.getPendingLikes()) + } + + @Test + fun `해석할 수 없는 기록은 덮어쓰지 않고 보관한 뒤 새 기록을 저장한다`() = + runTest { + val dataStore = createDataStore(temporaryFolder.newFile(FILE_NAME)) + dataStore.edit { it[PENDING_FEED_LIKES_KEY] = UNREADABLE_JSON } + val store = createStore(dataStore) + + store.updatePendingLike(feedId = 1L, isLiked = true) + + assertEquals(mapOf(1L to true), store.getPendingLikes()) + assertEquals(setOf(UNREADABLE_JSON), dataStore.data.first()[PENDING_FEED_LIKES_UNREADABLE_KEY]) + assertTrue(store.readResetNotice()) + } + + @Test + fun `해석할 수 없는 기록은 복원 시 빈 기록으로 보고 보관한 뒤 지울 때까지 초기화 표시를 남긴다`() = + runTest { + val dataStore = createDataStore(temporaryFolder.newFile(FILE_NAME)) + dataStore.edit { it[PENDING_FEED_LIKES_KEY] = UNREADABLE_JSON } + val store = createStore(dataStore) + + assertTrue(store.getPendingLikes().isEmpty()) + assertTrue(store.readResetNotice()) + store.clearResetNotice() + assertFalse(store.readResetNotice()) + assertEquals(setOf(UNREADABLE_JSON), dataStore.data.first()[PENDING_FEED_LIKES_UNREADABLE_KEY]) + } + + private fun TestScope.createDataStore(file: File): DataStore = + PreferenceDataStoreFactory.create( + corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler, + scope = backgroundScope, + produceFile = { file }, + ) + + private fun TestScope.createStore(dataStore: DataStore): DefaultPendingFeedLikeStore = + DefaultPendingFeedLikeStore(dataStore, StandardTestDispatcher(testScheduler)) + + companion object { + private const val FILE_NAME = "pending_feed_like.preferences_pb" + + // 저장 형식이 바뀐 경우처럼, JSON이지만 지금 코드가 해석할 수 없는 값 + private const val UNREADABLE_JSON = """{"likes":"not a list"}""" + + // 저장소 내부 키와 같은 이름으로, 파일에 실제로 무엇이 남는지 확인한다. + private val PENDING_FEED_LIKES_KEY = stringPreferencesKey("PENDING_FEED_LIKES_KEY") + private val PENDING_FEED_LIKES_UNREADABLE_KEY = stringSetPreferencesKey("PENDING_FEED_LIKES_UNREADABLE_KEY") + } +} diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index a18bd759d..81887a439 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -471,6 +471,11 @@ 서재 + 반영을 확인하지 못한 좋아요가 있어요. + 확인 필요 + 다시 시도 + 좋아요를 반영하는 데 문제가 생겼어요. + 좋아요 일부가 반영되지 않았을 수 있어요. 필터 제거 diff --git a/data/feed/build.gradle.kts b/data/feed/build.gradle.kts index 87a5b0557..04442a813 100644 --- a/data/feed/build.gradle.kts +++ b/data/feed/build.gradle.kts @@ -6,6 +6,10 @@ plugins { android { setNamespace("data.feed") + + testOptions { + unitTests.isReturnDefaultValues = true + } } dependencies { @@ -17,4 +21,6 @@ dependencies { // TODO: 멀티 파트 빌드 오류를 해결하기 위한 임시 조치 -> 추후 네트워크 레이어로 마이그레이션 implementation(libs.okhttp) + + testImplementation(libs.junit) } 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..1188fc54b 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,6 +15,10 @@ 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.repository.model.LikeSyncStatus +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -24,6 +28,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 +39,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 +59,104 @@ 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 _feedDetailLikeStates = MutableStateFlow>(emptyMap()) + val feedDetailLikeStates = _feedDetailLikeStates.asStateFlow() + private val pendingLikeStoreWriteMutex = Mutex() + private val likeStateLock = Any() + + // 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() + private var isLikesRestored = false + private val _isLikeRestoreFailed = MutableStateFlow(false) + val isLikeRestoreFailed = _isLikeRestoreFailed.asStateFlow() + private val _hasUnrestoredLikes = MutableStateFlow(false) + val hasUnrestoredLikes = _hasUnrestoredLikes.asStateFlow() + private val unconfirmedLikeIds = mutableSetOf() + private val likeRevisions = mutableMapOf() + + // A feed's last like change by user input or server confirmation, with the like value at that point. + // Fetched data requested before this change must not overwrite it. + private val lastLikeChanges = mutableMapOf() + private val completedLikeAttempts = mutableMapOf>() + private var likeEventSequence = 0L + private val _likeSyncStates = MutableStateFlow>(emptyMap()) + val likeSyncStates = _likeSyncStates.asStateFlow() + + init { + scope.launch { ensureLikesRestored() } + } + + /** + * 저장된 좋아요를 아직 불러오지 못했다면 다시 불러옵니다. + * 실패해도 예외를 던지지 않고 false를 반환해, 호출한 쪽이 저장·전송을 보류하게 합니다. + */ + private suspend fun ensureLikesRestored(): Boolean { + var recovered = false + val restored = restoreMutex.withLock { + if (isLikesRestored) return@withLock true + try { + restorePendingLikes() + isLikesRestored = true + // An earlier attempt failed, so selections made meanwhile may be held only in memory. + recovered = _isLikeRestoreFailed.value + _isLikeRestoreFailed.value = false + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + _isLikeRestoreFailed.value = true + Log.e("UpdatedFeedRepository", "Failed to restore pending feed likes", error) + } + isLikesRestored + } + if (recovered) persistHeldLikes() + return restored + } + + /** 복원하지 못한 좋아요 안내를 보여 준 뒤 호출해, 같은 안내가 반복되지 않게 합니다. */ + fun acknowledgeUnrestoredLikes() { + _hasUnrestoredLikes.value = false + scope.launch { + try { + pendingFeedLikeStore.clearResetNotice() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + // If the notice stays, the user is told once more on the next launch. + Log.e("UpdatedFeedRepository", "Failed to clear the unrestored likes notice", error) + } + } + } + + /** 복원에 실패한 동안 메모리에만 남은 선택을, 복원에 성공한 뒤 저장소에 저장합니다. */ + private suspend fun persistHeldLikes() { + val feedIds = synchronized(likeStateLock) { pendingLikeStates.keys.toList() } + feedIds.forEach { feedId -> persistPendingLikeOrMarkRetry(feedId) } + } + + private suspend fun restorePendingLikes() { + val pendingLikes = 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) { + pendingLikes.forEach { (id, isLiked) -> + // A click made while storage was loading takes precedence, including a cancelled selection. + pendingLikeStates[id] = if (id in likeRevisions) { + findCurrentLikeState(id) ?: pendingLikeStates[id] ?: isLiked + } else { + isLiked + } + unconfirmedLikeIds += id + setLikeSyncStatus(id, LikeSyncStatus.NEEDS_RETRY) + } + applyPendingLikeStatesToCachedFeeds() + } + } // ============================================================================================ // Feed Creation & Modification @@ -166,13 +269,14 @@ class UpdatedFeedRepository // ============================================================================================ /** - * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태(Dirty)를 병합하여 캐시(Flow)를 갱신합니다. + * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태를 병합하여 캐시(Flow)를 갱신합니다. */ suspend fun fetchFeeds( lastFeedId: Long, size: Int, feedsOption: String, ): FeedsEntity { + val version = likeStateVersion() val result = feedApi .getFeeds( feedsOption = feedsOption, @@ -180,51 +284,91 @@ class UpdatedFeedRepository size = size, ).toData() - val mergedFeeds = result.feeds.map { feed -> applyDirtyState(feed) } + return synchronized(likeStateLock) { + val mergedFeeds = result.feeds.map { feed -> applyLatestLikeState(feed, version) } + updateCachedLikes(mergedFeeds.associate { it.id to CachedFeedLikeState(it.isLiked, it.likeCount) }) - val isRecommended = feedsOption == "RECOMMENDED" - val targetFlow = if (isRecommended) _sosoRecommendedFeeds else _sosoAllFeeds + val isRecommended = feedsOption == "RECOMMENDED" + val targetFlow = if (isRecommended) _sosoRecommendedFeeds else _sosoAllFeeds - targetFlow.update { currentList -> - if (lastFeedId == 0L) { - mergedFeeds - } else { - val newFeeds = mergedFeeds.filterNot { new -> currentList.any { it.id == new.id } } - currentList + newFeeds + targetFlow.update { currentList -> + if (lastFeedId == 0L) { + mergedFeeds + } else { + val newFeeds = mergedFeeds.filterNot { new -> currentList.any { it.id == new.id } } + currentList + newFeeds + } } - } - return result.copy(feeds = targetFlow.value) + result.copy(feeds = targetFlow.value) + } } /** * 외부에서 가져온 내 피드 데이터를 캐시에 주입합니다. + * likeStateVersion에는 조회를 시작하기 직전에 받아 둔 좋아요 상태 번호를 넘깁니다. */ fun updateMyFeedsCache( feeds: List, isRefreshed: Boolean, + likeStateVersion: Long, ) { - val mergedFeeds = feeds.map { feed -> applyDirtyState(feed) } + synchronized(likeStateLock) { + val mergedFeeds = feeds.map { feed -> applyLatestLikeState(feed, likeStateVersion) } + updateCachedLikes(mergedFeeds.associate { it.id to CachedFeedLikeState(it.isLiked, it.likeCount) }) - _myFeeds.update { current -> - if (isRefreshed) mergedFeeds else (current + mergedFeeds).distinctBy { it.id } + _myFeeds.update { current -> + if (isRefreshed) mergedFeeds else (current + mergedFeeds).distinctBy { it.id } + } } } /** * 서버 데이터보다 로컬의 변경사항(좋아요)을 우선 적용하여 반환합니다. */ - private fun applyDirtyState(feed: FeedEntity): FeedEntity { - val localIsLiked = dirtyFeedStates[feed.id] ?: return feed - - if (feed.isLiked != localIsLiked) { - val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 - return feed.copy( + private fun applyPendingLikeState(feed: FeedEntity): FeedEntity = + synchronized(likeStateLock) { + val localIsLiked = pendingLikeStates[feed.id] ?: return@synchronized feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) + if (feed.isLiked == localIsLiked) return@synchronized feed + feed.copy( isLiked = localIsLiked, - likeCount = adjustedCount.coerceAtLeast(0), + likeCount = (feed.likeCount + if (localIsLiked) 1 else -1).coerceAtLeast(0), ) } - return feed + + private fun updateCachedLikes(likes: Map) { + listOf(_sosoAllFeeds, _sosoRecommendedFeeds, _myFeeds).forEach { flow -> + flow.update { feeds -> + feeds.map { feed -> + val state = likes[feed.id] + if (state == null) feed else feed.copy(isLiked = state.isLiked, likeCount = state.likeCount) + } + } + } + _feedDetailLikeStates.update { states -> + states.mapValues { (id, state) -> likes[id] ?: state } + } + } + + private fun applyPendingLikeStatesToCachedFeeds() { + val updateAction: (List) -> List = { list -> + list.map { feed -> applyPendingLikeState(feed) } + } + + _sosoAllFeeds.update(updateAction) + _sosoRecommendedFeeds.update(updateAction) + _myFeeds.update(updateAction) + _feedDetailLikeStates.update { states -> + states.mapValues { (id, state) -> + val desired = pendingLikeStates[id] + if (desired == null || desired == state.isLiked) { + state + } else { + state.copy(isLiked = desired, likeCount = (state.likeCount + if (desired) 1 else -1).coerceAtLeast(0)) + } + } + } } // ============================================================================================ @@ -235,68 +379,257 @@ class UpdatedFeedRepository * 로컬 캐시의 좋아요 상태를 즉시 토글하고 변경 내역을 기록합니다. */ fun toggleLikeLocal(feedId: Long) { - updateFeedInFlow(_sosoAllFeeds, feedId) - updateFeedInFlow(_sosoRecommendedFeeds, feedId) - updateFeedInFlow(_myFeeds, feedId) - } - - private fun updateFeedInFlow( - flow: MutableStateFlow>, - feedId: Long, - ) { - flow.update { list -> - val index = list.indexOfFirst { it.id == feedId } - if (index == -1) return@update list - - val target = list[index] - val newLiked = !target.isLiked - val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 - - trackDirtyState(feedId, target.isLiked, newLiked) - - val newList = list.toMutableList() - newList[index] = target.copy(isLiked = newLiked, likeCount = newCount) - newList + synchronized(likeStateLock) { + val current = findCurrentLikeState(feedId) ?: return + val newLiked = !current + // Record the user's input once, even when the same feed is cached in several lists. + val sequence = ++likeEventSequence + likeRevisions[feedId] = sequence + lastLikeChanges[feedId] = LikeChange(sequence, newLiked) + trackPendingLikeState(feedId, current, newLiked) + val update: (List) -> List = { feeds -> + feeds.map { feed -> + if (feed.id != feedId || feed.isLiked == newLiked) { + feed + } else { + feed.copy( + isLiked = newLiked, + likeCount = (feed.likeCount + if (newLiked) 1 else -1).coerceAtLeast(0), + ) + } + } + } + _sosoAllFeeds.update(update) + _sosoRecommendedFeeds.update(update) + _myFeeds.update(update) + _feedDetailLikeStates.value[feedId]?.let { detail -> + _feedDetailLikeStates.update { states -> + states + ( + feedId to detail.copy( + isLiked = newLiked, + likeCount = ( + detail.likeCount + if (detail.isLiked == newLiked) { + 0 + } else if (newLiked) { + 1 + } else { + -1 + } + ).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 (feedId !in unconfirmedLikeIds && originalLikeStates[feedId] == new) { + pendingLikeStates.remove(feedId) + originalLikeStates.remove(feedId) + setLikeSyncStatus(feedId, null) } else { - dirtyFeedStates[feedId] = new + pendingLikeStates[feedId] = new + } + scope.launch { + // Restore failed: keep the selection in memory and hold the write. + if (!ensureLikesRestored()) return@launch + persistPendingLikeOrMarkRetry(feedId) } } - /** - * 기록된 변경 사항들을 서버와 동기화합니다. - */ - fun syncDirtyFeeds() { - if (dirtyFeedStates.isEmpty()) return + private suspend fun persistPendingLikeOrMarkRetry(feedId: Long) { + try { + persistPendingLike(feedId) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + synchronized(likeStateLock) { + if (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) { + pendingLikeStoreWriteMutex.withLock { + val latest = synchronized(likeStateLock) { pendingLikeStates[feedId] } + if (latest == null) { + pendingFeedLikeStore.deletePendingLike(feedId) + } else { + pendingFeedLikeStore.updatePendingLike(feedId, latest) + } + } + } + + /** Screen disposal sends all remaining selections, including earlier failures. */ + fun syncPendingLikes() = syncPendingLikes(null) - val syncMap = dirtyFeedStates.toMap() - dirtyFeedStates.clear() - originalFeedStates.clear() + /** A manual retry targets only unresolved items, using their latest selection. */ + fun retryPendingLikes(feedIds: Set? = null) { + if (_isLikeRestoreFailed.value) { + // Restore failed earlier: restore first, then retry including the restored items. + scope.launch { if (ensureLikesRestored()) retryPendingLikes(feedIds) } + return + } + val targets = synchronized(likeStateLock) { + _likeSyncStates.value + .filter { (id, status) -> status == LikeSyncStatus.NEEDS_RETRY && (feedIds == null || id in feedIds) } + .keys + .toSet() + } + if (targets.isNotEmpty()) syncPendingLikes(targets) + } + private fun syncPendingLikes(feedIds: Set?) { + val requestedAt = synchronized(likeStateLock) { ++likeEventSequence } scope.launch { - syncMap.forEach { (id, isLiked) -> - runCatching { - if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) - }.onFailure { - Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) + // Restore failed: hold sending until a later sync or retry restores the saved likes. + if (!ensureLikesRestored()) return@launch + likeSyncMutex.withLock { + val targets = synchronized(likeStateLock) { + pendingLikeStates.keys.filter { feedIds == null || it in feedIds } } + targets.forEach { id -> syncPendingLike(id, requestedAt) } } } } + private suspend fun syncPendingLike( + feedId: Long, + requestedAt: Long, + ) { + val selection = synchronized(likeStateLock) { + val desired = pendingLikeStates[feedId] ?: return + val revision = likeRevisions[feedId] ?: 0L + val completed = completedLikeAttempts[feedId] + // Coalesce triggers that overlapped the same attempt, including a failed attempt. + if (completed != null && completed.first == revision && completed.second > requestedAt) return + val needsNetwork = feedId in unconfirmedLikeIds || originalLikeStates[feedId] != desired + if (needsNetwork) unconfirmedLikeIds += feedId + setLikeSyncStatus(feedId, LikeSyncStatus.SYNCING) + LikeSyncRequest(desired, revision, needsNetwork) + } + var serverConfirmed = !selection.needsNetwork + try { + persistPendingLike(feedId) + if (selection.needsNetwork) { + if (selection.isLiked) feedApi.postLikes(feedId) else feedApi.deleteLikes(feedId) + serverConfirmed = true + } + synchronized(likeStateLock) { + originalLikeStates[feedId] = selection.isLiked + unconfirmedLikeIds.remove(feedId) + if (pendingLikeStates[feedId] == selection.isLiked) { + pendingLikeStates.remove(feedId) + } + // Mark the server change in the same lock as the pending cleanup so an older fetch cannot slip in. + lastLikeChanges[feedId] = LikeChange(++likeEventSequence, pendingLikeStates[feedId] ?: selection.isLiked) + // 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) + synchronized(likeStateLock) { + if (!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 (error is CancellationException) throw error + Log.e("UpdatedFeedRepository", "Failed to sync feed $feedId", error) + } finally { + synchronized(likeStateLock) { + completedLikeAttempts[feedId] = selection.revision to ++likeEventSequence + } + } + } + + private fun setLikeSyncStatus( + feedId: Long, + status: LikeSyncStatus?, + ) { + _likeSyncStates.update { states -> + if (status == null) states - feedId else states + (feedId to status) + } + } + + private data class LikeSyncRequest( + val isLiked: Boolean, + val revision: Long, + val needsNetwork: Boolean, + ) + + private data class LikeChange( + val sequence: Long, + val isLiked: Boolean, + ) + + private fun findCurrentLikeState(feedId: Long): Boolean? = + findCachedFeed(feedId)?.isLiked ?: _feedDetailLikeStates.value[feedId]?.isLiked + + /** 조회를 시작하기 직전에 받아 두는 번호입니다. 응답을 반영할 때 그 뒤에 좋아요가 바뀌었는지 비교합니다. */ + fun likeStateVersion(): Long = synchronized(likeStateLock) { likeEventSequence } + + private fun likeChangeSince( + feedId: Long, + version: Long, + ): LikeChange? = lastLikeChanges[feedId]?.takeIf { it.sequence > version } + + private fun findCurrentLike(feedId: Long): CachedFeedLikeState? = + findCachedFeed(feedId)?.let { CachedFeedLikeState(it.isLiked, it.likeCount) } ?: _feedDetailLikeStates.value[feedId] + + private fun adjustedLikeCount( + likeCount: Int, + isLiked: Boolean, + newLiked: Boolean, + ): Int = if (isLiked == newLiked) likeCount else (likeCount + if (newLiked) 1 else -1).coerceAtLeast(0) + + private fun applyLatestLikeState( + feed: FeedEntity, + version: Long, + ): FeedEntity { + 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) } + // Not on screen: use the last known like instead of the older response. + return feed.copy( + isLiked = change.isLiked, + likeCount = adjustedLikeCount(feed.likeCount, feed.isLiked, change.isLiked), + ) + } + + private fun applyLatestLikeStateToDetail( + feed: FeedDetailEntity, + version: Long, + ): FeedDetailEntity { + 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) } + // Not on screen: use the last known like instead of the older response. + return feed.copy( + isLiked = change.isLiked, + likeCount = adjustedLikeCount(feed.likeCount, feed.isLiked, change.isLiked), + ) + } + // ============================================================================================ // Feed Actions (Remove, Report) // ============================================================================================ @@ -364,22 +697,31 @@ class UpdatedFeedRepository * 피드 상세 정보를 조회하고 로컬 상태를 병합하여 반환합니다. */ suspend fun fetchFeed(feedId: Long): FeedDetailEntity { + val version = likeStateVersion() val rawDetail = feedApi.getFeed(feedId).toData() - return applyDirtyStateToDetail(rawDetail) + return synchronized(likeStateLock) { + val mergedDetail = applyLatestLikeStateToDetail(rawDetail, version) + val likeState = CachedFeedLikeState(mergedDetail.isLiked, mergedDetail.likeCount) + _feedDetailLikeStates.update { it + (feedId to likeState) } + updateCachedLikes(mapOf(feedId to likeState)) + mergedDetail + } } - private fun applyDirtyStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { - val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + private fun applyPendingLikeStateToDetail(feed: FeedDetailEntity): FeedDetailEntity = + synchronized(likeStateLock) { + val localIsLiked = pendingLikeStates[feed.id] ?: return@synchronized feed + originalLikeStates.putIfAbsent(feed.id, feed.isLiked) - if (feed.isLiked != localIsLiked) { - val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 - return feed.copy( - isLiked = localIsLiked, - likeCount = adjustedCount.coerceAtLeast(0), - ) + if (feed.isLiked != localIsLiked) { + val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 + return@synchronized feed.copy( + isLiked = localIsLiked, + likeCount = adjustedCount.coerceAtLeast(0), + ) + } + return@synchronized feed } - return feed - } /** * 댓글 목록을 조회합니다. 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..6083bc6b2 --- /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 + +data class CachedFeedLikeState( + val isLiked: Boolean, + val likeCount: Int, +) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/LikeSyncStatus.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/LikeSyncStatus.kt new file mode 100644 index 000000000..7da390664 --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/model/LikeSyncStatus.kt @@ -0,0 +1,6 @@ +package com.into.websoso.data.feed.repository.model + +enum class LikeSyncStatus { + SYNCING, + NEEDS_RETRY, +} 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..b164dfde9 --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/store/PendingFeedLikeStore.kt @@ -0,0 +1,32 @@ +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 + + /** 저장된 기록을 복원하지 못해 비운 표시(파일 손상, 해석 불가)가 있는지 반환합니다. 표시는 지우지 않습니다. */ + suspend fun readResetNotice(): Boolean + + /** 사용자에게 안내한 뒤 표시를 지웁니다. */ + suspend fun clearResetNotice() +} 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 new file mode 100644 index 000000000..54cde0030 --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryAccountTest.kt @@ -0,0 +1,228 @@ +package com.into.websoso.data.feed.repository + +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdatedFeedRepositoryAccountTest { + @Test + fun `현재 계정 결함 기록 - A에서 실패한 pending이 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) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(12L) + advanceUntilIdle() + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(AccountLikeCall("A", 12L, true)), feedApi.likeCalls) + assertEquals(mapOf(12L to true), pendingStore.currentPendingLikes()) + assertFalse(feedApi.isLikedFor("A", 12L)) + + 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)) + val refreshed = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(12L) + // B의 서버 원본은 false인데 A의 메모리 pending이 목록에 적용된다. + assertTrue(refreshed.isLiked) + assertEquals(1, 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)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 계정 결함 기록 - 저장소만 비워도 A의 메모리 pending이 B 요청으로 전송된다`() = + runTest { + val feedApi = accountAwareFeedApi(13L) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(13L) + advanceUntilIdle() + + feedApi.currentAccountId = null + // 실제 로그아웃 구현이 아니다. 저장소만 정리하는 대응으로 충분한지 분리해서 확인한다. + pendingStore.deletePendingLike(13L) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + feedApi.currentAccountId = "B" + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(AccountLikeCall("B", 13L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("A", 13L)) + assertTrue(feedApi.isLikedFor("B", 13L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 계정 결함 기록 - B에서 Repository를 재생성해도 저장된 A pending이 복원되어 전송된다`() = + runTest { + val feedApi = accountAwareFeedApi(14L) + val pendingStore = FakePendingFeedLikeStore() + val repositoryA = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repositoryA.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repositoryA.toggleLikeLocal(14L) + advanceUntilIdle() + assertEquals(mapOf(14L to true), pendingStore.currentPendingLikes()) + assertTrue(feedApi.likeCalls.isEmpty()) + + feedApi.currentAccountId = null + feedApi.currentAccountId = "B" + // 같은 가짜 저장소를 읽는 새 인스턴스다. 실제 DataStore/프로세스 재실행 검증은 아니다. + val repositoryB = createRepository(feedApi, pendingStore) + advanceUntilIdle() + val restored = repositoryB.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(14L) + assertTrue(restored.isLiked) + assertFalse(feedApi.isLikedFor("B", 14L)) + + repositoryB.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(AccountLikeCall("B", 14L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("A", 14L)) + assertTrue(feedApi.isLikedFor("B", 14L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 계정 결함 기록 - A에서 호출하고 실행을 기다리던 동기화가 B 요청으로 전송된다`() = + runTest { + val feedApi = accountAwareFeedApi(15L) + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(15L) + advanceUntilIdle() + + repository.syncPendingLikes() + // launch된 동기화는 아직 실행하지 않고, 먼저 계정 컨텍스트를 바꾼다. + assertTrue(feedApi.likeCalls.isEmpty()) + feedApi.currentAccountId = null + feedApi.currentAccountId = "B" + advanceUntilIdle() + + assertEquals(listOf(AccountLikeCall("B", 15L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("A", 15L)) + assertTrue(feedApi.isLikedFor("B", 15L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + @Test + fun `현재 계정 결함 기록 - 저장소 정리 뒤 실행된 A의 저장이 B에서 복원되어 전송된다`() = + runTest { + val feedApi = accountAwareFeedApi(16L) + val pendingStore = FakePendingFeedLikeStore() + val repositoryA = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repositoryA.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repositoryA.toggleLikeLocal(16L) + // 화면과 메모리만 바뀐 시점이다. launch된 저장 작업은 아직 실행하지 않는다. + assertTrue( + repositoryA.sosoAllFeeds.value + .feed(16L) + .isLiked, + ) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + feedApi.currentAccountId = null + // 저장 작업 실행 전 저장소 정리를 모의한다. 실제 로그아웃 구현은 아니다. + pendingStore.deletePendingLike(16L) + feedApi.currentAccountId = "B" + advanceUntilIdle() + assertEquals(mapOf(16L to true), pendingStore.currentPendingLikes()) + + val repositoryB = createRepository(feedApi, pendingStore) + advanceUntilIdle() + repositoryB.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repositoryB.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(AccountLikeCall("B", 16L, true)), feedApi.likeCalls) + assertFalse(feedApi.isLikedFor("A", 16L)) + assertTrue(feedApi.isLikedFor("B", 16L)) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + } + + private fun accountAwareFeedApi(feedId: Long): AccountAwareFeedApi = + AccountAwareFeedApi( + accountApis = listOf("A", "B").associateWith { + FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(feedId, isLiked = false, likeCount = 0)) + } + }, + currentAccountId = "A", + ) +} + +private data class AccountLikeCall( + val accountId: String, + val feedId: Long, + val isLiked: Boolean, +) + +// 실제 로그인/인증을 실행하지 않고, 요청 시점의 계정 컨텍스트와 계정별 서버 상태를 모델링한다. +private class AccountAwareFeedApi( + val accountApis: Map, + var currentAccountId: String?, +) : FeedApi by accountApis.values.first() { + val likeCalls = mutableListOf() + + fun isLikedFor( + accountId: String, + feedId: Long, + ): Boolean = + accountApis + .getValue(accountId) + .feedsResponse + .feeds + .single { it.feedId == feedId } + .isLiked + + override suspend fun getFeeds( + feedsOption: String, + lastFeedId: Long, + size: Int, + ): FeedsResponseDto = accountApis.getValue(requireNotNull(currentAccountId)).getFeeds(feedsOption, lastFeedId, size) + + override suspend fun postLikes(feedId: Long) = sendLike(feedId, isLiked = true) + + override suspend fun deleteLikes(feedId: Long) = sendLike(feedId, isLiked = false) + + private suspend fun sendLike( + feedId: Long, + isLiked: Boolean, + ) { + val accountId = requireNotNull(currentAccountId) + val api = accountApis.getValue(accountId) + likeCalls += AccountLikeCall(accountId, feedId, isLiked) + if (isLiked) api.postLikes(feedId) else api.deleteLikes(feedId) + } +} diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt new file mode 100644 index 000000000..14bd260b8 --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryLikeStateTest.kt @@ -0,0 +1,506 @@ +package com.into.websoso.data.feed.repository + +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto +import com.into.websoso.data.feed.repository.model.LikeSyncStatus +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdatedFeedRepositoryLikeStateTest { + @Test + fun `실패 항목을 재조회해도 안내와 최신 선택이 유지된다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val repository = createRepository(api, FakePendingFeedLikeStore()) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + assertTrue( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + + @Test + fun `복원된 선택은 확인 필요로 표시하고 취소도 보존한다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore(mapOf(1L to true)) + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + repository.retryPendingLikes(setOf(1L)) + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `복원보다 먼저 발생한 클릭과 취소를 오래된 저장 기록으로 덮지 않는다`() = + runTest { + val store = FakePendingFeedLikeStore(mapOf(1L to true)) + val repository = createRepository(server(), store) + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertFalse( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + + @Test + fun `여러 목록에 같은 피드가 있어도 한 클릭을 한 선택으로 기록한다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.fetchFeeds(0L, 10, "RECOMMENDED") + repository.updateMyFeedsCache(repository.sosoAllFeeds.value, true, repository.likeStateVersion()) + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertTrue( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertTrue( + repository.sosoRecommendedFeeds.value + .single() + .isLiked, + ) + assertTrue( + repository.myFeeds.value + .single() + .isLiked, + ) + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(store.currentPendingLikes().isEmpty()) + } + + @Test + fun `목록 없이 상세만 열어도 클릭과 실패 뒤 취소가 상세 상태에 반영된다`() = + runTest { + val api = server() + api.feedDetailResponses[1L] = detailResponse() + api.planLikeRequest("POST", 1L, LikeOutcome.TIMEOUT_AFTER_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeed(1L) + repository.toggleLikeLocal(1L) + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + assertEquals( + 1, + repository.feedDetailLikeStates.value + .getValue(1L) + .likeCount, + ) + repository.syncPendingLikes() + advanceUntilIdle() + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertFalse( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + assertEquals( + 0, + repository.feedDetailLikeStates.value + .getValue(1L) + .likeCount, + ) + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + repository.retryPendingLikes(setOf(1L)) + advanceUntilIdle() + assertFalse(api.getFeed(1L).isLiked) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `상세 조회 뒤 늦게 복원된 선택도 상세 화면이 관찰하는 상태에 반영된다`() = + runTest { + val api = server() + api.feedDetailResponses[1L] = detailResponse() + val repository = createRepository(api, FakePendingFeedLikeStore(mapOf(1L to true))) + repository.fetchFeed(1L) + assertFalse( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + advanceUntilIdle() + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + assertEquals( + 1, + repository.feedDetailLikeStates.value + .getValue(1L) + .likeCount, + ) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + + @Test + fun `전송 전 취소를 끝낸 뒤에는 새로 조회한 서버 값을 다음 선택의 기준으로 삼는다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.toggleLikeLocal(1L) + advanceUntilIdle() + api.feedsResponse = feedsResponseOf(feedResponse(1L, true, 1)) + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + } + + @Test + fun `다른 목록에서 갱신된 좋아요를 모든 캐시에 반영하고 그 값을 기준으로 취소한다`() = + runTest { + val api = server() + api.feedDetailResponses[1L] = detailResponse() + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.updateMyFeedsCache(repository.sosoAllFeeds.value, true, repository.likeStateVersion()) + repository.fetchFeed(1L) + + api.feedsResponse = feedsResponseOf(feedResponse(1L, true, 1)) + repository.fetchFeeds(0L, 10, "RECOMMENDED") + listOf(repository.sosoAllFeeds, repository.sosoRecommendedFeeds, repository.myFeeds).forEach { feeds -> + assertTrue(feeds.value.single().isLiked) + assertEquals(1, feeds.value.single().likeCount) + } + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertTrue(api.postLikesCalls.isEmpty()) + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue(store.currentPendingLikes().isEmpty()) + listOf(repository.sosoAllFeeds, repository.sosoRecommendedFeeds, repository.myFeeds).forEach { feeds -> + assertFalse(feeds.value.single().isLiked) + assertEquals(0, feeds.value.single().likeCount) + } + assertFalse( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + } + + @Test + fun `상세 조회 중에 좋아요를 누르고 전송이 끝나면 늦게 온 응답이 좋아요를 덮지 않는다`() = + runTest { + val fakeServer = server().apply { feedDetailResponses[1L] = detailResponse() } + val response = CompletableDeferred() + val api = heldDetailApi(fakeServer, response) + val repository = createRepository(api, FakePendingFeedLikeStore()) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + val detail = async { repository.fetchFeed(1L) } + runCurrent() + + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + response.complete(Unit) + advanceUntilIdle() + + assertTrue(detail.await().isLiked) + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(1L) + .isLiked, + ) + } + + @Test + fun `좋아요를 누른 뒤 시작한 목록 조회 중에 전송이 끝나면 늦게 온 응답이 좋아요를 덮지 않는다`() = + runTest { + val fakeServer = server() + val response = CompletableDeferred() + var holdFeeds = false + val api = heldFeedsApi(fakeServer, response) { holdFeeds } + val repository = createRepository(api, FakePendingFeedLikeStore()) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + + holdFeeds = true + val refreshed = async { repository.fetchFeeds(0L, 10, "ALL") } + runCurrent() + repository.syncPendingLikes() + advanceUntilIdle() + response.complete(Unit) + advanceUntilIdle() + + assertTrue( + refreshed + .await() + .feeds + .feed(1L) + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(1L) + .isLiked, + ) + } + + @Test + fun `전송 확인 후 저장 정리가 끝나기 전에 늦게 온 목록 응답도 좋아요를 덮지 않는다`() = + runTest { + val fakeServer = server() + val response = CompletableDeferred() + var holdFeeds = false + val api = heldFeedsApi(fakeServer, response) { holdFeeds } + val backing = FakePendingFeedLikeStore() + val cleanup = CompletableDeferred() + val store = object : PendingFeedLikeStore by backing { + override suspend fun deletePendingLike(feedId: Long) { + cleanup.await() + backing.deletePendingLike(feedId) + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + + holdFeeds = true + val refreshed = async { repository.fetchFeeds(0L, 10, "ALL") } + runCurrent() + repository.syncPendingLikes() + advanceUntilIdle() + // POST는 끝났고 pending도 정리됐지만, 저장 정리(삭제)는 아직 멈춰 있는 상태 + assertEquals(listOf(1L), fakeServer.postLikesCompleted) + response.complete(Unit) + advanceUntilIdle() + + assertTrue( + refreshed + .await() + .feeds + .feed(1L) + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(1L) + .isLiked, + ) + cleanup.complete(Unit) + advanceUntilIdle() + assertTrue(backing.currentPendingLikes().isEmpty()) + } + + @Test + fun `내 피드 조회를 시작한 뒤 좋아요가 바뀌면 오래된 내 피드 데이터가 좋아요를 덮지 않는다`() = + runTest { + val repository = createRepository(server(), FakePendingFeedLikeStore()) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + val staleFeeds = repository.sosoAllFeeds.value + val version = repository.likeStateVersion() + + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + repository.updateMyFeedsCache(staleFeeds, isRefreshed = true, likeStateVersion = version) + + assertTrue( + repository.myFeeds.value + .feed(1L) + .isLiked, + ) + assertTrue( + repository.sosoAllFeeds.value + .feed(1L) + .isLiked, + ) + } + + @Test + fun `화면에 없는 피드도 복원한 좋아요가 목록 조회 중에 전송되면 늦게 온 응답이 덮지 않는다`() = + runTest { + val fakeServer = server() + val response = CompletableDeferred() + val api = heldFeedsApi(fakeServer, response) { true } + val repository = createRepository(api, FakePendingFeedLikeStore(mapOf(1L to true))) + advanceUntilIdle() + + val firstPage = async { repository.fetchFeeds(0L, 10, "ALL") } + runCurrent() + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), fakeServer.postLikesCompleted) + response.complete(Unit) + advanceUntilIdle() + + val feed = firstPage + .await() + .feeds + .feed(1L) + assertTrue(feed.isLiked) + assertEquals(1, feed.likeCount) + } + + @Test + fun `화면에 없는 피드도 복원한 좋아요가 상세 조회 중에 전송되면 늦게 온 응답이 덮지 않는다`() = + runTest { + val fakeServer = server().apply { feedDetailResponses[1L] = detailResponse() } + val response = CompletableDeferred() + val api = heldDetailApi(fakeServer, response) + val repository = createRepository(api, FakePendingFeedLikeStore(mapOf(1L to true))) + advanceUntilIdle() + + val detail = async { repository.fetchFeed(1L) } + runCurrent() + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), fakeServer.postLikesCompleted) + response.complete(Unit) + advanceUntilIdle() + + val result = detail.await() + assertTrue(result.isLiked) + assertEquals(1, result.likeCount) + assertTrue( + repository.feedDetailLikeStates.value + .getValue(1L) + .isLiked, + ) + } + + // 조회를 시작한 순간의 서버 값으로 응답을 만들어 두고, 도착만 늦춘다. + private fun heldDetailApi( + fakeServer: FakeFeedApi, + response: CompletableDeferred, + ): FeedApi = + object : FeedApi by fakeServer { + override suspend fun getFeed(feedId: Long): FeedDetailResponseDto { + val staleDetail = fakeServer.getFeed(feedId) + response.await() + return staleDetail + } + } + + // 조회를 시작한 순간의 서버 값으로 응답을 만들어 두고, 도착만 늦춘다. + private fun heldFeedsApi( + fakeServer: FakeFeedApi, + response: CompletableDeferred, + shouldHold: () -> Boolean, + ): FeedApi = + object : FeedApi by fakeServer { + override suspend fun getFeeds( + feedsOption: String, + lastFeedId: Long, + size: Int, + ): FeedsResponseDto { + val staleFeeds = fakeServer.getFeeds(feedsOption, lastFeedId, size) + if (shouldHold()) response.await() + return staleFeeds + } + } + + private fun detailResponse() = + FeedDetailResponseDto( + userId = 1L, + feedId = 1L, + nickname = "nickname", + avatarImage = "avatar", + createdDate = "2026-01-01", + feedContent = "feed", + likeCount = 0, + isLiked = false, + commentCount = 0, + novelId = null, + title = null, + novelRating = null, + novelRatingCount = null, + isSpoiler = false, + isModified = false, + isMyFeed = false, + isPublic = true, + images = emptyList(), + novelThumbnailImage = null, + novelGenre = null, + novelAuthor = null, + feedWriterNovelRating = null, + novelDescription = null, + ) + + private fun server() = + FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(1L, false, 0)) + } +} diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt new file mode 100644 index 000000000..fdab8fdf0 --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryRecoveryTest.kt @@ -0,0 +1,490 @@ +package com.into.websoso.data.feed.repository + +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.data.feed.repository.model.LikeSyncStatus +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdatedFeedRepositoryRecoveryTest { + @Test + fun `서버 반영 후 타임아웃과 취소에도 마지막 선택을 보존하고 수동 재시도로 복구한다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.TIMEOUT_AFTER_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertTrue(repository.likeSyncStates.value.isEmpty()) + repository.syncPendingLikes() + advanceUntilIdle() + assertTrue( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertFalse( + repository + .fetchFeeds(0L, 10, "ALL") + .feeds + .single() + .isLiked, + ) + assertEquals( + 0, + repository.sosoAllFeeds.value + .single() + .likeCount, + ) + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue(store.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + } + + @Test + fun `응답 대기 중 취소한 뒤 타임아웃이 와도 취소 기록이 남는다`() = + runTest { + val api = server() + val response = CompletableDeferred() + api.planLikeRequest("POST", 1L, LikeOutcome.TIMEOUT_AFTER_APPLYING, responseGate = response) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + runCurrent() + assertEquals(LikeSyncStatus.SYNCING, repository.likeSyncStates.value[1L]) + repository.toggleLikeLocal(1L) + runCurrent() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + response.complete(Unit) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + } + + @Test + fun `타임아웃 뒤 여러 번 눌러도 처음 상태와 같다는 이유로 기록을 지우지 않는다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.TIMEOUT_AFTER_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + listOf(false, true, false).forEach { expected -> + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to expected), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + } + + @Test + fun `중복 동기화와 취소가 겹쳐도 POST 하나가 끝난 뒤 최신 DELETE만 보낸다`() = + runTest { + val api = server() + val postResponse = CompletableDeferred() + val deleteResponse = CompletableDeferred() + api.planLikeRequest("POST", 1L, responseGate = postResponse) + api.planLikeRequest("DELETE", 1L, responseGate = deleteResponse) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + runCurrent() + repository.syncPendingLikes() + runCurrent() + assertEquals(listOf(1L), api.postLikesCalls) + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + runCurrent() + assertTrue(api.deleteLikesCalls.isEmpty()) + postResponse.complete(Unit) + runCurrent() + assertEquals(listOf(1L), api.postLikesCalls) + assertEquals(listOf(1L), api.deleteLikesCalls) + assertFalse( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + deleteResponse.complete(Unit) + advanceUntilIdle() + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertEquals( + 0, + api.feedsResponse.feeds + .single() + .likeCount, + ) + assertTrue(store.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.deleteLikesCalls) + } + + @Test + fun `실패하는 요청에 동기화 호출이 겹쳐도 즉시 재시도하지 않는다`() = + runTest { + val api = server() + val response = CompletableDeferred() + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING, responseGate = response) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + runCurrent() + repository.syncPendingLikes() + runCurrent() + response.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertEquals(mapOf(1L to true), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L, 1L), api.postLikesCalls) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `실패 항목 수동 재시도와 화면 종료가 겹쳐도 한 번만 전송한다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + val response = CompletableDeferred() + api.planLikeRequest("POST", 1L, responseGate = response) + repository.retryPendingLikes() + runCurrent() + repository.retryPendingLikes() + repository.syncPendingLikes() + runCurrent() + assertEquals(listOf(1L, 1L), api.postLikesCalls) + response.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(1L, 1L), api.postLikesCalls) + assertTrue(store.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `수동 재시도는 실패 항목만 보내고 화면 종료는 새 선택도 보낸다`() = + runTest { + val api = server() + api.feedsResponse = feedsResponseOf(feedResponse(1L, false, 0), feedResponse(2L, false, 0)) + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + repository.toggleLikeLocal(2L) + advanceUntilIdle() + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L, 1L), api.postLikesCalls) + assertEquals(mapOf(2L to true), store.currentPendingLikes()) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L, 1L, 2L), api.postLikesCalls) + assertTrue(api.feedsResponse.feeds.all { it.isLiked && it.likeCount == 1 }) + } + + @Test + fun `이전 재시도가 성공해도 그동안 바뀐 최신 선택의 안내를 지우지 않는다`() = + runTest { + val api = server() + api.planLikeRequest("POST", 1L, LikeOutcome.FAIL_WITHOUT_APPLYING) + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + val response = CompletableDeferred() + api.planLikeRequest("POST", 1L, responseGate = response) + repository.retryPendingLikes() + runCurrent() + repository.toggleLikeLocal(1L) + response.complete(Unit) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + assertTrue(api.deleteLikesCalls.isEmpty()) + repository.retryPendingLikes() + advanceUntilIdle() + assertFalse( + api.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `요청 취소도 결과 미확정으로 남겨 이후 취소 선택을 보존한다`() = + runTest { + val api = object : FeedApi by server() { + override suspend fun postLikes(feedId: Long): Unit = throw CancellationException("cancelled response") + } + val store = FakePendingFeedLikeStore() + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertEquals(mapOf(1L to false), store.currentPendingLikes()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + } + + @Test + fun `저장에 실패하면 서버로 보내지 않고 안내를 남겨 다시 시도할 수 있다`() = + runTest { + val api = server() + val backing = FakePendingFeedLikeStore() + var failSave = true + val store = object : PendingFeedLikeStore by backing { + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + if (failSave) throw IOException("test storage unavailable") + backing.updatePendingLike(feedId, isLiked) + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertTrue(api.postLikesCalls.isEmpty()) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + failSave = false + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `성공 후 저장 기록 삭제만 실패하면 재시도는 서버 요청을 반복하지 않는다`() = + runTest { + val api = server() + val backing = FakePendingFeedLikeStore() + var failDelete = true + val store = object : PendingFeedLikeStore by backing { + override suspend fun deletePendingLike(feedId: Long) { + if (failDelete) throw IOException("test storage unavailable") + backing.deletePendingLike(feedId) + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertEquals(LikeSyncStatus.NEEDS_RETRY, repository.likeSyncStates.value[1L]) + failDelete = false + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(backing.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + } + + @Test + fun `저장된 좋아요를 읽지 못해도 예외 없이 저장과 전송을 보류한다`() = + runTest { + val api = server() + val backing = FakePendingFeedLikeStore() + val store = object : PendingFeedLikeStore by backing { + override suspend fun getPendingLikes(): Map = throw IOException("test storage unavailable") + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertTrue( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertTrue(backing.updateCalls.isEmpty()) + assertTrue(api.postLikesCalls.isEmpty()) + assertTrue(repository.isLikeRestoreFailed.value) + } + + @Test + fun `저장소를 다시 읽을 수 있게 되면 다시 시도로 복원하고 이어서 전송한다`() = + runTest { + val api = server() + val backing = FakePendingFeedLikeStore(initial = mapOf(1L to true)) + var failRead = true + val store = object : PendingFeedLikeStore by backing { + override suspend fun getPendingLikes(): Map { + if (failRead) throw IOException("test storage unavailable") + return backing.getPendingLikes() + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.retryPendingLikes() + advanceUntilIdle() + assertFalse( + repository.sosoAllFeeds.value + .single() + .isLiked, + ) + assertTrue(api.postLikesCalls.isEmpty()) + assertTrue(repository.isLikeRestoreFailed.value) + failRead = false + repository.retryPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(backing.currentPendingLikes().isEmpty()) + assertTrue(repository.likeSyncStates.value.isEmpty()) + assertFalse(repository.isLikeRestoreFailed.value) + } + + @Test + fun `손상된 저장 파일이 초기화됐으면 복원 후 미복원 안내 상태를 남기고 이어서 동작한다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore().apply { resetNotice = true } + val repository = createRepository(api, store) + advanceUntilIdle() + assertTrue(repository.hasUnrestoredLikes.value) + assertTrue(store.resetNotice) + + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), api.postLikesCalls) + assertTrue(store.currentPendingLikes().isEmpty()) + } + + @Test + fun `미복원 안내 표시는 봤음 처리 전까지 저장소를 다시 만들어도 남는다`() = + runTest { + val api = server() + val store = FakePendingFeedLikeStore().apply { resetNotice = true } + createRepository(api, store) + advanceUntilIdle() + + val recreated = createRepository(api, store) + advanceUntilIdle() + assertTrue(recreated.hasUnrestoredLikes.value) + + recreated.acknowledgeUnrestoredLikes() + advanceUntilIdle() + assertFalse(recreated.hasUnrestoredLikes.value) + assertFalse(store.resetNotice) + + val afterAcknowledged = createRepository(api, store) + advanceUntilIdle() + assertFalse(afterAcknowledged.hasUnrestoredLikes.value) + } + + @Test + fun `복원에 실패한 동안 누른 좋아요는 복원에 성공하면 함께 저장된다`() = + runTest { + val api = FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(1L, false, 0), feedResponse(2L, false, 0)) + } + val backing = FakePendingFeedLikeStore() + var failRead = true + val store = object : PendingFeedLikeStore by backing { + override suspend fun getPendingLikes(): Map { + if (failRead) throw IOException("test storage unavailable") + return backing.getPendingLikes() + } + } + val repository = createRepository(api, store) + advanceUntilIdle() + repository.fetchFeeds(0L, 10, "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + assertTrue(backing.currentPendingLikes().isEmpty()) + + failRead = false + repository.toggleLikeLocal(2L) + advanceUntilIdle() + assertEquals(mapOf(1L to true, 2L to true), backing.currentPendingLikes()) + } + + private fun server() = + FakeFeedApi().apply { + feedsResponse = feedsResponseOf(feedResponse(1L, false, 0)) + } +} diff --git a/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt new file mode 100644 index 000000000..f3356bfa3 --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTest.kt @@ -0,0 +1,318 @@ +package com.into.websoso.data.feed.repository + +import com.into.websoso.data.feed.model.FeedEntity +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdatedFeedRepositoryTest { + @Test + fun `좋아요를 누르고 동기화하면 서버 요청이 성공하고 대기 기록이 정리된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(1L) + advanceUntilIdle() + + val toggled = repository.sosoAllFeeds.value.feed(1L) + assertTrue(toggled.isLiked) + assertEquals(4, toggled.likeCount) + assertEquals(mapOf(1L to true), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(1L), feedApi.postLikesCalls) + assertTrue(feedApi.deleteLikesCalls.isEmpty()) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + val synced = repository.sosoAllFeeds.value.feed(1L) + assertTrue(synced.isLiked) + assertEquals(4, synced.likeCount) + assertServerMatches(feedApi, synced) + + // 메모리 pending이 비었는지 간접 확인: 다시 동기화해도 추가 요청이 없어야 한다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(1L), feedApi.postLikesCalls) + } + + @Test + fun `좋아요를 취소하고 동기화하면 서버 요청이 성공하고 대기 기록이 정리된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 11L, isLiked = true, likeCount = 4)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(11L) + advanceUntilIdle() + + val toggled = repository.sosoAllFeeds.value.feed(11L) + assertFalse(toggled.isLiked) + assertEquals(3, toggled.likeCount) + assertEquals(mapOf(11L to false), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(11L), feedApi.deleteLikesCalls) + assertTrue(feedApi.postLikesCalls.isEmpty()) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(11L)) + } + + @Test + fun `전송 전에 좋아요를 다시 취소하면 서버 요청 없이 원래 상태로 남는다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 2L, isLiked = false, likeCount = 5)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + repository.toggleLikeLocal(2L) + advanceUntilIdle() + assertEquals(mapOf(2L to true), pendingStore.currentPendingLikes()) + + repository.toggleLikeLocal(2L) + advanceUntilIdle() + + val reverted = repository.sosoAllFeeds.value.feed(2L) + assertFalse(reverted.isLiked) + assertEquals(5, reverted.likeCount) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertTrue(feedApi.postLikesCalls.isEmpty()) + assertTrue(feedApi.deleteLikesCalls.isEmpty()) + assertServerMatches(feedApi, reverted) + } + + @Test + fun `동기화 요청이 실패하면 대기 기록이 메모리와 저장소에 그대로 남는다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 3L, isLiked = false, likeCount = 2)) + planLikeRequest("POST", 3L, outcome = LikeOutcome.FAIL_WITHOUT_APPLYING) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(3L) + advanceUntilIdle() + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(3L), feedApi.postLikesCalls) + assertTrue(pendingStore.deleteIfMatchedCalls.isEmpty()) + assertEquals(mapOf(3L to true), pendingStore.currentPendingLikes()) + + val afterFailure = repository.sosoAllFeeds.value.feed(3L) + assertTrue(afterFailure.isLiked) + assertEquals(3, afterFailure.likeCount) + + // 서버 원본은 false다. 목록 병합은 메모리 pending만 보므로 재조회 결과로 메모리 보존을 확인한다. + assertFalse( + feedApi.feedsResponse.feeds + .single() + .isLiked, + ) + assertTrue(feedApi.appliedLikeRequests.isEmpty()) + val refreshed = + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL").feeds.feed(3L) + assertTrue(refreshed.isLiked) + assertEquals(3, refreshed.likeCount) + assertEquals(mapOf(3L to true), pendingStore.currentPendingLikes()) + + // 다음 요청은 성공하도록 두고 명시적 재동기화를 확인한다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(3L, 3L), feedApi.postLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(3L)) + } + + @Test + fun `두 건을 동기화할 때 하나만 실패하면 성공한 건만 정리되고 실패한 건만 남는다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = feedsResponseOf( + feedResponse(feedId = 4L, isLiked = false, likeCount = 1), + feedResponse(feedId = 5L, isLiked = false, likeCount = 1), + ) + repeat(2) { planLikeRequest("POST", 5L, outcome = LikeOutcome.FAIL_WITHOUT_APPLYING) } + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(4L) + repository.toggleLikeLocal(5L) + advanceUntilIdle() + assertEquals(mapOf(4L to true, 5L to true), pendingStore.currentPendingLikes()) + + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(4L, 5L), feedApi.postLikesCalls) + assertEquals(mapOf(5L to true), pendingStore.currentPendingLikes()) + + val feed4 = repository.sosoAllFeeds.value.feed(4L) + val feed5 = repository.sosoAllFeeds.value.feed(5L) + assertTrue(feed4.isLiked) + assertEquals(2, feed4.likeCount) + assertTrue(feed5.isLiked) + assertEquals(2, feed5.likeCount) + assertServerMatches(feedApi, feed4) + assertFalse( + feedApi.feedsResponse.feeds + .first { it.feedId == 5L } + .isLiked, + ) + + // 다시 동기화하면 실패했던 5만 다시 시도된다. 이 호출만으로 메모리와 저장소를 구분하지는 않는다. + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(4L, 5L, 5L), feedApi.postLikesCalls) + } + + @Test + fun `전송 중에 취소하면 화면의 취소가 유지되고 취소가 다시 대기열에 쌓인다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 6L, isLiked = false, likeCount = 0)) + } + val pendingStore = FakePendingFeedLikeStore() + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() + + repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + repository.toggleLikeLocal(6L) + advanceUntilIdle() + + val postGate = CompletableDeferred() + feedApi.planLikeRequest("POST", 6L, responseGate = postGate) + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(6L), feedApi.postLikesCalls) + + repository.toggleLikeLocal(6L) // 좋아요 응답을 받기 전에 취소 + advanceUntilIdle() + + val cancelled = repository.sosoAllFeeds.value.feed(6L) + assertFalse(cancelled.isLiked) + assertEquals(0, cancelled.likeCount) + + postGate.complete(Unit) // 뒤늦게 좋아요 요청이 성공 응답을 받음 + advanceUntilIdle() + + val afterPostSucceeds = repository.sosoAllFeeds.value.feed(6L) + assertFalse(afterPostSucceeds.isLiked) // 화면의 취소가 유지된다 + assertEquals(0, afterPostSucceeds.likeCount) + assertEquals(mapOf(6L to false), pendingStore.currentPendingLikes()) // 취소가 다시 대기열에 남는다 + + repository.syncPendingLikes() // 명시적으로 다시 동기화 + advanceUntilIdle() + + assertEquals(listOf(6L), feedApi.deleteLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(6L)) + + repository.syncPendingLikes() + advanceUntilIdle() + assertEquals(listOf(6L), feedApi.deleteLikesCalls) // 메모리도 비어 추가 요청이 없다 + } + + @Test + fun `복원이 끝난 뒤 목록을 조회하면 복원된 pending이 즉시 반영된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore(initial = mapOf(1L to true)) + val repository = createRepository(feedApi, pendingStore) + advanceUntilIdle() // init { restorePendingLikes() } 완료를 기다린다 + + val result = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + val restoredFeed = result.feeds.feed(1L) + assertTrue(restoredFeed.isLiked) + assertEquals(4, restoredFeed.likeCount) + + val cachedFeed = repository.sosoAllFeeds.value.feed(1L) + assertTrue(cachedFeed.isLiked) + assertEquals(4, cachedFeed.likeCount) + + // 복원된 기록이 이후 명시적 동기화에서 실제로 쓰이는지 확인한다. + repository.syncPendingLikes() + advanceUntilIdle() + + assertEquals(listOf(1L), feedApi.postLikesCalls) + assertTrue(pendingStore.currentPendingLikes().isEmpty()) + assertServerMatches(feedApi, repository.sosoAllFeeds.value.feed(1L)) + } + + @Test + fun `복원이 끝나기 전에 목록을 조회해도 복원이 끝나면 목록에 뒤늦게 반영된다`() = + runTest { + val feedApi = FakeFeedApi().apply { + feedsResponse = + feedsResponseOf(feedResponse(feedId = 1L, isLiked = false, likeCount = 3)) + } + val pendingStore = FakePendingFeedLikeStore(initial = mapOf(1L to true)) + val repository = createRepository(feedApi, pendingStore) + + // advanceUntilIdle()을 호출하지 않아 init { restorePendingLikes() }가 아직 실행되지 않은 상태에서 조회한다. + val result = repository.fetchFeeds(lastFeedId = 0L, size = 10, feedsOption = "ALL") + + val beforeRestore = result.feeds.feed(1L) + assertFalse(beforeRestore.isLiked) + assertEquals(3, beforeRestore.likeCount) + + advanceUntilIdle() // restorePendingLikes()가 뒤늦게 끝난다 + + val afterRestore = repository.sosoAllFeeds.value.feed(1L) + assertTrue(afterRestore.isLiked) + assertEquals(4, afterRestore.likeCount) + } + + private fun assertServerMatches( + feedApi: FakeFeedApi, + feed: FeedEntity, + ) { + val serverFeed = feedApi.feedsResponse.feeds.single { it.feedId == feed.id } + assertEquals("서버와 화면의 좋아요 상태", feed.isLiked, serverFeed.isLiked) + assertEquals("서버와 화면의 좋아요 개수", feed.likeCount, serverFeed.likeCount) + } +} 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 new file mode 100644 index 000000000..125aff49d --- /dev/null +++ b/data/feed/src/test/java/com/into/websoso/data/feed/repository/UpdatedFeedRepositoryTestSupport.kt @@ -0,0 +1,282 @@ +package com.into.websoso.data.feed.repository + +import android.content.ContextWrapper +import com.into.websoso.core.common.image.ImageCompressor +import com.into.websoso.core.network.common.ImageDownloader +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.core.network.datasource.feed.mapper.MultiPartMapper +import com.into.websoso.core.network.datasource.feed.model.request.CommentRequestDto +import com.into.websoso.core.network.datasource.feed.model.response.CommentsResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto +import com.into.websoso.data.feed.model.FeedEntity +import com.into.websoso.data.feed.store.PendingFeedLikeStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import okhttp3.MultipartBody +import java.net.SocketTimeoutException + +@OptIn(ExperimentalCoroutinesApi::class) +internal fun TestScope.createRepository( + feedApi: FeedApi, + pendingFeedLikeStore: PendingFeedLikeStore, +): UpdatedFeedRepository = + UpdatedFeedRepository( + feedApi = feedApi, + pendingFeedLikeStore = pendingFeedLikeStore, + multiPartMapper = MultiPartMapper(ContextWrapper(null)), + imageDownloader = ImageDownloader(ContextWrapper(null)), + imageCompressor = ImageCompressor(ContextWrapper(null)), + dispatcher = StandardTestDispatcher(testScheduler), + ) + +internal fun List.feed(id: Long): FeedEntity = first { it.id == id } + +internal fun feedsResponseOf(vararg feeds: FeedResponseDto): FeedsResponseDto = FeedsResponseDto(isLoadable = false, feeds = feeds.toList()) + +internal fun feedResponse( + feedId: Long, + isLiked: Boolean, + likeCount: Int, +): FeedResponseDto = + FeedResponseDto( + userId = 1L, + nickname = "닉네임", + avatarImage = "avatar.png", + feedId = feedId, + createdDate = "2026-01-01", + feedContent = "content-$feedId", + likeCount = likeCount, + isLiked = isLiked, + commentCount = 0, + novelId = 100L, + title = "novel-title", + novelRating = 4.5f, + novelRatingCount = 10, + isSpoiler = false, + isModified = false, + isMyFeed = false, + isPublic = true, + thumbnailUrl = null, + imageCount = 0, + genreName = "로맨스", + userNovelRating = null, + feedWriterNovelRating = null, + ) + +internal enum class LikeOutcome { + SUCCESS, + FAIL_WITHOUT_APPLYING, + TIMEOUT_AFTER_APPLYING, +} + +internal class PlannedLikeRequest( + val method: String, + val feedId: Long, + val outcome: LikeOutcome, + val processingGate: CompletableDeferred?, + val responseGate: CompletableDeferred?, +) + +internal class FakeFeedApi : FeedApi { + var feedsResponse: FeedsResponseDto = FeedsResponseDto(isLoadable = false, feeds = emptyList()) + val feedDetailResponses = mutableMapOf() + + /** 요청 시작 순서. 서버가 요청을 받은 순서를 나타낸다. */ + val postLikesCalls = mutableListOf() + val deleteLikesCalls = mutableListOf() + + /** 성공 응답 도착 순서. 실패 응답은 포함하지 않는다. */ + val postLikesCompleted = mutableListOf() + val deleteLikesCompleted = mutableListOf() + + val appliedLikeRequests = mutableListOf() + val respondedLikeRequests = mutableListOf() + private val plannedLikeRequests = mutableListOf() + + /** 같은 method/feedId의 다음 요청부터 순서대로 적용한다. 두 gate가 없으면 즉시 처리·응답한다. */ + fun planLikeRequest( + method: String, + feedId: Long, + outcome: LikeOutcome = LikeOutcome.SUCCESS, + processingGate: CompletableDeferred? = null, + responseGate: CompletableDeferred? = null, + ): PlannedLikeRequest { + require(method == "POST" || method == "DELETE") + return PlannedLikeRequest(method, feedId, outcome, processingGate, responseGate).also { + plannedLikeRequests += it + } + } + + override suspend fun getFeeds( + feedsOption: String, + lastFeedId: Long, + size: Int, + ): FeedsResponseDto = feedsResponse + + override suspend fun getFeed(feedId: Long): FeedDetailResponseDto = feedDetailResponses[feedId] ?: error("등록되지 않은 feedId: $feedId") + + override suspend fun postFeed( + feedRequestDto: MultipartBody.Part, + images: List?, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun putFeed( + feedId: Long, + feedRequestDto: MultipartBody.Part, + images: List?, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun deleteFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postLikes(feedId: Long) { + postLikesCalls += feedId + executeLikeRequest("POST", feedId) + postLikesCompleted += feedId + } + + override suspend fun deleteLikes(feedId: Long) { + deleteLikesCalls += feedId + executeLikeRequest("DELETE", feedId) + deleteLikesCompleted += feedId + } + + private suspend fun executeLikeRequest( + method: String, + feedId: Long, + ) { + val index = plannedLikeRequests.indexOfFirst { it.method == method && it.feedId == feedId } + val request = if (index >= 0) { + plannedLikeRequests.removeAt(index) + } else { + PlannedLikeRequest(method, feedId, LikeOutcome.SUCCESS, null, null) + } + request.processingGate?.await() + if (request.outcome != LikeOutcome.FAIL_WITHOUT_APPLYING) { + applyLikeOnServer(feedId, isLiked = method == "POST") + appliedLikeRequests += request + } + request.responseGate?.await() + // 예외로 전달되는 실패/타임아웃도 앱에 결과가 도착한 순서에 포함한다. + respondedLikeRequests += request + when (request.outcome) { + LikeOutcome.SUCCESS -> Unit + LikeOutcome.FAIL_WITHOUT_APPLYING -> throw RuntimeException("$method 반영 전 실패: $feedId") + LikeOutcome.TIMEOUT_AFTER_APPLYING -> throw SocketTimeoutException("서버 반영 후 응답 유실: $feedId") + } + } + + // 테스트 가정: POST/DELETE는 목표 상태를 설정하고, 같은 상태를 반복 설정해도 개수는 변하지 않는다. + private fun applyLikeOnServer( + feedId: Long, + isLiked: Boolean, + ) { + feedDetailResponses[feedId]?.let { detail -> + if (detail.isLiked != isLiked) { + feedDetailResponses[feedId] = detail.copy( + isLiked = isLiked, + likeCount = detail.likeCount + if (isLiked) 1 else -1, + ) + } + } + feedsResponse = feedsResponse.copy( + feeds = feedsResponse.feeds.map { feed -> + if (feed.feedId == feedId && feed.isLiked != isLiked) { + feed.copy( + isLiked = isLiked, + likeCount = feed.likeCount + if (isLiked) 1 else -1, + ) + } else { + feed + } + }, + ) + } + + override suspend fun postSpoilerFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postImpertinenceFeed(feedId: Long): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun getComments(feedId: Long): CommentsResponseDto = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postComment( + feedId: Long, + commentRequestDto: CommentRequestDto, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun putComment( + feedId: Long, + commentId: Long, + commentRequestDto: CommentRequestDto, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun deleteComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postSpoilerComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") + + override suspend fun postImpertinenceComment( + feedId: Long, + commentId: Long, + ): Unit = error("좋아요 테스트에서 사용하지 않음") +} + +internal class FakePendingFeedLikeStore( + initial: Map = emptyMap(), +) : PendingFeedLikeStore { + private val state = MutableStateFlow(initial) + + override val pendingLikes: Flow> = state.asStateFlow() + + val updateCalls = mutableListOf>() + val deleteCalls = mutableListOf() + val deleteIfMatchedCalls = mutableListOf>() + + /** true면 손상된 파일을 초기화한 직후처럼 동작한다. clearResetNotice()를 호출해야 false로 바뀐다. */ + var resetNotice: Boolean = false + + fun currentPendingLikes(): Map = state.value + + override suspend fun getPendingLikes(): Map = state.value + + override suspend fun updatePendingLike( + feedId: Long, + isLiked: Boolean, + ) { + updateCalls += feedId to isLiked + state.update { it + (feedId to isLiked) } + } + + override suspend fun deletePendingLike(feedId: Long) { + deleteCalls += feedId + state.update { it - feedId } + } + + override suspend fun deletePendingLikeIfMatched( + feedId: Long, + isLiked: Boolean, + ): Boolean { + deleteIfMatchedCalls += feedId to isLiked + if (state.value[feedId] != isLiked) return false + state.update { it - feedId } + return true + } + + override suspend fun readResetNotice(): Boolean = resetNotice + + override suspend fun clearResetNotice() { + resetNotice = false + } +} diff --git a/domain/feed/src/main/java/com/into/websoso/feed/GetMyFeedsUseCase.kt b/domain/feed/src/main/java/com/into/websoso/feed/GetMyFeedsUseCase.kt index eb17345a9..97a0bbe41 100644 --- a/domain/feed/src/main/java/com/into/websoso/feed/GetMyFeedsUseCase.kt +++ b/domain/feed/src/main/java/com/into/websoso/feed/GetMyFeedsUseCase.kt @@ -36,6 +36,7 @@ class GetMyFeedsUseCase val profile = myProfile ?: userRepository.fetchMyProfile().also { myProfile = it } val myId = myId ?: userRepository.fetchUserInfo().userId.also { myId = it } + val likeStateVersion = feedRepository.likeStateVersion() val myFeedsEntity: UserFeedsEntity = userRepository.fetchMyActivities( lastFeedId = lastFeedId, size = if (isFeedRefreshed) INITIAL_REQUEST_SIZE else ADDITIONAL_REQUEST_SIZE, @@ -52,6 +53,7 @@ class GetMyFeedsUseCase feedRepository.updateMyFeedsCache( feeds = convertedFeeds, isRefreshed = isFeedRefreshed, + likeStateVersion = likeStateVersion, ) return Feeds( diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt index 85f5492b8..90b44a9e9 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt @@ -28,6 +28,8 @@ fun FeedRoute( ) { val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val likeSyncStates by viewModel.likeSyncStates.collectAsStateWithLifecycle() + val isLikeRestoreFailed by viewModel.isLikeRestoreFailed.collectAsStateWithLifecycle() val bottomSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, confirmValueChange = { newValue -> @@ -38,6 +40,9 @@ fun FeedRoute( FeedScreen( uiState = uiState, + likeSyncStates = likeSyncStates, + isLikeRestoreFailed = isLikeRestoreFailed, + onRetryLikes = viewModel::retryLikes, bottomSheetState = bottomSheetState, onTabSelected = viewModel::updateTab, onSortSelected = viewModel::updateMyFeedSort, diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt index 2dedcf288..d1f2d34c2 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt @@ -37,7 +37,9 @@ import com.into.websoso.core.designsystem.theme.Gray80 import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.designsystem.theme.White import com.into.websoso.core.resource.R +import com.into.websoso.data.feed.repository.model.LikeSyncStatus import com.into.websoso.feature.feed.component.FeedFilterChip +import com.into.websoso.feature.feed.component.FeedLikeSyncNotice import com.into.websoso.feature.feed.component.FeedSection import com.into.websoso.feature.feed.component.MyFeedFilterModal import com.into.websoso.feature.feed.model.FeedOrder @@ -68,6 +70,9 @@ internal fun FeedScreen( onRefreshPull: () -> Unit, onWriteFeedClick: () -> Unit, onLoadMore: () -> Unit, + likeSyncStates: Map = emptyMap(), + isLikeRestoreFailed: Boolean = false, + onRetryLikes: () -> Unit = {}, ) { Scaffold(containerColor = White) { _ -> Column(modifier = Modifier.statusBarsPadding()) { @@ -77,6 +82,13 @@ internal fun FeedScreen( onWriteClick = onWriteClick, ) + FeedLikeSyncNotice( + isRestoreFailed = isLikeRestoreFailed, + needsRetry = likeSyncStates.values.any { it == LikeSyncStatus.NEEDS_RETRY }, + isSyncing = likeSyncStates.values.any { it == LikeSyncStatus.SYNCING }, + onRetry = onRetryLikes, + ) + Spacer(modifier = Modifier.height(height = 2.dp)) Row( @@ -159,6 +171,7 @@ internal fun FeedScreen( } FeedSection( + likeSyncStates = likeSyncStates, currentTab = uiState.selectedTab, feeds = when (uiState.selectedTab) { FeedTab.MY_FEED -> uiState.myFeedData.feeds 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..762457130 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 @@ -28,6 +28,13 @@ class FeedViewModel ) : ViewModel() { private val _uiState = MutableStateFlow(FeedUiState()) val uiState = _uiState.asStateFlow() + val likeSyncStates = feedRepository.likeSyncStates + val isLikeRestoreFailed = feedRepository.isLikeRestoreFailed + val hasUnrestoredLikes = feedRepository.hasUnrestoredLikes + + fun retryLikes() = feedRepository.retryPendingLikes() + + fun acknowledgeUnrestoredLikes() = feedRepository.acknowledgeUnrestoredLikes() init { collectFeedRefreshEvent() @@ -202,7 +209,7 @@ class FeedViewModel override fun onCleared() { super.onCleared() - feedRepository.syncDirtyFeeds() + feedRepository.syncPendingLikes() } // --- 기타 탭/필터 로직 --- diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt new file mode 100644 index 000000000..af00bd33f --- /dev/null +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedLikeSyncNotice.kt @@ -0,0 +1,63 @@ +package com.into.websoso.feature.feed.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.into.websoso.core.designsystem.theme.Black +import com.into.websoso.core.designsystem.theme.Gray200 +import com.into.websoso.core.designsystem.theme.Gray50 +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R + +@Composable +fun FeedLikeSyncNotice( + isRestoreFailed: Boolean, + needsRetry: Boolean, + isSyncing: Boolean, + onRetry: () -> Unit, +) { + if (!isRestoreFailed && !needsRetry) return + Row( + modifier = Modifier + .fillMaxWidth() + .background(Gray50) + .padding(horizontal = 20.dp, vertical = 4.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource( + if (isRestoreFailed) R.string.feed_like_restore_failed_message else R.string.feed_like_unconfirmed_message, + ), + style = WebsosoTheme.typography.body4, + color = Black, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = onRetry, + enabled = !isSyncing, + colors = ButtonDefaults.textButtonColors( + contentColor = Primary100, + disabledContentColor = Gray200, + ), + ) { + Text( + text = stringResource(R.string.feed_like_retry), + style = WebsosoTheme.typography.label1, + ) + } + } +} diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt index 8f3911f80..94b91f593 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -55,6 +56,7 @@ import com.into.websoso.core.resource.R import com.into.websoso.core.resource.R.drawable.ic_link import com.into.websoso.core.resource.R.drawable.ic_navigate_right import com.into.websoso.core.resource.R.drawable.ic_star +import com.into.websoso.data.feed.repository.model.LikeSyncStatus import com.into.websoso.feature.feed.model.FeedTab import com.into.websoso.feature.feed.model.FeedUiModel import kotlinx.collections.immutable.ImmutableList @@ -77,6 +79,7 @@ internal fun FeedSection( onLoadMore: () -> Unit, isLoading: Boolean, isRefreshing: Boolean, + likeSyncStates: Map = emptyMap(), ) { val view = LocalView.current PullToRefreshBox( @@ -124,6 +127,7 @@ internal fun FeedSection( itemsIndexed(items = feeds) { index, feed -> FeedItem( feed = feed, + likeSyncStatus = likeSyncStates[feed.id], currentTab = currentTab, onProfileClick = onProfileClick, onNovelClick = onNovelClick, @@ -157,6 +161,7 @@ internal fun FeedSection( @Composable private fun FeedItem( feed: FeedUiModel, + likeSyncStatus: LikeSyncStatus?, currentTab: FeedTab, onProfileClick: (userId: Long, feedTab: FeedTab) -> Unit, onNovelClick: (novelId: Long) -> Unit, @@ -384,6 +389,14 @@ private fun FeedItem( } } } + + if (likeSyncStatus == LikeSyncStatus.NEEDS_RETRY) { + Text( + text = stringResource(R.string.feed_like_needs_retry), + style = WebsosoTheme.typography.body4, + color = Black, + ) + } } }