fix(iap): 인 앱 결제 라이브러리 버전 8.0.0 적용, 결제 보완사항 적용 — 즉시 소비, ITEM_ALREADY_OWNED 처리, obfuscatedAccountId 설정

- 구매 성공 직후 consume 처리하여 재구매 불가(ITEM_ALREADY_OWNED) 이슈 완화
- ITEM_ALREADY_OWNED 응답 시 미소비 구매 자동 정리 및 안내 메시지
- BillingFlowParams에 obfuscatedAccountId 설정으로 계정 연계 강화
- 서비스 연결 문제에 대한 사용자 메시지 보강
This commit is contained in:
2025-10-22 23:40:14 +09:00
parent 24672b7cf2
commit c5eb9767aa
2 changed files with 87 additions and 32 deletions

View File

@@ -174,7 +174,7 @@ dependencies {
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
// google in-app-purchase
implementation "com.android.billingclient:billing-ktx:6.2.0"
implementation "com.android.billingclient:billing-ktx:8.0.0"
// ROOM
ksp "androidx.room:room-compiler:2.7.0"

View File

@@ -5,6 +5,7 @@ import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
@@ -14,6 +15,7 @@ import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
@@ -26,6 +28,7 @@ import kr.co.vividnext.sodalive.databinding.FragmentCanChargeIapBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import org.koin.android.ext.android.inject
import java.security.MessageDigest
class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
FragmentCanChargeIapBinding::inflate
@@ -120,29 +123,51 @@ class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
private fun setupBillingClient() {
purchaseUpdateListener = PurchasesUpdatedListener { billingResult, purchases ->
handler.post {
if (
billingResult.responseCode == BillingClient.BillingResponseCode.OK &&
purchases != null &&
selectedProductDetails != null
) {
for (purchase in purchases) {
handlePurchase(purchase)
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
if (purchases != null && selectedProductDetails != null) {
for (purchase in purchases) {
handlePurchase(purchase)
}
} else {
selectedProductDetails = null
viewModel.showToast("구매 정보를 확인할 수 없습니다. 다시 시도해 주세요.")
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
selectedProductDetails = null
viewModel.showToast("구매를 취소했습니다.")
}
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
selectedProductDetails = null
queryAndConsumeUnconsumedPurchases()
viewModel.showToast("이전에 완료되지 않은 구매를 정리했습니다. 다시 시도해 주세요.")
}
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> {
selectedProductDetails = null
viewModel.showToast("결제 서비스 연결이 원활하지 않습니다. 네트워크 상태를 확인 후 다시 시도해 주세요.")
}
else -> {
selectedProductDetails = null
viewModel.showToast("구매를 하지 못했습니다. 다시 시도해 주세요.")
}
} else if (
billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED
) {
selectedProductDetails = null
viewModel.showToast("구매를 취소했습니다.")
} else {
selectedProductDetails = null
viewModel.showToast("구매를 하지 못했습니다.\n다시 시도해 주세요.")
}
}
}
billingClient = BillingClient.newBuilder(requireActivity())
.enablePendingPurchases()
.setListener(purchaseUpdateListener)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
)
.enableAutoServiceReconnection()
.build()
loadingDialog.show(screenWidth)
@@ -187,9 +212,9 @@ class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
)
.build()
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
billingClient.queryProductDetailsAsync(params) { billingResult, result ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
handler.post { adapter.addItems(productDetailsList) }
handler.post { adapter.addItems(result.productDetailsList) }
} else {
viewModel.showToast("인 앱 결제 이용이 불가능 합니다. 다시 시도해 주세요.")
}
@@ -239,28 +264,43 @@ class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
!purchase.isAcknowledged
) {
val currentProduct = selectedProductDetails ?: return
// 서버 검증/지급 먼저 수행
viewModel.chargeCan(
title = selectedProductDetails!!.name,
selectedProductDetails = selectedProductDetails!!,
title = currentProduct.name,
selectedProductDetails = currentProduct,
purchase = purchase
) { chargeCan ->
handler.post {
viewModel.showToast("캔이 충전되었습니다")
SharedPreferenceManager.can += chargeCan
if (activity != null) {
if (activity as? CanChargeActivity != null) {
(activity as CanChargeActivity).successIapCharge()
} else {
requireActivity().finish()
}
// 지급 성공 → 즉시 소비 처리 후 UI 업데이트
if (!purchase.isAcknowledged) {
consumePurchase(purchase) {
onPurchaseConsumed(chargeCan)
}
} else {
onPurchaseConsumed(chargeCan)
}
}
}
}
private fun onPurchaseConsumed(chargeCan: Int) {
handler.post {
selectedProductDetails = null
viewModel.showToast("캔이 충전되었습니다")
SharedPreferenceManager.can += chargeCan
if (activity != null) {
if (activity as? CanChargeActivity != null) {
(activity as CanChargeActivity).successIapCharge()
} else {
requireActivity().finish()
}
}
}
}
private fun launchPurchaseFlow(productDetails: ProductDetails) {
val obfuscatedAccountId = obfuscateAccountId(SharedPreferenceManager.userId)
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
@@ -268,8 +308,23 @@ class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
.setProductDetails(productDetails)
.build()
)
).build()
)
.setObfuscatedAccountId(obfuscatedAccountId)
.build()
billingClient.launchBillingFlow(requireActivity(), billingFlowParams)
}
private fun obfuscateAccountId(userId: Long): String {
return try {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest("voiceon_user_$userId".toByteArray(Charsets.UTF_8))
Base64.encodeToString(digest, Base64.NO_WRAP)
} catch (e: Exception) {
Base64.encodeToString(
"voiceon_user_$userId".toByteArray(Charsets.UTF_8),
Base64.NO_WRAP
)
}
}
}