Development record of developer who study hard everyday.

레이블이 안드로이드 인앱결제 구현인 게시물을 표시합니다. 모든 게시물 표시
레이블이 안드로이드 인앱결제 구현인 게시물을 표시합니다. 모든 게시물 표시
, , ,

안드로이드 인앱결제 5.0 구현 예제

 안드로이드 인앱결제 5.0 구현 예제 

안드로이드 개발 블로그

회사에서 웹앱 프로젝트를 진행하는 중 인앱결제를 구현해야할 일이 생겼다.

웹앱이기때문에 인앱결제를 진행하여 인앱결제 결과를 서버에 보내주면 되었다.

개인적으로 인앱결제는 코드로 구현하는 것보다 설정과 테스트가 힘들었다.

인앱결제를 위한 각종 설정과 테스트 방법은 아래 링크를 참고하기를 바란다.

따로 정리해 두었다.

인앱결제 설정 및 테스트 방법


인앱결제를 구현할 때 MVVM 패턴을 적용하였고 비즈니스 로직은 BillingManager에 구현하였다.

그럼, 인앱결제를 구현해보자!!

1. dependency 추가


dependencies {
//인앱결제
  def billing_version = "5.0.0"
implementation "com.android.billingclient:billing-ktx:$billing_version"

}


2. BillingManager 클래스 만들기

인앱결제를 위해 billing 라이브러리에서 호출하는 메서드들을 관리하는 BillingManager 클래스를 만든다.

일단, 그냥 싱글톤으로 만든다.

class BillingManager(
private val application: Application
) {
lateinit var billingClient: BillingClient                           //1
private lateinit var productDetailsResult: ProductDetailsResult     //2

companion object {
private val TAG = BillingManager::class.java.simpleName

const val IN_APP_TYPE = BillingClient.ProductType.INAPP         //3

const val CO_PARENT_ITEM = "com.xxxx.xxxx.increase_coparent"    //4
const val REMOVE_ADS_ITEM = "com.xxxx.xxxx.remove_ads"          //5

@Volatile
private var INSTANCE: BillingManager? = null

fun getInstance(app: Application): BillingManager = INSTANCE ?: synchronized(this) {
INSTANCE ?: BillingManager(app).also {
INSTANCE = it

}
}
}
}

1=> 인앱결제 관련 메소드들을 호출할 수 있는 BillingClient 객체를 담는 변수

2=> 인앱상품들을 쿼리한 결과를 담는 변수

3 => 여기서는 인앱상품 결제만 사용한다.(정기결제 X)

4, 5=> 인앱 상품ID이다. 결제 서버로부터 인앱상품 정보를 가져올 때 필요하다.


3. BillingClient 초기화하기

BillingClient는 GooglePlay 결제 라이브러리와 앱의 통신을 위한 기본 인터페이스이다.

class MainActivity : BaseActivity<ActivityMainBinding>(), PurchasesUpdatedListener {
//1
private val viewModel: MainVM by viewModels { viewModelFactory }

private lateinit var billingManager: BillingManager

private lateinit var userNo: String
private lateinit var productID: String
private var currentProductPrice: String? = ""

override val layoutResID = R.layout.activity_main
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
binding.viewModel = viewModel

initInApp()    //2
}
}
private fun initInApp() {    //3
billingManager = BillingManager.getInstance(application).also { billingManager ->
billingManager.billingClient = BillingClient.newBuilder(this)
.setListener(this)
.enablePendingPurchases()
.build()
}
}
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && !purchases.isNullOrEmpty()) {
Log.d(TAG, "onPurchasesUpdated ok")
val purchase = purchases.firstOrNull() ?: kotlin.run {
Log.d(TAG, "purchases is null")
return
}
        //구매진행(나중에 구현예정)
processPurchase(purchase)

} else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
// Handle an error caused by a user cancelling the purchase flow.
Log.d(TAG, "onPurchasesUpdated user_canceled")         //서버로 인앱결제 실패했다고 알림
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "0", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!)
} else {
// Handle any other error codes.
Log.d(TAG, "onPurchasesUpdated error : ${billingResult.responseCode}, ${billingResult.debugMessage}")         //서버로 인앱결제 실패했다고 알림
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "0", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!)
}
}

1=> 메인액티비티에서 PurchasedUpdatedListener를 구현한다.

인앱상품 구매를 진행하면 PurchasedUpdatedListener가 호출된다.

2=> 메인액티비티 onCreate에서 인앱관련 초기화를 진행한다.

3=> BillingManager를 초기화해주면서 BillingClient를 초기화한다.

 

4. Google Play에 연결

인앱결제를 위해서는 Google Play에 연결해야한다.

BillingManager에 startBillingProcess 메소드를 만들어주고 MainActivity의 onCreate()에서 호출한다.

Google Play와 연결되었을 때는 onBillingSetupFinished가 호출되고 연결이 종료되었을 때는 onBillingServiceDisconnected가 호출된다.


BillingManager 클래스


class BillingManager(
private val application: Application
) {
lateinit var billingClient: BillingClient
private lateinit var productDetailsResult: ProductDetailsResult
    //1
fun startBillingProcess(billingConnectionState: MutableStateFlow<Boolean>) {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
// The BillingClient is ready. You can query purchases here.
Log.d(
TAG,
"onBillingSetupFinished, code: ${billingResult.responseCode}, debug: ${billingResult.debugMessage}"
)
billingConnectionState.value = true
}
}

override fun onBillingServiceDisconnected() {
// Try to restart the connection on the next request to
// Google Play by calling the startConnection() method.
Log.d(TAG, "onBillingServiceDisconnected")
billingConnectionState.value = false
startBillingProcess(billingConnectionState)
}
})
}

companion object {
private val TAG = BillingManager::class.java.simpleName

const val IN_APP_TYPE = BillingClient.ProductType.INAPP

const val CO_PARENT_ITEM = "com.appg.mommaplanner.increase_coparent"
const val REMOVE_ADS_ITEM = "com.appg.mommaplanner.remove_ads"

@Volatile
private var INSTANCE: BillingManager? = null

fun getInstance(app: Application): BillingManager = INSTANCE ?: synchronized(this) {
INSTANCE ?: BillingManager(app).also {
INSTANCE = it

}
}
}
}

MVVM 패턴을 사용하므로 View와 상호작용할 때는 ViewModel을 거친다.

_billingConnectionState : MutableStateFlow<Boolean>객체를 넘겨주어서 Google Play와 연결되면 true를 넣어준다.

그리고 MainActivity에서 billingConnectionState를 관찰한다.


class MainVM @Inject constructor(
application: MainApplication,
) : BaseViewModel(application) {
val locationData = LocationLiveData(application)
private val billingManager by lazy { BillingManager.getInstance(application) }

private val _billingConnectionState = MutableStateFlow(false)
val billingConnectionState: StateFlow<Boolean> = _billingConnectionState

var currentUserNo: String = ""
var currentProductID : String = ""
var currentPurchaseToken : String = ""
var purchaseReceiveCallback : String = ""

fun startInAppPurchase() {
Log.d(TAG, "startInAppPurchase")

billingManager.startBillingProcess(_billingConnectionState)
}

}


MainActivity를 보자

class MainActivity : BaseActivity<ActivityMainBinding>(),
WebViewInterface.WebViewSettingsInterceptor, WebViewInterface.WebViewClientHandler,
WebViewInterface.WebViewDispatcher, PurchasesUpdatedListener {
@Inject
lateinit var preferenceRepository: PreferenceRepository


private val viewModel: MainVM by viewModels { viewModelFactory }

private lateinit var billingManager: BillingManager

private lateinit var userNo: String
private lateinit var productID: String
private var currentProductPrice: String? = ""


override val layoutResID = R.layout.activity_main
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
binding.viewModel = viewModel

init()
initInApp()
initAdmob()
}
}
private fun initInApp() {
billingManager = BillingManager.getInstance(application).also { billingManager ->
billingManager.billingClient = BillingClient.newBuilder(this)
.setListener(this)
.enablePendingPurchases()
.build()
}

viewModel.startInAppPurchase()

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.billingConnectionState.collect { connection ->
if (connection) {                         //Google Play와 연결되면 미완료된 구매가 있는지 확인한다.                         //나중에 구현 예정
viewModel.queryPurchase()
}
}
}
}

}


5. 인앱상품 조회하기

Google Play와 연결되었기 때문에 구매를 시작할 수 있다.

구매를 시작하기 전에 인앱상품을 먼저 조회하자.


ViewModel 클래스


class MainVM @Inject constructor(
application: MainApplication,
) : BaseViewModel(application) {
private val billingManager by lazy { BillingManager.getInstance(application) }

private val _billingConnectionState = MutableStateFlow(false)
val billingConnectionState: StateFlow<Boolean> = _billingConnectionState

private val _productWithProductDetails = MutableLiveData<Map<String, ProductDetails>>(emptyMap())
val productWithProductDetails : LiveData<Map<String, ProductDetails>> = _productWithProductDetails

var currentUserNo: String = ""
var currentProductID : String = ""
var currentPurchaseToken : String = ""
var purchaseReceiveCallback : String = ""


fun startInAppPurchase() {
Log.d(TAG, "startInAppPurchase")

billingManager.startBillingProcess(_billingConnectionState)
}
    //인앱상품 조회 함수: 서버에서 userNo, productID, callback을 받는다.
fun queryProductDetails(userNo: String, productID: String, callback: String) {
viewModelScope.launch {
currentUserNo = userNo
currentProductID = productID
purchaseReceiveCallback = callback

billingManager.queryProductDetails(currentUserNo, currentProductID, _productWithProductDetails)
}
}


}


BillingManager 클래스

class BillingManager(
private val application: Application
) {
lateinit var billingClient: BillingClient
private lateinit var productDetailsResult: ProductDetailsResult

fun startBillingProcess(billingConnectionState: MutableStateFlow<Boolean>) {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
// The BillingClient is ready. You can query purchases here.
Log.d(
TAG,
"onBillingSetupFinished, code: ${billingResult.responseCode}, debug: ${billingResult.debugMessage}"
)
billingConnectionState.value = true
}
}

override fun onBillingServiceDisconnected() {
// Try to restart the connection on the next request to
// Google Play by calling the startConnection() method.
Log.d(TAG, "onBillingServiceDisconnected")
billingConnectionState.value = false
startBillingProcess(billingConnectionState)
}
})
}

suspend fun queryProductDetails(
userNo: String,
productId: String,
_productWithProductDetails: MutableLiveData<Map<String, ProductDetails>>
) {
//현재 판매되는 인앱상품들 리스트
val productList = listOf (
QueryProductDetailsParams.Product.newBuilder()
.setProductId(CO_PARENT_ITEM)
.setProductType(BillingClient.ProductType.INAPP)
.build(),
QueryProductDetailsParams.Product.newBuilder()
.setProductId(REMOVE_ADS_ITEM)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)

val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)

withContext(Dispatchers.IO) {
productDetailsResult = billingClient.queryProductDetails(params.build())

val responseCode = productDetailsResult.billingResult.responseCode
val debugMsg = productDetailsResult.billingResult.debugMessage

if (responseCode == BillingClient.BillingResponseCode.OK) {
Log.d(TAG, "queryProductDetails result : OK")

var productDetailsMap = emptyMap<String, ProductDetails>()
                                 //ProductDetailsResult 객체에서 ProductDetailsList 객체를 꺼낸다.
val productDetailsList = productDetailsResult.productDetailsList ?: listOf()

Log.d(TAG, "productDetailsList.size: ${productDetailsList.size}")

if (productDetailsList.isEmpty()) {
Log.e(
TAG, "onProductDetailsResponse: Found null or empty ProductDetails. " +
"Check to see if the Products you requested are correctly published in the Google Play Console."
)
} else {                     //ProductID를 키로하는 Map객채를 만들어준다.
productDetailsMap = productDetailsList.associateBy { details ->
details.productId
}
}                 //MutableLiveData에 넣어준다
_productWithProductDetails.postValue(productDetailsMap)


} else {
Log.i(TAG, "onProductDetailsResponse: $responseCode $debugMsg")
}
}
}

companion object {
private val TAG = BillingManager::class.java.simpleName

const val IN_APP_TYPE = BillingClient.ProductType.INAPP

const val CO_PARENT_ITEM = "com.appg.mommaplanner.increase_coparent"
const val REMOVE_ADS_ITEM = "com.appg.mommaplanner.remove_ads"

@Volatile
private var INSTANCE: BillingManager? = null

fun getInstance(app: Application): BillingManager = INSTANCE ?: synchronized(this) {
INSTANCE ?: BillingManager(app).also {
INSTANCE = it

}
}
}
}


MainActivity 클래스 initInApp() 메소드

private fun initInApp() {
billingManager = BillingManager.getInstance(application).also { billingManager ->
billingManager.billingClient = BillingClient.newBuilder(this)
.setListener(this)
.enablePendingPurchases()
.build()
}

viewModel.startInAppPurchase()

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.billingConnectionState.collect { connection ->
if (connection) {
viewModel.queryPurchase()
// viewModel.queryProductDetails()
}
}
}
}
        //queryProductDetails()의 결과를 변형한 Map객체를 관찰한다.         //내가 구매해야 할 ID에 해당하는 상품을 구매한다.
viewModel.productWithProductDetails.observe(this) { productMap ->
productMap[viewModel.currentProductID]?.let { productDetails ->                 //구매를 진행하는 processPurchase 메소드.                 //추후에 구현 예정
processPurchase(productDetails)                 //나의 경우 가격을 서버로 전달해줘야되서 가격을 따로 저장했다.
currentProductPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice?.replace(",","")?.substring(1)
Log.d(TAG, "currentProductPrice : $currentProductPrice")
}
}

}


6. 구매흐름 시작

앱에서 구매 요청을 시작할 때는 기본 스레드에서 launchBillingFlow()를 호출한다.

MainActivity 클래스 processPurchase() 메소드 안에서 호출하자.

private fun processPurchase(productDetails: ProductDetails) {
// 1
val productDetailsParamsList = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.build()
)

val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.build()

//2
val billingResult = BillingManager.getInstance(application).billingClient.launchBillingFlow(
this,
billingFlowParams
)
Log.d(TAG, "billingResult, ${billingResult.responseCode}, ${billingResult.debugMessage}")
}

1=> 인앱상품을 쿼리한(queryProductDetails) 다음 내가 구매할 ProductDetails를 가지고 구매흐름을 시작한다.

2=> launchBillingFlow()를 호출하면 onPurchasesUpdated()가 호출된다.


MainActivity 클래스의 onPurchasesUpdated와 processPurchase(purchase: Purchase)

override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && !purchases.isNullOrEmpty()) {
Log.d(TAG, "onPurchasesUpdated ok")         //나는 다중구매 허용 안해서 그냥 첫 번째 값 가져온다
val purchase = purchases.firstOrNull() ?: kotlin.run {
Log.d(TAG, "purchases is null")
return
}

handlePurchase(purchase)

} else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
// Handle an error caused by a user cancelling the purchase flow.         // 구매 실패했다고 서버에 알려줌
Log.d(TAG, "onPurchasesUpdated user_canceled")
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "0", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!)
} else {
// Handle any other error codes.         // 구매 실패했다고 서버에 알려줌
Log.d(TAG, "onPurchasesUpdated error : ${billingResult.responseCode}, ${billingResult.debugMessage}")
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "0", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!)
}
}

private fun handlePurchase(purchase : Purchase) {
when (purchase.purchaseState) {
Purchase.PurchaseState.PURCHASED -> {
if (viewModel.purchaseReceiveCallback.isNotEmpty()) {
Log.d(TAG, "purchase finish")
                                 //소비성 아이템은 consume, 비소비성 아이템은 acknowledge
if(viewModel.currentProductID == BillingManager.CO_PARENT_ITEM) {
//양육자 늘리기는 소비성 아이템
        viewModel.consumePurchase(viewModel.currentUserNo, purchase.purchaseToken)
} else {
//광고가리기는 비소비성 아이템
        viewModel.acknowledgePurchase(viewModel.currentUserNo, purchase.purchaseToken)
}
}
}
Purchase.PurchaseState.PENDING,
Purchase.PurchaseState.UNSPECIFIED_STATE -> {
Log.d(TAG, "onPurchasesUpdated, state: ${purchase.purchaseState}")
}
else -> {
Log.d(TAG, "onPurchasesUpdated, Unknown")
}
}
}

인앱구매를 시작하고나서 구매가 완료되었다고 끝나는게 아니라 구매확인 절차가 필요하다.

이때, 소비성아이템은 consume을 해야하고 비소비성 아이템은 acknowledge를 해줘야한다.


7. 구매처리 or 구매확인

구매 상태가 PURCHASED인지 확인 후 구매처리 or 구매확인을 한다.

구매를 인증하여 사용자에게 자격을 부여하는 과정이라고 생각하면된다.


ViewModel 클래스

class MainVM @Inject constructor(
application: MainApplication,
) : BaseViewModel(application) {
private val billingManager by lazy { BillingManager.getInstance(application) }

private val _billingConnectionState = MutableStateFlow(false)
val billingConnectionState: StateFlow<Boolean> = _billingConnectionState

private val _productWithProductDetails = MutableLiveData<Map<String, ProductDetails>>(emptyMap())
val productWithProductDetails : LiveData<Map<String, ProductDetails>> = _productWithProductDetails

private val _acknowledgeEvent = MutableStateFlow(99)
val acknowledgeEvent: StateFlow<Int> = _acknowledgeEvent

private val _consumeEvent = MutableStateFlow(99)
val consumeEvent: StateFlow<Int> = _consumeEvent

var currentUserNo: String = ""
var currentProductID : String = ""
var currentPurchaseToken : String = ""
var purchaseReceiveCallback : String = ""

fun startInAppPurchase() {
Log.d(TAG, "startInAppPurchase")

billingManager.startBillingProcess(_billingConnectionState)
}

fun queryProductDetails(userNo: String, productID: String, callback: String) {
viewModelScope.launch {
currentUserNo = userNo
currentProductID = productID
purchaseReceiveCallback = callback

billingManager.queryProductDetails(currentUserNo, currentProductID, _productWithProductDetails)
}
}

fun acknowledgePurchase(userNo: String, purchaseToken: String) {
viewModelScope.launch {
currentUserNo = userNo
currentPurchaseToken = purchaseToken

billingManager.acknowledgePurchase(purchaseToken, _acknowledgeEvent)
}
}

fun consumePurchase(userNo: String, purchaseToken: String) {
viewModelScope.launch {
currentUserNo = userNo
currentPurchaseToken = purchaseToken

billingManager.consumePurchase(purchaseToken, _consumeEvent)
}
}

}

👆 acknowledgePurchase와 consumePurchase를 주목한다.


BillingManager 클래스

class BillingManager(
private val application: Application
) {
lateinit var billingClient: BillingClient
private lateinit var productDetailsResult: ProductDetailsResult
/

fun startBillingProcess(billingConnectionState: MutableStateFlow<Boolean>) {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
// The BillingClient is ready. You can query purchases here.
Log.d(
TAG,
"onBillingSetupFinished, code: ${billingResult.responseCode}, debug: ${billingResult.debugMessage}"
)
billingConnectionState.value = true
}
}

override fun onBillingServiceDisconnected() {
// Try to restart the connection on the next request to
// Google Play by calling the startConnection() method.
Log.d(TAG, "onBillingServiceDisconnected")
billingConnectionState.value = false
startBillingProcess(billingConnectionState)
}
})
}

suspend fun queryProductDetails(
userNo: String,
productId: String,
_productWithProductDetails: MutableLiveData<Map<String, ProductDetails>>
) {
//현재 판매되는 인앱상품들 리스트
val productList = listOf (
QueryProductDetailsParams.Product.newBuilder()
.setProductId(CO_PARENT_ITEM)
.setProductType(BillingClient.ProductType.INAPP)
.build(),
QueryProductDetailsParams.Product.newBuilder()
.setProductId(REMOVE_ADS_ITEM)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)

val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)

withContext(Dispatchers.IO) {
productDetailsResult = billingClient.queryProductDetails(params.build())

val responseCode = productDetailsResult.billingResult.responseCode
val debugMsg = productDetailsResult.billingResult.debugMessage

if (responseCode == BillingClient.BillingResponseCode.OK) {
Log.d(TAG, "queryProductDetails result : OK")

var productDetailsMap = emptyMap<String, ProductDetails>()

val productDetailsList = productDetailsResult.productDetailsList ?: listOf()

Log.d(TAG, "productDetailsList.size: ${productDetailsList.size}")

if (productDetailsList.isEmpty()) {
Log.e(
TAG, "onProductDetailsResponse: Found null or empty ProductDetails. " +
"Check to see if the Products you requested are correctly published in the Google Play Console."
)
} else {
productDetailsMap = productDetailsList.associateBy { details ->
details.productId
}
}
// _productWithProductDetails.value = productDetailsMap
_productWithProductDetails.postValue(productDetailsMap)

/*for(productDetails in productDetailsList) {
Log.d(TAG, "title: ${productDetails.title}, name: ${productDetails.name}, id: ${productDetails.productId}, type: ${productDetails.productType}," +
"description: ${productDetails.description}")

if(productId == productDetails.productId) {
Log.d(TAG, "productDetails.productId 구매리스트에 담음")
_productWithProductDetails.value = mapOf(productId to productDetails)
}
}*/

} else {
Log.i(TAG, "onProductDetailsResponse: $responseCode $debugMsg")
}
}
}

suspend fun acknowledgePurchase(purchaseToken: String, _acknowledgeEvent: MutableStateFlow<Int>) {

val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchaseToken)

val ackPurchaseResult = withContext(Dispatchers.IO) {
billingClient.acknowledgePurchase(acknowledgePurchaseParams.build())
}

val responseCode = ackPurchaseResult.responseCode
val debugMsg = ackPurchaseResult.debugMessage
Log.d(TAG, "acknowledgePurchase, responseCode: $responseCode debugMsg: $debugMsg")

_acknowledgeEvent.value = responseCode
}

suspend fun consumePurchase(purchaseToken: String, _consumeEvent: MutableStateFlow<Int>) {
val consumeParams = ConsumeParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()

val consumeResult = withContext(Dispatchers.IO) {
billingClient.consumePurchase(consumeParams)
}

val responseCode = consumeResult.billingResult.responseCode
val debugMsg = consumeResult.billingResult.debugMessage
Log.d(TAG, "consumePurchase, responseCode: $responseCode debugMsg: $debugMsg")

_consumeEvent.value = responseCode
}

companion object {
private val TAG = BillingManager::class.java.simpleName

const val IN_APP_TYPE = BillingClient.ProductType.INAPP

const val CO_PARENT_ITEM = "com.appg.mommaplanner.increase_coparent"
const val REMOVE_ADS_ITEM = "com.appg.mommaplanner.remove_ads"

@Volatile
private var INSTANCE: BillingManager? = null

fun getInstance(app: Application): BillingManager = INSTANCE ?: synchronized(this) {
INSTANCE ?: BillingManager(app).also {
INSTANCE = it

}
}
}
}

👆 acknowledgePurchase와 consumePurchase를 주목한다.

Purchase 객체를 각각 acknowledge와 consume을 해주고 뷰모델에서 건네받은 MutableStateFlow에 값을 넣어준다.


MainActivity 클래스

private fun initInApp() {
billingManager = BillingManager.getInstance(application).also { billingManager ->
billingManager.billingClient = BillingClient.newBuilder(this)
.setListener(this)
.enablePendingPurchases()
.build()
}

viewModel.startInAppPurchase()

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.billingConnectionState.collect { connection ->
if (connection) {
viewModel.queryPurchase()
}
}
}
}

viewModel.productWithProductDetails.observe(this) { productMap ->
productMap[viewModel.currentProductID]?.let { productDetails ->
processPurchase(productDetails)
currentProductPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice?.replace(",","")?.substring(1)
Log.d(TAG, "currentProductPrice : $currentProductPrice")
}
}

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.acknowledgeEvent.collect { responseCode ->
Log.d(TAG, "acknowledgeEvent.collect, responseCode: $responseCode")
when(responseCode) {
BillingClient.BillingResponseCode.OK ->                             //서버에 구매성공 알림
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "1", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!, viewModel.currentPurchaseToken)
99 -> //초기값
              return@collect
else ->                             //서버에 구매 실패 알림
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "0", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!)
}
}
}
}

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.consumeEvent.collect { responseCode ->
Log.d(TAG, "consumeEvent.collect, responseCode: $responseCode")
when(responseCode) {
BillingClient.BillingResponseCode.OK ->                             //서버에 구매 성공 알림
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "1", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!, viewModel.currentPurchaseToken)
99 -> //초기값
              return@collect
else ->                             //서버에 구매 실패 알림
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "0", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!)
}
}
}
}
}

메인 액티비티에서는 consumeEvent와 acknowledgeEvent를 각각 collect해주면서 BillingResponseCode가 OK일 때 서버에 인앱결제 성공을 알린다.


8. 구매조회

사용자가 인앱결제 시 네트워크 상태 등으로 불완전한 결제가 이루어질 때가 있다.

그런 상황을 대비해서 구매를 조회해서 미완결된 구매를 처리해주어야한다.

보통 액티비티 클래스의 onResume()에서 처리해준다.

(나는 프로젝트 특성상 Google Play에 연결이 되면 구매를 조회했다.)


ViewModel 클래스

class MainVM @Inject constructor(
application: MainApplication,
) : BaseViewModel(application) {
private val billingManager by lazy { BillingManager.getInstance(application) }

private val _billingConnectionState = MutableStateFlow(false)
val billingConnectionState: StateFlow<Boolean> = _billingConnectionState

private val _productWithProductDetails = MutableLiveData<Map<String, ProductDetails>>(emptyMap())
val productWithProductDetails : LiveData<Map<String, ProductDetails>> = _productWithProductDetails

private val _historyPurchase = MutableStateFlow<List<Purchase>>(emptyList())
val historyPurchase : StateFlow<List<Purchase>> = _historyPurchase

private val _acknowledgeEvent = MutableStateFlow(99)
val acknowledgeEvent: StateFlow<Int> = _acknowledgeEvent

private val _consumeEvent = MutableStateFlow(99)
val consumeEvent: StateFlow<Int> = _consumeEvent

var currentUserNo: String = ""
var currentProductID : String = ""
var currentPurchaseToken : String = ""
var purchaseReceiveCallback : String = ""

fun startInAppPurchase() {
Log.d(TAG, "startInAppPurchase")

billingManager.startBillingProcess(_billingConnectionState)
}

fun queryProductDetails(userNo: String, productID: String, callback: String) {
viewModelScope.launch {
currentUserNo = userNo
currentProductID = productID
purchaseReceiveCallback = callback

billingManager.queryProductDetails(currentUserNo, currentProductID, _productWithProductDetails)
}
}

fun queryPurchase() {
viewModelScope.launch {
billingManager.queryPurchases(_historyPurchase)
}
}

fun acknowledgePurchase(userNo: String, purchaseToken: String) {
viewModelScope.launch {
currentUserNo = userNo
currentPurchaseToken = purchaseToken

billingManager.acknowledgePurchase(purchaseToken, _acknowledgeEvent)
}
}


fun consumePurchase(userNo: String, purchaseToken: String) {
viewModelScope.launch {
currentUserNo = userNo
currentPurchaseToken = purchaseToken

billingManager.consumePurchase(purchaseToken, _consumeEvent)
}
}

fun terminateInAppPurchase() {
billingManager.terminateBillingConnection()
}

}

👆 queryPurchase()를 주목한다.


BillingManager 클래스


class BillingManager(
private val application: Application
) {
lateinit var billingClient: BillingClient
private lateinit var productDetailsResult: ProductDetailsResult

fun startBillingProcess(billingConnectionState: MutableStateFlow<Boolean>) {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
// The BillingClient is ready. You can query purchases here.
Log.d(
TAG,
"onBillingSetupFinished, code: ${billingResult.responseCode}, debug: ${billingResult.debugMessage}"
)
billingConnectionState.value = true
}
}

override fun onBillingServiceDisconnected() {
// Try to restart the connection on the next request to
// Google Play by calling the startConnection() method.
Log.d(TAG, "onBillingServiceDisconnected")
billingConnectionState.value = false
startBillingProcess(billingConnectionState)
}
})
}

suspend fun queryProductDetails(
userNo: String,
productId: String,
_productWithProductDetails: MutableLiveData<Map<String, ProductDetails>>
) {
//현재 판매되는 인앱상품들 리스트
val productList = listOf (
QueryProductDetailsParams.Product.newBuilder()
.setProductId(CO_PARENT_ITEM)
.setProductType(BillingClient.ProductType.INAPP)
.build(),
QueryProductDetailsParams.Product.newBuilder()
.setProductId(REMOVE_ADS_ITEM)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)

val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)

withContext(Dispatchers.IO) {
productDetailsResult = billingClient.queryProductDetails(params.build())

val responseCode = productDetailsResult.billingResult.responseCode
val debugMsg = productDetailsResult.billingResult.debugMessage

if (responseCode == BillingClient.BillingResponseCode.OK) {
Log.d(TAG, "queryProductDetails result : OK")

var productDetailsMap = emptyMap<String, ProductDetails>()

val productDetailsList = productDetailsResult.productDetailsList ?: listOf()

Log.d(TAG, "productDetailsList.size: ${productDetailsList.size}")

if (productDetailsList.isEmpty()) {
Log.e(
TAG, "onProductDetailsResponse: Found null or empty ProductDetails. " +
"Check to see if the Products you requested are correctly published in the Google Play Console."
)
} else {
productDetailsMap = productDetailsList.associateBy { details ->
details.productId
}
}
_productWithProductDetails.postValue(productDetailsMap)


} else {
Log.i(TAG, "onProductDetailsResponse: $responseCode $debugMsg")
}
}
}

suspend fun acknowledgePurchase(purchaseToken: String, _acknowledgeEvent: MutableStateFlow<Int>) {

val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchaseToken)

val ackPurchaseResult = withContext(Dispatchers.IO) {
billingClient.acknowledgePurchase(acknowledgePurchaseParams.build())
}

val responseCode = ackPurchaseResult.responseCode
val debugMsg = ackPurchaseResult.debugMessage
Log.d(TAG, "acknowledgePurchase, responseCode: $responseCode debugMsg: $debugMsg")

_acknowledgeEvent.value = responseCode
}

suspend fun queryPurchases(_historyPurchase: MutableStateFlow<List<Purchase>>) {
if (!billingClient.isReady) {
Log.e(TAG, "queryPurchases: BillingClient is not ready")
}

val params = QueryPurchasesParams.newBuilder()
.setProductType(IN_APP_TYPE)

withContext(Dispatchers.IO) {
val purchasesResult = billingClient.queryPurchasesAsync(params.build())
Log.d(
TAG,
"queryPurchases, code: ${purchasesResult.billingResult.responseCode}, msg: ${purchasesResult.billingResult.debugMessage} "
)

if (purchasesResult.purchasesList.isNotEmpty()) _historyPurchase.value = purchasesResult.purchasesList
}
}

suspend fun consumePurchase(purchaseToken: String, _consumeEvent: MutableStateFlow<Int>) {
val consumeParams = ConsumeParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()

val consumeResult = withContext(Dispatchers.IO) {
billingClient.consumePurchase(consumeParams)
}

val responseCode = consumeResult.billingResult.responseCode
val debugMsg = consumeResult.billingResult.debugMessage
Log.d(TAG, "consumePurchase, responseCode: $responseCode debugMsg: $debugMsg")

_consumeEvent.value = responseCode
}

companion object {
private val TAG = BillingManager::class.java.simpleName

const val IN_APP_TYPE = BillingClient.ProductType.INAPP

const val CO_PARENT_ITEM = "com.appg.mommaplanner.increase_coparent"
const val REMOVE_ADS_ITEM = "com.appg.mommaplanner.remove_ads"

@Volatile
private var INSTANCE: BillingManager? = null

fun getInstance(app: Application): BillingManager = INSTANCE ?: synchronized(this) {
INSTANCE ?: BillingManager(app).also {
INSTANCE = it

}
}
}
}

👆 queryPurchase()를 주목한다.

미완결된 구매가 있으면 _historyPurchase에 값을 넣어주고 메인 액티비티에서 hiostoryPurchase를 collect 해줄거다.


MainActivity 클래스 initInApp() 메소드

private fun initInApp() {
billingManager = BillingManager.getInstance(application).also { billingManager ->
billingManager.billingClient = BillingClient.newBuilder(this)
.setListener(this)
.enablePendingPurchases()
.build()
}

viewModel.startInAppPurchase()

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.billingConnectionState.collect { connection ->
if (connection) {
viewModel.queryPurchase()
}
}
}
}


viewModel.productWithProductDetails.observe(this) { productMap ->
productMap[viewModel.currentProductID]?.let { productDetails ->
processPurchase(productDetails)
currentProductPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice?.replace(",","")?.substring(1)
Log.d(TAG, "currentProductPrice : $currentProductPrice")
}
}

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.historyPurchase.collect { purchases ->
val purchase = purchases.firstOrNull() ?: kotlin.run {
Log.d(TAG, "purchases is null")
return@collect
}

handlePurchase(purchase)
}
}
}

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.acknowledgeEvent.collect { responseCode ->
Log.d(TAG, "acknowledgeEvent.collect, responseCode: $responseCode")
when(responseCode) {
BillingClient.BillingResponseCode.OK ->
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "1", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!, viewModel.currentPurchaseToken)
99 -> //초기값
return@collect
else ->
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "0", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!)
}
}
}
}

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.consumeEvent.collect { responseCode ->
Log.d(TAG, "consumeEvent.collect, responseCode: $responseCode")
when(responseCode) {
BillingClient.BillingResponseCode.OK ->
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "1", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!, viewModel.currentPurchaseToken)
99 -> //초기값
return@collect
else ->
binding.webView.callJavaScript(viewModel.purchaseReceiveCallback, "0", viewModel.currentUserNo, viewModel.currentProductID, currentProductPrice!!)
}
}
}
}
}

👆 historyPurchase를 collect해주고 Purchase 객체가 있으면 handlePurchase()를 해준다.

handlePurchase()는 구매를 처리하는(consume or acknowledge) 함수였다.


9. GooglePlay 연결 종료

앱을 종료하면 Google Play와의 연결을 종료한다.


ViewModel 클래스

class MainVM @Inject constructor(
application: MainApplication,
) : BaseViewModel(application) {
private val billingManager by lazy { BillingManager.getInstance(application) }

private val _billingConnectionState = MutableStateFlow(false)
val billingConnectionState: StateFlow<Boolean> = _billingConnectionState

private val _productWithProductDetails = MutableLiveData<Map<String, ProductDetails>>(emptyMap())
val productWithProductDetails : LiveData<Map<String, ProductDetails>> = _productWithProductDetails

private val _historyPurchase = MutableStateFlow<List<Purchase>>(emptyList())
val historyPurchase : StateFlow<List<Purchase>> = _historyPurchase

private val _acknowledgeEvent = MutableStateFlow(99)
val acknowledgeEvent: StateFlow<Int> = _acknowledgeEvent

private val _consumeEvent = MutableStateFlow(99)
val consumeEvent: StateFlow<Int> = _consumeEvent

var currentUserNo: String = ""
var currentProductID : String = ""
var currentPurchaseToken : String = ""
var purchaseReceiveCallback : String = ""

fun startInAppPurchase() {
Log.d(TAG, "startInAppPurchase")

billingManager.startBillingProcess(_billingConnectionState)
}

fun queryProductDetails(userNo: String, productID: String, callback: String) {
viewModelScope.launch {
currentUserNo = userNo
currentProductID = productID
purchaseReceiveCallback = callback

billingManager.queryProductDetails(currentUserNo, currentProductID, _productWithProductDetails)
}
}

fun queryPurchase() {
viewModelScope.launch {
billingManager.queryPurchases(_historyPurchase)
}
}

fun acknowledgePurchase(userNo: String, purchaseToken: String) {
viewModelScope.launch {
currentUserNo = userNo
currentPurchaseToken = purchaseToken

billingManager.acknowledgePurchase(purchaseToken, _acknowledgeEvent)
}
}

fun consumePurchase(userNo: String, purchaseToken: String) {
viewModelScope.launch {
currentUserNo = userNo
currentPurchaseToken = purchaseToken

billingManager.consumePurchase(purchaseToken, _consumeEvent)
}
}

fun terminateInAppPurchase() {
billingManager.terminateBillingConnection()
}

}
☝ terminateInAppPurchase 주목


BillingManager 클래스

class BillingManager(
private val application: Application
) {
lateinit var billingClient: BillingClient
private lateinit var productDetailsResult: ProductDetailsResult

//인앱결제
private val purchaseUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
//to be implemented in a later section
val responseCode = billingResult.responseCode
val debugMessage = billingResult.debugMessage
Log.d(TAG, "onPurchasesUpdated, code: $responseCode, debug: $debugMessage")

when (responseCode) {
BillingClient.BillingResponseCode.OK -> {
Log.i(TAG, "onPurchasesUpdated: OK")
if (!purchases.isNullOrEmpty()) {

}
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
Log.i(TAG, "onPurchasesUpdated: User canceled the purchase")
}
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
Log.i(TAG, "onPurchasesUpdated: The user already owns this item")
}
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
Log.e(
TAG, "onPurchasesUpdated: Developer error means that Google Play " +
"does not recognize the configuration. If you are just getting started, " +
"make sure you have configured the application correctly in the " +
"Google Play Console. The SKU product ID must match and the APK you " +
"are using must be signed with release keys."
)
}
}
}

fun startBillingProcess(billingConnectionState: MutableStateFlow<Boolean>) {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
// The BillingClient is ready. You can query purchases here.
Log.d(
TAG,
"onBillingSetupFinished, code: ${billingResult.responseCode}, debug: ${billingResult.debugMessage}"
)
billingConnectionState.value = true
}
}

override fun onBillingServiceDisconnected() {
// Try to restart the connection on the next request to
// Google Play by calling the startConnection() method.
Log.d(TAG, "onBillingServiceDisconnected")
billingConnectionState.value = false
startBillingProcess(billingConnectionState)
}
})
}

suspend fun queryProductDetails(
userNo: String,
productId: String,
_productWithProductDetails: MutableLiveData<Map<String, ProductDetails>>
) {
//현재 판매되는 인앱상품들 리스트
val productList = listOf (
QueryProductDetailsParams.Product.newBuilder()
.setProductId(CO_PARENT_ITEM)
.setProductType(BillingClient.ProductType.INAPP)
.build(),
QueryProductDetailsParams.Product.newBuilder()
.setProductId(REMOVE_ADS_ITEM)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)

val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)

withContext(Dispatchers.IO) {
productDetailsResult = billingClient.queryProductDetails(params.build())

val responseCode = productDetailsResult.billingResult.responseCode
val debugMsg = productDetailsResult.billingResult.debugMessage

if (responseCode == BillingClient.BillingResponseCode.OK) {
Log.d(TAG, "queryProductDetails result : OK")

var productDetailsMap = emptyMap<String, ProductDetails>()

val productDetailsList = productDetailsResult.productDetailsList ?: listOf()

Log.d(TAG, "productDetailsList.size: ${productDetailsList.size}")

if (productDetailsList.isEmpty()) {
Log.e(
TAG, "onProductDetailsResponse: Found null or empty ProductDetails. " +
"Check to see if the Products you requested are correctly published in the Google Play Console."
)
} else {
productDetailsMap = productDetailsList.associateBy { details ->
details.productId
}
}
_productWithProductDetails.postValue(productDetailsMap)

} else {
Log.i(TAG, "onProductDetailsResponse: $responseCode $debugMsg")
}
}
}

suspend fun acknowledgePurchase(purchaseToken: String, _acknowledgeEvent: MutableStateFlow<Int>) {

val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchaseToken)

val ackPurchaseResult = withContext(Dispatchers.IO) {
billingClient.acknowledgePurchase(acknowledgePurchaseParams.build())
}

val responseCode = ackPurchaseResult.responseCode
val debugMsg = ackPurchaseResult.debugMessage
Log.d(TAG, "acknowledgePurchase, responseCode: $responseCode debugMsg: $debugMsg")

_acknowledgeEvent.value = responseCode
}

suspend fun queryPurchases(_historyPurchase: MutableStateFlow<List<Purchase>>) {
if (!billingClient.isReady) {
Log.e(TAG, "queryPurchases: BillingClient is not ready")
}

val params = QueryPurchasesParams.newBuilder()
.setProductType(IN_APP_TYPE)

withContext(Dispatchers.IO) {
val purchasesResult = billingClient.queryPurchasesAsync(params.build())
Log.d(
TAG,
"queryPurchases, code: ${purchasesResult.billingResult.responseCode}, msg: ${purchasesResult.billingResult.debugMessage} "
)

if (purchasesResult.purchasesList.isNotEmpty()) _historyPurchase.value = purchasesResult.purchasesList
}
}

suspend fun consumePurchase(purchaseToken: String, _consumeEvent: MutableStateFlow<Int>) {
val consumeParams = ConsumeParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()

val consumeResult = withContext(Dispatchers.IO) {
billingClient.consumePurchase(consumeParams)
}

val responseCode = consumeResult.billingResult.responseCode
val debugMsg = consumeResult.billingResult.debugMessage
Log.d(TAG, "consumePurchase, responseCode: $responseCode debugMsg: $debugMsg")

_consumeEvent.value = responseCode
}

fun terminateBillingConnection() {
Log.i(TAG, "Terminating connection")
billingClient.endConnection()
}

companion object {
private val TAG = BillingManager::class.java.simpleName

const val IN_APP_TYPE = BillingClient.ProductType.INAPP

const val CO_PARENT_ITEM = "com.appg.mommaplanner.increase_coparent"
const val REMOVE_ADS_ITEM = "com.appg.mommaplanner.remove_ads"

@Volatile
private var INSTANCE: BillingManager? = null

fun getInstance(app: Application): BillingManager = INSTANCE ?: synchronized(this) {
INSTANCE ?: BillingManager(app).also {
INSTANCE = it

}
}
}
}

☝ terminateBillingConnection 주목


MainActivity 클래스 onDestroy()에서 Google Play와의 연결을 종료시킨다.

override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy")
//인앱연결 해지
viewModel.terminateInAppPurchase()
}


Share:
Read More