refactor(v2): 도메인 액션 경로를 통합한다

This commit is contained in:
2026-07-16 16:57:39 +09:00
parent 4373452478
commit de90f771c1
34 changed files with 1780 additions and 264 deletions

View File

@@ -5,19 +5,22 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.app.SodaLiveApp import kr.co.vividnext.sodalive.app.SodaLiveApp
import kr.co.vividnext.sodalive.audition.AuditionActivity import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.message.MessageActivity import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction
import kr.co.vividnext.sodalive.v2.community.action.CommunityActionCommand
import kr.co.vividnext.sodalive.v2.community.action.handleCommunityAction
import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand
import kr.co.vividnext.sodalive.v2.content.action.handleContentAction
import kr.co.vividnext.sodalive.v2.creator.action.CreatorActionCommand
import kr.co.vividnext.sodalive.v2.creator.action.handleCreatorAction
import kr.co.vividnext.sodalive.v2.main.MainV2Activity import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import java.util.Locale import java.util.Locale
class DeepLinkActivity : AppCompatActivity() { class DeepLinkActivity : AppCompatActivity() {
@@ -276,7 +279,7 @@ class DeepLinkActivity : AppCompatActivity() {
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 } ?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0) { if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0) {
startActivity(DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId)) handleChatAction(ChatActionCommand.DmRoom(roomId))
return true return true
} }
@@ -286,36 +289,29 @@ class DeepLinkActivity : AppCompatActivity() {
return true return true
} }
communityPostId != null && communityPostId > 0 -> {
handleCommunityAction(CommunityActionCommand.PostDetail(communityPostId))
return true
}
channelId != null && channelId > 0 -> { channelId != null && channelId > 0 -> {
startActivity( handleCreatorAction(CreatorActionCommand.Profile(channelId))
CreatorChannelActivity.newIntent(applicationContext, channelId)
)
return true return true
} }
contentId != null && contentId > 0 -> { contentId != null && contentId > 0 -> {
startActivity( handleContentAction(ContentActionCommand.AudioDetail(contentId))
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
return true return true
} }
messageId != null && messageId > 0 -> { messageId != null && messageId > 0 -> {
// messageId는 DM room ID가 아니므로 과거 알림 수신 호환만 유지한다.
startActivity(Intent(applicationContext, MessageActivity::class.java)) startActivity(Intent(applicationContext, MessageActivity::class.java))
return true return true
} }
communityCreatorId != null && communityCreatorId > 0 -> { communityCreatorId != null && communityCreatorId > 0 -> {
startActivity( handleCreatorAction(CreatorActionCommand.Profile(communityCreatorId))
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, communityCreatorId)
if (communityPostId != null && communityPostId > 0) {
putExtra(Constants.EXTRA_COMMUNITY_POST_ID, communityPostId)
}
}
)
return true return true
} }
@@ -345,11 +341,7 @@ class DeepLinkActivity : AppCompatActivity() {
return false return false
} }
startActivity( handleContentAction(ContentActionCommand.SeriesDetail(deepLinkValueId))
Intent(applicationContext, SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, deepLinkValueId)
}
)
true true
} }
@@ -358,11 +350,7 @@ class DeepLinkActivity : AppCompatActivity() {
return false return false
} }
startActivity( handleContentAction(ContentActionCommand.AudioDetail(deepLinkValueId))
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, deepLinkValueId)
}
)
true true
} }
@@ -371,9 +359,7 @@ class DeepLinkActivity : AppCompatActivity() {
return false return false
} }
startActivity( handleCreatorAction(CreatorActionCommand.Profile(deepLinkValueId))
CreatorChannelActivity.newIntent(applicationContext, deepLinkValueId)
)
true true
} }
@@ -391,15 +377,13 @@ class DeepLinkActivity : AppCompatActivity() {
return false return false
} }
startActivity( // deepLinkValueId는 legacy creatorId이며 postId는 routeForegroundDeepLink에서 먼저 처리한다.
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply { handleCreatorAction(CreatorActionCommand.Profile(deepLinkValueId))
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
)
true true
} }
"message" -> { "message" -> {
// 현재 DM은 chat/{roomId} 계약을 사용하며 message는 legacy fallback이다.
startActivity(Intent(applicationContext, MessageActivity::class.java)) startActivity(Intent(applicationContext, MessageActivity::class.java))
true true
} }

View File

@@ -0,0 +1,54 @@
package kr.co.vividnext.sodalive.v2.chat.action
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
class ChatAction {
fun execute(
command: ChatActionCommand,
ensureAccess: (AccessRequirement) -> Boolean
): ChatActionResult {
return when (command) {
is ChatActionCommand.AiRoom -> {
if (command.roomId <= 0L) return ChatActionResult.Ignored
if (!ensureAccess(AccessRequirement.Login)) return ChatActionResult.Blocked(AccessRequirement.Login)
ChatActionResult.NavigateToAiRoom(command.roomId)
}
is ChatActionCommand.DmRoom -> {
if (command.roomId <= 0L) return ChatActionResult.Ignored
if (!ensureAccess(AccessRequirement.Login)) return ChatActionResult.Blocked(AccessRequirement.Login)
ChatActionResult.NavigateToDmRoom(command.roomId)
}
is ChatActionCommand.DmCreator -> {
if (command.creatorId <= 0L) return ChatActionResult.Ignored
if (!ensureAccess(AccessRequirement.Login)) return ChatActionResult.Blocked(AccessRequirement.Login)
ChatActionResult.NavigateToCreatorDm(command.creatorId)
}
ChatActionCommand.OwnerDmList -> {
if (!ensureAccess(AccessRequirement.Login)) return ChatActionResult.Blocked(AccessRequirement.Login)
ChatActionResult.NavigateToOwnerDmList
}
}
}
}
sealed interface ChatActionResult {
data object Ignored : ChatActionResult
data class Blocked(
val requirement: AccessRequirement
) : ChatActionResult
data class NavigateToAiRoom(
val roomId: Long
) : ChatActionResult
data class NavigateToDmRoom(
val roomId: Long
) : ChatActionResult
data class NavigateToCreatorDm(
val creatorId: Long
) : ChatActionResult
data object NavigateToOwnerDmList : ChatActionResult
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.v2.chat.action
sealed interface ChatActionCommand {
data class AiRoom(
val roomId: Long
) : ChatActionCommand
data class DmRoom(
val roomId: Long
) : ChatActionCommand
data class DmCreator(
val creatorId: Long
) : ChatActionCommand
data object OwnerDmList : ChatActionCommand
}

View File

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.v2.chat.action
import android.app.Activity
import android.content.Context
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
internal class ChatActionHandler(
private val action: ChatAction = ChatAction()
) {
@OptIn(UnstableApi::class)
fun handle(
context: Context,
command: ChatActionCommand,
ensureAccess: (AccessRequirement) -> Boolean
): ChatActionResult {
val result = action.execute(command, ensureAccess)
when (result) {
is ChatActionResult.NavigateToAiRoom -> context.startActivity(
ChatRoomActivity.newIntent(context, result.roomId)
)
is ChatActionResult.NavigateToDmRoom -> context.startActivity(
DmChatRoomActivity.newIntentByRoomId(context, result.roomId)
)
is ChatActionResult.NavigateToCreatorDm -> context.startActivity(
DmChatRoomActivity.newIntentByCreatorId(context, result.creatorId)
)
ChatActionResult.NavigateToOwnerDmList -> context.startActivity(MainV2Activity.newChatDmIntent(context))
is ChatActionResult.Blocked,
ChatActionResult.Ignored -> Unit
}
return result
}
}
fun Activity.handleChatAction(command: ChatActionCommand): ChatActionResult {
return ChatActionHandler().handle(this, command, ::ensureV2Access)
}
fun Fragment.handleChatAction(command: ChatActionCommand): ChatActionResult {
return ChatActionHandler().handle(requireContext(), command, ::ensureV2Access)
}

View File

@@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.v2.community.action
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
class CommunityAction {
fun execute(
command: CommunityActionCommand,
ensureAccess: (AccessRequirement) -> Boolean
): CommunityActionResult {
val postId = when (command) {
is CommunityActionCommand.PostDetail -> command.postId
}
if (postId <= 0L) return CommunityActionResult.Ignored
val requirement = AccessRequirement.Login
if (!ensureAccess(requirement)) return CommunityActionResult.Blocked(requirement)
return when (command) {
is CommunityActionCommand.PostDetail -> CommunityActionResult.NavigateToPostDetail(postId)
}
}
}
sealed interface CommunityActionResult {
data object Ignored : CommunityActionResult
data class Blocked(
val requirement: AccessRequirement
) : CommunityActionResult
data class NavigateToPostDetail(
val postId: Long
) : CommunityActionResult
}

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.v2.community.action
sealed interface CommunityActionCommand {
data class PostDetail(
val postId: Long
) : CommunityActionCommand
}

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.v2.community.action
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.fragment.app.Fragment
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity
internal class CommunityActionHandler(
private val action: CommunityAction = CommunityAction()
) {
fun handle(
context: Context,
command: CommunityActionCommand,
ensureAccess: (AccessRequirement) -> Boolean,
launchIntent: ((Intent) -> Unit)? = null
): CommunityActionResult {
val result = action.execute(command, ensureAccess)
if (result is CommunityActionResult.NavigateToPostDetail) {
val intent = CreatorChannelCommunityDetailActivity.newIntent(context, result.postId)
if (launchIntent != null) {
launchIntent(intent)
} else {
context.startActivity(intent)
}
}
return result
}
}
fun Activity.handleCommunityAction(
command: CommunityActionCommand,
launchIntent: ((Intent) -> Unit)? = null
): CommunityActionResult {
return CommunityActionHandler().handle(this, command, ::ensureV2Access, launchIntent)
}
fun Fragment.handleCommunityAction(command: CommunityActionCommand): CommunityActionResult {
return CommunityActionHandler().handle(requireContext(), command, ::ensureV2Access)
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.v2.community.action
import android.app.Activity
enum class CommunityActivityResultSource {
Write,
Modify,
Detail
}
fun resolveCommunityActivityResult(
source: CommunityActivityResultSource,
resultCode: Int
): CommunityChange {
if (resultCode != Activity.RESULT_OK) return CommunityChange.Ignored
return when (source) {
CommunityActivityResultSource.Write -> CommunityChange.Created
CommunityActivityResultSource.Modify,
CommunityActivityResultSource.Detail -> CommunityChange.Updated
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.v2.community.action
sealed interface CommunityChange {
data object Ignored : CommunityChange
data object Created : CommunityChange
data object Updated : CommunityChange
data class Deleted(
val postId: Long
) : CommunityChange
data class PinChanged(
val postId: Long
) : CommunityChange
}

View File

@@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.v2.creator.action
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
class CreatorAction {
fun execute(
command: CreatorActionCommand,
ensureAccess: (AccessRequirement) -> Boolean
): CreatorActionResult {
val creatorId = when (command) {
is CreatorActionCommand.Profile -> command.creatorId
}
if (creatorId <= 0L) return CreatorActionResult.Ignored
val requirement = AccessRequirement.Login
if (!ensureAccess(requirement)) return CreatorActionResult.Blocked(requirement)
return when (command) {
is CreatorActionCommand.Profile -> CreatorActionResult.NavigateToCreatorProfile(creatorId)
}
}
}
sealed interface CreatorActionResult {
data object Ignored : CreatorActionResult
data class Blocked(
val requirement: AccessRequirement
) : CreatorActionResult
data class NavigateToCreatorProfile(
val creatorId: Long
) : CreatorActionResult
}

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.v2.creator.action
sealed interface CreatorActionCommand {
data class Profile(
val creatorId: Long
) : CreatorActionCommand
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.creator.action
import android.app.Activity
import android.content.Context
import androidx.fragment.app.Fragment
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
internal class CreatorActionHandler(
private val action: CreatorAction = CreatorAction()
) {
fun handle(
context: Context,
command: CreatorActionCommand,
ensureAccess: (AccessRequirement) -> Boolean
): CreatorActionResult {
val result = action.execute(command, ensureAccess)
if (result is CreatorActionResult.NavigateToCreatorProfile) {
context.startActivity(CreatorChannelActivity.newIntent(context, result.creatorId))
}
return result
}
}
fun Activity.handleCreatorAction(command: CreatorActionCommand): CreatorActionResult {
return CreatorActionHandler().handle(this, command, ::ensureV2Access)
}
fun Fragment.handleCreatorAction(command: CreatorActionCommand): CreatorActionResult {
return CreatorActionHandler().handle(requireContext(), command, ::ensureV2Access)
}

View File

@@ -27,7 +27,6 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -47,13 +46,19 @@ import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog
import kr.co.vividnext.sodalive.report.UserReportDialog import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.v2.access.AccessRequirement import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction
import kr.co.vividnext.sodalive.v2.community.action.CommunityActionCommand
import kr.co.vividnext.sodalive.v2.community.action.CommunityActivityResultSource
import kr.co.vividnext.sodalive.v2.community.action.CommunityChange
import kr.co.vividnext.sodalive.v2.community.action.handleCommunityAction
import kr.co.vividnext.sodalive.v2.community.action.resolveCommunityActivityResult
import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand
import kr.co.vividnext.sodalive.v2.content.action.handleContentAction import kr.co.vividnext.sodalive.v2.content.action.handleContentAction
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioFragment import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioFragment
import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragment import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragment
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
@@ -74,8 +79,6 @@ import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesFr
import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator
import kr.co.vividnext.sodalive.v2.live.action.LiveCreationResult import kr.co.vividnext.sodalive.v2.live.action.LiveCreationResult
import kr.co.vividnext.sodalive.v2.live.action.resolveLiveCreationResult import kr.co.vividnext.sodalive.v2.live.action.resolveLiveCreationResult
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -118,24 +121,23 @@ class CreatorChannelActivity :
private val communityWriteLauncher = registerForActivityResult( private val communityWriteLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode == RESULT_OK) { handleCommunityChange(
homeActionDelegate?.refreshHome() resolveCommunityActivityResult(CommunityActivityResultSource.Write, result.resultCode)
refreshCreatorChannelCommunity() )
}
} }
private val communityPostModifyLauncher = registerForActivityResult( private val communityPostModifyLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode == RESULT_OK) { handleCommunityChange(
refreshCreatorChannelCommunity() resolveCommunityActivityResult(CommunityActivityResultSource.Modify, result.resultCode)
} )
} }
private val communityDetailLauncher = registerForActivityResult( private val communityDetailLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode == RESULT_OK) { handleCommunityChange(
refreshCreatorChannelCommunity() resolveCommunityActivityResult(CommunityActivityResultSource.Detail, result.resultCode)
} )
} }
private val fanTalkWriteLauncher = registerForActivityResult( private val fanTalkWriteLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
@@ -242,9 +244,9 @@ class CreatorChannelActivity :
} }
binding.tvDmButton.setOnClickListener { binding.tvDmButton.setOnClickListener {
if (currentHeader?.isOwner == true) { if (currentHeader?.isOwner == true) {
startActivity(MainV2Activity.newChatDmIntent(this)) handleChatAction(ChatActionCommand.OwnerDmList)
} else { } else {
startActivity(DmChatRoomActivity.newIntentByCreatorId(this, creatorId)) handleChatAction(ChatActionCommand.DmCreator(creatorId))
} }
} }
} }
@@ -538,7 +540,7 @@ class CreatorChannelActivity :
} }
override fun onCreatorChannelChatRoomCreated(chatRoomId: Long) { override fun onCreatorChannelChatRoomCreated(chatRoomId: Long) {
startActivity(ChatRoomActivity.newIntent(this, chatRoomId)) handleChatAction(ChatActionCommand.AiRoom(chatRoomId))
} }
override fun onCreatorChannelScheduleClicked(schedule: CreatorChannelScheduleResponse) { override fun onCreatorChannelScheduleClicked(schedule: CreatorChannelScheduleResponse) {
@@ -612,10 +614,7 @@ class CreatorChannelActivity :
} }
override fun onCreatorChannelCommunityPostClicked(postId: Long) { override fun onCreatorChannelCommunityPostClicked(postId: Long) {
if (postId <= 0L) return handleCommunityAction(CommunityActionCommand.PostDetail(postId), communityDetailLauncher::launch)
ensureV2Access(AccessRequirement.Login) {
communityDetailLauncher.launch(CreatorChannelCommunityDetailActivity.newIntent(this, postId))
}
} }
override fun onCreatorChannelFanTalkContentChanged() { override fun onCreatorChannelFanTalkContentChanged() {
@@ -736,7 +735,7 @@ class CreatorChannelActivity :
.subscribe( .subscribe(
{ response -> { response ->
if (response.success) { if (response.success) {
refreshCreatorChannelCommunity() handleCommunityChange(CommunityChange.PinChanged(item.postId))
} else { } else {
response.message?.let(::showToast) response.message?.let(::showToast)
} }
@@ -763,7 +762,7 @@ class CreatorChannelActivity :
.subscribe( .subscribe(
{ response -> { response ->
if (response.success) { if (response.success) {
refreshCreatorChannelCommunity() handleCommunityChange(CommunityChange.Deleted(item.postId))
} else { } else {
response.message?.let(::showToast) response.message?.let(::showToast)
} }
@@ -779,6 +778,19 @@ class CreatorChannelActivity :
findCommunityFragment()?.onCreatorChannelCommunityRefreshRequested() findCommunityFragment()?.onCreatorChannelCommunityRefreshRequested()
} }
private fun handleCommunityChange(change: CommunityChange) {
when (change) {
CommunityChange.Ignored -> Unit
CommunityChange.Created -> {
homeActionDelegate?.refreshHome()
refreshCreatorChannelCommunity()
}
CommunityChange.Updated,
is CommunityChange.Deleted,
is CommunityChange.PinChanged -> refreshCreatorChannelCommunity()
}
}
private fun refreshCreatorChannelFanTalk() { private fun refreshCreatorChannelFanTalk() {
findFanTalkFragment()?.onCreatorChannelFanTalkRefreshRequested() findFanTalkFragment()?.onCreatorChannelFanTalkRefreshRequested()
} }

View File

@@ -37,14 +37,14 @@ import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.audition.AuditionActivity import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.main.EventPopupDialogFragment import kr.co.vividnext.sodalive.main.EventPopupDialogFragment
import kr.co.vividnext.sodalive.message.MessageActivity import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.MyPageFragment import kr.co.vividnext.sodalive.mypage.MyPageFragment
@@ -53,10 +53,17 @@ import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsDialog
import kr.co.vividnext.sodalive.v2.access.AccessRequirement import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.access.isV2AccessAllowed import kr.co.vividnext.sodalive.v2.access.isV2AccessAllowed
import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction
import kr.co.vividnext.sodalive.v2.common.data.ContentSort import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity import kr.co.vividnext.sodalive.v2.community.action.CommunityActionCommand
import kr.co.vividnext.sodalive.v2.community.action.handleCommunityAction
import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand
import kr.co.vividnext.sodalive.v2.content.action.handleContentAction
import kr.co.vividnext.sodalive.v2.creator.action.CreatorActionCommand
import kr.co.vividnext.sodalive.v2.creator.action.handleCreatorAction
import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator
import kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragment import kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragment
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
import kr.co.vividnext.sodalive.v2.main.content.ContentMainFragment import kr.co.vividnext.sodalive.v2.main.content.ContentMainFragment
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
@@ -72,19 +79,37 @@ import kotlin.math.max
class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding::inflate) { class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding::inflate) {
private val viewModel: MainV2ViewModel by inject() private val viewModel: MainV2ViewModel by inject()
private val liveViewModel: LiveViewModel by inject()
private val liveActionCoordinator: LiveActionCoordinator by lazy {
LiveActionCoordinator(
activity = this,
layoutInflater = layoutInflater,
fragmentManager = supportFragmentManager,
liveViewModel = liveViewModel,
screenWidthProvider = { screenWidth },
refreshHome = {}
)
}
private lateinit var notificationSettingsDialog: NotificationSettingsDialog private lateinit var notificationSettingsDialog: NotificationSettingsDialog
private lateinit var routeLoadingDialog: LoadingDialog
private var mediaController: MediaController? = null private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = null private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() } private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
private val audioContentReceiver = AudioContentReceiver() private val audioContentReceiver = AudioContentReceiver()
private var playerStateJob: Job? = null private var playerStateJob: Job? = null
private var isDeferredRouteLoading = false
private var isLiveEntryLoading = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
overrideRootWindowInsets() overrideRootWindowInsets()
isDeferredRouteLoading =
intent.hasExtra(Constants.EXTRA_DATA) || intent.hasExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE)
updateRouteLoadingDialog()
checkPermissions() checkPermissions()
trackAppLaunchIfNeeded() trackAppLaunchIfNeeded()
pushTokenUpdate() pushTokenUpdate()
@@ -93,6 +118,8 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
if (!handleAudioNotificationRoute(intent) && isV2AccessAllowed(AccessRequirement.Login)) { if (!handleAudioNotificationRoute(intent) && isV2AccessAllowed(AccessRequirement.Login)) {
executeDeeplink(intent) executeDeeplink(intent)
} }
isDeferredRouteLoading = false
updateRouteLoadingDialog()
}, 1000) }, 1000)
if (isV2AccessAllowed(AccessRequirement.Login)) { if (isV2AccessAllowed(AccessRequirement.Login)) {
@@ -172,12 +199,32 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
) )
} }
setupLiveEntryObservers()
setupBottomNavigation() setupBottomNavigation()
if (intent.hasExtra(EXTRA_CHAT_FILTER)) { if (intent.hasExtra(EXTRA_CHAT_FILTER)) {
selectChatTabWithLoginGuard() selectChatTabWithLoginGuard()
} }
} }
private fun setupLiveEntryObservers() {
routeLoadingDialog = LoadingDialog(this, layoutInflater)
liveViewModel.toastLiveData.observe(this) {
it?.let(::showToast)
}
liveViewModel.isLoading.observe(this) { isLoading ->
isLiveEntryLoading = isLoading
updateRouteLoadingDialog()
}
}
private fun updateRouteLoadingDialog() {
if (isDeferredRouteLoading || isLiveEntryLoading) {
routeLoadingDialog.show(screenWidth)
} else {
routeLoadingDialog.dismiss()
}
}
fun openChatTab() { fun openChatTab() {
selectChatTabWithLoginGuard() selectChatTabWithLoginGuard()
} }
@@ -433,15 +480,7 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0) val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
intent.removeExtra(Constants.EXTRA_AUDIO_CONTENT_ID) intent.removeExtra(Constants.EXTRA_AUDIO_CONTENT_ID)
if (contentId > 0) { if (contentId > 0) {
ensureV2Access(AccessRequirement.Login) { handleContentAction(ContentActionCommand.AudioDetail(contentId))
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
}
} else if (!isV2AccessAllowed(AccessRequirement.Login)) {
ensureV2Access(AccessRequirement.Login)
} }
contentId > 0 contentId > 0
} }
@@ -566,9 +605,16 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
putQuery("content_id") putQuery("content_id")
putQuery("deep_link_value") putQuery("deep_link_value")
putQuery("deep_link_sub5") putQuery("deep_link_sub5")
putQuery("postId")
putQuery(Constants.EXTRA_COMMUNITY_CREATOR_ID) putQuery(Constants.EXTRA_COMMUNITY_CREATOR_ID)
putQuery(Constants.EXTRA_COMMUNITY_POST_ID) putQuery(Constants.EXTRA_COMMUNITY_POST_ID)
extras.getString("postId")?.takeIf { it.isNotBlank() }?.let {
if (!extras.containsKey(Constants.EXTRA_COMMUNITY_POST_ID)) {
extras.putString(Constants.EXTRA_COMMUNITY_POST_ID, it)
}
}
applyPathDeepLink(data = data) { key, value -> applyPathDeepLink(data = data) { key, value ->
if (!value.isNullOrBlank() && !extras.containsKey(key)) { if (!value.isNullOrBlank() && !extras.containsKey(key)) {
extras.putString(key, value) extras.putString(key, value)
@@ -652,41 +698,39 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 } ?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0) { if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0) {
startActivity(DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId)) handleChatAction(ChatActionCommand.DmRoom(roomId))
return true return true
} }
when { when {
roomId != null && roomId > 0 -> {
liveActionCoordinator.enterLiveRoom(roomId)
return true
}
communityPostId != null && communityPostId > 0 -> {
handleCommunityAction(CommunityActionCommand.PostDetail(communityPostId))
return true
}
channelId != null && channelId > 0 -> { channelId != null && channelId > 0 -> {
startActivity( handleCreatorAction(CreatorActionCommand.Profile(channelId))
CreatorChannelActivity.newIntent(applicationContext, channelId)
)
return true return true
} }
contentId != null && contentId > 0 -> { contentId != null && contentId > 0 -> {
startActivity( handleContentAction(ContentActionCommand.AudioDetail(contentId))
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
return true return true
} }
messageId != null && messageId > 0 -> { messageId != null && messageId > 0 -> {
// messageId는 DM room ID가 아니므로 과거 알림 수신 호환만 유지한다.
startActivity(Intent(applicationContext, MessageActivity::class.java)) startActivity(Intent(applicationContext, MessageActivity::class.java))
return true return true
} }
communityCreatorId != null && communityCreatorId > 0 -> { communityCreatorId != null && communityCreatorId > 0 -> {
startActivity( handleCreatorAction(CreatorActionCommand.Profile(communityCreatorId))
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, communityCreatorId)
if (communityPostId != null && communityPostId > 0) {
putExtra(Constants.EXTRA_COMMUNITY_POST_ID, communityPostId)
}
}
)
return true return true
} }
@@ -711,11 +755,7 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
if (deepLinkValueId == null || deepLinkValueId <= 0) { if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false return false
} }
startActivity( handleContentAction(ContentActionCommand.SeriesDetail(deepLinkValueId))
Intent(applicationContext, SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, deepLinkValueId)
}
)
true true
} }
@@ -723,11 +763,7 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
if (deepLinkValueId == null || deepLinkValueId <= 0) { if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false return false
} }
startActivity( handleContentAction(ContentActionCommand.AudioDetail(deepLinkValueId))
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, deepLinkValueId)
}
)
true true
} }
@@ -735,9 +771,15 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
if (deepLinkValueId == null || deepLinkValueId <= 0) { if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false return false
} }
startActivity( handleCreatorAction(CreatorActionCommand.Profile(deepLinkValueId))
CreatorChannelActivity.newIntent(applicationContext, deepLinkValueId) true
) }
"live" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
liveActionCoordinator.enterLiveRoom(deepLinkValueId)
true true
} }
@@ -745,15 +787,13 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
if (deepLinkValueId == null || deepLinkValueId <= 0) { if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false return false
} }
startActivity( // deepLinkValueId는 legacy creatorId이며 postId는 executeBundleRoute에서 먼저 처리한다.
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply { handleCreatorAction(CreatorActionCommand.Profile(deepLinkValueId))
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
)
true true
} }
"message" -> { "message" -> {
// 현재 DM은 chat/{roomId} 계약을 사용하며 message는 legacy fallback이다.
startActivity(Intent(applicationContext, MessageActivity::class.java)) startActivity(Intent(applicationContext, MessageActivity::class.java))
true true
} }

View File

@@ -11,14 +11,14 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.v2.access.AccessRequirement import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiState import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiState
@@ -173,8 +173,8 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
private fun onChatRoomClick(item: ChatRoomListUiItem) { private fun onChatRoomClick(item: ChatRoomListUiItem) {
when (item.chatType) { when (item.chatType) {
ChatRoomType.AI -> startActivity(ChatRoomActivity.newIntent(requireContext(), item.roomId)) ChatRoomType.AI -> handleChatAction(ChatActionCommand.AiRoom(item.roomId))
ChatRoomType.DM -> startActivity(DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)) ChatRoomType.DM -> handleChatAction(ChatActionCommand.DmRoom(item.roomId))
} }
} }

View File

@@ -30,6 +30,8 @@ import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access 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.ContentActionCommand
import kr.co.vividnext.sodalive.v2.content.action.handleContentAction import kr.co.vividnext.sodalive.v2.content.action.handleContentAction
import kr.co.vividnext.sodalive.v2.creator.action.CreatorActionCommand
import kr.co.vividnext.sodalive.v2.creator.action.handleCreatorAction
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType 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.data.MainContentAllType
import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState
@@ -617,6 +619,9 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private fun onBannerClick(item: ContentBannerUiModel) { private fun onBannerClick(item: ContentBannerUiModel) {
val route = item.toContentBannerRoute() ?: return val route = item.toContentBannerRoute() ?: return
when (route) { when (route) {
is ContentBannerRoute.Creator -> handleCreatorAction(
CreatorActionCommand.Profile(route.creatorId)
)
is ContentBannerRoute.Series -> handleContentAction( is ContentBannerRoute.Series -> handleContentAction(
ContentActionCommand.SeriesDetail(seriesId = route.seriesId) ContentActionCommand.SeriesDetail(seriesId = route.seriesId)
) )

View File

@@ -7,7 +7,6 @@ import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
data class ContentBannerSection( data class ContentBannerSection(
@@ -68,7 +67,7 @@ fun ContentBannerRoute.toContentBannerIntent(context: Context): Intent? {
putExtra(Constants.EXTRA_EVENT, eventItem) putExtra(Constants.EXTRA_EVENT, eventItem)
} }
is ContentBannerRoute.Creator -> CreatorChannelActivity.newIntent(context, creatorId) is ContentBannerRoute.Creator -> null
is ContentBannerRoute.Series -> null is ContentBannerRoute.Series -> null

View File

@@ -6,28 +6,30 @@ import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.following.FollowingCreatorActivity import kr.co.vividnext.sodalive.following.FollowingCreatorActivity
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListActivity import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListActivity
import kr.co.vividnext.sodalive.live.LiveViewModel import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity import kr.co.vividnext.sodalive.search.SearchActivity
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.live.onair.HomeOnAirLiveActivity
import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
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.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction
import kr.co.vividnext.sodalive.v2.community.action.CommunityActionCommand
import kr.co.vividnext.sodalive.v2.community.action.handleCommunityAction
import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand
import kr.co.vividnext.sodalive.v2.content.action.handleContentAction import kr.co.vividnext.sodalive.v2.content.action.handleContentAction
import kr.co.vividnext.sodalive.v2.creator.action.CreatorActionCommand
import kr.co.vividnext.sodalive.v2.creator.action.handleCreatorAction
import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator
import kr.co.vividnext.sodalive.v2.live.onair.HomeOnAirLiveActivity
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.main.home.model.HomeFollowingChatSection 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.HomeFollowingCreatorSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingLiveSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingLiveSection
@@ -45,6 +47,7 @@ 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.HomeRecommendationBannerUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCheerCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCheerCreatorSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterRoute
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostSection
@@ -56,7 +59,6 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyAct
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState 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.toHomeRecommendationBannerIntent
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerRoute 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.toHomeRecommendationAiCharacterRoute
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorRoute 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.HomeAiCharacterAdapter
@@ -556,9 +558,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
val community = item as? HomeFollowingNewsUiItem.Community ?: return val community = item as? HomeFollowingNewsUiItem.Community ?: return
val postId = community.postId val postId = community.postId
if (postId <= 0L) return if (postId <= 0L) return
ensureV2Access(AccessRequirement.Login) { handleCommunityAction(CommunityActionCommand.PostDetail(postId))
startActivity(CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId))
}
} }
private fun openFollowingCreatorAll() { private fun openFollowingCreatorAll() {
@@ -568,17 +568,18 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
} }
private fun openFollowingChat(item: ChatRoomListUiItem) { private fun openFollowingChat(item: ChatRoomListUiItem) {
ensureV2Access(AccessRequirement.Login) { when (item.chatType) {
when (item.chatType) { ChatRoomType.AI -> handleChatAction(ChatActionCommand.AiRoom(item.roomId))
ChatRoomType.AI -> startActivity(ChatRoomActivity.newIntent(requireContext(), item.roomId)) ChatRoomType.DM -> handleChatAction(ChatActionCommand.DmRoom(item.roomId))
ChatRoomType.DM -> startActivity(DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId))
}
} }
} }
private fun onBannerClick(item: HomeRecommendationBannerUiModel) { private fun onBannerClick(item: HomeRecommendationBannerUiModel) {
val route = item.toHomeRecommendationBannerRoute() ?: return val route = item.toHomeRecommendationBannerRoute() ?: return
when (route) { when (route) {
is HomeRecommendationBannerRoute.Creator -> handleCreatorAction(
CreatorActionCommand.Profile(route.creatorId)
)
is HomeRecommendationBannerRoute.Series -> handleContentAction( is HomeRecommendationBannerRoute.Series -> handleContentAction(
ContentActionCommand.SeriesDetail(seriesId = route.seriesId) ContentActionCommand.SeriesDetail(seriesId = route.seriesId)
) )
@@ -594,16 +595,18 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
is HomeRecommendationRecentlyActiveCreatorRoute.AudioContent -> handleContentAction( is HomeRecommendationRecentlyActiveCreatorRoute.AudioContent -> handleContentAction(
ContentActionCommand.AudioDetail(audioContentId = route.contentId) ContentActionCommand.AudioDetail(audioContentId = route.contentId)
) )
is HomeRecommendationRecentlyActiveCreatorRoute.Community -> ensureV2Access(AccessRequirement.Login) { is HomeRecommendationRecentlyActiveCreatorRoute.Community -> handleCommunityAction(
startActivity(CreatorChannelCommunityDetailActivity.newIntent(requireContext(), route.postId)) CommunityActionCommand.PostDetail(route.postId)
} )
} }
} }
private fun onAiCharacterClick(item: HomeRecommendationAiCharacterUiModel) { private fun onAiCharacterClick(item: HomeRecommendationAiCharacterUiModel) {
val route = item.toHomeRecommendationAiCharacterRoute() ?: return val route = item.toHomeRecommendationAiCharacterRoute() ?: return
ensureV2Access(AccessRequirement.Login) { when (route) {
startActivity(route.toHomeRecommendationAiCharacterIntent(requireContext())) is HomeRecommendationAiCharacterRoute.Creator -> handleCreatorAction(
CreatorActionCommand.Profile(route.creatorId)
)
} }
} }
@@ -613,18 +616,12 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
} }
private fun openCreatorProfile(creatorId: Long) { private fun openCreatorProfile(creatorId: Long) {
ensureV2Access(AccessRequirement.Login) { handleCreatorAction(CreatorActionCommand.Profile(creatorId))
startActivity(
CreatorChannelActivity.newIntent(requireContext(), creatorId)
)
}
} }
private fun openPopularCommunityPost(item: FeedItem.Community) { private fun openPopularCommunityPost(item: FeedItem.Community) {
val postId = item.postId.toLongOrNull() ?: return val postId = item.postId.toLongOrNull() ?: return
ensureV2Access(AccessRequirement.Login) { handleCommunityAction(CommunityActionCommand.PostDetail(postId))
startActivity(CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId))
}
} }
private fun showToast(toastMessage: ToastMessage) { private fun showToast(toastMessage: ToastMessage) {

View File

@@ -8,10 +8,10 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
import java.util.Locale import java.util.Locale
import androidx.core.net.toUri
data class HomeRecommendationLiveSection( data class HomeRecommendationLiveSection(
val items: List<HomeRecommendationLiveUiModel> val items: List<HomeRecommendationLiveUiModel>
@@ -105,13 +105,11 @@ fun HomeRecommendationBannerRoute.toHomeRecommendationBannerIntent(context: Cont
} }
} }
is HomeRecommendationBannerRoute.Creator -> { is HomeRecommendationBannerRoute.Creator -> null
CreatorChannelActivity.newIntent(context, creatorId)
}
is HomeRecommendationBannerRoute.Series -> null is HomeRecommendationBannerRoute.Series -> null
is HomeRecommendationBannerRoute.Link -> Intent(Intent.ACTION_VIEW, Uri.parse(url)) is HomeRecommendationBannerRoute.Link -> Intent(Intent.ACTION_VIEW, url.toUri())
} }
} }
@@ -163,12 +161,6 @@ fun HomeRecommendationAiCharacterUiModel.toHomeRecommendationAiCharacterRoute():
return creatorId.takeIf { it > 0L }?.let(HomeRecommendationAiCharacterRoute::Creator) return creatorId.takeIf { it > 0L }?.let(HomeRecommendationAiCharacterRoute::Creator)
} }
fun HomeRecommendationAiCharacterRoute.toHomeRecommendationAiCharacterIntent(context: Context): Intent {
return when (this) {
is HomeRecommendationAiCharacterRoute.Creator -> CreatorChannelActivity.newIntent(context, creatorId)
}
}
data class HomeRecommendationGenreCreatorGroupUiModel( data class HomeRecommendationGenreCreatorGroupUiModel(
val genre: String, val genre: String,
val creators: List<HomeRecommendationCreatorUiModel>, val creators: List<HomeRecommendationCreatorUiModel>,

View File

@@ -8,14 +8,16 @@ import java.io.File
class DeepLinkActivitySourceTest { class DeepLinkActivitySourceTest {
@Test @Test
fun `DeepLinkActivity는 chat_type 없이 chat path DM 채팅방으로 라우팅한다`() { fun `DeepLinkActivity는 chat path DM을 Chat Action으로 라우팅한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt").readText() val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt").readText()
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity")) assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand"))
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction"))
assertFalse(source.contains("chat_type")) assertFalse(source.contains("chat_type"))
assertFalse(source.contains("isUserCreatorChat")) assertFalse(source.contains("isUserCreatorChat"))
assertTrue(source.contains("return bundle.getString(\"deep_link_value\") == \"chat\"")) assertTrue(source.contains("return bundle.getString(\"deep_link_value\") == \"chat\""))
assertTrue(source.contains("DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId)")) assertTrue(source.contains("handleChatAction(ChatActionCommand.DmRoom(roomId))"))
assertFalse(source.contains("DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId)"))
assertTrue(source.contains("if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0)")) assertTrue(source.contains("if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0)"))
} }
@@ -50,6 +52,47 @@ class DeepLinkActivitySourceTest {
assertTrue(dmRouteIndex < liveRouteIndex) assertTrue(dmRouteIndex < liveRouteIndex)
} }
@Test
fun `DeepLinkActivity는 Community post를 creator fallback보다 먼저 Action으로 라우팅한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt").readText()
val postRouteIndex = source.indexOf("communityPostId != null && communityPostId > 0 ->")
val creatorFallbackIndex = source.indexOf("communityCreatorId != null && communityCreatorId > 0 ->")
assertTrue(postRouteIndex >= 0)
assertTrue(creatorFallbackIndex >= 0)
assertTrue(postRouteIndex < creatorFallbackIndex)
assertTrue(
source.contains(
"handleCommunityAction(CommunityActionCommand.PostDetail(communityPostId))"
)
)
assertTrue(
source.contains(
"handleCreatorAction(CreatorActionCommand.Profile(communityCreatorId))"
)
)
}
@Test
fun `DeepLinkActivity는 foreground 도메인 상세를 기존 Action으로 실행한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt").readText()
val routeSource = source.substringAfter("private fun routeByDeepLinkValue(")
.substringBefore("private fun applyPathDeepLink(")
val communityRoute = routeSource.substringAfter("\"community\" ->").substringBefore("\"message\" ->")
val messageRoute = routeSource.substringAfter("\"message\" ->").substringBefore("\"audition\" ->")
assertTrue(source.contains("handleContentAction(ContentActionCommand.AudioDetail(contentId))"))
assertTrue(source.contains("handleContentAction(ContentActionCommand.SeriesDetail(deepLinkValueId))"))
assertTrue(source.contains("handleCreatorAction(CreatorActionCommand.Profile(channelId))"))
assertTrue(source.contains("handleChatAction(ChatActionCommand.DmRoom(roomId))"))
assertTrue(source.contains("routeLiveInMain(roomId)"))
assertTrue(communityRoute.contains("handleCreatorAction(CreatorActionCommand.Profile(deepLinkValueId))"))
assertFalse(communityRoute.contains("CommunityActionCommand.PostDetail"))
assertTrue(messageRoute.contains("MessageActivity::class.java"))
assertFalse(messageRoute.contains("ChatActionCommand.DmRoom"))
}
private fun projectFile(relativePath: String): File { private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath")) val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() } return candidates.firstOrNull { it.exists() }

View File

@@ -0,0 +1,176 @@
package kr.co.vividnext.sodalive.v2.chat.action
import android.app.Activity
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
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 ChatActionTest {
private val action = ChatAction()
@Test
fun `유효하지 않은 room 또는 creator ID는 무시한다`() {
var accessCalls = 0
val results = listOf(
action.execute(ChatActionCommand.AiRoom(roomId = 0L)) { accessCalls += 1; true },
action.execute(ChatActionCommand.DmRoom(roomId = -1L)) { accessCalls += 1; true },
action.execute(ChatActionCommand.DmCreator(creatorId = 0L)) { accessCalls += 1; true }
)
assertEquals(List(3) { ChatActionResult.Ignored }, results)
assertEquals(0, accessCalls)
}
@Test
fun `AI room command는 AI 채팅방 navigation 결과를 반환한다`() {
val result = action.execute(ChatActionCommand.AiRoom(roomId = 41L)) { requirement ->
assertEquals(AccessRequirement.Login, requirement)
true
}
assertEquals(ChatActionResult.NavigateToAiRoom(roomId = 41L), result)
}
@Test
fun `DM command는 room과 creator 진입을 구분한다`() {
val dmRoom = action.execute(ChatActionCommand.DmRoom(roomId = 42L)) { requirement ->
assertEquals(AccessRequirement.Login, requirement)
true
}
val dmCreator = action.execute(ChatActionCommand.DmCreator(creatorId = 43L)) { requirement ->
assertEquals(AccessRequirement.Login, requirement)
true
}
assertEquals(ChatActionResult.NavigateToDmRoom(roomId = 42L), dmRoom)
assertEquals(ChatActionResult.NavigateToCreatorDm(creatorId = 43L), dmCreator)
}
@Test
fun `Owner DM command는 owner DM 목록 navigation 결과를 반환한다`() {
val result = action.execute(ChatActionCommand.OwnerDmList) { requirement ->
assertEquals(AccessRequirement.Login, requirement)
true
}
assertEquals(ChatActionResult.NavigateToOwnerDmList, result)
}
@Test
fun `로그인 접근이 차단되면 Blocked 결과를 반환한다`() {
val result = action.execute(ChatActionCommand.AiRoom(roomId = 41L)) { requirement ->
assertEquals(AccessRequirement.Login, requirement)
false
}
assertEquals(ChatActionResult.Blocked(AccessRequirement.Login), result)
}
@Test
fun `Handler는 AI room command를 기존 ChatRoomActivity로 연결한다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
ChatActionHandler().handle(activity, ChatActionCommand.AiRoom(roomId = 44L)) { true }
val intent = shadowOf(activity).nextStartedActivity
assertEquals(ChatRoomActivity::class.java.name, intent.component?.className)
}
@Test
fun `Handler는 DM room command에 기존 room ID extra를 전달한다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
ChatActionHandler().handle(activity, ChatActionCommand.DmRoom(roomId = 45L)) { true }
val intent = shadowOf(activity).nextStartedActivity
assertEquals(DmChatRoomActivity::class.java.name, intent.component?.className)
assertEquals(45L, intent.getLongExtra(DmChatRoomActivity.EXTRA_ROOM_ID, 0L))
}
@Test
fun `Handler는 DM creator command에 기존 creator ID extra를 전달한다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
ChatActionHandler().handle(activity, ChatActionCommand.DmCreator(creatorId = 46L)) { true }
val intent = shadowOf(activity).nextStartedActivity
assertEquals(DmChatRoomActivity::class.java.name, intent.component?.className)
assertEquals(46L, intent.getLongExtra(DmChatRoomActivity.EXTRA_CREATOR_ID, 0L))
}
@Test
fun `Handler는 owner DM command를 MainV2 DM 필터 intent로 연결한다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
ChatActionHandler().handle(activity, ChatActionCommand.OwnerDmList) { true }
val intent = shadowOf(activity).nextStartedActivity
val expected = MainV2Activity.newChatDmIntent(activity)
assertEquals(expected.component?.className, intent.component?.className)
assertEquals(expected.flags, intent.flags)
}
@Test
fun `Handler는 invalid chat command에서 Activity를 시작하지 않는다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val result = ChatActionHandler().handle(activity, ChatActionCommand.DmRoom(roomId = 0L)) { true }
assertEquals(ChatActionResult.Ignored, result)
assertNull(shadowOf(activity).nextStartedActivity)
}
@Test
fun `Handler는 login 차단 결과에서 Activity를 시작하지 않는다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val result = ChatActionHandler().handle(activity, ChatActionCommand.DmRoom(roomId = 45L)) { false }
assertEquals(ChatActionResult.Blocked(AccessRequirement.Login), result)
assertNull(shadowOf(activity).nextStartedActivity)
}
@Test
fun `대상 화면은 채팅방 Activity 대신 Chat Action을 사용한다`() {
val targetSources = listOf(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt",
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt",
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).map { path -> projectFile(path).readText() }
val handlerSource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/chat/action/ChatActionHandler.kt"
).readText()
targetSources.forEach { source ->
assertTrue(source.contains("handleChatAction(ChatActionCommand."))
assertFalse(source.contains("ChatRoomActivity.newIntent"))
assertFalse(source.contains("DmChatRoomActivity.newIntentByRoomId"))
assertFalse(source.contains("DmChatRoomActivity.newIntentByCreatorId"))
}
assertTrue(handlerSource.contains("ChatRoomActivity.newIntent"))
assertTrue(handlerSource.contains("DmChatRoomActivity.newIntentByRoomId"))
assertTrue(handlerSource.contains("DmChatRoomActivity.newIntentByCreatorId"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}

View File

@@ -0,0 +1,114 @@
package kr.co.vividnext.sodalive.v2.community.action
import android.app.Activity
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity
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 CommunityActionTest {
private val action = CommunityAction()
@Test
fun `유효하지 않은 post ID는 Access를 확인하지 않고 무시한다`() {
var accessCheckCount = 0
val result = action.execute(CommunityActionCommand.PostDetail(postId = 0L)) {
accessCheckCount += 1
true
}
assertEquals(CommunityActionResult.Ignored, result)
assertEquals(0, accessCheckCount)
}
@Test
fun `Community Post Detail은 로그인 Access가 허용되면 navigation 결과를 반환한다`() {
var capturedRequirement: AccessRequirement? = null
val result = action.execute(CommunityActionCommand.PostDetail(postId = 21L)) { requirement ->
capturedRequirement = requirement
true
}
assertEquals(AccessRequirement.Login, capturedRequirement)
assertEquals(CommunityActionResult.NavigateToPostDetail(postId = 21L), result)
}
@Test
fun `Access가 거부되면 Community Post Detail navigation을 차단한다`() {
val result = action.execute(CommunityActionCommand.PostDetail(postId = 22L)) { false }
assertEquals(CommunityActionResult.Blocked(AccessRequirement.Login), result)
}
@Test
fun `Handler는 기본 launch로 기존 Community Detail Activity를 시작한다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val result = CommunityActionHandler().handle(
context = activity,
command = CommunityActionCommand.PostDetail(postId = 23L),
ensureAccess = { true }
)
val intent = shadowOf(activity).nextStartedActivity
assertEquals(CommunityActionResult.NavigateToPostDetail(postId = 23L), result)
assertEquals(CreatorChannelCommunityDetailActivity::class.java.name, intent.component?.className)
}
@Test
fun `Handler는 ActivityResultLauncher용 launch lambda를 사용할 수 있다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
var launchedComponent: String? = null
val result = CommunityActionHandler().handle(
context = activity,
command = CommunityActionCommand.PostDetail(postId = 24L),
ensureAccess = { true },
launchIntent = { intent -> launchedComponent = intent.component?.className }
)
assertEquals(CommunityActionResult.NavigateToPostDetail(postId = 24L), result)
assertEquals(CreatorChannelCommunityDetailActivity::class.java.name, launchedComponent)
assertNull(shadowOf(activity).nextStartedActivity)
}
@Test
fun `대상 화면은 Community Detail Activity 대신 Community Action을 사용한다`() {
val homeSource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
).readText()
val activitySource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val handlerSource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/community/action/CommunityActionHandler.kt"
).readText()
val targetSources = listOf(homeSource, activitySource)
targetSources.forEach { source ->
assertTrue(source.contains("handleCommunityAction(CommunityActionCommand.PostDetail"))
assertFalse(source.contains("CreatorChannelCommunityDetailActivity.newIntent"))
}
assertTrue(handlerSource.contains("CreatorChannelCommunityDetailActivity.newIntent"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}

View File

@@ -0,0 +1,56 @@
package kr.co.vividnext.sodalive.v2.community.action
import android.app.Activity
import org.junit.Assert.assertEquals
import org.junit.Test
class CommunityChangeTest {
@Test
fun `작성 Activity RESULT_OK는 Created 변경으로 변환한다`() {
val change = resolveCommunityActivityResult(
source = CommunityActivityResultSource.Write,
resultCode = Activity.RESULT_OK
)
assertEquals(CommunityChange.Created, change)
}
@Test
fun `수정 Activity RESULT_OK는 Updated 변경으로 변환한다`() {
val change = resolveCommunityActivityResult(
source = CommunityActivityResultSource.Modify,
resultCode = Activity.RESULT_OK
)
assertEquals(CommunityChange.Updated, change)
}
@Test
fun `상세 Activity RESULT_OK는 Updated 변경으로 변환한다`() {
val change = resolveCommunityActivityResult(
source = CommunityActivityResultSource.Detail,
resultCode = Activity.RESULT_OK
)
assertEquals(CommunityChange.Updated, change)
}
@Test
fun `취소 또는 실패 결과는 Community 변경을 무시한다`() {
val changes = CommunityActivityResultSource.entries.map { source ->
resolveCommunityActivityResult(source = source, resultCode = Activity.RESULT_CANCELED)
}
assertEquals(List(CommunityActivityResultSource.entries.size) { CommunityChange.Ignored }, changes)
}
@Test
fun `삭제와 고정 변경은 post ID를 가진 명시적 변경으로 표현한다`() {
val deleted = CommunityChange.Deleted(postId = 31L)
val pinChanged = CommunityChange.PinChanged(postId = 32L)
assertEquals(31L, deleted.postId)
assertEquals(32L, pinChanged.postId)
}
}

View File

@@ -0,0 +1,109 @@
package kr.co.vividnext.sodalive.v2.creator.action
import android.app.Activity
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
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 CreatorActionTest {
private val action = CreatorAction()
@Test
fun `유효하지 않은 creator ID는 Access를 확인하지 않고 무시한다`() {
var accessCheckCount = 0
val result = action.execute(CreatorActionCommand.Profile(creatorId = 0L)) {
accessCheckCount += 1
true
}
assertEquals(CreatorActionResult.Ignored, result)
assertEquals(0, accessCheckCount)
}
@Test
fun `Creator Profile은 로그인 Access가 허용되면 navigation 결과를 반환한다`() {
var capturedRequirement: AccessRequirement? = null
val result = action.execute(CreatorActionCommand.Profile(creatorId = 11L)) { requirement ->
capturedRequirement = requirement
true
}
assertEquals(AccessRequirement.Login, capturedRequirement)
assertEquals(CreatorActionResult.NavigateToCreatorProfile(creatorId = 11L), result)
}
@Test
fun `Access가 거부되면 Creator Profile navigation을 차단한다`() {
val result = action.execute(CreatorActionCommand.Profile(creatorId = 12L)) { false }
assertEquals(CreatorActionResult.Blocked(AccessRequirement.Login), result)
}
@Test
fun `Handler는 허용된 Creator Profile에 기존 creator ID extra를 전달한다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val result = CreatorActionHandler().handle(
context = activity,
command = CreatorActionCommand.Profile(creatorId = 13L),
ensureAccess = { true }
)
val intent = shadowOf(activity).nextStartedActivity
assertEquals(CreatorActionResult.NavigateToCreatorProfile(creatorId = 13L), result)
assertEquals(CreatorChannelActivity::class.java.name, intent.component?.className)
assertEquals(13L, intent.getLongExtra(CreatorChannelActivity.EXTRA_CREATOR_ID, 0L))
}
@Test
fun `Handler는 invalid Creator Profile에서 Activity를 시작하지 않는다`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val result = CreatorActionHandler().handle(
context = activity,
command = CreatorActionCommand.Profile(creatorId = -1L),
ensureAccess = { true }
)
assertEquals(CreatorActionResult.Ignored, result)
assertNull(shadowOf(activity).nextStartedActivity)
}
@Test
fun `대상 화면은 Creator Channel Activity 대신 Creator Action을 사용한다`() {
val homeSource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
).readText()
val homeModelSource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt"
).readText()
val contentModelSource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt"
).readText()
assertTrue(homeSource.contains("handleCreatorAction(CreatorActionCommand.Profile(creatorId))"))
assertFalse(homeModelSource.contains("CreatorChannelActivity.newIntent"))
assertFalse(contentModelSource.contains("CreatorChannelActivity.newIntent"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}

View File

@@ -40,8 +40,8 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("if (creatorId <= 0L)")) assertTrue(source.contains("if (creatorId <= 0L)"))
assertTrue(source.contains("finish()")) assertTrue(source.contains("finish()"))
assertFalse(source.contains("is CreatorChannelHomeUiState.Error -> showToast")) assertFalse(source.contains("is CreatorChannelHomeUiState.Error -> showToast"))
assertTrue(source.contains("ChatRoomActivity.newIntent(this, chatRoomId)")) assertTrue(source.contains("handleChatAction(ChatActionCommand.AiRoom(chatRoomId))"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByCreatorId(this, creatorId)")) assertTrue(source.contains("handleChatAction(ChatActionCommand.DmCreator(creatorId))"))
assertTrue(source.contains("homeActionDelegate?.createChatRoom(characterId)")) assertTrue(source.contains("homeActionDelegate?.createChatRoom(characterId)"))
assertTrue(source.contains("updateActionButtonLayout")) assertTrue(source.contains("updateActionButtonLayout"))
assertTrue(source.contains("marginStart = if (isChatVisible && isDmVisible)")) assertTrue(source.contains("marginStart = if (isChatVisible && isDmVisible)"))
@@ -93,7 +93,7 @@ class CreatorChannelActivitySourceTest {
} }
@Test @Test
fun `커뮤니티 게시물 클릭 source는 상세 Activity newIntent로 진입하고 invalid postId를 무시한다`() { fun `커뮤니티 게시물 클릭 source는 공통 Community Action으로 진입한다`() {
val source = projectFile( val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText() ).readText()
@@ -104,27 +104,19 @@ class CreatorChannelActivitySourceTest {
assertTrue(handlerEnd > handlerStart) assertTrue(handlerEnd > handlerStart)
val handlerSource = source.substring(handlerStart, handlerEnd) val handlerSource = source.substring(handlerStart, handlerEnd)
assertTrue( assertFalse(
source.contains( source.contains(
"import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity" "import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity"
) )
) )
assertTrue(handlerSource.contains("if (postId <= 0L) return")) assertTrue(handlerSource.contains("handleCommunityAction(CommunityActionCommand.PostDetail(postId)"))
assertTrue(handlerSource.contains("ensureV2Access(AccessRequirement.Login)"))
assertTrue(handlerSource.contains("CreatorChannelCommunityDetailActivity.newIntent(this, postId)"))
assertTrue(source.contains("private val communityDetailLauncher = registerForActivityResult")) assertTrue(source.contains("private val communityDetailLauncher = registerForActivityResult"))
assertTrue( assertTrue(
handlerSource.contains( handlerSource.contains(
"communityDetailLauncher.launch(CreatorChannelCommunityDetailActivity.newIntent(this, postId))" "handleCommunityAction(CommunityActionCommand.PostDetail(postId), communityDetailLauncher::launch)"
) )
) )
assertTrue( assertTrue(source.contains("private fun handleCommunityChange(change: CommunityChange)"))
handlerSource.indexOf("ensureV2Access(AccessRequirement.Login)") <
handlerSource.indexOf(
"communityDetailLauncher.launch(CreatorChannelCommunityDetailActivity.newIntent(this, postId))"
)
)
assertTrue(source.contains("refreshCreatorChannelCommunity()"))
} }
@Test @Test
@@ -229,7 +221,7 @@ class CreatorChannelActivitySourceTest {
assertTrue(layout.contains("android:id=\"@+id/tv_chat_button\"")) assertTrue(layout.contains("android:id=\"@+id/tv_chat_button\""))
assertTrue(layout.contains("android:id=\"@+id/tv_dm_button\"")) assertTrue(layout.contains("android:id=\"@+id/tv_dm_button\""))
assertTrue(layout.contains("tools:visibility=\"visible\"")) assertTrue(layout.contains("tools:visibility=\"visible\""))
assertTrue(source.contains("MainV2Activity.newChatDmIntent(this)")) assertTrue(source.contains("handleChatAction(ChatActionCommand.OwnerDmList)"))
assertTrue( assertTrue(
source.contains( source.contains(
"if (header.isOwner) R.string.creator_channel_dm_check_button else R.string.creator_channel_dm_button" "if (header.isOwner) R.string.creator_channel_dm_check_button else R.string.creator_channel_dm_button"
@@ -245,7 +237,7 @@ class CreatorChannelActivitySourceTest {
assertTrue(chat.contains("selectedIndex = initialFilter.tabIndex")) assertTrue(chat.contains("selectedIndex = initialFilter.tabIndex"))
assertTrue(chat.contains("fun selectFilter(filter: ChatRoomFilter)")) assertTrue(chat.contains("fun selectFilter(filter: ChatRoomFilter)"))
assertTrue(filter.contains("DM(\"DM\", 2)")) assertTrue(filter.contains("DM(\"DM\", 2)"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByCreatorId(this, creatorId)")) assertTrue(source.contains("handleChatAction(ChatActionCommand.DmCreator(creatorId))"))
} }
@Test @Test
@@ -665,6 +657,86 @@ class CreatorChannelActivitySourceTest {
assertTrue(fragment.contains("mediaPlayerManager?.stopContent()")) assertTrue(fragment.contains("mediaPlayerManager?.stopContent()"))
} }
@Test
fun `커뮤니티 고정 성공은 실제 post id로 단일 변경 handler를 호출한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val fixedSource = sourceSection(
source = source,
startMarker = "private fun updateCreatorChannelCommunityPostFixed",
endMarker = "private fun deleteCreatorChannelCommunityPost"
)
val successMarker = "if (response.success)"
val beforeSuccessSource = fixedSource.substring(0, fixedSource.indexOf(successMarker))
val successSource = fixedSource.substring(
fixedSource.indexOf(successMarker),
fixedSource.indexOf("} else", fixedSource.indexOf(successMarker))
)
assertTrue(fixedSource.contains("postId = item.postId"))
assertTrue(fixedSource.contains("isFixed = !item.isPinned"))
assertTrue(successSource.contains("handleCommunityChange(CommunityChange.PinChanged(item.postId))"))
assertFalse(beforeSuccessSource.contains("CommunityChange.PinChanged"))
assertEquals(1, fixedSource.split("CommunityChange.PinChanged").size - 1)
assertFalse(fixedSource.contains("CommunityChange.PinChanged(creatorId)"))
assertFalse(fixedSource.contains("refreshCreatorChannelCommunity()"))
}
@Test
fun `커뮤니티 삭제 성공은 실제 post id로 단일 변경 handler를 호출한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val deleteSource = sourceSection(
source = source,
startMarker = "private fun deleteCreatorChannelCommunityPost",
endMarker = "private fun authToken()"
)
val successMarker = "if (response.success)"
val beforeSuccessSource = deleteSource.substring(0, deleteSource.indexOf(successMarker))
val successSource = deleteSource.substring(
deleteSource.indexOf(successMarker),
deleteSource.indexOf("} else", deleteSource.indexOf(successMarker))
)
assertTrue(deleteSource.contains("creatorCommunityId = item.postId"))
assertTrue(deleteSource.contains("isActive = false"))
assertTrue(successSource.contains("handleCommunityChange(CommunityChange.Deleted(item.postId))"))
assertFalse(beforeSuccessSource.contains("CommunityChange.Deleted"))
assertEquals(1, deleteSource.split("CommunityChange.Deleted").size - 1)
assertFalse(deleteSource.contains("CommunityChange.Deleted(creatorId)"))
assertFalse(deleteSource.contains("refreshCreatorChannelCommunity()"))
}
@Test
fun `커뮤니티 변경 handler는 변경 종류별 projection 갱신을 단일 소유한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val handlerSource = sourceSection(
source = source,
startMarker = "private fun handleCommunityChange(change: CommunityChange)",
endMarker = "private fun refreshCreatorChannelFanTalk()"
)
val createdSource = handlerSource.substring(
handlerSource.indexOf("CommunityChange.Created"),
handlerSource.indexOf("CommunityChange.Updated")
)
val mutationSource = handlerSource.substring(handlerSource.indexOf("CommunityChange.Updated"))
assertTrue(createdSource.contains("homeActionDelegate?.refreshHome()"))
assertTrue(createdSource.contains("refreshCreatorChannelCommunity()"))
assertTrue(
mutationSource.contains(
"CommunityChange.Updated,\n" +
" is CommunityChange.Deleted,\n" +
" is CommunityChange.PinChanged -> refreshCreatorChannelCommunity()"
)
)
assertFalse(mutationSource.contains("homeActionDelegate?.refreshHome()"))
}
@Test @Test
fun `FanTalk tab source는 Fragment Host pagination height delete dialog를 Activity에 연결한다`() { fun `FanTalk tab source는 Fragment Host pagination height delete dialog를 Activity에 연결한다`() {
val source = projectFile( val source = projectFile(
@@ -1100,7 +1172,7 @@ class CreatorChannelActivitySourceTest {
assertTrue(fragment.contains("onCommunityClick = ::onCommunityClicked")) assertTrue(fragment.contains("onCommunityClick = ::onCommunityClicked"))
assertTrue(fragment.contains("host.onCreatorChannelCommunityPostClicked(postId)")) assertTrue(fragment.contains("host.onCreatorChannelCommunityPostClicked(postId)"))
assertTrue(activity.contains("override fun onCreatorChannelCommunityPostClicked(postId: Long)")) assertTrue(activity.contains("override fun onCreatorChannelCommunityPostClicked(postId: Long)"))
assertTrue(activity.contains("CreatorChannelCommunityDetailActivity.newIntent(this, postId)")) assertTrue(activity.contains("handleCommunityAction(CommunityActionCommand.PostDetail(postId)"))
} }
@Test @Test

View File

@@ -8,15 +8,17 @@ import java.io.File
class MainV2ActivitySourceTest { class MainV2ActivitySourceTest {
@Test @Test
fun `MainV2Activity는 chat_type 없이 chat path DM 채팅방으로 라우팅한다`() { fun `MainV2Activity는 chat path DM을 Chat Action으로 라우팅한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText() val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity")) assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand"))
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction"))
assertFalse(source.contains("chat_type")) assertFalse(source.contains("chat_type"))
assertFalse(source.contains("isUserCreatorChat")) assertFalse(source.contains("isUserCreatorChat"))
assertTrue(source.contains("return bundle.getString(\"deep_link_value\") == \"chat\"")) assertTrue(source.contains("return bundle.getString(\"deep_link_value\") == \"chat\""))
assertTrue(source.contains("val roomId = bundle.getString(\"room_id\")?.toLongOrNull()")) assertTrue(source.contains("val roomId = bundle.getString(\"room_id\")?.toLongOrNull()"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId)")) assertTrue(source.contains("handleChatAction(ChatActionCommand.DmRoom(roomId))"))
assertFalse(source.contains("DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId)"))
assertTrue(source.contains("if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0)")) assertTrue(source.contains("if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0)"))
assertFalse(source.contains("private fun isLoggedIn()")) assertFalse(source.contains("private fun isLoggedIn()"))
assertTrue(source.contains("fun showLoginActivity()")) assertTrue(source.contains("fun showLoginActivity()"))
@@ -36,6 +38,15 @@ class MainV2ActivitySourceTest {
assertTrue(source.contains("putQuery(\"room_id\")")) assertTrue(source.contains("putQuery(\"room_id\")"))
} }
@Test
fun `MainV2Activity는 community postId query를 canonical extra로 정규화한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
assertTrue(source.contains("putQuery(\"postId\")"))
assertTrue(source.contains("extras.getString(\"postId\")?.takeIf { it.isNotBlank() }?.let"))
assertTrue(source.contains("extras.putString(Constants.EXTRA_COMMUNITY_POST_ID, it)"))
}
@Test @Test
fun `MainV2Activity는 chat path deep_link 단독 payload를 DM 채팅방으로 라우팅한다`() { fun `MainV2Activity는 chat path deep_link 단독 payload를 DM 채팅방으로 라우팅한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText() val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
@@ -53,6 +64,104 @@ class MainV2ActivitySourceTest {
assertTrue(dmRouteIndex < firstFallbackIndex) assertTrue(dmRouteIndex < firstFallbackIndex)
} }
@Test
fun `MainV2Activity는 non-DM room을 channel보다 먼저 Live Action으로 라우팅한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val routeSource = source.substringFrom("private fun executeBundleRoute(bundle: Bundle): Boolean")
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator"))
assertTrue(source.contains("private val liveActionCoordinator: LiveActionCoordinator by lazy"))
assertTrue(routeSource.contains("roomId != null && roomId > 0 ->"))
assertTrue(routeSource.contains("liveActionCoordinator.enterLiveRoom(roomId)"))
assertBefore(
routeSource,
"roomId != null && roomId > 0 ->",
"channelId != null && channelId > 0 ->"
)
}
@Test
fun `MainV2Activity는 cold start 딥링크 공통 대기와 Live 조회에 문구 없는 로딩을 유지한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val onCreateSource = source.substringFrom("override fun onCreate(savedInstanceState: Bundle?)")
val observerSource = source.substringFrom("private fun setupLiveEntryObservers()")
val updateSource = source.substringFrom("private fun updateRouteLoadingDialog()")
val setupViewSource = source.substringFrom("override fun setupView()")
assertTrue(source.contains("import kr.co.vividnext.sodalive.common.LoadingDialog"))
assertTrue(source.contains("private lateinit var routeLoadingDialog: LoadingDialog"))
assertTrue(source.contains("private var isDeferredRouteLoading = false"))
assertTrue(source.contains("private var isLiveEntryLoading = false"))
assertTrue(observerSource.contains("routeLoadingDialog = LoadingDialog(this, layoutInflater)"))
assertTrue(observerSource.contains("liveViewModel.toastLiveData.observe(this)"))
assertTrue(observerSource.contains("it?.let(::showToast)"))
assertTrue(observerSource.contains("liveViewModel.isLoading.observe(this)"))
assertTrue(observerSource.contains("isLiveEntryLoading = isLoading"))
assertTrue(observerSource.contains("updateRouteLoadingDialog()"))
assertTrue(
onCreateSource.contains(
"intent.hasExtra(Constants.EXTRA_DATA) || intent.hasExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE)"
)
)
assertBefore(onCreateSource, "isDeferredRouteLoading =", "handler.postDelayed")
assertTrue(onCreateSource.contains("isDeferredRouteLoading = false"))
assertTrue(updateSource.contains("if (isDeferredRouteLoading || isLiveEntryLoading)"))
assertTrue(updateSource.contains("routeLoadingDialog.show(screenWidth)"))
assertFalse(updateSource.contains("R.string.screen_live_loading"))
assertTrue(updateSource.contains("routeLoadingDialog.dismiss()"))
assertTrue(setupViewSource.contains("setupLiveEntryObservers()"))
}
@Test
fun `MainV2Activity는 Community post를 creator fallback보다 먼저 Action으로 라우팅한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val routeSource = source.substringFrom("private fun executeBundleRoute(bundle: Bundle): Boolean")
assertTrue(routeSource.contains("communityPostId != null && communityPostId > 0 ->"))
assertTrue(
routeSource.contains(
"handleCommunityAction(CommunityActionCommand.PostDetail(communityPostId))"
)
)
assertTrue(routeSource.contains("communityCreatorId != null && communityCreatorId > 0 ->"))
assertTrue(
routeSource.contains(
"handleCreatorAction(CreatorActionCommand.Profile(communityCreatorId))"
)
)
assertBefore(
routeSource,
"communityPostId != null && communityPostId > 0 ->",
"communityCreatorId != null && communityCreatorId > 0 ->"
)
}
@Test
fun `MainV2Activity는 deep-link 도메인 상세를 기존 Action으로 실행한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val routeSource = source.substringFrom("private fun routeByDeepLinkValue(")
val communityRoute = routeSource.substringAfter("\"community\" ->").substringBefore("\"message\" ->")
val messageRoute = routeSource.substringAfter("\"message\" ->").substringBefore("\"audition\" ->")
assertTrue(routeSource.contains("handleContentAction(ContentActionCommand.SeriesDetail(deepLinkValueId))"))
assertTrue(routeSource.contains("handleContentAction(ContentActionCommand.AudioDetail(deepLinkValueId))"))
assertTrue(routeSource.contains("handleCreatorAction(CreatorActionCommand.Profile(deepLinkValueId))"))
assertTrue(routeSource.contains("liveActionCoordinator.enterLiveRoom(deepLinkValueId)"))
assertTrue(communityRoute.contains("handleCreatorAction(CreatorActionCommand.Profile(deepLinkValueId))"))
assertFalse(communityRoute.contains("CommunityActionCommand.PostDetail"))
assertTrue(messageRoute.contains("MessageActivity::class.java"))
assertFalse(messageRoute.contains("ChatActionCommand.DmRoom"))
}
@Test
fun `MainV2Activity는 audio detail notification을 Content Action으로 실행한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val routeSource = source.substringFrom("private fun handleAudioNotificationRoute(intent: Intent): Boolean")
assertTrue(routeSource.contains("handleContentAction(ContentActionCommand.AudioDetail(contentId))"))
assertFalse(routeSource.contains("Intent(applicationContext, AudioContentDetailActivity::class.java)"))
}
@Test @Test
fun `MainV2Activity 대화 탭 이동은 로그인 가드를 통과한 뒤 탭을 전환한다`() { fun `MainV2Activity 대화 탭 이동은 로그인 가드를 통과한 뒤 탭을 전환한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText() val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()

View File

@@ -17,7 +17,7 @@ class MainV2AudioNotificationRouteSourceTest {
assertTrue(source.contains("const val ROUTE_AUDIO_DETAIL")) assertTrue(source.contains("const val ROUTE_AUDIO_DETAIL"))
assertTrue(source.contains("handleAudioNotificationRoute")) assertTrue(source.contains("handleAudioNotificationRoute"))
assertTrue(source.contains("showPlayerFragment()")) assertTrue(source.contains("showPlayerFragment()"))
assertTrue(source.contains("AudioContentDetailActivity::class.java")) assertTrue(source.contains("ContentActionCommand.AudioDetail"))
assertTrue(source.contains("Constants.EXTRA_AUDIO_CONTENT_ID")) assertTrue(source.contains("Constants.EXTRA_AUDIO_CONTENT_ID"))
assertTrue(source.contains("removeExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE)")) assertTrue(source.contains("removeExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE)"))
} }
@@ -41,7 +41,11 @@ class MainV2AudioNotificationRouteSourceTest {
assertTrue(routeSource.contains("val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)")) assertTrue(routeSource.contains("val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)"))
assertTrue(routeSource.contains("intent.removeExtra(Constants.EXTRA_AUDIO_CONTENT_ID)")) assertTrue(routeSource.contains("intent.removeExtra(Constants.EXTRA_AUDIO_CONTENT_ID)"))
assertTrue(routeSource.contains("if (contentId > 0")) assertTrue(routeSource.contains("if (contentId > 0"))
assertBefore(routeSource, "if (contentId > 0", "AudioContentDetailActivity::class.java") assertBefore(
routeSource,
"if (contentId > 0",
"handleContentAction(ContentActionCommand.AudioDetail(contentId))"
)
} }
private fun assertBefore(source: String, expectedBefore: String, expectedAfter: String) { private fun assertBefore(source: String, expectedBefore: String, expectedAfter: String) {

View File

@@ -132,9 +132,11 @@ class ChatMainFragmentLayoutTest {
assertTrue(source.contains("private fun onChatRoomClick(item: ChatRoomListUiItem)")) assertTrue(source.contains("private fun onChatRoomClick(item: ChatRoomListUiItem)"))
assertTrue(source.contains("when (item.chatType)")) assertTrue(source.contains("when (item.chatType)"))
assertTrue(source.contains("ChatRoomType.AI ->")) assertTrue(source.contains("ChatRoomType.AI ->"))
assertTrue(source.contains("ChatRoomActivity.newIntent(requireContext(), item.roomId)")) assertTrue(source.contains("handleChatAction(ChatActionCommand.AiRoom(item.roomId))"))
assertTrue(source.contains("ChatRoomType.DM ->")) assertTrue(source.contains("ChatRoomType.DM ->"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)")) assertTrue(source.contains("handleChatAction(ChatActionCommand.DmRoom(item.roomId))"))
assertFalse(source.contains("ChatRoomActivity.newIntent(requireContext(), item.roomId)"))
assertFalse(source.contains("DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)"))
} }
@Test @Test

View File

@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerRoute 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.ContentBannerUiModel
import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerIntent import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerIntent
@@ -467,7 +466,7 @@ class ContentMainFragmentSourceTest {
val context = RuntimeEnvironment.getApplication() as Context val context = RuntimeEnvironment.getApplication() as Context
val eventItem = EventItem(id = 1L, thumbnailImageUrl = "https://example.com/event.png") val eventItem = EventItem(id = 1L, thumbnailImageUrl = "https://example.com/event.png")
val eventIntent = requireNotNull(ContentBannerRoute.Event(eventItem).toContentBannerIntent(context)) val eventIntent = requireNotNull(ContentBannerRoute.Event(eventItem).toContentBannerIntent(context))
val creatorIntent = requireNotNull(ContentBannerRoute.Creator(2L).toContentBannerIntent(context)) val creatorIntent = ContentBannerRoute.Creator(2L).toContentBannerIntent(context)
val seriesIntent = ContentBannerRoute.Series(3L).toContentBannerIntent(context) val seriesIntent = ContentBannerRoute.Series(3L).toContentBannerIntent(context)
val webIntent = requireNotNull( val webIntent = requireNotNull(
ContentBannerRoute.Link("https://example.com", isWebUrl = true).toContentBannerIntent(context) ContentBannerRoute.Link("https://example.com", isWebUrl = true).toContentBannerIntent(context)
@@ -481,8 +480,7 @@ class ContentMainFragmentSourceTest {
assertEquals(EventDetailActivity::class.java.name, eventIntent.component?.className) assertEquals(EventDetailActivity::class.java.name, eventIntent.component?.className)
assertEquals(eventItem, eventIntent.getParcelableExtra(Constants.EXTRA_EVENT)) assertEquals(eventItem, eventIntent.getParcelableExtra(Constants.EXTRA_EVENT))
assertEquals(CreatorChannelActivity::class.java.name, creatorIntent.component?.className) assertNull(creatorIntent)
assertEquals(2L, creatorIntent.getLongExtra(CreatorChannelActivity.EXTRA_CREATOR_ID, 0L))
assertNull(seriesIntent) assertNull(seriesIntent)
assertEquals(android.content.Intent.ACTION_VIEW, webIntent.action) assertEquals(android.content.Intent.ACTION_VIEW, webIntent.action)
assertEquals("https://example.com", webIntent.data.toString()) assertEquals("https://example.com", webIntent.data.toString())

View File

@@ -27,7 +27,6 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem 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.HomeAiCharacterItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem
@@ -56,7 +55,6 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyAct
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerIntent 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.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.toHomeRecommendationAiCharacterRoute
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorRoute 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.visibleHomePopularCommunityPosts
@@ -872,9 +870,7 @@ class HomeMainFragmentLayoutTest {
val eventIntent = requireNotNull( val eventIntent = requireNotNull(
HomeRecommendationBannerRoute.Event(eventItem).toHomeRecommendationBannerIntent(context) HomeRecommendationBannerRoute.Event(eventItem).toHomeRecommendationBannerIntent(context)
) )
val creatorIntent = requireNotNull( val creatorIntent = HomeRecommendationBannerRoute.Creator(2L).toHomeRecommendationBannerIntent(context)
HomeRecommendationBannerRoute.Creator(2L).toHomeRecommendationBannerIntent(context)
)
val seriesIntent = HomeRecommendationBannerRoute.Series(3L).toHomeRecommendationBannerIntent(context) val seriesIntent = HomeRecommendationBannerRoute.Series(3L).toHomeRecommendationBannerIntent(context)
val webIntent = requireNotNull( val webIntent = requireNotNull(
HomeRecommendationBannerRoute.Link( HomeRecommendationBannerRoute.Link(
@@ -891,8 +887,7 @@ class HomeMainFragmentLayoutTest {
assertEquals(EventDetailActivity::class.java.name, eventIntent.component?.className) assertEquals(EventDetailActivity::class.java.name, eventIntent.component?.className)
assertEquals(eventItem, eventIntent.getParcelableExtra(Constants.EXTRA_EVENT)) assertEquals(eventItem, eventIntent.getParcelableExtra(Constants.EXTRA_EVENT))
assertEquals(CreatorChannelActivity::class.java.name, creatorIntent.component?.className) assertNull(creatorIntent)
assertEquals(2L, creatorIntent.getLongExtra(CreatorChannelActivity.EXTRA_CREATOR_ID, 0L))
assertNull(seriesIntent) assertNull(seriesIntent)
assertEquals(android.content.Intent.ACTION_VIEW, webIntent.action) assertEquals(android.content.Intent.ACTION_VIEW, webIntent.action)
assertEquals("https://example.com", webIntent.data.toString()) assertEquals("https://example.com", webIntent.data.toString())
@@ -992,15 +987,6 @@ class HomeMainFragmentLayoutTest {
assertEquals(null, aiCharacter(creatorId = -1L).toHomeRecommendationAiCharacterRoute()) assertEquals(null, aiCharacter(creatorId = -1L).toHomeRecommendationAiCharacterRoute())
} }
@Test
fun `home ai character route creates creator channel intent`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val intent = HomeRecommendationAiCharacterRoute.Creator(22L).toHomeRecommendationAiCharacterIntent(context)
assertEquals(CreatorChannelActivity::class.java.name, intent.component?.className)
assertEquals(22L, intent.getLongExtra(CreatorChannelActivity.EXTRA_CREATOR_ID, 0L))
}
@Test @Test
fun `home popular community adapter applies blur when locked paid post image is loaded`() { fun `home popular community adapter applies blur when locked paid post image is loaded`() {
val source = projectFile( val source = projectFile(

View File

@@ -15,15 +15,11 @@ class HomeMainFragmentLoginGuardSourceTest {
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.access.ensureV2Access")) assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.access.ensureV2Access"))
assertGuardedStartActivity(source, "private fun openHomeOnAirLive()") assertGuardedStartActivity(source, "private fun openHomeOnAirLive()")
assertGuardedStartActivity(source, "private fun openFollowingChat(item: ChatRoomListUiItem)")
assertGuardedStartActivity(source, "private fun onBannerClick(item: HomeRecommendationBannerUiModel)") assertGuardedStartActivity(source, "private fun onBannerClick(item: HomeRecommendationBannerUiModel)")
assertGuardedStartActivity( assertTrue(source.contains("handleChatAction(ChatActionCommand.AiRoom"))
source, assertTrue(source.contains("handleChatAction(ChatActionCommand.DmRoom"))
"private fun onRecentActivityClick(item: HomeRecommendationRecentlyActiveCreatorUiModel)" assertTrue(source.contains("handleCommunityAction(CommunityActionCommand.PostDetail"))
) assertTrue(source.contains("handleCreatorAction(CreatorActionCommand.Profile"))
assertGuardedStartActivity(source, "private fun onAiCharacterClick(item: HomeRecommendationAiCharacterUiModel)")
assertGuardedStartActivity(source, "private fun openCreatorProfile(creatorId: Long)")
assertGuardedStartActivity(source, "private fun openPopularCommunityPost(item: FeedItem.Community)")
} }
@Test @Test
@@ -33,9 +29,9 @@ class HomeMainFragmentLoginGuardSourceTest {
).readText() ).readText()
assertBeforeGuard(source, "val route = item.toHomeRecommendationBannerRoute() ?: return") assertBeforeGuard(source, "val route = item.toHomeRecommendationBannerRoute() ?: return")
assertBeforeGuard(source, "val route = item.toHomeRecommendationRecentlyActiveCreatorRoute() ?: return") assertBefore(source, "val route = item.toHomeRecommendationRecentlyActiveCreatorRoute() ?: return", "handle")
assertBeforeGuard(source, "val route = item.toHomeRecommendationAiCharacterRoute() ?: return") assertBefore(source, "val route = item.toHomeRecommendationAiCharacterRoute() ?: return", "handle")
assertBeforeGuard(source, "val postId = item.postId.toLongOrNull() ?: return") assertBefore(source, "val postId = item.postId.toLongOrNull() ?: return", "handleCommunityAction")
assertFalse(source.contains("requiresAdultContentAccess = true")) assertFalse(source.contains("requiresAdultContentAccess = true"))
} }
@@ -46,7 +42,8 @@ class HomeMainFragmentLoginGuardSourceTest {
).readText() ).readText()
val clickSource = source.substringFrom("private fun openPopularCommunityPost(item: FeedItem.Community)") val clickSource = source.substringFrom("private fun openPopularCommunityPost(item: FeedItem.Community)")
assertTrue(clickSource.contains("CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)")) assertTrue(clickSource.contains("handleCommunityAction(CommunityActionCommand.PostDetail(postId))"))
assertFalse(clickSource.contains("CreatorChannelCommunityDetailActivity.newIntent"))
assertFalse(clickSource.contains("CreatorCommunityAllActivity")) assertFalse(clickSource.contains("CreatorCommunityAllActivity"))
assertFalse(clickSource.contains("EXTRA_COMMUNITY_CREATOR_ID")) assertFalse(clickSource.contains("EXTRA_COMMUNITY_CREATOR_ID"))
assertFalse(clickSource.contains("EXTRA_COMMUNITY_POST_ID")) assertFalse(clickSource.contains("EXTRA_COMMUNITY_POST_ID"))
@@ -65,14 +62,15 @@ class HomeMainFragmentLoginGuardSourceTest {
assertBefore( assertBefore(
clickSource, clickSource,
"val community = item as? HomeFollowingNewsUiItem.Community ?: return", "val community = item as? HomeFollowingNewsUiItem.Community ?: return",
"ensureV2Access" "handleCommunityAction"
) )
assertBefore( assertBefore(
clickSource, clickSource,
"if (postId <= 0L) return", "if (postId <= 0L) return",
"ensureV2Access" "handleCommunityAction"
) )
assertTrue(clickSource.contains("CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)")) assertTrue(clickSource.contains("handleCommunityAction(CommunityActionCommand.PostDetail(postId))"))
assertFalse(clickSource.contains("CreatorChannelCommunityDetailActivity.newIntent"))
assertFalse(clickSource.contains("CreatorCommunityAllActivity")) assertFalse(clickSource.contains("CreatorCommunityAllActivity"))
assertFalse(clickSource.contains("EXTRA_COMMUNITY_POST_ID")) assertFalse(clickSource.contains("EXTRA_COMMUNITY_POST_ID"))
} }
@@ -177,6 +175,14 @@ class HomeMainFragmentLoginGuardSourceTest {
) )
} }
private fun assertGuardedAction(source: String, functionSignature: String, actionCall: String) {
val functionSource = source.substringFrom(functionSignature)
assertTrue(
"$functionSignature must call ensureV2Access before $actionCall.",
functionSource.indexOf("ensureV2Access") in 0 until functionSource.indexOf(actionCall)
)
}
private fun assertBeforeGuard(source: String, expectedReturn: String) { private fun assertBeforeGuard(source: String, expectedReturn: String) {
val returnIndex = source.indexOf(expectedReturn) val returnIndex = source.indexOf(expectedReturn)
val guardIndex = source.indexOf("ensureV2Access", returnIndex) val guardIndex = source.indexOf("ensureV2Access", returnIndex)

View File

@@ -1,16 +1,26 @@
# V2 공통 접근 가드와 도메인 액션 Plan/Task # V2 공통 접근 가드와 도메인 액션 Plan/Task
## Assumptions ## Assumptions
- 작업 범위는 `kr.co.vividnext.sodalive.v2` 하위 신규/기존 코드 이를 조립하는 `AppDI.kt`로 제한한다. - 작업 범위는 `kr.co.vividnext.sodalive.v2` 하위 신규/기존 코드, 대응 테스트, 사용자 승인을 받은 레거시 `DeepLinkActivity`, 필요한 경우 이를 조립하는 `AppDI.kt`, 본 작업 문서로 제한한다.
- 화면별 최초 집계 API와 Response는 feature가 계속 소유할 수 있다. - 화면별 최초 집계 API와 Response는 feature가 계속 소유할 수 있다.
- 공통화 대상은 최소 두 호출부에서 반복되거나 모든 화면에서 동일해야 하는 정책과 사용자 행동이다. - 공통화 대상은 최소 두 호출부에서 반복되거나 모든 화면에서 동일해야 하는 정책과 사용자 행동이다.
- 기존 UX, API, Intent extra, Activity result, 화면 새로고침 동작을 보존한다. - 기존 UX, API, Intent extra, Activity result, 화면 새로고침 동작을 보존한다.
- 직접적인 하위 Activity 결과는 `ActivityResult -> 명시적 도메인 결과 -> feature 단일 handler`를 기본 전달 방식으로 사용한다. - 직접적인 하위 Activity 결과는 `ActivityResult -> 명시적 도메인 결과 -> feature 단일 handler`를 기본 전달 방식으로 사용한다.
- 현재 화면 안에서 Action/Repository가 직접 반환하는 결과에는 불필요하게 `ActivityResult`를 도입하지 않고 같은 handler를 호출한다. - 현재 화면 안에서 Action/Repository가 직접 반환하는 결과에는 불필요하게 `ActivityResult`를 도입하지 않고 같은 handler를 호출한다.
- `SharedFlow`/observer는 Activity 밖의 독립 소비자가 다수 존재하거나 백그라운드 변경 전파 요구가 확인될 때만 사용한다. - `SharedFlow`/observer는 Activity 밖의 독립 소비자가 다수 존재하거나 백그라운드 변경 전파 요구가 확인될 때만 사용한다.
- 레거시 기능은 수정하지 않고 `v2` adapter/wrapper에서 호출한다. - 레거시 기능은 수정하지 않고 `v2` adapter/wrapper에서 호출한다. 단, V2 푸시/딥링크 진입점으로 계속 사용할 `DeepLinkActivity`의 Action 연결 변경은 사용자가 승인했다.
- 구현은 Access부터 도메인별 Phase로 진행하며 각 Phase를 독립적으로 검증한다. - 구현은 Access부터 도메인별 Phase로 진행하며 각 Phase를 독립적으로 검증한다.
- 모든 도메인에 동일한 형식의 클래스를 강제하지 않고 실제 중복과 정책이 확인된 최소 단위만 추출한다. - 모든 도메인에 동일한 형식의 클래스를 강제하지 않고 실제 중복과 정책이 확인된 최소 단위만 추출한다.
- `AppDI.kt`는 실제 외부 의존성, 공유 생명주기 또는 구현 교체가 필요한 계약만 조립한다. 상태 없는 Action/Handler는 호출 경계에서 직접 조합할 수 있으며 DI 등록 자체를 완료 조건으로 삼지 않는다.
### 최종 소유 경계
- Access: 접근 허용 판단과 기존 로그인/본인인증/성인 설정 UX 실행을 소유한다.
- 도메인 Action: 안정적인 ID/command 검사, 필요한 `AccessRequirement` 선택, Access 실행 요청과 `Ignored`/`Blocked`/도메인 결과 반환을 소유한다.
- Action Handler: Access 실행기 주입과 레거시 `Intent`/Activity/Dialog/navigation adapter를 소유한다.
- feature: UI model -> command 변환, 화면별 최초 query/API, 단일 화면 생성·mutation API, `ActivityResult` 수신과 refresh/callback/projection 조합을 소유한다.
- 보호된 도메인 Action의 호출부는 같은 Access guard를 바깥에서 중복 실행하지 않는다. 공통 Access 직접 호출은 화면 진입 자체 또는 화면 전용 동작에 사용한다.
- 도메인 관련 코드를 모두 이관하지도, 페이지 이동만 이관하지도 않는다. 여러 호출부에서 같아야 하는 유효성·접근 정책·결과 계약은 Action으로, Android 화면 이동은 Handler로 이관하고 화면별 query/API와 후처리는 feature에 유지한다.
- parent-child Fragment composition, same-feature child flow, 도메인 Action이 없는 system-only route, Home on-air 목록 화면 진입, 단일 owner 기능은 합의된 반복 정책이 없으면 feature에 유지한다.
## Success Criteria ## Success Criteria
- `v2` 로그인/성인 접근 판단의 단일 소유자가 존재한다. - `v2` 로그인/성인 접근 판단의 단일 소유자가 존재한다.
@@ -20,7 +30,9 @@
- 화면별 최초 query와 UI composition은 유지된다. - 화면별 최초 query와 UI composition은 유지된다.
- Community mutation은 명시적 변경 결과를 반환하고 Creator Channel의 단일 composition handler가 Home/Community projection 갱신을 결정한다. - Community mutation은 명시적 변경 결과를 반환하고 Creator Channel의 단일 composition handler가 Home/Community projection 갱신을 결정한다.
- Activity result를 사용하는 도메인 흐름은 raw result를 화면마다 해석하지 않고 명시적 결과와 단일 handler를 사용한다. - Activity result를 사용하는 도메인 흐름은 raw result를 화면마다 해석하지 않고 명시적 결과와 단일 handler를 사용한다.
- feature 간 직접 Activity/Coordinator 의존은 공통 Action 또는 navigation 계약으로 대체된다. - feature 간 직접 Activity/Coordinator 의존 중 합의된 반복 navigation/정책 대상은 공통 Action 또는 Handler 계약으로 대체되고, 최종 소유 경계의 예외는 근거와 함께 유지된다.
- 푸시/딥링크의 Content, Creator, Community, Chat 이동은 기존 Action을 사용하고 Live 이동은 `LiveActionCoordinator`를 사용한다.
- Live room ID는 channel ID보다 우선하며, Community post ID는 creator ID보다 우선한다.
- 각 Phase의 테스트, 컴파일, ktlint, whitespace 검증 결과가 문서에 누적된다. - 각 Phase의 테스트, 컴파일, ktlint, whitespace 검증 결과가 문서에 누적된다.
### Phase 0: 기준선과 Action 카탈로그 확정 ### Phase 0: 기준선과 Action 카탈로그 확정
@@ -750,29 +762,47 @@
- 2026-07-15: 사전 준비 3개와 시나리오 22개는 `PASS`, 시나리오 8개는 `SKIPPED`, `FAILED`는 0개로 판정되어 Task 3.8을 완료 처리했다. - 2026-07-15: 사전 준비 3개와 시나리오 22개는 `PASS`, 시나리오 8개는 `SKIPPED`, `FAILED`는 0개로 판정되어 Task 3.8을 완료 처리했다.
### Phase 4: Creator와 Community 공통 Action ### Phase 4: Creator와 Community 공통 Action
- [ ] **Task 4.1: Creator Channel 진입 호출부와 계약 확정** - [x] **Task 4.1: Creator Channel 진입 호출부와 계약 확정**
- 확인 예정 파일: - 확인 예정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/` - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/` - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/`
- 검증 기준: `creatorId`, 로그인 요구 여부, route source, 기존 extra 차이가 정리된다. - 검증 기준: `creatorId`, 로그인 요구 여부, route source, 기존 extra 차이가 정리된다.
- 확인 결과:
- Home 추천/랭킹/AI 캐릭터와 Content banner의 Creator Channel 이동은 `creatorId`만 있으면 충분하며 기존 `CreatorChannelActivity.EXTRA_CREATOR_ID` extra는 action handler가 유지한다.
- `MainV2Activity`의 deeplink/system route는 Phase 1에서 별도 순서와 반환 계약을 고정한 경로이므로 Phase 4 사용자 액션 전환 범위에서 제외한다.
- 모델 helper는 feature UI model에서 route ID만 반환하고 Creator Channel Activity Intent 생성은 Creator Action handler로 이동한다.
- 검증 기록:
- 2026-07-15: `HomeMainFragment`, `HomeRecommendationUiModels`, `ContentMainFragment`, `AudioRecommendationsUiModels`, `MainV2Activity` 직접 확인과 `rg` 검색으로 Creator Channel 진입 호출부를 확정했다.
- [ ] **Task 4.2: Creator Action 구현과 호출부 전환** - [x] **Task 4.2: Creator Action 구현과 호출부 전환**
- 생성 예정 파일: - 생성 예정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/action/CreatorActions.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/action/CreatorAction.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/action/CreatorActionsTest.kt` - `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/action/CreatorActionTest.kt`
- 검증 기준: 유효 ID와 Access를 한 곳에서 처리하고 feature UI model을 입력받지 않다. - 검증 기준: 유효 ID와 `AccessRequirement.Login`을 Action에서 처리하고 feature UI model을 입력받지 않으며, Handler가 Access 실행기와 Creator Channel navigation을 조합한다.
- RED 검증 기록:
- 2026-07-15: `CreatorActionTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.action.*" --tests "kr.co.vividnext.sodalive.v2.community.action.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*"`를 실행해 `CreatorAction`, `CreatorActionCommand`, `CreatorActionResult`, `CreatorActionHandler` 미정의 컴파일 오류로 RED를 확인했다.
- 2026-07-15: source 전환 assertion을 추가한 뒤 같은 명령에서 `CreatorActionTest > 대상 화면은 Creator Channel Activity 대신 Creator Action을 사용한다` 실패로 기존 직접 Intent 잔존 RED를 확인했다.
- GREEN 검증 기록:
- 2026-07-15: `CreatorActionCommand.Profile`, `CreatorAction`, `CreatorActionHandler`, Activity/Fragment `handleCreatorAction`을 추가하고 Home/Content 호출부를 전환했다. Handler만 기존 `CreatorChannelActivity.newIntent`와 `EXTRA_CREATOR_ID` extra를 소유한다.
- 2026-07-15: Creator/Community/Chat action 테스트 묶음과 Home/Content/Creator source 관련 테스트 묶음이 각각 `BUILD SUCCESSFUL`로 통과했다.
- [ ] **Task 4.3: Community 게시글 진입 계약 구현과 호출부 전환** - [x] **Task 4.3: Community 게시글 진입 계약 구현과 호출부 전환**
- 생성 예정 파일: - 생성 예정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/community/action/CommunityActions.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/community/action/CommunityAction.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/community/action/CommunityActionsTest.kt` - `app/src/test/java/kr/co/vividnext/sodalive/v2/community/action/CommunityActionTest.kt`
- 수정 예정 파일: - 수정 예정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.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/CreatorChannelActivity.kt`
- 검증 기준: 로그인, ID 검사, Activity result 차이를 보존하고 공통 게시글 진입점을 사용한다. - 검증 기준: 로그인, ID 검사, Activity result 차이를 보존하고 공통 게시글 진입점을 사용한다.
- RED 검증 기록:
- 2026-07-15: `CommunityActionTest`를 먼저 추가하고 action 테스트 묶음을 실행해 `CommunityAction`, `CommunityActionCommand`, `CommunityActionResult`, `CommunityActionHandler` 미정의 컴파일 오류로 RED를 확인했다.
- 2026-07-15: source 전환 assertion 추가 후 `CommunityActionTest > 대상 화면은 Community Detail Activity 대신 Community Action을 사용한다` 실패로 Home/Creator 직접 상세 Intent 잔존 RED를 확인했다.
- GREEN 검증 기록:
- 2026-07-15: `CommunityActionCommand.PostDetail`, `CommunityAction`, `CommunityActionHandler`를 추가하고 Home의 following news/recent activity/popular community 및 Creator Channel community post click을 `handleCommunityAction`으로 전환했다.
- 2026-07-15: Creator Channel의 `ActivityResultLauncher` 계약 보존을 위해 `launchIntent` 주입 경계를 두었고, action/result에는 Fragment refresh callback을 넣지 않았다.
- [ ] **Task 4.4: Community 변경 결과와 레거시 ActivityResult adapter 구현** - [x] **Task 4.4: Community 변경 결과와 레거시 ActivityResult adapter 구현**
- 생성 예정 파일: - 생성 예정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/community/action/CommunityChange.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/community/action/CommunityChange.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/community/action/CommunityActivityResultMapper.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/community/action/CommunityActivityResultMapper.kt`
@@ -781,9 +811,13 @@
- 작성, 수정, 삭제, 고정 변경을 `Created`, `Updated`, `Deleted`, `PinChanged`로 표현한다. - 작성, 수정, 삭제, 고정 변경을 `Created`, `Updated`, `Deleted`, `PinChanged`로 표현한다.
- 레거시 작성/수정 Activity의 `RESULT_OK`를 명시적인 Community 변경 결과로 변환한다. - 레거시 작성/수정 Activity의 `RESULT_OK`를 명시적인 Community 변경 결과로 변환한다.
- 결과 계약에는 Home/Community 탭 refresh callback 또는 Fragment 참조를 포함하지 않는다. - 결과 계약에는 Home/Community 탭 refresh callback 또는 Fragment 참조를 포함하지 않는다.
- 검증 기준: result code, 유효/누락 post ID, 변경 종류가 화면 의존 없이 테스트된다. - 검증 기준: legacy Activity result는 result code와 source로 변경 종류를 판정하고 `CommunityChange`의 post ID payload를 단위 테스트한다. delete/pin mutation 성공 경로의 실제 post ID wiring은 후속 Task 6.4에서 별도 회귀 검증한다.
- RED 검증 기록:
- 2026-07-15: `CommunityChangeTest`를 먼저 추가하고 action 테스트 묶음을 실행해 `CommunityChange`, `CommunityActivityResultSource`, `resolveCommunityActivityResult` 미정의 컴파일 오류로 RED를 확인했다.
- GREEN 검증 기록:
- 2026-07-15: `CommunityChange.Ignored/Created/Updated/Deleted/PinChanged`와 `resolveCommunityActivityResult`를 추가했다. Write `RESULT_OK`는 `Created`, Modify/Detail `RESULT_OK`는 `Updated`, 실패/취소는 `Ignored`로 변환한다.
- [ ] **Task 4.5: Creator Channel Community 변경 composition handler 통합** - [x] **Task 4.5: Creator Channel Community 변경 composition handler 통합**
- 수정 예정 파일: - 수정 예정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- 작업: - 작업:
@@ -794,8 +828,12 @@
- Community Action/결과가 Creator Channel Home 또는 Community Fragment를 알지 않는다. - Community Action/결과가 Creator Channel Home 또는 Community Fragment를 알지 않는다.
- Activity 내부의 개별 성공 경로에 `refreshHome()`과 `refreshCreatorChannelCommunity()` 조합이 반복되지 않는다. - Activity 내부의 개별 성공 경로에 `refreshHome()`과 `refreshCreatorChannelCommunity()` 조합이 반복되지 않는다.
- 현재 Activity 내부 전파를 위해 전역 EventBus/observer singleton을 추가하지 않는다. - 현재 Activity 내부 전파를 위해 전역 EventBus/observer singleton을 추가하지 않는다.
- 검증 기록:
- 2026-07-15: `CreatorChannelActivity`의 community write/modify/detail launcher가 raw result를 `CommunityChange`로 변환한 뒤 `handleCommunityChange`로 전달하도록 통합했다.
- 2026-07-15: pin/delete 성공 경로도 각각 `CommunityChange.PinChanged`, `CommunityChange.Deleted`를 거쳐 단일 handler에서 Community projection refresh를 결정하게 했다. `Created`만 Home과 Community를 함께 갱신하고 `Updated/Deleted/PinChanged`는 Community만 갱신한다.
- 2026-07-15: Activity 밖 소비자가 없고 Creator Channel Activity 내부 projection 조합으로 충분해 `SharedFlow`, EventBus, singleton observer는 추가하지 않았다.
- [ ] **Task 4.6: Creator/Community Phase 회귀 검증** - [x] **Task 4.6: Creator/Community Phase 회귀 검증**
- 실행 명령: - 실행 명령:
```bash ```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.action.*" ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.action.*"
@@ -804,9 +842,15 @@
./gradlew :app:ktlintCheck ./gradlew :app:ktlintCheck
git diff --check git diff --check
``` ```
- 검증 기록:
- 2026-07-15: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.action.*" --tests "kr.co.vividnext.sodalive.v2.community.action.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*"`가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-15: Home/Content/Chat/Creator source 회귀 묶음 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"`가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-15: `rg`로 대상 호출부를 검색해 `CreatorChannelActivity.newIntent`, `CreatorChannelCommunityDetailActivity.newIntent`는 action handler 및 Phase 1에서 제외한 MainV2Activity system/deeplink route에만 남음을 확인했다.
- 2026-07-15: `./gradlew :app:compileDebugKotlin`은 `BUILD SUCCESSFUL`로 통과했고, `./gradlew tasks --all`도 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-15: `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`는 `BUILD SUCCESSFUL`로 통과했으며 test source 보고서는 0건이다. `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했지만 갱신된 main/test ktlint 리포트에서 Phase 4 변경 파일명 검색 결과는 0건이다.
### Phase 5: Chat 공통 Action ### Phase 5: Chat 공통 Action
- [ ] **Task 5.1: Chat/DM 진입 유형과 결과 계약 확정** - [x] **Task 5.1: Chat/DM 진입 유형과 결과 계약 확정**
- 확인 예정 파일: - 확인 예정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/` - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
@@ -815,22 +859,39 @@
- 검증 기준: - 검증 기준:
- 서로 다른 동작을 하나의 nullable/boolean 다목적 함수로 합치지 않는다. - 서로 다른 동작을 하나의 nullable/boolean 다목적 함수로 합치지 않는다.
- 호출 화면 후처리가 필요한 Activity result는 명시적 Chat 결과와 feature 단일 handler로 연결한다. - 호출 화면 후처리가 필요한 Activity result는 명시적 Chat 결과와 feature 단일 handler로 연결한다.
- 확인 결과:
- Chat list와 Home following chat은 기존 room ID로 AI legacy `ChatRoomActivity` 또는 V2 `DmChatRoomActivity`에 진입한다.
- Creator Channel AI chat은 `AccessRequirement.AdultContent` 통과 후 기존 `CreatorChannelHomeViewModel.createChatRoom(characterId)` API 소유를 유지하고, 성공 event의 room ID만 Chat Action으로 전달한다.
- Creator Channel DM은 owner의 `MainV2Activity.newChatDmIntent`와 non-owner의 creator ID 기반 `DmChatRoomActivity` 진입을 분리한다.
- 현재 Chat/DM 하위 Activity는 result를 반환하지 않으므로 ActivityResult adapter는 추가하지 않는다.
- 검증 기록:
- 2026-07-15: ChatMain/Home/Creator/MainV2/DM Activity와 관련 tests를 확인해 `AiRoom`, `DmRoom`, `DmCreator`, `OwnerDmList` command 계약을 확정했다. MainV2Activity deeplink/system route는 기존 계약대로 제외했다.
- [ ] **Task 5.2: Chat Action 계약과 단위 테스트 작성** - [x] **Task 5.2: Chat Action 계약과 단위 테스트 작성**
- 생성 예정 파일: - 생성 예정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/chat/action/ChatActions.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/chat/action/ChatAction.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/chat/action/ChatActionCommand.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/chat/action/ChatActionCommand.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/chat/action/ChatActionsTest.kt` - `app/src/test/java/kr/co/vividnext/sodalive/v2/chat/action/ChatActionTest.kt`
- 검증 기준: room/creator 식별자와 진입 유형이 명시적이고 Home/Creator UI model에 의존하지 않다. - 검증 기준: room/creator 식별자와 진입 유형이 명시적이고 Home/Creator UI model에 의존하지 않으며, 유효 ID 확인 뒤 `AccessRequirement.Login`을 Action이 소유한다.
- RED 검증 기록:
- 2026-07-15: `ChatActionTest`를 먼저 추가하고 action 테스트 묶음을 실행해 `ChatAction`, `ChatActionCommand`, `ChatActionResult`, `ChatActionHandler` 미정의 컴파일 오류로 RED를 확인했다.
- 2026-07-15: source 전환 assertion 추가 후 `ChatActionTest > 대상 화면은 채팅방 Activity 대신 Chat Action을 사용한다` 실패로 직접 chat Activity helper 잔존 RED를 확인했다.
- GREEN 검증 기록:
- 2026-07-15: `ChatActionCommand.AiRoom/DmRoom/DmCreator/OwnerDmList`, `ChatAction`, `ChatActionHandler`, Activity/Fragment `handleChatAction`을 추가했다. Handler만 기존 `ChatRoomActivity`, `DmChatRoomActivity`, `MainV2Activity.newChatDmIntent`를 소유한다.
- [ ] **Task 5.3: Home/Chat/Creator Channel 호출부 전환** - [x] **Task 5.3: Home/Chat/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/HomeMainFragment.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt` - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.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/CreatorChannelActivity.kt`
- 검증 기준: 기존 login guard, room 생성 API, 성공 후 이동, owner/non-owner 분기 유지다. - 검증 기준: Chat Action이 로그인 정책을 소유해 호출 화면의 중복 login guard는 제거하고, Creator Channel의 `AdultContent` 사전 조건과 room 생성 API, 생성 성공 후 이동, owner/non-owner 분기 유지다.
- 검증 기록:
- 2026-07-15: `HomeMainFragment.openFollowingChat`은 기존 로그인 guard를 유지한 채 `ChatActionCommand.AiRoom/DmRoom`으로 전환했다.
- 2026-07-15: `ChatMainFragment.onChatRoomClick`은 기존 AI/DM 분기를 유지하되 navigation을 `handleChatAction`으로 위임했다.
- 2026-07-15: `CreatorChannelActivity`의 AI chat 생성 API는 `homeActionDelegate?.createChatRoom(characterId)`에 그대로 남기고, 생성 완료 room ID와 owner/non-owner DM navigation만 `ChatAction`으로 전환했다.
- 2026-07-16: 첫 번째 기록의 화면-local login guard 유지는 후속 P1에서 대체되었다. 최종 계약은 `ChatAction`이 `AccessRequirement.Login`을 소유하고 `HomeMainFragment.openFollowingChat`은 guard를 중복 실행하지 않는 것이다.
- [ ] **Task 5.4: Chat Phase 회귀 검증** - [x] **Task 5.4: Chat Phase 회귀 검증**
- 실행 명령: - 실행 명령:
```bash ```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.chat.action.*" ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.chat.action.*"
@@ -839,34 +900,141 @@
./gradlew :app:ktlintCheck ./gradlew :app:ktlintCheck
git diff --check git diff --check
``` ```
- 검증 기록:
- 2026-07-15: Chat Action 테스트와 Home/Chat/Creator source 회귀 묶음이 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-15: `rg` 검색 결과 대상 화면의 직접 `ChatRoomActivity.newIntent`, `DmChatRoomActivity.newIntentByRoomId`, `DmChatRoomActivity.newIntentByCreatorId` 호출은 제거됐고, handler 및 Phase 1에서 제외한 `MainV2Activity` deeplink route만 남았다.
- 2026-07-15: `./gradlew :app:compileDebugKotlin`은 `BUILD SUCCESSFUL`로 통과했고, `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했지만 갱신된 ktlint 리포트에서 Phase 5 변경 파일명 검색 결과는 0건이다.
- 2026-07-15: `git diff --check HEAD`는 whitespace 오류 없이 통과했다.
### Phase 6: 의존 방향과 이전 구현 정리 ### Phase 6: 최종 소유 경계와 문서·코드 동기화
- [ ] **Task 6.1: feature 간 직접 navigation/Coordinator 의존 재검사** - [x] **Task 6.1: 최종 소유 경계를 PRD와 계획 문서에 고정**
- 수정 파일:
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/prd.md`
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md`
- 작업:
- Access는 접근 판단과 로그인/본인인증/성인 설정 UX 실행을 소유한다.
- 도메인 Action은 안정적인 입력 검사, 필요한 `AccessRequirement` 선택, Access 실행 요청과 명시적 결과를 소유한다.
- Action Handler는 Access 실행기 주입과 레거시 navigation adapter를 소유한다.
- feature는 UI model -> command 변환, 화면별 query/API와 생성·mutation API, `ActivityResult` 및 refresh/callback/projection 조합을 소유한다.
- 도메인 관련 코드 전체가 아니라 여러 호출부에서 같아야 하는 유효성·접근 정책·결과 계약만 Action으로 이관하고, 실제 Android 이동은 Handler에 둔다.
- 보호된 도메인 Action 호출부의 중복 Access guard 제거 원칙과 직접 Access 사용 예외를 명시한다.
- `AppDI.kt`는 실제 외부 의존성, 공유 생명주기 또는 구현 교체가 필요한 경우에만 조립 경계로 사용하고 상태 없는 Action/Handler의 DI 등록을 강제하지 않는다.
- 검증 기준: PRD와 계획 문서가 같은 네 계층 소유 경계와 이관/예외 기준을 사용한다.
- 검증 기록:
- 2026-07-16: 합의한 최종 소유 경계를 두 문서에 명시했고, 도메인 전체 이관과 navigation-only 이관을 모두 배제했다. 이번 Task에서는 문서만 수정하고 운영/테스트 코드는 변경하지 않았다.
- [x] **Task 6.2: Phase 1~5의 규범 계약과 실제 파일명 동기화**
- 수정 파일:
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/prd.md`
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md`
- 작업:
- Phase 1 Access와 Phase 2/3 Content·Live 소유 경계는 유지한다.
- Phase 4 Creator Action에 Login/Blocked 계약을 명시하고 Creator/Community Action 파일명을 실제 singular 이름으로 정정한다.
- Phase 5 Chat Action에 Login 소유를 명시하고 화면-local 중복 guard 제거를 규범 계약으로 확정한다.
- Creator Channel AI Chat의 `AdultContent` 사전 조건과 `createChatRoom(characterId)` API는 feature에, 생성 성공 room navigation은 Chat Action에 유지한다.
- 기존 2026-07-15 검증 기록은 삭제하지 않고, 후속 P1에서 대체된 login guard 기록에 최종 계약을 덧붙인다.
- 합의된 반복 정책 대상과 parent-child/same-feature/system/deeplink/notification/목록/단일 owner 예외를 구분한다.
- 검증 기준: Phase 1~5 본문, Review Follow-up, PRD 카탈로그가 서로 모순되지 않고 실제 파일명을 가리킨다.
- 검증 기록:
- 2026-07-16: Creator/Community/Chat Action 파일명, Creator/Chat Login 계약, Creator AI Chat 생성 소유권, 직접 의존 예외를 문서에 동기화했다. 과거 검증 기록은 이력으로 보존했다.
- [x] **Task 6.3: 최종 소유 경계 기준 코드 재점검과 최소 보완**
- 확인 범위:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/access/`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/content/action/`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/live/action/`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/action/`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/community/action/`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/chat/action/`
- 해당 Action 호출 Activity/Fragment와 `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- 작업:
- Action이 안정적인 입력 검사와 접근 요구를 소유하고 Handler가 Access/navigation을 조합하는지 확인한다.
- 보호된 Action 호출부에 같은 Access guard가 중복되거나, Action/Handler에 화면별 query/API/refresh callback이 유입된 곳이 있는지 확인한다.
- `AppDI.kt` 등록은 실제 조립 필요성이 확인된 경우에만 추가하고, 현재 구조가 최종 소유 경계와 일치하면 변경하지 않는다.
- 검증 기준: 발견된 불일치만 최소 수정하고, 합의된 예외나 단일 owner 기능을 형식적인 Action/DI로 이관하지 않는다.
- 검증 기록:
- 2026-07-16: Access, Content, Live, Creator, Community, Chat Action/Handler와 호출 Activity/Fragment, `AppDI.kt`를 재점검했다. Action은 ID/command 검사와 접근 요구 선택을 소유하고 Handler는 Access 실행기와 navigation adapter를 조합하는 구조를 유지한다.
- 2026-07-16: 보호된 Creator/Community/Chat 호출부의 중복 guard와 Action/Handler에 feature query/API/refresh callback이 유입된 위치를 검색했다. `LiveActionCoordinator`는 문서화된 presentation adapter 예외로 `refreshHome` callback을 주입받고, Community/Chat/Creator/Content Action 결과 타입에는 화면별 callback이 없다.
- 2026-07-16: `AppDI.kt`에 상태 없는 Action/Handler 등록을 추가할 필요가 없음을 확인했다. 실제 불일치 보완은 Task 6.4의 Community mutation wiring 회귀 테스트 추가로 제한했다.
- [x] **Task 6.4: Community delete/pin mutation 성공 경로 wiring 회귀 테스트 보강**
- 수정 예정 파일:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt`
- 필요 시 `app/src/test/java/kr/co/vividnext/sodalive/v2/community/action/CommunityChangeTest.kt`
- 작업:
- pin 성공 경로가 실제 `item.postId`로 `handleCommunityChange(CommunityChange.PinChanged(...))`를 호출하는지 검증한다.
- delete 성공 경로가 실제 `item.postId`로 `handleCommunityChange(CommunityChange.Deleted(...))`를 호출하는지 검증한다.
- `Created`는 Home/Community, `Updated`/`Deleted`/`PinChanged`는 Community projection으로 연결되는 composition 계약을 함께 고정한다.
- 검증 기준: `CommunityChange` 객체의 payload 보유만이 아니라 mutation 성공 경로에서 실제 post ID 전달과 단일 handler 연결이 회귀 테스트로 보호된다.
- RED/GREEN 검증 기록:
- 2026-07-16: `CreatorChannelActivitySourceTest`에 pin/delete 성공 경로가 실제 `item.postId`로 `CommunityChange.PinChanged`/`Deleted`를 생성하고 `handleCommunityChange`로만 전달하는지 검증하는 source test 2건과, `handleCommunityChange`의 projection 갱신 소유권 테스트 1건을 추가했다.
- 2026-07-16: 새 테스트는 현재 구현을 회귀 고정하는 characterization 성격이므로 별도 production 변경 없이 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" --rerun-tasks`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-16: Kotlin LSP는 환경에 `kotlin-ls`가 설치되어 있지 않아 실행할 수 없었고, Gradle test/compile로 대체 검증했다.
- [x] **Task 6.5: 소유 경계 동기화 집중 검증**
- 실행 명령:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.action.*" --tests "kr.co.vividnext.sodalive.v2.community.action.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*" --rerun-tasks
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --rerun-tasks
./gradlew :app:compileDebugKotlin
./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks
./gradlew :app:ktlintCheck --rerun-tasks
git diff --check HEAD
```
- 검증 기준: focused 테스트와 컴파일, test source ktlint, whitespace 검증이 통과한다. 전체 main ktlint가 기존 기준선으로 실패하면 Phase 6 변경 파일의 신규 위반이 없음을 리포트에서 확인하고 실패 위치를 기록한다.
- 검증 기록:
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.action.*" --tests "kr.co.vividnext.sodalive.v2.community.action.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*" --rerun-tasks`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --rerun-tasks`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-16: `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`, `git diff --check HEAD`는 통과했다.
- 2026-07-16: `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했다. `app/build/reports/ktlint/ktlintMainSourceSetCheck`, `ktlintTestSourceSetCheck` 리포트에서 Phase 6/7 변경 파일명 검색 결과는 0건이다.
### Phase 7: 최종 의존 방향 및 통합 검증
- 상태: Phase 6의 코드 점검과 집중 검증 완료 후 최종 기준으로 다시 실행한다. 아래 2026-07-15 검증 기록은 번호 변경 전 Phase 6의 기준선 이력으로 보존하며, Task 7.1~7.5는 모두 재검증 대상으로 재개방한다.
- [x] **Task 7.1: feature 간 직접 navigation/Coordinator 의존 재검사**
- 확인 범위: `app/src/main/java/kr/co/vividnext/sodalive/v2/**/*.kt` - 확인 범위: `app/src/main/java/kr/co/vividnext/sodalive/v2/**/*.kt`
- 실행 명령: - 실행 명령:
```bash ```bash
rg -n '^import kr\.co\.vividnext\.sodalive\.v2\.(main|creator|live)\..*(Activity|Fragment|Coordinator)' app/src/main/java/kr/co/vividnext/sodalive/v2 --glob '*.kt' rg -n '^import kr\.co\.vividnext\.sodalive\.v2\.(main|creator|live)\..*(Activity|Fragment|Coordinator)' app/src/main/java/kr/co/vividnext/sodalive/v2 --glob '*.kt'
``` ```
- 검증 기준: composition에 필요한 부모-자식 Fragment 연결을 제외하고 도메인 Action으로 대체할 직접 의존이 남지 않는다. - 검증 기준: 합의된 반복 navigation/정책의 직접 의존은 Action/Handler로 대체된다. parent-child Fragment composition, same-feature child flow, `MainV2Activity` system/deeplink/audio notification route, Home on-air 목록 화면 진입과 단일 owner 기능은 예외 근거가 기록되어 있으면 유지한다.
- 검증 기록:
- 2026-07-15: 직접 feature Activity/Fragment/Coordinator import와 destination Activity `newIntent`/`Intent(...)` 호출을 검색했다. 남은 직접 destination 생성은 `ContentActionHandler`, `CreatorActionHandler`, `CommunityActionHandler`, `ChatActionHandler`, `LiveActionCoordinator`, parent-child Fragment composition, same-feature child flow, Phase 1에서 제외한 `MainV2Activity` system/deeplink/audio notification route로 분류했다.
- 2026-07-15: `HomeMainFragment`의 `HomeOnAirLiveActivity.newIntent`는 on-air 목록 화면 자체 진입이고, `MainV2Activity` 직접 route는 기존 deeplink/system 계약 보존 대상이므로 Phase 6에서 Action으로 옮기지 않는다.
- 2026-07-16: 동일 import 검색을 재실행했다. 남은 항목은 Action/Handler, `MainV2Activity` system/deeplink/audio notification route, Home on-air 목록 화면 진입, parent-child Fragment composition, same-feature child flow로 분류되어 추가 이관 대상은 없었다.
- [ ] **Task 6.2: 이전 helper와 중복 함수 제거** - [x] **Task 7.2: 이전 helper와 중복 함수 제거**
- 삭제/수정 대상: 각 Phase에서 호환을 위해 남긴 adapter, 이전 `ensure...`, 중복 `open...`, 직접 route helper. - 삭제/수정 대상: 각 Phase에서 호환을 위해 남긴 adapter, 이전 `ensure...`, 중복 `open...`, 직접 route helper.
- 검증 기준: 모든 호출부가 새 공개 진입점을 사용하는 것이 확인된 구현만 제거한다. - 검증 기준: 모든 호출부가 새 공개 진입점을 사용하는 것이 확인된 구현만 제거한다.
- 검증 기록:
- 2026-07-15: `ensureMainV2NavigationAllowed`, `ensureLoginAndAdultAuth`, `MainV2LoginGuard`, `isLoggedIn(`, `showLoginActivity(` 검색 결과 운영 코드에는 레거시 `MyPageFragment` 호환을 위한 `MainV2Activity.showLoginActivity()`만 남았다. 해당 메서드는 Phase 1에서 의도적으로 유지한 호환 진입점이므로 삭제하지 않는다.
- 2026-07-15: 남은 `openAudioContentDetail`, `openSeriesDetail`, `openCreatorProfile`, `openFollowingChat`, `enterLiveRoom`은 UI model을 explicit command 또는 coordinator 호출로 변환하는 로컬 adapter이거나 기존 목록 화면 진입 wrapper라 동작 보존을 위해 유지한다.
- 2026-07-16: 이전 helper와 중복 함수 검색을 재실행했다. `MainV2Activity.showLoginActivity()`는 레거시 MyPage 호환 진입점으로 유지하고, 나머지 `open...`/`enter...` 함수는 UI model을 command로 바꾸거나 기존 목록 화면을 여는 로컬 adapter라 삭제하지 않았다.
- [ ] **Task 6.3: 미추출 도메인 재평가** - [x] **Task 7.3: 미추출 도메인 재평가**
- 확인 대상: FanTalk, Donation, Schedule, Ranking, Discovery. - 확인 대상: FanTalk, Donation, Schedule, Ranking, Discovery.
- 작업: 실제 반복 호출과 동일 정책이 생겼는지 확인하고 추출 또는 현행 유지 근거를 기록한다. - 작업: 실제 반복 호출과 동일 정책이 생겼는지 확인하고 추출 또는 현행 유지 근거를 기록한다.
- 검증 기준: 형식적 Action 추가 없이 단일 사용 기능은 기존 feature에 유지한다. - 검증 기준: 형식적 Action 추가 없이 단일 사용 기능은 기존 feature에 유지한다.
- 검증 기록:
- 2026-07-15: FanTalk는 Creator Channel 단일 owner에서 write/detail result 후 FanTalk와 Home projection을 함께 갱신하는 흐름이며, 별도 독립 호출부가 없어 Action 추출 대상에서 제외했다.
- 2026-07-15: Donation, Schedule은 Creator Channel 내부 tab/host composition과 UI projection 성격이 강하고 동일 navigation/action 정책을 공유하는 독립 호출부가 없어 현행 feature 소유로 유지한다.
- 2026-07-15: Ranking/Discovery는 현재 Content/Home의 집계·표시 흐름 또는 widget 성격으로 남아 있고, 이미 Content Action이 상세 진입만 소유하므로 추가 도메인 Action을 만들지 않는다.
- 2026-07-16: FanTalk, Donation, Schedule, Ranking, Discovery 관련 호출부를 다시 검색했다. 새 반복 navigation/접근 정책은 확인되지 않았고, 단일 owner 또는 화면별 집계/표시 흐름으로 남기는 기존 판단을 유지한다.
- [ ] **Task 6.4: 결과 전달 방식과 observer 필요성 재검사** - [x] **Task 7.4: 결과 전달 방식과 observer 필요성 재검사**
- 확인 대상: Content, Live, Community, Chat 및 후속 mutation 흐름. - 확인 대상: Content, Live, Community, Chat 및 후속 mutation 흐름.
- 작업: - 작업:
- 하위 Activity 결과가 `ActivityResult -> 명시적 결과 -> feature 단일 handler`로 연결되는지 확인한다. - 하위 Activity 결과가 `ActivityResult -> 명시적 결과 -> feature 단일 handler`로 연결되는지 확인한다.
- 동일한 raw result 해석과 refresh 조합이 여러 호출부에 남아 있는지 확인한다. - 동일한 raw result 해석과 refresh 조합이 여러 호출부에 남아 있는지 확인한다.
- `SharedFlow`/observer 도입 지점이 있다면 Activity 밖의 독립 소비자, lifecycle, replay, 중복 처리 근거를 기록한다. - `SharedFlow`/observer 도입 지점이 있다면 Activity 밖의 독립 소비자, lifecycle, replay, 중복 처리 근거를 기록한다.
- 검증 기준: 직접 결과 전달로 충분한 흐름에 전역 EventBus 또는 singleton observer가 추가되지 않는다. - 검증 기준: 직접 결과 전달로 충분한 흐름에 전역 EventBus 또는 singleton observer가 추가되지 않는다.
- 검증 기록:
- 2026-07-15: `ActivityResult`, `RESULT_OK`, `resolve*ActivityResult`, `SharedFlow`, `EventBus`, `observeForever` 계열 검색으로 결과 전달 방식을 재검사했다. Community는 `resolveCommunityActivityResult`와 `handleCommunityChange`, Live 생성은 `resolveLiveCreationResult`와 `handleLiveCreationResult`를 통해 명시적 결과와 단일 handler로 연결된다.
- 2026-07-15: FanTalk의 raw `RESULT_OK` 처리는 Creator Channel 내부 단일 owner 흐름이고 Phase 6에서 추출하지 않기로 한 도메인이므로 유지한다. `BannerView`의 `lifecycleObserver`는 UI lifecycle local observer이며 전역 event/observer가 아니다.
- 2026-07-15: 신규 `SharedFlow`, EventBus, singleton observer, `observeForever` 기반 전파는 추가되지 않았음을 확인했다.
- 2026-07-16: 결과 전달과 observer 검색을 재실행했다. Community와 Live 생성은 명시적 결과와 단일 handler를 유지하고, FanTalk raw `RESULT_OK`는 단일 owner 예외로 남았다. 새 `SharedFlow`, EventBus, singleton observer, production `observeForever` 전파는 확인되지 않았다.
- [ ] **Task 6.5: 통합 회귀 검증** - [x] **Task 7.5: 통합 회귀 검증**
- 실행 명령: - 실행 명령:
```bash ```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.*" ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.*"
@@ -880,7 +1048,261 @@
- Creator Channel 및 Community 상세 진입 - Creator Channel 및 Community 상세 진입
- Chat/DM 생성과 기존 room 진입 - Chat/DM 생성과 기존 room 진입
- 기존 Activity result와 새로고침 동작 - 기존 Activity result와 새로고침 동작
- 기대 결과: `v2` 전체 관련 테스트와 컴파일, ktlint, whitespace 검증 통과. - 기대 결과: `v2` 전체 관련 테스트와 컴파일, test source ktlint, whitespace 검증 통과한다. 전체 main ktlint가 기존 기준선으로 실패하면 Phase 6/7 변경 파일의 신규 위반이 없음을 확인하고 기록한다.
- 검증 기록:
- 2026-07-15: Action 통합 테스트 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.content.action.*" --tests "kr.co.vividnext.sodalive.v2.live.action.*" --tests "kr.co.vividnext.sodalive.v2.creator.action.*" --tests "kr.co.vividnext.sodalive.v2.community.action.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*" --rerun-tasks`가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-15: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.*" --rerun-tasks`가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-15: `./gradlew :app:compileDebugKotlin`은 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-15: `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`는 `BUILD SUCCESSFUL`로 통과했고 test source 리포트는 0건이다. `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했지만, main/test ktlint 리포트에서 현재 변경 Kotlin 파일명 검색 결과는 0건이다.
- 2026-07-15: `git diff --check HEAD`는 whitespace 오류 없이 통과했다. Kotlin LSP는 환경에 `kotlin-ls`가 없어 Gradle compile/test로 대체 검증했다.
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.*" --rerun-tasks`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-16: `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`, `git diff --check HEAD`는 통과했다.
- 2026-07-16: `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했다. main/test ktlint 리포트에서 Phase 6/7 변경 Kotlin 파일명 검색 결과는 0건이다.
### Phase 8: 푸시/딥링크의 도메인 Action 연결
- 상태: 사용자 후속 요구로 기존 system/deeplink 예외를 재개방한다. 새 범용 router는 만들지 않고 기존 payload 해석 위치에서 이미 분리된 Action을 호출한다.
- [x] **Task 8.1: 푸시/딥링크 계약과 우선순위 문서화**
- 수정 파일:
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/prd.md`
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md`
- 확정 계약:
- DM deep link room은 Chat Action, 그 외 room은 Live 진입으로 처리하고 channel ID보다 우선한다.
- 진행 중 Live는 기존 입장 정책을 실행하고 예약/미진행 Live는 레거시 Live Detail을 표시한다.
- Community `postId`가 있으면 PostDetail Action을 실행하고, 없을 때만 creator profile로 fallback한다.
- Community `deep_link_sub5`/path ID는 기존 `creatorId`이므로 `routeByDeepLinkValue("community")`는 Creator fallback이며, 별도 query/canonical `postId`를 ID로 대체 해석하지 않는다.
- 현재 DM은 `${URISCHEME}://chat/{roomId}`를 Chat Action으로 처리한다. legacy `message_id`는 room ID로 재해석하지 않고 과거 알림 호환 route로만 유지한다.
- Content/Series/Creator/Community/DM은 기존 Action을 직접 재사용하며 새 `PushRouteAction`을 만들지 않는다.
- message/audition/audio player 등 Action이 없는 system-only route는 현행 처리를 유지한다.
- 검증 기록:
- 2026-07-16: 사용자가 `DeepLinkActivity`의 최소 변경을 승인했고, Live 및 Community payload 우선순위와 fallback 계약을 PRD와 본 계획에 반영했다.
- [x] **Task 8.2: 푸시/딥링크 라우팅 회귀 테스트를 RED로 추가**
- 수정 파일:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/main/DeepLinkActivitySourceTest.kt`
- 검증 기준:
- Main과 DeepLink 진입점이 Content/Creator/Community/Chat Action을 호출한다.
- non-DM room이 channel보다 먼저 Live로 라우팅되고, Community post가 creator fallback보다 먼저 처리된다.
- audio detail notification이 Content Action을 사용한다.
- 구현 전 focused 테스트가 새 기대 조건으로 실패하고, 실패 원인이 아직 연결되지 않은 Action/Live route임을 확인한다.
- 검증 기록:
- 2026-07-16: `MainV2ActivitySourceTest`와 `DeepLinkActivitySourceTest`에 DM/Content/Series/Creator/Community Action 연결, non-DM Live 우선, Community post 우선, audio detail notification Action 연결 기대 조건을 추가했다.
- 2026-07-16: 운영 코드 변경 전에 두 테스트 클래스를 `--rerun-tasks`로 실행했고 15개 중 신규 기대 조건 8개가 실패했다. 실패 지점은 기존 직접 Activity 이동, `MainV2Activity`의 non-DM room 분기 누락, Community post 우선 분기 누락으로 확인되어 의도한 RED 상태다.
- [x] **Task 8.3: `MainV2Activity` cold start/onNewIntent Action 연결**
- 수정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- 작업:
- DM, Content, Series, Creator, Community route를 기존 도메인 Action에 연결한다.
- non-DM room을 channel보다 먼저 `LiveActionCoordinator.enterLiveRoom`에 연결한다.
- Community post를 creator fallback보다 먼저 처리한다.
- audio detail notification을 Content Action에 연결하고 system-only route는 유지한다.
- 검증 기준: focused source 테스트와 `LiveEntryPolicyTest`가 통과한다.
- 검증 기록:
- 2026-07-16: DM, Content, Series, Creator, Community 경로를 기존 Action에 연결하고, non-DM room을 channel보다 먼저 `LiveActionCoordinator.enterLiveRoom`으로 처리하도록 보완했다. `deep_link_value=live` fallback과 audio detail notification도 각각 Live coordinator와 Content Action에 연결했다.
- 2026-07-16: Live coordinator는 기존 `LiveEntryPolicy`를 그대로 사용하므로 channel 정보가 없는 예약/미진행 방은 레거시 Live Detail을 표시하고, 진행 중 방은 기존 무료/유료/비밀번호 정책에 따라 입장한다.
- [x] **Task 8.4: 승인된 `DeepLinkActivity` foreground Action 연결**
- 수정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`
- 작업:
- foreground Content, Series, Creator, Community, DM 직접 navigation을 기존 Action 호출로 교체한다.
- Live는 현재 Main 전달 계약을 유지하고 Main의 Live Action 진입점에서 처리한다.
- Community post 우선 및 creator fallback 계약을 적용한다.
- FCM payload 보존, foreground Live broadcast, system-only route는 변경하지 않는다.
- 검증 기준: DeepLink source 테스트와 FCM payload 회귀 테스트가 통과한다.
- 검증 기록:
- 2026-07-16: 승인 범위 안에서 `DeepLinkActivity`의 foreground DM, Content, Series, Creator, Community 직접 navigation을 기존 Action 호출로 교체했다. Live의 Main 전달, foreground LiveRoom broadcast, message/audition/payment route는 유지했다.
- 2026-07-16: Community `postId` 분기를 creator fallback보다 앞에 두어 PostDetail Action을 우선하고, post ID가 없을 때만 Creator Action으로 이동하도록 연결했다.
- [x] **Task 8.5: 집중 및 최종 회귀 검증**
- 실행 명령:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.v2.live.action.LiveEntryPolicyTest" --rerun-tasks
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.*" --rerun-tasks
./gradlew :app:compileDebugKotlin
./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks
./gradlew :app:ktlintCheck --rerun-tasks
git diff --check HEAD
```
- 수동 확인 항목:
- 진행 중 Live push는 라이브에 입장한다.
- 예약 Live push는 레거시 Live Detail을 표시한다.
- Community push는 post ID가 있으면 게시글 상세, 없으면 Creator Channel을 표시한다.
- cold start, foreground, `onNewIntent`, 앱 내 알림 목록 경로가 동일 우선순위를 사용한다.
- 실행 상태: 연결 기기가 검증 직전 해제되어 실제 알림 터치/화면 표시 확인은 이번 실행에서 수행하지 못했다. 라우팅 계약과 우선순위는 아래 자동화 검증으로 확인했고 실기기 시나리오는 잔여 수동 확인 항목으로 기록한다.
- 기대 결과: focused/전체 V2 테스트, 컴파일, test ktlint, whitespace 검증이 통과한다. 전체 main ktlint가 기존 기준선으로 실패하면 이번 변경 파일의 신규 위반이 없음을 리포트에서 확인해 기록한다.
- 검증 기록:
- 2026-07-16: RED 확인 후 `MainV2ActivitySourceTest`와 `DeepLinkActivitySourceTest`를 재실행해 15개 전체가 통과했다.
- 2026-07-16: 위 두 테스트와 `SodaFirebaseMessagingServiceSourceTest`, `LiveEntryPolicyTest` 집중 묶음을 `--rerun-tasks`로 실행해 `BUILD SUCCESSFUL`을 확인했다. FCM의 기존 deep-link/room payload 보존과 Live의 상세/입장 정책이 함께 통과했다.
- 2026-07-16: 후속 리뷰에서 Community `deep_link_sub5`가 legacy `creatorId`, 별도 `postId` query가 게시글 ID라는 기존 계약을 재확인했다. 따라서 상위 post Action 우선 분기와 `routeByDeepLinkValue("community")`의 Creator fallback을 유지했다.
- 2026-07-16: 현재 DM 발행 계약은 `${URISCHEME}://chat/{roomId}`이고 Chat Action으로 연결되어 있음을 확인했다. legacy `message_id`는 DM room ID 근거가 없어 재해석하지 않고 과거 알림 호환 경로로만 유지하기로 결정했다.
- 2026-07-16: `MainV2Activity`가 raw deep-link URL의 `postId` query를 canonical `EXTRA_COMMUNITY_POST_ID`로 정규화한다는 테스트를 먼저 추가해 11개 중 1개 실패를 확인하고, 매핑을 보완한 뒤 11개 전체 통과를 확인했다.
- 2026-07-16: 전체 V2 테스트 첫 실행에서 기존 `MainV2AudioNotificationRouteSourceTest`의 직접 Activity 기대 1건이 실패해 Content Action 기대 조건으로 정정했고, 단일 테스트와 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.*" --rerun-tasks` 재실행이 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-16: 최종 집중 묶음에 `MainV2ActivitySourceTest`, `MainV2AudioNotificationRouteSourceTest`, `DeepLinkActivitySourceTest`, `SodaFirebaseMessagingServiceSourceTest`, `LiveEntryPolicyTest`를 포함해 `BUILD SUCCESSFUL`을 확인했다. 이후 Community fallback과 legacy message 계약을 명시한 두 source 테스트도 재실행해 통과했다.
- 2026-07-16: `./gradlew :app:compileDebugKotlin`과 `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 550건으로 실패했지만 `DeepLinkActivity.kt`, `MainV2Activity.kt`는 main ktlint 리포트 검색 결과 0건이다.
- 2026-07-16: `git diff --check HEAD`는 whitespace 오류 없이 통과했다. 코드 리뷰에서 푸시/딥링크 Action 연결 범위의 P0~P2 발견 사항은 없었다.
- 2026-07-16: `adb devices`에서 기기 1대를 처음 확인했으나 설치 상태 확인 전에 연결이 해제됐고 재조회 결과 기기가 없어 실기기 알림 터치 검증은 수행하지 못했다.
### Phase 9: Live 푸시 목적지 판단 로딩 표시
- 상태: `LiveViewModel.getRoomDetail()`은 비동기 처리 중 `isLoading`, 실패 시 `toastLiveData`를 발행하지만 `MainV2Activity`가 두 상태를 관찰하지 않아 푸시 진입 중 진행·실패 여부가 보이지 않는 문제를 보완한다.
- 후속 결정: Task 9.1~9.4의 `screen_live_loading` 문구 계약과 검증 기록은 당시 실행 이력으로 유지한다. 현재 계약은 cold start 딥링크의 공통 1초 대기와 Live 추가 조회가 같은 문구 없는 spinner를 공유하는 Task 9.5 이후 내용이다.
- [x] **Task 9.1: Live 푸시 로딩 UX 계약 문서화**
- 수정 파일:
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/prd.md`
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md`
- 확정 계약:
- `MainV2Activity`에서 Live room detail 조회 중 기존 `LoadingDialog`와 `R.string.screen_live_loading` 문구를 표시한다.
- 조회 성공·실패로 `LiveViewModel.isLoading`이 `false`가 되면 로딩을 해제한다.
- 조회 실패 메시지는 `LiveViewModel.toastLiveData`를 관찰해 기존 Toast 방식으로 표시한다.
- 별도 네트워크 판단이 없는 다른 도메인 route까지 범용 push loader로 확장하지 않는다.
- 검증 기록:
- 2026-07-16: `HomeMainFragment`, `HomeOnAirLiveActivity`, `CreatorChannelActivity`는 동일 `LiveViewModel` 상태를 `LoadingDialog`와 Toast에 연결하지만 `MainV2Activity`만 관찰하지 않는 차이를 확인했고, 이를 원인으로 확정했다.
- 2026-07-16: 사용자가 기존 `LoadingDialog`/Toast 패턴을 Live 푸시 판단 구간에 적용하는 최소 설계를 승인했다.
- [x] **Task 9.2: `MainV2Activity` Live 진입 상태 회귀 테스트 RED 추가**
- 수정 파일:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
- 검증 기준:
- `MainV2Activity`가 `LiveViewModel.isLoading`과 `toastLiveData`를 관찰한다.
- loading `true`에는 `screen_live_loading` 문구로 `LoadingDialog`를 표시하고 `false`에는 해제한다.
- 구현 전 focused 테스트가 아직 observer가 없다는 이유로 실패한다.
- 검증 기록:
- 2026-07-16: `MainV2ActivitySourceTest`에 Live 푸시 판단 중 LoadingDialog 표시·해제와 실패 Toast observer 기대 조건을 추가했다.
- 2026-07-16: 운영 코드 변경 전에 focused 테스트를 `--rerun-tasks`로 실행해 12개 중 새 테스트 1개가 `setupLiveEntryObservers()` 부재로 실패하는 의도한 RED 상태를 확인했다.
- [x] **Task 9.3: `MainV2Activity` Live 푸시 로딩·실패 안내 연결**
- 수정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- 작업:
- 기존 `LoadingDialog`를 재사용한다.
- `setupView()`에서 Live 진입 상태 observer를 등록해 cold start와 `onNewIntent` 경로에 공통 적용한다.
- Live Action/정책, payload 우선순위, 다른 도메인 route는 변경하지 않는다.
- 검증 기준: Task 9.2 focused 테스트와 기존 푸시/Live 회귀 테스트가 통과한다.
- 검증 기록:
- 2026-07-16: `MainV2Activity.setupLiveEntryObservers()`를 추가해 `LiveViewModel.isLoading`이 `true`이면 기존 `LoadingDialog`에 `R.string.screen_live_loading`을 표시하고 `false`이면 해제하도록 연결했다.
- 2026-07-16: `LiveViewModel.toastLiveData`를 기존 `BaseActivity.showToast`에 연결했으며 Live Action, payload 우선순위, 다른 도메인 route는 변경하지 않았다.
- 2026-07-16: 구현 후 `MainV2ActivitySourceTest` 12개 전체가 `BUILD SUCCESSFUL`로 통과했다.
- [x] **Task 9.4: 집중 및 최종 회귀 검증**
- 실행 명령:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --rerun-tasks
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.v2.live.action.*" --rerun-tasks
./gradlew :app:compileDebugKotlin
./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks
git diff --check HEAD
```
- 수동 확인 항목:
- Live 푸시를 터치하면 상태 판단 중 `라이브를 불러오고 있습니다.` 로딩이 표시된다.
- 진행 중 Live 입장 또는 예약 Live Detail 표시 직전에 로딩이 해제된다.
- Live 조회 실패 시 로딩이 해제되고 오류 Toast가 표시된다.
- 기대 결과: focused/Live 회귀 테스트, 컴파일, test ktlint, whitespace 검증이 통과한다.
- 검증 기록:
- 2026-07-16: `MainV2ActivitySourceTest` focused 테스트를 `--rerun-tasks`로 실행해 12개 전체가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-16: `MainV2ActivitySourceTest`, `SodaFirebaseMessagingServiceSourceTest`, `v2.live.action.*` 회귀 묶음을 `--rerun-tasks`로 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-16: `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`, `./gradlew tasks --all`, `git diff --check HEAD`가 통과했다. test ktlint 보고서는 0건이다.
- 2026-07-16: `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 550건으로 실패했지만, 현재 생성된 main ktlint 보고서에서 `MainV2Activity.kt` 검색 결과는 0건이다.
- 2026-07-16: `LiveViewModel.getRoomDetail()`이 성공 callback보다 먼저 `isLoading=false`를 발행하는 순서와 `MainV2Activity` 생명주기 observer 등록 위치를 재검토했으며, 이번 로딩 연결 범위에서 P0~P2 추가 발견 사항은 없었다.
- 2026-07-16: `adb devices` 결과 연결 기기가 없어 Live 푸시 터치, 진행/예약 분기, 실패 Toast의 실기기 수동 확인은 수행하지 못했다. 자동 회귀 검증은 완료했고 수동 시나리오는 연결 기기와 유효한 Live payload가 준비되면 확인할 잔여 항목으로 기록한다.
- [x] **Task 9.5: 딥링크 공통 대기 로딩 UX 계약 보완**
- 수정 파일:
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/prd.md`
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md`
- 확정 계약:
- cold start에서 `Constants.EXTRA_DATA` 또는 audio notification route가 있으면 기존 1초 지연 동안 문구 없는 `LoadingDialog`를 표시한다.
- Live route는 공통 대기 종료 후 room detail 조회가 끝날 때까지 같은 spinner를 유지한다.
- 다른 도메인 route는 공통 대기 종료 시 spinner를 해제하고 기존 Action으로 이동한다.
- 일반 앱 실행과 지연이 없는 `onNewIntent`에 인위적인 1초 대기를 추가하지 않는다.
- Live 조회 실패 Toast 계약은 유지한다.
- 검증 기록:
- 2026-07-16: 모든 cold start route가 `MainV2Activity.onCreate()`의 기존 1초 지연 블록을 공유하고, Live만 이후 `LiveViewModel.getRoomDetail()` 네트워크 조회를 수행함을 확인했다.
- 2026-07-16: 사용자가 도메인별 문구 대신 공통 1초 대기와 Live 추가 조회에 문구 없는 spinner를 사용하는 권장 방향을 승인했다.
- [x] **Task 9.6: 공통 대기와 Live 조회 로딩 상태 결합**
- 수정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
- 작업:
- cold start route 대기 상태와 Live 조회 상태를 별도 Boolean으로 관리한다.
- 두 상태 중 하나라도 진행 중이면 기존 `LoadingDialog.show(screenWidth)`를 호출하고 둘 다 종료되면 해제한다.
- `R.string.screen_live_loading` 문구 의존을 제거하고 새 문자열 리소스는 추가하지 않는다.
- 검증 기준:
- 일반 앱 실행에는 공통 로딩 상태가 활성화되지 않는다.
- cold start 딥링크와 audio notification route는 1초 대기 동안 로딩 상태가 활성화된다.
- Live 조회가 시작되면 공통 대기 종료 시점에도 로딩이 유지된다.
- focused 테스트에서 구현 전 RED, 구현 후 GREEN을 확인한다.
- RED 검증 기록:
- 2026-07-16: 기존 Live 전용 문구 테스트를 cold start 공통 대기, Live 연속 로딩, 문구 없는 spinner 계약으로 변경했다.
- 2026-07-16: 운영 코드 변경 전에 `MainV2ActivitySourceTest`를 `--rerun-tasks`로 실행해 12개 중 새 계약 테스트 1개가 `updateRouteLoadingDialog()` 부재로 실패하는 의도한 RED 상태를 확인했다.
- GREEN 검증 기록:
- 2026-07-16: `MainV2Activity`에 `isDeferredRouteLoading`과 `isLiveEntryLoading`을 추가하고 `updateRouteLoadingDialog()`에서 두 상태를 결합했다.
- 2026-07-16: cold start의 `Constants.EXTRA_DATA` 또는 audio notification route가 있으면 기존 1초 지연 전에 공통 상태를 활성화하고, route 실행 후 비활성화하도록 연결했다.
- 2026-07-16: Live observer는 별도 조회 상태만 갱신하며, `LoadingDialog.show(screenWidth)`를 사용해 `screen_live_loading` 문구 의존과 신규 문자열 추가 없이 같은 spinner를 공유한다.
- 2026-07-16: 구현 후 `MainV2ActivitySourceTest` 12개 전체가 `BUILD SUCCESSFUL`로 통과했다.
- [x] **Task 9.7: 집중 및 최종 회귀 검증**
- 실행 명령:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --rerun-tasks
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2AudioNotificationRouteSourceTest" --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.v2.live.action.*" --rerun-tasks
./gradlew :app:compileDebugKotlin
./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks
git diff --check HEAD
```
- 수동 확인 항목:
- Content, Community, Creator, DM 등 cold start 푸시를 터치하면 공통 1초 대기 동안 문구 없는 spinner가 표시되고 목적지 이동 시 해제된다.
- Live cold start 푸시는 공통 대기에서 room detail 조회까지 spinner가 끊기지 않는다.
- 일반 앱 실행과 지연 없는 non-Live `onNewIntent`에는 불필요한 spinner가 표시되지 않는다.
- 기대 결과: focused/route/FCM/Live 회귀 테스트, 컴파일, test ktlint, whitespace 검증이 통과한다.
- 검증 기록:
- 2026-07-16: `MainV2ActivitySourceTest` focused 테스트를 `--rerun-tasks`로 실행해 12개 전체가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-16: `MainV2ActivitySourceTest`, `MainV2AudioNotificationRouteSourceTest`, `SodaFirebaseMessagingServiceSourceTest`, `v2.live.action.*` 회귀 묶음을 `--rerun-tasks`로 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-16: `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`, `./gradlew tasks --all`, `git diff --check HEAD`가 통과했다. test ktlint 보고서는 0건이다.
- 2026-07-16: test ktlint 첫 실행은 sandbox의 Gradle wrapper lock 접근 제한으로 시작되지 않았고, 동일 명령을 승인된 캐시 접근으로 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-16: `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 550건으로 실패했지만, 현재 main ktlint 보고서에서 `MainV2Activity.kt` 검색 결과는 0건이다.
- 2026-07-16: cold start route 상태와 Live 조회 상태의 전환 순서, 일반 실행의 초기 `false` 조건, `onNewIntent` 비지연 경로를 재검토했으며 이번 후속 범위에서 P0~P2 추가 발견 사항은 없었다.
- 2026-07-16: `adb devices` 결과 연결 기기가 없어 도메인별 cold start 푸시와 Live 연속 spinner의 실기기 수동 확인은 수행하지 못했다. 자동 회귀 검증은 완료했고 수동 시나리오는 잔여 확인 항목으로 기록한다.
### Historical Review Follow-up: Action 계약 보완
- 이 절은 Phase 6 추가 전에 완료한 P1/P2/P3 이력이다. P2에서 남은 mutation 성공 경로 wiring 검증은 Task 6.4에서 별도 재개한다.
- `P0`~`P3`는 Phase가 아니라 리뷰 이슈 우선순위다. `P0`은 즉시 차단해야 하는 치명적 문제, `P1`은 병합 전 해결할 높은 우선순위 문제, `P2`는 중요하지만 후속 보완 가능한 문제, `P3`는 낮은 우선순위의 정리·개선 사항을 뜻한다.
- [x] **P1: Chat Action 로그인 접근 정책 소유**
- 수정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/chat/action/ChatAction.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/chat/action/ChatActionHandler.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/chat/action/ChatActionTest.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt`
- 검증 기록:
- 2026-07-16: `ChatAction`이 유효 ID 확인 후 `AccessRequirement.Login`을 검사하고 차단 시 `ChatActionResult.Blocked(AccessRequirement.Login)`을 반환하도록 보완했다. `ChatActionHandler`의 Activity/Fragment 확장 함수는 `ensureV2Access`를 주입하며, `Blocked`와 `Ignored`에서는 Activity를 시작하지 않는다.
- 2026-07-16: `HomeMainFragment.openFollowingChat`의 화면-local login guard는 제거하고 `handleChatAction(ChatActionCommand.AiRoom/DmRoom)` 호출만 남겨 Chat Action이 동일 정책을 소유하게 했다.
- [x] **P2: Community ActivityResult 계약 문서와 테스트 정정**
- 수정 파일:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/community/action/CommunityChangeTest.kt`
- `docs/20260714_V2_공통_접근_가드와_도메인_액션/plan-task.md`
- 검증 기록:
- 2026-07-16: 기존 write/modify/detail Activity result는 post ID extra를 반환하지 않으므로 `resolveCommunityActivityResult(source, resultCode)`는 `RESULT_OK`와 source만 명시적 변경으로 변환하는 계약으로 유지했다.
- 2026-07-16: `CommunityChangeTest`의 post ID 검증은 동일 객체끼리 비교하지 않고 `Deleted.postId`, `PinChanged.postId` payload를 직접 확인하도록 정정했다. post ID 유효성은 상세 진입 `CommunityAction`과 delete/pin 성공 경로에서 다룬다.
- [x] **P3: 미사용 AI Character Intent helper 제거**
- 수정 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt`
- 검증 기록:
- 2026-07-16: 운영 호출부 없이 항상 `null`을 반환하던 `HomeRecommendationAiCharacterRoute.toHomeRecommendationAiCharacterIntent()`와 해당 테스트/import를 제거했다. AI Character route는 `creatorId` 보존과 invalid ID 무시만 검증하고, 실제 이동은 `HomeMainFragment`의 `handleCreatorAction(CreatorActionCommand.Profile(...))`가 담당한다.
- [x] **리뷰 보완 검증**
- 검증 기록:
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.chat.action.ChatActionTest" --rerun-tasks`가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.community.action.CommunityChangeTest" --rerun-tasks`가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest" --rerun-tasks`와 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest" --rerun-tasks`가 각각 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-16: Chat 관련 회귀 묶음 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" --rerun-tasks`가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-16: `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`, `git diff --check HEAD`는 통과했다. `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했지만, main/test ktlint 리포트에서 이번 리뷰 보완 변경 파일명 검색 결과는 0건이다. Kotlin LSP는 환경에 `kotlin-ls`가 없어 Gradle compile/test로 대체 검증했다.
## Verification Log ## Verification Log
- 2026-07-14: 사용자 요청에 따라 이번 단계에서는 PRD와 구현 계획 문서만 작성했다. 운영 코드, 테스트 코드, DI는 아직 변경하지 않았고 Gradle 검증은 실행하지 않았다. - 2026-07-14: 사용자 요청에 따라 이번 단계에서는 PRD와 구현 계획 문서만 작성했다. 운영 코드, 테스트 코드, DI는 아직 변경하지 않았고 Gradle 검증은 실행하지 않았다.
@@ -891,3 +1313,15 @@
- 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 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 기기가 없어 실기기 수동 탭 검증은 후속 확인 항목으로 남겼다. - 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 기기가 없어 실기기 수동 탭 검증은 후속 확인 항목으로 남겼다.
- 2026-07-15: 사용자 수동 테스트 결과 Phase 3 사전 준비 3개와 시나리오 22개를 `PASS`로 확인했다. 미체크 시나리오 `M3-03`, `M3-04`, `M3-06`, `M3-15`, `M3-24`, `M3-26`, `M3-29`, `M3-30`은 사용자 판단에 따라 테스트 불가능 또는 의미 없음으로 보아 `SKIPPED` 처리했으며, `FAILED`는 0개다. 모든 항목의 판정이 완료되어 Task 3.8과 Phase 3 수동 회귀 검증을 완료 처리했다. - 2026-07-15: 사용자 수동 테스트 결과 Phase 3 사전 준비 3개와 시나리오 22개를 `PASS`로 확인했다. 미체크 시나리오 `M3-03`, `M3-04`, `M3-06`, `M3-15`, `M3-24`, `M3-26`, `M3-29`, `M3-30`은 사용자 판단에 따라 테스트 불가능 또는 의미 없음으로 보아 `SKIPPED` 처리했으며, `FAILED`는 0개다. 모든 항목의 판정이 완료되어 Task 3.8과 Phase 3 수동 회귀 검증을 완료 처리했다.
- 2026-07-15: Phase 4/5 Creator/Community/Chat Action 전환 후 action 테스트 묶음, Home/Content/Chat/Creator source 회귀 묶음, 넓은 관련 테스트 묶음, `:app:compileDebugKotlin`, `tasks --all`, `git diff --check HEAD`가 통과했다. `:app:ktlintTestSourceSetCheck --rerun-tasks`는 통과했고 test source 보고서는 0건이다. `:app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했지만 Phase 4/5 변경 Kotlin 파일의 신규 ktlint 위반은 리포트 검색 결과 0건이다. Kotlin LSP는 환경에 `kotlin-ls`가 없어 Gradle compile/test로 대체 검증했다.
- 2026-07-15: Phase 6 의존 방향/결과 전달/미추출 도메인 재검사를 완료했다. 추가 코드 삭제나 신규 Action 추출은 필요하지 않았고, `MainV2Activity` system/deeplink route와 `MainV2Activity.showLoginActivity()` 호환 진입점, parent-child composition, FanTalk 단일 owner raw result 처리는 의도적으로 유지했다. Action 통합 테스트, `v2.*` 전체 단위 테스트, `:app:compileDebugKotlin`, `:app:ktlintTestSourceSetCheck --rerun-tasks`, `git diff --check HEAD`는 통과했다. `:app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했지만 현재 변경 Kotlin 파일의 신규 ktlint 위반은 0건이다.
- 2026-07-16: 코드 리뷰 보완으로 Chat Action 로그인 접근 정책 소유, Community ActivityResult 계약 문서/테스트 정정, 미사용 AI Character Intent helper 제거를 완료했다. 관련 focused 테스트, Chat 회귀 묶음, `:app:compileDebugKotlin`, `:app:ktlintTestSourceSetCheck --rerun-tasks`, `git diff --check HEAD`는 통과했다. `:app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 위반으로 실패했지만 리뷰 보완 변경 Kotlin 파일의 신규 ktlint 위반은 0건이다.
- 2026-07-16: 최종 소유 경계를 기준으로 PRD와 Phase 1~5 규범 문구를 동기화하고 별도 Phase 6을 추가했다. 기존 최종 점검 Phase 6은 과거 검증 기록을 보존한 채 Phase 7로 이동하고 Task 7.1~7.5를 모두 재개방했다. 이번 단계에서는 두 문서만 수정했으며 운영/테스트 코드는 변경하지 않았고, 코드 재점검·Community mutation wiring 테스트·집중/최종 검증은 Task 6.3~7.5에 미완료로 남겼다.
- 2026-07-16: 두 문서에 대해 `git diff --check HEAD`가 무출력으로 통과했다. Phase 0~7 번호와 Task 6/7 체크 상태를 검색해 새 Phase 6의 문서 Task 6.1~6.2만 완료, Task 6.3~7.5는 미완료임을 확인했고, 정정 대상이던 Creator/Community/Chat 복수형 파일명과 이전 카탈로그 문구는 더 이상 남지 않음을 확인했다. 문서만 수정하는 단계이므로 Gradle 검증은 실행하지 않았다.
- 2026-07-16: 직전 두 항목은 Phase 6/7 후속 작업을 시작하기 전 재개방 시점의 기록으로 유지한다. 이후 Task 6.3~7.5와 코드 리뷰 보완을 완료했으며, 최종 재검증에서 `./gradlew :app:testDebugUnitTest --rerun-tasks`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintTestSourceSetCheck --rerun-tasks`, `git diff --check cc8d061b`, `git diff --check HEAD`가 통과했다. `./gradlew :app:ktlintCheck --rerun-tasks`는 기존 main source 기준선 550건으로 실패했지만 `v2` 경로와 test/androidTest source의 신규 위반은 0건이며, 기능 코드에서 P0~P2 수준의 리뷰 발견 사항은 없었다.
- 2026-07-16: Phase 8에서 `DeepLinkActivity`와 `MainV2Activity`의 DM/Content/Series/Creator/Community route를 기존 Action으로 연결하고 Live를 `LiveActionCoordinator`에 연결했다. Community는 별도 `postId`가 있으면 PostDetail Action을 우선하고, legacy `community/{creatorId}` 또는 `deep_link_sub5`만 있으면 Creator Action으로 fallback한다. 현재 DM은 `chat/{roomId}`만 Chat Action으로 처리하고 legacy `message`는 과거 호환 경로로 유지한다.
- 2026-07-16: Phase 8 최종 검증에서 focused route/FCM/Live 테스트, 전체 `v2.*` 테스트, `:app:compileDebugKotlin`, `:app:ktlintTestSourceSetCheck --rerun-tasks`, `git diff --check HEAD`가 통과했다. 전체 main ktlint는 기존 기준선 550건으로 실패했지만 Phase 8 production 변경 파일의 신규 위반은 0건이고 코드 리뷰의 P0~P2 발견 사항은 없었다. 연결 기기가 검증 도중 해제되어 실제 푸시 터치 수동 시나리오는 잔여 확인 항목으로 남겼다.
- 2026-07-16: Phase 9에서 `MainV2Activity`가 Live 푸시/딥링크 목적지 판단에 사용하는 `LiveViewModel.isLoading`과 `toastLiveData`를 관찰하도록 연결했다. 조회 중에는 기존 `LoadingDialog`에 `라이브를 불러오고 있습니다.`를 표시하고, 성공·실패 종료 시 해제하며 실패 메시지는 Toast로 표시한다. 다른 동기식 도메인 route에는 범용 로딩을 추가하지 않았다.
- 2026-07-16: Phase 9 최종 검증에서 focused 12개 테스트, FCM/Live Action 회귀 묶음, `:app:compileDebugKotlin`, `:app:ktlintTestSourceSetCheck --rerun-tasks`, `tasks --all`, `git diff --check HEAD`가 통과했다. 전체 main ktlint는 기존 기준선 550건으로 실패했지만 `MainV2Activity.kt`의 신규 위반은 0건이다. 연결 기기가 없어 실제 Live 푸시 터치 수동 시나리오는 잔여 확인 항목으로 남겼다.
- 2026-07-16: Phase 9 후속 결정으로 Live 전용 `라이브를 불러오고 있습니다.` 문구를 제거하고, cold start 푸시/딥링크의 기존 1초 대기와 Live 추가 room detail 조회가 하나의 문구 없는 `LoadingDialog` spinner를 공유하도록 변경했다. 일반 앱 실행과 지연 없는 non-Live `onNewIntent`에는 공통 대기를 추가하지 않았다.
- 2026-07-16: Phase 9 후속 최종 검증에서 focused 12개 테스트, route/오디오 알림/FCM/Live Action 회귀 묶음, `:app:compileDebugKotlin`, `:app:ktlintTestSourceSetCheck --rerun-tasks`, `tasks --all`, `git diff --check HEAD`가 통과했다. 전체 main ktlint는 기존 기준선 550건으로 실패했지만 `MainV2Activity.kt`의 신규 위반은 0건이다. 연결 기기가 없어 실기기 수동 시나리오는 잔여 확인 항목으로 남겼다.

View File

@@ -26,6 +26,8 @@
- 도메인 Action의 결과가 호출 화면에 따라 달리 표현되어야 하면 명시적인 result 계약으로 반환한다. - 도메인 Action의 결과가 호출 화면에 따라 달리 표현되어야 하면 명시적인 result 계약으로 반환한다.
- Home, Content, Creator Channel, On-air 등 feature는 화면별 최초 집계 API와 UI 구성을 유지하면서 공통 Action을 조합한다. - Home, Content, Creator Channel, On-air 등 feature는 화면별 최초 집계 API와 UI 구성을 유지하면서 공통 Action을 조합한다.
- 새 화면은 기존 도메인 Action을 호출해 동일 정책과 동작을 재구현하지 않고 사용할 수 있어야 한다. - 새 화면은 기존 도메인 Action을 호출해 동일 정책과 동작을 재구현하지 않고 사용할 수 있어야 한다.
- 푸시와 딥링크로 유입된 도메인 이동도 일반 화면과 같은 Action 및 Live 진입 정책을 사용한다.
- 푸시와 딥링크로 Live 상태를 조회해 목적지를 판단하는 동안 사용자에게 진행 상태와 실패 결과를 표시한다.
- 각 Phase는 기존 동작을 보존하고 독립적으로 테스트·컴파일 가능한 상태로 완료한다. - 각 Phase는 기존 동작을 보존하고 독립적으로 테스트·컴파일 가능한 상태로 완료한다.
- 하위 Activity가 반환하는 도메인 변경은 `ActivityResult`를 명시적 결과로 변환한 뒤 feature의 단일 handler에서 후처리한다. - 하위 Activity가 반환하는 도메인 변경은 `ActivityResult`를 명시적 결과로 변환한 뒤 feature의 단일 handler에서 후처리한다.
@@ -39,7 +41,7 @@
- 한 번만 사용되고 별도 정책이 없는 화면 전용 동작에 형식적인 Action, UseCase, Repository 인터페이스를 추가하지 않는다. - 한 번만 사용되고 별도 정책이 없는 화면 전용 동작에 형식적인 Action, UseCase, Repository 인터페이스를 추가하지 않는다.
- UI 레이아웃, 문구, 디자인, 화면 전환 UX를 변경하지 않는다. - UI 레이아웃, 문구, 디자인, 화면 전환 UX를 변경하지 않는다.
- 본인인증 SDK, 로그인 API, 콘텐츠 구매, 라이브 결제 등 레거시 기능 자체를 수정하지 않는다. - 본인인증 SDK, 로그인 API, 콘텐츠 구매, 라이브 결제 등 레거시 기능 자체를 수정하지 않는다.
- 레거시 파일을 공통화를 위해 직접 수정하지 않는다. 필요한 기능은 `v2` wrapper/adapter에서 호출한다. - 레거시 파일을 공통화를 위해 직접 수정하지 않는다. 단, V2 푸시/딥링크 진입점으로 계속 사용하는 `DeepLinkActivity`는 사용자 승인에 따라 기존 payload 계약을 Action에 연결하는 최소 변경을 허용한다.
- FanTalk, Donation, Schedule 등 현재 재사용 수요가 확인되지 않은 기능을 추측으로 공통화하지 않는다. - FanTalk, Donation, Schedule 등 현재 재사용 수요가 확인되지 않은 기능을 추측으로 공통화하지 않는다.
- Creator Channel 내부의 Community 변경 전파를 위해 전역 EventBus 또는 application singleton observer를 도입하지 않는다. - Creator Channel 내부의 Community 변경 전파를 위해 전역 EventBus 또는 application singleton observer를 도입하지 않는다.
- 직접적인 요청자와 결과 처리자가 명확한 흐름을 일괄적으로 `SharedFlow` 또는 observer 기반으로 변경하지 않는다. - 직접적인 요청자와 결과 처리자가 명확한 흐름을 일괄적으로 `SharedFlow` 또는 observer 기반으로 변경하지 않는다.
@@ -112,6 +114,16 @@ Access 판단 결과를 기존 로그인, 본인인증, 설정 이동 UX에 연
- Action의 공개 입력에 `HomeRecommendation...UiModel`, `CreatorChannel...Response` 같은 feature 전용 타입을 사용하지 않는다. - Action의 공개 입력에 `HomeRecommendation...UiModel`, `CreatorChannel...Response` 같은 feature 전용 타입을 사용하지 않는다.
- 동일한 이름이더라도 정책이 다른 동작은 Access와 도메인 정책으로 나누고 하나의 범용 함수에 합치지 않는다. - 동일한 이름이더라도 정책이 다른 동작은 Access와 도메인 정책으로 나누고 하나의 범용 함수에 합치지 않는다.
#### Final Ownership Boundary
- Access는 로그인 여부, 본인인증, 성인 콘텐츠 설정 등 접근 허용 판단과 기존 로그인/인증/설정 UX 실행을 소유한다.
- 도메인 Action은 안정적인 ID/command 유효성 검사, 동작에 필요한 `AccessRequirement` 선택, Access 실행 요청과 `Ignored`/`Blocked`/도메인 결과 반환을 소유한다.
- Action Handler는 Access 실행기 주입과 레거시 `Intent`/Activity/Dialog/navigation adapter를 소유하고, 호출 feature의 UI model이나 화면별 refresh를 알지 않는다.
- feature는 UI model을 command로 변환하고, 화면별 최초 query/API, 단일 화면만 소유하는 생성·mutation API, `ActivityResult` 수신, refresh/callback/projection 조합을 소유한다.
- 보호된 도메인 Action을 호출하는 화면은 같은 Access guard를 바깥에서 중복 실행하지 않는다. 공통 Access 직접 호출은 화면 진입 자체 또는 화면 전용 동작의 접근 제한에 사용한다.
- 도메인과 관련된 모든 코드를 이관하지 않는다. 둘 이상의 호출부에서 반복되거나 모든 진입점에서 같아야 하는 유효성·접근 정책·결과 계약만 도메인 Action으로 이관한다.
- 도메인 Action의 범위는 페이지 이동만이 아니다. 반복되는 사전 조건과 결과 계약은 Action이, 실제 Android 화면 이동은 Handler가 소유한다.
- parent-child Fragment composition, same-feature child flow, 도메인 Action이 없는 system-only route, Home on-air 목록 화면 진입, 단일 owner 기능은 합의된 반복 정책이 없으면 feature에 유지한다.
#### Initial Action Catalog #### Initial Action Catalog
| 소유자 | 단일 동작 후보 | 주요 호출 화면 | 공통 계약 방향 | | 소유자 | 단일 동작 후보 | 주요 호출 화면 | 공통 계약 방향 |
|---|---|---|---| |---|---|---|---|
@@ -119,10 +131,10 @@ Access 판단 결과를 기존 로그인, 본인인증, 설정 이동 UX에 연
| Content | 오디오 콘텐츠 상세 진입 | Home, Content, Content Overview, Creator Channel | `contentId`, 접근 정보 -> 진입/차단 결과 | | Content | 오디오 콘텐츠 상세 진입 | Home, Content, Content Overview, Creator Channel | `contentId`, 접근 정보 -> 진입/차단 결과 |
| Content | 시리즈 상세 진입 | Content, Creator Channel | `seriesId`, 접근 정보 -> 진입/차단 결과 | | Content | 시리즈 상세 진입 | Content, Creator Channel | `seriesId`, 접근 정보 -> 진입/차단 결과 |
| Live | 라이브 상세/입장 | Home, Creator Channel, On-air | `liveId` -> 상세/입장/비밀번호/결제/차단 결과 | | Live | 라이브 상세/입장 | Home, Creator Channel, On-air | `liveId` -> 상세/입장/비밀번호/결제/차단 결과 |
| Creator | 크리에이터 채널 진입 | Home, Content, Main, AI 캐릭터 route | `creatorId` -> 진입/무시 결과 | | Creator | 크리에이터 채널 진입 | Home, Content, Main, AI 캐릭터 route | `creatorId` -> 진입/차단/무시 결과 |
| Community | 커뮤니티 게시글 진입 | Home, Creator Channel | `postId` -> 진입/차단 결과 | | Community | 커뮤니티 게시글 진입 | Home, Creator Channel | `postId` -> 진입/차단/무시 결과 |
| Community | 게시글 작성/수정/삭제/고정 변경 결과 | Creator Channel Home/Community projection | mutation -> `CommunityChange` | | Community | 게시글 작성/수정/삭제/고정 변경 결과 | Creator Channel Home/Community projection | mutation -> `CommunityChange` |
| Chat | DM/채팅방 진입 | Home, Chat, Creator Channel | room/creator 식별자 -> 생성/진입/차단 결과 | | Chat | DM/채팅방 진입 | Home, Chat, Creator Channel | room/creator 식별자 -> 진입/차단/무시 결과 |
#### Result Propagation Policy #### Result Propagation Policy
- 하위 Activity의 완료 결과로 도메인 변경을 전달하는 기존 흐름은 `ActivityResultLauncher` 생명주기 계약을 유지한다. - 하위 Activity의 완료 결과로 도메인 변경을 전달하는 기존 흐름은 `ActivityResultLauncher` 생명주기 계약을 유지한다.
@@ -176,14 +188,16 @@ Access 판단 결과를 기존 로그인, 본인인증, 설정 이동 UX에 연
반복 navigation과 접근 조건을 각 소유 도메인의 단일 진입점으로 통합한다. 반복 navigation과 접근 조건을 각 소유 도메인의 단일 진입점으로 통합한다.
#### Requirements #### Requirements
- Creator Action은 유효한 `creatorId` 기준으로 Creator Channel 진입 제공한다. - Creator Action은 유효한 `creatorId``AccessRequirement.Login` 기준으로 Creator Channel 진입 또는 명시적 차단/무시 결과를 제공한다.
- Community Action은 로그인과 유효한 `postId` 확인 후 게시글 상세 진입을 제공한다. - Community Action은 로그인과 유효한 `postId` 확인 후 게시글 상세 진입을 제공한다.
- Community mutation 성공은 `CommunityChange.Created`, `Updated`, `Deleted`, `PinChanged`처럼 발생한 사실을 나타내는 명시적 결과로 표현한다. - Community mutation 성공은 `CommunityChange.Created`, `Updated`, `Deleted`, `PinChanged`처럼 발생한 사실을 나타내는 명시적 결과로 표현한다.
- 레거시 Community 작성/수정 Activity의 `ActivityResult.RESULT_OK``v2` adapter에서 `CommunityChange`로 변환한다. - 레거시 Community 작성/수정 Activity의 `ActivityResult.RESULT_OK``v2` adapter에서 `CommunityChange`로 변환한다.
- Community Action과 mutation 결과는 `refreshHome`, `refreshCommunityTab`처럼 호출 화면 구조를 나타내는 callback을 입력으로 받지 않는다. - Community Action과 mutation 결과는 `refreshHome`, `refreshCommunityTab`처럼 호출 화면 구조를 나타내는 callback을 입력으로 받지 않는다.
- Creator Channel은 `handleCommunityChange` 단일 composition 진입점에서 Community 변경 종류에 따른 Home/Community projection 갱신을 결정한다. - Creator Channel은 `handleCommunityChange` 단일 composition 진입점에서 Community 변경 종류에 따른 Home/Community projection 갱신을 결정한다.
- Community 작성, 수정, 삭제, 고정 변경의 성공 경로는 직접 개별 refresh를 호출하지 않고 `handleCommunityChange`를 사용한다. - Community 작성, 수정, 삭제, 고정 변경의 성공 경로는 직접 개별 refresh를 호출하지 않고 `handleCommunityChange`를 사용한다.
- Chat Action은 room 기반 진입과 creator 기반 DM 생성/진입의 차이를 명시적인 command로 구분한다. - Chat Action은 room 기반 진입과 creator 기반 DM/owner 목록 진입의 차이를 명시적인 command로 구분하고, 유효 ID 확인 후 `AccessRequirement.Login`을 적용한다.
- Creator Channel AI Chat의 `AccessRequirement.AdultContent` 사전 조건과 `createChatRoom(characterId)` API는 feature가 유지한다. 생성 성공 후 받은 room ID의 로그인 정책과 화면 이동은 Chat Action에 위임한다.
- Creator, Community, Chat의 보호된 Action 호출부는 같은 로그인 guard를 화면에서 중복 실행하지 않는다.
- 기존 owner/non-owner, AI/DM 채팅 타입 분기와 Activity result 계약을 유지한다. - 기존 owner/non-owner, AI/DM 채팅 타입 분기와 Activity result 계약을 유지한다.
- Chat/DM 하위 Activity 결과에 따라 호출 화면 후처리가 필요한 경우 raw result를 명시적 Chat 결과로 변환하고 호출 feature의 단일 handler에서 처리한다. - Chat/DM 하위 Activity 결과에 따라 호출 화면 후처리가 필요한 경우 raw result를 명시적 Chat 결과로 변환하고 호출 feature의 단일 handler에서 처리한다.
- Home의 UI model과 Creator Channel의 Response를 Action 공개 입력으로 사용하지 않는다. - Home의 UI model과 Creator Channel의 Response를 Action 공개 입력으로 사용하지 않는다.
@@ -199,28 +213,63 @@ Access 판단 결과를 기존 로그인, 본인인증, 설정 이동 UX에 연
공통 Action 도입 후 feature 간 직접 의존과 이전 중복 진입점을 정리한다. 공통 Action 도입 후 feature 간 직접 의존과 이전 중복 진입점을 정리한다.
#### Requirements #### Requirements
- feature는 공통 Access 또는 소유 도메인 Action 호출할 수 있다. - feature는 화면 진입 자체와 화면 전용 동작에는 공통 Access를 직접 호출할 수 있고, 합의된 보호 도메인 동작에는 소유 도메인 Action 호출다.
- 도메인 Action은 Home, Content Main, Creator Channel 같은 호출 feature를 import하지 않는다. - 도메인 Action은 Home, Content Main, Creator Channel 같은 호출 feature를 import하지 않는다.
- data 구현은 Retrofit DTO와 레거시 API를 알고, 도메인 정책은 Retrofit 및 Android UI를 알지 않도록 유지한다. - data 구현은 Retrofit DTO와 레거시 API를 알고, 도메인 정책은 Retrofit 및 Android UI를 알지 않도록 유지한다.
- Android UI가 필요한 navigation/Dialog wrapper는 정책과 분리된 application/presentation action으로 둔다. - Android UI가 필요한 navigation/Dialog wrapper는 정책과 분리된 application/presentation action으로 둔다.
- 직접적인 Activity 결과 흐름은 domain/application event로 우회하지 않고 `ActivityResult -> 명시적 결과 -> feature handler` 의존 방향을 유지한다. - 직접적인 Activity 결과 흐름은 domain/application event로 우회하지 않고 `ActivityResult -> 명시적 결과 -> feature handler` 의존 방향을 유지한다.
- `AppDI.kt`새 계약과 구현을 조립하되 레거시 등록을 불필요하게 변경하지 않는다. - `AppDI.kt`실제 외부 의존성, 공유 생명주기 또는 구현 교체가 필요한 계약만 조립한다. 상태 없는 Action/Handler는 호출 경계에서 직접 조합할 수 있으며 DI 등록 자체를 완료 조건으로 삼지 않는다.
- 모든 호출부 전환이 끝난 이전 helper와 중복 함수만 제거한다. - 모든 호출부 전환이 끝난 이전 helper와 중복 함수만 제거한다.
- package 이동은 Action 전환 후 필요성이 확인된 파일에 한해 별도 Task로 수행한다. - package 이동은 Action 전환 후 필요성이 확인된 파일에 한해 별도 Task로 수행한다.
- feature 간 직접 Activity/Coordinator 의존은 합의된 반복 navigation/정책 대상만 Action/Handler로 대체한다. parent-child composition, same-feature child flow, system/deeplink/notification route, 목록 화면 진입과 단일 owner 기능은 예외 근거를 기록하고 유지할 수 있다.
### Feature H: 푸시/딥링크의 도메인 Action 연결
푸시와 딥링크 payload 해석은 기존 진입점에 유지하고, 해석된 도메인 command의 실행은 이미 분리된 Action과 Live 진입 정책에 위임한다.
#### Requirements
- FCM 알림과 앱 내 알림 목록은 기존처럼 `DeepLinkActivity`로 진입하며, 별도의 범용 `PushRouteAction`이나 EventBus를 추가하지 않는다.
- `DeepLinkActivity`가 foreground에서 직접 처리하는 오디오, 시리즈, 크리에이터, 커뮤니티 게시글, DM 이동은 각각 Content, Creator, Community, Chat Action을 사용한다.
- `DeepLinkActivity`에서 Main으로 전달한 payload와 앱 cold start payload는 `MainV2Activity`가 같은 도메인 Action으로 처리한다.
- 채팅 deep-link 값과 유효한 room ID가 함께 있으면 DM Action을 먼저 실행하고, 그 외 유효한 room ID는 channel/content ID보다 먼저 Live 진입으로 처리한다.
- Live 진입은 `LiveActionCoordinator.enterLiveRoom(roomId)`를 사용한다. 조회 결과 현재 진행 중인 라이브이면 기존 무료/유료/비밀번호 정책에 따라 입장하고, 예약 또는 즉시 입장할 channel 정보가 없으면 레거시 Live Detail 화면을 표시한다.
- Community payload에 유효한 `postId`가 있으면 creator ID보다 우선하여 `CommunityActionCommand.PostDetail(postId)`를 실행한다.
- Community payload의 `postId`는 서버 실제 사용상 항상 전달되는 값으로 보되 선택적 계약은 유지한다. 유효한 `postId`가 없고 creator ID가 있으면 `CreatorActionCommand.Profile(creatorId)`로 Creator Channel을 표시한다.
- Community 딥링크의 `deep_link_sub5``${URISCHEME}://community/{id}` path ID는 기존 계약의 `creatorId`다. `postId`는 query/canonical extra로 별도 정규화하므로, `routeByDeepLinkValue("community")`는 post ID가 없는 레거시 Creator fallback으로 유지한다.
- 현재 DM 푸시 계약은 `${URISCHEME}://chat/{roomId}`이며 Chat Action의 `DmRoom`으로 처리한다. 기존 `message`/`message_id`는 DM room ID로 재해석하지 않고 과거 알림 수신 호환용 legacy Message route로만 유지한다.
- audio detail notification은 Content Action을 사용하고, 도메인 상세 이동이 아닌 audio player 화면 route는 기존 Access/system route를 유지한다.
- 현재 서버에서 더 이상 발행하지 않는 legacy message, audition, payment callback처럼 대응 도메인 Action이 없는 system-only route는 기존 직접 처리를 유지한다.
- `LiveRoomActivity`가 foreground일 때 broadcast로 payload를 전달하는 기존 특수 경로와 `Intent` flag/extra 계약을 유지한다.
- `MainV2Activity` cold start에서 `Constants.EXTRA_DATA` 또는 audio notification route가 있으면 기존 1초 지연 처리 동안 `LoadingDialog`를 문구 없이 표시한다.
- Live route는 공통 1초 대기 종료 후에도 room detail 조회가 진행 중이면 같은 문구 없는 로딩을 유지한다.
- Live 상태 조회가 완료되거나 실패하면 로딩을 해제하고, `LiveViewModel.toastLiveData`의 실패 메시지를 기존 Toast 방식으로 표시한다.
- Content, Creator, Community, Chat처럼 목적지 판단 자체에 별도 네트워크 조회가 없는 route는 공통 1초 대기가 끝나면 로딩을 해제하고 목적지로 이동한다.
- `onNewIntent`처럼 기존 1초 지연이 없는 route에 인위적인 공통 대기를 추가하지 않는다. 단, Live의 실제 room detail 조회 로딩은 동일하게 표시한다.
#### Edge Cases
- 0 이하이거나 파싱할 수 없는 ID는 기존처럼 이동하지 않는다.
- Live payload에 room ID와 channel ID가 모두 있어도 room ID를 우선해 Creator Channel로 잘못 이동하지 않는다.
- Community payload에 post ID와 creator ID가 모두 있어도 게시글 상세를 우선한다.
- Community post ID가 없거나 유효하지 않은 경우에만 creator ID fallback을 사용한다.
- `DeepLinkActivity`가 Main으로 Live payload를 전달하는 경우 cold start와 `onNewIntent` 모두 동일한 Live Action 진입점을 사용한다.
- 일반 앱 실행에는 공통 딥링크 로딩을 표시하지 않는다.
- 공통 1초 대기가 끝나는 시점에 Live room detail 조회가 진행 중이면 로딩을 중간에 해제하지 않는다.
- Live 상태 조회가 실패해 목적지로 이동하지 못해도 로딩이 남아 있지 않고 실패 안내가 표시된다.
--- ---
## 8. UX / UI Expectations ## 8. UX / UI Expectations
- 로그인, 본인인증, 성인 콘텐츠 설정 안내의 표시 순서와 문구를 유지한다. - 로그인, 본인인증, 성인 콘텐츠 설정 안내의 표시 순서와 문구를 유지한다.
- 허용된 사용자의 콘텐츠, 라이브, 커뮤니티, 채팅 진입 결과는 기존과 같아야 한다. - 허용된 사용자의 콘텐츠, 라이브, 커뮤니티, 채팅 진입 결과는 기존과 같아야 한다.
- 푸시/딥링크에서도 일반 화면과 같은 도메인 접근 정책을 적용하며, 진행 중 Live와 예약 Live의 기존 입장/상세 UX를 유지한다.
- cold start 푸시/딥링크의 공통 1초 대기와 Live의 추가 상태 조회 중에는 도메인에 종속되지 않은 문구 없는 spinner를 표시한다.
- 기존 Dialog 크기, Activity flag, Intent extra, Activity result 처리와 화면 새로고침 동작을 유지한다. - 기존 Dialog 크기, Activity flag, Intent extra, Activity result 처리와 화면 새로고침 동작을 유지한다.
- 리팩토링 자체로 신규 화면, 버튼, Toast, loading UI를 추가하지 않는다. - 리팩토링 자체로 신규 화면, 버튼, Toast, loading UI를 추가하지 않는다.
--- ---
## 9. Technical Constraints ## 9. Technical Constraints
- 변경 범위는 `app/src/main/java/kr/co/vividnext/sodalive/v2`, 대응 `app/src/test/.../v2`, `AppDI.kt`, 본 작업 문서로 제한한다. - 변경 범위는 `app/src/main/java/kr/co/vividnext/sodalive/v2`, 대응 테스트, 사용자 승인을 받은 `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`, 필요한 경우 이를 조립하는 `AppDI.kt`, 본 작업 문서로 제한한다.
- 레거시 파일은 직접 수정하지 않고 기존 기능을 호출하는 `v2` wrapper/adapter를 작성한다. - 레거시 파일은 직접 수정하지 않고 기존 기능을 호출하는 `v2` wrapper/adapter를 작성한다. 이번 후속 범위에서는 V2 진입점으로 유지할 `DeepLinkActivity`만 명시적 예외다.
- API -> Repository -> ViewModel -> Activity/Fragment의 기존 흐름을 임의로 깨지 않는다. - API -> Repository -> ViewModel -> Activity/Fragment의 기존 흐름을 임의로 깨지 않는다.
- 신규 테스트는 Access 판단, Action 입력·출력, route/정책 같은 순수 로직을 우선 검증한다. - 신규 테스트는 Access 판단, Action 입력·출력, route/정책 같은 순수 로직을 우선 검증한다.
- 소스 문자열 테스트만으로 정책을 검증하지 않고, 가능한 범위에서 실제 입력·출력 단위 테스트를 추가한다. - 소스 문자열 테스트만으로 정책을 검증하지 않고, 가능한 범위에서 실제 입력·출력 단위 테스트를 추가한다.
@@ -238,9 +287,14 @@ Access 판단 결과를 기존 로그인, 본인인증, 설정 이동 UX에 연
- `CreatorChannelActivity``HomeOnAirLiveActivity`에 화면 전용 `ensureLoginAndAdultAuth` 구현이 남지 않는다. - `CreatorChannelActivity``HomeOnAirLiveActivity`에 화면 전용 `ensureLoginAndAdultAuth` 구현이 남지 않는다.
- 대상 UI 호출부에서 `SharedPreferenceManager.token`으로 개별 행동 접근을 판단하지 않는다. - 대상 UI 호출부에서 `SharedPreferenceManager.token`으로 개별 행동 접근을 판단하지 않는다.
- 오디오, 시리즈, 라이브, 크리에이터, 커뮤니티, 채팅의 합의된 호출부가 각각 단일 Action 진입점을 사용한다. - 오디오, 시리즈, 라이브, 크리에이터, 커뮤니티, 채팅의 합의된 호출부가 각각 단일 Action 진입점을 사용한다.
- 보호된 Creator, Community, Chat Action 호출부에 동일한 화면-local 로그인 guard가 중복되지 않는다.
- Creator Channel의 Community 작성, 수정, 삭제, 고정 변경 성공 경로가 명시적 `CommunityChange`를 거쳐 하나의 composition handler에서 projection을 갱신한다. - Creator Channel의 Community 작성, 수정, 삭제, 고정 변경 성공 경로가 명시적 `CommunityChange`를 거쳐 하나의 composition handler에서 projection을 갱신한다.
- 공통 Action 공개 계약이 feature 전용 UI model 또는 DTO에 의존하지 않는다. - 공통 Action 공개 계약이 feature 전용 UI model 또는 DTO에 의존하지 않는다.
- 기존 Intent extra, Activity result, 로그인/인증/설정 UX를 검증하는 회귀 테스트가 통과한다. - 기존 Intent extra, Activity result, 로그인/인증/설정 UX를 검증하는 회귀 테스트가 통과한다.
- 푸시/딥링크의 Content, Creator, Community, Chat 이동은 해당 Action을 사용하고 Live 이동은 `LiveActionCoordinator`를 사용한다.
- room ID가 있는 Live payload는 channel ID보다 우선하고, Community post ID는 creator ID보다 우선한다.
- Main의 cold start 푸시/딥링크 공통 1초 대기 중 문구 없는 로딩이 표시되고, Live는 추가 상태 조회까지 끊김 없이 유지되며 성공·실패 종료 시 해제된다.
- Live 상태 조회 실패 메시지가 사용자에게 노출된다.
- 각 Phase 완료 시 중복 제거 전후 호출부 목록과 검증 결과가 `plan-task.md`에 누적 기록된다. - 각 Phase 완료 시 중복 제거 전후 호출부 목록과 검증 결과가 `plan-task.md`에 누적 기록된다.
--- ---

View File

@@ -96,6 +96,17 @@ Live Action Phase 테스트 예시:
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinatorUiGateTest" ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinatorUiGateTest"
``` ```
Creator/Community Action Phase 테스트 예시:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.action.*"
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.community.action.*"
```
Chat Action Phase 테스트 예시:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.chat.action.*"
```
참고: 참고:
- 신규 Kotlin 테스트 메서드는 backtick 함수명을 사용하고, 테스트명은 한글 문장으로 작성한다. - 신규 Kotlin 테스트 메서드는 backtick 함수명을 사용하고, 테스트명은 한글 문장으로 작성한다.
- 기존 영어 테스트명을 수정하지 않는 최소 변경 상황을 제외하고, 새 테스트명에 영어 문장을 사용하지 않는다. - 기존 영어 테스트명을 수정하지 않는 최소 변경 상황을 제외하고, 새 테스트명에 영어 문장을 사용하지 않는다.