3 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
Amirmahdi f3b227f09a feat:vr2 2026-06-14 20:05:29 +03:30
Amirmahdi c76f8aa08f feat: add myket iap 2026-06-14 18:31:15 +03:30
13 changed files with 201 additions and 13 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">
+18 -2
View File
@@ -15,10 +15,22 @@ android {
applicationId = "com.approagency.pharmacy" applicationId = "com.approagency.pharmacy"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 2
versionName = "1.0" versionName = "1.0.2+MK"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 { buildTypes {
@@ -39,6 +51,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }
@@ -80,6 +93,9 @@ dependencies {
// SMS User Consent API (OTP auto-fill) // SMS User Consent API (OTP auto-fill)
implementation(libs.play.services.auth.api.phone) implementation(libs.play.services.auth.api.phone)
// Myket in-app billing
implementation("com.github.myketstore:myket-billing-client:1.6")
// Koin for DI // Koin for DI
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.koin.androidx.compose.v410) implementation(libs.koin.androidx.compose.v410)
+2 -2
View File
@@ -11,8 +11,8 @@
"type": "SINGLE", "type": "SINGLE",
"filters": [], "filters": [],
"attributes": [], "attributes": [],
"versionCode": 1, "versionCode": 2,
"versionName": "1.0", "versionName": "1.0.2+MK",
"outputFile": "app-release.apk" "outputFile": "app-release.apk"
} }
], ],
+6
View File
@@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
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"
@@ -0,0 +1,153 @@
package com.approagency.pharmacy.data.billing
import android.app.Activity
import android.content.Context
import com.approagency.pharmacy.BuildConfig
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.billing.PurchaseGateway
import com.approagency.pharmacy.domain.billing.PurchaseResult
import com.approagency.pharmacy.domain.model.SubscriptionProduct
import ir.myket.billingclient.IabHelper
import ir.myket.billingclient.util.Purchase
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* درگاهِ پرداختِ مایکت بر پایهٔ کتابخانهٔ `myket-billing-client` (IabHelper).
*
* جریان (مطابق همان مدلی که در اپ فلاترِ آپرواجنسی استفاده شده):
* 1. اتصال به سرویس مایکت ([ensureSetup]).
* 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید.
* 3. توکن به سرور فرستاده می‌شود (در لایهٔ اشتراک) و پس از تأیید، خرید [consume]
* می‌شود تا کاربر بتواند دورهٔ بعد دوباره خرید کند.
*
* چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را می‌پذیرد، همهٔ فراخوانی‌ها
* با [mutex] ترتیبی می‌شوند.
*/
class MyketPurchaseGateway(
context: Context,
private val session: SessionManager
) : PurchaseGateway {
override val name: String = "myket"
override val isAvailable: Boolean = true
private val appContext = context.applicationContext
private val mutex = Mutex()
@Volatile private var helper: IabHelper? = null
@Volatile private var setupDone = false
/** خریدهای منتظرِ مصرف، برحسبِ توکن. */
private val pending = mutableMapOf<String, Purchase>()
override suspend fun purchase(
activity: Activity,
product: SubscriptionProduct
): Result<PurchaseResult> = 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<Unit> = 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<Unit> {
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<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 {
data class Success(val purchase: Purchase) : PurchaseOutcome
data object AlreadyOwned : PurchaseOutcome
data class Error(val message: String) : PurchaseOutcome
}
}
@@ -22,4 +22,6 @@ class StubPurchaseGateway : PurchaseGateway {
product: SubscriptionProduct product: SubscriptionProduct
): Result<PurchaseResult> = ): Result<PurchaseResult> =
Result.failure(IllegalStateException("درگاه پرداخت روی این نسخه فعال نیست.")) Result.failure(IllegalStateException("درگاه پرداخت روی این نسخه فعال نیست."))
override suspend fun consume(purchaseToken: String): Result<Unit> = Result.success(Unit)
} }
@@ -1,7 +1,7 @@
package com.approagency.pharmacy.di 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.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()) }
// درگاه پرداخت: روی main استاب؛ شاخه‌های myket/bazar جایگزین می‌کنند. // درگاه پرداخت: روی شاخهٔ myket پیاده‌سازی مایکت.
single<PurchaseGateway> { StubPurchaseGateway() } single<PurchaseGateway> { MyketPurchaseGateway(androidContext(), get()) }
factory { DrugHtmlParser() } factory { DrugHtmlParser() }
@@ -26,6 +26,13 @@ interface PurchaseGateway {
* نیاز به [activity] برای باز کردن جریان پرداخت فروشگاه دارد. * نیاز به [activity] برای باز کردن جریان پرداخت فروشگاه دارد.
*/ */
suspend fun purchase(activity: Activity, product: SubscriptionProduct): Result<PurchaseResult> suspend fun purchase(activity: Activity, product: SubscriptionProduct): Result<PurchaseResult>
/**
* مصرفِ خرید پس از تأییدِ سرور، تا کاربر بتواند دوباره (دورهٔ بعد) خرید کند.
* فقط برای درگاه‌هایی که مدلِ مصرف‌شدنی دارند (مثل مایکت) معنا دارد؛ برای
* بقیه می‌تواند بی‌اثر باشد.
*/
suspend fun consume(purchaseToken: String): Result<Unit>
} }
/** نتیجه‌ی یک خرید موفق در فروشگاه. */ /** نتیجه‌ی یک خرید موفق در فروشگاه. */
@@ -47,9 +47,9 @@ object AppLegal {
title = "۴. جست‌وجوی رایگان، اشتراک و پرداخت", title = "۴. جست‌وجوی رایگان، اشتراک و پرداخت",
items = listOf( items = listOf(
"برنامه تعدادی جست‌وجوی رایگان در اختیار کاربر قرار می‌دهد؛ پس از پایانِ سهمیهٔ رایگان، برای استفادهٔ نامحدود تهیهٔ اشتراک لازم است.", "برنامه تعدادی جست‌وجوی رایگان در اختیار کاربر قرار می‌دهد؛ پس از پایانِ سهمیهٔ رایگان، برای استفادهٔ نامحدود تهیهٔ اشتراک لازم است.",
"خرید و پرداختِ اشتراک از طریق درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه (فروشگاه) انجام می‌شود و تابعِ قوانینِ همان فروشگاه است.", "خرید و پرداختِ اشتراک از طریق درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه (مایکت) انجام می‌شود و تابعِ قوانینِ همان فروشگاه است.",
"قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود.", "قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود.",
"بازگشتِ وجه تابعِ سیاست‌های فروشگاهِ فروشگاه است و از طریق همان فروشگاه پیگیری می‌شود.", "بازگشتِ وجه تابعِ سیاست‌های فروشگاهِ مایکت است و از طریق همان فروشگاه پیگیری می‌شود.",
"در صورتی که پس از پرداختِ موفق اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید." "در صورتی که پس از پرداختِ موفق اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید."
) )
), ),
@@ -135,6 +135,8 @@ class AccountViewModel(
} }
repository.subscribe(product.id, result.purchaseToken, gateway.name).fold( repository.subscribe(product.id, result.purchaseToken, gateway.name).fold(
onSuccess = { onSuccess = {
// پس از تأییدِ سرور، خرید را مصرف کن تا دورهٔ بعد قابل خرید باشد.
gateway.consume(result.purchaseToken)
_ui.update { _ui.update {
it.copy( it.copy(
purchasingProductId = null, purchasingProductId = null,
+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">داروچی</string> <string name="app_name">داروچی</string>
</resources> </resources>
+2 -2
View File
@@ -41,9 +41,9 @@
- برنامه تعدادی **جست‌وجوی رایگان** در اختیار کاربر قرار می‌دهد. پس از پایانِ - برنامه تعدادی **جست‌وجوی رایگان** در اختیار کاربر قرار می‌دهد. پس از پایانِ
سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است. سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است.
- خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه - خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه
(فروشگاه)** انجام می‌شود و تابعِ قوانین و شرایطِ همان فروشگاه است. (مایکت)** انجام می‌شود و تابعِ قوانین و شرایطِ همان فروشگاه است.
- قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود. - قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود.
- **بازگشتِ وجه** تابعِ سیاست‌های فروشگاهِ فروشگاه است. درخواست‌های مربوط به - **بازگشتِ وجه** تابعِ سیاست‌های فروشگاهِ مایکت است. درخواست‌های مربوط به
عودتِ وجهِ خریدهای درون‌برنامه‌ای از طریق همان فروشگاه پیگیری می‌شود. عودتِ وجهِ خریدهای درون‌برنامه‌ای از طریق همان فروشگاه پیگیری می‌شود.
- در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید. - در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید.
+1
View File
@@ -18,6 +18,7 @@ dependencyResolutionManagement {
//google() //google()
//mavenCentral() //mavenCentral()
maven { url = uri("https://maven.myket.ir") } maven { url = uri("https://maven.myket.ir") }
maven { url = uri("https://jitpack.io") }
} }
} }