## Summary - The Capacitor shell buys through StoreKit (iOS 15) and Play Billing instead of RevenueCat. - After the store sheet succeeds, the app POSTs the signed transaction or Play purchase token to `/monetization/store/confirm/`. - Restore reads current store entitlements and confirms each one. Web checkout stays on Stripe. - The store account token from `GET /monetization/subscription/` is sent as `appAccountToken` / `obfuscatedAccountId`. Depends on the `chat_backend` PR for issue #78 (`feat/78-native-store-purchases`). ## Test plan - [x] `storeBilling`, `revenueCat`, `finance`, and `BillingSection` unit tests - [ ] iOS sandbox purchase and restore against a backend with `APPLE_IAP_BUNDLE_ID` set - [ ] Play license-test purchase and restore against a backend with `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` set - [ ] Web billing still opens Stripe Checkout Reviewed-on: #122
This commit was merged in pull request #122.
This commit is contained in:
1 parent
4a5b05f421
commit
8b58192026
25 files changed
+702
-226
No files matched your search
@@ -0,0 +1,30 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
namespace "ai.hesychia.chat.billing"
|
||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
|
||||
defaultConfig {
|
||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':capacitor-android')
|
||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||
implementation "com.android.billingclient:billing:7.1.1"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
package ai.hesychia.chat.billing;
|
||||
|
||||
import android.app.Activity;
|
||||
import com.android.billingclient.api.AcknowledgePurchaseParams;
|
||||
import com.android.billingclient.api.BillingClient;
|
||||
import com.android.billingclient.api.BillingClientStateListener;
|
||||
import com.android.billingclient.api.BillingFlowParams;
|
||||
import com.android.billingclient.api.BillingResult;
|
||||
import com.android.billingclient.api.PendingPurchasesParams;
|
||||
import com.android.billingclient.api.ProductDetails;
|
||||
import com.android.billingclient.api.Purchase;
|
||||
import com.android.billingclient.api.PurchasesUpdatedListener;
|
||||
import com.android.billingclient.api.QueryProductDetailsParams;
|
||||
import com.android.billingclient.api.QueryPurchasesParams;
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@CapacitorPlugin(name = "NativeStoreBilling")
|
||||
public class NativeStoreBillingPlugin extends Plugin implements PurchasesUpdatedListener {
|
||||
|
||||
private BillingClient billingClient;
|
||||
private PluginCall pendingPurchase;
|
||||
private String pendingProductId = "";
|
||||
|
||||
@PluginMethod
|
||||
public void purchase(PluginCall call) {
|
||||
String productId = call.getString("productId", "");
|
||||
String accountToken = call.getString("accountToken", "");
|
||||
if (productId == null || productId.isEmpty()) {
|
||||
call.reject("productId is required");
|
||||
return;
|
||||
}
|
||||
Activity activity = getActivity();
|
||||
if (activity == null) {
|
||||
call.reject("Play Billing needs a foreground activity");
|
||||
return;
|
||||
}
|
||||
activity.runOnUiThread(() ->
|
||||
connect(call, () -> queryAndLaunch(call, productId, accountToken == null ? "" : accountToken))
|
||||
);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void restore(PluginCall call) {
|
||||
Activity activity = getActivity();
|
||||
if (activity == null) {
|
||||
call.reject("Play Billing needs a foreground activity");
|
||||
return;
|
||||
}
|
||||
activity.runOnUiThread(() -> connect(call, () -> queryOwned(call, "")));
|
||||
}
|
||||
|
||||
private void connect(PluginCall call, Runnable whenReady) {
|
||||
if (billingClient != null && billingClient.isReady()) {
|
||||
whenReady.run();
|
||||
return;
|
||||
}
|
||||
billingClient = BillingClient.newBuilder(getContext())
|
||||
.setListener(this)
|
||||
.enablePendingPurchases(
|
||||
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
|
||||
)
|
||||
.build();
|
||||
billingClient.startConnection(
|
||||
new BillingClientStateListener() {
|
||||
@Override
|
||||
public void onBillingSetupFinished(BillingResult billingResult) {
|
||||
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
||||
whenReady.run();
|
||||
return;
|
||||
}
|
||||
call.reject("Play Billing setup failed: " + billingResult.getDebugMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBillingServiceDisconnected() {
|
||||
// The next purchase reconnects.
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private void queryAndLaunch(PluginCall call, String productId, String accountToken) {
|
||||
QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder()
|
||||
.setProductList(
|
||||
Collections.singletonList(
|
||||
QueryProductDetailsParams.Product.newBuilder()
|
||||
.setProductId(productId)
|
||||
.setProductType(BillingClient.ProductType.SUBS)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build();
|
||||
billingClient.queryProductDetailsAsync(params, (billingResult, productDetailsList) -> {
|
||||
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
|
||||
call.reject("Could not load Play product: " + billingResult.getDebugMessage());
|
||||
return;
|
||||
}
|
||||
if (productDetailsList == null || productDetailsList.isEmpty()) {
|
||||
call.reject("Play product was not found: " + productId);
|
||||
return;
|
||||
}
|
||||
ProductDetails details = productDetailsList.get(0);
|
||||
List<ProductDetails.SubscriptionOfferDetails> offers = details.getSubscriptionOfferDetails();
|
||||
if (offers == null || offers.isEmpty()) {
|
||||
call.reject("Play product has no subscription offer");
|
||||
return;
|
||||
}
|
||||
String offerToken = offers.get(0).getOfferToken();
|
||||
BillingFlowParams.ProductDetailsParams productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||
.setProductDetails(details)
|
||||
.setOfferToken(offerToken)
|
||||
.build();
|
||||
BillingFlowParams.Builder flow = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(Collections.singletonList(productParams));
|
||||
if (!accountToken.isEmpty()) {
|
||||
flow.setObfuscatedAccountId(accountToken);
|
||||
}
|
||||
Activity activity = getActivity();
|
||||
if (activity == null) {
|
||||
call.reject("Play Billing needs a foreground activity");
|
||||
return;
|
||||
}
|
||||
activity.runOnUiThread(() -> {
|
||||
pendingPurchase = call;
|
||||
pendingProductId = productId;
|
||||
BillingResult launched = billingClient.launchBillingFlow(activity, flow.build());
|
||||
if (launched.getResponseCode() != BillingClient.BillingResponseCode.OK) {
|
||||
pendingPurchase = null;
|
||||
call.reject("Could not open Play purchase sheet: " + launched.getDebugMessage());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
|
||||
PluginCall call = pendingPurchase;
|
||||
pendingPurchase = null;
|
||||
if (call == null) {
|
||||
return;
|
||||
}
|
||||
int code = billingResult.getResponseCode();
|
||||
if (code == BillingClient.BillingResponseCode.USER_CANCELED) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("userCancelled", true);
|
||||
call.reject("Purchase cancelled", "PURCHASE_CANCELLED", null, data);
|
||||
return;
|
||||
}
|
||||
if (code == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {
|
||||
queryOwned(call, pendingProductId);
|
||||
return;
|
||||
}
|
||||
if (code != BillingClient.BillingResponseCode.OK || purchases == null || purchases.isEmpty()) {
|
||||
call.reject("Play purchase failed: " + billingResult.getDebugMessage());
|
||||
return;
|
||||
}
|
||||
Purchase match = null;
|
||||
for (Purchase purchase : purchases) {
|
||||
if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
|
||||
continue;
|
||||
}
|
||||
if (pendingProductId.isEmpty() || purchase.getProducts().contains(pendingProductId)) {
|
||||
match = purchase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match == null) {
|
||||
call.reject("Play purchase did not complete");
|
||||
return;
|
||||
}
|
||||
acknowledge(match);
|
||||
call.resolve(purchaseToJson(match));
|
||||
}
|
||||
|
||||
private void queryOwned(PluginCall call, String productId) {
|
||||
QueryPurchasesParams params = QueryPurchasesParams.newBuilder()
|
||||
.setProductType(BillingClient.ProductType.SUBS)
|
||||
.build();
|
||||
billingClient.queryPurchasesAsync(params, (billingResult, purchases) -> {
|
||||
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
|
||||
call.reject("Could not restore Play purchases: " + billingResult.getDebugMessage());
|
||||
return;
|
||||
}
|
||||
JSArray rows = new JSArray();
|
||||
Purchase single = null;
|
||||
List<Purchase> owned = purchases == null ? new ArrayList<>() : purchases;
|
||||
for (Purchase purchase : owned) {
|
||||
if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
|
||||
continue;
|
||||
}
|
||||
if (!productId.isEmpty() && !purchase.getProducts().contains(productId)) {
|
||||
continue;
|
||||
}
|
||||
acknowledge(purchase);
|
||||
JSONObject row = purchaseToJson(purchase);
|
||||
rows.put(row);
|
||||
single = purchase;
|
||||
}
|
||||
if (!productId.isEmpty()) {
|
||||
if (single == null) {
|
||||
call.reject("No active Play purchase for " + productId);
|
||||
return;
|
||||
}
|
||||
call.resolve(purchaseToJson(single));
|
||||
return;
|
||||
}
|
||||
JSObject result = new JSObject();
|
||||
result.put("transactions", rows);
|
||||
call.resolve(result);
|
||||
});
|
||||
}
|
||||
|
||||
private void acknowledge(Purchase purchase) {
|
||||
if (purchase.isAcknowledged() || billingClient == null) {
|
||||
return;
|
||||
}
|
||||
AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder()
|
||||
.setPurchaseToken(purchase.getPurchaseToken())
|
||||
.build();
|
||||
billingClient.acknowledgePurchase(params, billingResult -> { });
|
||||
}
|
||||
|
||||
private JSObject purchaseToJson(Purchase purchase) {
|
||||
String productId = purchase.getProducts().isEmpty() ? "" : purchase.getProducts().get(0);
|
||||
String orderId = purchase.getOrderId() == null ? purchase.getPurchaseToken() : purchase.getOrderId();
|
||||
JSObject row = new JSObject();
|
||||
row.put("store", "play_store");
|
||||
row.put("productId", productId);
|
||||
row.put("transactionId", orderId);
|
||||
row.put("purchaseToken", purchase.getPurchaseToken());
|
||||
row.put("signedTransaction", "");
|
||||
return row;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user