From c5eb9767aae9d08c6596d22e79cdc9c309ddfca9 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 22 Oct 2025 23:40:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(iap):=20=EC=9D=B8=20=EC=95=B1=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=208.0.0=20=EC=A0=81=EC=9A=A9,=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EB=B3=B4=EC=99=84=EC=82=AC=ED=95=AD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=E2=80=94=20=EC=A6=89=EC=8B=9C=20=EC=86=8C=EB=B9=84?= =?UTF-8?q?,=20ITEM=5FALREADY=5FOWNED=20=EC=B2=98=EB=A6=AC,=20obfuscatedAc?= =?UTF-8?q?countId=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구매 성공 직후 consume 처리하여 재구매 불가(ITEM_ALREADY_OWNED) 이슈 완화 - ITEM_ALREADY_OWNED 응답 시 미소비 구매 자동 정리 및 안내 메시지 - BillingFlowParams에 obfuscatedAccountId 설정으로 계정 연계 강화 - 서비스 연결 문제에 대한 사용자 메시지 보강 --- app/build.gradle | 2 +- .../can/charge/iap/CanChargeIapFragment.kt | 117 +++++++++++++----- 2 files changed, 87 insertions(+), 32 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 81d8ca59..0c5b99a2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/charge/iap/CanChargeIapFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/charge/iap/CanChargeIapFragment.kt index 007af4bf..6d83ddc6 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/charge/iap/CanChargeIapFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/charge/iap/CanChargeIapFragment.kt @@ -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::inflate @@ -120,29 +123,51 @@ class CanChargeIapFragment : BaseFragment( 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( ) .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( 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( .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 + ) + } + } }