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.SimpleItemAnimator
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.schedulers.Schedulers
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterType
import kr.co.vividnext.sodalive.common.LoadingDialog
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.extensions.dpToPx
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 org.koin.android.ext.android.inject
import java.util.Locale
class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
ActivityChatRoomBinding::inflate
@@ -48,13 +58,18 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
private var hasMoreMessages: Boolean = true // Repository 연동 시 서버 값으로 갱신 예정
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 보관)
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 setNoticeHidden(hidden: Boolean) {
ChatRoomPreferenceManager.putBoolean(noticePrefKey(roomId), hidden)
@@ -132,7 +147,9 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// 타입 배지 텍스트 및 배경
val (badgeText, badgeBg) = when (info.characterType) {
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.setBackgroundResource(badgeBg)
@@ -198,7 +215,11 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
}
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 갱신
updateQuotaUi(response.nextRechargeAtEpoch)
updateQuotaUi(response.totalRemaining)
}, { error ->
// 실패: 타이핑 인디케이터 제거 및 FAILED로 업데이트
chatAdapter.hideTypingIndicator()
@@ -461,17 +482,34 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// endregion 6.2 Send flow
// 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}"
isQuotaPurchaseInFlight = true
compositeDisposable.add(
chatRepository.purchaseChatQuota(roomId, token)
chatRepository.purchaseChatQuota(
roomId = roomId,
token = token,
chargeType = ChatRoomQuotaChargeType.CAN,
canOption = canOption
)
.doFinally { isQuotaPurchaseInFlight = false }
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
// 쿼터 UI 갱신
updateQuotaUi(resp.nextRechargeAtEpoch)
updateQuotaUi(resp.totalRemaining)
// 결제 성공 시 로컬 캔 차감(30캔) 및 헤더 배지 즉시 반영
val newCan = (SharedPreferenceManager.can - 30).coerceAtLeast(0)
val newCan = (SharedPreferenceManager.can - canOption.needCan).coerceAtLeast(0)
SharedPreferenceManager.can = newCan
binding.tvCanBadge.text = newCan.moneyFormat()
}, { err ->
@@ -480,33 +518,47 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
)
}
private fun updateQuotaUi(nextRechargeAtEpoch: Long?) {
if (nextRechargeAtEpoch != null) {
// 입력창 숨김 및 안내 표시 + 카운트다운 시작
private fun purchaseRewardedChatQuota() {
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
val timeText = formatEpochToHms(nextRechargeAtEpoch)
ensureQuotaNoticeShown(timeText)
startQuotaCountdown(nextRechargeAtEpoch)
ensureQuotaNoticeShown()
} else {
// 입력창 표시 및 안내 제거
binding.inputContainer.isVisible = true
stopQuotaCountdown()
ensureQuotaNoticeRemoved()
}
if (totalRemaining <= 1) {
preloadChatQuotaRewardedAd()
}
}
private fun ensureQuotaNoticeShown(timeText: String?) {
private fun ensureQuotaNoticeShown() {
val idx = items.indexOfLast { it is ChatListItem.QuotaNotice }
val newItem = ChatListItem.QuotaNotice(timeText = timeText)
if (idx >= 0) {
val old = items[idx] as ChatListItem.QuotaNotice
// 동일 시간 텍스트면 불필요한 갱신 회피
if (old.timeText == newItem.timeText) return
items[idx] = newItem
chatAdapter.setItems(items)
} else {
appendMessage(newItem)
}
if (idx >= 0) return
appendMessage(ChatListItem.QuotaNotice)
}
private fun ensureQuotaNoticeRemoved() {
@@ -517,81 +569,104 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
}
}
private fun startQuotaCountdown(targetEpoch: Long?) {
stopQuotaCountdown()
if (targetEpoch == null) return
val targetMs = if (targetEpoch < 1_000_000_000_000L) targetEpoch * 1000 else targetEpoch
val now = System.currentTimeMillis()
val duration = targetMs - now
if (duration <= 0) {
checkQuotaStatus()
private val chatQuotaRewardedAdLoadListener = object : RewardedAdLoadListener {
override fun onAdLoaded(rewardedAd: RewardedAd) {
chatQuotaRewardedAdLoader = null
clearChatQuotaRewardedAd()
chatQuotaRewardedAd = rewardedAd
hasRewardHandledForCurrentAd = false
}
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
}
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() {
ensureQuotaNoticeShown(
SodaLiveApplicationHolder.get()
.getString(R.string.screen_audio_content_detail_time_default)
)
checkQuotaStatus()
}
}.start()
}
val adUnitId = BuildConfig.YANDEX_REWARDED_CHAT_QUOTA_AD_UNIT_ID
if (adUnitId.isBlank()) return
private fun stopQuotaCountdown() {
quotaTimer?.cancel()
quotaTimer = null
}
private fun checkQuotaStatus() {
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
chatRepository.getChatQuotaStatus(roomId, token)
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
updateQuotaUi(resp.nextRechargeAtEpoch)
}, { /* 무시: 다음 틱에 재시도 가능 */ })
chatQuotaRewardedAdLoader?.setAdLoadListener(null)
chatQuotaRewardedAdLoader = RewardedAdLoader(this).apply {
setAdLoadListener(chatQuotaRewardedAdLoadListener)
}
chatQuotaRewardedAdLoader?.loadAd(
AdRequestConfiguration.Builder(adUnitId).build()
)
}
private fun formatEpochToHms(epoch: Long?): String? {
if (epoch == null) return null
val ms = if (epoch < 1_000_000_000_000L) epoch * 1000 else epoch
val remain = ms - System.currentTimeMillis()
val displayMs = (remain + DISPLAY_FUDGE_MS).coerceAtLeast(0L)
return if (displayMs > 0L) {
formatMillisToHms(displayMs)
} else {
SodaLiveApplicationHolder.get()
.getString(R.string.screen_audio_content_detail_time_default)
private fun showChatQuotaRewardedAd() {
if (isQuotaPurchaseInFlight || isChatQuotaRewardedAdShowing) return
val rewardedAd = chatQuotaRewardedAd
if (rewardedAd == null) {
preloadChatQuotaRewardedAd(force = true)
showToast(getString(R.string.chat_quota_rewarded_ad_unavailable))
return
}
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 {
var totalSec = (ms / 1000).coerceAtLeast(0)
val hours = totalSec / 3600
totalSec %= 3600
val minutes = totalSec / 60
val seconds = totalSec % 60
return String.format(
locale = Locale.getDefault(),
"%02d:%02d:%02d",
hours,
minutes,
seconds
)
private fun clearChatQuotaRewardedAd() {
chatQuotaRewardedAd?.setAdEventListener(null)
chatQuotaRewardedAd = null
hasRewardHandledForCurrentAd = false
isChatQuotaRewardedAdShowing = false
}
override fun onDestroy() {
stopQuotaCountdown()
super.onDestroy()
private fun releaseChatQuotaRewardedAd() {
chatQuotaRewardedAdLoader?.setAdLoadListener(null)
chatQuotaRewardedAdLoader = null
clearChatQuotaRewardedAd()
}
// endregion Quota handling
@@ -605,11 +680,17 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
.subscribe({ localList ->
if (localList.isNotEmpty() && items.isEmpty()) {
val localItems = localList
.sortedWith(compareBy<ChatMessage> { it.createdAt }.thenBy { it.messageId }
.thenBy { it.localId ?: "" })
.sortedWith(
compareBy<ChatMessage> { it.createdAt }
.thenBy { it.messageId }
.thenBy { it.localId ?: "" }
)
.map { msg ->
if (msg.mine) ChatListItem.UserMessage(msg)
else ChatListItem.AiMessage(msg, characterInfo?.name)
if (msg.mine) {
ChatListItem.UserMessage(msg)
} else {
ChatListItem.AiMessage(msg, characterInfo?.name)
}
}
items.clear()
items.addAll(localItems)
@@ -665,7 +746,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
isLoading = false
// 쿼터 UI 갱신
updateQuotaUi(response.nextRechargeAtEpoch)
updateQuotaUi(response.totalRemaining)
// 7.3: 오래된 메시지 정리(백그라운드)
compositeDisposable.add(
@@ -760,8 +841,11 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
.map { it.toDomain() }
.filter { !existingIds.contains(it.messageId) }
.map { domain ->
if (domain.mine) ChatListItem.UserMessage(domain)
else ChatListItem.AiMessage(domain, characterInfo?.name)
if (domain.mine) {
ChatListItem.UserMessage(domain)
} else {
ChatListItem.AiMessage(domain, characterInfo?.name)
}
}
// 상단에 추가하면서 스크롤 위치 보정
@@ -955,8 +1039,12 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
).show(resources.displayMetrics.widthPixels)
}
override fun onDestroy() {
releaseChatQuotaRewardedAd()
super.onDestroy()
}
companion object {
private const val DISPLAY_FUDGE_MS: Long = 5_000L
const val EXTRA_ROOM_ID: String = "extra_room_id"
fun newIntent(context: Context, roomId: Long): Intent {