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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+41
-16
@@ -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);
|
||||
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