반응형
개인 프로젝트를 진행하면서 식권 구매 페이지에 결제 시스템을 넣어야 해서 전에 한번 해본 적이 있는 구글 인 앱 결제로 진행하였고 블로그에 결제 관련 로직을 정리해보려 한다.
일단 구글 인 앱 결제를 이용하기 위해선 google play console에서 개발자 계정을 만든 뒤 앱을 업로드하는 게 필요하다. 나 같은 경우는 알파 버전에 앱을 올리고 인앱 상품을 만들어 테스트 한 뒤 프로덕션으로 올리는 방식으로 진행하였다. 아무튼 이 과정은 생략하고 코드 쪽에 대한 설명으로 포스팅하려 한다.
먼저 BillingHelper.kt을 구현할 것이다. 이 파일은 인앱 결제를 위한 클라이언트 객체를 만들어주기 위한 클래스다.
import android.content.Context
import com.android.billingclient.api.*
class BillingHelper(val context: Context, val listener: PurchasesUpdatedListener){
companion object{
@Volatile
private var instance: BillingClient? = null
@Synchronized
fun getInstance(context: Context, listener: PurchasesUpdatedListener) = instance ?: setUpBillingClient(context, listener).also { instance = it }
private fun setUpBillingClient(context: Context, listener: PurchasesUpdatedListener): BillingClient {
return BillingClient.newBuilder(context)
.enablePendingPurchases()
.setListener(listener)
.build()
}
}
}
자세히 보면 BillingHelper 생성자 객체를 만들어주는 게 아니라 BillingClient 객체를 만들어서 리턴해주는 것을 볼 수 있다.
이제 이 객체를 사용할 프래그먼트를 만들도록 하겠다.
fragment_meal_ticket.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/frameLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
tools:context=".ui.frag.mealticket.MealTicketFrag">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_meal_ticket_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:nestedScrollingEnabled="false"
android:orientation="vertical"
android:overScrollMode="never"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:itemCount="4"
tools:listitem="@layout/layout_meal_ticket_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
android:overScrollMode="never"를 통해 위아래 맨 끝의 RecyclerView 효과를 제거했다.
리사이클러뷰에 들어갈 아이템 xml
layout_meal_ticket_item.xml
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/cv_meal_ticket_item_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/Theme.MaterialComponents.Light"
android:layout_margin="8dp"
android:background="@color/white">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_meal_ticket_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#000"
android:textSize="20sp"
android:text="Title"
/>
<TextView
android:id="@+id/tv_meal_ticket_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="price"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"/>
<TextView
android:id="@+id/tv_meal_ticket_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="description"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
MealTicketFrag.kt
class MealTicketFrag : BaseFrag(), PurchasesUpdatedListener {
private val binding: FragmentMealTicketBinding by lazy { FragmentMealTicketBinding.inflate(layoutInflater) }
private lateinit var billingClient: BillingClient
private lateinit var listener: ConsumeResponseListener
private var ticketType = "" // 구매한 식권의 타입을 지정해줄 변수
private var ticketCnt = 0 // 구매한 식권의 수량을 지정해줄 변수
private val accountViewModel: AccountViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupBillingClient()
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setAdapter()
setObserver()
}
private fun setAdapter(){
showLog("setAdapter")
// 리사이클러뷰의 객체를 클릭한 경우
val adapter = MealTicketAdapter { item ->
if(sessionHelper.isLogin()){
purchaseProduct(item)
}
else {
(requireActivity() as MainAct).showSignInNotificationDialog { result ->
if(result) purchaseProduct(item)
else showMsg(getString(R.string.fail_update_session))
}
}
}
binding.rvMealTicketList.adapter = adapter
showLog("isReady: ${billingClient.isReady}")
if (billingClient.isReady) {
// play console에 생성된 특정 상품 리스트들을 가져온 뒤 객체를 생성
val skuDetailsParams = SkuDetailsParams.newBuilder()
.setSkusList(listOf("ticket_1", "ticket_2", "ticket_3", "ticket_4"))
.setType(BillingClient.SkuType.INAPP)
.build()
// 비동기로 가져온 상품 리스트들을 어떻게 처리할지 구현
billingClient.querySkuDetailsAsync(skuDetailsParams, object : SkuDetailsResponseListener {
override fun onSkuDetailsResponse(billingResult: BillingResult, list: MutableList<SkuDetails>?) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK){
showLog(list.toString(), "querySkuDetailsAsync")
// 아답터에 상품 리스트들을 전달해서 업데이트 시켜준다.
adapter.submitList(list)
}
else
showLog("Error Code: ${billingResult.responseCode}")
}
})
}
}
private fun setObserver(){
// 유저 식권 보유량 업데이트 상태를 받아오는 곳
accountViewModel.updateUserTicket.observe(viewLifecycleOwner, Observer {
val resource = it ?: return@Observer
when(resource.status){
Status.SUCCESS ->{
val data = resource.data!!
when(data.status){
"200" -> showMsg(data.msg.toString())
"202" -> accountViewModel.updateToken(sessionHelper.tokenVo!!)
else -> showMsg(data.msg.toString())
}
}
Status.ERROR -> {
showMsg(resource.message.toString())
}
Status.ERROR_INT -> {
showMsg(getString(resource.intMsg!!))
}
else -> return@Observer
}
// 토큰 만료 상태가 아닌 경우만 식권 상태를 리셋 해줌
// 토큰 만료 상태의 경우 토큰을 재발행하는 요청을 한 후에
// 자동으로 다시 식권을 업데이트 하는 요청이 필요하다.
if(resource.data?.status != "202"){
ticketCnt = 0
ticketType = ""
}
accountViewModel.updateUserTicket.postValue(null)
})
// 유저의 갱신된 토큰(access_token, 때에 따라선 refresh_token도 갱신)
accountViewModel.updateToken.observe(viewLifecycleOwner, Observer {
val resource = it ?: return@Observer
when(resource.status){
Status.SUCCESS-> {
// 토큰이 갱신된 상태이므로 다시 유저 식권 수량을 업데이트 요청
accountViewModel.updateUserTicket(ticketType, ticketCnt)
}
Status.ERROR ->{
showMsg.showShortToastMsg(resource.message.toString())
}
Status.ERROR_INT->{
showMsg.showShortToastMsg(getString(resource.intMsg!!))
}
else -> return@Observer
}
})
}
private fun setupBillingClient() {
// ConsumeResponseListener를 통해 구매에 상태값을 가져와 이후 처리를 수행할 부분
listener = ConsumeResponseListener { billingResult: BillingResult, str: String ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
showLog("구매 성공!!")
accountViewModel.updateUserTicket(ticketType, ticketCnt)
}
else {
showLog("구매 실패!!")
}
}
// 전역변수로 선언된 BillingClient 객체를 BillingHelper를 통해 생성하는 과정
billingClient = BillingHelper.getInstance(requireContext(), this)
// billingClient.startConnection()을 통해 google play console에 접속을 시도
// 이 구문은 fragment 실행과 상관 없이 앱이 실행되면 바로 실행된다.
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
showLog("연결 성공!!")
val purchases: MutableList<Purchase>? = billingClient.queryPurchases(BillingClient.SkuType.INAPP).purchasesList
showLog(purchases.toString(), "purchases")
// 구매를 진행할 경우 수행할 함수
handleItemAlreadyPurchase(purchases)
} else {
showLog("Error Code: ${billingResult.responseCode}, 연결에 실패했습니다.")
}
}
override fun onBillingServiceDisconnected() {
showLog("연결이 끊김")
}
})
}
private fun handleItemAlreadyPurchase(purchases: MutableList<Purchase>?) {
// purchases가 null이 아닌 경우
// 각 sku에 따라 처리하는데, 식권의 타입과 식권의 수량을 정의해준다.
if (purchases != null) {
for (purchase in purchases) {
when (purchase.sku) {
"ticket_1" -> {
showLog("ticket_1")
ticketType = "user_ticket_5000"
ticketCnt = 5
}
"ticket_2" -> {
showLog("ticket_2")
ticketType = "user_ticket_4000"
ticketCnt = 5
}
"ticket_3" -> {
showLog("ticket_3")
ticketType = "user_ticket_3500"
ticketCnt = 5
}
"ticket_4" -> {
showLog("ticket_4")
ticketType = "user_ticket_2000"
ticketCnt = 5
}
}
// 비동기로 식권 구매로직 상태를 받아서 필요한 기능을 수행할 수 있게 돕는다.
val consumeParams = ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
billingClient.consumeAsync(consumeParams, listener)
}
}
}
// 식권 구매을 진행하는 메서드
private fun purchaseProduct(item: SkuDetails){
// 구매할 상품 객체를 생성
val billingFlowParams = BillingFlowParams.newBuilder()
.setSkuDetails(item)
.build()
// 구매 로직 실행
when(billingClient.launchBillingFlow(requireActivity(), billingFlowParams).responseCode){
BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> {
showMsg("BILLING UNAVAILABLE")
showLog("BILLING UNAVAILABLE", "purchaseProduct")
}
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
showMsg("DEVELOPER ERROR")
showLog("DEVELOPER ERROR", "purchaseProduct")
}
BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> {
showMsg("FEATURE NOT SUPPORTED")
showLog("FEATURE NOT SUPPORTED", "purchaseProduct")
}
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
showMsg("ITEM ALREADY OWNED")
showLog("ITEM ALREADY OWNED", "purchaseProduct")
}
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> {
showMsg("SERVICE DISCONNECTED")
showLog("SERVICE DISCONNECTED", "purchaseProduct")
}
BillingClient.BillingResponseCode.SERVICE_TIMEOUT -> {
showMsg("SERVICE TIMEOUT")
showLog("SERVICE TIMEOUT", "purchaseProduct")
}
BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> {
showMsg("ITEM UNAVAILABLE")
showLog("ITEM UNAVAILABLE", "purchaseProduct")
}
}
}
// 구매 상태를 업데이트 해주는 코드
override fun onPurchasesUpdated(billingResult: BillingResult, list: MutableList<Purchase>?) {
// 상품 구매에 성공한 경우
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && list != null){
handleItemAlreadyPurchase(list)
}
// 유저가 상품 구매를 취소한 경우
else if(billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED){
showMsg(getString(R.string.cancel_buying_product))
}
// 상품 구매시 에러가 뜬 경우
else{
showMsg( "Error: ${billingResult.responseCode}")
}
}
}
코드를 하나하나 정리 하려 했으나... 너무 글이 길어질거 같아 주석으로 각 코드들에 대해 설명하였다.
결과 영상
'개발 > Android' 카테고리의 다른 글
WorkManager 기초 (0) | 2023.07.19 |
---|---|
WorkManager 소개 (0) | 2023.07.19 |
[Android] Activity 정리 (4대 컴포넌트) (0) | 2021.03.06 |
안드로이드 4대 컴포넌트 (0) | 2021.03.05 |
[Android] 화면 크기 별 Layout 생성 (0) | 2021.03.05 |