Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2715bc720d |
Generated
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
<?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" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" 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">
|
||||||
|
|||||||
+2
-18
@@ -15,22 +15,10 @@ android {
|
|||||||
applicationId = "com.approagency.pharmacy"
|
applicationId = "com.approagency.pharmacy"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 2
|
versionCode = 1
|
||||||
versionName = "1.0.2+BZ"
|
versionName = "1.0"
|
||||||
|
|
||||||
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 {
|
||||||
@@ -51,7 +39,6 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +80,6 @@ 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)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
"type": "SINGLE",
|
"type": "SINGLE",
|
||||||
"filters": [],
|
"filters": [],
|
||||||
"attributes": [],
|
"attributes": [],
|
||||||
"versionCode": 2,
|
"versionCode": 1,
|
||||||
"versionName": "1.0.2+MK",
|
"versionName": "1.0",
|
||||||
"outputFile": "app-release.apk"
|
"outputFile": "app-release.apk"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<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"
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
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,6 +22,4 @@ 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.BazaarPurchaseGateway
|
import com.approagency.pharmacy.data.billing.StubPurchaseGateway
|
||||||
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 پیادهسازی کافهبازار.
|
// درگاه پرداخت: روی main استاب؛ شاخههای myket/bazar جایگزین میکنند.
|
||||||
single<PurchaseGateway> { BazaarPurchaseGateway(androidContext(), get()) }
|
single<PurchaseGateway> { StubPurchaseGateway() }
|
||||||
|
|
||||||
|
|
||||||
factory { DrugHtmlParser() }
|
factory { DrugHtmlParser() }
|
||||||
|
|||||||
@@ -26,13 +26,6 @@ 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,8 +135,6 @@ 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,
|
||||||
|
|||||||
@@ -41,9 +41,9 @@
|
|||||||
- برنامه تعدادی **جستوجوی رایگان** در اختیار کاربر قرار میدهد. پس از پایانِ
|
- برنامه تعدادی **جستوجوی رایگان** در اختیار کاربر قرار میدهد. پس از پایانِ
|
||||||
سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است.
|
سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است.
|
||||||
- خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درونبرنامهایِ فروشگاه
|
- خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درونبرنامهایِ فروشگاه
|
||||||
(کافهبازار)** انجام میشود و تابعِ قوانین و شرایطِ همان فروشگاه است.
|
(فروشگاه)** انجام میشود و تابعِ قوانین و شرایطِ همان فروشگاه است.
|
||||||
- قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده میشود.
|
- قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده میشود.
|
||||||
- **بازگشتِ وجه** تابعِ سیاستهای فروشگاهِ کافهبازار است. درخواستهای مربوط به
|
- **بازگشتِ وجه** تابعِ سیاستهای فروشگاهِ فروشگاه است. درخواستهای مربوط به
|
||||||
عودتِ وجهِ خریدهای درونبرنامهای از طریق همان فروشگاه پیگیری میشود.
|
عودتِ وجهِ خریدهای درونبرنامهای از طریق همان فروشگاه پیگیری میشود.
|
||||||
- در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید.
|
- در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید.
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user