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 + ) + } + } }