///
///
namespace CdvPurchase {
export namespace GooglePlay {
export class Transaction extends CdvPurchase.Transaction {
public nativePurchase: Bridge.Purchase;
constructor(purchase: Bridge.Purchase, parentReceipt: Receipt, decorator: Internal.TransactionDecorator) {
super(Platform.GOOGLE_PLAY, parentReceipt, decorator);
this.nativePurchase = purchase;
this.refresh(purchase, true);
}
static toState(fromConstructor: boolean, state: Bridge.PurchaseState, isAcknowledged: boolean, isConsumed: boolean): TransactionState {
switch(state) {
case Bridge.PurchaseState.PENDING:
return TransactionState.INITIATED;
case Bridge.PurchaseState.PURCHASED:
// Note: we still want to validate acknowledged non-consumables and subscriptions,
// so we don't return APPROVED
if (isConsumed)
return TransactionState.FINISHED;
else if (isAcknowledged)
return TransactionState.APPROVED;
else if (fromConstructor)
return TransactionState.INITIATED;
else
return TransactionState.APPROVED;
case Bridge.PurchaseState.UNSPECIFIED_STATE:
return TransactionState.UNKNOWN_STATE;
}
}
/**
* Refresh the value in the transaction based on the native purchase update
*/
refresh(purchase: Bridge.Purchase, fromConstructor?: boolean) {
this.nativePurchase = purchase;
this.transactionId = `${purchase.orderId || purchase.purchaseToken}`;
this.purchaseId = `${purchase.purchaseToken}`;
this.products = purchase.productIds.map(productId => ({ id: productId }));
if (purchase.purchaseTime) this.purchaseDate = new Date(purchase.purchaseTime);
this.isPending = (purchase.getPurchaseState === Bridge.PurchaseState.PENDING)
if (typeof purchase.acknowledged !== 'undefined') this.isAcknowledged = purchase.acknowledged;
if (typeof purchase.consumed !== 'undefined') this.isConsumed = purchase.consumed;
if (typeof purchase.autoRenewing !== 'undefined') this.renewalIntent = purchase.autoRenewing ? RenewalIntent.RENEW : RenewalIntent.LAPSE;
if (typeof purchase.quantity !== 'undefined') this.quantity = purchase.quantity;
// Handle expiryTimeMillis for subscriptions
if (purchase.expiryTimeMillis) {
const expiryTime = parseInt(purchase.expiryTimeMillis, 10);
if (!isNaN(expiryTime)) {
this.expirationDate = new Date(expiryTime);
}
}
this.state = Transaction.toState(fromConstructor ?? false, purchase.getPurchaseState, this.isAcknowledged ?? false, this.isConsumed ?? false);
}
removed() {
if (this.renewalIntent) {
this.expirationDate = new Date(Date.now() - Internal.ExpiryMonitor.GRACE_PERIOD_MS[Platform.GOOGLE_PLAY]);
}
else {
this.isConsumed = true;
}
this.state = TransactionState.CANCELLED;
}
}
export class Receipt extends CdvPurchase.Receipt {
/** Token that uniquely identifies a purchase for a given item and user pair. */
public purchaseToken: string;
/** Unique order identifier for the transaction. (like GPA.XXXX-XXXX-XXXX-XXXXX) */
public orderId?: string;
/** @internal */
constructor(purchase: Bridge.Purchase, decorator: Internal.TransactionDecorator & Internal.ReceiptDecorator) {
super(Platform.GOOGLE_PLAY, decorator);
this.transactions = [new Transaction(purchase, this, decorator)];
this.purchaseToken = purchase.purchaseToken;
this.orderId = purchase.orderId;
}
/** Refresh the content of the purchase based on the native BridgePurchase */
refreshPurchase(purchase: Bridge.Purchase) {
(this.transactions[0] as Transaction)?.refresh(purchase);
this.orderId = purchase.orderId;
}
removed() {
this.transactions.forEach(t => (t as Transaction)?.removed());
}
}
export class Adapter implements CdvPurchase.Adapter {
/** Adapter identifier */
id = Platform.GOOGLE_PLAY;
/** Adapter name */
name = 'GooglePlay';
/** Has the adapter been successfully initialized */
ready = false;
supportsParallelLoading = false;
canSkipFinish = true;
/** List of products managed by the GooglePlay adapter */
get products(): GProduct[] { return this._products.products; }
private _products: Products;
get receipts(): Receipt[] { return this._receipts; }
private _receipts: Receipt[] = [];
/** The GooglePlay bridge */
bridge: Bridge.BridgeInterface = Bridge.CapacitorBridge.isAvailable()
? new Bridge.CapacitorBridge()
: new Bridge.Bridge();
/** Prevent double initialization */
initialized = false;
/** Used to retry failed commands */
retry = new Internal.Retry();
private context: Internal.AdapterContext;
private log: Logger;
public autoRefreshIntervalMillis: number = 0;
static trimProductTitles: boolean = true;
static _instance: Adapter;
constructor(context: Internal.AdapterContext, autoRefreshIntervalMillis: number = 1000 * 3600 * 24) {
if (Adapter._instance) throw new Error('GooglePlay adapter already initialized');
this._products = new Products(context.apiDecorators);
this.autoRefreshIntervalMillis = autoRefreshIntervalMillis;
this.context = context;
this.log = context.log.child('GooglePlay');
Adapter._instance = this;
}
private initializationPromise?: Promise;
/** Returns true on Android, the only platform supported by this adapter */
get isSupported(): boolean {
return Utils.platformId() === 'android';
}
async initialize(): Promise {
this.log.info("Initialize");
if (this.initializationPromise) return this.initializationPromise;
return this.initializationPromise = new Promise((resolve) => {
const bridgeLogger = this.log.child('Bridge');
const iabOptions = {
onSetPurchases: this.onSetPurchases.bind(this),
onPurchasesUpdated: this.onPurchasesUpdated.bind(this),
onPurchaseConsumed: this.onPurchaseConsumed.bind(this),
showLog: this.context.verbosity >= LogLevel.DEBUG ? true : false,
log: (msg: string) => bridgeLogger.info(msg),
}
const iabReady = () => {
this.log.debug("Ready");
// Auto-refresh every 24 hours (or autoRefreshIntervalMillis)
if (this.autoRefreshIntervalMillis > 0) {
window.setInterval(() => this.getPurchases(), this.autoRefreshIntervalMillis);
}
resolve(undefined);
}
const iabError = (err: string) => {
this.initialized = false;
this.context.error(playStoreError(ErrorCode.SETUP, "Init failed - " + err, null));
this.retry.retry(() => this.initialize());
}
this.bridge.init(iabReady, iabError, iabOptions);
});
}
/** Prepare the list of SKUs sorted by type */
getSkusOf(products: IRegisterProduct[]): {inAppSkus: string[], subsSkus: string[]} {
const inAppSkus: string[] = [];
const subsSkus: string[] = [];
for (const product of products) {
if (product.type === ProductType.PAID_SUBSCRIPTION)
subsSkus.push(product.id);
else
inAppSkus.push(product.id);
}
return {inAppSkus, subsSkus};
}
/** @inheritdoc */
loadReceipts(): Promise {
return new Promise((resolve) => {
// let's also refresh purchases
this.getPurchases()
.then(err => {
resolve(this._receipts);
});
});
}
/** @inheritDoc */
loadProducts(products: IRegisterProduct[]): Promise<(GProduct | IError)[]> {
return new Promise((resolve) => {
this.log.debug("Load: " + JSON.stringify(products));
/** Called when a list of product definitions have been loaded */
const iabLoaded = (validProducts: (Bridge.InAppProduct | Bridge.Subscription)[]) => {
this.log.debug("Loaded: " + JSON.stringify(validProducts));
// Add type check to handle invalid responses
if (!Array.isArray(validProducts)) {
const message = `Invalid product list received: ${JSON.stringify(validProducts)}, retrying later...`;
this.log.warn(message);
this.retry.retry(go);
this.context.error(playStoreError(ErrorCode.LOAD, message, null));
return;
}
const ret = products.map(registeredProduct => {
const validProduct = validProducts.find(vp => vp.productId === registeredProduct.id);
if (validProduct && validProduct.productId) {
return this._products.addProduct(registeredProduct, validProduct);
}
else {
return playStoreError(ErrorCode.INVALID_PRODUCT_ID, `Product with id ${registeredProduct.id} not found.`, registeredProduct.id);
}
});
resolve(ret);
}
/** Start loading products */
const go = () => {
const { inAppSkus, subsSkus } = this.getSkusOf(products);
this.log.debug("getAvailableProducts: " + JSON.stringify(inAppSkus) + " | " + JSON.stringify(subsSkus));
this.bridge.getAvailableProducts(inAppSkus, subsSkus, iabLoaded, (err: string) => {
// failed to load products, retry later.
this.retry.retry(go);
this.context.error(playStoreError(ErrorCode.LOAD, 'Loading product info failed - ' + err + ' - retrying later...', null))
});
}
go();
});
}
/** @inheritDoc */
finish(transaction: CdvPurchase.Transaction): Promise {
return new Promise(resolve => {
const onSuccess = () => {
if (transaction.state !== TransactionState.FINISHED) {
transaction.state = TransactionState.FINISHED;
this.context.listener.receiptsUpdated(Platform.GOOGLE_PLAY, [transaction.parentReceipt]);
}
resolve(undefined);
};
const firstProduct = transaction.products[0];
if (!firstProduct)
return resolve(playStoreError(ErrorCode.FINISH, 'Cannot finish a transaction with no product', null));
const product = this._products.getProduct(firstProduct.id);
if (!product)
return resolve(playStoreError(ErrorCode.FINISH, 'Cannot finish transaction, unknown product ' + firstProduct.id, firstProduct.id));
const receipt = this._receipts.find(r => r.hasTransaction(transaction));
if (!receipt)
return resolve(playStoreError(ErrorCode.FINISH, 'Cannot finish transaction, linked receipt not found.', product.id));
if (!receipt.purchaseToken)
return resolve(playStoreError(ErrorCode.FINISH, 'Cannot finish transaction, linked receipt contains no purchaseToken.', product.id));
const onFailure = (message: string, code?: ErrorCode) => resolve(playStoreError(code || ErrorCode.UNKNOWN, message, product.id));
if (product.type === ProductType.NON_RENEWING_SUBSCRIPTION || product.type === ProductType.CONSUMABLE) {
if (!transaction.isConsumed)
return this.bridge.consumePurchase(onSuccess, onFailure, receipt.purchaseToken);
}
else { // subscription and non-consumable
if (!transaction.isAcknowledged)
return this.bridge.acknowledgePurchase(onSuccess, onFailure, receipt.purchaseToken);
}
// nothing to do
resolve(undefined);
});
}
/** Called by the bridge when a purchase has been consumed */
onPurchaseConsumed(purchase: Bridge.Purchase): void {
this.log.debug("onPurchaseConsumed: " + purchase.orderId);
purchase.acknowledged = true; // consumed is the equivalent of acknowledged for consumables
purchase.consumed = true;
this.onPurchasesUpdated([purchase]);
}
/** Schedule to refresh purchases for subscriptions that don't have expiration dates */
private refreshSchedule: {
[purchaseToken: string]: {
timeoutId: number;
refreshTime: number;
}[];
} = {};
/** Refresh intervals (in milliseconds) */
private static REFRESH_INTERVALS = {
SANDBOX: 6 * 60 * 1000, // 6 minutes for sandbox
PRODUCTION: 7 * 24 * 60 * 60 * 1000 + 10 * 60 * 1000, // 7 days + 10 minutes for production
};
/**
* Schedule a purchase refresh for a subscription without expiration date
*/
private scheduleRefreshForSubscription(purchase: Bridge.Purchase): void {
if (!purchase.purchaseToken) return;
const schedule = this.refreshSchedule[purchase.purchaseToken] || [];
if (schedule.length === 0) {
this.refreshSchedule[purchase.purchaseToken] = schedule;
}
// Determine refresh interval based on sandbox status and auto-renewing flag
let refreshIntervals = [Adapter.REFRESH_INTERVALS.SANDBOX, Adapter.REFRESH_INTERVALS.PRODUCTION];
refreshIntervals.forEach(refreshInterval => {
const refreshTime = purchase.purchaseTime + refreshInterval;
if (schedule.find(s => s.refreshTime === refreshTime) || refreshTime < Date.now()) {
return;
}
this.log.debug(`Scheduling refresh for purchase token ${purchase.purchaseToken} at ${new Date(refreshTime).toISOString()}`);
// Schedule the refresh
const timeoutId = window.setTimeout(() => {
this.log.debug(`Executing scheduled refresh for purchase token ${purchase.purchaseToken}`);
delete this.refreshSchedule[purchase.purchaseToken];
this.getPurchases().catch(err => {
this.log.warn(`Failed scheduled refresh: ${err}`);
});
}, refreshTime - Date.now());
// Store the scheduled refresh
schedule.push({
timeoutId: timeoutId as unknown as number,
refreshTime
});
});
}
/**
* Detect subscriptions that need scheduled refreshes
*/
private scheduleRefreshesForSubscriptions(purchases: Bridge.Purchase[]): void {
for (const purchase of purchases) {
// Skip if not auto-renewing
if (purchase.autoRenewing !== false) continue;
const productId = purchase.productIds[0];
const product = productId ? this._products.getProduct(productId) : undefined;
if (!product || product.type !== ProductType.PAID_SUBSCRIPTION) continue;
if (!purchase.expiryTimeMillis) {
this.scheduleRefreshForSubscription(purchase);
}
}
}
/**
* Called when the platform reports some purchases
*/
onSetPurchases(purchases: Bridge.Purchase[]): void {
this.log.debug("onSetPurchases: " + JSON.stringify(purchases));
this.onPurchasesUpdated(purchases);
this.context.listener.receiptsReady(Platform.GOOGLE_PLAY);
// Schedule refreshes for subscriptions without expiration dates
this.scheduleRefreshesForSubscriptions(purchases);
}
/**
* Called when the platform reports updates for some purchases
*
* Notice that purchases can be removed from the array, we should handle that so they stop
* being "owned" by the user.
*/
onPurchasesUpdated(purchases: Bridge.Purchase[]): void {
this.log.debug("onPurchaseUpdated: " + purchases.map(p => p.orderId).join(', '));
// GooglePlay generates one receipt for each purchase
const removedReceipts = this.receipts.filter(r => !purchases.find(p => p.purchaseToken === r.purchaseToken));
if (removedReceipts.length > 0) {
this.log.debug("Removed purchases: " + removedReceipts.map(r => r.purchaseToken).join(', '));
removedReceipts.forEach(receipt => receipt.removed());
}
purchases.forEach(purchase => {
const existingReceipt = this.receipts.find(r => r.purchaseToken === purchase.purchaseToken);
if (existingReceipt) {
// Before refreshing, check if this is a subscription and update expirationDate
// based on autoRenewing status - this ensures proper "owned" flag status
const firstTransaction = existingReceipt.transactions[0] as Transaction;
if (firstTransaction) {
const firstProductId = firstTransaction.products[0]?.id;
if (firstProductId) {
const product = this._products.getProduct(firstProductId);
if (product && product.type === ProductType.PAID_SUBSCRIPTION) {
// Always update the expirationDate if expiryTimeMillis is available
// regardless of autoRenewing status
if (purchase.getPurchaseState === Bridge.PurchaseState.PURCHASED &&
purchase.expiryTimeMillis) {
const expiryTime = parseInt(purchase.expiryTimeMillis, 10);
if (!isNaN(expiryTime)) {
// Set the transaction's expirationDate using the expiryTimeMillis from Google Play
firstTransaction.expirationDate = new Date(expiryTime);
// Log the expiration update for debugging
this.log.debug(`Updated expirationDate for ${firstProductId} to ${firstTransaction.expirationDate} (autoRenewing: ${purchase.autoRenewing})`);
}
}
}
}
}
existingReceipt.refreshPurchase(purchase);
this.context.listener.receiptsUpdated(Platform.GOOGLE_PLAY, [existingReceipt]);
}
else {
const newReceipt = new Receipt(purchase, this.context.apiDecorators);
this.receipts.push(newReceipt);
this.context.listener.receiptsUpdated(Platform.GOOGLE_PLAY, [newReceipt]);
if (newReceipt.transactions[0].state === TransactionState.INITIATED && !newReceipt.transactions[0].isPending) {
// For compatibility, we set the state of "new" purchases to initiated from the constructor,
// they'll got to "approved" when refreshed.
// this way, users receive the "initiated" event, then "approved"
newReceipt.refreshPurchase(purchase);
this.context.listener.receiptsUpdated(Platform.GOOGLE_PLAY, [newReceipt]);
}
}
});
}
onPriceChangeConfirmationResult(result: "OK" | "UserCanceled" | "UnknownProduct"): void {
}
/** Refresh purchases from GooglePlay */
getPurchases(): Promise {
return new Promise(resolve => {
this.log.debug('getPurchases');
const success = () => {
this.log.debug('getPurchases success');
setTimeout(() => resolve(undefined), 0);
}
const failure = (message: string, code?: number) => {
this.log.warn('getPurchases failed: ' + message + ' (' + code + ')');
setTimeout(() => resolve(playStoreError(code || ErrorCode.UNKNOWN, message, null)), 0);
}
this.bridge.getPurchases(success, failure);
});
}
/** @inheritDoc */
async order(offer: GOffer, additionalData: CdvPurchase.AdditionalData): Promise {
return new Promise(resolve => {
this.log.info("Order - " + JSON.stringify(offer));
const buySuccess = () => resolve(undefined);
const buyFailed = (message: string, code?: ErrorCode): void => {
this.log.warn('Order failed: ' + JSON.stringify({message, code}));
resolve(playStoreError(code ?? ErrorCode.UNKNOWN, message, offer.productId));
};
if (offer.productType === ProductType.PAID_SUBSCRIPTION) {
const idAndToken = 'token' in offer ? offer.productId + '@' + offer.token : offer.productId;
// find if the user already owns a product in the same group
const oldPurchaseToken = this.findOldPurchaseToken(offer.productId, offer.productGroup);
if (oldPurchaseToken) {
if (!additionalData.googlePlay)
additionalData.googlePlay = { oldPurchaseToken };
else if (!additionalData.googlePlay.oldPurchaseToken) {
additionalData.googlePlay.oldPurchaseToken = oldPurchaseToken;
}
}
this.bridge.subscribe(buySuccess, buyFailed, idAndToken, additionalData);
}
else {
const idAndToken = 'token' in offer && offer.token ? offer.productId + '@' + offer.token : offer.productId;
this.bridge.buy(buySuccess, buyFailed, idAndToken, additionalData);
}
});
}
/**
* Find a purchaseToken for an owned product in the same group as the requested one.
*
* @param productId - The product identifier to request matching purchaseToken for.
* @param productGroup - The group of the product to request matching purchaseToken for.
*
* @return A purchaseToken, undefined if none have been found.
*/
findOldPurchaseToken(productId: string, productGroup?: string): string | undefined {
if (!productGroup) return undefined;
const oldReceipt = this._receipts.find(r => {
return !!r.transactions.find(t => {
return !!t.products.find(p => {
const product = this._products.getProduct(p.id);
if (!product) return false;
if (!Internal.LocalReceipts.isOwned([r], product)) return false;
return (p.id === productId) || (productGroup && product.group === productGroup);
});
});
});
return oldReceipt?.purchaseToken;
}
/**
* Prepare for receipt validation
*/
async receiptValidationBody(receipt: Receipt): Promise {
const transaction = receipt.transactions[0] as GooglePlay.Transaction;
if (!transaction) return;
const productId = transaction.products[0]?.id;
if (!productId) return;
const product = this._products.getProduct(productId);
if (!product) return;
const purchase = transaction.nativePurchase;
return {
id: productId,
type: product.type,
offers: product.offers,
products: this._products.products,
transaction: {
type: Platform.GOOGLE_PLAY,
id: receipt.transactions[0].transactionId,
purchaseToken: purchase.purchaseToken,
signature: purchase.signature,
receipt: purchase.receipt,
}
}
}
async handleReceiptValidationResponse(receipt: CdvPurchase.Receipt, response: Validator.Response.Payload): Promise {
if (response?.ok) {
const transaction = response?.data?.transaction;
if (transaction?.type !== Platform.GOOGLE_PLAY) return;
switch (transaction.kind) {
case 'androidpublisher#productPurchase':
break;
case 'androidpublisher#subscriptionPurchase':
break;
case 'androidpublisher#subscriptionPurchaseV2':
transaction;
break;
case 'fovea#subscriptionGone':
// the transaction doesn't exist anymore
break;
}
}
return; // Nothing specific to do on GooglePlay
}
async requestPayment(payment: PaymentRequest, additionalData?: CdvPurchase.AdditionalData): Promise {
return playStoreError(ErrorCode.UNKNOWN, 'requestPayment not supported', null);
}
async manageSubscriptions(): Promise {
this.bridge.manageSubscriptions();
return;
}
async manageBilling(): Promise {
this.bridge.manageBilling();
return;
}
async getStorefront(): Promise {
return new Promise((resolve) => {
this.bridge.getStorefront((countryCode: string) => {
resolve(countryCode || undefined);
}, (message: string) => {
this.log.warn('getStorefront failed: ' + message);
resolve(undefined);
});
});
}
checkSupport(functionality: PlatformFunctionality): boolean {
const supported: PlatformFunctionality[] = [
'order', 'manageBilling', 'manageSubscriptions', 'getStorefront'
];
return supported.indexOf(functionality) >= 0;
}
restorePurchases(): Promise {
return new Promise(resolve => {
this.bridge.getPurchases(() => resolve(undefined), (message, code) => {
this.log.warn('getPurchases() failed: ' + (code ?? 'ERROR') + ': ' + message);
resolve(playStoreError(code ?? ErrorCode.UNKNOWN, message, null));
});
});
}
}
function playStoreError(code: ErrorCode, message: string, productId: string | null) {
return storeError(code, message, Platform.GOOGLE_PLAY, productId);
}
}
}