feat(iap): native Myket in-app billing plugin (AIDL) + wire purchase/consume
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 24s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m1s

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:
soroush.asadi
2026-06-12 20:59:56 +03:30
parent 7dbadee406
commit d1bd279eba
8 changed files with 341 additions and 18 deletions
+4
View File
@@ -12,6 +12,10 @@ if (keystorePropsFile.exists()) {
android {
namespace = "com.bargevasat.app"
compileSdk = rootProject.ext.compileSdkVersion
// AGP 8 disables AIDL by default; the Myket billing service needs it.
buildFeatures {
aidl true
}
defaultConfig {
applicationId "com.bargevasat.app"
minSdkVersion rootProject.ext.minSdkVersion
+10
View File
@@ -39,4 +39,14 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Myket in-app billing -->
<uses-permission android:name="ir.mservices.market.BILLING" />
<!-- Android 11+ package visibility: allow binding to the Myket billing service -->
<queries>
<package android:name="ir.mservices.market" />
<intent>
<action android:name="ir.mservices.market.InAppBillingService.BIND" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,17 @@
// Google Play In-App Billing v3 interface. Myket implements the SAME interface
// (bound to the Myket app via ir.mservices.market.InAppBillingService.BIND).
package com.android.vending.billing;
import android.os.Bundle;
interface IInAppBillingService {
int isBillingSupported(int apiVersion, String packageName, String type);
Bundle getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle);
Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, String developerPayload);
Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken);
int consumePurchase(int apiVersion, String packageName, String purchaseToken);
}
@@ -1,9 +1,11 @@
package com.bargevasat.app;
import android.content.Intent;
import android.os.Bundle;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.bargevasat.app.billing.MyketBillingPlugin;
import com.getcapacitor.BridgeActivity;
/**
@@ -13,6 +15,8 @@ import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
// Register native plugins before the bridge starts.
registerPlugin(MyketBillingPlugin.class);
super.onCreate(savedInstanceState);
enableImmersive();
}
@@ -25,6 +29,15 @@ public class MainActivity extends BridgeActivity {
if (hasFocus) enableImmersive();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
// Forward the Myket purchase intent result to the billing plugin.
if (requestCode == MyketBillingPlugin.RC_BUY) {
MyketBillingPlugin.onPurchaseActivityResult(resultCode, data);
}
}
private void enableImmersive() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller =
@@ -0,0 +1,181 @@
package com.bargevasat.app.billing;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import com.android.vending.billing.IInAppBillingService;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONObject;
/**
* Myket in-app billing for the Capacitor WebView. Myket implements the classic
* Google Play IAB v3 AIDL (IInAppBillingService), bound to the Myket app via
* "ir.mservices.market.InAppBillingService.BIND". The purchase intent result is
* delivered to MainActivity.onActivityResult, which forwards to
* {@link #onPurchaseActivityResult}.
*/
@CapacitorPlugin(name = "MyketBilling")
public class MyketBillingPlugin extends Plugin {
private static final String TAG = "MyketBilling";
private static final String MARKET_PACKAGE = "ir.mservices.market";
private static final String BIND_ACTION = "ir.mservices.market.InAppBillingService.BIND";
private static final int API_VERSION = 3;
public static final int RC_BUY = 11001;
private static final int RESULT_OK_CODE = 0; // BILLING_RESPONSE_RESULT_OK
private static MyketBillingPlugin instance;
private IInAppBillingService service;
private ServiceConnection conn;
private boolean bound = false;
private String rsaKey = "";
private PluginCall pendingPurchase;
@Override
public void load() {
instance = this;
}
/** Forwarded from MainActivity.onActivityResult for RC_BUY. */
public static void onPurchaseActivityResult(int resultCode, Intent data) {
if (instance != null) instance.handlePurchaseResult(resultCode, data);
}
// ----------------------------- JS methods -----------------------------
@PluginMethod
public void isAvailable(PluginCall call) {
JSObject ret = new JSObject();
ret.put("available", isPackageInstalled(MARKET_PACKAGE));
call.resolve(ret);
}
@PluginMethod
public void connect(PluginCall call) {
rsaKey = call.getString("rsaPublicKey", rsaKey);
if (!isPackageInstalled(MARKET_PACKAGE)) { call.reject("myket_not_installed"); return; }
bind(call::resolve, call);
}
@PluginMethod
public void purchase(final PluginCall call) {
final String sku = call.getString("sku");
final String key = call.getString("rsaPublicKey", rsaKey);
if (key != null) rsaKey = key;
if (sku == null) { call.reject("missing_sku"); return; }
if (!isPackageInstalled(MARKET_PACKAGE)) { call.reject("myket_not_installed"); return; }
bind(() -> {
try {
Bundle buy = service.getBuyIntent(API_VERSION, getContext().getPackageName(), sku, "inapp", "");
int rc = buy.getInt("RESPONSE_CODE");
if (rc != RESULT_OK_CODE) { call.reject("buy_intent_failed_" + rc); return; }
PendingIntent pi = buy.getParcelable("BUY_INTENT");
if (pi == null) { call.reject("no_buy_intent"); return; }
pendingPurchase = call;
getActivity().startIntentSenderForResult(pi.getIntentSender(), RC_BUY, new Intent(), 0, 0, 0);
} catch (Exception e) {
call.reject("purchase_error", e);
}
}, call);
}
@PluginMethod
public void consume(final PluginCall call) {
final String token = call.getString("token");
if (token == null) { call.reject("missing_token"); return; }
bind(() -> {
try {
int rc = service.consumePurchase(API_VERSION, getContext().getPackageName(), token);
if (rc == RESULT_OK_CODE) call.resolve();
else call.reject("consume_failed_" + rc);
} catch (RemoteException e) {
call.reject("consume_error", e);
}
}, call);
}
// ----------------------------- internals -----------------------------
private void handlePurchaseResult(int resultCode, Intent data) {
PluginCall call = pendingPurchase;
pendingPurchase = null;
if (call == null) return;
if (data == null || resultCode != Activity.RESULT_OK) { call.reject("purchase_cancelled"); return; }
int rc = data.getIntExtra("RESPONSE_CODE", 0);
if (rc != RESULT_OK_CODE) { call.reject("purchase_failed_" + rc); return; }
String purchaseData = data.getStringExtra("INAPP_PURCHASE_DATA");
String signature = data.getStringExtra("INAPP_DATA_SIGNATURE");
if (purchaseData == null) { call.reject("no_purchase_data"); return; }
if (rsaKey != null && !rsaKey.isEmpty()
&& !Security.verifyPurchase(rsaKey, purchaseData, signature)) {
call.reject("invalid_signature");
return;
}
try {
JSONObject o = new JSONObject(purchaseData);
JSObject ret = new JSObject();
ret.put("purchaseToken", o.optString("purchaseToken"));
ret.put("productId", o.optString("productId"));
ret.put("orderId", o.optString("orderId"));
ret.put("purchaseData", purchaseData);
ret.put("signature", signature == null ? "" : signature);
call.resolve(ret);
} catch (Exception e) {
call.reject("parse_error", e);
}
}
private void bind(final Runnable onReady, final PluginCall failCall) {
if (bound && service != null) { if (onReady != null) onReady.run(); return; }
conn = new ServiceConnection() {
@Override public void onServiceConnected(ComponentName name, IBinder binder) {
service = IInAppBillingService.Stub.asInterface(binder);
bound = true;
if (onReady != null) onReady.run();
}
@Override public void onServiceDisconnected(ComponentName name) { service = null; bound = false; }
};
Intent intent = new Intent(BIND_ACTION);
intent.setPackage(MARKET_PACKAGE);
try {
boolean ok = getContext().bindService(intent, conn, Context.BIND_AUTO_CREATE);
if (!ok && failCall != null) failCall.reject("myket_unavailable");
} catch (Exception e) {
Log.e(TAG, "bindService failed", e);
if (failCall != null) failCall.reject("myket_unavailable", e);
}
}
private boolean isPackageInstalled(String pkg) {
try {
getContext().getPackageManager().getPackageInfo(pkg, 0);
return true;
} catch (Exception e) {
return false;
}
}
@Override
protected void handleOnDestroy() {
if (bound && conn != null) {
try { getContext().unbindService(conn); } catch (Exception ignored) {}
}
bound = false;
service = null;
if (instance == this) instance = null;
super.handleOnDestroy();
}
}
@@ -0,0 +1,71 @@
package com.bargevasat.app.billing;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.SignatureException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
/**
* Verifies that a Myket purchase payload was signed by the store, using the
* app's RSA public key (from the Myket developer panel). Mirrors Google Play
* IAB v3 "Security" — Myket uses the same SHA1withRSA signing.
*/
public final class Security {
private static final String TAG = "MyketSecurity";
private static final String KEY_FACTORY_ALGORITHM = "RSA";
private static final String SIGNATURE_ALGORITHM = "SHA1withRSA";
private Security() {}
/** @return true if signedData was signed by the private key matching base64PublicKey. */
public static boolean verifyPurchase(String base64PublicKey, String signedData, String signature) {
if (TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey) || TextUtils.isEmpty(signature)) {
Log.w(TAG, "Purchase verification failed: missing data.");
return false;
}
try {
PublicKey key = generatePublicKey(base64PublicKey);
return verify(key, signedData, signature);
} catch (Exception e) {
Log.e(TAG, "Verification error", e);
return false;
}
}
private static PublicKey generatePublicKey(String encodedPublicKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM);
return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
}
private static boolean verify(PublicKey publicKey, String signedData, String signature) {
byte[] signatureBytes;
try {
signatureBytes = Base64.decode(signature, Base64.DEFAULT);
} catch (IllegalArgumentException e) {
Log.e(TAG, "Base64 decoding failed.");
return false;
}
try {
java.security.Signature sig = java.security.Signature.getInstance(SIGNATURE_ALGORITHM);
sig.initVerify(publicKey);
sig.update(signedData.getBytes());
if (!sig.verify(signatureBytes)) {
Log.e(TAG, "Signature verification failed.");
return false;
}
return true;
} catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
Log.e(TAG, "Signature exception", e);
}
return false;
}
}
+3 -1
View File
@@ -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
View File
@@ -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.