forked from Tadrisino/MathApp_frontend
UI readability pass and Bazaar in-app purchase bridge
- muted text #9e9ec0 -> #727299 (contrast 2.6 -> 4.5 on white); tab labels 11px - map: chapter titles wrap to two lines instead of being cut off - reduced-motion respected (MotionConfig + CSS), focus-visible ring, no pull-to-refresh, short vibration on correct/wrong answers - src/lib/bazaar.ts: bridge to the Android WebView wrapper; checkout uses Bazaar IAP only inside that app, Zarinpal everywhere else (unchanged) - android/: WebView wrapper with Poolakey; written but never built Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
8
android/.gitignore
vendored
Normal file
8
android/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
*.jks
|
||||
*.keystore
|
||||
44
android/README.md
Normal file
44
android/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# اپِ اندرویدِ تدریسینو (پوستهی WebView + پرداختِ بازار)
|
||||
|
||||
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درونبرنامهایِ
|
||||
کافهبازار است (کتابخانهی Poolakey)، چون بازار برای اپی که در آن منتشر میشود پرداختِ
|
||||
محصولِ دیجیتال را فقط از راهِ خودش میپذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
|
||||
فرانت بهروز میشود، بیآنکه نسخهی تازهی اپ لازم باشد.
|
||||
|
||||
> ⚠️ **این پروژه هنوز یک بار هم build نشده.** روی ماشینی نوشته شد که Android SDK نداشت
|
||||
> (۲۰۲۶-۱۰-۰۳). APIِ Poolakey از README خودِ کتابخانه است. اولین کار: باز کردن در Android
|
||||
> Studio، build، و یک خریدِ واقعی روی گوشی.
|
||||
|
||||
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
||||
|
||||
| چه | کجا گذاشته میشود | وضعیت |
|
||||
|---|---|---|
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمیشود |
|
||||
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
||||
|
||||
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
|
||||
بازار نمیخواند.
|
||||
|
||||
## build
|
||||
|
||||
1. Android Studio ← Open ← همین پوشهی `android/`. Studio خودش Gradle wrapper را میسازد.
|
||||
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
|
||||
بدونِ همان کلید هیچ نسخهی بعدی روی بازار بهروزرسانی نمیشود.
|
||||
3. نصب روی گوشیای که کافهبازار دارد و با حسابِ تست وارد شده.
|
||||
|
||||
## جریانِ خرید (برای عیبیابی)
|
||||
|
||||
```
|
||||
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
|
||||
فرانت TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||
اپ Poolakey.purchaseProduct(sku, payload=orderId) → purchaseToken
|
||||
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
|
||||
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار میپرسد
|
||||
فرانت TadrisinoAndroid.bazaarConsume(token) ← تا همان بسته دوباره خریدنی باشد
|
||||
```
|
||||
|
||||
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» میزند: فرانت همان سفارش را میفرستد
|
||||
و اپ توکنِ ماندهٔ همان سفارش را از `getPurchasedProducts` پیدا میکند.
|
||||
|
||||
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بکاند.
|
||||
50
android/app/build.gradle.kts
Normal file
50
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,50 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
// ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
|
||||
// برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
|
||||
// که همان اپِ ریاضی است (CLAUDE.mdِ بکاند، ۲۰۲۶-۰۹-۳۰).
|
||||
namespace = "com.tadrisino.app"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.tadrisino.app"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
// آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
|
||||
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درونبرنامهای»). کلیدِ عمومی
|
||||
// است و در اپ مینشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمیکند.
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.2")
|
||||
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
|
||||
}
|
||||
29
android/app/src/main/AndroidManifest.xml
Normal file
29
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- اندروید ۱۱+: بدونِ این، اپ نمیتواند سرویسِ پرداختِ بازار را پیدا کند. -->
|
||||
<queries>
|
||||
<package android:name="com.farsitel.bazaar" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Tadrisino">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
167
android/app/src/main/java/com/tadrisino/app/MainActivity.kt
Normal file
167
android/app/src/main/java/com/tadrisino/app/MainActivity.kt
Normal file
@@ -0,0 +1,167 @@
|
||||
package com.tadrisino.app
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import ir.cafebazaar.poolakey.Connection
|
||||
import ir.cafebazaar.poolakey.Payment
|
||||
import ir.cafebazaar.poolakey.config.PaymentConfiguration
|
||||
import ir.cafebazaar.poolakey.config.SecurityCheck
|
||||
import ir.cafebazaar.poolakey.request.PurchaseRequest
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
||||
*
|
||||
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
||||
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||
* TadrisinoAndroid.bazaarConsume(purchaseToken)
|
||||
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
|
||||
* { orderId, error: "canceled" | "failed", message }
|
||||
*
|
||||
* سمتِ سرور توکن را از خودِ بازار میسنجد (`POST /api/payment/bazaar/verify`)، پس اینجا
|
||||
* فقط واسطه است و به هیچ نتیجهای اعتماد نمیکند.
|
||||
*
|
||||
* ⚠️ نوشته شده بیآنکه build شود (ماشینِ نویسنده Android SDK نداشت، ۲۰۲۶-۱۰-۰۳)؛ APIِ
|
||||
* Poolakey از README خودش است. پیش از انتشار با Android Studio بسازید و یک خریدِ واقعی بزنید.
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private var payment: Payment? = null
|
||||
private var connection: Connection? = null
|
||||
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
webView = WebView(this)
|
||||
setContentView(webView)
|
||||
|
||||
webView.settings.javaScriptEnabled = true
|
||||
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
// هر آدرسی بیرونِ دامنهی خودمان در مرورگرِ گوشی باز میشود، نه داخلِ اپ؛ پس پل فقط
|
||||
// در صفحههای خودمان در دسترس است.
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
if (request.url.host == webHost) return false
|
||||
startActivity(Intent(Intent.ACTION_VIEW, request.url))
|
||||
return true
|
||||
}
|
||||
}
|
||||
webView.addJavascriptInterface(Bridge(), "TadrisinoAndroid")
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
// اپِ وب برای هر صفحه history.pushState میزند؛ دکمهی back همان را برمیگرداند.
|
||||
override fun handleOnBackPressed() {
|
||||
if (webView.canGoBack()) webView.goBack() else finish()
|
||||
}
|
||||
})
|
||||
|
||||
connectToBazaar()
|
||||
if (savedInstanceState == null) webView.loadUrl(BuildConfig.WEB_URL)
|
||||
}
|
||||
|
||||
private fun connectToBazaar() {
|
||||
if (BuildConfig.BAZAAR_RSA_KEY.isEmpty()) {
|
||||
Log.e(TAG, "BAZAAR_RSA_KEY is empty; in-app purchase disabled")
|
||||
return
|
||||
}
|
||||
val config = PaymentConfiguration(
|
||||
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
|
||||
)
|
||||
payment = Payment(context = this, config = config).also { p ->
|
||||
connection = p.connect {
|
||||
connectionSucceed { Log.i(TAG, "bazaar connected") }
|
||||
connectionFailed { Log.e(TAG, "bazaar connection failed", it) }
|
||||
disconnected { Log.w(TAG, "bazaar disconnected") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun purchase(sku: String, orderId: String) {
|
||||
val p = payment
|
||||
if (p == null) {
|
||||
send(orderId, error = "failed", message = "bazaar not connected")
|
||||
return
|
||||
}
|
||||
p.purchaseProduct(
|
||||
registry = activityResultRegistry,
|
||||
request = PurchaseRequest(productId = sku, payload = orderId),
|
||||
) {
|
||||
purchaseSucceed { send(orderId, token = it.purchaseToken) }
|
||||
purchaseCanceled { send(orderId, error = "canceled") }
|
||||
purchaseFailed { recoverOwned(sku, orderId, it) }
|
||||
failedToBeginFlow { recoverOwned(sku, orderId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
|
||||
* دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
||||
* سفارش (payload) پیدا شد، همان برمیگردد تا سرور ثبتش کند.
|
||||
*/
|
||||
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
|
||||
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
|
||||
p.getPurchasedProducts {
|
||||
querySucceed { owned ->
|
||||
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
|
||||
if (match != null) send(orderId, token = match.purchaseToken)
|
||||
else send(orderId, error = "failed", message = cause.message)
|
||||
}
|
||||
queryFailed { send(orderId, error = "failed", message = cause.message) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(purchaseToken: String) {
|
||||
payment?.consumeProduct(purchaseToken) {
|
||||
consumeSucceed { Log.i(TAG, "consumed") }
|
||||
consumeFailed { Log.e(TAG, "consume failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(orderId: String, token: String? = null, error: String? = null, message: String? = null) {
|
||||
val detail = JSONObject().put("orderId", orderId)
|
||||
if (token != null) detail.put("purchaseToken", token)
|
||||
if (error != null) detail.put("error", error)
|
||||
if (message != null) detail.put("message", message)
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar', { detail: $detail }));", null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** متدهای این کلاس روی threadِ WebView صدا زده میشوند، نه UI. */
|
||||
private inner class Bridge {
|
||||
@JavascriptInterface
|
||||
fun bazaarPurchase(sku: String, orderId: String) {
|
||||
runOnUiThread {
|
||||
if (Uri.parse(webView.url ?: "").host != webHost) return@runOnUiThread
|
||||
purchase(sku, orderId)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun bazaarConsume(purchaseToken: String) {
|
||||
runOnUiThread { consume(purchaseToken) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
connection?.disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Tadrisino"
|
||||
}
|
||||
}
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">تدریسینو</string>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/themes.xml
Normal file
7
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Tadrisino" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">#ED6956</item>
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
android/build.gradle.kts
Normal file
4
android/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
}
|
||||
2
android/gradle.properties
Normal file
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx2g
|
||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Poolakey (کتابخانهی پرداختِ کافهبازار) فقط روی jitpack منتشر میشود.
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
rootProject.name = "Tadrisino"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user