diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt index ba440bd1..7c43acce 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt @@ -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::inflate @@ -48,13 +58,18 @@ class ChatRoomActivity : BaseActivity( 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( // 타입 배지 텍스트 및 배경 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( } override fun onPurchaseQuota() { - onPurchaseQuotaClicked() + onQuotaNoticeAction(ChatQuotaNoticeAction.REWARDED_AD) + } + + override fun onQuotaNoticeAction(action: ChatQuotaNoticeAction) { + this@ChatRoomActivity.onQuotaNoticeAction(action) } }) } @@ -381,7 +402,7 @@ class ChatRoomActivity : BaseActivity( } // 응답에 포함된 쿼터 상태로 UI 갱신 - updateQuotaUi(response.nextRechargeAtEpoch) + updateQuotaUi(response.totalRemaining) }, { error -> // 실패: 타이핑 인디케이터 제거 및 FAILED로 업데이트 chatAdapter.hideTypingIndicator() @@ -461,17 +482,34 @@ class ChatRoomActivity : BaseActivity( // 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( ) } - 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( } } - 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( .subscribe({ localList -> if (localList.isNotEmpty() && items.isEmpty()) { val localItems = localList - .sortedWith(compareBy { it.createdAt }.thenBy { it.messageId } - .thenBy { it.localId ?: "" }) + .sortedWith( + compareBy { 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( isLoading = false // 쿼터 UI 갱신 - updateQuotaUi(response.nextRechargeAtEpoch) + updateQuotaUi(response.totalRemaining) // 7.3: 오래된 메시지 정리(백그라운드) compositeDisposable.add( @@ -760,8 +841,11 @@ class ChatRoomActivity : BaseActivity( .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( ).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 {