diff --git a/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt b/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt index ec42a6813..9b40bf53e 100644 --- a/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt @@ -8,6 +8,7 @@ import android.view.View import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.annotation.IntegerRes +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.firebase.messaging.FirebaseMessaging @@ -32,12 +33,18 @@ import com.into.websoso.ui.main.MainActivity.FragmentType.LIBRARY import com.into.websoso.ui.main.MainActivity.FragmentType.MY_PAGE import com.into.websoso.ui.main.feed.FeedFragment import com.into.websoso.ui.main.home.HomeFragment +import com.into.websoso.ui.main.home.HomeStartupRequest import com.into.websoso.ui.main.library.LibraryFragment import com.into.websoso.ui.main.myPage.MyPageFragment +import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : BaseActivity(activity_main) { + @Inject + lateinit var homeStartupRequest: Lazy + private val mainViewModel: MainViewModel by viewModels() private var backPressedTime: Long = 0L private var currentFragment: Fragment? = null @@ -52,6 +59,11 @@ class MainActivity : BaseActivity(activity_main) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (intent.getAdaptedSerializableExtra(DESTINATION_KEY)?.let { it != HOME } == true || + intent.getLongExtra(CollectionDeepLink.PENDING_COLLECTION_ID, 0L) > 0L + ) { + discardStartupRequest() + } setupBackButtonListener() setupBottomNavigationView() setupObserver() @@ -142,6 +154,7 @@ class MainActivity : BaseActivity(activity_main) { private fun replaceCurrentFragment(itemId: Int) { val tag = fragmentTags[itemId] ?: return + if (itemId != menu_home) discardStartupRequest() val isLibrary = itemId == menu_library val existingFragment = supportFragmentManager.findFragmentByTag(tag) val targetFragment = existingFragment ?: findOrCreateFragment(tag) @@ -175,7 +188,9 @@ class MainActivity : BaseActivity(activity_main) { private fun findOrCreateFragment(tag: String): Fragment = supportFragmentManager.findFragmentByTag(tag) ?: when (tag) { - HomeFragment.TAG -> HomeFragment() + HomeFragment.TAG -> HomeFragment().apply { + arguments = bundleOf(HomeStartupRequest.KEY to intent.getStringExtra(HomeStartupRequest.KEY)) + } FeedFragment.TAG -> FeedFragment() LibraryFragment.TAG -> LibraryFragment() MyPageFragment.TAG -> MyPageFragment() @@ -231,6 +246,16 @@ class MainActivity : BaseActivity(activity_main) { } } + private fun discardStartupRequest() { + intent.getStringExtra(HomeStartupRequest.KEY)?.let { homeStartupRequest.get().discard(it) } + intent.removeExtra(HomeStartupRequest.KEY) + } + + override fun onDestroy() { + if (!isChangingConfigurations) discardStartupRequest() + super.onDestroy() + } + companion object { private const val DESTINATION_KEY = "destination" const val IS_LOGIN_KEY = "isLogin" diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt index 92ccd920d..1318a6a05 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt @@ -8,9 +8,12 @@ import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import com.google.firebase.messaging.FirebaseMessaging +import com.into.websoso.R.color.gray_200_949399 +import com.into.websoso.R.color.gray_300_52515F import com.into.websoso.R.layout.fragment_home import com.into.websoso.core.common.ui.base.BaseFragment import com.into.websoso.core.common.ui.model.ResultFrom.FeedDetailBack @@ -21,8 +24,13 @@ import com.into.websoso.core.common.ui.model.ResultFrom.NovelDetailBack import com.into.websoso.core.common.ui.model.ResultFrom.ProfileEditSuccess import com.into.websoso.core.common.util.SingleEventHandler 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.resource.R.drawable.ic_novel_rating_alert import com.into.websoso.core.resource.R.string.home_rising_feed_for_user +import com.into.websoso.core.resource.R.string.home_taste_load_failed +import com.into.websoso.core.resource.R.string.home_taste_refresh_failed +import com.into.websoso.core.resource.R.string.load_load_title import com.into.websoso.databinding.FragmentHomeBinding import com.into.websoso.ui.detailExplore.DetailExploreActivity import com.into.websoso.ui.feedDetail.FeedDetailActivity @@ -31,6 +39,8 @@ import com.into.websoso.ui.main.home.adpater.PopularFeedsAdapter import com.into.websoso.ui.main.home.adpater.PopularNovelsAdapter import com.into.websoso.ui.main.home.adpater.RecommendedNovelsByUserTasteAdapter import com.into.websoso.ui.main.home.dialog.TermsAgreementDialogFragment +import com.into.websoso.ui.main.home.model.HomeTasteStatus +import com.into.websoso.ui.main.home.model.HomeUiState import com.into.websoso.ui.normalExplore.NormalExploreActivity import com.into.websoso.ui.notification.NotificationActivity import com.into.websoso.ui.novelDetail.NovelDetailActivity @@ -45,6 +55,7 @@ class HomeFragment : BaseFragment(fragment_home) { private val homeViewModel: HomeViewModel by viewModels() private val mainViewModel: MainViewModel by activityViewModels() + private var renderedState: HomeUiState? = null private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } @@ -80,7 +91,12 @@ class HomeFragment : BaseFragment(fragment_home) { ProfileEditSuccess.RESULT_OK -> { mainViewModel.updateUserInfo() - homeViewModel.updateNovel() + homeViewModel.updateNovel( + preferencesChanged = result.data?.getBooleanExtra( + ProfileEditActivity.EXTRA_GENRE_PREFERENCES_CHANGED, + false, + ) == true, + ) } } } @@ -102,6 +118,7 @@ class HomeFragment : BaseFragment(fragment_home) { bindViewModel() setupAdapter() setupItemDecoration() + binding.btnHomeTasteRetry.setOnClickListener { homeViewModel.retryTaste() } setupObserver() setupDotsIndicator() onSettingPreferenceGenreClick() @@ -162,15 +179,28 @@ class HomeFragment : BaseFragment(fragment_home) { !uiState.loading -> { binding.wllHome.setWebsosoLoadingVisibility(false) - popularNovelsAdapter.submitList(uiState.popularNovels) - popularFeedsAdapter.submitList(uiState.popularFeeds) - updateRecommendedNovelByUserTasteVisibility(uiState.recommendedNovelsByUserTaste.isEmpty()) - recommendedNovelsByUserTasteAdapter.submitList(uiState.recommendedNovelsByUserTaste) + if (renderedState?.popularNovels !== uiState.popularNovels) { + popularNovelsAdapter.submitList(uiState.popularNovels) + } + if (renderedState?.popularFeeds !== uiState.popularFeeds) { + popularFeedsAdapter.submitList(uiState.popularFeeds) + } + if (renderedState?.recommendedNovelsByUserTaste !== uiState.recommendedNovelsByUserTaste) { + recommendedNovelsByUserTasteAdapter.submitList(uiState.recommendedNovelsByUserTaste) + } + renderTasteStatus(uiState) updateHasNotificationUnread(uiState.isNotificationUnread) + renderedState = uiState } } } + homeViewModel.tasteRefreshFailed.collectWithLifecycle(viewLifecycleOwner) { + val state = homeViewModel.uiState.value ?: return@collectWithLifecycle + if (state.loading || state.error) return@collectWithLifecycle + showWebsosoToast(requireContext(), getString(home_taste_refresh_failed), ic_novel_rating_alert) + } + homeViewModel.isNotificationPermissionFirstLaunched.observe(viewLifecycleOwner) { isFirstLaunch -> if (isFirstLaunch) { showNotificationPermissionDialog() @@ -193,18 +223,28 @@ class HomeFragment : BaseFragment(fragment_home) { } } - private fun updateRecommendedNovelByUserTasteVisibility(isRecommendNovelByUserTasteEmpty: Boolean) { + private fun renderTasteStatus(state: HomeUiState) { with(binding) { - if (isRecommendNovelByUserTasteEmpty) { - clHomeUserRecommendNovel.visibility = View.GONE - clHomeRecommendNovel.visibility = View.VISIBLE - } else { - clHomeUserRecommendNovel.visibility = View.VISIBLE - clHomeRecommendNovel.visibility = View.GONE - } + val empty = state.tasteStatus == HomeTasteStatus.EMPTY + val showContent = state.tasteStatus == HomeTasteStatus.CONTENT + clHomeRecommendNovel.visibility = if (empty) View.VISIBLE else View.GONE + clHomeUserRecommendNovel.visibility = if (empty) View.GONE else View.VISIBLE + rvRecommendNovelByUserTaste.visibility = if (showContent) View.VISIBLE else View.GONE + llHomeTasteStatus.visibility = if (showContent || empty) View.GONE else View.VISIBLE + val error = state.tasteStatus == HomeTasteStatus.ERROR + tvHomeTasteStatus.setText(if (error) home_taste_load_failed else load_load_title) + tvHomeTasteStatus.setTextColor( + ContextCompat.getColor(requireContext(), if (error) gray_300_52515F else gray_200_949399), + ) + btnHomeTasteRetry.visibility = if (error) View.VISIBLE else View.GONE } } + override fun onDestroyView() { + renderedState = null + super.onDestroyView() + } + private fun updateHasNotificationUnread(hasUnread: Boolean) { binding.ivHomeNotification.isSelected = hasUnread } diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt new file mode 100644 index 000000000..f70e116d9 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt @@ -0,0 +1,49 @@ +package com.into.websoso.ui.main.home + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import retrofit2.HttpException + +internal enum class HomeSection { + POPULAR, + FEEDS, + TASTE, +} + +internal fun isSessionFailure(error: Throwable): Boolean = error is HttpException && error.code() == 401 + +/** Requests belong to the Home ViewModel; replacing one never cancels another section. */ +internal class HomeSectionLoader( + private val scope: CoroutineScope, +) { + private val jobs = mutableMapOf() + + fun load( + section: HomeSection, + request: suspend () -> T, + success: (T) -> Unit, + failure: (Exception) -> Unit, + ): Deferred { + jobs[section]?.cancel() + return scope + .async { + try { + val result = request() + currentCoroutineContext().ensureActive() + success(result) + true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + currentCoroutineContext().ensureActive() + failure(error) + false + } + }.also { jobs[section] = it } + } +} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt new file mode 100644 index 000000000..19c9ba66d --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt @@ -0,0 +1,15 @@ +package com.into.websoso.ui.main.home + +/** Main-thread only. Tracks when Home can open; content is owned by HomeViewModel. */ +internal class HomeSectionRelease { + private val waitingForUpper = mutableSetOf(HomeSection.POPULAR, HomeSection.FEEDS) + + fun update(section: HomeSection): Boolean { + waitingForUpper.remove(section) + return waitingForUpper.isEmpty() + } + + fun recover(loading: Boolean) { + if (!loading) waitingForUpper.clear() + } +} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt new file mode 100644 index 000000000..560be2312 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt @@ -0,0 +1,144 @@ +package com.into.websoso.ui.main.home + +import androidx.annotation.MainThread +import com.into.websoso.data.account.AccountRepository +import com.into.websoso.data.model.PopularFeedEntity +import com.into.websoso.data.model.PopularNovelsEntity +import com.into.websoso.data.repository.FeedRepository +import com.into.websoso.data.repository.NovelRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** A single Splash-to-Home handoff. Only its opaque ID crosses Activity boundaries. */ +@Singleton +@MainThread +class HomeStartupRequest internal constructor( + private val sessionIdentity: suspend () -> String, + private val fetchPopular: suspend () -> PopularNovelsEntity, + private val fetchFeeds: suspend () -> List, + private val dispatcher: CoroutineDispatcher, +) { + @Inject + constructor( + accountRepository: AccountRepository, + novelRepository: NovelRepository, + feedRepository: FeedRepository, + ) : this( + accountRepository::accessToken, + novelRepository::fetchPopularNovels, + feedRepository::fetchPopularFeeds, + Dispatchers.Main.immediate, + ) + + private var pending: Pair? = null + private var generation = 0L + + suspend fun start(): String? { + val startedGeneration = ++generation + pending?.second?.cancel() + pending = null + // Reading the session is best-effort; a prefetch setup failure must not block navigation. + val identity = try { + sessionIdentity() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return null + } + currentCoroutineContext().ensureActive() + if (identity.isBlank() || startedGeneration != generation) return null + + val owner = SupervisorJob() + val scope = CoroutineScope(owner + dispatcher) + val requests = Requests( + identity, + sessionIdentity, + scope.async { fetchPopular() }, + scope.async { fetchFeeds() }, + ) + owner.complete() + return UUID.randomUUID().toString().also { pending = it to requests } + } + + internal fun take(id: String?): Requests? { + val entry = pending?.takeIf { it.first == id } ?: return null + pending = null + return entry.second + } + + fun discard(id: String?) { + take(id)?.cancel() + } + + internal class Requests( + private val identity: String, + private val sessionIdentity: suspend () -> String, + private val popular: Deferred, + private val feeds: Deferred>, + ) { + suspend fun popular(): PopularNovelsEntity? = await(popular) + + suspend fun feeds(): List? = await(feeds) + + fun cancelPopular() = popular.cancel() + + fun cancelFeeds() = feeds.cancel() + + fun cancel() { + cancelPopular() + cancelFeeds() + } + + private suspend fun await(request: Deferred): T? = + try { + if (!isSameSession()) { + cancel() + null + } else { + val result = request.await() + if (isSameSession()) { + result + } else { + cancel() + null + } + } + } catch (cancelled: CancellationException) { + currentCoroutineContext().ensureActive() + null + } catch (error: Exception) { + // Only request failures reach here; session checks never throw. + currentCoroutineContext().ensureActive() + if (isSameSession()) throw error + cancel() + null + } finally { + // Cancellation/replacement of this Home section also cancels its transferred request. + request.cancel() + } + + // A storage read failure means the session cannot be confirmed: fall back to an ordinary Home request. + private suspend fun isSameSession(): Boolean = + try { + sessionIdentity() == identity + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } + } + + companion object { + const val KEY = "home_startup_request_id" + } +} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index caf547121..cdbdd4d35 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -6,23 +6,27 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.into.websoso.data.model.PopularFeedEntity -import com.into.websoso.data.model.PopularNovelsEntity -import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity import com.into.websoso.data.model.TermsAgreementEntity import com.into.websoso.data.repository.FeedRepository import com.into.websoso.data.repository.NotificationRepository import com.into.websoso.data.repository.NovelRepository import com.into.websoso.data.repository.PushMessageRepository import com.into.websoso.data.repository.UserRepository +import com.into.websoso.ui.main.home.model.HomeTasteStatus import com.into.websoso.ui.main.home.model.HomeUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Deferred import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import retrofit2.HttpException import javax.inject.Inject @HiltViewModel @@ -35,10 +39,16 @@ class HomeViewModel private val notificationRepository: NotificationRepository, private val userRepository: UserRepository, private val savedStateHandle: SavedStateHandle, + homeStartupRequest: HomeStartupRequest, ) : ViewModel() { + // Received data is kept here even while loading; the Fragment waits before rendering it. private val _uiState: MutableLiveData = MutableLiveData(HomeUiState()) val uiState: LiveData get() = _uiState + // Foreground notice only; returning to Home must not replay an old refresh failure. + private val _tasteRefreshFailed = MutableSharedFlow(extraBufferCapacity = 1) + val tasteRefreshFailed: SharedFlow = _tasteRefreshFailed.asSharedFlow() + private val _isNotificationPermissionFirstLaunched: MutableLiveData = MutableLiveData() val isNotificationPermissionFirstLaunched: LiveData get() = _isNotificationPermissionFirstLaunched @@ -53,6 +63,11 @@ class HomeViewModel savedStateHandle["isTermsAgreementChecked"] = value } + private val sectionLoader = HomeSectionLoader(viewModelScope) + private val sectionRelease = HomeSectionRelease() + private var hasSessionFailure = false + private val startupRequests = homeStartupRequest.take(savedStateHandle.remove(HomeStartupRequest.KEY)) + init { updateHomeData(true) updateNotificationUnread() @@ -71,37 +86,105 @@ class HomeViewModel } private suspend fun fetchUserHomeData() { - coroutineScope { - val popularNovelsDeferred = - async { runCatching { novelRepository.fetchPopularNovels() } } - val popularFeedsDeferred = - async { runCatching { feedRepository.fetchPopularFeeds() } } - val recommendedNovelsDeferred = - async { runCatching { novelRepository.fetchRecommendedNovelsByUserTaste() } } + val requests = listOf(loadPopularNovels(initial = true), loadPopularFeeds(initial = true), loadTasteNovels()) + requests.joinAll() + if (requests.all { !it.isCancelled && it.await() }) recoverGlobalError() + } - val popularNovelsResult = popularNovelsDeferred.await() - val popularFeedsResult = popularFeedsDeferred.await() - val recommendedNovelsResult = recommendedNovelsDeferred.await() + private fun loadPopularNovels(initial: Boolean = false): Deferred = + sectionLoader + .load( + section = HomeSection.POPULAR, + request = { (if (initial) startupRequests?.popular() else null) ?: novelRepository.fetchPopularNovels() }, + success = { result -> + updateContent(HomeSection.POPULAR) { it.copy(popularNovels = result.popularNovels) } + }, + failure = { handleFailureState(it) }, + ).also { + // Cancel the transferred request only after the loader has cancelled the job awaiting it. + if (!initial) startupRequests?.cancelPopular() + } - val failure = popularNovelsResult.exceptionOrNull() - ?: popularFeedsResult.exceptionOrNull() - ?: recommendedNovelsResult.exceptionOrNull() - if (failure != null) { - handleFailureState() - return@coroutineScope + private fun loadPopularFeeds( + recoverError: Boolean = false, + initial: Boolean = false, + ): Deferred = + sectionLoader + .load( + section = HomeSection.FEEDS, + request = { (if (initial) startupRequests?.feeds() else null) ?: feedRepository.fetchPopularFeeds() }, + success = { result -> + updateContent(HomeSection.FEEDS) { it.copy(popularFeeds = result.toHomePopularFeedPages()) } + if (recoverError) recoverGlobalError() + }, + failure = { handleFailureState(it) }, + ).also { + if (!initial) startupRequests?.cancelFeeds() } - val popularNovels = popularNovelsResult.getOrThrow() - val popularFeeds = popularFeedsResult.getOrThrow() - val recommendedNovels = recommendedNovelsResult.getOrThrow() - - _uiState.value = uiState.value?.copy( - loading = false, - error = false, - popularNovels = popularNovels.popularNovels, - popularFeeds = popularFeeds.toHomePopularFeedPages(), - recommendedNovelsByUserTaste = recommendedNovels.tasteNovels, - ) + override fun onCleared() { + startupRequests?.cancel() + super.onCleared() + } + + private fun loadTasteNovels(preferencesChanged: Boolean = false): Deferred { + val previousStatus = _uiState.value?.tasteStatus + val keepVisible = !preferencesChanged && + (previousStatus == HomeTasteStatus.CONTENT || previousStatus == HomeTasteStatus.EMPTY) + if (!keepVisible) { + updateContent(HomeSection.TASTE) { current -> + current.copy( + tasteStatus = HomeTasteStatus.LOADING, + recommendedNovelsByUserTaste = if (preferencesChanged) emptyList() else current.recommendedNovelsByUserTaste, + ) + } + } + return sectionLoader.load( + section = HomeSection.TASTE, + request = novelRepository::fetchRecommendedNovelsByUserTaste, + success = { result -> + updateContent(HomeSection.TASTE) { current -> + current.copy( + recommendedNovelsByUserTaste = result.tasteNovels, + tasteStatus = if (result.tasteNovels.isEmpty()) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, + ) + } + }, + failure = { error -> + if (isSessionFailure(error)) { + handleFailureState(error) + } else if (keepVisible && (error !is HttpException || error.code() != 403)) { + _tasteRefreshFailed.tryEmit(Unit) + } else { + updateContent(HomeSection.TASTE) { it.copy(tasteStatus = HomeTasteStatus.ERROR) } + } + }, + ) + } + + private fun updateContent( + section: HomeSection, + transform: (HomeUiState) -> HomeUiState, + ) { + updateState { current -> + val updated = transform(current) + if (sectionRelease.update(section)) updated.copy(loading = false) else updated + } + } + + private fun updateState(transform: (HomeUiState) -> HomeUiState) { + val current = _uiState.value ?: return + val updated = transform(current) + if (updated != current) _uiState.value = updated + } + + // Match the existing recovery events; a content response cannot establish a new session. + private fun recoverGlobalError() { + if (hasSessionFailure) return + updateState { current -> + if (!current.error) return@updateState current + sectionRelease.recover(loading = current.loading) + current.copy(error = false) } } @@ -138,79 +221,60 @@ class HomeViewModel val failure = popularNovelsResult.exceptionOrNull() ?: popularFeedsResult.exceptionOrNull() if (failure != null) { - handleFailureState() + handleFailureState(failure) return@coroutineScope } val popularNovels = popularNovelsResult.getOrThrow() val popularFeeds = popularFeedsResult.getOrThrow() - _uiState.value = uiState.value?.copy( - loading = false, - error = false, - popularNovels = popularNovels.popularNovels, - popularFeeds = popularFeeds.toHomePopularFeedPages(), - ) - } - } - - private fun handleFailureState() { - _uiState.value = uiState.value?.copy( - loading = false, - error = true, - ) - } - - fun updateFeed() { - viewModelScope.launch { - runCatching { - feedRepository.fetchPopularFeeds() - }.onSuccess { popularFeeds -> - _uiState.value = uiState.value?.copy( + updateState { current -> + current.copy( + loading = false, error = false, + popularNovels = popularNovels.popularNovels, popularFeeds = popularFeeds.toHomePopularFeedPages(), ) - }.onFailure { - _uiState.value = uiState.value?.copy(error = true) } } } - fun updateNovel() { - viewModelScope.launch { - runCatching { - listOf( - async { novelRepository.fetchPopularNovels() }, - async { novelRepository.fetchRecommendedNovelsByUserTaste() }, - ).awaitAll() - }.onSuccess { responses -> - val popularNovels = responses[0] as PopularNovelsEntity - val recommendedNovels = responses[1] as RecommendedNovelsByUserTasteEntity - - _uiState.value = uiState.value?.copy( - popularNovels = popularNovels.popularNovels, - recommendedNovelsByUserTaste = recommendedNovels.tasteNovels, - ) - }.onFailure { - _uiState.value = uiState.value?.copy( - error = true, - ) - } + private fun handleFailureState( + error: Throwable, + preserveLoading: Boolean = false, + ) { + hasSessionFailure = hasSessionFailure || isSessionFailure(error) + updateState { current -> + current.copy( + loading = preserveLoading && current.loading, + error = true, + ) } } + fun updateFeed() { + loadPopularFeeds(recoverError = true) + } + + fun updateNovel(preferencesChanged: Boolean = false) { + loadPopularNovels() + loadTasteNovels(preferencesChanged) + } + + fun retryTaste() { + val current = _uiState.value ?: return + if (current.error || current.tasteStatus != HomeTasteStatus.ERROR) return + loadTasteNovels() + } + fun updateNotificationUnread() { viewModelScope.launch { runCatching { notificationRepository.fetchNotificationUnread() }.onSuccess { isNotificationUnread -> - _uiState.value = uiState.value?.copy( - isNotificationUnread = isNotificationUnread, - ) - }.onFailure { - _uiState.value = uiState.value?.copy( - error = true, - ) + updateState { it.copy(isNotificationUnread = isNotificationUnread) } + }.onFailure { error -> + handleFailureState(error, preserveLoading = true) } } } diff --git a/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt b/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt index ac485f897..c4c621a12 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt @@ -7,8 +7,16 @@ import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity.Recommende data class HomeUiState( val loading: Boolean = true, val error: Boolean = false, + val tasteStatus: HomeTasteStatus = HomeTasteStatus.LOADING, val isNotificationUnread: Boolean = false, val popularNovels: List = listOf(), val popularFeeds: List> = listOf(), val recommendedNovelsByUserTaste: List = listOf(), ) + +enum class HomeTasteStatus { + LOADING, + CONTENT, + EMPTY, + ERROR, +} diff --git a/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditActivity.kt b/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditActivity.kt index a612c0ef6..f75127e8b 100644 --- a/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditActivity.kt @@ -196,8 +196,9 @@ class ProfileEditActivity : BaseActivity(activity_pr private fun handleProfileEditResult(profileEditResult: ProfileEditResult) { when (profileEditResult) { - ProfileEditResult.Success -> { - setResult(ProfileEditSuccess.RESULT_OK) + is ProfileEditResult.Success -> { + val result = Intent().putExtra(EXTRA_GENRE_PREFERENCES_CHANGED, profileEditResult.genrePreferencesChanged) + setResult(ProfileEditSuccess.RESULT_OK, result) showWebsosoToast( this, getString(profile_edit_success), @@ -298,6 +299,8 @@ class ProfileEditActivity : BaseActivity(activity_pr } companion object { + const val EXTRA_GENRE_PREFERENCES_CHANGED = "EXTRA_GENRE_PREFERENCES_CHANGED" + private const val PROFILE_EDIT_CHARACTER_BOTTOM_SHEET_DIALOG = "PROFILE_EDIT_CHARACTER_BOTTOM_SHEET_DIALOG" diff --git a/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditViewModel.kt b/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditViewModel.kt index 3d1be8fbf..604b8efd9 100644 --- a/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditViewModel.kt @@ -194,7 +194,9 @@ class ProfileEditViewModel ) }.onSuccess { _profileEditUiState.value = profileEditUiState.value?.copy( - profileEditResult = ProfileEditResult.Success, + profileEditResult = ProfileEditResult.Success( + genrePreferencesChanged = previousProfile.genrePreferences.toSet() != currentProfile.genrePreferences.toSet(), + ), ) }.onFailure { _profileEditUiState.value = profileEditUiState.value?.copy( diff --git a/app/src/main/java/com/into/websoso/ui/profileEdit/model/ProfileEditUiModel.kt b/app/src/main/java/com/into/websoso/ui/profileEdit/model/ProfileEditUiModel.kt index b57ae477e..b78f6c723 100644 --- a/app/src/main/java/com/into/websoso/ui/profileEdit/model/ProfileEditUiModel.kt +++ b/app/src/main/java/com/into/websoso/ui/profileEdit/model/ProfileEditUiModel.kt @@ -21,7 +21,9 @@ data class NicknameModel( sealed interface ProfileEditResult { data object Loading : ProfileEditResult - data object Success : ProfileEditResult + data class Success( + val genrePreferencesChanged: Boolean, + ) : ProfileEditResult data object Error : ProfileEditResult } diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt index d36672109..bdb6db0c7 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt @@ -13,6 +13,7 @@ import com.into.websoso.core.common.ui.base.BaseActivity import com.into.websoso.core.common.util.collectWithLifecycle import com.into.websoso.databinding.ActivitySplashBinding import com.into.websoso.ui.collection.CollectionDeepLink +import com.into.websoso.ui.main.home.HomeStartupRequest import com.into.websoso.ui.splash.UiEffect.NavigateToLogin import com.into.websoso.ui.splash.UiEffect.NavigateToMain import com.into.websoso.ui.splash.UiEffect.ShowDialog @@ -53,6 +54,12 @@ class SplashActivity : BaseActivity(R.layout.activity_spl intent.putExtra(CollectionDeepLink.PENDING_COLLECTION_ID, collectionId) } } + splashViewModel.start( + isHomeDestination = intent.getLongExtra( + CollectionDeepLink.PENDING_COLLECTION_ID, + 0L, + ) == 0L, + ) } @SuppressLint("HardwareIds") @@ -64,15 +71,45 @@ class SplashActivity : BaseActivity(R.layout.activity_spl private fun collectUiEffect() { splashViewModel.uiEffect.collectWithLifecycle(this) { uiEffect -> when (uiEffect) { - NavigateToLogin -> websosoNavigator.navigateToLoginActivity(::startDestination) - NavigateToMain -> websosoNavigator.navigateToMainActivity(::startDestination) - ShowDialog -> showMinimumVersionDialog() + NavigateToLogin -> { + websosoNavigator.navigateToLoginActivity { + startDestination( + it, + uiEffect, + ) + } + } + + is NavigateToMain -> { + websosoNavigator.navigateToMainActivity( + startActivity = { startDestination(it, uiEffect) }, + ) + } + + ShowDialog -> { + showMinimumVersionDialog() + splashViewModel.onUiEffectHandled(uiEffect) + } } } } - private fun startDestination(destination: Intent) { - startActivity(CollectionDeepLink.forward(intent, destination)) + private fun startDestination( + destination: Intent, + effect: UiEffect, + ) { + val target = CollectionDeepLink.forward(intent, destination) + if (effect is NavigateToMain) { + effect.startupRequestId?.let { target.putExtra(HomeStartupRequest.KEY, it) } + } + // Collected only while STARTED; do not suspend between receiving the effect and navigation. + try { + startActivity(target) + } catch (error: Exception) { + splashViewModel.onNavigationFailed() + throw error + } + splashViewModel.onUiEffectHandled(effect) finish() } diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt index c2b15bf72..1215dcc0c 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt @@ -5,14 +5,17 @@ import androidx.lifecycle.viewModelScope import com.into.websoso.data.account.AccountRepository import com.into.websoso.data.repository.UserRepository import com.into.websoso.data.repository.VersionRepository +import com.into.websoso.ui.main.home.HomeStartupRequest import com.into.websoso.ui.splash.UiEffect.NavigateToLogin import com.into.websoso.ui.splash.UiEffect.NavigateToMain import com.into.websoso.ui.splash.UiEffect.ShowDialog +import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,11 +26,22 @@ class SplashViewModel private val versionRepository: VersionRepository, private val userRepository: UserRepository, private val accountRepository: AccountRepository, + private val homeStartupRequest: Lazy, ) : ViewModel() { - private val _uiEffect = Channel(Channel.BUFFERED) - val uiEffect: Flow get() = _uiEffect.receiveAsFlow() + private val _uiEffect = MutableStateFlow(null) + val uiEffect: Flow get() = _uiEffect.filterNotNull() + private var hasStarted = false + private var isHomeDestination = true + private var startupRequestId: String? = null - init { + fun start(isHomeDestination: Boolean) { + this.isHomeDestination = isHomeDestination + if (!isHomeDestination) { + discardStartupRequest() + if (_uiEffect.value is NavigateToMain) _uiEffect.value = NavigateToMain(null) + } + if (hasStarted) return + hasStarted = true viewModelScope.launch { val isUpdateRequired = checkMinimumVersion() if (isUpdateRequired.not()) handleAutoLogin() @@ -48,24 +62,48 @@ class SplashViewModel runCatching { versionRepository.isUpdateRequired() }.getOrElse { false }.also { isRequired -> - if (isRequired) _uiEffect.send(ShowDialog) + if (isRequired) _uiEffect.value = ShowDialog } - private suspend fun handleAutoLogin() { - delay(1000) - - if (shouldRefresh()) { - _uiEffect.send(NavigateToLogin) - return + private suspend fun handleAutoLogin() = + coroutineScope { + // Overlap authentication and Home requests with the minimum display time. + val minimumDisplay = launch { delay(1000) } + val authenticated = if (shouldRefresh()) { + false + } else { + accountRepository.createTokens().isSuccess + } + if (authenticated) { + startupRequestId = if (isHomeDestination) homeStartupRequest.get().start() else null + // A new deep link may arrive while the session lookup is suspended. + if (!isHomeDestination) discardStartupRequest() + } + minimumDisplay.join() + // Read startupRequestId here, not earlier: a deep link during the wait may have cleared it. + _uiEffect.value = if (authenticated) NavigateToMain(startupRequestId) else NavigateToLogin } - accountRepository - .createTokens() - .onSuccess { - _uiEffect.send(NavigateToMain) - }.onFailure { - _uiEffect.send(NavigateToLogin) - } + fun onUiEffectHandled(effect: UiEffect) { + if (_uiEffect.value != effect) return + // Main now owns the ID, even if Home has not taken its requests yet. + if (effect is NavigateToMain) startupRequestId = null + _uiEffect.value = null + } + + fun onNavigationFailed() { + discardStartupRequest() + _uiEffect.value = null + } + + private fun discardStartupRequest() { + startupRequestId?.let { homeStartupRequest.get().discard(it) } + startupRequestId = null + } + + override fun onCleared() { + discardStartupRequest() + super.onCleared() } private suspend fun shouldRefresh(): Boolean = @@ -78,7 +116,9 @@ class SplashViewModel sealed interface UiEffect { data object NavigateToLogin : UiEffect - data object NavigateToMain : UiEffect + data class NavigateToMain( + val startupRequestId: String?, + ) : UiEffect data object ShowDialog : UiEffect } diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 599e0a029..705a975a5 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -250,6 +250,50 @@ app:spanCount="2" tools:listitem="@layout/item_recommended_novel_by_user_taste" /> + + + + + + + + (code, "synthetic".toResponseBody())) + + assertTrue(isSessionFailure(http(401))) + assertFalse(isSessionFailure(http(403))) + assertFalse(isSessionFailure(http(500))) + assertFalse(isSessionFailure(IOException("offline"))) + } + + @Test + fun `retrying failed taste leaves in flight upper requests alone`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val upper = CompletableDeferred() + val displayed = mutableListOf() + val calls = mutableListOf() + val pending = listOf(HomeSection.POPULAR, HomeSection.FEEDS).map { section -> + loader.load( + section, + { + calls.add(section) + upper.await() + }, + displayed::add, + ) { error("unexpected failure") } + } + var failures = 0 + loader + .load( + HomeSection.TASTE, + { + calls.add(HomeSection.TASTE) + throw IOException("offline") + }, + displayed::add, + ) { + failures++ + }.join() + loader + .load( + HomeSection.TASTE, + { + calls.add(HomeSection.TASTE) + "recovered" + }, + displayed::add, + ) { + error("unexpected failure") + }.join() + assertEquals(listOf("recovered"), displayed) + assertEquals(1, failures) + assertTrue(pending.none { it.isCancelled || it.isCompleted }) + upper.complete("upper") + pending.forEach { it.join() } + assertEquals(listOf(HomeSection.POPULAR, HomeSection.FEEDS, HomeSection.TASTE, HomeSection.TASTE), calls) + assertEquals(3, displayed.size) + } + + @Test + fun `ready sections publish before delayed taste`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val taste = CompletableDeferred() + val displayed = mutableListOf() + val waiting = loader.load(HomeSection.TASTE, { taste.await() }, displayed::add) { error("unexpected failure") } + loader.load(HomeSection.POPULAR, { "popular" }, displayed::add) { error("unexpected failure") }.join() + loader.load(HomeSection.FEEDS, { "feeds" }, displayed::add) { error("unexpected failure") }.join() + assertEquals(listOf("popular", "feeds"), displayed) + assertFalse(waiting.isCompleted) + taste.complete("taste") + waiting.join() + assertEquals(listOf("popular", "feeds", "taste"), displayed) + } + + @Test + fun `one request failure does not cancel another section`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val popular = CompletableDeferred() + val displayed = mutableListOf() + val failures = mutableListOf() + val waiting = loader.load(HomeSection.POPULAR, { popular.await() }, displayed::add) { error("unexpected failure") } + loader.load(HomeSection.TASTE, { error("synthetic") }, { error("unexpected success") }) { failures.add("taste") }.join() + assertEquals(listOf("taste"), failures) + assertFalse(waiting.isCancelled) + popular.complete("popular") + waiting.join() + assertEquals(listOf("popular"), displayed) + } + + @Test + fun `superseded response that ignores cancellation cannot overwrite latest result`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val oldResponse = CompletableDeferred() + val displayed = mutableListOf() + val old = loader.load(HomeSection.FEEDS, { withContext(NonCancellable) { oldResponse.await() } }, displayed::add) { + error("cancelled request must not publish failure") + } + loader.load(HomeSection.FEEDS, { "new" }, displayed::add) { error("unexpected failure") }.join() + oldResponse.complete("old") + old.join() + assertTrue(old.isCancelled) + assertEquals(listOf("new"), displayed) + } + + @Test + fun `late failure from superseded request cannot hide new content`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val gate = CompletableDeferred() + val displayed = mutableListOf() + val failures = mutableListOf() + val old = loader.load( + HomeSection.TASTE, + { + withContext(NonCancellable) { + gate.await() + error("old failure") + } + }, + { error("unexpected success") }, + failures::add, + ) + loader.load(HomeSection.TASTE, { "new" }, displayed::add, failures::add).join() + gate.complete(Unit) + old.join() + assertEquals(listOf("new"), displayed) + assertTrue(failures.isEmpty()) + } + + @Test + fun `leaving Home cancels every owned request without publishing errors`() = + runBlocking { + val owner = SupervisorJob() + val loader = HomeSectionLoader(CoroutineScope(owner + Dispatchers.Unconfined)) + val gate = CompletableDeferred() + val displayed = mutableListOf() + val failures = mutableListOf() + val jobs = HomeSection.entries.map { section -> loader.load(section, { gate.await() }, displayed::add, failures::add) } + owner.cancelAndJoin() + assertTrue(jobs.all { it.isCancelled }) + assertTrue(displayed.isEmpty()) + assertTrue(failures.isEmpty()) + } +} diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt new file mode 100644 index 000000000..e73a50c13 --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt @@ -0,0 +1,76 @@ +package com.into.websoso.ui.main.home + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HomeSectionReleaseTest { + @Test + fun `upper content waits for both upper results but never for taste`() { + listOf(HomeSection.POPULAR, HomeSection.FEEDS).forEach { first -> + val release = HomeSectionRelease() + val second = if (first == HomeSection.POPULAR) HomeSection.FEEDS else HomeSection.POPULAR + + assertFalse(release.update(first)) + assertFalse(release.update(first)) + assertTrue(release.update(second)) + } + } + + @Test + fun `taste updates never open Home before both upper results`() { + val release = HomeSectionRelease() + + assertFalse(release.update(HomeSection.TASTE)) + assertFalse(release.update(HomeSection.FEEDS)) + assertFalse(release.update(HomeSection.TASTE)) + assertTrue(release.update(HomeSection.POPULAR)) + } + + @Test + fun `all section updates remain released once Home opens`() { + val release = HomeSectionRelease() + release.update(HomeSection.POPULAR) + release.update(HomeSection.FEEDS) + + HomeSection.entries.forEach { section -> + assertTrue(release.update(section)) + } + } + + @Test + fun `recovery displaying partial content releases later section updates`() { + val release = HomeSectionRelease() + assertFalse(release.update(HomeSection.FEEDS)) + + release.recover(loading = false) + + assertTrue(release.update(HomeSection.TASTE)) + assertTrue(release.update(HomeSection.FEEDS)) + } + + @Test + fun `recovery during initial loading keeps waiting for the missing upper result`() { + val release = HomeSectionRelease() + assertFalse(release.update(HomeSection.FEEDS)) + + release.recover(loading = true) + + assertFalse(release.update(HomeSection.TASTE)) + assertFalse(release.update(HomeSection.FEEDS)) + assertTrue(release.update(HomeSection.POPULAR)) + } + + @Test + fun `a new Home owner starts with its own upper wait`() { + val previous = HomeSectionRelease() + previous.recover(loading = false) + assertTrue(previous.update(HomeSection.TASTE)) + + val next = HomeSectionRelease() + + assertFalse(next.update(HomeSection.FEEDS)) + assertFalse(next.update(HomeSection.TASTE)) + assertTrue(next.update(HomeSection.POPULAR)) + } +} diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt new file mode 100644 index 000000000..48db4df77 --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt @@ -0,0 +1,185 @@ +package com.into.websoso.ui.main.home + +import com.into.websoso.data.model.PopularFeedEntity +import com.into.websoso.data.model.PopularNovelsEntity +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeStartupRequestTest { + private val dispatcher = StandardTestDispatcher() + private var identity = "session-a" + private val popular = PopularNovelsEntity(emptyList()) + private val feeds = emptyList() + + @Test + fun `completed responses are delivered once and unrelated IDs cannot claim them`() = + runTest(dispatcher) { + var calls = 0 + val startup = HomeStartupRequest( + { identity }, + { + calls++ + popular + }, + { + calls++ + feeds + }, + dispatcher, + ) + val id = startup.start() + runCurrent() + assertEquals(2, calls) + assertNull(startup.take("unrelated")) + val requests = startup.take(id)!! + assertNull(startup.take(id)) + assertSame(popular, requests.popular()) + assertSame(feeds, requests.feeds()) + assertEquals(2, calls) + } + + @Test + fun `session changes during handoff reject both an old result and an old failure`() = + runTest(dispatcher) { + listOf(false, true).forEach { fail -> + identity = "session-a" + val response = CompletableDeferred() + val startup = HomeStartupRequest({ identity }, { response.await() }, { feeds }, dispatcher) + val requests = startup.take(startup.start())!! + val result = async { requests.popular() } + runCurrent() + identity = "session-b" + if (fail) response.completeExceptionally(IOException("synthetic")) else response.complete(popular) + runCurrent() + assertNull(result.await()) + assertNull(requests.feeds()) + } + } + + @Test + fun `session lookup failures after handoff fall back instead of surfacing a storage error`() = + runTest(dispatcher) { + // Lookup 1 is start(); 2 checks before waiting, 3 after the response, 4 in the failure path. + listOf(setOf(2), setOf(3, 4)).forEach { failingLookups -> + var lookups = 0 + val startup = HomeStartupRequest( + { if (++lookups in failingLookups) throw IOException("synthetic storage failure") else identity }, + { popular }, + { feeds }, + dispatcher, + ) + val requests = startup.take(startup.start())!! + runCurrent() + val result = runCatching { requests.popular() } + assertTrue("$failingLookups: ${result.exceptionOrNull()}", result.isSuccess && result.getOrNull() == null) + } + } + + @Test + fun `consumer cancellation cancels its in flight request without cancelling its sibling`() = + runTest(dispatcher) { + var popularCancelled = false + var feedsCancelled = false + val startup = HomeStartupRequest( + { identity }, + { + try { + CompletableDeferred().await() + } finally { + popularCancelled = true + } + }, + { + try { + CompletableDeferred>().await() + } finally { + feedsCancelled = true + } + }, + dispatcher, + ) + val requests = startup.take(startup.start())!! + val consumer = async { requests.popular() } + runCurrent() + consumer.cancel() + runCurrent() + assertTrue(consumer.isCancelled) + assertTrue(popularCancelled) + assertFalse(feedsCancelled) + requests.cancel() + runCurrent() + assertTrue(feedsCancelled) + } + + @Test + fun `new startup cancels unclaimed work and discarding an old ID leaves the new work alone`() = + runTest(dispatcher) { + var cancelled = 0 + val startup = HomeStartupRequest( + { identity }, + { + try { + CompletableDeferred().await() + } finally { + cancelled++ + } + }, + { feeds }, + dispatcher, + ) + val first = startup.start() + runCurrent() + val second = startup.start() + runCurrent() + assertEquals(1, cancelled) + assertNull(startup.take(first)) + startup.discard(first) + runCurrent() + assertEquals(1, cancelled) + startup.discard(second) + runCurrent() + assertEquals(2, cancelled) + } + + @Test + fun `missing session or failed session lookup never starts content requests`() = + runTest(dispatcher) { + listOf String>({ "" }, { throw IOException("synthetic") }).forEach { session -> + val startup = HomeStartupRequest(session, { error("unexpected request") }, { error("unexpected request") }, dispatcher) + assertNull(startup.start()) + runCurrent() + } + } + + @Test + fun `a superseded slow session lookup cannot replace a newer startup`() = + runTest(dispatcher) { + val session = CompletableDeferred() + var lookups = 0 + val startup = HomeStartupRequest( + { if (++lookups == 1) session.await() else identity }, + { popular }, + { feeds }, + dispatcher, + ) + val first = async { startup.start() } + runCurrent() + val second = startup.start() + session.complete(identity) + runCurrent() + assertNull(first.await()) + assertSame(popular, startup.take(second)!!.popular()) + } +} diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt new file mode 100644 index 000000000..1a683fd6e --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -0,0 +1,1028 @@ +package com.into.websoso.ui.main.home + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.preferencesOf +import androidx.lifecycle.Observer +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModelStore +import com.into.websoso.data.remote.api.AuthApi +import com.into.websoso.data.remote.api.FeedApi +import com.into.websoso.data.remote.api.NotificationApi +import com.into.websoso.data.remote.api.NovelApi +import com.into.websoso.data.remote.api.PushMessageApi +import com.into.websoso.data.remote.api.UserApi +import com.into.websoso.data.remote.response.NotificationUnreadResponseDto +import com.into.websoso.data.remote.response.PopularFeedsResponseDto +import com.into.websoso.data.remote.response.PopularFeedsResponseDto.PopularFeedResponseDto +import com.into.websoso.data.remote.response.PopularNovelsResponseDto +import com.into.websoso.data.remote.response.PopularNovelsResponseDto.PopularNovelResponseDto +import com.into.websoso.data.remote.response.RecommendedNovelsByUserTasteResponseDto +import com.into.websoso.data.remote.response.RecommendedNovelsByUserTasteResponseDto.RecommendedNovelByUserTasteResponseDto +import com.into.websoso.data.remote.response.TermsAgreementResponseDto +import com.into.websoso.data.repository.AuthRepository +import com.into.websoso.data.repository.FeedRepository +import com.into.websoso.data.repository.NotificationRepository +import com.into.websoso.data.repository.NovelRepository +import com.into.websoso.data.repository.PushMessageRepository +import com.into.websoso.data.repository.UserRepository +import com.into.websoso.ui.main.home.model.HomeTasteStatus +import com.into.websoso.ui.main.home.model.HomeUiState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val owners = mutableListOf() + private val calls = mutableMapOf() + private var tasteRequest: suspend () -> RecommendedNovelsByUserTasteResponseDto = { taste(10) } + private var popularRequest: suspend () -> PopularNovelsResponseDto = { popular() } + private var feedRequest: suspend () -> PopularFeedsResponseDto = { feeds() } + private var notificationRequest: suspend () -> NotificationUnreadResponseDto = { NotificationUnreadResponseDto(true) } + private var sessionIdentity = "session-a" + private val startupRequest by lazy { + HomeStartupRequest( + { sessionIdentity }, + { NovelRepository(api()).fetchPopularNovels() }, + { FeedRepository(api()).fetchPopularFeeds() }, + dispatcher, + ) + } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + ArchTaskExecutor.getInstance().setDelegate( + object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }, + ) + } + + @After + fun tearDown() { + owners.forEach(ViewModelStore::clear) + Dispatchers.resetMain() + ArchTaskExecutor.getInstance().setDelegate(null) + } + + @Test + fun `all response orders retain received data and unread state without releasing upper content early`() = + runTest(dispatcher) { + val orders = listOf("pft", "ptf", "fpt", "ftp", "tpf", "tfp").flatMap { order -> + (0..order.length).map { index -> order.substring(0, index) + "n" + order.substring(index) } + } + orders.forEach { order -> + calls.clear() + val popularResponse = CompletableDeferred() + val feedResponse = CompletableDeferred() + val tasteResponse = CompletableDeferred() + val unreadResponse = CompletableDeferred() + popularRequest = { popularResponse.await() } + feedRequest = { feedResponse.await() } + tasteRequest = { tasteResponse.await() } + notificationRequest = { unreadResponse.await() } + val vm = createViewModel() + val displayed = mutableListOf() + val observer = Observer { state -> + if (!state.loading && !state.error) displayed.add(state) + } + vm.uiState.observeForever(observer) + try { + runCurrent() + assertTrue(order, displayed.isEmpty()) + val received = mutableSetOf() + order.forEach { response -> + when (response) { + 'p' -> popularResponse.complete(popular()) + 'f' -> feedResponse.complete(feeds()) + 't' -> tasteResponse.complete(taste(10)) + 'n' -> unreadResponse.complete(NotificationUnreadResponseDto(true)) + } + received.add(response) + runCurrent() + val state = vm.uiState.value!! + val upperReady = 'p' in received && 'f' in received + assertEquals(order, !upperReady, state.loading) + assertEquals(order, 'n' in received, state.isNotificationUnread) + assertEquals(order, 'p' in received, state.popularNovels.isNotEmpty()) + assertEquals(order, 'f' in received, state.popularFeeds.isNotEmpty()) + assertEquals(order, if ('t' in received) 10 else 0, state.recommendedNovelsByUserTaste.size) + assertFalse(order, state.error) + if (!upperReady) assertTrue(order, displayed.isEmpty()) + } + assertTrue(order, displayed.isNotEmpty()) + displayed.forEach { state -> + assertEquals(order, 1, state.popularNovels.size) + assertEquals(order, 1, state.popularFeeds.size) + } + assertEquals(HomeTasteStatus.CONTENT, displayed.last().tasteStatus) + assertEquals(10, displayed.last().recommendedNovelsByUserTaste.size) + assertTrue(displayed.last().isNotificationUnread) + listOf("getPopularNovels", "getPopularFeeds", "getRecommendedNovelsByUserTaste", "getNotificationUnread").forEach { + assertEquals(order, 1, calls[it]) + } + } finally { + vm.uiState.removeObserver(observer) + owners.last().clear() + } + } + } + + @Test + fun `early taste failure is stored while upper content still waits and retry preserves both upper lists`() = + runTest(dispatcher) { + val popularResponse = CompletableDeferred() + val feedResponse = CompletableDeferred() + popularRequest = { popularResponse.await() } + feedRequest = { feedResponse.await() } + tasteRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + + assertTrue(vm.uiState.value!!.loading) + assertFalse(vm.uiState.value!!.error) + assertEquals(HomeTasteStatus.ERROR, vm.uiState.value!!.tasteStatus) + feedResponse.complete(feeds()) + runCurrent() + val firstFeeds = vm.uiState.value!!.popularFeeds + assertTrue(firstFeeds.isNotEmpty()) + assertTrue(vm.uiState.value!!.loading) + + popularResponse.complete(popular()) + runCurrent() + val ready = vm.uiState.value!! + assertFalse(ready.loading) + assertFalse(ready.error) + assertEquals(HomeTasteStatus.ERROR, ready.tasteStatus) + assertSame(firstFeeds, ready.popularFeeds) + + tasteRequest = { taste(0) } + vm.retryTaste() + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + runCurrent() + val retried = vm.uiState.value!! + assertEquals(HomeTasteStatus.EMPTY, retried.tasteStatus) + assertSame(ready.popularNovels, retried.popularNovels) + assertSame(ready.popularFeeds, retried.popularFeeds) + assertTrue(retried.isNotificationUnread) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + assertEquals(2, calls["getRecommendedNovelsByUserTaste"]) + } + + @Test + fun `actual view model publishes upper before taste and keeps unread notification`() = + runTest(dispatcher) { + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + val vm = createViewModel() + runCurrent() + + val upper = vm.uiState.value!! + assertFalse(upper.loading) + assertEquals(1, upper.popularNovels.size) + assertEquals(HomeTasteStatus.LOADING, upper.tasteStatus) + assertTrue(upper.isNotificationUnread) + + pending.complete(taste(10)) + runCurrent() + val complete = vm.uiState.value!! + assertEquals(10, complete.recommendedNovelsByUserTaste.size) + assertEquals(HomeTasteStatus.CONTENT, complete.tasteStatus) + assertTrue(complete.isNotificationUnread) + assertEquals(false, vm.isNotificationPermissionFirstLaunched.value) + } + + @Test + fun `actual retry is taste only rejects duplicate input and recovers empty without hiding upper`() = + runTest(dispatcher) { + tasteRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + val before = vm.uiState.value!! + assertFalse(before.error) + assertEquals(HomeTasteStatus.ERROR, before.tasteStatus) + val upperCalls = calls.filterKeys { it != "getRecommendedNovelsByUserTaste" } + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + + vm.retryTaste() + vm.retryTaste() + runCurrent() + assertEquals(2, calls["getRecommendedNovelsByUserTaste"]) + assertEquals(upperCalls, calls.filterKeys { it != "getRecommendedNovelsByUserTaste" }) + assertSame(before.popularNovels, vm.uiState.value!!.popularNovels) + assertSame(before.popularFeeds, vm.uiState.value!!.popularFeeds) + assertFalse(vm.uiState.value!!.loading) + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + + pending.complete(taste(0)) + runCurrent() + assertEquals(HomeTasteStatus.EMPTY, vm.uiState.value!!.tasteStatus) + assertFalse(vm.uiState.value!!.error) + } + + @Test + fun `updateFeed and updateNovel retain other data and discard replaced taste results`() = + runTest(dispatcher) { + val vm = createViewModel() + runCurrent() + val initialTaste = vm.uiState.value!!.recommendedNovelsByUserTaste + vm.updateFeed() + runCurrent() + assertSame(initialTaste, vm.uiState.value!!.recommendedNovelsByUserTaste) + + val old = CompletableDeferred() + tasteRequest = { withContext(NonCancellable) { old.await() } } + vm.updateNovel() + runCurrent() + assertSame(initialTaste, vm.uiState.value!!.recommendedNovelsByUserTaste) + assertEquals(HomeTasteStatus.CONTENT, vm.uiState.value!!.tasteStatus) + assertFalse(vm.uiState.value!!.loading) + + tasteRequest = { taste(1) } + vm.updateNovel() + runCurrent() + old.complete(taste(8)) + runCurrent() + val latest = vm.uiState.value!! + assertEquals(1, latest.recommendedNovelsByUserTaste.size) + assertEquals(HomeTasteStatus.CONTENT, latest.tasteStatus) + assertEquals(2, calls["getPopularFeeds"]) + assertEquals(3, calls["getPopularNovels"]) + } + + @Test + fun `unauthorized response stays global after later content success or retry`() = + runTest(dispatcher) { + val upper = CompletableDeferred() + popularRequest = { upper.await() } + tasteRequest = { throw http(401) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + val count = calls["getRecommendedNovelsByUserTaste"] + vm.retryTaste() + upper.complete(popular()) + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertEquals(count, calls["getRecommendedNovelsByUserTaste"]) + tasteRequest = { taste(10) } + vm.updateNovel() + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + } + + @Test + fun `forbidden taste response can be retried locally without requesting upper sections`() = + runTest(dispatcher) { + tasteRequest = { throw http(403) } + val vm = createViewModel() + runCurrent() + val before = vm.uiState.value!! + assertFalse(before.error) + assertFalse(before.loading) + assertEquals(HomeTasteStatus.ERROR, before.tasteStatus) + + vm.retryTaste() + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertEquals(HomeTasteStatus.ERROR, vm.uiState.value!!.tasteStatus) + + tasteRequest = { taste(2) } + vm.retryTaste() + runCurrent() + val recovered = vm.uiState.value!! + assertFalse(recovered.error) + assertEquals(HomeTasteStatus.CONTENT, recovered.tasteStatus) + assertEquals(2, recovered.recommendedNovelsByUserTaste.size) + assertSame(before.popularNovels, recovered.popularNovels) + assertSame(before.popularFeeds, recovered.popularFeeds) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + assertEquals(3, calls["getRecommendedNovelsByUserTaste"]) + } + + @Test + fun `forbidden upper and unread failures do not prevent recovery in the same Home owner`() = + runTest(dispatcher) { + listOf("popular", "feeds", "unread").forEach { source -> + val vm = createViewModel() + runCurrent() + assertFalse(vm.uiState.value!!.error) + + popularRequest = { throw http(403) } + feedRequest = { throw http(403) } + notificationRequest = { throw http(403) } + when (source) { + "popular" -> vm.updateNovel() + "feeds" -> vm.updateFeed() + else -> vm.updateNotificationUnread() + } + runCurrent() + assertTrue(source, vm.uiState.value!!.error) + + popularRequest = { popular() } + feedRequest = { feeds() } + notificationRequest = { NotificationUnreadResponseDto(true) } + vm.updateFeed() + runCurrent() + assertFalse(source, vm.uiState.value!!.error) + assertFalse(source, vm.uiState.value!!.loading) + } + } + + @Test + fun `notification failure remains global while taste and upper responses complete`() = + runTest(dispatcher) { + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + notificationRequest = { throw http(401) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + pending.complete(taste(10)) + runCurrent() + assertTrue(vm.uiState.value!!.error) + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + } + + @Test + fun `initial complete success and feed refresh recover ordinary global failures but novel and notification success do not`() = + runTest(dispatcher) { + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + notificationRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + pending.complete(taste(10)) + runCurrent() + val complete = vm.uiState.value!! + assertFalse(complete.error) + assertEquals(10, complete.recommendedNovelsByUserTaste.size) + + vm.updateNotificationUnread() + runCurrent() + assertTrue(vm.uiState.value!!.error) + notificationRequest = { NotificationUnreadResponseDto(false) } + vm.updateNotificationUnread() + vm.updateNovel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertFalse(vm.uiState.value!!.isNotificationUnread) + + vm.updateFeed() + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertFalse(vm.uiState.value!!.isNotificationUnread) + assertEquals(HomeTasteStatus.CONTENT, vm.uiState.value!!.tasteStatus) + } + + @Test + fun `feed failure can recover on next feed success without a global retry button`() = + runTest(dispatcher) { + val vm = createViewModel() + runCurrent() + feedRequest = { throw http(500) } + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + val tasteBefore = calls["getRecommendedNovelsByUserTaste"] + + feedRequest = { feeds() } + vm.updateFeed() + runCurrent() + val recovered = vm.uiState.value!! + assertFalse(recovered.error) + assertEquals(tasteBefore, calls["getRecommendedNovelsByUserTaste"]) + assertTrue(recovered.popularFeeds.isNotEmpty()) + } + + @Test + fun `feed refresh can recover after failed initial upper request and novel success alone does not recover`() = + runTest(dispatcher) { + popularRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + + vm.updateFeed() + runCurrent() + val feedRecovered = vm.uiState.value!! + assertFalse(feedRecovered.error) + assertFalse(feedRecovered.loading) + assertTrue(feedRecovered.popularFeeds.isNotEmpty()) + + vm.updateNovel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + popularRequest = { popular() } + vm.updateNovel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + vm.updateFeed() + runCurrent() + val complete = vm.uiState.value!! + assertFalse(complete.error) + assertTrue(complete.popularNovels.isNotEmpty()) + } + + @Test + fun `partial recovery keeps later taste retry and feed refresh visible`() = + runTest(dispatcher) { + popularRequest = { throw http(500) } + tasteRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + + vm.updateFeed() + runCurrent() + val recovered = vm.uiState.value!! + assertFalse(recovered.error) + assertFalse(recovered.loading) + assertEquals(HomeTasteStatus.ERROR, recovered.tasteStatus) + + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + vm.retryTaste() + vm.retryTaste() + runCurrent() + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + assertEquals(2, calls["getRecommendedNovelsByUserTaste"]) + + pending.complete(taste(2)) + runCurrent() + val retried = vm.uiState.value!! + assertEquals(HomeTasteStatus.CONTENT, retried.tasteStatus) + assertEquals(2, retried.recommendedNovelsByUserTaste.size) + + val updatedFeed = feeds().popularFeeds.single().copy(likeCount = 1) + feedRequest = { PopularFeedsResponseDto(listOf(updatedFeed)) } + vm.updateFeed() + runCurrent() + val refreshed = vm.uiState.value!! + val refreshedPage = refreshed.popularFeeds.single() + assertEquals(1, refreshedPage.single().likeCount) + assertSame(retried.recommendedNovelsByUserTaste, refreshed.recommendedNovelsByUserTaste) + assertTrue(refreshed.popularNovels.isEmpty()) + assertFalse(refreshed.error) + assertFalse(refreshed.loading) + assertTrue(refreshed.isNotificationUnread) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(3, calls["getPopularFeeds"]) + assertEquals(2, calls["getRecommendedNovelsByUserTaste"]) + } + + @Test + fun `recovery during initial loading still waits for both upper results`() = + runTest(dispatcher) { + val upper = CompletableDeferred() + val pendingTaste = CompletableDeferred() + popularRequest = { upper.await() } + tasteRequest = { pendingTaste.await() } + notificationRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertTrue(vm.uiState.value!!.loading) + + vm.updateFeed() + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertTrue(vm.uiState.value!!.loading) + + pendingTaste.complete(taste(2)) + runCurrent() + assertTrue(vm.uiState.value!!.loading) + + upper.complete(popular()) + runCurrent() + val ready = vm.uiState.value!! + assertFalse(ready.error) + assertFalse(ready.loading) + assertEquals(1, ready.popularNovels.size) + assertTrue(ready.popularFeeds.isNotEmpty()) + assertEquals(HomeTasteStatus.CONTENT, ready.tasteStatus) + assertEquals(2, ready.recommendedNovelsByUserTaste.size) + } + + @Test + fun `initial taste failure does not count as complete success or let a later taste success clear global error`() = + runTest(dispatcher) { + notificationRequest = { throw http(500) } + tasteRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + + tasteRequest = { taste(10) } + vm.updateNovel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + vm.updateFeed() + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertEquals(HomeTasteStatus.CONTENT, vm.uiState.value!!.tasteStatus) + } + + @Test + fun `replacing an initial request does not turn its cancellation into complete initial success`() = + runTest(dispatcher) { + val old = CompletableDeferred() + tasteRequest = { withContext(NonCancellable) { old.await() } } + notificationRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + tasteRequest = { taste(2) } + vm.updateNovel() + runCurrent() + old.complete(taste(10)) + runCurrent() + val updated = vm.uiState.value!! + assertTrue(updated.error) + assertEquals(2, updated.recommendedNovelsByUserTaste.size) + vm.updateFeed() + runCurrent() + assertFalse(vm.uiState.value!!.error) + } + + @Test + fun `same preference empty refresh stays visibly empty until an empty or content response`() = + runTest(dispatcher) { + listOf(0, 3).forEach { count -> + tasteRequest = { taste(0) } + val vm = createViewModel() + runCurrent() + val statuses = mutableListOf() + val observer = Observer { statuses.add(it.tasteStatus) } + vm.uiState.observeForever(observer) + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + + // Background refresh and NovelDetailBack both use the default, unchanged preferences. + vm.updateNovel() + runCurrent() + assertTrue(statuses.all { it == HomeTasteStatus.EMPTY }) + pending.complete(taste(count)) + runCurrent() + val updated = vm.uiState.value!! + assertFalse(statuses.contains(HomeTasteStatus.LOADING)) + assertEquals(if (count == 0) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, statuses.last()) + assertEquals(count, updated.recommendedNovelsByUserTaste.size) + vm.uiState.removeObserver(observer) + } + } + + @Test + fun `same preference refresh failures keep content or empty until a later success`() = + runTest(dispatcher) { + listOf(0, 10).forEach { count -> + listOf(IOException("offline"), http(500)).forEach { failure -> + tasteRequest = { taste(count) } + val vm = createViewModel() + runCurrent() + var notices = 0 + val collector = backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + runCurrent() + val before = vm.uiState.value!! + val tasteCalls = calls["getRecommendedNovelsByUserTaste"]!! + tasteRequest = { throw failure } + + vm.updateNovel() + runCurrent() + val retained = vm.uiState.value!! + assertFalse(retained.error) + assertEquals(before.tasteStatus, retained.tasteStatus) + assertSame(before.recommendedNovelsByUserTaste, retained.recommendedNovelsByUserTaste) + assertEquals(tasteCalls + 1, calls["getRecommendedNovelsByUserTaste"]) + assertEquals(1, notices) + + collector.cancel() + runCurrent() + val restarted = backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + runCurrent() + assertEquals(1, notices) + + val nextCount = if (count == 0) 2 else 0 + tasteRequest = { taste(nextCount) } + vm.updateNovel() + runCurrent() + val refreshed = vm.uiState.value!! + assertEquals(if (nextCount == 0) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, refreshed.tasteStatus) + assertEquals(nextCount, refreshed.recommendedNovelsByUserTaste.size) + assertEquals(tasteCalls + 2, calls["getRecommendedNovelsByUserTaste"]) + assertEquals(1, notices) + restarted.cancel() + } + } + } + + @Test + fun `preference change failure never restores previous content or empty and can be retried`() = + runTest(dispatcher) { + listOf(0, 10).forEach { count -> + tasteRequest = { taste(count) } + val vm = createViewModel() + runCurrent() + var notices = 0 + val collector = backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + runCurrent() + tasteRequest = { throw IOException("offline") } + + vm.updateNovel(preferencesChanged = true) + val loading = vm.uiState.value!! + assertEquals(HomeTasteStatus.LOADING, loading.tasteStatus) + assertTrue(loading.recommendedNovelsByUserTaste.isEmpty()) + runCurrent() + val failed = vm.uiState.value!! + assertFalse(failed.error) + assertEquals(HomeTasteStatus.ERROR, failed.tasteStatus) + assertTrue(failed.recommendedNovelsByUserTaste.isEmpty()) + assertEquals(0, notices) + + val upperCalls = calls.filterKeys { it != "getRecommendedNovelsByUserTaste" } + tasteRequest = { taste(2) } + vm.retryTaste() + runCurrent() + val retried = vm.uiState.value!! + assertEquals(HomeTasteStatus.CONTENT, retried.tasteStatus) + assertEquals(2, retried.recommendedNovelsByUserTaste.size) + assertEquals(upperCalls, calls.filterKeys { it != "getRecommendedNovelsByUserTaste" }) + collector.cancel() + } + } + + @Test + fun `unauthorized and forbidden refreshes are not hidden by previously visible taste content`() = + runTest(dispatcher) { + listOf(401, 403).forEach { code -> + tasteRequest = { taste(2) } + val vm = createViewModel() + runCurrent() + var notices = 0 + val collector = backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + runCurrent() + tasteRequest = { throw http(code) } + + vm.updateNovel() + runCurrent() + assertEquals(code == 401, vm.uiState.value!!.error) + if (code == 403) assertEquals(HomeTasteStatus.ERROR, vm.uiState.value!!.tasteStatus) + assertEquals(0, notices) + collector.cancel() + } + } + + @Test + fun `superseded refresh failure cannot restore invalidated taste or emit a refresh notice`() = + runTest(dispatcher) { + val vm = createViewModel() + runCurrent() + var notices = 0 + backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + val old = CompletableDeferred() + tasteRequest = { + withContext(NonCancellable) { + old.await() + throw IOException("old refresh failed") + } + } + vm.updateNovel() + runCurrent() + + tasteRequest = { throw http(500) } + vm.updateNovel(preferencesChanged = true) + runCurrent() + old.complete(Unit) + runCurrent() + + val failed = vm.uiState.value!! + assertFalse(failed.error) + assertEquals(HomeTasteStatus.ERROR, failed.tasteStatus) + assertTrue(failed.recommendedNovelsByUserTaste.isEmpty()) + assertEquals(0, notices) + } + + @Test + fun `profile edit invalidates both empty CTA and existing content while new preferences load`() = + runTest(dispatcher) { + listOf(0, 10).forEach { oldCount -> + tasteRequest = { taste(oldCount) } + val vm = createViewModel() + runCurrent() + val upper = vm.uiState.value!!.popularFeeds + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + + vm.updateNovel(preferencesChanged = true) + val invalidated = vm.uiState.value!! + assertEquals(HomeTasteStatus.LOADING, invalidated.tasteStatus) + assertTrue(invalidated.recommendedNovelsByUserTaste.isEmpty()) + runCurrent() + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + assertSame(upper, vm.uiState.value!!.popularFeeds) + assertFalse(vm.uiState.value!!.loading) + val newCount = if (oldCount == 0) 2 else 0 + pending.complete(taste(newCount)) + runCurrent() + val updated = vm.uiState.value!! + assertEquals(if (newCount == 0) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, updated.tasteStatus) + assertEquals(newCount, updated.recommendedNovelsByUserTaste.size) + } + } + + @Test + fun `cleared owner never receives late data and reentry creates fresh state`() = + runTest(dispatcher) { + val pending = CompletableDeferred() + tasteRequest = { withContext(NonCancellable) { pending.await() } } + val old = createViewModel() + runCurrent() + val left = old.uiState.value + owners.last().clear() + tasteRequest = { taste(0) } + val next = createViewModel() + runCurrent() + pending.complete(taste(10)) + runCurrent() + assertSame(left, old.uiState.value) + assertEquals(HomeTasteStatus.EMPTY, next.uiState.value!!.tasteStatus) + } + + @Test + fun `a new Home owner after authentication recovery does not inherit the expired session failure`() = + runTest(dispatcher) { + tasteRequest = { throw http(401) } + val expired = createViewModel() + runCurrent() + assertTrue(expired.uiState.value!!.error) + // Login and Main navigation clear the old task; simulate its ViewModelStore boundary. + owners.last().clear() + tasteRequest = { taste(10) } + val recovered = createViewModel() + runCurrent() + val ready = recovered.uiState.value!! + assertFalse(ready.error) + assertFalse(ready.loading) + assertEquals(HomeTasteStatus.CONTENT, ready.tasteStatus) + assertEquals(10, ready.recommendedNovelsByUserTaste.size) + + // A later ordinary error remains recoverable in the new session. + feedRequest = { throw http(500) } + recovered.updateFeed() + runCurrent() + assertTrue(recovered.uiState.value!!.error) + feedRequest = { feeds() } + recovered.updateFeed() + runCurrent() + assertFalse(recovered.uiState.value!!.error) + assertTrue(expired.uiState.value!!.error) + } + + @Test + fun `terms and permission state remain available after grouped publication`() = + runTest(dispatcher) { + val vm = createViewModel(termsChecked = false) + runCurrent() + assertTrue(vm.showTermsAgreementDialog.value) + assertEquals(1, calls["getTermsAgreement"]) + vm.updateTermsAgreementDialogState() + assertFalse(vm.showTermsAgreementDialog.value) + vm.updateIsNotificationPermissionFirstLaunched(true) + runCurrent() + assertEquals(true, vm.isNotificationPermissionFirstLaunched.value) + assertEquals(HomeTasteStatus.CONTENT, vm.uiState.value!!.tasteStatus) + // The existing preferences flow rechecks outstanding terms after a setting changes. + assertTrue(vm.showTermsAgreementDialog.value) + assertEquals(2, calls["getTermsAgreement"]) + } + + @Test + fun `startup requests are consumed once and publish upper without waiting for taste`() = + runTest(dispatcher) { + val pendingPopular = CompletableDeferred() + val pendingTaste = CompletableDeferred() + popularRequest = { pendingPopular.await() } + tasteRequest = { pendingTaste.await() } + val id = startupRequest.start() + runCurrent() + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + assertFalse(calls.containsKey("getRecommendedNovelsByUserTaste")) + + val vm = createViewModel(startupId = id) + runCurrent() + assertTrue(vm.uiState.value!!.loading) + pendingPopular.complete(popular()) + runCurrent() + assertFalse(vm.uiState.value!!.loading) + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + assertEquals(1, calls["getRecommendedNovelsByUserTaste"]) + } + + @Test + fun `prefetch failures use existing global failure policy without automatically retrying`() = + runTest(dispatcher) { + popularRequest = { throw http(401) } + val id = startupRequest.start() + runCurrent() + val vm = createViewModel(startupId = id) + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + } + + @Test + fun `changed session and missing startup requests fall back to ordinary Home loading`() = + runTest(dispatcher) { + val id = startupRequest.start() + runCurrent() + sessionIdentity = "session-b" + val vm = createViewModel(startupId = id) + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertFalse(vm.uiState.value!!.loading) + assertEquals(2, calls["getPopularNovels"]) + assertEquals(2, calls["getPopularFeeds"]) + + createViewModel(startupId = id) + runCurrent() + assertEquals(3, calls["getPopularNovels"]) + assertEquals(3, calls["getPopularFeeds"]) + } + + @Test + fun `refresh replaces only its prefetched section and clearing Home cancels the remaining request`() = + runTest(dispatcher) { + var popularCancelled = false + var feedsCancelled = false + popularRequest = { + try { + CompletableDeferred().await() + } finally { + popularCancelled = true + } + } + feedRequest = { + try { + CompletableDeferred().await() + } finally { + feedsCancelled = true + } + } + val id = startupRequest.start() + runCurrent() + val vm = createViewModel(startupId = id) + runCurrent() + popularRequest = { popular() } + vm.updateNovel() + runCurrent() + assertTrue(popularCancelled) + assertFalse(feedsCancelled) + assertEquals(2, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + owners.last().clear() + runCurrent() + assertTrue(feedsCancelled) + } + + @Test + fun `refreshing while startup requests are pending does not start fallback requests in replaced jobs`() = + runTest(dispatcher) { + // Resume Home and startup coroutines in place, as Dispatchers.Main.immediate can on the main thread. + val immediate = UnconfinedTestDispatcher(testScheduler) + Dispatchers.setMain(immediate) + val immediateStartupRequest = HomeStartupRequest( + { sessionIdentity }, + { NovelRepository(api()).fetchPopularNovels() }, + { FeedRepository(api()).fetchPopularFeeds() }, + immediate, + ) + popularRequest = { CompletableDeferred().await() } + feedRequest = { CompletableDeferred().await() } + val id = immediateStartupRequest.start() + runCurrent() + val vm = createViewModel(startupId = id, homeStartupRequest = immediateStartupRequest) + runCurrent() + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + + popularRequest = { popular() } + vm.updateNovel() + runCurrent() + // Startup request + refresh only; the replaced initial job must not fetch again. + assertEquals(2, calls["getPopularNovels"]) + + feedRequest = { feeds() } + vm.updateFeed() + runCurrent() + assertEquals(2, calls["getPopularFeeds"]) + } + + private fun createViewModel( + termsChecked: Boolean = true, + startupId: String? = null, + homeStartupRequest: HomeStartupRequest = startupRequest, + ): HomeViewModel { + val storage = object : DataStore { + override val data = MutableStateFlow( + preferencesOf( + UserRepository.TERMS_AGREEMENT_CHECKED_KEY to termsChecked, + PushMessageRepository.NOTIFICATION_PERMISSION_FIRST_LAUNCHED_KEY to false, + ), + ) + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + transform(data.value).also { data.value = it } + } + val user = UserRepository(api(), storage) + return HomeViewModel( + NovelRepository(api()), + FeedRepository(api()), + PushMessageRepository(user, AuthRepository(api()), storage, api()), + NotificationRepository(api()), + user, + SavedStateHandle(mapOf(HomeStartupRequest.KEY to startupId)), + homeStartupRequest, + ).also { vm -> + owners.add(ViewModelStore().apply { put("home", vm) }) + } + } + + // Only Retrofit interfaces are doubled; repositories and the ViewModel are production code. + @Suppress("UNCHECKED_CAST") + private inline fun api(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, args -> + val call: suspend () -> Any = { + calls[method.name] = (calls[method.name] ?: 0) + 1 + when (method.name) { + "getPopularNovels" -> popularRequest() + "getPopularFeeds" -> feedRequest() + "getRecommendedNovelsByUserTaste" -> tasteRequest() + "getNotificationUnread" -> notificationRequest() + "getTermsAgreement" -> TermsAgreementResponseDto(false, false, false) + else -> error("Unexpected API call: ${method.name}") + } + } + call.startCoroutineUninterceptedOrReturn(args.last() as Continuation) + } as T + + private fun http(code: Int): HttpException = HttpException(Response.error(code, "synthetic".toResponseBody())) + + private fun popular(): PopularNovelsResponseDto = + PopularNovelsResponseDto(listOf(PopularNovelResponseDto(novelId = 1, novelImage = "image", title = "title"))) + + private fun feeds(): PopularFeedsResponseDto = + PopularFeedsResponseDto( + listOf(PopularFeedResponseDto(1, "content", 0, 0, false, true, "title", "image", "fantasy")), + ) + + private fun taste(count: Int): RecommendedNovelsByUserTasteResponseDto = + RecommendedNovelsByUserTasteResponseDto( + (1L..count.toLong()).map { RecommendedNovelByUserTasteResponseDto(it, "title", "author", "image", 0, 0.0, 0) }, + ) +} diff --git a/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt new file mode 100644 index 000000000..c9eb41b4e --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt @@ -0,0 +1,244 @@ +package com.into.websoso.ui.profileEdit + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.preferencesOf +import androidx.lifecycle.ViewModelStore +import com.into.websoso.data.remote.api.AvatarApi +import com.into.websoso.data.remote.api.UserApi +import com.into.websoso.data.remote.request.UserProfileEditRequestDto +import com.into.websoso.data.remote.response.AvatarsResponseDto +import com.into.websoso.data.remote.response.MyProfileResponseDto +import com.into.websoso.data.remote.response.UserNicknameValidityResponseDto +import com.into.websoso.data.repository.AvatarRepository +import com.into.websoso.data.repository.UserRepository +import com.into.websoso.domain.usecase.CheckNicknameValidityUseCase +import com.into.websoso.ui.profileEdit.model.AvatarModel +import com.into.websoso.ui.profileEdit.model.Genre +import com.into.websoso.ui.profileEdit.model.ProfileEditResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileEditViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val owners = mutableListOf() + private val savedRequests = mutableListOf() + private var saveRequest: suspend () -> Unit = {} + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + ArchTaskExecutor.getInstance().setDelegate( + object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }, + ) + } + + @After + fun tearDown() { + owners.forEach(ViewModelStore::clear) + Dispatchers.resetMain() + ArchTaskExecutor.getInstance().setDelegate(null) + } + + @Test + fun `nickname introduction and avatar edits do not report a genre change`() = + runTest(dispatcher) { + listOf("nickname", "introduction", "avatar").forEach { field -> + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + when (field) { + "nickname" -> { + vm.updateNickname("newname") + vm.checkNicknameValidity("newname") + } + + "introduction" -> { + vm.updateIntroduction("new introduction") + } + + "avatar" -> { + vm.updateSelectedAvatar(AvatarModel(avatarId = 2)) + vm.updateRepresentativeAvatar() + } + } + runCurrent() + vm.updateProfile() + runCurrent() + + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertFalse(result.genrePreferencesChanged) + assertEquals(listOf("fantasy", "romance"), savedRequests.last().genrePreferences) + } + assertEquals(3, savedRequests.size) + } + + @Test + fun `adding removing or clearing genres reports a genre change`() = + runTest(dispatcher) { + listOf( + listOf(Genre.MYSTERY), + listOf(Genre.FANTASY), + listOf(Genre.FANTASY, Genre.ROMANCE), + ).forEach { toggles -> + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + toggles.forEach(vm::updateSelectedGenres) + vm.updateProfile() + runCurrent() + + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertTrue(result.genrePreferencesChanged) + } + assertTrue(savedRequests.last().genrePreferences.isEmpty()) + } + + @Test + fun `restoring the same genres in a different order does not report a change`() = + runTest(dispatcher) { + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + vm.updateSelectedGenres(Genre.FANTASY) + vm.updateSelectedGenres(Genre.FANTASY) + vm.updateIntroduction("new introduction") + vm.updateProfile() + runCurrent() + + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertFalse(result.genrePreferencesChanged) + assertEquals(listOf("romance", "fantasy"), savedRequests.single().genrePreferences) + } + + @Test + fun `success describes the saved request even if selection changes while saving`() = + runTest(dispatcher) { + listOf(false, true).forEach { submittedChange -> + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + val pending = CompletableDeferred() + saveRequest = { pending.await() } + if (submittedChange) vm.updateSelectedGenres(Genre.MYSTERY) + vm.updateIntroduction("new introduction") + vm.updateProfile() + runCurrent() + assertFalse(vm.profileEditUiState.value!!.profileEditResult is ProfileEditResult.Success) + + vm.updateSelectedGenres(Genre.MYSTERY) + pending.complete(Unit) + runCurrent() + + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertEquals(submittedChange, result.genrePreferencesChanged) + assertEquals( + submittedChange, + savedRequests.last().genrePreferences.contains("mystery"), + ) + } + } + + @Test + fun `failed save does not publish success and a successful retry reports the saved change`() = + runTest(dispatcher) { + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + vm.updateSelectedGenres(Genre.MYSTERY) + saveRequest = { throw IOException("offline") } + vm.updateProfile() + runCurrent() + assertFalse(vm.profileEditUiState.value!!.profileEditResult is ProfileEditResult.Success) + + saveRequest = {} + vm.updateProfile() + runCurrent() + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertTrue(result.genrePreferencesChanged) + assertEquals(2, savedRequests.size) + } + + private fun createViewModel(): ProfileEditViewModel { + val storage = object : DataStore { + override val data = MutableStateFlow(preferencesOf()) + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + transform(data.value).also { data.value = it } + } + return ProfileEditViewModel( + CheckNicknameValidityUseCase(), + UserRepository(api(), storage), + AvatarRepository(api()), + ).also { vm -> owners.add(ViewModelStore().apply { put("profile", vm) }) } + } + + @Suppress("UNCHECKED_CAST") + private inline fun api(): T = + Proxy.newProxyInstance( + T::class.java.classLoader, + arrayOf(T::class.java), + ) { _, method, args -> + val call: suspend () -> Any = { + when (method.name) { + "getMyProfile" -> { + MyProfileResponseDto( + "nickname", + "introduction", + "", + listOf("fantasy", "romance"), + ) + } + + "getAvatars" -> { + AvatarsResponseDto(emptyList()) + } + + "getNicknameValidity" -> { + UserNicknameValidityResponseDto(true) + } + + "patchProfile" -> { + savedRequests.add(args.first() as UserProfileEditRequestDto) + saveRequest() + } + + else -> { + error("Unexpected API call: ${method.name}") + } + } + } + call.startCoroutineUninterceptedOrReturn(args.last() as Continuation) + } as T +} diff --git a/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt new file mode 100644 index 000000000..4252c5147 --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt @@ -0,0 +1,503 @@ +package com.into.websoso.ui.splash + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.preferencesOf +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import com.into.websoso.core.common.util.collectWithLifecycle +import com.into.websoso.data.account.AccountRepository +import com.into.websoso.data.account.datasource.AccountLocalDataSource +import com.into.websoso.data.account.datasource.AccountRemoteDataSource +import com.into.websoso.data.account.model.TokenEntity +import com.into.websoso.data.model.PopularFeedEntity +import com.into.websoso.data.model.PopularNovelsEntity +import com.into.websoso.data.remote.api.UserApi +import com.into.websoso.data.remote.api.VersionApi +import com.into.websoso.data.remote.response.MinimumVersionResponseDto +import com.into.websoso.data.repository.UserRepository +import com.into.websoso.data.repository.VersionRepository +import com.into.websoso.ui.main.home.HomeStartupRequest +import dagger.Lazy +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn + +@OptIn(ExperimentalCoroutinesApi::class) +class SplashViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val owners = mutableListOf() + private val lifecycles = mutableListOf() + private val calls = mutableListOf() + private var accessToken = "synthetic-access" + private var refreshToken = "synthetic-refresh" + private var versionRequest: suspend () -> MinimumVersionResponseDto = { + MinimumVersionResponseDto("0.0.0", "2026-10-05") + } + private var tokenRequest: suspend () -> TokenEntity = { + delay(300) + TokenEntity("synthetic-new-access", "synthetic-new-refresh") + } + private var startupSession: suspend () -> String = { accessToken } + private var startedRequests = 0 + private var cancelledRequests = 0 + private val startup by lazy { + HomeStartupRequest( + sessionIdentity = { + calls.add("startupSession") + startupSession() + }, + fetchPopular = { pendingResponse() }, + fetchFeeds = { pendingResponse>() }, + dispatcher = dispatcher, + ) + } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + lifecycles.forEach { it.currentState = Lifecycle.State.DESTROYED } + owners.forEach(ViewModelStore::clear) + dispatcher.scheduler.runCurrent() + Dispatchers.resetMain() + } + + @Test + fun `successful authentication starts Home requests during the minimum display after version approval`() = + runTest(dispatcher) { + versionRequest = { + delay(200) + MinimumVersionResponseDto("0.0.0", "2026-10-05") + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + runCurrent() + assertFalse(calls.contains("postReissue")) + + advanceTimeBy(200) + runCurrent() + assertTrue(calls.contains("postReissue")) + advanceTimeBy(299) + runCurrent() + assertFalse(calls.contains("startupSession")) + assertEquals(0, startedRequests) + + advanceTimeBy(1) + runCurrent() + assertEquals("synthetic-new-access", accessToken) + assertEquals(2, startedRequests) + assertFalse(destination.isCompleted) + + advanceTimeBy(699) + runCurrent() + assertFalse(destination.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertNotNull((destination.await() as UiEffect.NavigateToMain).startupRequestId) + assertEquals(1, calls.count { it == "postReissue" }) + assertEquals(1, calls.count { it == "startupSession" }) + } + + @Test + fun `slow authentication navigates when ready without another display delay`() = + runTest(dispatcher) { + tokenRequest = { + delay(1500) + TokenEntity("synthetic-new-access", "synthetic-new-refresh") + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + runCurrent() + assertTrue(calls.contains("postReissue")) + advanceTimeBy(1499) + runCurrent() + assertFalse(destination.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertNotNull((destination.await() as UiEffect.NavigateToMain).startupRequestId) + } + + @Test + fun `missing session waits for minimum display and navigates to login without refreshing`() = + runTest(dispatcher) { + accessToken = "" + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(999) + runCurrent() + assertFalse(destination.isCompleted) + assertFalse(calls.contains("postReissue")) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.NavigateToLogin, destination.await()) + assertFalse(calls.contains("startupSession")) + } + + @Test + fun `failed authentication preserves the minimum display and login destination`() = + runTest(dispatcher) { + tokenRequest = { + delay(300) + throw IOException("synthetic failure") + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(999) + runCurrent() + assertFalse(destination.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.NavigateToLogin, destination.await()) + assertEquals(1, calls.count { it == "postReissue" }) + assertFalse(calls.contains("startupSession")) + } + + @Test + fun `required update prevents authentication and navigation`() = + runTest(dispatcher) { + versionRequest = { MinimumVersionResponseDto("999999.0.0", "2026-10-05") } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.ShowDialog, destination.await()) + advanceTimeBy(2000) + runCurrent() + assertEquals(listOf("getMinimumVersion"), calls) + } + + @Test + fun `clearing Splash cancels authentication and prevents late navigation`() = + runTest(dispatcher) { + var cancelled = false + tokenRequest = { + try { + CompletableDeferred().await() + } finally { + cancelled = true + } + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + runCurrent() + assertTrue(calls.contains("postReissue")) + + owners.single().clear() + advanceTimeBy(2000) + runCurrent() + assertTrue(cancelled) + assertFalse(destination.isCompleted) + destination.cancel() + } + + @Test + fun `clearing Splash during the remaining minimum display cancels Home requests and navigation`() = + runTest(dispatcher) { + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(300) + runCurrent() + assertEquals(2, startedRequests) + assertFalse(destination.isCompleted) + + owners.single().clear() + runCurrent() + assertEquals(2, cancelledRequests) + + advanceTimeBy(1000) + runCurrent() + assertFalse(destination.isCompleted) + destination.cancel() + } + + @Test + fun `a deep link during the remaining minimum display cancels Home requests without early navigation`() = + runTest(dispatcher) { + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(300) + runCurrent() + assertEquals(2, startedRequests) + assertFalse(destination.isCompleted) + + vm.start(isHomeDestination = false) + runCurrent() + assertEquals(2, cancelledRequests) + assertFalse(destination.isCompleted) + + advanceTimeBy(699) + runCurrent() + assertFalse(destination.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.NavigateToMain(null), destination.await()) + assertEquals(1, calls.count { it == "postReissue" }) + assertEquals(1, calls.count { it == "startupSession" }) + } + + @Test + fun `session lookup finishing in background waits for a recreated started collector and navigates once`() = + runTest(dispatcher) { + val session = CompletableDeferred() + startupSession = { session.await() } + val vm = createViewModel() + val effects = mutableListOf() + val firstActivity = activityLifecycle() + vm.uiEffect.collectWithLifecycle(firstActivity) { effects.add(it) } + firstActivity.lifecycle.currentState = Lifecycle.State.STARTED + advanceTimeBy(300) + runCurrent() + assertTrue(calls.contains("startupSession")) + assertTrue(effects.isEmpty()) + + firstActivity.lifecycle.currentState = Lifecycle.State.CREATED + runCurrent() + session.complete(accessToken) + runCurrent() + assertEquals(2, startedRequests) + assertTrue(effects.isEmpty()) + + advanceTimeBy(700) + runCurrent() + assertTrue(effects.isEmpty()) + + firstActivity.lifecycle.currentState = Lifecycle.State.DESTROYED + runCurrent() + val recreatedActivity = activityLifecycle() + vm.start(isHomeDestination = true) + vm.uiEffect.collectWithLifecycle(recreatedActivity) { effect -> + effects.add(effect) + vm.onUiEffectHandled(effect) + } + runCurrent() + assertTrue(effects.isEmpty()) + recreatedActivity.lifecycle.currentState = Lifecycle.State.STARTED + runCurrent() + val effect = effects.single() as UiEffect.NavigateToMain + assertNotNull(effect.startupRequestId) + assertEquals(1, calls.count { it == "postReissue" }) + assertEquals(1, calls.count { it == "startupSession" }) + + recreatedActivity.lifecycle.currentState = Lifecycle.State.CREATED + runCurrent() + recreatedActivity.lifecycle.currentState = Lifecycle.State.STARTED + runCurrent() + assertEquals(1, effects.size) + + // Main may receive the ID before Home is ready to take its requests. + owners.single().clear() + runCurrent() + assertEquals(0, cancelledRequests) + val transferred = startup.take(effect.startupRequestId) + assertNotNull(transferred) + transferred!!.cancel() + runCurrent() + assertEquals(2, cancelledRequests) + } + + @Test + fun `an unhandled destination is retained and clearing Splash discards its requests`() = + runTest(dispatcher) { + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + val effect = vm.uiEffect.first() as UiEffect.NavigateToMain + assertEquals(effect, vm.uiEffect.first()) + assertEquals(2, startedRequests) + + owners.single().clear() + runCurrent() + assertEquals(2, cancelledRequests) + assertNull(startup.take(effect.startupRequestId)) + } + + @Test + fun `failed navigation discards the untransferred request and destination`() = + runTest(dispatcher) { + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + val effect = vm.uiEffect.first() as UiEffect.NavigateToMain + + vm.onNavigationFailed() + runCurrent() + assertEquals(2, cancelledRequests) + assertNull(startup.take(effect.startupRequestId)) + val next = async { vm.uiEffect.first() } + runCurrent() + assertFalse(next.isCompleted) + next.cancel() + } + + @Test + fun `collection destination skips startup requests`() = + runTest(dispatcher) { + val vm = createViewModel(isHomeDestination = false) + advanceTimeBy(1000) + runCurrent() + assertEquals(UiEffect.NavigateToMain(null), vm.uiEffect.first()) + assertFalse(calls.contains("startupSession")) + assertEquals(0, startedRequests) + } + + @Test + fun `a deep link arriving during session lookup prevents transfer`() = + runTest(dispatcher) { + val session = CompletableDeferred() + startupSession = { session.await() } + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + assertTrue(calls.contains("startupSession")) + + vm.start(isHomeDestination = false) + session.complete(accessToken) + runCurrent() + assertEquals(UiEffect.NavigateToMain(null), vm.uiEffect.first()) + assertEquals(0, startedRequests) + } + + @Test + fun `a deep link arriving after preparation discards requests and replaces the pending ID`() = + runTest(dispatcher) { + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + val effect = vm.uiEffect.first() as UiEffect.NavigateToMain + + vm.start(isHomeDestination = false) + runCurrent() + assertEquals(UiEffect.NavigateToMain(null), vm.uiEffect.first()) + assertNull(startup.take(effect.startupRequestId)) + assertEquals(2, cancelledRequests) + assertEquals(1, calls.count { it == "postReissue" }) + } + + @Test + fun `clearing Splash during session lookup cancels preparation without a destination`() = + runTest(dispatcher) { + var cancelled = false + startupSession = { + try { + CompletableDeferred().await() + } finally { + cancelled = true + } + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(1000) + runCurrent() + assertTrue(calls.contains("startupSession")) + + owners.single().clear() + runCurrent() + assertTrue(cancelled) + assertEquals(0, startedRequests) + assertFalse(destination.isCompleted) + destination.cancel() + } + + @Test + fun `failed startup session lookup still navigates without a request ID`() = + runTest(dispatcher) { + startupSession = { throw IOException("synthetic setup failure") } + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + assertEquals(UiEffect.NavigateToMain(null), vm.uiEffect.first()) + assertEquals(0, startedRequests) + } + + private fun activityLifecycle() = + object : LifecycleOwner { + override val lifecycle = LifecycleRegistry.createUnsafe(this).also { + it.currentState = Lifecycle.State.CREATED + lifecycles.add(it) + } + } + + private suspend fun pendingResponse(): T { + startedRequests++ + try { + return CompletableDeferred().await() + } finally { + cancelledRequests++ + } + } + + private fun createViewModel(isHomeDestination: Boolean = true): SplashViewModel { + val storage = object : DataStore { + override val data = MutableStateFlow(preferencesOf()) + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + transform(data.value).also { data.value = it } + } + return SplashViewModel( + VersionRepository(api()), + UserRepository(api(), storage), + AccountRepository(api(), api()), + Lazy { startup }, + ).also { vm -> + owners.add(ViewModelStore().apply { put("splash", vm) }) + vm.start(isHomeDestination) + } + } + + // Keep repositories real; replace only their external data sources with synthetic inputs. + @Suppress("UNCHECKED_CAST") + private inline fun api(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, args -> + val call: suspend () -> Any = { + calls.add(method.name) + when (method.name) { + "getMinimumVersion" -> versionRequest() + "postReissue" -> tokenRequest() + "selectAccessToken" -> accessToken + "selectRefreshToken" -> refreshToken + "updateAccessToken" -> accessToken = args.first() as String + "updateRefreshToken" -> refreshToken = args.first() as String + else -> error("Unexpected data source call: ${method.name}") + } + } + call.startCoroutineUninterceptedOrReturn(args.last() as Continuation) + } as T +} diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index a18bd759d..d06530671 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -241,6 +241,9 @@ + 오늘의 발견 + 지금 뜨는 글 %s님을 위한 추천글 + 네트워크 연결에 실패했어요 + 작품 다시 불러오기 + 취향 추천을 새로 불러오지 못했어요 관심 등록한 작품의 최신 글이에요 로맨스, 로판, 판타지, 현판 등\n선호장르를 기반으로 웹소설을 추천해드려요! 선호장르를 기반으로 추천해드려요