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 {
|
android {
|
||||||
namespace = "com.bargevasat.app"
|
namespace = "com.bargevasat.app"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
// AGP 8 disables AIDL by default; the Myket billing service needs it.
|
||||||
|
buildFeatures {
|
||||||
|
aidl true
|
||||||
|
}
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.bargevasat.app"
|
applicationId "com.bargevasat.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
|||||||
@@ -39,4 +39,14 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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>
|
</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;
|
package com.bargevasat.app;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import androidx.core.view.WindowCompat;
|
import androidx.core.view.WindowCompat;
|
||||||
import androidx.core.view.WindowInsetsCompat;
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
import androidx.core.view.WindowInsetsControllerCompat;
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
|
import com.bargevasat.app.billing.MyketBillingPlugin;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +15,8 @@ import com.getcapacitor.BridgeActivity;
|
|||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
// Register native plugins before the bridge starts.
|
||||||
|
registerPlugin(MyketBillingPlugin.class);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
enableImmersive();
|
enableImmersive();
|
||||||
}
|
}
|
||||||
@@ -25,6 +29,15 @@ public class MainActivity extends BridgeActivity {
|
|||||||
if (hasFocus) enableImmersive();
|
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() {
|
private void enableImmersive() {
|
||||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||||
WindowInsetsControllerCompat controller =
|
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 { useSessionStore } from "@/lib/session-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { getService } from "@/lib/online/service";
|
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 { sound } from "@/lib/sound";
|
||||||
import { CoinPack } from "@/lib/online/types";
|
import { CoinPack } from "@/lib/online/types";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
@@ -47,6 +47,8 @@ export function BuyCoinsScreen() {
|
|||||||
if (r.kind === "token") {
|
if (r.kind === "token") {
|
||||||
const v = await getService().verifyIab(r.store, r.productId, r.token);
|
const v = await getService().verifyIab(r.store, r.productId, r.token);
|
||||||
if (v.ok && v.profile) {
|
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);
|
setProfile(v.profile);
|
||||||
sound.play("purchase");
|
sound.play("purchase");
|
||||||
setGained(v.coins);
|
setGained(v.coins);
|
||||||
|
|||||||
+41
-16
@@ -4,13 +4,14 @@
|
|||||||
// `bazaar://in_app?...&sku=...&redirect_url=...`; Bazaar processes payment and
|
// `bazaar://in_app?...&sku=...&redirect_url=...`; Bazaar processes payment and
|
||||||
// reopens the app at redirect_url with `?purchaseToken=...`. We stash the SKU
|
// reopens the app at redirect_url with `?purchaseToken=...`. We stash the SKU
|
||||||
// first, then on return POST the token to `/api/coins/iab/verify`.
|
// first, then on return POST the token to `/api/coins/iab/verify`.
|
||||||
// - **Myket**: native AIDL billing. A Capacitor plugin must inject
|
// - **Myket**: native AIDL billing via the `MyketBilling` Capacitor plugin
|
||||||
// `window.MyketBilling` (see ANDROID.md). We call `.purchase(sku)` and POST the
|
// (android/.../billing/MyketBillingPlugin.java). We call `.purchase({sku})`,
|
||||||
// returned token to verify. Without the bridge, Myket is "unavailable".
|
// 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),
|
// The active store is the build flavor `NEXT_PUBLIC_STORE` (bazaar|myket|web).
|
||||||
// overridden at runtime if the Myket native bridge is present.
|
|
||||||
|
|
||||||
|
import { registerPlugin } from "@capacitor/core";
|
||||||
import { CoinPack } from "./online/types";
|
import { CoinPack } from "./online/types";
|
||||||
|
|
||||||
export type StoreId = "bazaar" | "myket" | "web";
|
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 PACKAGE = process.env.NEXT_PUBLIC_APP_PACKAGE ?? "com.bargevasat.app";
|
||||||
const PENDING_SKU_KEY = "iab_pending_sku";
|
const PENDING_SKU_KEY = "iab_pending_sku";
|
||||||
|
|
||||||
/** Native bridge contract a Myket Capacitor plugin must fulfil. */
|
// Myket in-app billing RSA public key (Myket developer panel). Public, not secret.
|
||||||
interface MyketBridge {
|
const MYKET_RSA_PUBLIC_KEY =
|
||||||
available?: boolean;
|
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbUBKRU4g1AQrbOO8GkcBn79ol0hbs5PZVd5vPP6za98BTc9leqvyGE+DwSg7lbsXTZxCzPRBS3m0qB9LShe70WG+RQapG9Q2lodszYkauicPkJSpbXWh/nfrziTWNqEHqUfCsC4+lkKSEkxDNa1Po7uZzbwaJ+Kf1+d8wSWYpxwIDAQAB";
|
||||||
purchase: (sku: string) => Promise<{ purchaseToken: string; productId?: string }>;
|
|
||||||
consume?: (token: string) => Promise<void>;
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
MyketBilling?: MyketBridge;
|
|
||||||
Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string };
|
Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStore(): StoreId {
|
export function getStore(): StoreId {
|
||||||
if (typeof window === "undefined") return ENV_STORE;
|
if (typeof window === "undefined") return ENV_STORE;
|
||||||
// Myket's native bridge wins when present (a Myket-flavored build).
|
// Honor an explicit build flavor (bazaar | myket).
|
||||||
if (window.MyketBilling?.available) return "myket";
|
|
||||||
// Honor an explicit build flavor.
|
|
||||||
if (ENV_STORE !== "web") return ENV_STORE;
|
if (ENV_STORE !== "web") return ENV_STORE;
|
||||||
// Otherwise, inside the Android app shell (Capacitor) default to Cafe Bazaar
|
// 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
|
// 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" };
|
return { kind: "redirect" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store === "myket" && window.MyketBilling) {
|
if (store === "myket") {
|
||||||
const res = await window.MyketBilling.purchase(sku);
|
try {
|
||||||
|
const res = await MyketBilling.purchase({ sku, rsaPublicKey: MYKET_RSA_PUBLIC_KEY });
|
||||||
return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken };
|
return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken };
|
||||||
|
} catch {
|
||||||
|
return { kind: "unavailable" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
* On app load, capture a Bazaar redirect (`?purchaseToken=...`). Returns the
|
||||||
* pending purchase to verify, or null. Also clears the stashed SKU.
|
* pending purchase to verify, or null. Also clears the stashed SKU.
|
||||||
|
|||||||
Reference in New Issue
Block a user