2 Commits
Author SHA1 Message Date
Amirmahdi 4a67bed0d3 feat: add terms and conditions 2026-06-14 21:47:22 +03:30
Amirmahdi cba6aa6153 feat: payment cafe 2026-06-14 21:17:51 +03:30
6 changed files with 36 additions and 59 deletions
+6 -6
View File
@@ -16,20 +16,20 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = 2
versionName = "1.0.2+MK" versionName = "1.0.2+BZ"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// ---- Myket in-app billing ---- // ---- Bazaar (Cafe Bazaar) in-app billing ----
val marketApplicationId = "ir.mservices.market" val marketApplicationId = "com.farsitel.bazaar"
val marketBindAddress = "ir.mservices.market.InAppBillingService.BIND" val marketBindAddress = "ir.cafebazaar.pardakht.InAppBillingService.BIND"
manifestPlaceholders["marketApplicationId"] = marketApplicationId manifestPlaceholders["marketApplicationId"] = marketApplicationId
manifestPlaceholders["marketBindAddress"] = marketBindAddress manifestPlaceholders["marketBindAddress"] = marketBindAddress
manifestPlaceholders["marketPermission"] = "$marketApplicationId.BILLING" manifestPlaceholders["marketPermission"] = "com.farsitel.bazaar.permission.PAY_THROUGH_BAZAAR"
buildConfigField( buildConfigField(
"String", "String",
"IAB_PUBLIC_KEY", "IAB_PUBLIC_KEY",
"\"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCDSaqJLDPxQZBZD/LGRsZOJ8m1hLVFedg/tnqebQusikVTNYSEjmP86i1TtXu/2b4E8hpkrqyNlNVvEg1lrHCpnxCh0tOubYiG6X/GEQtgOtIRuBzO3HM/r9w1HQm4gZOJc6DJO46Sxp3YbryJujp+pG8p7bU6zHp4zcqydCJ14QIDAQAB\"" "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwDzw+tRS/C//9JebsgTQOPAJU3Qwg7aIOhO4wqPVMNCjkGQZH7MckLU8hC6Rrag8gakkGBROSN/J58W4q1wxFbaUgRiTvyM04ElE7tHHa8CKtNH3NSUFzCQkhqd+9mLGflmj7JEj6EaZLoTLfZYhQgGDpWNXwW9HmO2lygXkua6BUkelpafgAmLz/ojlBm+ByLdSVkXIaaGqs2GLLR4qZHiksljQskbv0T0Ccax8pcCAwEAAQ==\""
) )
} }
-5
View File
@@ -3,11 +3,6 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- دیدن و اتصال به سرویس پرداختِ مایکت روی اندروید ۱۱+ -->
<queries>
<package android:name="ir.mservices.market" />
</queries>
<application <application
android:name=".DrugApp" android:name=".DrugApp"
android:allowBackup="true" android:allowBackup="true"
@@ -17,23 +17,27 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
/** /**
* درگاهِ پرداختِ مایکت بر پایهٔ کتابخانهٔ `myket-billing-client` (IabHelper). * درگاهِ پرداختِ کافهبازار بر پایهٔ همان کتابخانهٔ `myket-billing-client`
* (IabHelper). فروشگاهِ مقصد با `manifestPlaceholders` در build.gradle مشخص
* میشود (com.farsitel.bazaar)، بنابراین کدِ IabHelper یکسان است و فقط مقصدِ
* اتصال و کلیدِ عمومی فرق میکند.
* *
* جریان (مطابق همان مدلی که در اپ فلاترِ آپرواجنسی استفاده شده): * جریان:
* 1. اتصال به سرویس مایکت ([ensureSetup]). * 1. اتصال به سرویس بازار ([ensureSetup]).
* 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید. * 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید.
* 3. توکن به سرور فرستاده میشود (در لایهٔ اشتراک) و پس از تأیید، خرید [consume] * 3. توکن به سرور فرستاده میشود (با gateway = "bazaar") و پس از تأیید، خرید
* میشود تا کاربر بتواند دورهٔ بعد دوباره خرید کند. * [consume] میشود تا کاربر بتواند دورهٔ بعد دوباره خرید کند.
* *
* چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را میپذیرد، همهٔ فراخوانیها * چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را میپذیرد، همهٔ فراخوانیها
* با [mutex] ترتیبی میشوند. * با [mutex] ترتیبی میشوند.
*/ */
class MyketPurchaseGateway( class BazaarPurchaseGateway(
context: Context, context: Context,
private val session: SessionManager private val session: SessionManager
) : PurchaseGateway { ) : PurchaseGateway {
override val name: String = "myket" // مقدارِ معتبرِ درگاه در سمتِ سرورِ آپرواجنسی برای کافه‌بازار «cafe» است.
override val name: String = "cafe"
override val isAvailable: Boolean = true override val isAvailable: Boolean = true
private val appContext = context.applicationContext private val appContext = context.applicationContext
@@ -53,7 +57,7 @@ class MyketPurchaseGateway(
?: return Result.failure(IllegalArgumentException("شناسهٔ محصول (SKU) نامعتبر است.")) ?: return Result.failure(IllegalArgumentException("شناسهٔ محصول (SKU) نامعتبر است."))
ensureSetup().onFailure { return Result.failure(it) } ensureSetup().onFailure { return Result.failure(it) }
val helper = helper ?: return Result.failure(IllegalStateException("اتصال به مایکت برقرار نشد.")) val helper = helper ?: return Result.failure(IllegalStateException("اتصال به بازار برقرار نشد."))
// developerPayload = شماره موبایلِ کاربر برای شناساییِ خریدار؛ در صورت نبود، توکن تصادفی. // developerPayload = شماره موبایلِ کاربر برای شناساییِ خریدار؛ در صورت نبود، توکن تصادفی.
val payload = session.account.value.mobile?.takeIf { it.isNotBlank() } val payload = session.account.value.mobile?.takeIf { it.isNotBlank() }
@@ -61,7 +65,8 @@ class MyketPurchaseGateway(
val outcome = runCatching { val outcome = runCatching {
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
helper.launchPurchaseFlow( // محصول‌های بازار از نوع «اشتراک» هستند.
helper.launchSubscriptionPurchaseFlow(
activity, activity,
sku, sku,
IabHelper.OnIabPurchaseFinishedListener { result, purchase -> IabHelper.OnIabPurchaseFinishedListener { result, purchase ->
@@ -71,7 +76,7 @@ class MyketPurchaseGateway(
result.response == IabHelper.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED -> result.response == IabHelper.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED ->
cont.resume(PurchaseOutcome.AlreadyOwned) cont.resume(PurchaseOutcome.AlreadyOwned)
else -> else ->
cont.resume(PurchaseOutcome.Error(result.message ?: "خطای پرداخت مایکت")) cont.resume(PurchaseOutcome.Error(result.message ?: "خطای پرداخت بازار"))
} }
}, },
payload payload
@@ -81,8 +86,8 @@ class MyketPurchaseGateway(
val purchase = when (outcome) { val purchase = when (outcome) {
is PurchaseOutcome.Success -> outcome.purchase is PurchaseOutcome.Success -> outcome.purchase
PurchaseOutcome.AlreadyOwned -> recoverOwned(sku) PurchaseOutcome.AlreadyOwned ->
?: return Result.failure(IllegalStateException("این محصول قبلاً خریداری شده است.")) return Result.failure(IllegalStateException("اشتراک شما از قبل فعال است."))
is PurchaseOutcome.Error -> return Result.failure(IllegalStateException(outcome.message)) is PurchaseOutcome.Error -> return Result.failure(IllegalStateException(outcome.message))
} }
@@ -94,20 +99,13 @@ class MyketPurchaseGateway(
Result.success(PurchaseResult(purchaseToken = token, orderId = purchase.orderId)) Result.success(PurchaseResult(purchaseToken = token, orderId = purchase.orderId))
} }
override suspend fun consume(purchaseToken: String): Result<Unit> = mutex.withLock { // اشتراکِ کافه‌بازار مصرف نمی‌شود؛ این متد بی‌اثر است.
val purchase = pending[purchaseToken] ?: return Result.success(Unit) override suspend fun consume(purchaseToken: String): Result<Unit> {
val helper = helper ?: return Result.success(Unit) pending.remove(purchaseToken)
runCatching { return Result.success(Unit)
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<Unit> { private suspend fun ensureSetup(): Result<Unit> {
if (setupDone && helper != null) return Result.success(Unit) if (setupDone && helper != null) return Result.success(Unit)
val newHelper = IabHelper(appContext, BuildConfig.IAB_PUBLIC_KEY) val newHelper = IabHelper(appContext, BuildConfig.IAB_PUBLIC_KEY)
@@ -117,7 +115,7 @@ class MyketPurchaseGateway(
newHelper.startSetup(IabHelper.OnIabSetupFinishedListener { result -> newHelper.startSetup(IabHelper.OnIabSetupFinishedListener { result ->
if (result.isSuccess) cont.resume(Unit) if (result.isSuccess) cont.resume(Unit)
else cont.resumeWithException( else cont.resumeWithException(
IllegalStateException(result.message ?: "اتصال به مایکت ناموفق بود") IllegalStateException(result.message ?: "اتصال به بازار ناموفق بود")
) )
}) })
} }
@@ -129,22 +127,6 @@ class MyketPurchaseGateway(
} }
} }
/** بازیابیِ خریدِ مصرف‌نشدهٔ موجود (وقتی محصول قبلاً خریده شده). */
private suspend fun recoverOwned(sku: String): Purchase? {
val helper = helper ?: return null
return runCatching {
suspendCancellableCoroutine<Purchase?> { 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 { private sealed interface PurchaseOutcome {
data class Success(val purchase: Purchase) : PurchaseOutcome data class Success(val purchase: Purchase) : PurchaseOutcome
data object AlreadyOwned : PurchaseOutcome data object AlreadyOwned : PurchaseOutcome
@@ -1,7 +1,7 @@
package com.approagency.pharmacy.di package com.approagency.pharmacy.di
import com.approagency.pharmacy.data.billing.MyketPurchaseGateway import com.approagency.pharmacy.data.billing.BazaarPurchaseGateway
import com.approagency.pharmacy.data.local.LocalDrugDataSource import com.approagency.pharmacy.data.local.LocalDrugDataSource
import com.approagency.pharmacy.data.local.SessionManager import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.data.local.database.LabDatabase import com.approagency.pharmacy.data.local.database.LabDatabase
@@ -116,8 +116,8 @@ val appModule= module {
single<AuthRepository> { AuthRepositoryImpl(get(), get()) } single<AuthRepository> { AuthRepositoryImpl(get(), get()) }
// درگاه پرداخت: روی شاخهٔ myket پیاده‌سازی مایکت. // درگاه پرداخت: روی شاخهٔ bazar پیاده‌سازی کافه‌بازار.
single<PurchaseGateway> { MyketPurchaseGateway(androidContext(), get()) } single<PurchaseGateway> { BazaarPurchaseGateway(androidContext(), get()) }
factory { DrugHtmlParser() } factory { DrugHtmlParser() }
@@ -47,9 +47,9 @@ object AppLegal {
title = "۴. جست‌وجوی رایگان، اشتراک و پرداخت", title = "۴. جست‌وجوی رایگان، اشتراک و پرداخت",
items = listOf( items = listOf(
"برنامه تعدادی جست‌وجوی رایگان در اختیار کاربر قرار می‌دهد؛ پس از پایانِ سهمیهٔ رایگان، برای استفادهٔ نامحدود تهیهٔ اشتراک لازم است.", "برنامه تعدادی جست‌وجوی رایگان در اختیار کاربر قرار می‌دهد؛ پس از پایانِ سهمیهٔ رایگان، برای استفادهٔ نامحدود تهیهٔ اشتراک لازم است.",
"خرید و پرداختِ اشتراک از طریق درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه (مایکت) انجام می‌شود و تابعِ قوانینِ همان فروشگاه است.", "خرید و پرداختِ اشتراک از طریق درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه (کافه‌بازار) انجام می‌شود و تابعِ قوانینِ همان فروشگاه است.",
"قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود.", "قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود.",
"بازگشتِ وجه تابعِ سیاست‌های فروشگاهِ مایکت است و از طریق همان فروشگاه پیگیری می‌شود.", "بازگشتِ وجه تابعِ سیاست‌های فروشگاهِ کافه‌بازار است و از طریق همان فروشگاه پیگیری می‌شود.",
"در صورتی که پس از پرداختِ موفق اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید." "در صورتی که پس از پرداختِ موفق اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید."
) )
), ),
+2 -2
View File
@@ -41,9 +41,9 @@
- برنامه تعدادی **جست‌وجوی رایگان** در اختیار کاربر قرار می‌دهد. پس از پایانِ - برنامه تعدادی **جست‌وجوی رایگان** در اختیار کاربر قرار می‌دهد. پس از پایانِ
سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است. سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است.
- خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه - خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه
(مایکت)** انجام می‌شود و تابعِ قوانین و شرایطِ همان فروشگاه است. (کافه‌بازار)** انجام می‌شود و تابعِ قوانین و شرایطِ همان فروشگاه است.
- قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود. - قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود.
- **بازگشتِ وجه** تابعِ سیاست‌های فروشگاهِ مایکت است. درخواست‌های مربوط به - **بازگشتِ وجه** تابعِ سیاست‌های فروشگاهِ کافه‌بازار است. درخواست‌های مربوط به
عودتِ وجهِ خریدهای درون‌برنامه‌ای از طریق همان فروشگاه پیگیری می‌شود. عودتِ وجهِ خریدهای درون‌برنامه‌ای از طریق همان فروشگاه پیگیری می‌شود.
- در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید. - در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید.