Skip to content

Commit

Permalink
Updates Android purchase controller logic for alpha.35, bug fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
super-bryan committed Jan 13, 2024
1 parent 461cd3f commit 93ab9a3
Show file tree
Hide file tree
Showing 16 changed files with 251 additions and 78 deletions.
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,6 @@ dependencies {
testImplementation 'org.jetbrains.kotlin:kotlin-test'
testImplementation 'org.mockito:mockito-core:5.0.0'

implementation "com.superwall.sdk:superwall-android:1.0.0-alpha.26"
implementation "com.superwall.sdk:superwall-android:1.0.0-alpha.35"
implementation 'com.android.billingclient:billing:6.1.0'
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ class BridgingCreator(val flutterPluginBinding: FlutterPlugin.FlutterPluginBindi
when (call.method) {
"createBridgeInstance" -> {
val bridgeId = call.argument<String>("bridgeId")
val initializationArgs = call.argument<Map<String, Any>>("args")

if (bridgeId != null) {
createBridgeInstanceFromBridgeId(bridgeId)
createBridgeInstanceFromBridgeId(bridgeId, initializationArgs)
result.success(null)
} else {
println("WARNING: Unable to create bridge")
Expand All @@ -58,7 +59,7 @@ class BridgingCreator(val flutterPluginBinding: FlutterPlugin.FlutterPluginBindi
}

// Create the bridge instance as instructed from Dart
private fun createBridgeInstanceFromBridgeId(bridgeId: BridgeId, initializationArgs: Map<String, Any>? = null): BridgeInstance {
private fun createBridgeInstanceFromBridgeId(bridgeId: BridgeId, initializationArgs: Map<String, Any>?): BridgeInstance {
// An existing bridge instance might exist if it were created natively, instead of from Dart
val existingBridgeInstance = instances[bridgeId]
existingBridgeInstance?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ package com.superwall.superwallkit_flutter.bridges

import android.app.Activity
import android.content.Context
import com.android.billingclient.api.ProductDetails
import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import io.flutter.plugin.common.MethodChannel
import com.superwall.sdk.delegate.PurchaseResult
import com.superwall.sdk.delegate.RestorationResult
import com.android.billingclient.api.SkuDetails
import com.superwall.superwallkit_flutter.asyncInvokeMethodOnMain
import com.superwall.superwallkit_flutter.bridgeInstance
import io.flutter.embedding.engine.plugins.FlutterPlugin

class PurchaseControllerProxyBridge(
context: Context,
Expand All @@ -20,13 +18,20 @@ class PurchaseControllerProxyBridge(

// PurchaseController

override suspend fun purchase(activity: Activity, product: SkuDetails): PurchaseResult {
val purchaseResultBridgeId = communicator.asyncInvokeMethodOnMain("purchaseProduct", mapOf("productId" to product.sku)) as? BridgeId
override suspend fun purchase(activity: Activity, productDetails: ProductDetails, basePlanId: String?, offerId: String?): PurchaseResult {
val attributes = mapOf(
"productId" to productDetails.productId,
"basePlanId" to basePlanId,
"offerId" to offerId
)
val purchaseResultBridgeId = communicator.asyncInvokeMethodOnMain("purchaseFromGooglePlay", attributes) as? BridgeId
val purchaseResultBridge = purchaseResultBridgeId?.bridgeInstance() as? PurchaseResultBridge

if (purchaseResultBridge == null) {
println("WARNING: Unexpected result")
return PurchaseResult.Failed(PurchaseControllerProxyPluginError());
return PurchaseResult.Failed("ERROR: Could not find a purchase result " +
"bridge. Make sure you enforce that it was created from Dart before " +
"sending back a response.");
}

return purchaseResultBridge.purchaseResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ class PurchaseResultRestoredBridge(
initializationArgs: Map<String, Any>? = null
) : PurchaseResultBridge(context, bridgeId, initializationArgs) {
companion object { fun bridgeClass(): BridgeClass = "PurchaseResultRestoredBridge" }
// TODO: CHANGE ONCE AVAILABLE
override val purchaseResult: PurchaseResult = PurchaseResult.Purchased()
override val purchaseResult: PurchaseResult = PurchaseResult.Restored()
}

class PurchaseResultPendingBridge(
Expand All @@ -66,8 +65,6 @@ class PurchaseResultFailedBridge(
init {
val errorString = initializationArgs?.get("error") as? String
?: throw IllegalArgumentException("Attempting to create `PurchaseResultFailedBridge` without providing `error`.")
purchaseResult = PurchaseResult.Failed(PurchaseResultError(errorString))
purchaseResult = PurchaseResult.Failed(errorString)
}
}

data class PurchaseResultError(override val message: String) : Throwable()
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,8 @@ class SuperwallBridge(
result.notImplemented()
}
"getUserAttributes" -> {
// TODO: Replace with sync functionality once fixed in Android SDK
CoroutineScope(Dispatchers.IO).launch {
val attributes = Superwall.instance.getUserAttributes()
runOnUiThread {
result.success(attributes)
}
}
val attributes = Superwall.instance.userAttributes
result.success(attributes)
}
"setUserAttributes" -> {
val userAttributes = call.argument<Map<String, Any?>>("userAttributes")
Expand Down
21 changes: 13 additions & 8 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ PODS:
- Flutter (1.0.0)
- integration_test (0.0.1):
- Flutter
- purchases_flutter (4.13.0):
- purchases_flutter (6.15.0):
- Flutter
- PurchasesHybridCommon (= 4.18.0)
- PurchasesHybridCommon (4.18.0):
- RevenueCat (= 4.21.0)
- RevenueCat (4.21.0)
- PurchasesHybridCommon (= 8.10.1)
- PurchasesHybridCommon (8.10.1):
- RevenueCat (= 4.31.9)
- RevenueCatUI (= 4.31.9)
- RevenueCat (4.31.9)
- RevenueCatUI (4.31.9):
- RevenueCat (= 4.31.9)
- SuperwallKit (3.4.5)
- superwallkit_flutter (0.0.1):
- Flutter
Expand All @@ -23,6 +26,7 @@ SPEC REPOS:
trunk:
- PurchasesHybridCommon
- RevenueCat
- RevenueCatUI
- SuperwallKit

EXTERNAL SOURCES:
Expand All @@ -38,9 +42,10 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
integration_test: 13825b8a9334a850581300559b8839134b124670
purchases_flutter: ee7f7c20c021c4a1a482a70ab78d3d36594295bb
PurchasesHybridCommon: 6b510d4965166df2137347a414d6a8e2906d5a5e
RevenueCat: 465a9dc1fff60b09f29792082e5d5d5aeed01122
purchases_flutter: ce847b4848ccec17cadfb1a2e2a3ad1d3f681849
PurchasesHybridCommon: 170de77f83fd9cb570457157e92ea04c7aef8196
RevenueCat: aa3e439fb6282e7768609774a73c06ab87c3d9c3
RevenueCatUI: ef3f12dddf49d333a66a13f1d32c73ef1075ae95
SuperwallKit: dc2c06d2d496e16a223a8e169fb2b99d2d8b0258
superwallkit_flutter: c3af773fcf7399e2a82ddffb9c0e1c2e4c842731

Expand Down
178 changes: 170 additions & 8 deletions example/lib/RCPurchaseController.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ class RCPurchaseController extends PurchaseController {
// Listen for changes
Purchases.addCustomerInfoUpdateListener((customerInfo) {
// Gets called whenever new CustomerInfo is available
bool hasActiveSubscription = customerInfo.entitlements.active.isNotEmpty; // Why? -> https://www.revenuecat.com/docs/entitlements#entitlements
if (hasActiveSubscription) {
bool hasActiveEntitlementOrSubscription = customerInfo.hasActiveEntitlementOrSubscription(); // Why? -> https://www.revenuecat.com/docs/entitlements#entitlements
if (hasActiveEntitlementOrSubscription) {
Superwall.shared.setSubscriptionStatus(SubscriptionStatus.active);
} else {
Superwall.shared.setSubscriptionStatus(SubscriptionStatus.inactive);
Expand All @@ -27,14 +27,142 @@ class RCPurchaseController extends PurchaseController {
}

// MARK: Handle Purchases
/// Makes a purchase with RevenueCat and returns its result. This gets called when
/// someone tries to purchase a product on one of your paywalls.

/// Makes a purchase from App Store with RevenueCat and returns its
/// result. This gets called when someone tries to purchase a product on
/// one of your paywalls from iOS.
@override
Future<PurchaseResult> purchaseFromAppStore(String productId) async {
// Find products matching productId from RevenueCat
List<StoreProduct> products = await PurchasesAdditions.getAllProducts([productId]);

// Get first product for product ID (this will properly throw if empty)
StoreProduct? storeProduct = products.firstOrNull;

if (storeProduct == null) {
return PurchaseResult.failed("Failed to find store product for $productId");
}

PurchaseResult purchaseResult = await _purchaseStoreProduct(storeProduct);
return purchaseResult;
}

/// Makes a purchase from Google Play with RevenueCat and returns its
/// result. This gets called when someone tries to purchase a product on
/// one of your paywalls from Android.
@override
Future<PurchaseResult> purchase(String productId) async {
Future<PurchaseResult> purchaseFromGooglePlay(String productId, String? basePlanId, String? offerId) async {
// Find products matching productId from RevenueCat
List<StoreProduct> products = await PurchasesAdditions.getAllProducts([productId]);

// Choose the product which matches the given base plan.
// If no base plan set, select first product or fail.
String storeProductId = "$productId:$basePlanId";

// Try to find the first product where the googleProduct's basePlanId matches the given basePlanId.
// StoreProduct? matchingProduct = products.firstWhere(
StoreProduct? matchingProduct;

// Loop through each product in the products list.
for (final product in products) {
// Check if the current product's basePlanId matches the given basePlanId.
if (product.identifier == storeProductId) {
// If a match is found, assign this product to matchingProduct.
matchingProduct = product;
// Break the loop as we found our matching product.
break;
}
}

// If a matching product is not found, then try to get the first product from the list.
StoreProduct? storeProduct = matchingProduct ?? (products.isNotEmpty ? products.first : null);

// If no product is found (either matching or the first one), return a failed purchase result.
if (storeProduct == null) {
return PurchaseResult.failed("Product not found");
}

switch (storeProduct.productCategory) {
case ProductCategory.subscription:
SubscriptionOption? subscriptionOption = await _fetchGooglePlaySubscriptionOption(storeProduct, basePlanId, offerId);
if (subscriptionOption == null) {
return PurchaseResult.failed("Valid subscription option not found for product.");
}
return await _purchaseSubscriptionOption(subscriptionOption);
case ProductCategory.nonSubscription:
return await _purchaseStoreProduct(storeProduct);
case null:
return PurchaseResult.failed("Unable to determine product category");
}
}

Future<SubscriptionOption?> _fetchGooglePlaySubscriptionOption(
StoreProduct storeProduct,
String? basePlanId,
String? offerId,
) async {
final subscriptionOptions = storeProduct.subscriptionOptions;

if (subscriptionOptions != null && subscriptionOptions.isNotEmpty) {
// Concatenate base + offer ID
final subscriptionOptionId = _buildSubscriptionOptionId(basePlanId, offerId);

// Find first subscription option that matches the subscription option ID or use the default offer
SubscriptionOption? subscriptionOption;

// Search for the subscription option with the matching ID
for (final option in subscriptionOptions) {
if (option.id == subscriptionOptionId) {
subscriptionOption = option;
break;
}
}

// If no matching subscription option is found, use the default option
subscriptionOption ??= storeProduct.defaultOption;

// Return the subscription option
return subscriptionOption;
}

return null;
}

Future<PurchaseResult> _purchaseSubscriptionOption(SubscriptionOption subscriptionOption) async {
// Define the async perform purchase function
Future<CustomerInfo> performPurchase() async {
// Attempt to purchase product
CustomerInfo customerInfo = await Purchases.purchaseSubscriptionOption(subscriptionOption);
return customerInfo;
}

PurchaseResult purchaseResult = await _handleSharedPurchase(performPurchase);
return purchaseResult;
}

Future<PurchaseResult> _purchaseStoreProduct(StoreProduct storeProduct) async {
// Define the async perform purchase function
Future<CustomerInfo> performPurchase() async {
// Attempt to purchase product
CustomerInfo customerInfo = await Purchases.purchaseStoreProduct(storeProduct);
return customerInfo;
}

PurchaseResult purchaseResult = await _handleSharedPurchase(performPurchase);
return purchaseResult;
}

// MARK: Shared purchase
Future<PurchaseResult> _handleSharedPurchase(Future<CustomerInfo> Function() performPurchase) async {
try {
// Store the current purchase date to later determine if this is a new purchase or restore
DateTime purchaseDate = DateTime.now();
CustomerInfo customerInfo = await Purchases.purchaseProduct(productId);
if (customerInfo.entitlements.active.isNotEmpty) {

// Perform the purchase using the function provided
CustomerInfo customerInfo = await performPurchase();

// Handle the results
if (customerInfo.hasActiveEntitlementOrSubscription()) {
DateTime? latestTransactionPurchaseDate = customerInfo.getLatestTransactionPurchaseDate();

// If no latest transaction date is found, consider it as a new purchase.
Expand Down Expand Up @@ -63,7 +191,9 @@ class RCPurchaseController extends PurchaseController {
}
}


// MARK: Handle Restores

/// Makes a restore with RevenueCat and returns `.restored`, unless an error is thrown.
/// This gets called when someone tries to restore purchases on one of your paywalls.
@override
Expand All @@ -73,12 +203,35 @@ class RCPurchaseController extends PurchaseController {
return RestorationResult.restored;
} on PlatformException catch (e) {
// Error restoring purchases
return RestorationResult.failed(e.message ?? "Purchase failed in RCPurchaseController");
return RestorationResult.failed(e.message ?? "Restore failed in RCPurchaseController");
}
}
}

// MARK: Helpers

String _buildSubscriptionOptionId(String? basePlanId, String? offerId) {
String result = '';

if (basePlanId != null) {
result += basePlanId;
}

if (offerId != null) {
if (basePlanId != null) {
result += ':';
}
result += offerId;
}

return result;
}

extension CustomerInfoAdditions on CustomerInfo {
bool hasActiveEntitlementOrSubscription() {
return (activeSubscriptions.isNotEmpty || entitlements.active.isNotEmpty);
}

DateTime? getLatestTransactionPurchaseDate() {
Map<String, String> allPurchaseDates = this.allPurchaseDates;
if (allPurchaseDates.entries.isEmpty) {
Expand All @@ -95,4 +248,13 @@ extension CustomerInfoAdditions on CustomerInfo {

return latestDate;
}
}

extension PurchasesAdditions on Purchases {
static Future<List<StoreProduct>> getAllProducts(List<String> productIdentifiers) async {
final subscriptionProducts = await Purchases.getProducts(productIdentifiers, productCategory: ProductCategory.subscription);
final nonSubscriptionProducts = await Purchases.getProducts(productIdentifiers, productCategory: ProductCategory.nonSubscription);
final combinedProducts = [...subscriptionProducts, ...nonSubscriptionProducts];
return combinedProducts;
}
}
4 changes: 2 additions & 2 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ class _MyAppState extends State<MyApp> implements SuperwallDelegate {
String apiKey = Platform.isIOS ? "pk_5f6d9ae96b889bc2c36ca0f2368de2c4c3d5f6119aacd3d2" : "pk_d1f0959f70c761b1d55bb774a03e22b2b6ed290ce6561f85";

Logging logging = Logging();
logging.level = LogLevel.debug;
logging.scopes = { LogScope.paywallViewController };
logging.level = LogLevel.warn;
logging.scopes = { LogScope.all };

SuperwallOptions options = SuperwallOptions();
options.logging = logging;
Expand Down
Loading

0 comments on commit 93ab9a3

Please sign in to comment.