diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentAction.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentAction.kt new file mode 100644 index 00000000..07d6872a --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentAction.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.v2.content.action + +import kr.co.vividnext.sodalive.v2.access.AccessRequirement + +class ContentAction { + fun execute( + command: ContentActionCommand, + ensureAccess: (AccessRequirement) -> Boolean + ): ContentActionResult { + val isValidId = when (command) { + is ContentActionCommand.AudioDetail -> command.audioContentId > 0L + is ContentActionCommand.SeriesDetail -> command.seriesId > 0L + } + if (!isValidId) return ContentActionResult.Ignored + + val requiresAdultContentAccess = when (command) { + is ContentActionCommand.AudioDetail -> command.requiresAdultContentAccess + is ContentActionCommand.SeriesDetail -> command.requiresAdultContentAccess + } + val requirement = AccessRequirement.forAdultContent(requiresAdultContentAccess) + if (!ensureAccess(requirement)) return ContentActionResult.Blocked(requirement) + + return when (command) { + is ContentActionCommand.AudioDetail -> + ContentActionResult.NavigateToAudioDetail(command.audioContentId) + is ContentActionCommand.SeriesDetail -> + ContentActionResult.NavigateToSeriesDetail(command.seriesId) + } + } +} + +sealed interface ContentActionResult { + data object Ignored : ContentActionResult + + data class Blocked( + val requirement: AccessRequirement + ) : ContentActionResult + + data class NavigateToAudioDetail( + val audioContentId: Long + ) : ContentActionResult + + data class NavigateToSeriesDetail( + val seriesId: Long + ) : ContentActionResult +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionCommand.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionCommand.kt new file mode 100644 index 00000000..1b0c991e --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionCommand.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.content.action + +sealed interface ContentActionCommand { + data class AudioDetail( + val audioContentId: Long, + val requiresAdultContentAccess: Boolean = false + ) : ContentActionCommand + + data class SeriesDetail( + val seriesId: Long, + val requiresAdultContentAccess: Boolean = false + ) : ContentActionCommand +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionHandler.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionHandler.kt new file mode 100644 index 00000000..80bdea94 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionHandler.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.v2.content.action + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.fragment.app.Fragment +import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity +import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity +import kr.co.vividnext.sodalive.common.Constants +import kr.co.vividnext.sodalive.v2.access.AccessRequirement +import kr.co.vividnext.sodalive.v2.access.ensureV2Access + +internal class ContentActionHandler( + private val action: ContentAction = ContentAction() +) { + fun handle( + context: Context, + command: ContentActionCommand, + ensureAccess: (AccessRequirement) -> Boolean + ): ContentActionResult { + val result = action.execute(command, ensureAccess) + when (result) { + is ContentActionResult.NavigateToAudioDetail -> context.startActivity( + Intent(context, AudioContentDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, result.audioContentId) + } + ) + is ContentActionResult.NavigateToSeriesDetail -> context.startActivity( + Intent(context, SeriesDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_SERIES_ID, result.seriesId) + } + ) + is ContentActionResult.Blocked, + ContentActionResult.Ignored -> Unit + } + return result + } +} + +fun Activity.handleContentAction(command: ContentActionCommand): ContentActionResult { + return ContentActionHandler().handle(this, command, ::ensureV2Access) +} + +fun Fragment.handleContentAction(command: ContentActionCommand): ContentActionResult { + return ContentActionHandler().handle(requireContext(), command, ::ensureV2Access) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt index cb8839ca..e7070f5e 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt @@ -25,8 +25,6 @@ import com.google.gson.Gson import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.schedulers.Schedulers import kr.co.vividnext.sodalive.R -import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity -import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity @@ -49,6 +47,8 @@ import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog import kr.co.vividnext.sodalive.report.UserReportDialog import kr.co.vividnext.sodalive.v2.access.AccessRequirement import kr.co.vividnext.sodalive.v2.access.ensureV2Access +import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand +import kr.co.vividnext.sodalive.v2.content.action.handleContentAction import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog import kr.co.vividnext.sodalive.v2.common.CreatorActivityType import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioFragment @@ -561,8 +561,11 @@ class CreatorChannelActivity : override fun isCreatorChannelOwner(): Boolean = currentHeader?.isOwner == true - override fun onCreatorChannelAudioContentClicked(audioContentId: Long) { - startAudioContentDetail(audioContentId) + override fun onCreatorChannelAudioContentClicked( + audioContentId: Long, + requiresAdultContentAccess: Boolean + ) { + openAudioContentDetail(audioContentId, requiresAdultContentAccess) } override fun onCreatorChannelAudioContentChanged() { @@ -572,12 +575,11 @@ class CreatorChannelActivity : } } - override fun onCreatorChannelSeriesClicked(seriesId: Long) { - startActivity( - Intent(this, SeriesDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_SERIES_ID, seriesId) - } - ) + override fun onCreatorChannelSeriesClicked( + seriesId: Long, + requiresAdultContentAccess: Boolean + ) { + openSeriesDetail(seriesId, requiresAdultContentAccess) } override fun onCreatorChannelSeriesContentChanged() { @@ -964,8 +966,11 @@ class CreatorChannelActivity : } } - override fun onCreatorChannelLiveReplayClicked(audioContentId: Long) { - startAudioContentDetail(audioContentId) + override fun onCreatorChannelLiveReplayClicked( + audioContentId: Long, + requiresAdultContentAccess: Boolean + ) { + openAudioContentDetail(audioContentId, requiresAdultContentAccess) } private fun findLiveFragment(): CreatorChannelLiveFragment? { @@ -1086,11 +1091,7 @@ class CreatorChannelActivity : private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse) { when (schedule.type) { CreatorActivityType.Audio, - CreatorActivityType.LiveReplay -> startActivity( - Intent(this, AudioContentDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, schedule.targetId) - } - ) + CreatorActivityType.LiveReplay -> openAudioContentDetail(schedule.targetId) CreatorActivityType.Live -> liveCoordinator.showLiveRoomDetail(schedule.targetId) @@ -1099,22 +1100,34 @@ class CreatorChannelActivity : } private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse) { - startAudioContentDetail(audioContent.audioContentId) + openAudioContentDetail(audioContent.audioContentId, audioContent.isAdult) } - private fun startAudioContentDetail(audioContentId: Long) { - startActivity( - Intent(this, AudioContentDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId) - } + private fun openAudioContentDetail( + audioContentId: Long, + requiresAdultContentAccess: Boolean = false + ) { + handleContentAction( + ContentActionCommand.AudioDetail( + audioContentId = audioContentId, + requiresAdultContentAccess = requiresAdultContentAccess + ) ) } private fun onSeriesClicked(series: CreatorChannelSeriesResponse) { - startActivity( - Intent(this, SeriesDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_SERIES_ID, series.seriesId) - } + openSeriesDetail(series.seriesId) + } + + private fun openSeriesDetail( + seriesId: Long, + requiresAdultContentAccess: Boolean = false + ) { + handleContentAction( + ContentActionCommand.SeriesDetail( + seriesId = seriesId, + requiresAdultContentAccess = requiresAdultContentAccess + ) ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioFragment.kt index 8d5332b4..40ceabff 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioFragment.kt @@ -27,7 +27,7 @@ class CreatorChannelAudioFragment : BaseFragment - host.onCreatorChannelAudioContentClicked(item.audioContentId) + host.onCreatorChannelAudioContentClicked(item.audioContentId, item.showAdultBadge) } private var sortPopup: CreatorChannelSortPopup? = null private var currentContentState: CreatorChannelAudioUiState.Content? = null @@ -238,7 +238,10 @@ class CreatorChannelAudioFragment : BaseFragment - host.onCreatorChannelLiveReplayClicked(item.audioContentId) + host.onCreatorChannelLiveReplayClicked(item.audioContentId, item.showAdultBadge) } private var lastContentLayoutKey: CreatorChannelLiveContentLayoutKey? = null private var sortPopup: CreatorChannelSortPopup? = null @@ -189,7 +189,10 @@ class CreatorChannelLiveFragment : BaseFragment - host.onCreatorChannelSeriesClicked(seriesId) + private val seriesAdapter = CreatorChannelSeriesAdapter { item -> + host.onCreatorChannelSeriesClicked(item.seriesId, item.showAdultBadge) } private var sortPopup: CreatorChannelSortPopup? = null private var currentContentState: CreatorChannelSeriesUiState.Content? = null @@ -154,7 +154,10 @@ class CreatorChannelSeriesFragment : BaseFragment Unit = {} + private val onSeriesClicked: (CreatorChannelSeriesItemUiModel) -> Unit = {} ) : RecyclerView.Adapter() { private var items: List = emptyList() @@ -40,7 +40,7 @@ class CreatorChannelSeriesAdapter( class ViewHolder( private val binding: ItemCreatorChannelSeriesBinding, - private val onSeriesClicked: (Long) -> Unit + private val onSeriesClicked: (CreatorChannelSeriesItemUiModel) -> Unit ) : RecyclerView.ViewHolder(binding.root) { init { @@ -65,7 +65,7 @@ class CreatorChannelSeriesAdapter( tvCreatorChannelSeriesTitle.text = item.title tvCreatorChannelSeriesSubtitle.text = formatSubtitle(item.subtitle) bindProgress(item) - root.setOnClickListener { onSeriesClicked(item.seriesId) } + root.setOnClickListener { onSeriesClicked(item) } } private fun formatSubtitle(subtitle: CreatorChannelSeriesSubtitleUiModel): String { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt index d0500866..1b63ef5d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt @@ -14,10 +14,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.audio_content.box.AudioContentBoxActivity -import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity -import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.base.BaseFragment -import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding @@ -31,6 +28,8 @@ import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup import kr.co.vividnext.sodalive.v2.main.MainV2Activity import kr.co.vividnext.sodalive.v2.access.AccessRequirement import kr.co.vividnext.sodalive.v2.access.ensureV2Access +import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand +import kr.co.vividnext.sodalive.v2.content.action.handleContentAction import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState @@ -38,6 +37,7 @@ import kr.co.vividnext.sodalive.v2.main.content.model.AudioRecommendationsUiStat import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardSection import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardUiModel import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerSection +import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerRoute import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerUiModel import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioSection import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioUiModel @@ -616,8 +616,13 @@ class ContentMainFragment : BaseFragment( private fun onBannerClick(item: ContentBannerUiModel) { val route = item.toContentBannerRoute() ?: return - ensureV2Access(AccessRequirement.Login) { - startActivity(route.toContentBannerIntent(requireContext())) + when (route) { + is ContentBannerRoute.Series -> handleContentAction( + ContentActionCommand.SeriesDetail(seriesId = route.seriesId) + ) + else -> ensureV2Access(AccessRequirement.Login) { + route.toContentBannerIntent(requireContext())?.let(::startActivity) + } } } @@ -645,38 +650,33 @@ class ContentMainFragment : BaseFragment( audioContentId: Long, requiresAdultContentAccess: Boolean = false ) { - if (audioContentId <= 0L) return - ensureV2Access(AccessRequirement.forAdultContent(requiresAdultContentAccess)) { - startActivity( - Intent(requireContext(), AudioContentDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId) - } + handleContentAction( + ContentActionCommand.AudioDetail( + audioContentId = audioContentId, + requiresAdultContentAccess = requiresAdultContentAccess ) - } + ) } private fun openRankingAudioContentDetail(item: ContentRankingItem) { - val audioContentId = item.contentId.toLongOrNull()?.takeIf { it > 0L } ?: return + val audioContentId = item.contentId.toLongOrNull() ?: return openAudioContentDetail(audioContentId) } private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) { - val seriesId = item.seriesId.takeIf { it > 0L } ?: return - openSeriesDetail(seriesId) + openSeriesDetail(item.seriesId) } private fun openSeriesDetail( seriesId: Long, requiresAdultContentAccess: Boolean = false ) { - if (seriesId <= 0L) return - ensureV2Access(AccessRequirement.forAdultContent(requiresAdultContentAccess)) { - startActivity( - Intent(requireContext(), SeriesDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_SERIES_ID, seriesId) - } + handleContentAction( + ContentActionCommand.SeriesDetail( + seriesId = seriesId, + requiresAdultContentAccess = requiresAdultContentAccess ) - } + ) } private fun findAllTabAudioAdultAccess(audioContentId: Long): Boolean { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt index a5ef012c..a4e2d93d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.net.Uri import kr.co.vividnext.sodalive.BuildConfig -import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventItem @@ -63,7 +62,7 @@ fun ContentBannerUiModel.toContentBannerRoute(): ContentBannerRoute? { ) } -fun ContentBannerRoute.toContentBannerIntent(context: Context): Intent { +fun ContentBannerRoute.toContentBannerIntent(context: Context): Intent? { return when (this) { is ContentBannerRoute.Event -> Intent(context, EventDetailActivity::class.java).apply { putExtra(Constants.EXTRA_EVENT, eventItem) @@ -71,9 +70,7 @@ fun ContentBannerRoute.toContentBannerIntent(context: Context): Intent { is ContentBannerRoute.Creator -> CreatorChannelActivity.newIntent(context, creatorId) - is ContentBannerRoute.Series -> Intent(context, SeriesDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_SERIES_ID, seriesId) - } + is ContentBannerRoute.Series -> null is ContentBannerRoute.Link -> Intent(Intent.ACTION_VIEW, Uri.parse(url)) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt index 7f3132d3..09502f70 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt @@ -8,11 +8,12 @@ import androidx.core.view.doOnLayout import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R -import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.base.BaseActivity -import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.databinding.ActivityContentOverviewBinding +import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand +import kr.co.vividnext.sodalive.v2.content.action.handleContentAction import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType +import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiModel import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiState import kr.co.vividnext.sodalive.v2.main.content.overview.model.toTitleResId import kr.co.vividnext.sodalive.v2.main.content.overview.ui.ContentOverviewAdapter @@ -125,13 +126,12 @@ class ContentOverviewActivity : BaseActivity( ?: toastMessage.resId?.let { resId -> showToast(getString(resId)) } } - private fun openAudioContentDetail(contentId: Long) { - if (contentId <= 0) return - - startActivity( - Intent(this, AudioContentDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId) - } + private fun openAudioContentDetail(item: ContentOverviewUiModel) { + handleContentAction( + ContentActionCommand.AudioDetail( + audioContentId = item.contentId, + requiresAdultContentAccess = item.showAdultBadge + ) ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt index 2c6a79fc..c65f2faa 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt @@ -8,7 +8,7 @@ import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiModel class ContentOverviewAdapter( - private val onContentClick: (Long) -> Unit = {} + private val onContentClick: (ContentOverviewUiModel) -> Unit = {} ) : RecyclerView.Adapter() { private var items: List = emptyList() @@ -40,7 +40,7 @@ class ContentOverviewAdapter( class ViewHolder( private val binding: ItemContentAudioCardBinding, - private val onContentClick: (Long) -> Unit + private val onContentClick: (ContentOverviewUiModel) -> Unit ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: ContentOverviewUiModel, gridItemWidthPx: Int) = with(binding.audioContentCard.root) { setGridItemWidthPx(gridItemWidthPx) @@ -49,8 +49,7 @@ class ContentOverviewAdapter( setAdultVisible(item.showAdultBadge) thumbnailView().loadUrl(item.coverImage) setOnClickListener { - if (item.contentId <= 0) return@setOnClickListener - onContentClick(item.contentId) + onContentClick(item) } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt index 95429da3..9ab70e8b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt @@ -26,6 +26,8 @@ import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType import kr.co.vividnext.sodalive.v2.access.AccessRequirement import kr.co.vividnext.sodalive.v2.access.ensureV2Access +import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand +import kr.co.vividnext.sodalive.v2.content.action.handleContentAction import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingChatSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingLiveSection @@ -39,6 +41,7 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeCreatorRankingUiState import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection +import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerRoute import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCheerCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorSection @@ -48,13 +51,13 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularComm import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentDebutCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorSection +import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorRoute import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerRoute import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterRoute -import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorRoute import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder @@ -581,15 +584,25 @@ class HomeMainFragment : BaseFragment( private fun onBannerClick(item: HomeRecommendationBannerUiModel) { val route = item.toHomeRecommendationBannerRoute() ?: return - ensureV2Access(AccessRequirement.Login) { - startActivity(route.toHomeRecommendationBannerIntent(requireContext())) + when (route) { + is HomeRecommendationBannerRoute.Series -> handleContentAction( + ContentActionCommand.SeriesDetail(seriesId = route.seriesId) + ) + else -> ensureV2Access(AccessRequirement.Login) { + route.toHomeRecommendationBannerIntent(requireContext())?.let(::startActivity) + } } } private fun onRecentActivityClick(item: HomeRecommendationRecentlyActiveCreatorUiModel) { val route = item.toHomeRecommendationRecentlyActiveCreatorRoute() ?: return - ensureV2Access(AccessRequirement.Login) { - startActivity(route.toHomeRecommendationRecentlyActiveCreatorIntent(requireContext())) + when (route) { + is HomeRecommendationRecentlyActiveCreatorRoute.AudioContent -> handleContentAction( + ContentActionCommand.AudioDetail(audioContentId = route.contentId) + ) + is HomeRecommendationRecentlyActiveCreatorRoute.Community -> ensureV2Access(AccessRequirement.Login) { + startActivity(CreatorChannelCommunityDetailActivity.newIntent(requireContext(), route.postId)) + } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt index 399d3d75..f70eb05d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt @@ -4,14 +4,11 @@ import android.content.Context import android.content.Intent import android.net.Uri import kr.co.vividnext.sodalive.BuildConfig -import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity -import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.v2.common.CreatorActivityType import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity -import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem import java.util.Locale @@ -100,7 +97,7 @@ fun HomeRecommendationBannerUiModel.toHomeRecommendationBannerRoute(): HomeRecom ) } -fun HomeRecommendationBannerRoute.toHomeRecommendationBannerIntent(context: Context): Intent { +fun HomeRecommendationBannerRoute.toHomeRecommendationBannerIntent(context: Context): Intent? { return when (this) { is HomeRecommendationBannerRoute.Event -> { Intent(context, EventDetailActivity::class.java).apply { @@ -112,11 +109,7 @@ fun HomeRecommendationBannerRoute.toHomeRecommendationBannerIntent(context: Cont CreatorChannelActivity.newIntent(context, creatorId) } - is HomeRecommendationBannerRoute.Series -> { - Intent(context, SeriesDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_SERIES_ID, seriesId) - } - } + is HomeRecommendationBannerRoute.Series -> null is HomeRecommendationBannerRoute.Link -> Intent(Intent.ACTION_VIEW, Uri.parse(url)) } @@ -151,20 +144,6 @@ fun HomeRecommendationRecentlyActiveCreatorUiModel.toHomeRecommendationRecentlyA } } -fun HomeRecommendationRecentlyActiveCreatorRoute.toHomeRecommendationRecentlyActiveCreatorIntent(context: Context): - Intent { - return when (this) { - is HomeRecommendationRecentlyActiveCreatorRoute.AudioContent -> { - Intent(context, AudioContentDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId) - } - } - - is HomeRecommendationRecentlyActiveCreatorRoute.Community -> - CreatorChannelCommunityDetailActivity.newIntent(context, postId) - } -} - data class HomeRecommendationCreatorUiModel( val creatorId: Long, val nickname: String, diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionTest.kt new file mode 100644 index 00000000..c600c905 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionTest.kt @@ -0,0 +1,193 @@ +package kr.co.vividnext.sodalive.v2.content.action + +import android.app.Activity +import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity +import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity +import kr.co.vividnext.sodalive.common.Constants +import kr.co.vividnext.sodalive.v2.access.AccessRequirement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(application = android.app.Application::class) +class ContentActionTest { + + private val action = ContentAction() + + @Test + fun `유효하지 않은 ID는 Access를 확인하지 않고 무시한다`() { + var accessCheckCount = 0 + val commands = listOf( + ContentActionCommand.AudioDetail(audioContentId = 0L), + ContentActionCommand.SeriesDetail(seriesId = -1L) + ) + + val results = commands.map { command -> + action.execute(command) { + accessCheckCount += 1 + true + } + } + + assertEquals(listOf(ContentActionResult.Ignored, ContentActionResult.Ignored), results) + assertEquals(0, accessCheckCount) + } + + @Test + fun `오디오 상세는 로그인 Access가 허용되면 navigation 결과를 반환한다`() { + var capturedRequirement: AccessRequirement? = null + + val result = action.execute(ContentActionCommand.AudioDetail(audioContentId = 11L)) { requirement -> + capturedRequirement = requirement + true + } + + assertEquals(AccessRequirement.Login, capturedRequirement) + assertEquals(ContentActionResult.NavigateToAudioDetail(audioContentId = 11L), result) + } + + @Test + fun `성인 시리즈 상세는 성인 Access가 허용되면 navigation 결과를 반환한다`() { + var capturedRequirement: AccessRequirement? = null + + val result = action.execute( + ContentActionCommand.SeriesDetail( + seriesId = 22L, + requiresAdultContentAccess = true + ) + ) { requirement -> + capturedRequirement = requirement + true + } + + assertEquals(AccessRequirement.AdultContent, capturedRequirement) + assertEquals(ContentActionResult.NavigateToSeriesDetail(seriesId = 22L), result) + } + + @Test + fun `Access가 거부되면 차단 결과를 반환한다`() { + val result = action.execute( + ContentActionCommand.AudioDetail( + audioContentId = 33L, + requiresAdultContentAccess = true + ) + ) { false } + + assertEquals(ContentActionResult.Blocked(AccessRequirement.AdultContent), result) + } + + @Test + fun `Handler는 허용된 오디오 상세에 기존 ID extra를 전달한다`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val result = ContentActionHandler().handle( + context = activity, + command = ContentActionCommand.AudioDetail(audioContentId = 44L), + ensureAccess = { true } + ) + val intent = shadowOf(activity).nextStartedActivity + + assertEquals(ContentActionResult.NavigateToAudioDetail(audioContentId = 44L), result) + assertEquals(AudioContentDetailActivity::class.java.name, intent.component?.className) + assertEquals(44L, intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0L)) + } + + @Test + fun `Handler는 허용된 시리즈 상세에 기존 ID extra를 전달한다`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val result = ContentActionHandler().handle( + context = activity, + command = ContentActionCommand.SeriesDetail(seriesId = 55L), + ensureAccess = { true } + ) + val intent = shadowOf(activity).nextStartedActivity + + assertEquals(ContentActionResult.NavigateToSeriesDetail(seriesId = 55L), result) + assertEquals(SeriesDetailActivity::class.java.name, intent.component?.className) + assertEquals(55L, intent.getLongExtra(Constants.EXTRA_SERIES_ID, 0L)) + } + + @Test + fun `Handler는 차단 결과에서 Activity를 시작하지 않는다`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val result = ContentActionHandler().handle( + context = activity, + command = ContentActionCommand.AudioDetail(audioContentId = 66L), + ensureAccess = { false } + ) + + assertEquals(ContentActionResult.Blocked(AccessRequirement.Login), result) + assertNull(shadowOf(activity).nextStartedActivity) + } + + @Test + fun `대상 화면은 상세 Activity 대신 Content Action을 사용한다`() { + val actionSources = listOf( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt", + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt", + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt", + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" + ).map { path -> projectFile(path).readText() } + val navigationOwnerSources = actionSources + listOf( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt", + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt" + ).map { path -> projectFile(path).readText() } + + actionSources.forEach { source -> + assertTrue(source.contains("handleContentAction(")) + } + navigationOwnerSources.forEach { source -> + assertFalse(source.contains("AudioContentDetailActivity")) + assertFalse(source.contains("SeriesDetailActivity")) + } + } + + @Test + fun `Creator tab은 알고 있는 성인 여부를 Content Action까지 전달한다`() { + val audioFragmentSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioFragment.kt" + ).readText() + val liveFragmentSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt" + ).readText() + val seriesFragmentSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragment.kt" + ).readText() + + assertTrue( + audioFragmentSource.contains( + "host.onCreatorChannelAudioContentClicked(item.audioContentId, item.showAdultBadge)" + ) + ) + assertTrue( + liveFragmentSource.contains( + "host.onCreatorChannelLiveReplayClicked(item.audioContentId, item.showAdultBadge)" + ) + ) + assertTrue( + seriesFragmentSource.contains( + "host.onCreatorChannelSeriesClicked(item.seriesId, item.showAdultBadge)" + ) + ) + } + + private fun projectFile(relativePath: String): File { + val userDir = requireNotNull(System.getProperty("user.dir")) + val fromRoot = File(userDir, relativePath) + if (fromRoot.exists()) return fromRoot + + val projectRoot = requireNotNull(File(userDir).parentFile) + return projectRoot.resolve(relativePath) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt index 35554d3a..4b28320f 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt @@ -601,8 +601,11 @@ class CreatorChannelActivitySourceTest { assertTrue(source.contains("position == CreatorChannelTab.Series.ordinal")) assertTrue(source.contains("findSeriesFragment()?.onCreatorChannelSeriesViewportHeightChanged(minHeight)")) assertTrue(source.contains("override fun onCreatorChannelSeriesContentChanged()")) - assertTrue(source.contains("override fun onCreatorChannelSeriesClicked(seriesId: Long)")) - assertTrue(source.contains("putExtra(Constants.EXTRA_SERIES_ID, seriesId)")) + assertTrue(source.contains("override fun onCreatorChannelSeriesClicked(")) + assertTrue(source.contains("seriesId: Long")) + assertTrue(source.contains("requiresAdultContentAccess: Boolean")) + assertTrue(source.contains("ContentActionCommand.SeriesDetail(")) + assertTrue(source.contains("requiresAdultContentAccess = requiresAdultContentAccess")) } @Test @@ -1582,8 +1585,9 @@ class CreatorChannelActivitySourceTest { assertTrue(source.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)")) assertTrue(source.contains("CreatorActivityType.Audio")) assertTrue(source.contains("CreatorActivityType.LiveReplay")) - assertTrue(source.contains("AudioContentDetailActivity::class.java")) - assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, schedule.targetId)")) + assertTrue(source.contains("openAudioContentDetail(schedule.targetId)")) + assertTrue(source.contains("ContentActionCommand.AudioDetail(")) + assertFalse(source.contains("AudioContentDetailActivity")) assertTrue(source.contains("CreatorActivityType.Live")) assertTrue(source.contains("CreatorActivityType.Live -> liveCoordinator.showLiveRoomDetail(schedule.targetId)")) } @@ -1679,9 +1683,12 @@ class CreatorChannelActivitySourceTest { assertTrue(fragment.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)")) assertTrue(fragment.contains("host.onCreatorChannelAudioContentClicked(audioContent)")) assertTrue(source.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)")) - assertTrue(source.contains("AudioContentDetailActivity::class.java")) - assertTrue(source.contains("startAudioContentDetail(audioContent.audioContentId)")) - assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId)")) + assertTrue( + source.contains("openAudioContentDetail(audioContent.audioContentId, audioContent.isAdult)") + ) + assertTrue(source.contains("ContentActionCommand.AudioDetail(")) + assertTrue(source.contains("requiresAdultContentAccess = requiresAdultContentAccess")) + assertFalse(source.contains("AudioContentDetailActivity")) } @Test @@ -1816,10 +1823,10 @@ class CreatorChannelActivitySourceTest { assertTrue(fragment.contains("onSeriesClick = ::onSeriesClicked")) assertTrue(fragment.contains("private fun onSeriesClicked(series: CreatorChannelSeriesResponse)")) assertTrue(fragment.contains("host.onCreatorChannelSeriesClicked(series)")) - assertTrue(source.contains("import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity")) assertTrue(source.contains("private fun onSeriesClicked(series: CreatorChannelSeriesResponse)")) - assertTrue(source.contains("SeriesDetailActivity::class.java")) - assertTrue(source.contains("putExtra(Constants.EXTRA_SERIES_ID, series.seriesId)")) + assertTrue(source.contains("openSeriesDetail(series.seriesId)")) + assertTrue(source.contains("ContentActionCommand.SeriesDetail(")) + assertFalse(source.contains("SeriesDetailActivity")) } @Test diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragmentLayoutTest.kt index 8561290c..c816b9b0 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragmentLayoutTest.kt @@ -117,6 +117,8 @@ class CreatorChannelSeriesFragmentLayoutTest { assertTrue(adapter.contains("R.string.creator_channel_series_subtitle_content_count")) assertTrue(adapter.contains("R.string.creator_channel_series_status_proceeding")) assertTrue(adapter.contains("R.string.creator_channel_series_status_completed")) + assertTrue(fragment.contains("host.onCreatorChannelSeriesClicked(item.seriesId, item.showAdultBadge)")) + assertTrue(adapter.contains("onSeriesClicked(item)")) assertTrue(!adapter.contains("tvCreatorChannelSeriesSubtitle.text = item.subtitle")) } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentLoginGuardSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentLoginGuardSourceTest.kt index dab1dfed..ae9506f3 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentLoginGuardSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentLoginGuardSourceTest.kt @@ -8,27 +8,39 @@ import java.io.File class ContentMainFragmentLoginGuardSourceTest { @Test - fun `ContentMainFragment 실제 이동은 MainV2 로그인 가드를 통과한다`() { - val source = projectFile( + fun `ContentMainFragment 상세 이동은 공통 Content Action Access를 통과한다`() { + val fragmentSource = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt" ).readText() + val handlerSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionHandler.kt" + ).readText() - assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.access.ensureV2Access")) - assertGuardedStartActivity(source, "private fun onBannerClick(item: ContentBannerUiModel)") - assertGuardedStartActivity(source, "audioContentId: Long,\n requiresAdultContentAccess: Boolean = false") - assertGuardedStartActivity(source, "seriesId: Long,\n requiresAdultContentAccess: Boolean = false") - assertFalse(source.contains("kr.co.vividnext.sodalive.main.MainActivity")) + assertTrue(fragmentSource.contains("import kr.co.vividnext.sodalive.v2.content.action.handleContentAction")) + assertTrue(fragmentSource.contains("ContentActionCommand.AudioDetail(")) + assertTrue(fragmentSource.contains("ContentActionCommand.SeriesDetail(")) + assertTrue(handlerSource.contains("action.execute(command, ensureAccess)")) + assertTrue( + handlerSource.indexOf("action.execute(command, ensureAccess)") < + handlerSource.indexOf("context.startActivity(") + ) + assertFalse(fragmentSource.contains("kr.co.vividnext.sodalive.main.MainActivity")) } @Test - fun `ContentMainFragment invalid id return은 로그인 가드보다 먼저 유지된다`() { - val source = projectFile( + fun `Content Action invalid ID 결과는 Access 확인보다 먼저 유지된다`() { + val fragmentSource = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt" ).readText() + val actionSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentAction.kt" + ).readText() - assertBeforeGuard(source, "val route = item.toContentBannerRoute() ?: return") - assertBeforeGuard(source, "if (audioContentId <= 0L) return") - assertBeforeGuard(source, "if (seriesId <= 0L) return") + assertTrue(fragmentSource.contains("val route = item.toContentBannerRoute() ?: return")) + assertTrue( + actionSource.indexOf("if (!isValidId) return ContentActionResult.Ignored") < + actionSource.indexOf("if (!ensureAccess(requirement))") + ) } @Test @@ -40,34 +52,15 @@ class ContentMainFragmentLoginGuardSourceTest { assertTrue(source.contains("openAudioContentDetail(item.audioContentId, item.showAdultBadge)")) assertTrue(source.contains("openAudioContentDetail(audioContentId, findAllTabAudioAdultAccess(audioContentId))")) assertTrue(source.contains("openSeriesDetail(seriesId, findAllTabSeriesAdultAccess(seriesId))")) - assertTrue(source.contains("AccessRequirement.forAdultContent(requiresAdultContentAccess)")) + val actionSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentAction.kt" + ).readText() + + assertTrue(actionSource.contains("AccessRequirement.forAdultContent(requiresAdultContentAccess)")) assertFalse(source.contains("ContentCommentedAudioUiModel.showAdultBadge")) assertFalse(source.contains("ContentOriginalSeriesUiModel.showAdultBadge")) } - private fun assertGuardedStartActivity(source: String, functionSignature: String) { - val functionSource = source.substringFrom(functionSignature) - assertTrue( - "$functionSignature must call ensureV2Access before startActivity.", - functionSource.indexOf("ensureV2Access") in 0 until functionSource.indexOf("startActivity") - ) - } - - private fun assertBeforeGuard(source: String, expectedReturn: String) { - val returnIndex = source.indexOf(expectedReturn) - val guardIndex = source.indexOf("ensureV2Access", returnIndex) - - assertTrue("Missing source: $expectedReturn", returnIndex >= 0) - assertTrue("$expectedReturn must stay before guard.", guardIndex > returnIndex) - } - - private fun String.substringFrom(marker: String): String { - val startIndex = indexOf(marker) - assertTrue("Missing function: $marker", startIndex >= 0) - val nextFunctionIndex = indexOf("\n private fun ", startIndex + marker.length).takeIf { it >= 0 } ?: length - return substring(startIndex, nextFunctionIndex) - } - private fun projectFile(relativePath: String): File { val candidates = listOf(File(relativePath), File("../$relativePath")) return candidates.firstOrNull { it.exists() } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt index f6e203e5..834f338d 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt @@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.v2.main.content import android.app.Application import android.content.Context import kr.co.vividnext.sodalive.BuildConfig -import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventItem @@ -14,6 +13,7 @@ import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerIntent import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerRoute import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -140,7 +140,7 @@ class ContentMainFragmentSourceTest { assertTrue(source.contains("loadMore()")) assertTrue(source.contains("consumePaginationErrorMessage()")) assertTrue(source.contains("openAudioContentDetail(audioContentId)")) - assertTrue(source.contains("openSeriesDetail(seriesId)")) + assertTrue(source.contains("openSeriesDetail(seriesId, findAllTabSeriesAdultAccess(seriesId))")) assertTrue(source.contains("override fun onDestroyView()")) assertTrue(source.contains("sortPopup?.dismiss()")) assertTrue(source.contains("sortPopup = null")) @@ -240,27 +240,28 @@ class ContentMainFragmentSourceTest { } @Test - fun `content 추천 source는 오디오와 시리즈 routing extra를 사용한다`() { + fun `content 추천 source는 오디오와 시리즈 상세를 Content Action에 위임한다`() { val source = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt" ).readText() - assertTrue(source.contains("AudioContentDetailActivity::class.java")) - assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId)")) - assertTrue(source.contains("SeriesDetailActivity::class.java")) - assertTrue(source.contains("putExtra(Constants.EXTRA_SERIES_ID, seriesId)")) + assertTrue(source.contains("handleContentAction(")) + assertTrue(source.contains("ContentActionCommand.AudioDetail(")) + assertTrue(source.contains("ContentActionCommand.SeriesDetail(")) + assertFalse(source.contains("AudioContentDetailActivity")) + assertFalse(source.contains("SeriesDetailActivity")) assertTrue(source.contains("toContentBannerRoute()")) assertTrue(source.contains("toContentBannerIntent(requireContext())")) } @Test - fun `content 랭킹 routing은 유효하지 않은 contentId를 무시한다`() { + fun `content 랭킹 routing은 숫자가 아닌 contentId를 무시한다`() { val source = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt" ).readText() assertTrue(source.contains("private fun openRankingAudioContentDetail(item: ContentRankingItem)")) - assertTrue(source.contains("item.contentId.toLongOrNull()?.takeIf { it > 0L } ?: return")) + assertTrue(source.contains("item.contentId.toLongOrNull() ?: return")) assertTrue(source.contains("openAudioContentDetail(audioContentId)")) } @@ -465,21 +466,24 @@ class ContentMainFragmentSourceTest { fun `content banner route creates expected intents`() { val context = RuntimeEnvironment.getApplication() as Context val eventItem = EventItem(id = 1L, thumbnailImageUrl = "https://example.com/event.png") - val eventIntent = ContentBannerRoute.Event(eventItem).toContentBannerIntent(context) - val creatorIntent = ContentBannerRoute.Creator(2L).toContentBannerIntent(context) + val eventIntent = requireNotNull(ContentBannerRoute.Event(eventItem).toContentBannerIntent(context)) + val creatorIntent = requireNotNull(ContentBannerRoute.Creator(2L).toContentBannerIntent(context)) val seriesIntent = ContentBannerRoute.Series(3L).toContentBannerIntent(context) - val webIntent = ContentBannerRoute.Link("https://example.com", isWebUrl = true).toContentBannerIntent(context) - val deepLinkIntent = ContentBannerRoute.Link( - url = "${BuildConfig.APPSCHEME}://series/3", - isWebUrl = false - ).toContentBannerIntent(context) + val webIntent = requireNotNull( + ContentBannerRoute.Link("https://example.com", isWebUrl = true).toContentBannerIntent(context) + ) + val deepLinkIntent = requireNotNull( + ContentBannerRoute.Link( + url = "${BuildConfig.APPSCHEME}://series/3", + isWebUrl = false + ).toContentBannerIntent(context) + ) assertEquals(EventDetailActivity::class.java.name, eventIntent.component?.className) assertEquals(eventItem, eventIntent.getParcelableExtra(Constants.EXTRA_EVENT)) assertEquals(CreatorChannelActivity::class.java.name, creatorIntent.component?.className) assertEquals(2L, creatorIntent.getLongExtra(CreatorChannelActivity.EXTRA_CREATOR_ID, 0L)) - assertEquals(SeriesDetailActivity::class.java.name, seriesIntent.component?.className) - assertEquals(3L, seriesIntent.getLongExtra(Constants.EXTRA_SERIES_ID, 0L)) + assertNull(seriesIntent) assertEquals(android.content.Intent.ACTION_VIEW, webIntent.action) assertEquals("https://example.com", webIntent.data.toString()) assertEquals(android.content.Intent.ACTION_VIEW, deepLinkIntent.action) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivitySourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivitySourceTest.kt index b3a13721..07d0d930 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivitySourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivitySourceTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.main.content.overview import android.app.Application +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -63,24 +64,26 @@ class ContentOverviewActivitySourceTest { } @Test - fun `ContentOverviewActivity opens audio detail with content id`() { + fun `ContentOverviewActivity는 ID와 성인 여부를 Content Action에 전달한다`() { val source = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt" ).readText() - assertTrue(source.contains("AudioContentDetailActivity::class.java")) - assertTrue(source.contains("if (contentId <= 0) return")) - assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)")) + assertTrue(source.contains("handleContentAction(")) + assertTrue(source.contains("ContentActionCommand.AudioDetail(")) + assertTrue(source.contains("audioContentId = item.contentId")) + assertTrue(source.contains("requiresAdultContentAccess = item.showAdultBadge")) + assertFalse(source.contains("AudioContentDetailActivity")) } @Test - fun `ContentOverviewAdapter ignores invalid content id clicks`() { + fun `ContentOverviewAdapter는 Action 검증을 위해 전체 항목을 전달한다`() { val source = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt" ).readText() - assertTrue(source.contains("if (item.contentId <= 0) return@setOnClickListener")) - assertTrue(source.contains("onContentClick(item.contentId)")) + assertTrue(source.contains("onContentClick(item)")) + assertFalse(source.contains("if (item.contentId <= 0)")) } @Test diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt index cae607ca..507c5c12 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt @@ -23,14 +23,11 @@ import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.BuildConfig -import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity -import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity -import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem import kr.co.vividnext.sodalive.v2.main.home.data.HomeAiCharacterItem import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem @@ -61,7 +58,6 @@ import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerInt import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerRoute import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterRoute -import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorRoute import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomePopularCommunityPosts import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups @@ -84,6 +80,7 @@ import kr.co.vividnext.sodalive.v2.main.home.model.toUiModel import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -872,24 +869,31 @@ class HomeMainFragmentLayoutTest { fun `home banner route creates activity and link intents with expected extras`() { val context = ApplicationProvider.getApplicationContext() val eventItem = EventItem(id = 1L, thumbnailImageUrl = "https://example.com/event.png") - val eventIntent = HomeRecommendationBannerRoute.Event(eventItem).toHomeRecommendationBannerIntent(context) - val creatorIntent = HomeRecommendationBannerRoute.Creator(2L).toHomeRecommendationBannerIntent(context) + val eventIntent = requireNotNull( + HomeRecommendationBannerRoute.Event(eventItem).toHomeRecommendationBannerIntent(context) + ) + val creatorIntent = requireNotNull( + HomeRecommendationBannerRoute.Creator(2L).toHomeRecommendationBannerIntent(context) + ) val seriesIntent = HomeRecommendationBannerRoute.Series(3L).toHomeRecommendationBannerIntent(context) - val webIntent = HomeRecommendationBannerRoute.Link( - url = "https://example.com", - isWebUrl = true - ).toHomeRecommendationBannerIntent(context) - val deepLinkIntent = HomeRecommendationBannerRoute.Link( - url = "${BuildConfig.APPSCHEME}://series/3", - isWebUrl = false - ).toHomeRecommendationBannerIntent(context) + val webIntent = requireNotNull( + HomeRecommendationBannerRoute.Link( + url = "https://example.com", + isWebUrl = true + ).toHomeRecommendationBannerIntent(context) + ) + val deepLinkIntent = requireNotNull( + HomeRecommendationBannerRoute.Link( + url = "${BuildConfig.APPSCHEME}://series/3", + isWebUrl = false + ).toHomeRecommendationBannerIntent(context) + ) assertEquals(EventDetailActivity::class.java.name, eventIntent.component?.className) assertEquals(eventItem, eventIntent.getParcelableExtra(Constants.EXTRA_EVENT)) assertEquals(CreatorChannelActivity::class.java.name, creatorIntent.component?.className) assertEquals(2L, creatorIntent.getLongExtra(CreatorChannelActivity.EXTRA_CREATOR_ID, 0L)) - assertEquals(SeriesDetailActivity::class.java.name, seriesIntent.component?.className) - assertEquals(3L, seriesIntent.getLongExtra(Constants.EXTRA_SERIES_ID, 0L)) + assertNull(seriesIntent) assertEquals(android.content.Intent.ACTION_VIEW, webIntent.action) assertEquals("https://example.com", webIntent.data.toString()) assertEquals(android.content.Intent.ACTION_VIEW, deepLinkIntent.action) @@ -947,17 +951,19 @@ class HomeMainFragmentLayoutTest { } @Test - fun `home recent activity route creates expected intents`() { - val context = ApplicationProvider.getApplicationContext() - val audioIntent = HomeRecommendationRecentlyActiveCreatorRoute.AudioContent(11L) - .toHomeRecommendationRecentlyActiveCreatorIntent(context) - val communityIntent = HomeRecommendationRecentlyActiveCreatorRoute.Community(13L) - .toHomeRecommendationRecentlyActiveCreatorIntent(context) + fun `home 오디오와 시리즈 route는 Content Action을 사용한다`() { + val fragmentSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt" + ).readText() + val modelSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt" + ).readText() - assertEquals(AudioContentDetailActivity::class.java.name, audioIntent.component?.className) - assertEquals(11L, audioIntent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0L)) - assertEquals(CreatorChannelCommunityDetailActivity::class.java.name, communityIntent.component?.className) - assertEquals(13L, communityIntent.getLongExtra("extra_post_id", 0L)) + assertTrue(fragmentSource.contains("ContentActionCommand.AudioDetail(")) + assertTrue(fragmentSource.contains("ContentActionCommand.SeriesDetail(")) + assertTrue(fragmentSource.contains("handleContentAction(")) + assertFalse(modelSource.contains("AudioContentDetailActivity")) + assertFalse(modelSource.contains("SeriesDetailActivity")) } @Test diff --git a/docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md b/docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md index 0fe59063..57f2f049 100644 --- a/docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md +++ b/docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md @@ -343,7 +343,7 @@ - 2026-07-14: Task 1.11에서는 production 코드를 추가 변경하지 않고 기존 source test 클래스 두 곳과 계획 문서만 보강했다. ### Phase 2: Content 공통 Action -- [ ] **Task 2.1: 오디오/시리즈 진입 호출부와 계약 확정** +- [x] **Task 2.1: 오디오/시리즈 진입 호출부와 계약 확정** - 확인 예정 파일: - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt` @@ -353,23 +353,56 @@ - 검증 기준: - 화면 전용 UI model을 받지 않는 최소 command/result 계약이 정해진다. - Activity result 또는 호출 화면 후처리가 있는 경로는 Content result adapter와 단일 handler 필요 여부가 결정된다. + - 확인 결과: + - 공개 입력은 `audioContentId` 또는 `seriesId`와 호출부가 이미 알고 있는 성인 콘텐츠 여부만 받는 `AudioDetail`/`SeriesDetail` command로 제한한다. Home/Content/Creator 전용 UI model과 Response는 Action에 전달하지 않는다. + - Action 결과는 유효하지 않은 ID의 `Ignored`, Access가 거부된 `Blocked`, 허용된 오디오/시리즈 navigation 결과로 구분한다. `ContentActionHandler`는 허용 결과만 기존 `Constants.EXTRA_AUDIO_CONTENT_ID` 또는 `Constants.EXTRA_SERIES_ID`로 변환한다. + - Home banner의 series와 최근 활동의 audio는 성인 여부를 제공하지 않으므로 기존처럼 `AccessRequirement.Login`을 사용한다. Home의 event/creator/link와 community route는 Content Action 범위에 포함하지 않는다. + - Content 추천 카드와 전체 탭은 `showAdultBadge`를 알고 있는 경우 이를 전달한다. 댓글/랭킹/원작 series/banner처럼 성인 여부가 없는 경로는 로그인만 확인한다. + - Content Overview audio, Creator Channel Home audio, Audio tab, Live replay, Series tab은 성인 여부를 보유하므로 command에 전달한다. Creator Channel schedule audio/live replay와 Home series는 성인 여부가 없어 로그인만 확인한다. + - 상세 진입은 모두 일반 `startActivity`이며 `ActivityResultLauncher`, route source extra, 호출 화면 후처리가 없다. 따라서 별도 Content result adapter나 feature composition handler를 추가하지 않는다. + - `MainV2Activity`의 audio notification/deeplink/mini player와 series deeplink는 Phase 1에서 route 소비 순서와 boolean 반환 계약을 별도로 고정한 시스템 route이므로, PRD에 합의된 Home/Content/Content Overview/Creator Channel 전환 범위에는 포함하지 않는다. + - 검증 기록: + - 2026-07-14: `rg`와 대상 source 확인으로 V2 상세 Activity 직접 참조, ID/extra, 성인 여부 보유 여부, Activity result 사용 여부를 대조해 위 command/result 계약과 전환 범위를 확정했다. + - 2026-07-14: 격리 worktree에서 변경 전 `./gradlew :app:testDebugUnitTest`를 실행해 전체 debug 단위 테스트가 `BUILD SUCCESSFUL`로 통과하는 기준선을 확인했다. 기존 dependency/manifest/Kotlin deprecation 경고는 있었지만 실패는 없었다. -- [ ] **Task 2.2: Content Action 순수 계약 테스트 및 구현** +- [x] **Task 2.2: Content Action 순수 계약 테스트 및 구현** - 생성 예정 파일: - `app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentAction.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionCommand.kt` - `app/src/test/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionTest.kt` - 검증 대상: invalid ID, audio/series route, 성인 Access 요구사항 전달, 허용/차단 결과. - 검증 기준: feature UI model과 DTO 의존 없이 입력·출력이 테스트된다. + - RED 검증 기록: + - 2026-07-14: `ContentActionTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.content.action.ContentActionTest"`를 실행해 `ContentAction`, `ContentActionCommand`, `ContentActionResult`, `ContentActionHandler` 미정의 컴파일 오류와 `BUILD FAILED`를 확인했다. + - GREEN 검증 기록: + - 2026-07-14: `ContentActionCommand`, `ContentAction`, `ContentActionResult`, `ContentActionHandler`를 최소 구현하고 같은 단일 테스트 명령을 재실행해 7개 테스트와 빌드가 통과했다. Action은 ID/성인 여부와 Access 함수만 입력받고 feature UI model, DTO, Android UI에 의존하지 않는다. -- [ ] **Task 2.3: Content navigation adapter 구현 및 전체 호출부 전환** +- [x] **Task 2.3: Content navigation adapter 구현 및 전체 호출부 전환** - 생성/수정 예정 파일: - `app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/ContentActionHandler.kt` - - Task 2.1에서 확정한 Home/Content/Overview/Creator Channel 호출 파일 + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioFragment.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragment.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/ui/CreatorChannelSeriesAdapter.kt` + - 위 호출부 계약을 검증하는 기존 source/layout 테스트 - 작업: 기존 extra와 Access UX를 보존하면서 오디오/시리즈 상세 진입을 한 곳으로 통합한다. - 검증 기준: 대상 UI에서 상세 Activity Intent 직접 생성이 제거되고 Content Action을 사용한다. + - RED 검증 기록: + - 2026-07-14: 기존 source/layout 테스트를 새 Content Action 결선 계약으로 먼저 변경하고 관련 6개 테스트 클래스 207건을 `--rerun-tasks`로 실행했다. 대상 화면의 직접 상세 Intent, series banner Intent, ID-only callback이 남아 있어 예상한 13건이 실패하고 `BUILD FAILED`가 발생함을 확인했다. + - GREEN 검증 기록: + - 2026-07-14: Home, Content, Content Overview, Creator Channel 호출부를 `handleContentAction`으로 전환하고 대상 feature/model source의 `AudioContentDetailActivity`/`SeriesDetailActivity` 직접 참조를 제거했다. 상세 Activity와 기존 ID extra 생성은 `ContentActionHandler`만 소유한다. + - 2026-07-14: Content Overview와 Creator Channel Audio/Live replay/Series tab callback이 기존 UI model의 `showAdultBadge`를 command까지 전달하도록 연결했다. 성인 정보를 제공하지 않는 Home, schedule, Creator Home series 경로는 기존 Login 요구사항을 유지했다. + - 2026-07-14: 같은 6개 테스트 클래스 207건을 `--rerun-tasks`로 재실행해 실패, 오류, 스킵 없이 `BUILD SUCCESSFUL`로 통과했다. + - 2026-07-14: 결선 GREEN 중 발견한 기존 전체 탭 source assertion은 다른 helper의 `openSeriesDetail(seriesId)` 문자열에 우연히 의존하고 있었다. 실제 계약인 `openSeriesDetail(seriesId, findAllTabSeriesAdultAccess(seriesId))`를 검증하도록 강화한 뒤 단일 테스트와 207건 결선을 다시 통과시켰다. -- [ ] **Task 2.4: Content Phase 회귀 검증** +- [x] **Task 2.4: Content Phase 회귀 검증** - 실행 명령: ```bash ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.content.action.*" @@ -378,8 +411,16 @@ ./gradlew :app:compileDebugKotlin ./gradlew :app:ktlintCheck git diff --check - ``` + ``` - 수동 확인: Home, Content, Content Overview, Creator Channel에서 audio/series 상세 진입과 성인 차단 UX가 기존과 동일하다. + - 검증 기록: + - 2026-07-14: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.content.action.*"`, `--tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `--tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`, 추가 Home 회귀 `--tests "kr.co.vividnext.sodalive.v2.main.home.*"`를 각각 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. Content 묶음은 99개 테스트가 통과했다. + - 2026-07-14: Phase 1에서 작성된 `ContentMainFragmentLoginGuardSourceTest`가 이전 화면 내부 `ensureV2Access` 구현을 고정해 Content 묶음 3건이 실패했다. 실제 Access 계약은 `ContentMainFragment -> ContentAction -> ContentActionHandler` 순으로 이동했으므로 invalid ID, 성인 요구사항, Access-before-navigation을 새 소유자에서 검증하도록 테스트를 갱신한 뒤 단일 클래스와 Content 99건을 다시 통과시켰다. + - 2026-07-14: `./gradlew :app:testDebugUnitTest`를 실행해 전체 debug 단위 테스트가 44초에 `BUILD SUCCESSFUL`로 통과했고, `./gradlew :app:compileDebugKotlin`도 `BUILD SUCCESSFUL`로 통과했다. + - 2026-07-14: `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`는 `BUILD SUCCESSFUL`로 통과했고 test source 리포트는 0건이다. `./gradlew :app:ktlintCheck`는 이번 변경과 무관한 기존 main source 기준선 위반 550줄로 실패했으며, 리포트에서 Phase 2 변경 Kotlin 파일의 위반은 0건임을 확인했다. 레거시 파일은 수정하지 않았다. + - 2026-07-14: 대상 Home, Content, Content Overview, Creator Channel 운영 source에서 `AudioContentDetailActivity`, `SeriesDetailActivity` 및 상세 ID extra를 검색한 결과 잔존이 없고, `ContentActionHandler`만 기존 Activity/extra 생성을 소유함을 확인했다. Phase 1에서 계약을 고정한 `MainV2Activity` 시스템 route는 범위대로 유지했다. + - 2026-07-14: `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다. `adb devices`는 연결된 기기가 없음을 반환해 실기기 수동 탭 검증은 수행하지 못했고, 대신 Robolectric handler 테스트로 허용/차단과 기존 extra를 검증했다. + - 2026-07-14: `git diff --check HEAD`는 whitespace 오류 없이 통과했다. ### Phase 3: Live 공통 Action - [ ] **Task 3.1: 기존 Live 흐름 차이와 레거시 의존 목록 확정** @@ -569,3 +610,4 @@ - 2026-07-14: Community 업로드 후 Home/Community 탭 갱신 설계를 구체화했다. 초기 구현은 전역 observer 대신 `CommunityChange` 결과와 Creator Channel의 단일 `handleCommunityChange` composition handler를 사용하며, 레거시 `ActivityResult`는 `v2` adapter에서 변경 결과로 변환한다. - 2026-07-14: 사용자 확인에 따라 Activity result가 필요한 모든 도메인 Action에 공통 결과 전달 원칙을 확장했다. 기본 방식은 `ActivityResult -> 명시적 도메인 결과 -> feature 단일 handler`이며, Action/Repository 직접 결과도 동일 handler로 합류한다. `SharedFlow`/observer는 Activity 밖의 독립 소비자와 lifecycle/replay 요구가 확인될 때만 도입한다. - 2026-07-14: Phase 0/1 리뷰 답변을 반영해 Legacy MyPage 호출부와 로그인 token 정책 확인, FanTalk source assertion 수동 삭제 사유를 기록했다. 문서 변경 후 `./gradlew tasks --all`은 `BUILD SUCCESSFUL`, `git diff --check HEAD`는 무출력으로 통과했다. +- 2026-07-14: Phase 2 Content Action 전환 후 전체 `:app:testDebugUnitTest`, `:app:compileDebugKotlin`, `tasks --all`, `git diff --check HEAD`가 통과했다. 전체 ktlint는 기존 main source 550줄 기준선 위반으로 실패했지만 Phase 2 변경 Kotlin 파일과 test source의 신규 위반은 0건이다. 연결된 Android 기기가 없어 실기기 수동 탭 검증은 후속 확인 항목으로 남겼다. diff --git a/docs/agent-guides/build-test-style.md b/docs/agent-guides/build-test-style.md index 59ead4ba..c1596529 100644 --- a/docs/agent-guides/build-test-style.md +++ b/docs/agent-guides/build-test-style.md @@ -85,6 +85,11 @@ Access Phase 테스트 예시: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.*" ``` +Content Action Phase 테스트 예시: +```bash +./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.content.action.ContentActionTest" +``` + 참고: - 신규 Kotlin 테스트 메서드는 backtick 함수명을 사용하고, 테스트명은 한글 문장으로 작성한다. - 기존 영어 테스트명을 수정하지 않는 최소 변경 상황을 제외하고, 새 테스트명에 영어 문장을 사용하지 않는다.