import 'dart:async'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; /// Wraps the app's one purchasable product: a one-year, non-auto-renewing /// *prepaid subscription* that grants a year of no ads (see /// [AppState.adsCurrentlyDisabled]). /// /// Prepaid subscription, specifically — not a consumable, a plain /// non-consumable, or an auto-renewing subscription: /// - A *consumable* has to be acknowledged (= consumed, for this product /// type) within 3 days of purchase or Play Billing auto-refunds it — /// there's no way to leave it unconsumed so it stays restorable for the /// whole year. And once consumed, Play Billing forgets it existed, so a /// user who loses local data (no cloud backup connected) has nothing /// left to restore from. This is what this product used to be, before /// that gap was found. /// - A plain *non-consumable* would only ever let a Google account buy it /// once, permanently — doesn't fit "buy another year once this one /// lapses". /// - An *auto-renewing subscription* would charge the user again every /// year without them actively choosing to. /// - A *prepaid* subscription base plan fits all three constraints: it's /// acknowledged immediately (same 3-day rule, satisfied same as any /// purchase), stays active — and restorable via [restorePurchase] — for /// its whole prepaid year without needing to be consumed, then simply /// expires with no charge and can be bought again. /// /// [adFreeYearProductId] is a placeholder — nothing will actually load or /// be purchasable until a real subscription with this same ID exists in /// your Google Play Console (Monetize > Products > Subscriptions), with a /// *prepaid* base plan named [_basePlanId] and whatever price/duration you /// choose there (Play Billing doesn't take a price from the app itself). /// Rename either constant to match whatever you actually create, if you'd /// rather not use these. class PurchaseService { static const adFreeYearProductId = 'ad_free_year'; static const _basePlanId = 'ad-free-year-prepaid'; /// Called once for every successful (or restored) purchase of /// [adFreeYearProductId], with the time Play Billing recorded the /// purchase actually happening — [AppState] anchors the year of ad-free /// time to that, not to "now", so restoring an existing purchase doesn't /// hand out a free extra year on top of time already elapsed. final void Function(DateTime purchaseTime) onPurchaseGranted; /// Called with a user-facing message when a purchase or restore attempt /// fails — [AppState] surfaces this via [AppState.purchaseError] for the /// Settings screen to display. final void Function(String message)? onPurchaseError; PurchaseService({required this.onPurchaseGranted, this.onPurchaseError}); StreamSubscription>? _subscription; ProductDetails? _product; /// The store's own formatted, localized price string (e.g. `"$4.99"`) /// once [initialize] has loaded the product — null before that, or if /// neither the product ID nor base plan ID above matches anything /// configured in the store yet. String? get priceLabel => _product?.price; Future initialize() async { final available = await InAppPurchase.instance.isAvailable(); if (!available) return; _subscription = InAppPurchase.instance.purchaseStream.listen( _handlePurchaseUpdates, onDone: () => _subscription?.cancel(), onError: (_) {}, ); final response = await InAppPurchase.instance.queryProductDetails({adFreeYearProductId}); _product = _selectOffer(response.productDetails); } /// For a subscription product, [InAppPurchase.queryProductDetails] /// returns one [ProductDetails] per offer/base-plan combination it has, /// not one per product ID — picks the prepaid base plan set up for this /// product specifically, falling back to whichever offer loaded first so /// a Play Console setup with only the one base plan (the expected, /// common case here) still works without its name needing to match /// exactly. ProductDetails? _selectOffer(List offers) { for (final offer in offers) { if (offer is! GooglePlayProductDetails) continue; final index = offer.subscriptionIndex; final basePlanId = index == null ? null : offer.productDetails.subscriptionOfferDetails?[index].basePlanId; if (basePlanId == _basePlanId) return offer; } return offers.isEmpty ? null : offers.first; } /// Kicks off the platform purchase UI. Does nothing (and reports an /// error) if the product hasn't loaded — either the store isn't /// available, or neither [adFreeYearProductId] nor [_basePlanId] matches /// a real product yet. Future buyAdFreeYear() async { final product = _product; if (product == null) { onPurchaseError?.call("This purchase isn't available right now."); return; } final purchaseParam = product is GooglePlayProductDetails ? GooglePlayPurchaseParam(productDetails: product, offerToken: product.offerToken) : PurchaseParam(productDetails: product); await InAppPurchase.instance.buyNonConsumable(purchaseParam: purchaseParam); } /// Re-derives the local entitlement from Play Billing's own record of an /// active (not yet expired) prepaid purchase — for a user who reinstalled /// or switched devices without cloud backup connected, where nothing /// local survived to sync the entitlement back down. A silent no-op if /// there's nothing currently active to find, which is the normal outcome /// for anyone who's never purchased, so that's not treated as an error. Future restorePurchase() async { try { await InAppPurchase.instance.restorePurchases(); } on InAppPurchaseException catch (e) { onPurchaseError?.call(e.message ?? "Couldn't restore your purchase. Try again later."); } } Future _handlePurchaseUpdates(List purchases) async { for (final purchase in purchases) { switch (purchase.status) { case PurchaseStatus.purchased: case PurchaseStatus.restored: if (purchase.productID == adFreeYearProductId) { onPurchaseGranted(_purchaseTime(purchase)); } if (purchase.pendingCompletePurchase) { await InAppPurchase.instance.completePurchase(purchase); } case PurchaseStatus.error: onPurchaseError?.call(purchase.error?.message ?? 'The purchase failed.'); if (purchase.pendingCompletePurchase) { await InAppPurchase.instance.completePurchase(purchase); } case PurchaseStatus.pending: case PurchaseStatus.canceled: break; } } } /// Play reports this as epoch milliseconds in a string (see /// `GooglePlayPurchaseDetails.transactionDate`) — falls back to the /// current time in the shouldn't-happen case that it's missing or /// malformed, rather than leaving the entitlement ungranted over a /// parsing hiccup. DateTime _purchaseTime(PurchaseDetails purchase) { final millis = int.tryParse(purchase.transactionDate ?? ''); return millis == null ? DateTime.now().toUtc() : DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true); } void dispose() { _subscription?.cancel(); } }