diff --git a/android/app/build.gradle b/android/app/build.gradle
index 143e309..386ed3c 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5c0bf40..0e17595 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -39,4 +39,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl b/android/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl
new file mode 100644
index 0000000..a61a55b
--- /dev/null
+++ b/android/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl
@@ -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);
+}
diff --git a/android/app/src/main/java/com/bargevasat/app/MainActivity.java b/android/app/src/main/java/com/bargevasat/app/MainActivity.java
index e84ad3b..97e0f1f 100644
--- a/android/app/src/main/java/com/bargevasat/app/MainActivity.java
+++ b/android/app/src/main/java/com/bargevasat/app/MainActivity.java
@@ -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 =
diff --git a/android/app/src/main/java/com/bargevasat/app/billing/MyketBillingPlugin.java b/android/app/src/main/java/com/bargevasat/app/billing/MyketBillingPlugin.java
new file mode 100644
index 0000000..bc32c0e
--- /dev/null
+++ b/android/app/src/main/java/com/bargevasat/app/billing/MyketBillingPlugin.java
@@ -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();
+ }
+}
diff --git a/android/app/src/main/java/com/bargevasat/app/billing/Security.java b/android/app/src/main/java/com/bargevasat/app/billing/Security.java
new file mode 100644
index 0000000..0193c38
--- /dev/null
+++ b/android/app/src/main/java/com/bargevasat/app/billing/Security.java
@@ -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;
+ }
+}
diff --git a/src/components/screens/BuyCoinsScreen.tsx b/src/components/screens/BuyCoinsScreen.tsx
index bea772f..c84d58d 100644
--- a/src/components/screens/BuyCoinsScreen.tsx
+++ b/src/components/screens/BuyCoinsScreen.tsx
@@ -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);
diff --git a/src/lib/storeBilling.ts b/src/lib/storeBilling.ts
index 2b2cb8a..f88e2b4 100644
--- a/src/lib/storeBilling.ts
+++ b/src/lib/storeBilling.ts
@@ -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;
+// 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;
+ purchase(opts: { sku: string; rsaPublicKey: string }): Promise<{
+ purchaseToken: string;
+ productId?: string;
+ }>;
+ consume(opts: { token: string }): Promise;
}
+const MyketBilling = registerPlugin("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 {
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 {
+ 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.