4 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
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 178 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+BZ"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// ---- Bazaar (Cafe Bazaar) in-app billing ----
val marketApplicationId = "com.farsitel.bazaar"
val marketBindAddress = "ir.cafebazaar.pardakht.InAppBillingService.BIND"
manifestPlaceholders["marketApplicationId"] = marketApplicationId
manifestPlaceholders["marketBindAddress"] = marketBindAddress
manifestPlaceholders["marketPermission"] = "com.farsitel.bazaar.permission.PAY_THROUGH_BAZAAR"
buildConfigField(
"String",
"IAB_PUBLIC_KEY",
"\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwDzw+tRS/C//9JebsgTQOPAJU3Qwg7aIOhO4wqPVMNCjkGQZH7MckLU8hC6Rrag8gakkGBROSN/J58W4q1wxFbaUgRiTvyM04ElE7tHHa8CKtNH3NSUFzCQkhqd+9mLGflmj7JEj6EaZLoTLfZYhQgGDpWNXwW9HmO2lygXkua6BUkelpafgAmLz/ojlBm+ByLdSVkXIaaGqs2GLLR4qZHiksljQskbv0T0Ccax8pcCAwEAAQ==\""
)
} }
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"
} }
], ],
+1
View File
@@ -2,6 +2,7 @@
<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" />
<application <application
android:name=".DrugApp" android:name=".DrugApp"
android:allowBackup="true" android:allowBackup="true"
@@ -0,0 +1,135 @@
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). فروشگاهِ مقصد با `manifestPlaceholders` در build.gradle مشخص
* می‌شود (com.farsitel.bazaar)، بنابراین کدِ IabHelper یکسان است و فقط مقصدِ
* اتصال و کلیدِ عمومی فرق می‌کند.
*
* جریان:
* 1. اتصال به سرویس بازار ([ensureSetup]).
* 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید.
* 3. توکن به سرور فرستاده می‌شود (با gateway = "bazaar") و پس از تأیید، خرید
* [consume] می‌شود تا کاربر بتواند دورهٔ بعد دوباره خرید کند.
*
* چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را می‌پذیرد، همهٔ فراخوانی‌ها
* با [mutex] ترتیبی می‌شوند.
*/
class BazaarPurchaseGateway(
context: Context,
private val session: SessionManager
) : PurchaseGateway {
// مقدارِ معتبرِ درگاه در سمتِ سرورِ آپرواجنسی برای کافه‌بازار «cafe» است.
override val name: String = "cafe"
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.launchSubscriptionPurchaseFlow(
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 ->
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> {
pending.remove(purchaseToken)
return Result.success(Unit)
}
/** اتصال به سرویس بازار (یک‌بار). */
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 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.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()) }
// درگاه پرداخت: روی main استاب؛ شاخه‌های myket/bazar جایگزین می‌کنند. // درگاه پرداخت: روی شاخهٔ bazar پیاده‌سازی کافه‌بازار.
single<PurchaseGateway> { StubPurchaseGateway() } single<PurchaseGateway> { BazaarPurchaseGateway(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,
+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") }
} }
} }