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.