feat(ads): 라이브와 콘텐츠 상세 광고 지면을 추가한다

This commit is contained in:
2026-04-24 18:29:52 +09:00
parent 4a4cdadef1
commit 31306583d0
8 changed files with 500 additions and 9 deletions

View File

@@ -76,6 +76,10 @@ android {
// release용 ad unit id는 배포 전 실제 값으로 교체한다. // release용 ad unit id는 배포 전 실제 값으로 교체한다.
buildConfigField 'String', 'YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID', '"R-M-19140295-1"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID', '"R-M-19140295-1"'
buildConfigField 'String', 'YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID', '"R-M-19140295-2"' buildConfigField 'String', 'YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID', '"R-M-19140295-2"'
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_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID', '"R-M-19140295-6"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID', '"R-M-19140295-5"'
buildConfigField 'String', 'BASE_URL', '"https://api.sodalive.net"' buildConfigField 'String', 'BASE_URL', '"https://api.sodalive.net"'
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"' buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"' buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
@@ -108,6 +112,10 @@ android {
buildConfigField 'String', 'YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID', '"R-M-19140297-1"' buildConfigField 'String', 'YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID', '"R-M-19140297-1"'
buildConfigField 'String', 'YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID', '"R-M-19140297-2"' buildConfigField 'String', 'YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID', '"R-M-19140297-2"'
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_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID', '"R-M-19140297-6"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID', '"R-M-19140297-5"'
buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"' buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"'
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"' buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"' buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'

View File

@@ -28,6 +28,18 @@ 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
@@ -60,6 +72,7 @@ import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent
import kr.co.vividnext.sodalive.report.ReportType import kr.co.vividnext.sodalive.report.ReportType
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>(
@@ -88,6 +101,42 @@ 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 isAudioContentInterstitialEligible = false
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)
@@ -105,6 +154,9 @@ 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() }
} }
@@ -315,9 +367,125 @@ 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() {
startService(
Intent(this, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.PAUSE.name
}
)
}
private fun updateAudioContentPlayOrPauseControls() {
val startPlaybackAction = audioContentStartPlaybackAction ?: return
if (isAudioContentPlaying) {
binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_pause)
binding.ivPlayOrPause.setOnClickListener { pauseAudioContentPlayback() }
binding.llPreview.setOnClickListener(null)
return
}
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
val startPlaybackClickListener = View.OnClickListener {
if (isAudioContentInterstitialEligible) {
playAudioContentWithInterstitialIfAvailable(startPlaybackAction)
} else {
startPlaybackAction()
}
}
binding.ivPlayOrPause.setOnClickListener(startPlaybackClickListener)
binding.llPreview.setOnClickListener(startPlaybackClickListener)
}
private fun setupBuyerList() { private fun setupBuyerList() {
val recyclerView = binding.rvBuyer val recyclerView = binding.rvBuyer
contentBuyerAdapter = AudioContentBuyerAdapter() contentBuyerAdapter = AudioContentBuyerAdapter()
@@ -775,10 +943,13 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
R.string.screen_audio_content_detail_total_duration_format, R.string.screen_audio_content_detail_total_duration_format,
response.duration response.duration
) )
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 &&
@@ -798,7 +969,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
binding.ivPlayOrPause.visibility = View.VISIBLE binding.ivPlayOrPause.visibility = View.VISIBLE
} }
val playClickAction = View.OnClickListener { val playAudioContentAction: () -> Unit = {
startService( startService(
Intent( Intent(
applicationContext, applicationContext,
@@ -842,9 +1013,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
) )
} }
audioContentStartPlaybackAction = playAudioContentAction
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play) binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
binding.ivPlayOrPause.setOnClickListener(playClickAction) updateAudioContentPlayOrPauseControls()
binding.llPreview.setOnClickListener(playClickAction)
if (!isAlertPreview) { if (!isAlertPreview) {
binding.ivSeekForward10.visibility = View.VISIBLE binding.ivSeekForward10.visibility = View.VISIBLE
@@ -873,6 +1045,8 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} }
} }
} else if (response.releaseDate == null) { } else if (response.releaseDate == null) {
audioContentStartPlaybackAction = null
isAudioContentInterstitialEligible = false
binding.llPreviewNo.visibility = View.VISIBLE binding.llPreviewNo.visibility = View.VISIBLE
} }
@@ -1166,7 +1340,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} else { } else {
contentOrder(audioContent, orderType) contentOrder(audioContent, orderType)
} }
}, }
).show(screenWidth) ).show(screenWidth)
} }
@@ -1193,6 +1367,12 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
}, 100) }, 100)
} }
override fun onDestroy() {
binding.yandexInlineBannerView.destroy()
releaseAudioContentPlayInterstitial()
super.onDestroy()
}
inner class AudioContentReceiver : BroadcastReceiver() { inner class AudioContentReceiver : BroadcastReceiver() {
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
@@ -1222,12 +1402,12 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
viewModel.isLoading.value = isLoading == true viewModel.isLoading.value = isLoading == true
if (this@AudioContentDetailActivity.audioContentId == contentId) { if (this@AudioContentDetailActivity.audioContentId == contentId) {
isAudioContentPlaying = isPlaying == true
runOnUiThread { runOnUiThread {
if (changeUi != null && changeUi) { if (changeUi != null && changeUi) {
if (isPlaying != null && isPlaying) { if (isPlaying != null && isPlaying) {
binding.ivPlayOrPause.visibility = View.VISIBLE binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_pause)
} else { } else {
if (isAlertPreview) { if (isAlertPreview) {
binding.ivPlayOrPause.visibility = View.GONE binding.ivPlayOrPause.visibility = View.GONE
@@ -1235,9 +1415,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} else { } else {
binding.ivPlayOrPause.visibility = View.VISIBLE binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
} }
} }
updateAudioContentPlayOrPauseControls()
} }
} }

View File

@@ -22,9 +22,12 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson import com.google.gson.Gson
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import com.zhpan.bannerview.BaseBannerAdapter import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle import com.zhpan.indicator.enums.IndicatorStyle
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.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
@@ -131,6 +134,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
} }
override fun onDestroyView() { override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView() super.onDestroyView()
} }
@@ -158,9 +162,31 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
setupRecommendChannel() setupRecommendChannel()
setupLatestFinishedLiveChannel() setupLatestFinishedLiveChannel()
setupLiveReplay() setupLiveReplay()
setupLiveTabInlineBanner()
setupLiveReservation() setupLiveReservation()
} }
private fun setupLiveTabInlineBanner() {
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_LIVE_TAB_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun renderMakeLiveByRole(role: String) { private fun renderMakeLiveByRole(role: String) {
if (role == MemberRole.CREATOR.name) { if (role == MemberRole.CREATOR.name) {
binding.llMakeLive.visibility = View.VISIBLE binding.llMakeLive.visibility = View.VISIBLE

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.live.room.detail
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -20,6 +19,9 @@ import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation import coil.transform.RoundedCornersTransformation
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
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
@@ -35,6 +37,7 @@ import org.koin.android.ext.android.inject
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlin.math.roundToInt
class LiveRoomDetailFragment( class LiveRoomDetailFragment(
private val roomId: Long, private val roomId: Long,
@@ -79,11 +82,39 @@ class LiveRoomDetailFragment(
behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.state = BottomSheetBehavior.STATE_EXPANDED
setupAdapter() setupAdapter()
setupLiveRoomDetailInlineBanner()
bindData() bindData()
binding.ivClose.setOnClickListener { dismiss() } binding.ivClose.setOnClickListener { dismiss() }
viewModel.getDetail(roomId) { dismiss() } viewModel.getDetail(roomId) { dismiss() }
} }
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
private fun setupLiveRoomDetailInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val screenWidth = resources.displayMetrics.widthPixels
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_LIVE_ROOM_DETAIL_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupAdapter() { private fun setupAdapter() {
val recyclerView = binding.rvParticipate val recyclerView = binding.rvParticipate
adapter = LiveRoomDetailAdapter {} adapter = LiveRoomDetailAdapter {}
@@ -384,7 +415,7 @@ class LiveRoomDetailFragment(
viewModel.shareRoomLink( viewModel.shareRoomLink(
response.roomId, response.roomId,
response.isPrivateRoom, response.isPrivateRoom,
response.password, response.password
) { ) {
val intent = Intent(Intent.ACTION_SEND) val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain" intent.type = "text/plain"

View File

@@ -396,6 +396,14 @@
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

@@ -143,11 +143,19 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginBottom="48dp" android:layout_marginBottom="24dp"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingHorizontal="24dp" android:paddingHorizontal="24dp"
android:visibility="gone" /> android:visibility="gone" />
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:maxHeight="90dp" />
<LinearLayout <LinearLayout
android:id="@+id/ll_replay_live" android:id="@+id/ll_replay_live"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -187,6 +187,14 @@
app:drawableStartCompat="@drawable/ic_live_detail_bottom" /> app:drawableStartCompat="@drawable/ic_live_detail_bottom" />
</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" />
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"

View File

@@ -0,0 +1,221 @@
# 20260424 Yandex 광고 추가 구현 계획
## 작업 체크리스트
- [x] 대상 화면 3곳의 광고 삽입 위치와 기존 Yandex 광고 패턴을 조사한다.
QA: `LiveFragment`, `LiveRoomDetailFragment`, `AudioContentDetailActivity`, `MyPageFragment`, `LiveRoomActivity`, 공식 Yandex 문서를 근거로 각 광고 형식과 삽입 위치를 설명할 수 있어야 한다.
- [x] AD_UNIT_ID 운영 방식을 기존 Yandex 광고와 같은 구조로 정하고, 지면별 분리 여부를 판단한다.
QA: 기존 `BuildConfig` 주입 패턴을 유지하면서 어떤 지면은 반드시 분리하고 어떤 경우에만 공용이 가능한지 문서에 남아 있어야 한다.
- [x] `app/build.gradle`에 신규 광고 지면용 ad unit id를 buildType별로 추가한다.
QA: `debug`/`release` 모두에서 라이브 탭 배너, 라이브 상세 배너, 콘텐츠 상세 전면 광고, 콘텐츠 상세 배너용 `BuildConfig` 값이 생성되어야 한다.
- [x] 라이브 탭의 최근 종료한 라이브와 라이브 다시 듣기 사이에 Yandex adaptive inline banner를 추가한다.
QA: `fragment_live.xml``rv_latest_finished_live_channel` 다음, `ll_replay_live` 이전에 배너 컨테이너가 추가되고, 배너 최대 높이는 90dp를 넘지 않아야 한다.
- [x] 라이브 상세 bottom sheet의 참여자 목록과 크리에이터 프로필 사이에 Yandex adaptive inline banner를 추가한다.
QA: `fragment_live_room_detail.xml``ll_participate_wrapper` 다음, 크리에이터 프로필 `RelativeLayout` 이전에 배너가 배치되고, bottom sheet 해제 시 배너 리소스가 정리되어야 한다.
- [x] 콘텐츠 상세에서 무료 콘텐츠 재생 또는 미리듣기 시작 시 Yandex interstitial 광고를 추가한다.
QA: `AudioContentDetailActivity.setupPlayArea()`의 실제 재생 시작 클릭 경로에서만 광고를 1회 시도하고, 광고 실패 여부와 무관하게 기존 재생 흐름이 유지되어야 한다.
- [x] 콘텐츠 상세의 오픈예정/theme 표시 영역과 이전화/다음화 영역 사이에 Yandex adaptive inline banner를 추가한다.
QA: `activity_audio_content_detail.xml``ll_previous_next_content`와 theme/open 예정 `RelativeLayout` 사이에 배너가 추가되고, 최대 높이는 90dp를 넘지 않아야 한다.
- [x] 각 화면 생명주기에 맞는 광고 로드/정리 코드를 반영한다.
QA: 배너는 화면 종료 시 `destroy()`가 호출되고, 전면 광고는 listener와 ad 참조가 화면 종료 시 정리되어야 한다.
- [x] 검증 결과를 문서 하단에 누적 기록한다.
QA: 최소 빌드, 테스트, 수동 확인 계획과 실제 실행 결과가 문서 하단에 남아 있어야 한다.
## 범위 메모
- 이번 요청 범위는 아래 4개 광고 지면 추가로 한정한다.
- 라이브 탭 배너 1개
- 라이브 상세 배너 1개
- 콘텐츠 상세 전면 광고 1개
- 콘텐츠 상세 배너 1개
- AD_UNIT_ID는 기존 Yandex 광고와 동일하게 `app/build.gradle``debug`/`release` `buildConfigField`로 관리한다.
- 배너 광고는 모두 Yandex adaptive inline banner를 기준으로 구현한다.
- 사용자가 명시한 제약에 따라 모든 배너의 최대 높이는 90dp를 상한으로 둔다.
- 전면 광고는 `AudioContentDetailActivity`에서 무료 콘텐츠 재생 또는 미리듣기 시작 시점에만 노출을 시도한다.
- 앱 전역 Yandex SDK 의존성과 기본 초기화는 이미 `app/build.gradle`, `SodaLiveApp.kt`에 존재하므로 이번 작업에서 신규 SDK 도입이나 앱 초기화 구조 변경은 제외한다.
- 기존 구현 패턴은 배너는 `MyPageFragment`, 전면 광고는 `LiveRoomActivity`를 우선 따른다.
- 같은 포맷이라도 페이지 목적과 노출 맥락이 다르면 AD_UNIT_ID를 분리하는 방향을 기본값으로 둔다.
- 실제 ad unit id 값은 아직 전달되지 않아 신규 4개 지면은 `BuildConfig`에 교체용 placeholder 문자열로 추가하고, 코드 구조와 주입 경로를 먼저 확정한다.
## 조사 근거
- 기존 배너 구현
- `app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt`
- `app/src/main/res/layout/fragment_my.xml`
- 기존 전면 광고 구현
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`
- 앱 초기화
- `app/src/main/java/kr/co/vividnext/sodalive/app/SodaLiveApp.kt`
- 대상 화면
- `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`
- `app/src/main/res/layout/fragment_live.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt`
- `app/src/main/res/layout/fragment_live_room_detail.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
- `app/src/main/res/layout/activity_audio_content_detail.xml`
- 공식 문서
- `https://ads.yandex.com/helpcenter/ko/dev/android/adaptive-inline-banner`
- `https://ads.yandex.com/helpcenter/ko/dev/android/interstitial`
## 구현 계획
### 1. ad unit id 주입 지점 확정
- 수정 대상: `app/build.gradle`
- 계획:
- `debug`/`release` 각각에 신규 광고 지면용 `buildConfigField`를 추가한다.
- 계획 후보 키
- `YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID`
- `YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID`
- `YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID`
- `YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID`
- 이유:
- 기존 `YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID`, `YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID`와 동일한 관리 방식을 유지해야 하기 때문이다.
### 1-1. AD_UNIT_ID 분리 전략
- 권장안:
- 이번 작업의 4개 지면은 모두 **서로 다른 AD_UNIT_ID**를 사용한다.
- 근거:
- 라이브 탭 배너, 라이브 상세 배너, 콘텐츠 상세 배너는 모두 같은 banner 포맷이지만 화면 맥락과 가시성, 스크롤 위치, 성과 측정 대상이 다르다.
- 콘텐츠 상세 전면 광고는 포맷 자체가 interstitial이라 배너와는 반드시 분리해야 한다.
- 지면별 AD_UNIT_ID를 분리하면 추후 리포트, fill rate, CTR, 운영 정책 조정, 특정 지면만 교체하는 작업이 쉬워진다.
- 공용 ID가 가능한 경우:
- 완전히 동일한 포맷이고,
- 동일한 UX 목적을 가지며,
- 운영/리포트도 합산으로 봐도 되는 경우에만 공용 사용을 검토할 수 있다.
- 이번 요청에서의 판단:
- 라이브 탭 배너 ↔ 라이브 상세 배너 ↔ 콘텐츠 상세 배너는 위치와 사용자 의도가 모두 달라 공용으로 묶지 않는 것이 낫다.
- 따라서 이번 계획에서는 **페이지별·지면별 분리**를 기준으로 문서와 코드 반영을 진행한다.
### 2. 라이브 탭 배너 추가
- 수정 대상:
- `app/src/main/res/layout/fragment_live.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`
- 위치:
- `fragment_live.xml``rv_latest_finished_live_channel` 다음, `ll_replay_live` 이전
- 계획:
- 스크롤 섹션 사이에 `BannerAdView` 또는 배너 전용 컨테이너를 추가한다.
- `LiveFragment`에 기존 `MyPageFragment.setupBottomInlineBanner()`와 같은 크기 계산/로드 로직을 화면 구조에 맞게 추가한다.
- `maxAdHeightDp`는 90으로 제한한다.
- `onDestroyView()`에서 배너 `destroy()`를 호출한다.
### 3. 라이브 상세 배너 추가
- 수정 대상:
- `app/src/main/res/layout/fragment_live_room_detail.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt`
- 위치:
- `ll_participate_wrapper` 아래, 크리에이터 프로필 `RelativeLayout`
- 계획:
- bottom sheet의 세로 흐름을 유지하면서 배너 컨테이너를 삽입한다.
- 참가자 영역이 숨겨지는 경우와 방장 여부에 따라 UI가 달라져도 광고 영역이 레이아웃을 깨지 않도록 표시 조건을 명확히 정한다.
- 배너 크기 계산 시 실제 측정 너비와 90dp 상한을 사용한다.
- fragment 종료 또는 dialog dismiss 시 배너 리소스를 해제한다.
### 4. 콘텐츠 상세 전면 광고 추가
- 수정 대상:
- `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
- 진입 경로 근거:
- `setupPlayArea()`에서 `binding.ivPlayOrPause.setOnClickListener(playClickAction)`
- `binding.llPreview.setOnClickListener(playClickAction)`
- 실제 재생은 `playClickAction` 내부의 `AudioContentPlayService` 시작으로 이어진다.
- 계획:
- `LiveRoomActivity``InterstitialAdLoader` / `InterstitialAdLoadListener` / `InterstitialAdEventListener` 패턴을 재사용한다.
- 무료 콘텐츠 재생 또는 미리듣기 클릭 시점에만 광고 노출을 시도한다.
- 재생/일시정지 버튼은 서비스 브로드캐스트 상태를 단일 기준으로 삼아, 재생 중이면 무조건 `PAUSE`만 보내고 광고 로직은 타지 않도록 분리한다.
- 광고가 없거나 실패하면 즉시 기존 `playClickAction`을 계속 진행한다.
- 중복 노출 방지를 위한 1회성 상태를 Activity 생명주기에 맞춰 정의한다.
- `onDestroy()`에서 loader, listener, ad 참조를 정리한다.
### 5. 콘텐츠 상세 배너 추가
- 수정 대상:
- `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` 다음, `tv_scheduled_to_open`/`tv_theme`를 포함한 `RelativeLayout` 이전
- 계획:
- 현재 스크롤 흐름을 유지하면서 중간 섹션으로 배너를 삽입한다.
- `NestedScrollView` 내부 측정 너비 기준으로 adaptive inline banner를 로드한다.
- `maxAdHeightDp`는 90으로 제한한다.
- Activity 종료 시 배너 `destroy()`를 호출한다.
## 예상 수정 파일
- `docs/20260424_Yandex광고추가구현계획.md`
- `app/build.gradle`
- `app/src/main/res/layout/fragment_live.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`
- `app/src/main/res/layout/fragment_live_room_detail.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt`
- `app/src/main/res/layout/activity_audio_content_detail.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
## 검증 계획
- 공통
- `./gradlew :app:assembleDebug`
- `./gradlew :app:testDebugUnitTest`
- 수동 확인
- 라이브 탭 진입 후 최근 종료한 라이브와 라이브 다시 듣기 사이에 배너가 보이는지 확인한다.
- 라이브 상세 bottom sheet를 열어 참여자 목록과 크리에이터 프로필 사이 배너 위치와 높이를 확인한다.
- 콘텐츠 상세에서 무료 콘텐츠 재생과 미리듣기 각각에 대해 전면 광고 시도 후 기존 재생 흐름이 유지되는지 확인한다.
- 콘텐츠 상세에서 이전화/다음화와 theme/open 예정 영역 사이 배너 위치와 높이를 확인한다.
- 모든 배너가 90dp를 넘지 않는지 레이아웃 검사 또는 화면 캡처로 확인한다.
## 검증 기록
- 2026-04-24
- 무엇: Yandex 광고 추가 작업의 구현 계획 문서를 생성했다.
- 왜: 저장소 규칙에 따라 구현 전에 `docs` 아래 계획 문서를 먼저 만들고, 그 문서를 기준으로 범위와 검증 기준을 고정해야 하기 때문이다.
- 어떻게:
- 생성 파일: `docs/20260424_Yandex광고추가구현계획.md`
- 근거 파일: `app/build.gradle`, `SodaLiveApp.kt`, `MyPageFragment.kt`, `LiveRoomActivity.kt`, `LiveFragment.kt`, `LiveRoomDetailFragment.kt`, `AudioContentDetailActivity.kt`, 각 대응 XML 레이아웃
- 근거 문서: `https://ads.yandex.com/helpcenter/ko/dev/android/adaptive-inline-banner`, `https://ads.yandex.com/helpcenter/ko/dev/android/interstitial`
- 결과: 광고 형식, 삽입 위치, 90dp 배너 높이 제한, 예상 수정 파일, 검증 계획을 구현 전에 확정했다.
- 2026-04-24
- 무엇: AD_UNIT_ID 운영 전략을 기존 Yandex 광고와 같은 `BuildConfig` 방식으로 정리하고, 지면별 분리 여부 판단을 문서에 반영했다.
- 왜: 구현 전에 ad unit 관리 방식을 고정해야 이후 코드 반영과 운영 기준이 흔들리지 않고, 지면별 성과 측정 단위도 명확해지기 때문이다.
- 어떻게:
- 기준 패턴: `MyPageFragment`의 inline banner id, `LiveRoomActivity`의 interstitial id
- 판단 결과: 이번 작업의 4개 지면은 화면 맥락과 포맷이 달라 모두 별도 AD_UNIT_ID를 사용하는 방향으로 계획을 확정했다.
- 결과: `app/build.gradle``buildConfigField`를 지면별로 추가하는 방향이 계획 문서에 반영됐다.
- 2026-04-24
- 무엇: 계획 문서 기준으로 라이브 탭, 라이브 상세, 콘텐츠 상세의 Yandex 광고 코드를 실제 반영했다.
- 왜: 사용자 요청대로 3개 화면의 지정 위치에 배너/전면 광고를 추가하고, 기존 저장소의 Yandex 광고 패턴을 동일하게 확장해야 했기 때문이다.
- 어떻게:
- 수정 파일: `app/build.gradle`, `app/src/main/res/layout/fragment_live.xml`, `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`, `app/src/main/res/layout/fragment_live_room_detail.xml`, `app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt`, `app/src/main/res/layout/activity_audio_content_detail.xml`, `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
- BuildConfig 반영: 신규 4개 지면용 `YANDEX_*` ad unit id 필드를 `debug`/`release`에 각각 추가했다.
- 라이브 탭: `rv_latest_finished_live_channel``ll_replay_live` 사이에 `BannerAdView`를 추가하고, `LiveFragment`에서 90dp 상한의 adaptive inline banner를 로드하도록 구현했다.
- 라이브 상세: `ll_participate_wrapper`와 크리에이터 프로필 블록 사이에 `BannerAdView`를 추가하고, `LiveRoomDetailFragment`에서 90dp 상한의 adaptive inline banner를 로드하도록 구현했다.
- 콘텐츠 상세: `ll_previous_next_content`와 theme/open 예정 블록 사이에 `BannerAdView`를 추가하고, `AudioContentDetailActivity`에서 90dp 상한의 adaptive inline banner를 로드하도록 구현했다.
- 콘텐츠 상세 전면 광고: 무료 재생 또는 미리듣기 클릭 경로만 interstitial로 감싸고, 광고가 없거나 실패하면 기존 재생 액션을 즉시 이어가도록 구현했다.
- 정리 코드: `LiveFragment.onDestroyView()`, `LiveRoomDetailFragment.onDestroyView()`, `AudioContentDetailActivity.onDestroy()`에서 배너/전면 광고 리소스를 정리했다.
- 2026-04-24
- 무엇: 빌드, 테스트, 린트, 수동 확인 가능 여부를 점검했다.
- 왜: 이번 변경은 `BuildConfig`, Kotlin, XML, 화면 생명주기를 함께 건드리므로 최소 컴파일·테스트와 실제 실행 가능 여부를 함께 확인해야 하기 때문이다.
- 어떻게:
- 진단 도구: `lsp_diagnostics`
- 진단 결과: `.kt` LSP 서버 미설정으로 `No LSP server configured for extension: .kt`
- 실행 명령: `./gradlew :app:assembleDebug`
- 실행 결과: `BUILD SUCCESSFUL`
- 실행 명령: `./gradlew :app:testDebugUnitTest`
- 실행 결과: `BUILD SUCCESSFUL`
- 실행 명령: `./gradlew :app:ktlintCheck`
- 실행 결과: 실패
- 린트 실패 원인: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt:1:1 Package name must not contain underscore`
- 린트 판단: 현재 저장소의 기존 패키지 경로 `audio_content` 때문에 발생한 규칙 위반으로, 이번 작업에서 새로 만든 오류는 확인되지 않았다.
- 실행 명령: `adb devices`
- 실행 결과: 연결 기기 없음
- 수동 확인 결과: ADB 연결 기기가 없어 앱 설치 및 실제 광고 노출 경로 수동 검증까지는 진행하지 못했다.
- 비고: 신규 ad unit id는 placeholder 문자열로 넣었으므로, 실제 광고 서버 응답 검증은 실 ad unit id 교체 후 추가 확인이 필요하다.
- 2026-04-24
- 무엇: Oracle 검토 의견을 반영해 배너 높이 상한과 interstitial 종료 경로 안전장치를 보강한 뒤 다시 빌드와 테스트를 확인했다.
- 왜: 코드상 `maxAdHeightDp = 90`만으로 끝내지 않고 XML 레벨에서도 90dp 상한을 명시해 두는 편이 안전하고, Activity 종료 중 광고 콜백이 들어와도 재생 동작이 이어지지 않도록 방어해야 하기 때문이다.
- 어떻게:
- 수정 파일: `fragment_live.xml`, `fragment_live_room_detail.xml`, `activity_audio_content_detail.xml`, `AudioContentDetailActivity.kt`
- 추가 반영: 각 `BannerAdView``android:maxHeight="90dp"`를 명시했다.
- 추가 반영: `AudioContentDetailActivity.continuePendingAudioContentPlayAction()`에서 `isFinishing || isDestroyed`일 때 재생 액션을 중단하도록 보강했다.
- 실행 명령: `./gradlew :app:assembleDebug`
- 실행 결과: `BUILD SUCCESSFUL`
- 실행 명령: `./gradlew :app:testDebugUnitTest`
- 실행 결과: `BUILD SUCCESSFUL`
- 2026-04-24
- 무엇: 콘텐츠 상세의 재생/일시정지 버튼과 전면 광고 게이팅 로직을 사용자 의도에 맞게 분리했다.
- 왜: 기존 구현은 pause 아이콘이 보여도 클릭 리스너가 재생 경로를 유지해, 무료 콘텐츠 또는 미리듣기에서 pause 클릭 시 전면 광고가 늦게 뜨는 문제가 있었기 때문이다.
- 어떻게:
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
- 상태 기준: `AudioContentPlayService` 브로드캐스트의 `EXTRA_AUDIO_CONTENT_PLAYING` 값을 단일 기준으로 사용하도록 정리했다.
- 버튼 동작: 재생 중이면 `PAUSE`만 보내고, 재생 시작 시점에만 무료/미리듣기 대상 여부를 판단해 interstitial을 시도하도록 분리했다.
- 기대 효과: 무료 콘텐츠 또는 유료 콘텐츠 미리듣기에서 “재생 시작 시”에만 전면 광고가 걸리고, pause 클릭은 즉시 pause로 동작한다.