1 Commits
Author SHA1 Message Date
AmirmahdiandClaude Opus 4.8 79dfcc6916 feat: in-app terms & conditions (Myket) + app name داروچی
- Add شرایط و قوانین page (full-screen dialog) reachable from drawer and login sheet
- TermsController + LegalContent + TermsDialog
- App bar title uses app_name (داروچی)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:57:27 +03:30
6 changed files with 59 additions and 36 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+BZ" versionName = "1.0.2+MK"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// ---- Bazaar (Cafe Bazaar) in-app billing ---- // ---- Myket in-app billing ----
val marketApplicationId = "com.farsitel.bazaar" val marketApplicationId = "ir.mservices.market"
val marketBindAddress = "ir.cafebazaar.pardakht.InAppBillingService.BIND" val marketBindAddress = "ir.mservices.market.InAppBillingService.BIND"
manifestPlaceholders["marketApplicationId"] = marketApplicationId manifestPlaceholders["marketApplicationId"] = marketApplicationId
manifestPlaceholders["marketBindAddress"] = marketBindAddress manifestPlaceholders["marketBindAddress"] = marketBindAddress
manifestPlaceholders["marketPermission"] = "com.farsitel.bazaar.permission.PAY_THROUGH_BAZAAR" manifestPlaceholders["marketPermission"] = "$marketApplicationId.BILLING"
buildConfigField( buildConfigField(
"String", "String",
"IAB_PUBLIC_KEY", "IAB_PUBLIC_KEY",
"\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwDzw+tRS/C//9JebsgTQOPAJU3Qwg7aIOhO4wqPVMNCjkGQZH7MckLU8hC6Rrag8gakkGBROSN/J58W4q1wxFbaUgRiTvyM04ElE7tHHa8CKtNH3NSUFzCQkhqd+9mLGflmj7JEj6EaZLoTLfZYhQgGDpWNXwW9HmO2lygXkua6BUkelpafgAmLz/ojlBm+ByLdSVkXIaaGqs2GLLR4qZHiksljQskbv0T0Ccax8pcCAwEAAQ==\"" "\"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCDSaqJLDPxQZBZD/LGRsZOJ8m1hLVFedg/tnqebQusikVTNYSEjmP86i1TtXu/2b4E8hpkrqyNlNVvEg1lrHCpnxCh0tOubYiG6X/GEQtgOtIRuBzO3HM/r9w1HQm4gZOJc6DJO46Sxp3YbryJujp+pG8p7bU6zHp4zcqydCJ14QIDAQAB\""
) )
} }
+5
View File
@@ -3,6 +3,11 @@
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,27 +17,23 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
/** /**
* درگاهِ پرداختِ کافهبازار بر پایهٔ همان کتابخانهٔ `myket-billing-client` * درگاهِ پرداختِ مایکت بر پایهٔ کتابخانهٔ `myket-billing-client` (IabHelper).
* (IabHelper). فروشگاهِ مقصد با `manifestPlaceholders` در build.gradle مشخص
* میشود (com.farsitel.bazaar)، بنابراین کدِ IabHelper یکسان است و فقط مقصدِ
* اتصال و کلیدِ عمومی فرق میکند.
* *
* جریان: * جریان (مطابق همان مدلی که در اپ فلاترِ آپرواجنسی استفاده شده):
* 1. اتصال به سرویس بازار ([ensureSetup]). * 1. اتصال به سرویس مایکت ([ensureSetup]).
* 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید. * 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید.
* 3. توکن به سرور فرستاده میشود (با gateway = "bazaar") و پس از تأیید، خرید * 3. توکن به سرور فرستاده میشود (در لایهٔ اشتراک) و پس از تأیید، خرید [consume]
* [consume] میشود تا کاربر بتواند دورهٔ بعد دوباره خرید کند. * میشود تا کاربر بتواند دورهٔ بعد دوباره خرید کند.
* *
* چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را میپذیرد، همهٔ فراخوانیها * چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را میپذیرد، همهٔ فراخوانیها
* با [mutex] ترتیبی میشوند. * با [mutex] ترتیبی میشوند.
*/ */
class BazaarPurchaseGateway( class MyketPurchaseGateway(
context: Context, context: Context,
private val session: SessionManager private val session: SessionManager
) : PurchaseGateway { ) : PurchaseGateway {
// مقدارِ معتبرِ درگاه در سمتِ سرورِ آپرواجنسی برای کافه‌بازار «cafe» است. override val name: String = "myket"
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
@@ -57,7 +53,7 @@ class BazaarPurchaseGateway(
?: 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() }
@@ -65,8 +61,7 @@ class BazaarPurchaseGateway(
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 ->
@@ -76,7 +71,7 @@ class BazaarPurchaseGateway(
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
@@ -86,8 +81,8 @@ class BazaarPurchaseGateway(
val purchase = when (outcome) { val purchase = when (outcome) {
is PurchaseOutcome.Success -> outcome.purchase is PurchaseOutcome.Success -> outcome.purchase
PurchaseOutcome.AlreadyOwned -> PurchaseOutcome.AlreadyOwned -> recoverOwned(sku)
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))
} }
@@ -99,13 +94,20 @@ class BazaarPurchaseGateway(
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 {
override suspend fun consume(purchaseToken: String): Result<Unit> { val purchase = pending[purchaseToken] ?: return Result.success(Unit)
pending.remove(purchaseToken) val helper = helper ?: return Result.success(Unit)
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<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)
@@ -115,7 +117,7 @@ class BazaarPurchaseGateway(
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 ?: "اتصال به مایکت ناموفق بود")
) )
}) })
} }
@@ -127,6 +129,22 @@ class BazaarPurchaseGateway(
} }
} }
/** بازیابیِ خریدِ مصرف‌نشدهٔ موجود (وقتی محصول قبلاً خریده شده). */
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.BazaarPurchaseGateway import com.approagency.pharmacy.data.billing.MyketPurchaseGateway
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()) }
// درگاه پرداخت: روی شاخهٔ bazar پیاده‌سازی کافه‌بازار. // درگاه پرداخت: روی شاخهٔ myket پیاده‌سازی مایکت.
single<PurchaseGateway> { BazaarPurchaseGateway(androidContext(), get()) } single<PurchaseGateway> { MyketPurchaseGateway(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 @@
- برنامه تعدادی **جست‌وجوی رایگان** در اختیار کاربر قرار می‌دهد. پس از پایانِ - برنامه تعدادی **جست‌وجوی رایگان** در اختیار کاربر قرار می‌دهد. پس از پایانِ
سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است. سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است.
- خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه - خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه
(کافه‌بازار)** انجام می‌شود و تابعِ قوانین و شرایطِ همان فروشگاه است. (مایکت)** انجام می‌شود و تابعِ قوانین و شرایطِ همان فروشگاه است.
- قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود. - قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود.
- **بازگشتِ وجه** تابعِ سیاست‌های فروشگاهِ کافه‌بازار است. درخواست‌های مربوط به - **بازگشتِ وجه** تابعِ سیاست‌های فروشگاهِ مایکت است. درخواست‌های مربوط به
عودتِ وجهِ خریدهای درون‌برنامه‌ای از طریق همان فروشگاه پیگیری می‌شود. عودتِ وجهِ خریدهای درون‌برنامه‌ای از طریق همان فروشگاه پیگیری می‌شود.
- در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید. - در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید.