diff --git a/.idea/misc.xml b/.idea/misc.xml index 08ffc7e..f104487 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,7 @@ + - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3840b4e..a1e2f78 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,6 +19,18 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // ---- Myket in-app billing ---- + val marketApplicationId = "ir.mservices.market" + val marketBindAddress = "ir.mservices.market.InAppBillingService.BIND" + manifestPlaceholders["marketApplicationId"] = marketApplicationId + manifestPlaceholders["marketBindAddress"] = marketBindAddress + manifestPlaceholders["marketPermission"] = "$marketApplicationId.BILLING" + buildConfigField( + "String", + "IAB_PUBLIC_KEY", + "\"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCDSaqJLDPxQZBZD/LGRsZOJ8m1hLVFedg/tnqebQusikVTNYSEjmP86i1TtXu/2b4E8hpkrqyNlNVvEg1lrHCpnxCh0tOubYiG6X/GEQtgOtIRuBzO3HM/r9w1HQm4gZOJc6DJO46Sxp3YbryJujp+pG8p7bU6zHp4zcqydCJ14QIDAQAB\"" + ) } buildTypes { @@ -39,6 +51,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -80,6 +93,9 @@ dependencies { // SMS User Consent API (OTP auto-fill) implementation(libs.play.services.auth.api.phone) + // Myket in-app billing + implementation("com.github.myketstore:myket-billing-client:1.6") + // Koin for DI implementation(libs.koin.android) implementation(libs.koin.androidx.compose.v410) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 00e3d2f..f429d7b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + + + + () + + override suspend fun purchase( + activity: Activity, + product: SubscriptionProduct + ): Result = mutex.withLock { + val sku = product.uuid?.takeIf { it.isNotBlank() } + ?: return Result.failure(IllegalArgumentException("شناسهٔ محصول (SKU) نامعتبر است.")) + + ensureSetup().onFailure { return Result.failure(it) } + val helper = helper ?: return Result.failure(IllegalStateException("اتصال به مایکت برقرار نشد.")) + + // developerPayload = شماره موبایلِ کاربر برای شناساییِ خریدار؛ در صورت نبود، توکن تصادفی. + val payload = session.account.value.mobile?.takeIf { it.isNotBlank() } + ?: UUID.randomUUID().toString() + + val outcome = runCatching { + suspendCancellableCoroutine { cont -> + helper.launchPurchaseFlow( + activity, + sku, + IabHelper.OnIabPurchaseFinishedListener { result, purchase -> + when { + result.isSuccess && purchase != null -> + cont.resume(PurchaseOutcome.Success(purchase)) + result.response == IabHelper.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED -> + cont.resume(PurchaseOutcome.AlreadyOwned) + else -> + cont.resume(PurchaseOutcome.Error(result.message ?: "خطای پرداخت مایکت")) + } + }, + payload + ) + } + }.getOrElse { return Result.failure(it) } + + val purchase = when (outcome) { + is PurchaseOutcome.Success -> outcome.purchase + PurchaseOutcome.AlreadyOwned -> recoverOwned(sku) + ?: return Result.failure(IllegalStateException("این محصول قبلاً خریداری شده است.")) + is PurchaseOutcome.Error -> return Result.failure(IllegalStateException(outcome.message)) + } + + val token = purchase.token + if (token.isNullOrBlank()) { + return Result.failure(IllegalStateException("توکن خرید دریافت نشد.")) + } + pending[token] = purchase + Result.success(PurchaseResult(purchaseToken = token, orderId = purchase.orderId)) + } + + override suspend fun consume(purchaseToken: String): Result = mutex.withLock { + val purchase = pending[purchaseToken] ?: return Result.success(Unit) + val helper = helper ?: return Result.success(Unit) + runCatching { + suspendCancellableCoroutine { cont -> + helper.consumeAsync(purchase, IabHelper.OnConsumeFinishedListener { _, result -> + if (result.isSuccess) cont.resume(Unit) + else cont.resumeWithException(IllegalStateException(result.message ?: "خطای مصرفِ خرید")) + }) + } + }.onSuccess { pending.remove(purchaseToken) } + } + + /** اتصال به سرویس مایکت (یک‌بار). */ + private suspend fun ensureSetup(): Result { + if (setupDone && helper != null) return Result.success(Unit) + val newHelper = IabHelper(appContext, BuildConfig.IAB_PUBLIC_KEY) + .apply { enableDebugLogging(BuildConfig.DEBUG) } + return runCatching { + suspendCancellableCoroutine { cont -> + newHelper.startSetup(IabHelper.OnIabSetupFinishedListener { result -> + if (result.isSuccess) cont.resume(Unit) + else cont.resumeWithException( + IllegalStateException(result.message ?: "اتصال به مایکت ناموفق بود") + ) + }) + } + }.onSuccess { + helper = newHelper + setupDone = true + }.onFailure { + runCatching { newHelper.dispose() } + } + } + + /** بازیابیِ خریدِ مصرف‌نشدهٔ موجود (وقتی محصول قبلاً خریده شده). */ + private suspend fun recoverOwned(sku: String): Purchase? { + val helper = helper ?: return null + return runCatching { + suspendCancellableCoroutine { cont -> + helper.queryInventoryAsync( + false, + listOf(sku), + IabHelper.QueryInventoryFinishedListener { result, inventory -> + cont.resume(if (result.isSuccess) inventory.getPurchase(sku) else null) + } + ) + } + }.getOrNull() + } + + private sealed interface PurchaseOutcome { + data class Success(val purchase: Purchase) : PurchaseOutcome + data object AlreadyOwned : PurchaseOutcome + data class Error(val message: String) : PurchaseOutcome + } +} diff --git a/app/src/main/java/com/approagency/pharmacy/data/billing/StubPurchaseGateway.kt b/app/src/main/java/com/approagency/pharmacy/data/billing/StubPurchaseGateway.kt index dbaf786..2f0d4a5 100644 --- a/app/src/main/java/com/approagency/pharmacy/data/billing/StubPurchaseGateway.kt +++ b/app/src/main/java/com/approagency/pharmacy/data/billing/StubPurchaseGateway.kt @@ -22,4 +22,6 @@ class StubPurchaseGateway : PurchaseGateway { product: SubscriptionProduct ): Result = Result.failure(IllegalStateException("درگاه پرداخت روی این نسخه فعال نیست.")) + + override suspend fun consume(purchaseToken: String): Result = Result.success(Unit) } diff --git a/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt b/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt index 6c8159e..085515a 100644 --- a/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt +++ b/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt @@ -1,7 +1,7 @@ package com.approagency.pharmacy.di -import com.approagency.pharmacy.data.billing.StubPurchaseGateway +import com.approagency.pharmacy.data.billing.MyketPurchaseGateway import com.approagency.pharmacy.data.local.LocalDrugDataSource import com.approagency.pharmacy.data.local.SessionManager import com.approagency.pharmacy.data.local.database.LabDatabase @@ -115,8 +115,8 @@ val appModule= module { single { AuthRepositoryImpl(get(), get()) } - // درگاه پرداخت: روی main استاب؛ شاخه‌های myket/bazar جایگزین می‌کنند. - single { StubPurchaseGateway() } + // درگاه پرداخت: روی شاخهٔ myket پیاده‌سازی مایکت. + single { MyketPurchaseGateway(androidContext(), get()) } factory { DrugHtmlParser() } diff --git a/app/src/main/java/com/approagency/pharmacy/domain/billing/PurchaseGateway.kt b/app/src/main/java/com/approagency/pharmacy/domain/billing/PurchaseGateway.kt index 9ad82ea..a4f509f 100644 --- a/app/src/main/java/com/approagency/pharmacy/domain/billing/PurchaseGateway.kt +++ b/app/src/main/java/com/approagency/pharmacy/domain/billing/PurchaseGateway.kt @@ -26,6 +26,13 @@ interface PurchaseGateway { * نیاز به [activity] برای باز کردن جریان پرداخت فروشگاه دارد. */ suspend fun purchase(activity: Activity, product: SubscriptionProduct): Result + + /** + * مصرفِ خرید پس از تأییدِ سرور، تا کاربر بتواند دوباره (دورهٔ بعد) خرید کند. + * فقط برای درگاه‌هایی که مدلِ مصرف‌شدنی دارند (مثل مایکت) معنا دارد؛ برای + * بقیه می‌تواند بی‌اثر باشد. + */ + suspend fun consume(purchaseToken: String): Result } /** نتیجه‌ی یک خرید موفق در فروشگاه. */ diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt b/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt index 5941b8d..8732830 100644 --- a/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt +++ b/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt @@ -135,6 +135,8 @@ class AccountViewModel( } repository.subscribe(product.id, result.purchaseToken, gateway.name).fold( onSuccess = { + // پس از تأییدِ سرور، خرید را مصرف کن تا دورهٔ بعد قابل خرید باشد. + gateway.consume(result.purchaseToken) _ui.update { it.copy( purchasingProductId = null, diff --git a/settings.gradle.kts b/settings.gradle.kts index c41b3c2..c8ebe31 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,6 +18,7 @@ dependencyResolutionManagement { //google() //mavenCentral() maven { url = uri("https://maven.myket.ir") } + maven { url = uri("https://jitpack.io") } } }