Compare commits

...

7 Commits

15 changed files with 258 additions and 164 deletions

View File

@@ -116,8 +116,6 @@ android {
// release용 ad unit id는 배포 전 실제 값으로 교체한다. // release용 ad unit id는 배포 전 실제 값으로 교체한다.
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID', '"R-M-19140295-3"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID', '"R-M-19140295-3"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID', '"R-M-19140295-4"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID', '"R-M-19140295-4"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID', '"R-M-19140295-5"'
buildConfigField 'String', 'YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID', '"R-M-19140295-6"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_CREATOR_COMMUNITY_ALL_AD_UNIT_ID', '"R-M-19140295-7"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_CREATOR_COMMUNITY_ALL_AD_UNIT_ID', '"R-M-19140295-7"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_HOME_AD_UNIT_ID', '"R-M-19140295-8"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_HOME_AD_UNIT_ID', '"R-M-19140295-8"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_DAY_OF_WEEK_AD_UNIT_ID', '"R-M-19140295-9"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_DAY_OF_WEEK_AD_UNIT_ID', '"R-M-19140295-9"'
@@ -160,8 +158,6 @@ android {
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID', '"R-M-19140297-3"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID', '"R-M-19140297-3"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID', '"R-M-19140297-4"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID', '"R-M-19140297-4"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID', '"R-M-19140297-5"'
buildConfigField 'String', 'YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID', '"R-M-19140297-6"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_CREATOR_COMMUNITY_ALL_AD_UNIT_ID', '"R-M-19140297-7"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_CREATOR_COMMUNITY_ALL_AD_UNIT_ID', '"R-M-19140297-7"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_HOME_AD_UNIT_ID', '"R-M-19140297-8"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_HOME_AD_UNIT_ID', '"R-M-19140297-8"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_DAY_OF_WEEK_AD_UNIT_ID', '"R-M-19140297-9"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_DAY_OF_WEEK_AD_UNIT_ID', '"R-M-19140297-9"'

View File

@@ -28,18 +28,6 @@ import coil.transform.RoundedCornersTransformation
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.google.gson.Gson import com.google.gson.Gson
import com.orhanobut.logger.Logger
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdError
import com.yandex.mobile.ads.common.AdRequest
import com.yandex.mobile.ads.common.AdRequestConfiguration
import com.yandex.mobile.ads.common.AdRequestError
import com.yandex.mobile.ads.common.ImpressionData
import com.yandex.mobile.ads.interstitial.InterstitialAd
import com.yandex.mobile.ads.interstitial.InterstitialAdEventListener
import com.yandex.mobile.ads.interstitial.InterstitialAdLoadListener
import com.yandex.mobile.ads.interstitial.InterstitialAdLoader
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.PurchaseOption import kr.co.vividnext.sodalive.audio_content.PurchaseOption
@@ -72,7 +60,6 @@ import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.roundToInt
@UnstableApi @UnstableApi
class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBinding>( class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBinding>(
@@ -101,42 +88,9 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
private lateinit var orderType: OrderType private lateinit var orderType: OrderType
private lateinit var imm: InputMethodManager private lateinit var imm: InputMethodManager
private var audioContentPlayInterstitialAdLoader: InterstitialAdLoader? = null
private var audioContentPlayInterstitialAd: InterstitialAd? = null
private var pendingAudioContentPlayAction: (() -> Unit)? = null
private var hasConsumedAudioContentPlayInterstitialAttempt = false
private var isAudioContentPlaying = false private var isAudioContentPlaying = false
private var isAudioContentInterstitialEligible = false
private var audioContentStartPlaybackAction: (() -> Unit)? = null private var audioContentStartPlaybackAction: (() -> Unit)? = null
private val audioContentPlayInterstitialAdLoadListener = object : InterstitialAdLoadListener {
override fun onAdLoaded(interstitialAd: InterstitialAd) {
clearAudioContentPlayInterstitialAd()
audioContentPlayInterstitialAd = interstitialAd
}
override fun onAdFailedToLoad(error: AdRequestError) {
Logger.e("Audio content interstitial failed to load: ${error.description}")
}
}
private val audioContentPlayInterstitialAdEventListener = object : InterstitialAdEventListener {
override fun onAdShown() = Unit
override fun onAdFailedToShow(adError: AdError) {
Logger.e("Audio content interstitial failed to show: ${adError.description}")
continuePendingAudioContentPlayAction()
}
override fun onAdDismissed() {
continuePendingAudioContentPlayAction()
}
override fun onAdClicked() = Unit
override fun onAdImpression(impressionData: ImpressionData?) = Unit
}
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
@@ -154,9 +108,6 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
binding.rlPreviewAlert.visibility = View.GONE binding.rlPreviewAlert.visibility = View.GONE
audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0) audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
hasConsumedAudioContentPlayInterstitialAttempt = false
releaseAudioContentPlayInterstitial()
setupAudioContentPlayInterstitial()
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() } viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
} }
@@ -367,91 +318,9 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
dialog.show(screenWidth - 26.7f.dpToPx().toInt()) dialog.show(screenWidth - 26.7f.dpToPx().toInt())
} }
setupAudioContentDetailInlineBanner()
setupAudioContentPlayInterstitial()
setupBuyerList() setupBuyerList()
} }
private fun setupAudioContentDetailInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
this@AudioContentDetailActivity,
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupAudioContentPlayInterstitial() {
val adUnitId = BuildConfig.YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID
if (adUnitId.isBlank()) {
Logger.e("Audio content interstitial blocked: ad unit id is blank.")
return
}
audioContentPlayInterstitialAdLoader = InterstitialAdLoader(this).apply {
setAdLoadListener(audioContentPlayInterstitialAdLoadListener)
}
audioContentPlayInterstitialAdLoader?.loadAd(
AdRequestConfiguration.Builder(adUnitId).build()
)
}
private fun playAudioContentWithInterstitialIfAvailable(playAction: () -> Unit) {
if (hasConsumedAudioContentPlayInterstitialAttempt || isFinishing || isDestroyed) {
playAction()
return
}
val interstitialAd = audioContentPlayInterstitialAd ?: run {
playAction()
return
}
hasConsumedAudioContentPlayInterstitialAttempt = true
pendingAudioContentPlayAction = playAction
interstitialAd.setAdEventListener(audioContentPlayInterstitialAdEventListener)
runCatching {
interstitialAd.show(this)
}.onFailure {
Logger.e("Audio content interstitial failed to show: ${it.message}")
continuePendingAudioContentPlayAction()
}
}
private fun continuePendingAudioContentPlayAction() {
val playAction = pendingAudioContentPlayAction
pendingAudioContentPlayAction = null
clearAudioContentPlayInterstitialAd()
if (isFinishing || isDestroyed) {
return
}
playAction?.invoke()
}
private fun clearAudioContentPlayInterstitialAd() {
audioContentPlayInterstitialAd?.setAdEventListener(null)
audioContentPlayInterstitialAd = null
}
private fun releaseAudioContentPlayInterstitial() {
audioContentPlayInterstitialAdLoader?.setAdLoadListener(null)
audioContentPlayInterstitialAdLoader = null
pendingAudioContentPlayAction = null
clearAudioContentPlayInterstitialAd()
}
private fun pauseAudioContentPlayback() { private fun pauseAudioContentPlayback() {
startService( startService(
Intent(this, AudioContentPlayService::class.java).apply { Intent(this, AudioContentPlayService::class.java).apply {
@@ -474,13 +343,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play) binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
val startPlaybackClickListener = View.OnClickListener { val startPlaybackClickListener = View.OnClickListener { startPlaybackAction() }
if (isAudioContentInterstitialEligible) {
playAudioContentWithInterstitialIfAvailable(startPlaybackAction)
} else {
startPlaybackAction()
}
}
binding.ivPlayOrPause.setOnClickListener(startPlaybackClickListener) binding.ivPlayOrPause.setOnClickListener(startPlaybackClickListener)
binding.llPreview.setOnClickListener(startPlaybackClickListener) binding.llPreview.setOnClickListener(startPlaybackClickListener)
@@ -944,12 +807,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
response.duration response.duration
) )
audioContentStartPlaybackAction = null audioContentStartPlaybackAction = null
isAudioContentInterstitialEligible = false
isAlertPreview = response.creator.creatorId != SharedPreferenceManager.userId && isAlertPreview = response.creator.creatorId != SharedPreferenceManager.userId &&
!response.existOrdered && !response.existOrdered &&
response.price > 0 response.price > 0
isAudioContentInterstitialEligible = response.price <= 0 || isAlertPreview
if ( if (
response.creator.creatorId != SharedPreferenceManager.userId && !response.existOrdered && response.creator.creatorId != SharedPreferenceManager.userId && !response.existOrdered &&
@@ -1046,7 +907,6 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} }
} else if (response.releaseDate == null) { } else if (response.releaseDate == null) {
audioContentStartPlaybackAction = null audioContentStartPlaybackAction = null
isAudioContentInterstitialEligible = false
binding.llPreviewNo.visibility = View.VISIBLE binding.llPreviewNo.visibility = View.VISIBLE
} }
@@ -1366,8 +1226,6 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} }
override fun onDestroy() { override fun onDestroy() {
binding.yandexInlineBannerView.destroy()
releaseAudioContentPlayInterstitial()
super.onDestroy() super.onDestroy()
} }

View File

@@ -180,6 +180,12 @@ class CreatorChannelActivity :
return return
} }
if (SharedPreferenceManager.token.isBlank()) {
showLoginActivity()
finish()
return
}
setupTabsAndPager() setupTabsAndPager()
setStatusBarIconAppearance() setStatusBarIconAppearance()
setTitleBarTopInset() setTitleBarTopInset()
@@ -221,7 +227,9 @@ class CreatorChannelActivity :
binding.btnCreatorChannelFantalkFixedPlus.setOnClickListener { openFanTalkWrite() } binding.btnCreatorChannelFantalkFixedPlus.setOnClickListener { openFanTalkWrite() }
binding.btnCreatorChannelOwnerCta.setOnClickListener { onOwnerCtaClicked() } binding.btnCreatorChannelOwnerCta.setOnClickListener { onOwnerCtaClicked() }
binding.tvChatButton.setOnClickListener { binding.tvChatButton.setOnClickListener {
currentHeader?.characterId?.let { characterId -> homeActionDelegate?.createChatRoom(characterId) } ensureLoginAndAdultAuth(isAdult = true) {
currentHeader?.characterId?.let { characterId -> homeActionDelegate?.createChatRoom(characterId) }
}
} }
binding.tvDmButton.setOnClickListener { binding.tvDmButton.setOnClickListener {
if (currentHeader?.isOwner == true) { if (currentHeader?.isOwner == true) {

View File

@@ -313,9 +313,9 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
homeFollowingViewModel.followingStateLiveData.observe(viewLifecycleOwner) { state -> homeFollowingViewModel.followingStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) { when (state) {
is HomeFollowingUiState.Content -> bindHomeFollowingContent(state) is HomeFollowingUiState.Content -> bindHomeFollowingContent(state)
HomeFollowingUiState.Empty -> bindHomeFollowingEmpty(showEmptyMessage = true)
HomeFollowingUiState.LoginRequired, HomeFollowingUiState.LoginRequired,
HomeFollowingUiState.Empty, is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false)
is HomeFollowingUiState.Error -> bindHomeFollowingEmpty()
HomeFollowingUiState.Loading -> Unit HomeFollowingUiState.Loading -> Unit
} }
@@ -378,6 +378,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
} }
private fun bindHomeFollowingContent(content: HomeFollowingUiState.Content) { private fun bindHomeFollowingContent(content: HomeFollowingUiState.Content) {
binding.tvHomeFollowingEmpty.visibility = View.GONE
bindFollowingCreatorSection(content.followingCreators) bindFollowingCreatorSection(content.followingCreators)
bindFollowingLiveSection(content.onAirLives) bindFollowingLiveSection(content.onAirLives)
bindFollowingChatSection(content.recentChats) bindFollowingChatSection(content.recentChats)
@@ -385,7 +386,8 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
bindFollowingNewsSection(content.recentNews) bindFollowingNewsSection(content.recentNews)
} }
private fun bindHomeFollowingEmpty() { private fun bindHomeFollowingEmpty(showEmptyMessage: Boolean) {
binding.tvHomeFollowingEmpty.visibility = if (showEmptyMessage) View.VISIBLE else View.GONE
binding.llHomeFollowingCreatorsSection.visibility = View.GONE binding.llHomeFollowingCreatorsSection.visibility = View.GONE
binding.llHomeFollowingOnAirSection.visibility = View.GONE binding.llHomeFollowingOnAirSection.visibility = View.GONE
binding.llHomeFollowingRecentChatsSection.visibility = View.GONE binding.llHomeFollowingRecentChatsSection.visibility = View.GONE

View File

@@ -396,14 +396,6 @@
android:visibility="gone" /> android:visibility="gone" />
</LinearLayout> </LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="90dp"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="13.3dp" />
<RelativeLayout <RelativeLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -236,6 +236,17 @@
android:orientation="vertical" android:orientation="vertical"
android:paddingBottom="@dimen/spacing_28"> android:paddingBottom="@dimen/spacing_28">
<TextView
android:id="@+id/tv_home_following_empty"
style="@style/Typography.Body3"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/screen_home_following_empty"
android:textAlignment="center"
android:textColor="@color/gray_500"
android:visibility="gone" />
<LinearLayout <LinearLayout
android:id="@+id/ll_home_following_creators_section" android:id="@+id/ll_home_following_creators_section"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -318,7 +318,7 @@
<string name="screen_home_following_photo_content">Photo</string> <string name="screen_home_following_photo_content">Photo</string>
<string name="screen_home_following_creator_ranking_news_message">%1$s reached %2$s in this week\'s ranking!</string> <string name="screen_home_following_creator_ranking_news_message">%1$s reached %2$s in this week\'s ranking!</string>
<string name="screen_home_following_content_ranking_news_message">%1$s reached %2$s in this week\'s ranking!</string> <string name="screen_home_following_content_ranking_news_message">%1$s reached %2$s in this week\'s ranking!</string>
<string name="screen_home_following_empty">No following news to show.</string> <string name="screen_home_following_empty">No following updates yet.\nFollow creators you are interested in.</string>
<string name="screen_home_following_error">Could not load following news.</string> <string name="screen_home_following_error">Could not load following news.</string>
<string name="home_recommendation_activity_live">Live</string> <string name="home_recommendation_activity_live">Live</string>
<string name="home_recommendation_activity_audio">Audio</string> <string name="home_recommendation_activity_audio">Audio</string>

View File

@@ -318,7 +318,7 @@
<string name="screen_home_following_photo_content">グラビア</string> <string name="screen_home_following_photo_content">グラビア</string>
<string name="screen_home_following_creator_ranking_news_message">%1$sさんが今週のランキング%2$sに入りました</string> <string name="screen_home_following_creator_ranking_news_message">%1$sさんが今週のランキング%2$sに入りました</string>
<string name="screen_home_following_content_ranking_news_message">%1$sが今週のランキング%2$sに入りました</string> <string name="screen_home_following_content_ranking_news_message">%1$sが今週のランキング%2$sに入りました</string>
<string name="screen_home_following_empty">表示できるフォロー中のお知らせありません。</string> <string name="screen_home_following_empty">フォロー中のお知らせはまだありません。\n気になるクリエイターをフォローしてみましょう。</string>
<string name="screen_home_following_error">フォロー中のお知らせを読み込めませんでした。</string> <string name="screen_home_following_error">フォロー中のお知らせを読み込めませんでした。</string>
<string name="home_recommendation_activity_live">ライブ</string> <string name="home_recommendation_activity_live">ライブ</string>
<string name="home_recommendation_activity_audio">オーディオ</string> <string name="home_recommendation_activity_audio">オーディオ</string>

View File

@@ -317,7 +317,7 @@
<string name="screen_home_following_photo_content">화보</string> <string name="screen_home_following_photo_content">화보</string>
<string name="screen_home_following_creator_ranking_news_message">%1$s님이 이번 주 랭킹 %2$s에 올랐어요!</string> <string name="screen_home_following_creator_ranking_news_message">%1$s님이 이번 주 랭킹 %2$s에 올랐어요!</string>
<string name="screen_home_following_content_ranking_news_message">%1$s 콘텐츠가 이번 주 랭킹 %2$s에 올랐어요!</string> <string name="screen_home_following_content_ranking_news_message">%1$s 콘텐츠가 이번 주 랭킹 %2$s에 올랐어요!</string>
<string name="screen_home_following_empty">표시할 팔로잉 소식이 없습니다.</string> <string name="screen_home_following_empty">아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.</string>
<string name="screen_home_following_error">팔로잉 소식을 불러오지 못했습니다.</string> <string name="screen_home_following_error">팔로잉 소식을 불러오지 못했습니다.</string>
<string name="home_recommendation_activity_live">라이브</string> <string name="home_recommendation_activity_live">라이브</string>
<string name="home_recommendation_activity_audio">오디오</string> <string name="home_recommendation_activity_audio">오디오</string>

View File

@@ -82,9 +82,9 @@ class HomeFollowingFragmentSourceTest {
assertTrue(source.contains("followingStateLiveData.observe(viewLifecycleOwner)")) assertTrue(source.contains("followingStateLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("is HomeFollowingUiState.Content -> bindHomeFollowingContent(state)")) assertTrue(source.contains("is HomeFollowingUiState.Content -> bindHomeFollowingContent(state)"))
assertTrue(source.contains("HomeFollowingUiState.Empty -> bindHomeFollowingEmpty(showEmptyMessage = true)"))
assertTrue(source.contains("HomeFollowingUiState.LoginRequired,")) assertTrue(source.contains("HomeFollowingUiState.LoginRequired,"))
assertTrue(source.contains("HomeFollowingUiState.Empty,")) assertTrue(source.contains("is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false)"))
assertTrue(source.contains("is HomeFollowingUiState.Error -> bindHomeFollowingEmpty()"))
assertTrue(source.contains("followingCreatorAdapter.submitItems(emptyList())")) assertTrue(source.contains("followingCreatorAdapter.submitItems(emptyList())"))
assertTrue(source.contains("followingLiveAdapter.submitItems(emptyList())")) assertTrue(source.contains("followingLiveAdapter.submitItems(emptyList())"))
assertTrue(source.contains("followingChatAdapter.submitItems(emptyList())")) assertTrue(source.contains("followingChatAdapter.submitItems(emptyList())"))
@@ -97,6 +97,34 @@ class HomeFollowingFragmentSourceTest {
assertTrue(source.contains("bindFollowingNewsSection(content.recentNews)")) assertTrue(source.contains("bindFollowingNewsSection(content.recentNews)"))
} }
@Test
fun `following empty state shows centered reusable empty string only for all empty content`() {
val layout = homeMainLayoutSource()
val emptyText = layout.substringAfter("@+id/tv_home_following_empty")
.substringBefore("@+id/ll_home_following_creators_section")
val source = homeMainFragmentSource()
val strings = projectFile("app/src/main/res/values/strings.xml").readText()
val stringsEn = projectFile("app/src/main/res/values-en/strings.xml").readText()
val stringsJa = projectFile("app/src/main/res/values-ja/strings.xml").readText()
assertTrue(layout.contains("@+id/tv_home_following_empty"))
assertTrue(emptyText.contains("android:layout_width=\"match_parent\""))
assertTrue(emptyText.contains("android:layout_height=\"match_parent\""))
assertTrue(emptyText.contains("android:gravity=\"center\""))
assertTrue(emptyText.contains("android:textAlignment=\"center\""))
assertTrue(emptyText.contains("android:text=\"@string/screen_home_following_empty\""))
assertTrue(emptyText.contains("android:visibility=\"gone\""))
assertTrue(strings.contains("<string name=\"screen_home_following_empty\">아직 팔로잉 소식이 없어요.\\n관심 있는 크리에이터를 팔로우해 보세요.</string>"))
assertTrue(stringsEn.contains("<string name=\"screen_home_following_empty\">No following updates yet.\\nFollow creators you are interested in.</string>"))
assertTrue(stringsJa.contains("<string name=\"screen_home_following_empty\">フォロー中のお知らせはまだありません。\\n気になるクリエイターをフォローしてみましょう。</string>"))
assertTrue(source.contains("binding.tvHomeFollowingEmpty.visibility = View.GONE"))
assertTrue(source.contains("binding.tvHomeFollowingEmpty.visibility = if (showEmptyMessage) View.VISIBLE else View.GONE"))
assertTrue(source.contains("HomeFollowingUiState.Empty -> bindHomeFollowingEmpty(showEmptyMessage = true)"))
assertTrue(source.contains("is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false)"))
}
@Test @Test
fun `following adapters bind figma required item fields`() { fun `following adapters bind figma required item fields`() {
val liveAdapter = projectFile( val liveAdapter = projectFile(

View File

@@ -780,7 +780,59 @@
--- ---
### Phase 12: 전체 empty 문구 중앙 표시
- [x] **Task 12.1: 문서와 source test로 empty 문구 계약 고정**
- 수정:
- `docs/20260625_메인_홈_팔로잉_탭/prd.md`
- `docs/20260625_메인_홈_팔로잉_탭/plan-task.md`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt`
- 작업:
- 기존 `screen_home_following_empty` string resource를 재사용한다는 계약을 문서화한다.
- `fragment_v2_main_home.xml``tv_home_following_empty`가 있고 `@string/screen_home_following_empty`를 사용하는지 source test로 고정한다.
- empty `TextView`가 팔로잉 탭 전체 영역에서 가운데 배치되고 텍스트도 가운데 정렬되는지 source test로 고정한다.
- `HomeFollowingUiState.Empty`는 empty 문구를 표시하고, `Content`, `LoginRequired`, `Error`는 숨김 경로를 갖는지 source test로 고정한다.
- 검증:
- Run: `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeFollowingFragmentSourceTest"`
- Expected RED: 구현 전 `tv_home_following_empty`와 empty visibility 분기 부재로 실패한다.
- 검증 기록:
- 2026-07-13: 기존 `screen_home_following_empty` string resource를 재사용해 전체 empty 문구를 표시하는 요구사항을 PRD와 계획 문서에 추가했다. `HomeFollowingFragmentSourceTest``tv_home_following_empty` id, string 재사용, 중앙 정렬 속성, `HomeFollowingUiState.Empty` 전용 표시 분기를 고정하는 source test를 추가했다.
- [x] **Task 12.2: 다국어 string과 empty UI 구현**
- 수정:
- `app/src/main/res/values/strings.xml`
- `app/src/main/res/values-en/strings.xml`
- `app/src/main/res/values-ja/strings.xml`
- `app/src/main/res/layout/fragment_v2_main_home.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- 작업:
- 기존 `screen_home_following_empty` 값을 한국어/영어/일본어 문구로 갱신한다.
- 팔로잉 탭 content 영역에 `tv_home_following_empty`를 추가하고 중앙 배치/중앙 정렬한다.
- `HomeFollowingUiState.Empty`에서 empty 문구를 표시하고 섹션 adapter는 비운다.
- `HomeFollowingUiState.Content`, `LoginRequired`, `Error`에서는 empty 문구를 숨긴다.
- 검증:
- Run: `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeFollowingFragmentSourceTest"`
- Expected GREEN: source test PASS.
- 검증 기록:
- 2026-07-13: `values`, `values-en`, `values-ja`의 기존 `screen_home_following_empty` 값을 한국어/영어/일본어 2줄 문구로 갱신했다. `fragment_v2_main_home.xml``tv_home_following_empty`를 추가하고 `@string/screen_home_following_empty`, `match_parent`, `gravity=center`, `textAlignment=center`, 기본 `gone`을 적용했다. `HomeMainFragment`에서는 `HomeFollowingUiState.Empty`만 empty 문구를 표시하고, `Content`, `LoginRequired`, `Error`는 숨기도록 분기했다.
- [x] **Task 12.3: empty 문구 회귀 검증 기록**
- 검증:
- Run: `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`
- Expected: 팔로잉 관련 local unit/source test PASS.
- Run: `./gradlew --no-daemon :app:mergeDebugResources`
- Expected: string/layout resource merge PASS.
- Run: `./gradlew --no-daemon :app:compileDebugKotlin`
- Expected: Kotlin compile PASS.
- Run: `git diff --check`
- Expected: whitespace error 없음.
- 검증 기록:
- 2026-07-13: `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew --no-daemon :app:mergeDebugResources`, `./gradlew --no-daemon :app:compileDebugKotlin`, `git diff --check` 모두 PASS. Gradle 실행 중 기존 deprecation warning만 출력됐다.
---
## Verification Log ## Verification Log
- 2026-07-13 Phase 12 전체 empty 문구 중앙 표시 검증: 팔로잉 탭 전체 empty 상태에서 기존 `screen_home_following_empty` 리소스를 재사용해 `아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.` 문구를 중앙 표시하도록 구현했다. 영어는 `No following updates yet.\nFollow creators you are interested in.`, 일본어는 `フォロー中のお知らせはまだありません。\n気になるクリエイターをフォローしてみましょう。`로 갱신했다. `HomeFollowingUiState.Empty`에서만 empty 문구가 보이고 `Content`, `LoginRequired`, `Error`에서는 숨김 처리된다. 검증으로 `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew --no-daemon :app:mergeDebugResources`, `./gradlew --no-daemon :app:compileDebugKotlin`, `git diff --check` 모두 PASS. Gradle 실행 중 기존 deprecation warning만 출력됐다.
- 2026-07-12 Phase 11 검증: 최근 소식 `COMMUNITY_POST` 상세 이동 계약을 source test로 추가하고 `HomeMainFragment.onFollowingNewsClick()`에서 `HomeFollowingNewsUiItem.Community``CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)`로 이동하도록 구현했다. RED 시도: 구현 전 targeted test `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`는 120초/300초 timeout으로 assertion 결과까지 도달하지 못했지만, 당시 production은 no-op 상태였고 새 source test는 `ensureMainV2NavigationAllowed`와 상세 이동 호출 문자열을 요구하도록 추가되어 실패 조건을 고정했다. GREEN: 동일 targeted test PASS. 회귀 검증 `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew --no-daemon :app:compileDebugKotlin`, `git diff --check` PASS. `./gradlew --no-daemon :app:ktlintCheck`는 이번 변경과 무관한 기존 legacy/source 위반(`explorer/profile/creator_community` package underscore, `LiveRoomActivity.kt` formatting, `NicknameUpdateViewModel.kt` indentation 등)으로 FAIL. 실기기 수동 QA는 `adb devices` 결과 연결된 device가 없어 blocked. - 2026-07-12 Phase 11 검증: 최근 소식 `COMMUNITY_POST` 상세 이동 계약을 source test로 추가하고 `HomeMainFragment.onFollowingNewsClick()`에서 `HomeFollowingNewsUiItem.Community``CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)`로 이동하도록 구현했다. RED 시도: 구현 전 targeted test `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`는 120초/300초 timeout으로 assertion 결과까지 도달하지 못했지만, 당시 production은 no-op 상태였고 새 source test는 `ensureMainV2NavigationAllowed`와 상세 이동 호출 문자열을 요구하도록 추가되어 실패 조건을 고정했다. GREEN: 동일 targeted test PASS. 회귀 검증 `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew --no-daemon :app:compileDebugKotlin`, `git diff --check` PASS. `./gradlew --no-daemon :app:ktlintCheck`는 이번 변경과 무관한 기존 legacy/source 위반(`explorer/profile/creator_community` package underscore, `LiveRoomActivity.kt` formatting, `NicknameUpdateViewModel.kt` indentation 등)으로 FAIL. 실기기 수동 QA는 `adb devices` 결과 연결된 device가 없어 blocked.
- 2026-06-25 Phase 1-3 구현 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 모두 PASS. - 2026-06-25 Phase 1-3 구현 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 모두 PASS.
- 2026-06-25 Phase 1-3 코드 리뷰 및 재검증: blocking finding 없음. `./gradlew :app:mergeDebugResources`는 최초 sandbox lock 권한 오류 후 승인 실행으로 PASS했고, 나머지 검증 명령도 PASS. - 2026-06-25 Phase 1-3 코드 리뷰 및 재검증: blocking finding 없음. `./gradlew :app:mergeDebugResources`는 최초 sandbox lock 권한 오류 후 승인 실행으로 PASS했고, 나머지 검증 명령도 PASS.

View File

@@ -287,6 +287,17 @@ Figma `24:5682` 기준 상단 title bar와 tab bar는 기존 홈 화면 구조
- `COMMUNITY_POST`가 아닌 최근 소식 item은 이번 작업 범위에서 기존처럼 이동하지 않는다. - `COMMUNITY_POST`가 아닌 최근 소식 item은 이번 작업 범위에서 기존처럼 이동하지 않는다.
- legacy `CreatorCommunityAllActivity``Constants.EXTRA_COMMUNITY_POST_ID` 직접 extra 조립 경로는 사용하지 않는다. - legacy `CreatorCommunityAllActivity``Constants.EXTRA_COMMUNITY_POST_ID` 직접 extra 조립 경로는 사용하지 않는다.
#### 2026-07-13 전체 empty 문구 Requirements
- `isLoginRequired = false`이고 `followingCreators`, `onAirLives`, `recentChats`, `monthlySchedules`, `recentNews`가 모두 비어 있는 `HomeFollowingUiState.Empty` 상태에서는 팔로잉 탭 본문 중앙에 empty 문구를 표시한다.
- empty 문구는 기존 `screen_home_following_empty` string resource를 재사용해 수정한다.
- 한국어 기본 문구는 `아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`로 표시한다.
- 영어 문구는 `No following updates yet.\nFollow creators you are interested in.`로 표시한다.
- 일본어 문구는 `フォロー中のお知らせはまだありません。\n気になるクリエイターをフォローしてみましょう。`로 표시한다.
- empty 문구 `TextView`는 팔로잉 탭 content 영역 전체의 가운데에 배치하고, 텍스트도 가운데 정렬한다.
- `HomeFollowingUiState.Content`에서는 empty 문구를 숨기고 기존 섹션별 visibility 정책을 유지한다.
- `HomeFollowingUiState.LoginRequired``Error`는 이번 범위에서 empty 문구를 표시하지 않고 기존처럼 섹션을 숨긴다.
- API/DTO/mapper 계약과 팔로잉 크리에이터 `전체` 버튼 노출 정책은 변경하지 않는다.
#### 2026-06-30 후속 수정 Requirements #### 2026-06-30 후속 수정 Requirements
- `CREATOR_RANKING` 최근 소식 본문은 순위 숫자만 단독 표시하지 않고, Figma `24:5717` 기준으로 크리에이터 이름과 순위를 포함한 문장형 메시지를 표시한다. - `CREATOR_RANKING` 최근 소식 본문은 순위 숫자만 단독 표시하지 않고, Figma `24:5717` 기준으로 크리에이터 이름과 순위를 포함한 문장형 메시지를 표시한다.
- ranking 문장 안의 순위 텍스트는 기존 `FeedRankTextStyler` highlight range로 강조한다. - ranking 문장 안의 순위 텍스트는 기존 `FeedRankTextStyler` highlight range로 강조한다.

View File

@@ -0,0 +1,39 @@
# 크리에이터 채널 채팅/DM 로그인 가드 계획
## 목표
`CreatorChannelActivity` 화면 자체를 로그인 필수로 만들고, `대화하기` 버튼은 기존 국가별 인증/성인 콘텐츠 guard 흐름을 재사용한다.
## 범위
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- 문서: `docs/20260713_크리에이터_채널_채팅_DM_로그인_가드/prd.md`, `plan-task.md`
- 제외: Repository/ViewModel/API 변경, UI 레이아웃 변경, 다른 화면 guard 변경
### Phase 1: 로그인 가드 적용
- [x] **Task 1.0: `CreatorChannelActivity.kt` 화면 진입 로그인 guard 추가**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- 작업: `setupView()`에서 유효한 `creatorId` 확인 후 토큰이 비어 있으면 `showLoginActivity()`, `finish()`, `return`으로 화면 초기화를 중단한다.
- 검증 기준: 비로그인 상태에서는 탭/페이저/클릭 리스너 설정 전에 로그인 화면으로 이동하고 현재 화면은 종료된다.
- [x] **Task 1.1: `CreatorChannelActivity.kt` 대화하기 클릭 guard 추가**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- 작업: `tvChatButton` 클릭 시 기존 `ensureLoginAndAdultAuth`로 국가별 인증/성인 콘텐츠 접근 조건을 확인하고, 통과 후 `homeActionDelegate?.createChatRoom(characterId)`를 실행한다.
- 검증 기준: 한국 사용자 본인인증 미완료 상태 또는 성인 콘텐츠 표시 설정 비활성 상태에서는 채팅방 생성 API가 호출되지 않는다.
- [x] **Task 1.2: `CreatorChannelActivity.kt` DM 클릭 로그인 guard 추가**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- 작업: 화면 레벨 로그인 보장을 전제로 기존 owner/non-owner DM 이동 분기를 유지한다.
- 검증 기준: 로그인 상태에서 기존 owner/non-owner DM 이동 분기가 유지된다.
- [x] **Task 1.3: 변경 검증 실행**
- 파일: `docs/20260713_크리에이터_채널_채팅_DM_로그인_가드/plan-task.md`
- 작업: 소스 확인 및 가능한 Gradle 검증을 실행하고 결과를 기록한다.
- 검증 기준: 관련 테스트 또는 컴파일 검증 결과를 기록한다.
## Verification Log
- 2026-07-13: `CreatorChannelActivity.kt` 소스 확인 결과 `tvChatButton``ensureLoginAndAdultAuth(isAdult = false)` 통과 후 `homeActionDelegate?.createChatRoom(characterId)`를 호출하고, `tvDmButton``SharedPreferenceManager.token.isBlank()`일 때 `showLoginActivity()` 후 반환하며 기존 owner/non-owner 이동 경로를 유지한다.
- 2026-07-13: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"` 실행 결과 Kotlin 컴파일은 통과했으나 기존 `CreatorChannelActivitySourceTest > 팬Talk 섹션은 Figma ListComment layout과 bind로 렌더링한다``CreatorChannelActivitySourceTest.kt:1894`에서 실패했다. 실패 assertion은 `item_creator_channel_home_fantalk.xml``@drawable/ic_chevron_down_white` 포함 여부로, 이번 변경 파일인 `CreatorChannelActivity.kt`의 클릭 guard 수정과 직접 관련 없는 기존 테스트 상태로 확인했다.
- 2026-07-13: 사용자 추가 확인에 따라 `대화하기` guard를 로그인-only에서 로그인+본인인증으로 변경했다. `tvChatButton``ensureLoginAndAuth`를 호출하고, 해당 함수는 비로그인 시 로그인 화면, 한국 사용자 본인인증 미완료 시 `auth_desc` 본인인증 안내, 통과 시 채팅방 생성 콜백을 실행한다. `isAdultContentVisible`은 확인하지 않는다.
- 2026-07-13: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"`를 재실행했다. `CreatorChannelActivity.kt` Kotlin 컴파일은 통과했으며, 테스트 결과는 동일하게 기존 FanTalk 레이아웃 assertion(`CreatorChannelActivitySourceTest.kt:1894`, `@drawable/ic_chevron_down_white`) 1건 실패로 종료되었다.
- 2026-07-13: 사용자 추가 확인에 따라 크리에이터 채널 화면 자체를 로그인 필수로 변경했다. `setupView()`는 유효한 `creatorId` 확인 후 `SharedPreferenceManager.token.isBlank()`이면 `showLoginActivity()`, `finish()`, `return`으로 초기화를 중단한다. `대화하기`는 기존 `ensureLoginAndAdultAuth`를 재사용해 국가별 인증/성인 콘텐츠 접근 조건을 확인한다.
- 2026-07-13: `대화하기` 호출부를 `ensureLoginAndAdultAuth(isAdult = true)`로 명시하고 `ensureLoginAndAdultAuth` 함수 시그니처는 기존 `isAdult: Boolean` 필수 파라미터 형태로 유지했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"` 재실행 결과 `CreatorChannelActivity.kt` Kotlin 컴파일은 통과했고, 기존 FanTalk 레이아웃 assertion(`CreatorChannelActivitySourceTest.kt:1894`, `@drawable/ic_chevron_down_white`) 1건 실패만 남았다.

View File

@@ -0,0 +1,80 @@
# PRD: 크리에이터 채널 채팅/DM 로그인 가드
## 1. Overview
크리에이터 채널 화면 진입과 상단의 `대화하기` 버튼 진입 전에 필요한 로그인/인증 가드를 적용한다.
## 2. Problem
- 현재 크리에이터 채널 화면은 비로그인 상태에서도 진입을 시도할 수 있다.
- 현재 `대화하기` 버튼은 인증 조건을 통과하지 않은 상태에서도 채팅방 생성 API를 호출할 수 있다.
- 현재 `DM` 버튼은 화면이 로그인 상태를 보장하지 않으면 비로그인 상태에서도 DM 화면으로 진입할 수 있다.
- 사용자는 보호된 기능 접근 시 API 오류 대신 로그인 화면 또는 인증 안내를 기대한다.
## 3. Goals
- 크리에이터 채널 화면은 비로그인 상태에서 로그인 화면으로 이동하고 종료한다.
- `대화하기` 버튼은 국가별 인증/성인 콘텐츠 접근 조건을 통과한 뒤 채팅방 생성 API를 호출한다.
- `DM` 버튼은 화면 레벨 로그인 보장을 전제로 기존 이동 경로를 유지한다.
- 기존 owner/non-owner 분기와 채팅방 생성 성공 후 이동 로직은 변경하지 않는다.
## 4. Non-Goals
- 채팅방 생성 API, Repository, ViewModel 구조는 변경하지 않는다.
- 로그인 필수 정책은 이 화면에만 적용하고 다른 크리에이터 채널 관련 화면으로 확장하지 않는다.
- UI 레이아웃, 문구, 디자인은 변경하지 않는다.
- 레거시 코드나 다른 화면의 로그인 가드는 수정하지 않는다.
## 5. Target Users
- 크리에이터 채널에서 AI 캐릭터 채팅을 시작하려는 사용자
- 크리에이터 채널에서 DM을 보내거나 확인하려는 사용자
## 6. User Stories
- 비로그인 사용자는 크리에이터 채널 화면 진입 시 로그인 화면으로 이동한다.
- 한국 로그인 사용자 중 본인인증 미완료 사용자는 `대화하기`를 누르면 본인인증 안내를 본다.
- 비한국 로그인 사용자 중 성인 콘텐츠 표시 설정이 꺼진 사용자는 `대화하기`를 누르면 콘텐츠 설정 화면으로 이동한다.
- 로그인 및 인증 조건을 통과한 사용자는 `대화하기`를 누르면 기존처럼 채팅방 생성 후 채팅방으로 이동한다.
- 로그인 사용자는 `DM`을 누르면 기존 owner/non-owner 분기에 따라 동일하게 이동한다.
## 7. Core Features
### Feature A: 화면 진입 로그인 guard
#### Requirements
- `CreatorChannelActivity` 진입 시 로그인 여부를 확인한다.
- 비로그인 상태라면 `showLoginActivity()`를 호출하고 현재 화면을 종료한다.
#### Edge Cases
- `creatorId`가 유효하지 않으면 기존처럼 로그인 화면 이동 없이 종료한다.
### Feature B: 대화하기 국가별 인증/성인 콘텐츠 guard
#### Requirements
- `CreatorChannelActivity``tvChatButton` 클릭 시 기존 `ensureLoginAndAdultAuth`를 사용한다.
- 한국 사용자이고 본인인증이 완료되지 않은 경우 본인인증 다이얼로그를 표시한다.
- 성인 콘텐츠 표시 설정(`isAdultContentVisible`)이 꺼진 경우 콘텐츠 설정 화면으로 이동한다.
- guard 통과 후에만 `homeActionDelegate?.createChatRoom(characterId)`를 호출한다.
#### Edge Cases
- `currentHeader` 또는 `characterId`가 없으면 기존처럼 아무 동작도 하지 않는다.
- 인증/성인 콘텐츠 접근 조건을 통과하지 못하면 API를 호출하지 않는다.
### Feature C: DM 이동 유지
#### Requirements
- `CreatorChannelActivity``tvDmButton`은 화면 레벨 로그인 보장을 전제로 기존 owner/non-owner 이동 경로를 유지한다.
#### Edge Cases
- owner인 경우 기존처럼 `MainV2Activity.newChatDmIntent(this)`로 이동한다.
- owner가 아닌 경우 기존처럼 `DmChatRoomActivity.newIntentByCreatorId(this, creatorId)`로 이동한다.
## 8. UX / UI Expectations
- 버튼 노출, 텍스트, 레이아웃은 변경하지 않는다.
- 비로그인 사용자는 오류 토스트 대신 로그인 화면으로 이동한다.
## 9. Technical Constraints
- 변경 파일은 `CreatorChannelActivity.kt`와 이번 작업 문서로 제한한다.
- 기존 `showLoginActivity()``startAuthFlow()`를 재사용한다.
- 기존 `ensureLoginAndAdultAuth()``대화하기` 인증 guard에도 재사용한다.
## 10. Metrics
- 별도 지표 추가 없음.
## 11. Open Questions
- 없음. 사용자 확인으로 화면 자체를 로그인 필수로 만들고, `대화하기`는 국가별 인증/성인 콘텐츠 guard를 적용하기로 확정했다.

View File

@@ -219,3 +219,20 @@
- 상태 기준: `AudioContentPlayService` 브로드캐스트의 `EXTRA_AUDIO_CONTENT_PLAYING` 값을 단일 기준으로 사용하도록 정리했다. - 상태 기준: `AudioContentPlayService` 브로드캐스트의 `EXTRA_AUDIO_CONTENT_PLAYING` 값을 단일 기준으로 사용하도록 정리했다.
- 버튼 동작: 재생 중이면 `PAUSE`만 보내고, 재생 시작 시점에만 무료/미리듣기 대상 여부를 판단해 interstitial을 시도하도록 분리했다. - 버튼 동작: 재생 중이면 `PAUSE`만 보내고, 재생 시작 시점에만 무료/미리듣기 대상 여부를 판단해 interstitial을 시도하도록 분리했다.
- 기대 효과: 무료 콘텐츠 또는 유료 콘텐츠 미리듣기에서 “재생 시작 시”에만 전면 광고가 걸리고, pause 클릭은 즉시 pause로 동작한다. - 기대 효과: 무료 콘텐츠 또는 유료 콘텐츠 미리듣기에서 “재생 시작 시”에만 전면 광고가 걸리고, pause 클릭은 즉시 pause로 동작한다.
- 2026-07-13
- 무엇: 콘텐츠 상세 페이지에서 Yandex 배너 광고 영역과 오디오 재생 전면 광고 로직을 제거했다.
- 왜: 사용자 요청에 따라 콘텐츠 상세 페이지에서는 광고 영역을 차지하지 않고, 무료 콘텐츠 재생 또는 미리듣기 시작 시 전면 광고를 시도하지 않아야 하기 때문이다.
- 어떻게:
- 수정 파일: `app/build.gradle`, `app/src/main/res/layout/activity_audio_content_detail.xml`, `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
- 배너 제거: `ll_previous_next_content` 아래에 있던 `yandex_inline_banner_view` `BannerAdView` 블록을 삭제했다.
- 전면 광고 제거: `AudioContentDetailActivity`의 Yandex interstitial import, loader/listener/state, preload/show/cleanup 경로를 삭제했다.
- 설정 제거: 더 이상 참조하지 않는 콘텐츠 상세 전용 `YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID`, `YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID` `BuildConfig` 필드를 `debug`/`release`에서 삭제했다.
- 재생 동작: `ivPlayOrPause``llPreview`의 재생 시작 클릭은 광고 게이팅 없이 기존 `AudioContentPlayService` 시작 액션을 바로 실행하도록 유지했다.
- 범위 제외: 라이브 탭, 라이브 상세, 마이페이지, 채팅 탭 등 다른 화면의 Yandex 광고 구현은 이번 제거 범위에 포함하지 않았다.
- 실행 명령: `rg -n "Yandex|yandex|AdRequest|Interstitial|BannerAdSize|YANDEX_.*AUDIO_CONTENT|yandex_inline_banner_view|BannerAdView" "app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt" "app/src/main/res/layout/activity_audio_content_detail.xml" "app/build.gradle"`
- 실행 결과: 콘텐츠 상세 대상 파일과 `BuildConfig` 필드에는 제거 대상 참조가 남지 않았고, `app/build.gradle`의 앱 공용 Yandex SDK 의존성만 남아 있음을 확인했다.
- 실행 명령: `./gradlew :app:compileDebugKotlin`
- 실행 결과: `BUILD SUCCESSFUL`
- 비고: 컴파일 중 표시된 warning은 기존 다른 화면의 unchecked cast, deprecated API, Yandex rewarded listener parameter name 관련 경고로 이번 콘텐츠 상세 제거 변경에서 새로 만든 오류는 아니다.
- 실행 명령: `git diff --check`
- 실행 결과: 출력 없음. 공백 오류 없음.