캔 충전페이지

- 인 앱 결제 페이지 추가
This commit is contained in:
klaus 2024-03-21 04:14:52 +09:00
parent 79cb4b995a
commit 6e3a4e1125
16 changed files with 477 additions and 49 deletions

View File

@ -40,8 +40,8 @@ android {
applicationId "kr.co.vividnext.sodalive"
minSdk 23
targetSdk 33
versionCode 27
versionName "1.8.2"
versionCode 28
versionName "1.8.3"
}
buildTypes {
@ -149,4 +149,7 @@ dependencies {
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
implementation "com.michalsvec:single-row-calednar:1.0.0"
// google in-app-purchase
implementation "com.android.billingclient:billing-ktx:6.2.0"
}

View File

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />

View File

@ -74,6 +74,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthApi
import kr.co.vividnext.sodalive.mypage.auth.AuthRepository
import kr.co.vividnext.sodalive.mypage.can.CanApi
import kr.co.vividnext.sodalive.mypage.can.CanRepository
import kr.co.vividnext.sodalive.mypage.can.charge.iap.CanChargeIapViewModel
import kr.co.vividnext.sodalive.mypage.can.charge.pg.CanChargePgViewModel
import kr.co.vividnext.sodalive.mypage.can.coupon.CanCouponViewModel
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentViewModel
@ -231,6 +232,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { CreatorCommunityWriteViewModel(get()) }
viewModel { CreatorCommunityModifyViewModel(get()) }
viewModel { CanCouponViewModel(get()) }
viewModel { CanChargeIapViewModel(get()) }
}
private val repositoryModule = module {

View File

@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.mypage.can
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.mypage.can.charge.iap.GoogleChargeRequest
import kr.co.vividnext.sodalive.mypage.can.charge.iap.GoogleVerifyRequest
import kr.co.vividnext.sodalive.mypage.can.charge.pg.CanResponse
import kr.co.vividnext.sodalive.mypage.can.charge.pg.ChargeRequest
import kr.co.vividnext.sodalive.mypage.can.charge.pg.ChargeResponse
@ -17,6 +19,18 @@ import retrofit2.http.POST
import retrofit2.http.Query
interface CanApi {
@POST("/charge/google")
fun googleChargeCan(
@Body request: GoogleChargeRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<ChargeResponse>>
@POST("/charge/google/verify")
fun googleChargeVerify(
@Body request: GoogleVerifyRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@POST("/charge")
fun chargeCan(
@Body chargeRequest: ChargeRequest,

View File

@ -1,11 +1,23 @@
package kr.co.vividnext.sodalive.mypage.can
import kr.co.vividnext.sodalive.mypage.can.charge.iap.GoogleChargeRequest
import kr.co.vividnext.sodalive.mypage.can.charge.iap.GoogleVerifyRequest
import kr.co.vividnext.sodalive.mypage.can.charge.pg.ChargeRequest
import kr.co.vividnext.sodalive.mypage.can.charge.pg.VerifyRequest
import kr.co.vividnext.sodalive.mypage.can.coupon.UseCanCouponRequest
import java.util.TimeZone
class CanRepository(private val api: CanApi) {
fun googleChargeCan(
request: GoogleChargeRequest,
token: String
) = api.googleChargeCan(request, authHeader = token)
fun googleChargeVerify(
request: GoogleVerifyRequest,
token: String
) = api.googleChargeVerify(request, authHeader = token)
fun chargeCan(
chargeRequest: ChargeRequest,
token: String

View File

@ -12,9 +12,11 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityCanChargeBinding
import kr.co.vividnext.sodalive.mypage.can.charge.iap.CanChargeIapFragment
import kr.co.vividnext.sodalive.mypage.can.charge.pg.CanChargePgFragment
import kr.co.vividnext.sodalive.mypage.can.charge.pg.CanResponse
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity
import kr.co.vividnext.sodalive.mypage.can.status.CanStatusActivity
class CanChargeActivity : BaseActivity<ActivityCanChargeBinding>(
ActivityCanChargeBinding::inflate
@ -39,7 +41,7 @@ class CanChargeActivity : BaseActivity<ActivityCanChargeBinding>(
false
)
changeFragment(PG_TAG_KEY)
changeFragment(IAP_TAG_KEY)
}
override fun setupView() {
@ -56,7 +58,7 @@ class CanChargeActivity : BaseActivity<ActivityCanChargeBinding>(
if (SharedPreferenceManager.isAuth) {
val tabs = binding.tabs
tabs.visibility = View.GONE
tabs.visibility = View.VISIBLE
tabs.addTab(tabs.newTab().setText("인 앱 결제").setTag(IAP_TAG_KEY))
tabs.addTab(tabs.newTab().setText("PG").setTag(PG_TAG_KEY))
@ -91,7 +93,7 @@ class CanChargeActivity : BaseActivity<ActivityCanChargeBinding>(
fragment = if (tag == PG_TAG_KEY) {
CanChargePgFragment()
} else {
CanChargePgFragment()
CanChargeIapFragment()
}
fragmentTransaction.add(R.id.fl_container, fragment, tag)
@ -111,6 +113,16 @@ class CanChargeActivity : BaseActivity<ActivityCanChargeBinding>(
activityResultLauncher.launch(intent)
}
fun successIapCharge() {
if (gotoPrevPage) {
setResult(RESULT_OK)
finish()
} else {
val intent = Intent(applicationContext, CanStatusActivity::class.java)
startActivity(intent)
}
}
companion object {
const val IAP_TAG_KEY = "iap"
const val PG_TAG_KEY = "pg"

View File

@ -0,0 +1,57 @@
package kr.co.vividnext.sodalive.mypage.can.charge.iap
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import com.android.billingclient.api.ProductDetails
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCanChargeBinding
import kr.co.vividnext.sodalive.extensions.fontSpan
class CanChargeIapAdapter(
private val onClick: (ProductDetails) -> Unit
) : RecyclerView.Adapter<CanChargeIapAdapter.ViewHolder>() {
val items = mutableListOf<ProductDetails>()
inner class ViewHolder(
private val context: Context,
private val binding: ItemCanChargeBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: ProductDetails) {
binding.tvPrice.text = item.oneTimePurchaseOfferDetails?.formattedPrice
val typeface = ResourcesCompat.getFont(context, R.font.gmarket_sans_medium)
binding.tvTitle.text = item.name.fontSpan(
typeface,
""
)
binding.root.setOnClickListener { onClick(item) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
parent.context,
ItemCanChargeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.count()
@SuppressLint("NotifyDataSetChanged")
fun addItems(items: List<ProductDetails>) {
this.items.addAll(items.sortedBy { it.description.toInt() })
notifyDataSetChanged()
}
}

View File

@ -0,0 +1,221 @@
package kr.co.vividnext.sodalive.mypage.can.charge.iap
import android.annotation.SuppressLint
import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
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
class CanChargeIapFragment : BaseFragment<FragmentCanChargeIapBinding>(
FragmentCanChargeIapBinding::inflate
) {
private val viewModel: CanChargeIapViewModel by inject()
private lateinit var adapter: CanChargeIapAdapter
private lateinit var loadingDialog: LoadingDialog
private lateinit var billingClient: BillingClient
private val handler = Handler(Looper.getMainLooper())
private var chargeId: Long = 0
private var chargeCan: Int = 0
private val purchaseUpdateListener = PurchasesUpdatedListener { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
for (purchase in purchases) {
viewModel.verifyPayment(
productId = purchase.products[0],
purchaseToken = purchase.purchaseToken,
chargeId = chargeId
) {
SharedPreferenceManager.can += chargeCan
(requireActivity() as CanChargeActivity).successIapCharge()
}
}
} else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
chargeId = 0
chargeCan = 0
showToast("구매를 취소했습니다.")
} else {
chargeId = 0
chargeCan = 0
showToast("구매를 하지 못했습니다.\n다시 시도해 주세요.")
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
bindData()
setupRecyclerView()
setupBillingClient()
}
private fun bindData() {
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
}
private fun setupRecyclerView() {
val recyclerView = binding.rvChargeCan
adapter = CanChargeIapAdapter { productDetails ->
chargeCan = productDetails.description.toInt()
viewModel.chargeCan(
title = productDetails.name,
chargeCan = chargeCan,
price = (
productDetails.oneTimePurchaseOfferDetails?.priceAmountMicros ?: 0L
).toDouble() / 1000000,
currencyCode = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
?: "KRW"
) { chargeId ->
this.chargeId = chargeId
launchPurchaseFlow(productDetails)
}
}
recyclerView.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.VERTICAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = 13.3f.dpToPx().toInt()
outRect.right = 13.3f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.top = 13.3f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 26.7f.dpToPx().toInt()
}
else -> {
outRect.top = 6.7f.dpToPx().toInt()
outRect.bottom = 6.7f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = adapter
}
private fun setupBillingClient() {
billingClient = BillingClient.newBuilder(requireContext())
.enablePendingPurchases()
.setListener(purchaseUpdateListener)
.build()
loadingDialog.show(screenWidth)
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingServiceDisconnected() {
loadingDialog.dismiss()
}
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
queryAvailableCans()
} else {
showToast("인 앱 결제 이용이 불가능 합니다. 다시 시도해 주세요.")
loadingDialog.dismiss()
}
}
})
}
@SuppressLint("NotifyDataSetChanged")
private fun queryAvailableCans() {
val skuList = listOf(
"${requireContext().packageName}.can_35",
"${requireContext().packageName}.can_55",
"${requireContext().packageName}.can_105",
"${requireContext().packageName}.can_350",
"${requireContext().packageName}.can_550",
"${requireContext().packageName}.can_1170"
)
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
skuList.map {
QueryProductDetailsParams.Product.newBuilder()
.setProductId(it)
.setProductType(BillingClient.ProductType.INAPP) // Use SUBS for subscriptions
.build()
}
)
.build()
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
handler.post {
adapter.addItems(productDetailsList)
productDetailsList.forEach {
Toast.makeText(
requireContext(),
"${it.name}, ${it.description}, ${it.productId}, ${it.productType}, ${
it.oneTimePurchaseOfferDetails?.priceAmountMicros
}, ${it.oneTimePurchaseOfferDetails?.priceCurrencyCode}",
Toast.LENGTH_LONG
).show()
}
}
} else {
handler.post { showToast("인 앱 결제 이용이 불가능 합니다. 다시 시도해 주세요.") }
}
handler.post { loadingDialog.dismiss() }
}
}
private fun launchPurchaseFlow(productDetails: ProductDetails) {
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.build()
)
).build()
billingClient.launchBillingFlow(requireActivity(), billingFlowParams)
}
}

View File

@ -0,0 +1,104 @@
package kr.co.vividnext.sodalive.mypage.can.charge.iap
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.mypage.can.CanRepository
class CanChargeIapViewModel(private val repository: CanRepository) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
fun chargeCan(
title: String,
chargeCan: Int,
price: Double,
currencyCode: String,
onSuccess: (Long) -> Unit
) {
_isLoading.value = true
compositeDisposable.add(
repository.googleChargeCan(
request = GoogleChargeRequest(
title = title,
chargeCan = chargeCan,
price = price,
currencyCode = currencyCode
),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
onSuccess(it.data.chargeId)
} else {
if (it.message != null) {
_toastLiveData.value = it.message
} else {
_toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
}
)
)
}
fun verifyPayment(
productId: String,
purchaseToken: String,
chargeId: Long,
onSuccess: () -> Unit
) {
_isLoading.value = true
compositeDisposable.add(
repository.googleChargeVerify(
request = GoogleVerifyRequest(
productId = productId,
purchaseToken = purchaseToken,
chargeId = chargeId
),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success) {
onSuccess()
} else {
if (it.message != null) {
_toastLiveData.value = it.message
} else {
_toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
}
)
)
}
}

View File

@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.mypage.can.charge.iap
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.mypage.can.payment.PaymentGateway
data class GoogleChargeRequest(
@SerializedName("title") val title: String,
@SerializedName("chargeCan") val chargeCan: Int,
@SerializedName("price") val price: Double,
@SerializedName("currencyCode") val currencyCode: String,
@SerializedName("paymentGateway") val paymentGateway: PaymentGateway = PaymentGateway.GOOGLE_IAP
)
data class GoogleVerifyRequest(
@SerializedName("productId") val productId: String,
@SerializedName("purchaseToken") val purchaseToken: String,
@SerializedName("chargeId") val chargeId: Long
)

View File

@ -81,16 +81,16 @@ class CanChargePgFragment : BaseFragment<FragmentCanChargePgBinding>(
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.canChargeLiveData.observe(this) {
viewModel.canChargeLiveData.observe(viewLifecycleOwner) {
adapter.items.addAll(it)
adapter.notifyDataSetChanged()
}
viewModel.toastLiveData.observe(this) {
viewModel.toastLiveData.observe(viewLifecycleOwner) {
it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {

View File

@ -140,8 +140,8 @@ class CanPaymentActivity : BaseActivity<ActivityCanPaymentBinding>(
R.font.gmarket_sans_bold
)
view.setTextColor(ContextCompat.getColor(applicationContext, R.color.color_9970ff))
view.setBackgroundResource(R.drawable.bg_round_corner_10_4d9970ff_9970ff)
view.setTextColor(ContextCompat.getColor(applicationContext, R.color.color_3bb9f1))
view.setBackgroundResource(R.drawable.bg_round_corner_10_13181b_3bb9f1)
}
private fun requestCharge() {

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_4d9970ff" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="@color/color_9970ff" />
</shape>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/color_4d9970ff" />
<corners android:radius="6.7dp" />
<stroke
android:width="1.3dp"
android:color="@color/color_9970ff" />
</shape>

View File

@ -0,0 +1,16 @@
<?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"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_charge_can"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -21,31 +21,15 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="5000 캔 + 1000 캔" />
<LinearLayout
android:id="@+id/ll_price"
<TextView
android:id="@+id/tv_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:fontFamily="@font/gmarket_sans_bold"
android:textColor="@color/color_eeeeee"
android:textSize="15.3sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/gmarket_sans_bold"
android:textColor="@color/color_eeeeee"
android:textSize="15.3sp"
tools:text="3,300" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/gmarket_sans_medium"
android:text=" 원"
android:textColor="@color/color_eeeeee"
android:textSize="15.3sp" />
</LinearLayout>
app:layout_constraintTop_toTopOf="parent"
tools:text="₩3,300" />
</androidx.constraintlayout.widget.ConstraintLayout>