forked from Tadrisino/MathApp_frontend
Android: buy() checks the Poolakey connection state and reconnects; BazaarNotFound / not supported / dynamic price not supported become error "unavailable" (not_installed | old_bazaar | not_connected) instead of a generic failure. assembleDebug green: com.tadrisino.math.app, versionCode 2, queries com.farsitel.bazaar. Not run on a phone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
7.4 KiB
TypeScript
142 lines
7.4 KiB
TypeScript
import { store } from './storage';
|
||
|
||
/**
|
||
* پلِ پرداختِ کافهبازار، وقتی همین سایت داخلِ اپِ اندرویدِ تدریسینو (WebView) باز است.
|
||
*
|
||
* خریدِ درونبرنامهایِ بازار فقط از کدِ بومیِ اندروید (کتابخانهی Poolakey) شدنی است، پس
|
||
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه میگذارد و این فایل تنها
|
||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست و خرید با زرینپال است.
|
||
*
|
||
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
||
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
|
||
* خریدِ اشتراک با subscribeProduct (فقط اشتراک فروخته میشود)؛ orderId همان payloadِ خرید است؛
|
||
* dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson) قیمتِ پیشخان؛ جواب با رویدادِ «tadrisino:bazaar-prices»
|
||
* و detailِ { prices: { sku: «۸۹۸,۰۰۰ تومان» } } یا { error }
|
||
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
||
* { orderId, purchaseToken } خرید انجام شد
|
||
* { orderId, error: 'canceled' | 'failed' | 'unavailable', message? }
|
||
* («unavailable»: بازار نصب نیست/وصل نشد یا قدیمی است؛ message: not_installed | old_bazaar | not_connected)
|
||
*
|
||
* `bazaarBuy` از APKهای پس از ۲۰۲۶-۱۰-۱۲ است. APKهای قبلی کلیدِ RSA نداشتند و Poolakey در آنها خاموش بود، پس از
|
||
* بازار هیچ خریدی نکردهاند؛ در آنها کاشیِ بازار غیرفعال است و «اپ را بهروز کن» میگوید.
|
||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
|
||
*/
|
||
|
||
interface AndroidBridge {
|
||
bazaarBuy?: (sku: string, orderId: string, dynamicPriceToken: string) => void;
|
||
/** APKِ قدیمی؛ فقط برای اینکه بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
|
||
bazaarPurchase?: (sku: string, orderId: string) => void;
|
||
bazaarPrices?: (skusJson: string) => void;
|
||
/** اولین صفحهی واقعی کشیده شد؛ اسپلشِ سیستم میتواند برود (APKهای قدیمیتر این را ندارند). */
|
||
appReady?: () => void;
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
TadrisinoAndroid?: AndroidBridge;
|
||
}
|
||
}
|
||
|
||
type BridgeResult =
|
||
| { orderId: string; purchaseToken: string }
|
||
| { orderId: string; error: 'canceled' | 'failed' | 'unavailable'; message?: string };
|
||
|
||
export class BazaarPurchaseError extends Error {
|
||
constructor(readonly kind: 'canceled' | 'failed' | 'unavailable' | 'timeout', message = '') {
|
||
super(message || kind);
|
||
}
|
||
}
|
||
|
||
/** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخهای). */
|
||
export function inAndroidApp(): boolean {
|
||
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
|
||
}
|
||
|
||
/** اپی که میتواند از بازار اشتراک بخرد. */
|
||
export function canBuyFromBazaar(): boolean {
|
||
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
|
||
}
|
||
|
||
/** صفحهی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را میگیرد. */
|
||
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
|
||
|
||
/** خرید را به اپِ اندروید میسپارد و purchaseToken را برمیگرداند. */
|
||
export function bazaarBuy(sku: string, orderId: string, dynamicPriceToken: string | null): Promise<string> {
|
||
const bridge = window.TadrisinoAndroid;
|
||
if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
|
||
return new Promise((resolve, reject) => {
|
||
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
|
||
function finish(settle: () => void) {
|
||
window.clearTimeout(timer);
|
||
window.removeEventListener('tadrisino:bazaar', onResult);
|
||
settle();
|
||
}
|
||
function onResult(event: Event) {
|
||
const detail = (event as CustomEvent<BridgeResult>).detail;
|
||
if (!detail || detail.orderId !== orderId) return;
|
||
if ('purchaseToken' in detail && detail.purchaseToken) finish(() => resolve(detail.purchaseToken));
|
||
else if ('error' in detail) finish(() => reject(new BazaarPurchaseError(detail.error, detail.message)));
|
||
}
|
||
window.addEventListener('tadrisino:bazaar', onResult);
|
||
try {
|
||
bridge.bazaarBuy!(sku, orderId, dynamicPriceToken ?? '');
|
||
} catch (err) {
|
||
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
|
||
}
|
||
});
|
||
}
|
||
|
||
type PricesResult = { prices?: Record<string, string>; error?: string };
|
||
|
||
function askPrices(skus: string[]): Promise<Record<string, string>> {
|
||
const bridge = window.TadrisinoAndroid;
|
||
if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({});
|
||
return new Promise((resolve, reject) => {
|
||
const timer = window.setTimeout(() => done(() => reject(new Error('timeout'))), 8000);
|
||
function done(settle: () => void) {
|
||
window.clearTimeout(timer);
|
||
window.removeEventListener('tadrisino:bazaar-prices', onResult);
|
||
settle();
|
||
}
|
||
function onResult(event: Event) {
|
||
const detail = (event as CustomEvent<PricesResult>).detail;
|
||
if (!detail) return;
|
||
if (detail.prices) done(() => resolve(detail.prices!));
|
||
else done(() => reject(new Error(detail.error || 'failed')));
|
||
}
|
||
window.addEventListener('tadrisino:bazaar-prices', onResult);
|
||
bridge.bazaarPrices!(JSON.stringify(skus));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* قیمتِ ثبتشده در پیشخانِ بازار برای هر SKU، از خودِ بازار روی گوشی (API سرور قیمت نمیدهد). اتصالِ Poolakey
|
||
* چند لحظه بعد از باز شدنِ اپ برقرار میشود، پس یک بار دوباره امتحان میشود. خطا یعنی قیمتی نشان داده نمیشود.
|
||
*/
|
||
export async function bazaarPrices(skus: string[]): Promise<Record<string, string>> {
|
||
try {
|
||
return await askPrices(skus);
|
||
} catch {
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
try {
|
||
return await askPrices(skus);
|
||
} catch (err) {
|
||
console.error('bazaarPrices failed', err);
|
||
return {};
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Idempotency-Keyِ خریدِ نیمهتمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
|
||
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمیشد چون هر
|
||
* توکن فقط به یک سفارش میخورد.
|
||
*/
|
||
const pendingKey = (sku: string) => `tadrisino.bazaarPending.${sku}`;
|
||
export const bazaarPending = {
|
||
get: (sku: string) => store.get(pendingKey(sku)),
|
||
set: (sku: string, key: string) => store.set(pendingKey(sku), key),
|
||
clear: (sku: string) => store.remove(pendingKey(sku)),
|
||
};
|