feat(chat-room): 채팅 쿼터 광고/캔 충전 흐름을 연결한다

This commit is contained in:
2026-04-30 12:47:53 +09:00
parent 723fe6b90c
commit bd3f961ee1

View File

@@ -14,21 +14,31 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import coil.load import coil.load
import com.yandex.mobile.ads.common.AdError
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.rewarded.Reward
import com.yandex.mobile.ads.rewarded.RewardedAd
import com.yandex.mobile.ads.rewarded.RewardedAdEventListener
import com.yandex.mobile.ads.rewarded.RewardedAdLoadListener
import com.yandex.mobile.ads.rewarded.RewardedAdLoader
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterType import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterType
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
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatRoomQuotaCanOption
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatRoomQuotaChargeType
import kr.co.vividnext.sodalive.user.UserRepository import kr.co.vividnext.sodalive.user.UserRepository
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.util.Locale
class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>( class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
ActivityChatRoomBinding::inflate ActivityChatRoomBinding::inflate
@@ -48,13 +58,18 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
private var hasMoreMessages: Boolean = true // Repository 연동 시 서버 값으로 갱신 예정 private var hasMoreMessages: Boolean = true // Repository 연동 시 서버 값으로 갱신 예정
private var nextCursor: Long? = null // 가장 오래된 메시지의 timestamp 등 private var nextCursor: Long? = null // 가장 오래된 메시지의 timestamp 등
// 쿼터/카운트다운 상태 // 쿼터/광고 상태
private var quotaTimer: android.os.CountDownTimer? = null private var currentTotalRemaining: Int = Int.MAX_VALUE
private var chatQuotaRewardedAdLoader: RewardedAdLoader? = null
private var chatQuotaRewardedAd: RewardedAd? = null
private var hasRewardHandledForCurrentAd: Boolean = false
private var isQuotaPurchaseInFlight: Boolean = false
private var isChatQuotaRewardedAdShowing: Boolean = false
// 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관) // 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관)
private var characterInfo: CharacterInfo? = null private var characterInfo: CharacterInfo? = null
private fun noticePrefKey(roomId: Long) = "chat_notice_hidden_room_${roomId}" private fun noticePrefKey(roomId: Long): String = "chat_notice_hidden_room_$roomId"
private fun isNoticeHidden(): Boolean = ChatRoomPreferenceManager.getBoolean(noticePrefKey(roomId), false) private fun isNoticeHidden(): Boolean = ChatRoomPreferenceManager.getBoolean(noticePrefKey(roomId), false)
private fun setNoticeHidden(hidden: Boolean) { private fun setNoticeHidden(hidden: Boolean) {
ChatRoomPreferenceManager.putBoolean(noticePrefKey(roomId), hidden) ChatRoomPreferenceManager.putBoolean(noticePrefKey(roomId), hidden)
@@ -132,7 +147,9 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// 타입 배지 텍스트 및 배경 // 타입 배지 텍스트 및 배경
val (badgeText, badgeBg) = when (info.characterType) { val (badgeText, badgeBg) = when (info.characterType) {
CharacterType.CLONE -> getString(R.string.chat_character_type_clone) to R.drawable.bg_character_status_clone CharacterType.CLONE -> getString(R.string.chat_character_type_clone) to R.drawable.bg_character_status_clone
CharacterType.CHARACTER -> getString(R.string.chat_character_type_character) to R.drawable.bg_character_status_character CharacterType.CHARACTER -> {
getString(R.string.chat_character_type_character) to R.drawable.bg_character_status_character
}
} }
binding.tvCharacterTypeBadge.text = badgeText binding.tvCharacterTypeBadge.text = badgeText
binding.tvCharacterTypeBadge.setBackgroundResource(badgeBg) binding.tvCharacterTypeBadge.setBackgroundResource(badgeBg)
@@ -198,7 +215,11 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
} }
override fun onPurchaseQuota() { override fun onPurchaseQuota() {
onPurchaseQuotaClicked() onQuotaNoticeAction(ChatQuotaNoticeAction.REWARDED_AD)
}
override fun onQuotaNoticeAction(action: ChatQuotaNoticeAction) {
this@ChatRoomActivity.onQuotaNoticeAction(action)
} }
}) })
} }
@@ -381,7 +402,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
} }
// 응답에 포함된 쿼터 상태로 UI 갱신 // 응답에 포함된 쿼터 상태로 UI 갱신
updateQuotaUi(response.nextRechargeAtEpoch) updateQuotaUi(response.totalRemaining)
}, { error -> }, { error ->
// 실패: 타이핑 인디케이터 제거 및 FAILED로 업데이트 // 실패: 타이핑 인디케이터 제거 및 FAILED로 업데이트
chatAdapter.hideTypingIndicator() chatAdapter.hideTypingIndicator()
@@ -461,17 +482,34 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// endregion 6.2 Send flow // endregion 6.2 Send flow
// region Quota handling // region Quota handling
private fun onPurchaseQuotaClicked() { private fun onQuotaNoticeAction(action: ChatQuotaNoticeAction) {
if (isQuotaPurchaseInFlight) return
when (action) {
ChatQuotaNoticeAction.REWARDED_AD -> showChatQuotaRewardedAd()
ChatQuotaNoticeAction.PURCHASE_10_CAN -> purchaseChatQuota(ChatRoomQuotaCanOption.CAN_10)
ChatQuotaNoticeAction.PURCHASE_20_CAN -> purchaseChatQuota(ChatRoomQuotaCanOption.CAN_20)
}
}
private fun purchaseChatQuota(canOption: ChatRoomQuotaCanOption) {
if (isQuotaPurchaseInFlight) return
val token = "Bearer ${SharedPreferenceManager.token}" val token = "Bearer ${SharedPreferenceManager.token}"
isQuotaPurchaseInFlight = true
compositeDisposable.add( compositeDisposable.add(
chatRepository.purchaseChatQuota(roomId, token) chatRepository.purchaseChatQuota(
roomId = roomId,
token = token,
chargeType = ChatRoomQuotaChargeType.CAN,
canOption = canOption
)
.doFinally { isQuotaPurchaseInFlight = false }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp -> .subscribe({ resp ->
// 쿼터 UI 갱신 updateQuotaUi(resp.totalRemaining)
updateQuotaUi(resp.nextRechargeAtEpoch)
// 결제 성공 시 로컬 캔 차감(30캔) 및 헤더 배지 즉시 반영 val newCan = (SharedPreferenceManager.can - canOption.needCan).coerceAtLeast(0)
val newCan = (SharedPreferenceManager.can - 30).coerceAtLeast(0)
SharedPreferenceManager.can = newCan SharedPreferenceManager.can = newCan
binding.tvCanBadge.text = newCan.moneyFormat() binding.tvCanBadge.text = newCan.moneyFormat()
}, { err -> }, { err ->
@@ -480,33 +518,47 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
) )
} }
private fun updateQuotaUi(nextRechargeAtEpoch: Long?) { private fun purchaseRewardedChatQuota() {
if (nextRechargeAtEpoch != null) { if (isQuotaPurchaseInFlight) return
// 입력창 숨김 및 안내 표시 + 카운트다운 시작
val token = "Bearer ${SharedPreferenceManager.token}"
isQuotaPurchaseInFlight = true
compositeDisposable.add(
chatRepository.purchaseChatQuota(
roomId = roomId,
token = token,
chargeType = ChatRoomQuotaChargeType.AD
)
.doFinally { isQuotaPurchaseInFlight = false }
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
updateQuotaUi(resp.totalRemaining)
}, { err ->
showToast(err.message ?: getString(R.string.chat_quota_purchase_failed))
})
)
}
private fun updateQuotaUi(totalRemaining: Int) {
currentTotalRemaining = totalRemaining
if (totalRemaining <= 0) {
binding.inputContainer.isVisible = false binding.inputContainer.isVisible = false
val timeText = formatEpochToHms(nextRechargeAtEpoch) ensureQuotaNoticeShown()
ensureQuotaNoticeShown(timeText)
startQuotaCountdown(nextRechargeAtEpoch)
} else { } else {
// 입력창 표시 및 안내 제거
binding.inputContainer.isVisible = true binding.inputContainer.isVisible = true
stopQuotaCountdown()
ensureQuotaNoticeRemoved() ensureQuotaNoticeRemoved()
} }
if (totalRemaining <= 1) {
preloadChatQuotaRewardedAd()
}
} }
private fun ensureQuotaNoticeShown(timeText: String?) { private fun ensureQuotaNoticeShown() {
val idx = items.indexOfLast { it is ChatListItem.QuotaNotice } val idx = items.indexOfLast { it is ChatListItem.QuotaNotice }
val newItem = ChatListItem.QuotaNotice(timeText = timeText) if (idx >= 0) return
if (idx >= 0) { appendMessage(ChatListItem.QuotaNotice)
val old = items[idx] as ChatListItem.QuotaNotice
// 동일 시간 텍스트면 불필요한 갱신 회피
if (old.timeText == newItem.timeText) return
items[idx] = newItem
chatAdapter.setItems(items)
} else {
appendMessage(newItem)
}
} }
private fun ensureQuotaNoticeRemoved() { private fun ensureQuotaNoticeRemoved() {
@@ -517,81 +569,104 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
} }
} }
private fun startQuotaCountdown(targetEpoch: Long?) { private val chatQuotaRewardedAdLoadListener = object : RewardedAdLoadListener {
stopQuotaCountdown() override fun onAdLoaded(rewardedAd: RewardedAd) {
if (targetEpoch == null) return chatQuotaRewardedAdLoader = null
val targetMs = if (targetEpoch < 1_000_000_000_000L) targetEpoch * 1000 else targetEpoch clearChatQuotaRewardedAd()
val now = System.currentTimeMillis() chatQuotaRewardedAd = rewardedAd
val duration = targetMs - now hasRewardHandledForCurrentAd = false
if (duration <= 0) { }
checkQuotaStatus()
override fun onAdFailedToLoad(adRequestError: AdRequestError) {
chatQuotaRewardedAdLoader = null
chatQuotaRewardedAd = null
hasRewardHandledForCurrentAd = false
}
}
private val chatQuotaRewardedAdEventListener = object : RewardedAdEventListener {
override fun onAdShown() {
isChatQuotaRewardedAdShowing = true
}
override fun onAdFailedToShow(adError: AdError) {
isChatQuotaRewardedAdShowing = false
clearChatQuotaRewardedAd()
preloadChatQuotaRewardedAd(force = true)
showToast(getString(R.string.chat_quota_rewarded_ad_unavailable))
}
override fun onAdDismissed() {
isChatQuotaRewardedAdShowing = false
val rewardHandled = hasRewardHandledForCurrentAd
clearChatQuotaRewardedAd()
if (!rewardHandled && currentTotalRemaining <= 1) {
preloadChatQuotaRewardedAd(force = true)
}
}
override fun onAdClicked() = Unit
override fun onAdImpression(impressionData: ImpressionData?) = Unit
override fun onRewarded(reward: Reward) {
if (hasRewardHandledForCurrentAd) return
hasRewardHandledForCurrentAd = true
purchaseRewardedChatQuota()
}
}
private fun preloadChatQuotaRewardedAd(force: Boolean = false) {
if (!force && (chatQuotaRewardedAd != null || chatQuotaRewardedAdLoader != null)) {
return return
} }
quotaTimer = object : android.os.CountDownTimer(duration, 1000L) {
override fun onTick(millisUntilFinished: Long) {
val timeText =
formatMillisToHms((millisUntilFinished + DISPLAY_FUDGE_MS).coerceAtLeast(0L))
// 안내 갱신
ensureQuotaNoticeShown(timeText)
}
override fun onFinish() { val adUnitId = BuildConfig.YANDEX_REWARDED_CHAT_QUOTA_AD_UNIT_ID
ensureQuotaNoticeShown( if (adUnitId.isBlank()) return
SodaLiveApplicationHolder.get()
.getString(R.string.screen_audio_content_detail_time_default)
)
checkQuotaStatus()
}
}.start()
}
private fun stopQuotaCountdown() { chatQuotaRewardedAdLoader?.setAdLoadListener(null)
quotaTimer?.cancel() chatQuotaRewardedAdLoader = RewardedAdLoader(this).apply {
quotaTimer = null setAdLoadListener(chatQuotaRewardedAdLoadListener)
} }
chatQuotaRewardedAdLoader?.loadAd(
private fun checkQuotaStatus() { AdRequestConfiguration.Builder(adUnitId).build()
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
chatRepository.getChatQuotaStatus(roomId, token)
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
updateQuotaUi(resp.nextRechargeAtEpoch)
}, { /* 무시: 다음 틱에 재시도 가능 */ })
) )
} }
private fun formatEpochToHms(epoch: Long?): String? { private fun showChatQuotaRewardedAd() {
if (epoch == null) return null if (isQuotaPurchaseInFlight || isChatQuotaRewardedAdShowing) return
val ms = if (epoch < 1_000_000_000_000L) epoch * 1000 else epoch
val remain = ms - System.currentTimeMillis() val rewardedAd = chatQuotaRewardedAd
val displayMs = (remain + DISPLAY_FUDGE_MS).coerceAtLeast(0L) if (rewardedAd == null) {
return if (displayMs > 0L) { preloadChatQuotaRewardedAd(force = true)
formatMillisToHms(displayMs) showToast(getString(R.string.chat_quota_rewarded_ad_unavailable))
} else { return
SodaLiveApplicationHolder.get() }
.getString(R.string.screen_audio_content_detail_time_default)
hasRewardHandledForCurrentAd = false
isChatQuotaRewardedAdShowing = true
rewardedAd.setAdEventListener(chatQuotaRewardedAdEventListener)
runCatching {
rewardedAd.show(this)
}.onFailure {
isChatQuotaRewardedAdShowing = false
clearChatQuotaRewardedAd()
preloadChatQuotaRewardedAd(force = true)
showToast(getString(R.string.chat_quota_rewarded_ad_unavailable))
} }
} }
private fun formatMillisToHms(ms: Long): String { private fun clearChatQuotaRewardedAd() {
var totalSec = (ms / 1000).coerceAtLeast(0) chatQuotaRewardedAd?.setAdEventListener(null)
val hours = totalSec / 3600 chatQuotaRewardedAd = null
totalSec %= 3600 hasRewardHandledForCurrentAd = false
val minutes = totalSec / 60 isChatQuotaRewardedAdShowing = false
val seconds = totalSec % 60
return String.format(
locale = Locale.getDefault(),
"%02d:%02d:%02d",
hours,
minutes,
seconds
)
} }
override fun onDestroy() { private fun releaseChatQuotaRewardedAd() {
stopQuotaCountdown() chatQuotaRewardedAdLoader?.setAdLoadListener(null)
super.onDestroy() chatQuotaRewardedAdLoader = null
clearChatQuotaRewardedAd()
} }
// endregion Quota handling // endregion Quota handling
@@ -605,11 +680,17 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
.subscribe({ localList -> .subscribe({ localList ->
if (localList.isNotEmpty() && items.isEmpty()) { if (localList.isNotEmpty() && items.isEmpty()) {
val localItems = localList val localItems = localList
.sortedWith(compareBy<ChatMessage> { it.createdAt }.thenBy { it.messageId } .sortedWith(
.thenBy { it.localId ?: "" }) compareBy<ChatMessage> { it.createdAt }
.thenBy { it.messageId }
.thenBy { it.localId ?: "" }
)
.map { msg -> .map { msg ->
if (msg.mine) ChatListItem.UserMessage(msg) if (msg.mine) {
else ChatListItem.AiMessage(msg, characterInfo?.name) ChatListItem.UserMessage(msg)
} else {
ChatListItem.AiMessage(msg, characterInfo?.name)
}
} }
items.clear() items.clear()
items.addAll(localItems) items.addAll(localItems)
@@ -665,7 +746,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
isLoading = false isLoading = false
// 쿼터 UI 갱신 // 쿼터 UI 갱신
updateQuotaUi(response.nextRechargeAtEpoch) updateQuotaUi(response.totalRemaining)
// 7.3: 오래된 메시지 정리(백그라운드) // 7.3: 오래된 메시지 정리(백그라운드)
compositeDisposable.add( compositeDisposable.add(
@@ -760,8 +841,11 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
.map { it.toDomain() } .map { it.toDomain() }
.filter { !existingIds.contains(it.messageId) } .filter { !existingIds.contains(it.messageId) }
.map { domain -> .map { domain ->
if (domain.mine) ChatListItem.UserMessage(domain) if (domain.mine) {
else ChatListItem.AiMessage(domain, characterInfo?.name) ChatListItem.UserMessage(domain)
} else {
ChatListItem.AiMessage(domain, characterInfo?.name)
}
} }
// 상단에 추가하면서 스크롤 위치 보정 // 상단에 추가하면서 스크롤 위치 보정
@@ -955,8 +1039,12 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
).show(resources.displayMetrics.widthPixels) ).show(resources.displayMetrics.widthPixels)
} }
override fun onDestroy() {
releaseChatQuotaRewardedAd()
super.onDestroy()
}
companion object { companion object {
private const val DISPLAY_FUDGE_MS: Long = 5_000L
const val EXTRA_ROOM_ID: String = "extra_room_id" const val EXTRA_ROOM_ID: String = "extra_room_id"
fun newIntent(context: Context, roomId: Long): Intent { fun newIntent(context: Context, roomId: Long): Intent {