fix(iap): 인 앱 결제 라이브러리 버전 8.0.0 적용, 결제 보완사항 적용 — 즉시 소비, ITEM_ALREADY_OWNED 처리, obfuscatedAccountId 설정
- 구매 성공 직후 consume 처리하여 재구매 불가(ITEM_ALREADY_OWNED) 이슈 완화 - ITEM_ALREADY_OWNED 응답 시 미소비 구매 자동 정리 및 안내 메시지 - BillingFlowParams에 obfuscatedAccountId 설정으로 계정 연계 강화 - 서비스 연결 문제에 대한 사용자 메시지 보강
This commit is contained in:
@@ -174,7 +174,7 @@ dependencies {
|
|||||||
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
|
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
|
||||||
|
|
||||||
// google in-app-purchase
|
// google in-app-purchase
|
||||||
implementation "com.android.billingclient:billing-ktx:6.2.0"
|
implementation "com.android.billingclient:billing-ktx:8.0.0"
|
||||||
|
|
||||||
// ROOM
|
// ROOM
|
||||||
ksp "androidx.room:room-compiler:2.7.0"
|
ksp "androidx.room:room-compiler:2.7.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.graphics.Rect
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.util.Base64
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
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.BillingFlowParams
|
||||||
import com.android.billingclient.api.BillingResult
|
import com.android.billingclient.api.BillingResult
|
||||||
import com.android.billingclient.api.ConsumeParams
|
import com.android.billingclient.api.ConsumeParams
|
||||||
|
import com.android.billingclient.api.PendingPurchasesParams
|
||||||
import com.android.billingclient.api.ProductDetails
|
import com.android.billingclient.api.ProductDetails
|
||||||
import com.android.billingclient.api.Purchase
|
import com.android.billingclient.api.Purchase
|
||||||
import com.android.billingclient.api.PurchasesUpdatedListener
|
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.extensions.dpToPx
|
||||||
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
|
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
|
class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
|
||||||
FragmentCanChargeIapBinding::inflate
|
FragmentCanChargeIapBinding::inflate
|
||||||
@@ -120,29 +123,51 @@ class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
|
|||||||
private fun setupBillingClient() {
|
private fun setupBillingClient() {
|
||||||
purchaseUpdateListener = PurchasesUpdatedListener { billingResult, purchases ->
|
purchaseUpdateListener = PurchasesUpdatedListener { billingResult, purchases ->
|
||||||
handler.post {
|
handler.post {
|
||||||
if (
|
when (billingResult.responseCode) {
|
||||||
billingResult.responseCode == BillingClient.BillingResponseCode.OK &&
|
BillingClient.BillingResponseCode.OK -> {
|
||||||
purchases != null &&
|
if (purchases != null && selectedProductDetails != null) {
|
||||||
selectedProductDetails != null
|
for (purchase in purchases) {
|
||||||
) {
|
handlePurchase(purchase)
|
||||||
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())
|
billingClient = BillingClient.newBuilder(requireActivity())
|
||||||
.enablePendingPurchases()
|
|
||||||
.setListener(purchaseUpdateListener)
|
.setListener(purchaseUpdateListener)
|
||||||
|
.enablePendingPurchases(
|
||||||
|
PendingPurchasesParams.newBuilder()
|
||||||
|
.enableOneTimeProducts()
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.enableAutoServiceReconnection()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
loadingDialog.show(screenWidth)
|
loadingDialog.show(screenWidth)
|
||||||
@@ -187,9 +212,9 @@ class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
|
|||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
|
billingClient.queryProductDetailsAsync(params) { billingResult, result ->
|
||||||
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
handler.post { adapter.addItems(productDetailsList) }
|
handler.post { adapter.addItems(result.productDetailsList) }
|
||||||
} else {
|
} else {
|
||||||
viewModel.showToast("인 앱 결제 이용이 불가능 합니다. 다시 시도해 주세요.")
|
viewModel.showToast("인 앱 결제 이용이 불가능 합니다. 다시 시도해 주세요.")
|
||||||
}
|
}
|
||||||
@@ -239,28 +264,43 @@ class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
|
|||||||
purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
|
purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
|
||||||
!purchase.isAcknowledged
|
!purchase.isAcknowledged
|
||||||
) {
|
) {
|
||||||
|
val currentProduct = selectedProductDetails ?: return
|
||||||
|
// 서버 검증/지급 먼저 수행
|
||||||
viewModel.chargeCan(
|
viewModel.chargeCan(
|
||||||
title = selectedProductDetails!!.name,
|
title = currentProduct.name,
|
||||||
selectedProductDetails = selectedProductDetails!!,
|
selectedProductDetails = currentProduct,
|
||||||
purchase = purchase
|
purchase = purchase
|
||||||
) { chargeCan ->
|
) { chargeCan ->
|
||||||
handler.post {
|
// 지급 성공 → 즉시 소비 처리 후 UI 업데이트
|
||||||
viewModel.showToast("캔이 충전되었습니다")
|
if (!purchase.isAcknowledged) {
|
||||||
SharedPreferenceManager.can += chargeCan
|
consumePurchase(purchase) {
|
||||||
|
onPurchaseConsumed(chargeCan)
|
||||||
if (activity != null) {
|
|
||||||
if (activity as? CanChargeActivity != null) {
|
|
||||||
(activity as CanChargeActivity).successIapCharge()
|
|
||||||
} else {
|
|
||||||
requireActivity().finish()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} 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) {
|
private fun launchPurchaseFlow(productDetails: ProductDetails) {
|
||||||
|
val obfuscatedAccountId = obfuscateAccountId(SharedPreferenceManager.userId)
|
||||||
val billingFlowParams = BillingFlowParams.newBuilder()
|
val billingFlowParams = BillingFlowParams.newBuilder()
|
||||||
.setProductDetailsParamsList(
|
.setProductDetailsParamsList(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -268,8 +308,23 @@ class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
|
|||||||
.setProductDetails(productDetails)
|
.setProductDetails(productDetails)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
).build()
|
)
|
||||||
|
.setObfuscatedAccountId(obfuscatedAccountId)
|
||||||
|
.build()
|
||||||
|
|
||||||
billingClient.launchBillingFlow(requireActivity(), billingFlowParams)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user