feat(iap): native Myket in-app billing plugin (AIDL) + wire purchase/consume
Implements real Myket IAB for the Capacitor app (Myket has no purchase deep-link like Bazaar — it uses the classic Google Play IAB v3 AIDL bound to the Myket app): - AIDL: com.android.vending.billing.IInAppBillingService (Myket-compatible). - MyketBillingPlugin (Capacitor): binds ir.mservices.market via "ir.mservices.market.InAppBillingService.BIND", runs getBuyIntent → startIntentSenderForResult, verifies INAPP_DATA_SIGNATURE with the RSA key (Security.java, SHA1withRSA), returns the purchaseToken; consume() too. - MainActivity registers the plugin + forwards the purchase activity result. - Manifest: ir.mservices.market.BILLING permission + <queries> for Android 11+ package visibility. - build.gradle: enable buildFeatures.aidl (AGP 8 disables it by default). - storeBilling: Myket goes through the plugin (RSA key embedded); after server verify, BuyCoins consumes the purchase so coins can be re-bought. Bazaar (deep-link) and web (ZarinPal) paths unchanged. Needs on-device testing with the Myket app installed + published products. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { isStoreBilling, purchaseViaStore } from "@/lib/storeBilling";
|
||||
import { consumeStorePurchase, isStoreBilling, purchaseViaStore } from "@/lib/storeBilling";
|
||||
import { sound } from "@/lib/sound";
|
||||
import { CoinPack } from "@/lib/online/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -47,6 +47,8 @@ export function BuyCoinsScreen() {
|
||||
if (r.kind === "token") {
|
||||
const v = await getService().verifyIab(r.store, r.productId, r.token);
|
||||
if (v.ok && v.profile) {
|
||||
// Consumable: let the store mark it consumed so it can be re-bought.
|
||||
await consumeStorePurchase(r.store, r.token);
|
||||
setProfile(v.profile);
|
||||
sound.play("purchase");
|
||||
setGained(v.coins);
|
||||
|
||||
+42
-17
@@ -4,13 +4,14 @@
|
||||
// `bazaar://in_app?...&sku=...&redirect_url=...`; Bazaar processes payment and
|
||||
// reopens the app at redirect_url with `?purchaseToken=...`. We stash the SKU
|
||||
// first, then on return POST the token to `/api/coins/iab/verify`.
|
||||
// - **Myket**: native AIDL billing. A Capacitor plugin must inject
|
||||
// `window.MyketBilling` (see ANDROID.md). We call `.purchase(sku)` and POST the
|
||||
// returned token to verify. Without the bridge, Myket is "unavailable".
|
||||
// - **Myket**: native AIDL billing via the `MyketBilling` Capacitor plugin
|
||||
// (android/.../billing/MyketBillingPlugin.java). We call `.purchase({sku})`,
|
||||
// POST the returned token to verify, then `.consume({token})` so the
|
||||
// consumable can be bought again.
|
||||
//
|
||||
// The active store is the build flavor `NEXT_PUBLIC_STORE` (bazaar|myket|web),
|
||||
// overridden at runtime if the Myket native bridge is present.
|
||||
// The active store is the build flavor `NEXT_PUBLIC_STORE` (bazaar|myket|web).
|
||||
|
||||
import { registerPlugin } from "@capacitor/core";
|
||||
import { CoinPack } from "./online/types";
|
||||
|
||||
export type StoreId = "bazaar" | "myket" | "web";
|
||||
@@ -19,24 +20,30 @@ const ENV_STORE = ((process.env.NEXT_PUBLIC_STORE as StoreId | undefined) ?? "we
|
||||
const PACKAGE = process.env.NEXT_PUBLIC_APP_PACKAGE ?? "com.bargevasat.app";
|
||||
const PENDING_SKU_KEY = "iab_pending_sku";
|
||||
|
||||
/** Native bridge contract a Myket Capacitor plugin must fulfil. */
|
||||
interface MyketBridge {
|
||||
available?: boolean;
|
||||
purchase: (sku: string) => Promise<{ purchaseToken: string; productId?: string }>;
|
||||
consume?: (token: string) => Promise<void>;
|
||||
// Myket in-app billing RSA public key (Myket developer panel). Public, not secret.
|
||||
const MYKET_RSA_PUBLIC_KEY =
|
||||
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbUBKRU4g1AQrbOO8GkcBn79ol0hbs5PZVd5vPP6za98BTc9leqvyGE+DwSg7lbsXTZxCzPRBS3m0qB9LShe70WG+RQapG9Q2lodszYkauicPkJSpbXWh/nfrziTWNqEHqUfCsC4+lkKSEkxDNa1Po7uZzbwaJ+Kf1+d8wSWYpxwIDAQAB";
|
||||
|
||||
interface MyketBillingPlugin {
|
||||
isAvailable(): Promise<{ available: boolean }>;
|
||||
connect(opts: { rsaPublicKey: string }): Promise<void>;
|
||||
purchase(opts: { sku: string; rsaPublicKey: string }): Promise<{
|
||||
purchaseToken: string;
|
||||
productId?: string;
|
||||
}>;
|
||||
consume(opts: { token: string }): Promise<void>;
|
||||
}
|
||||
const MyketBilling = registerPlugin<MyketBillingPlugin>("MyketBilling");
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MyketBilling?: MyketBridge;
|
||||
Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string };
|
||||
}
|
||||
}
|
||||
|
||||
export function getStore(): StoreId {
|
||||
if (typeof window === "undefined") return ENV_STORE;
|
||||
// Myket's native bridge wins when present (a Myket-flavored build).
|
||||
if (window.MyketBilling?.available) return "myket";
|
||||
// Honor an explicit build flavor.
|
||||
// Honor an explicit build flavor (bazaar | myket).
|
||||
if (ENV_STORE !== "web") return ENV_STORE;
|
||||
// Otherwise, inside the Android app shell (Capacitor) default to Cafe Bazaar
|
||||
// IAB — the APK ships to Bazaar, which requires its own billing. The web build
|
||||
@@ -77,14 +84,32 @@ export async function purchaseViaStore(pack: CoinPack): Promise<PurchaseStart> {
|
||||
return { kind: "redirect" };
|
||||
}
|
||||
|
||||
if (store === "myket" && window.MyketBilling) {
|
||||
const res = await window.MyketBilling.purchase(sku);
|
||||
return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken };
|
||||
if (store === "myket") {
|
||||
try {
|
||||
const res = await MyketBilling.purchase({ sku, rsaPublicKey: MYKET_RSA_PUBLIC_KEY });
|
||||
return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken };
|
||||
} catch {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a verified store purchase. Coin packs are consumable, so on Myket we
|
||||
* must consume the purchase (after the server credited it) to allow re-buying.
|
||||
* Bazaar consumables are handled server-side; this is a no-op there.
|
||||
*/
|
||||
export async function consumeStorePurchase(store: StoreId, token: string): Promise<void> {
|
||||
if (store !== "myket" || !token) return;
|
||||
try {
|
||||
await MyketBilling.consume({ token });
|
||||
} catch {
|
||||
/* best-effort; the server already credited */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On app load, capture a Bazaar redirect (`?purchaseToken=...`). Returns the
|
||||
* pending purchase to verify, or null. Also clears the stashed SKU.
|
||||
|
||||
Reference in New Issue
Block a user