feat(chat-room): 채팅 쿼터 광고/캔 충전 흐름을 연결한다
This commit is contained in:
@@ -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
|
||||
chatQuotaRewardedAdLoader?.setAdLoadListener(null)
|
||||
chatQuotaRewardedAdLoader = RewardedAdLoader(this).apply {
|
||||
setAdLoadListener(chatQuotaRewardedAdLoadListener)
|
||||
}
|
||||
|
||||
private fun checkQuotaStatus() {
|
||||
val token = "Bearer ${SharedPreferenceManager.token}"
|
||||
compositeDisposable.add(
|
||||
chatRepository.getChatQuotaStatus(roomId, token)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ resp ->
|
||||
updateQuotaUi(resp.nextRechargeAtEpoch)
|
||||
}, { /* 무시: 다음 틱에 재시도 가능 */ })
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user