diff --git a/app/src/main/java/com/dev/briefing/MainActivity.kt b/app/src/main/java/com/dev/briefing/MainActivity.kt index a9a341c..5d80c56 100644 --- a/app/src/main/java/com/dev/briefing/MainActivity.kt +++ b/app/src/main/java/com/dev/briefing/MainActivity.kt @@ -3,24 +3,25 @@ package com.dev.briefing import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import dagger.hilt.android.AndroidEntryPoint +import store.newsbriefing.app.core.common.util.InAppUtil import store.newsbriefing.app.core.designsystem.theme.BriefingTheme @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + InAppUtil.initBillingClient(this) + setContent { BriefingTheme { BriefingApp() } } } + + override fun onResume() { + super.onResume() + InAppUtil.onResume() + } } \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/store/newsbriefing/app/buildlogic/config/Config.kt b/build-logic/convention/src/main/kotlin/store/newsbriefing/app/buildlogic/config/Config.kt index 68a4e6a..6d9be6b 100644 --- a/build-logic/convention/src/main/kotlin/store/newsbriefing/app/buildlogic/config/Config.kt +++ b/build-logic/convention/src/main/kotlin/store/newsbriefing/app/buildlogic/config/Config.kt @@ -8,7 +8,7 @@ object Config { targetSdkVersion = 34, compileSdkVersion = 34, applicationId = "com.dev.briefing", - versionCode = 1, + versionCode = 9, versionName = "2.0.0", nameSpace = "com.dev.briefing" ) diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 1f9f48e..046a05a 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -9,4 +9,5 @@ android { dependencies { implementation(libs.kotlinx.coroutines.android) + api(libs.billing.client) } \ No newline at end of file diff --git a/core/common/src/main/java/store/newsbriefing/app/core/common/util/InAppUtil.kt b/core/common/src/main/java/store/newsbriefing/app/core/common/util/InAppUtil.kt new file mode 100644 index 0000000..55150b4 --- /dev/null +++ b/core/common/src/main/java/store/newsbriefing/app/core/common/util/InAppUtil.kt @@ -0,0 +1,187 @@ +package store.newsbriefing.app.core.common.util + +import android.app.Activity +import android.util.Log +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.AcknowledgePurchaseResponseListener +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.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams + +object InAppUtil : PurchasesUpdatedListener { + + private const val TAG = "GooglePayUtil" + + private lateinit var billingClient: BillingClient + private var productDetailsList: List = mutableListOf() + private lateinit var acknowledgePurchaseResponseListener: AcknowledgePurchaseResponseListener + + private val productIdList = listOf("premium") + + /** + * Billing Client 초기화 + * Google Play 연결 + */ + fun initBillingClient(activity: Activity) { + // Billing Client 초기화 + billingClient = BillingClient.newBuilder(activity) + .setListener(this) + .enablePendingPurchases() + .build() + + // Billing Client 와 Google Play 연결 + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingServiceDisconnected() { + // 연결이 종료될 시 재시도 요망 + Log.d(TAG, "연결 실패") + } + + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + // 연결 성공 + Log.d(TAG, "연결 성공") + Log.d(TAG, "billingClient.connectionState : ${billingClient.connectionState}") + queryProductDetails(productIdList) + } + } + }) + + acknowledgePurchaseResponseListener = AcknowledgePurchaseResponseListener { billingResult -> + Log.d(TAG, "billingResult.responseCode : ${billingResult.responseCode}") + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "비소비성 제품 구매 성공") + } else { + Log.d(TAG, "비소비성 제품 구매 실패") + } + } + } + + /** + * 구매한 상품 조회 + * */ + fun onResume() { + if (!billingClient.isReady) return + + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + + billingClient.queryPurchasesAsync(params.build()) { _, purchases -> + Log.d(TAG, "$purchases") + + for (purchase in purchases) { + handlePurchase(purchase) + } + } + } + + private fun handlePurchase(purchase: Purchase) { + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) { + // 구독이 활성화된 상태이고 아직 인식되지 않은 경우 + acknowledgePurchase(purchase) + } + } + + private fun acknowledgePurchase(purchase: Purchase) { + val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + + billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "Purchase acknowledged successfully") + } else { + Log.e(TAG, "Error acknowledging purchase: ${billingResult.debugMessage}") + } + } + } + + /** + * 상품 목록 조회 + * */ + fun queryProductDetails(productIds: List) { + Log.d(TAG, "queryProductDetails") + + val productList = ArrayList() + for (id in productIds) { + productList.add( + QueryProductDetailsParams.Product.newBuilder() + .setProductId(id) + .setProductType(BillingClient.ProductType.SUBS) + .build() + ) + } + + val queryProductDetailsParams = + QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + billingClient.queryProductDetailsAsync(queryProductDetailsParams) { billingResult, products -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + productDetailsList = products + Log.d(TAG, "$billingResult") + Log.d(TAG, "$productDetailsList") + } else { + Log.e(TAG, "Error querying product details: ${billingResult.debugMessage}") + } + } + } + + /** + * 상품 결제 요청 + * */ + fun getPay(activity: Activity, id: String) { + Log.d(TAG, "getPay : $id") + + val list: MutableList = mutableListOf() + + for (productDetails in productDetailsList) { + if (productDetails.productId == id) { + val subscriptionOfferDetails = productDetails.subscriptionOfferDetails + if (subscriptionOfferDetails != null && subscriptionOfferDetails.isNotEmpty()) { + val offerToken = subscriptionOfferDetails[0].offerToken + + val flowProductDetailParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) + .build() + + list.add(flowProductDetailParams) + } + } + } + + val flowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(list) + .build() + + val responseCode = billingClient.launchBillingFlow(activity, flowParams).responseCode + Log.d(TAG, responseCode.toString()) + Log.d(TAG, BillingClient.BillingResponseCode.OK.toString()) + } + + /** + * 결제 결과 수신 + * */ + override fun onPurchasesUpdated( + billingResult: BillingResult, + purchases: MutableList? + ) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + for (purchase in purchases) { + Log.d(TAG, "onPurchasesUpdated : 구매 성공") + handlePurchase(purchase) + } + } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { + Log.d(TAG, "유저 취소") + } else { + Log.e(TAG, "Error: ${billingResult.debugMessage}") + } + } +} diff --git a/feature/auth/proguard-rules.pro b/feature/auth/proguard-rules.pro index 481bb43..712327b 100644 --- a/feature/auth/proguard-rules.pro +++ b/feature/auth/proguard-rules.pro @@ -18,4 +18,9 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} \ No newline at end of file diff --git a/feature/setting/src/main/java/store/newsbriefing/app/feature/setting/SettingScreen.kt b/feature/setting/src/main/java/store/newsbriefing/app/feature/setting/SettingScreen.kt index 7f94d13..d2cad87 100644 --- a/feature/setting/src/main/java/store/newsbriefing/app/feature/setting/SettingScreen.kt +++ b/feature/setting/src/main/java/store/newsbriefing/app/feature/setting/SettingScreen.kt @@ -1,5 +1,8 @@ package store.newsbriefing.app.feature.setting +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.net.Uri import androidx.compose.foundation.background @@ -33,6 +36,7 @@ import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.startActivity import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.flow.collectLatest +import store.newsbriefing.app.core.common.util.InAppUtil import store.newsbriefing.app.core.designsystem.theme.BriefingTheme import store.newsbriefing.app.core.designsystem.theme.Pretendard @@ -75,6 +79,7 @@ internal fun SettingScreen( appVersion: String ) { val context = LocalContext.current + val activity = context.findActivity() Column( modifier = Modifier @@ -88,7 +93,7 @@ internal fun SettingScreen( SettingTitle(stringResource(id = R.string.subscription_service)) SettingItem(stringResource(id = R.string.briefing_premium)) { - + InAppUtil.getPay(activity, "premium") } SettingTitle(stringResource(id = R.string.app_information)) @@ -274,4 +279,15 @@ private fun AppVersionItem( ) ) } +} + +private fun Context.findActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) { + return context + } + context = context.baseContext + } + throw IllegalStateException("Activity not found") } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c96c9c..d500a31 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,8 @@ googleid = {module ="com.google.android.libraries.identity.googleid:googleid", v androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentialsPlayServicesAuth" } androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsPlayServicesAuth" } +billing-client = { module = "com.android.billingclient:billing-ktx", version = "7.0.0" } + core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }