diff --git a/.common_version b/.common_version index af0b7ddbf..59e9e6049 100644 --- a/.common_version +++ b/.common_version @@ -1 +1 @@ -1.0.6 +1.0.11 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea28acdd..f06613d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.1.0 + +- Adds Subscriber Attributes, which allow developers to store additional, structured information +for a user in RevenueCat. More info: https://docs.revenuecat.com/docs/user-attributes. + ## 1.0.5 - Updates README.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..9b66d5229 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,3 @@ +- Open xcode project inside example/ios and changes made to the plugin should be automatically reflected. +- When updating iOS dependency, make sure to run `pod install` inside `example/ios/`. + diff --git a/RELEASING.md b/RELEASING.md index 53ea5c798..0e65724ed 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,5 +1,5 @@ 1. Update to the latest SDK versions in purchases_flutter.podspec and build.gradle. -1. Run `./scripts/download-purchases-common.sh 1.0.6` (change version to latest) +1. Run `./scripts/download-purchases-common.sh 1.0.11` (change version to latest) 1. Run `flutter format` 1. Update versions in VERSIONS.md. 1. Update version in pubspec.yaml, purchases_flutter.podspec and android/build.gradle. diff --git a/VERSIONS.md b/VERSIONS.md index bbc764c94..14ccebfef 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -1,5 +1,6 @@ | Version | iOS version | Android version | Common files version | |---------|-------------|-----------------|----------------------| +| 1.1.0 | 3.2.2 | 3.1.0 | 1.0.11 | | 1.0.5 | 3.0.1 | 3.0.4 | 1.0.6 | | 1.0.4 | 3.0.1 | 3.0.4 | 1.0.6 | | 1.0.3 | 3.0.1 | 3.0.4 | 1.0.6 | diff --git a/android/build.gradle b/android/build.gradle index 3b33e5080..827ba52eb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,8 +1,8 @@ group 'com.revenuecat.purchases_flutter' -version '1.0.5' +version '1.1.0' buildscript { - ext.kotlin_version = '1.3.61' + ext.kotlin_version = '1.3.71' repositories { google() jcenter() @@ -38,5 +38,5 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'com.revenuecat.purchases:purchases:3.0.4' + implementation 'com.revenuecat.purchases:purchases:3.1.0' } \ No newline at end of file diff --git a/android/src/main/java/com/revenuecat/purchases/common/common.kt b/android/src/main/java/com/revenuecat/purchases/common/common.kt index c38e10586..f46b1383f 100644 --- a/android/src/main/java/com/revenuecat/purchases/common/common.kt +++ b/android/src/main/java/com/revenuecat/purchases/common/common.kt @@ -266,6 +266,34 @@ fun checkTrialOrIntroductoryPriceEligibility( }.toMap() } +fun invalidatePurchaserInfoCache() { + Purchases.sharedInstance.invalidatePurchaserInfoCache() +} + +// region Subscriber Attributes + +fun setAttributes(attributes: Map) { + Purchases.sharedInstance.setAttributes(attributes) +} + +fun setEmail(email: String?) { + Purchases.sharedInstance.setEmail(email) +} + +fun setPhoneNumber(phoneNumber: String?) { + Purchases.sharedInstance.setPhoneNumber(phoneNumber) +} + +fun setDisplayName(displayName: String?) { + Purchases.sharedInstance.setDisplayName(displayName) +} + +fun setPushToken(fcmToken: String?) { + Purchases.sharedInstance.setPushToken(fcmToken) +} + +// region private functions + private fun getMakePurchaseErrorFunction(onResult: OnResult): (PurchasesError, Boolean) -> Unit { return { error, userCancelled -> onResult.onError(error.map(mapOf("userCancelled" to userCancelled))) } } diff --git a/android/src/main/java/com/revenuecat/purchases/common/mappers.kt b/android/src/main/java/com/revenuecat/purchases/common/mappers.kt index 004ee1c85..01e2489e2 100644 --- a/android/src/main/java/com/revenuecat/purchases/common/mappers.kt +++ b/android/src/main/java/com/revenuecat/purchases/common/mappers.kt @@ -8,6 +8,8 @@ import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.Package import com.revenuecat.purchases.PurchaserInfo import com.revenuecat.purchases.util.Iso8601Utils +import java.text.NumberFormat +import java.util.Currency fun EntitlementInfo.map(): Map = mapOf( @@ -31,7 +33,6 @@ fun EntitlementInfos.map(): Map = "active" to this.active.asIterable().associate { it.key to it.value.map() } ) - fun SkuDetails.map(): Map = mapOf( "identifier" to sku, @@ -39,8 +40,10 @@ fun SkuDetails.map(): Map = "title" to title, "price" to priceAmountMicros / 1000000.0, "price_string" to price, - "currency_code" to priceCurrencyCode - ) + mapIntroPrice() + "currency_code" to priceCurrencyCode, + "introPrice" to mapIntroPrice(), + "discounts" to null + ) + mapIntroPriceDeprecated() fun PurchaserInfo.map(): Map = mapOf( @@ -90,28 +93,24 @@ private fun Package.map(offeringIdentifier: String): Map = "offeringIdentifier" to offeringIdentifier ) -private fun SkuDetails.mapIntroPrice(): Map { - return if (!freeTrialPeriod.isNullOrBlank()) { - // Check freeTrialPeriod first to give priority to trials - // Format using device locale. iOS will format using App Store locale, but there's no way - // to figure out how the price in the SKUDetails is being formatted. - val format = java.text.NumberFormat.getCurrencyInstance().apply { - currency = java.util.Currency.getInstance(priceCurrencyCode) - } +private fun SkuDetails.mapIntroPriceDeprecated(): Map { + val isFreeTrialAvailable = freeTrialPeriod != null && freeTrialPeriod.isNotBlank() + return if (isFreeTrialAvailable) { + val format = formatUsingDeviceLocale() mapOf( "intro_price" to 0, "intro_price_string" to format.format(0), "intro_price_period" to freeTrialPeriod, "intro_price_cycles" to 1 - ) + freeTrialPeriod.mapPeriod() - } else if (!introductoryPrice.isNullOrBlank()) { + ) + freeTrialPeriod.mapPeriodDeprecated() + } else if (introductoryPrice != null || introductoryPrice.isNotBlank()) { mapOf( "intro_price" to introductoryPriceAmountMicros / 1000000.0, "intro_price_string" to introductoryPrice, "intro_price_period" to introductoryPricePeriod, "intro_price_cycles" to (introductoryPriceCycles?.takeUnless { it.isBlank() }?.toInt() ?: 0) - ) + introductoryPricePeriod.mapPeriod() + ) + introductoryPricePeriod.mapPeriodDeprecated() } else { mapOf( "intro_price" to null, @@ -124,7 +123,47 @@ private fun SkuDetails.mapIntroPrice(): Map { } } -private fun String?.mapPeriod(): Map { +private fun SkuDetails.formatUsingDeviceLocale(): NumberFormat { + return NumberFormat.getCurrencyInstance().apply { + currency = Currency.getInstance(priceCurrencyCode) + } +} + +private fun SkuDetails.mapIntroPrice(): Map { + return if (freeTrialPeriod != null && freeTrialPeriod.isNotBlank()) { + // Check freeTrialPeriod first to give priority to trials + // Format using device locale. iOS will format using App Store locale, but there's no way + // to figure out how the price in the SKUDetails is being formatted. + val format = java.text.NumberFormat.getCurrencyInstance().apply { + currency = java.util.Currency.getInstance(priceCurrencyCode) + } + mapOf( + "price" to 0, + "priceString" to format.format(0), + "period" to freeTrialPeriod, + "cycles" to 1 + ) + freeTrialPeriod.mapPeriod() + } else if (introductoryPrice != null && introductoryPrice.isNotBlank()) { + mapOf( + "price" to introductoryPriceAmountMicros / 1000000.0, + "priceString" to introductoryPrice, + "period" to introductoryPricePeriod, + "cycles" to (introductoryPriceCycles?.takeUnless { it.isBlank() }?.toInt() + ?: 0) + ) + introductoryPricePeriod.mapPeriod() + } else { + mapOf( + "price" to null, + "priceString" to null, + "period" to null, + "cycles" to null, + "periodUnit" to null, + "periodNumberOfUnits" to null + ) + } +} + +private fun String?.mapPeriodDeprecated(): Map { return if (this == null || this.isBlank()) { mapOf( "intro_price_period_unit" to null, @@ -153,3 +192,33 @@ private fun String?.mapPeriod(): Map { } } } + +private fun String?.mapPeriod(): Map { + return if (this == null || this.isBlank()) { + mapOf( + "periodUnit" to null, + "periodNumberOfUnits" to null + ) + } else { + PurchasesPeriod.parse(this).let { period -> + when { + period.years > 0 -> mapOf( + "periodUnit" to "YEAR", + "periodNumberOfUnits" to period.years + ) + period.months > 0 -> mapOf( + "periodUnit" to "MONTH", + "periodNumberOfUnits" to period.months + ) + period.days > 0 -> mapOf( + "periodUnit" to "DAY", + "periodNumberOfUnits" to period.days + ) + else -> mapOf( + "periodUnit" to "DAY", + "periodNumberOfUnits" to 0 + ) + } + } + } +} diff --git a/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java b/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java index 182097af1..e6fd0a4d1 100644 --- a/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java +++ b/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java @@ -78,8 +78,7 @@ public void onMethodCall(MethodCall call, Result result) { Map data = call.argument("data"); int network = call.argument("network") != null ? (int) call.argument("network") : -1; String networkUserId = call.argument("networkUserId"); - addAttributionData(data, network, networkUserId); - result.success(null); + addAttributionData(data, network, networkUserId, result); break; case "getOfferings": getOfferings(result); @@ -140,6 +139,29 @@ public void onMethodCall(MethodCall call, Result result) { productIdentifiers = call.argument("productIdentifiers"); checkTrialOrIntroductoryPriceEligibility(productIdentifiers, result); break; + case "invalidatePurchaserInfoCache": + invalidatePurchaserInfoCache(result); + break; + case "setAttributes": + Map attributes = call.argument("attributes"); + setAttributes(attributes, result); + break; + case "setEmail": + String email = call.argument("email"); + setEmail(email, result); + break; + case "setPhoneNumber": + String phoneNumber = call.argument("phoneNumber"); + setPhoneNumber(phoneNumber, result); + break; + case "setDisplayName": + String displayName = call.argument("displayName"); + setDisplayName(displayName, result); + break; + case "setPushToken": + String pushToken = call.argument("pushToken"); + setPushToken(pushToken, result); + break; default: result.notImplemented(); break; @@ -170,8 +192,10 @@ private void setAllowSharingAppStoreAccount(boolean allowSharingAppStoreAccount, result.success(null); } - private void addAttributionData(Map data, int network, @Nullable String networkUserId) { + private void addAttributionData(Map data, int network, + @Nullable String networkUserId, Result result) { CommonKt.addAttributionData(data, network, networkUserId); + result.success(null); } private void getOfferings(final Result result) { @@ -263,6 +287,40 @@ private void checkTrialOrIntroductoryPriceEligibility(ArrayList productI result.success(CommonKt.checkTrialOrIntroductoryPriceEligibility(productIDs)); } + private void invalidatePurchaserInfoCache(Result result) { + CommonKt.invalidatePurchaserInfoCache(); + result.success(null); + } + + //================================================================================ + // Subscriber Attributes + //================================================================================ + + private void setAttributes(Map map, final Result result) { + CommonKt.setAttributes(map); + result.success(null); + } + + private void setEmail(String email, final Result result) { + CommonKt.setEmail(email); + result.success(null); + } + + private void setPhoneNumber(String phoneNumber, final Result result) { + CommonKt.setPhoneNumber(phoneNumber); + result.success(null); + } + + private void setDisplayName(String displayName, final Result result) { + CommonKt.setDisplayName(displayName); + result.success(null); + } + + private void setPushToken(String pushToken, final Result result) { + CommonKt.setPushToken(pushToken); + result.success(null); + } + @NotNull private OnResult getOnResult(final Result result) { return new OnResult() { diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index e670477ef..88673f1f9 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,9 +1,9 @@ PODS: - Flutter (1.0.0) - - Purchases (3.0.1) - - purchases_flutter (1.0.3): + - Purchases (3.2.2) + - purchases_flutter (1.1.0): - Flutter - - Purchases (~> 3.0.1) + - Purchases (~> 3.2.2) DEPENDENCIES: - Flutter (from `.symlinks/flutter/ios`) @@ -21,8 +21,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: 0e3d915762c693b495b44d77113d4970485de6ec - Purchases: f090e75880b4ee1a1aa436d469d91fd5224a70b1 - purchases_flutter: 34a610c5c72ead858c93af15f9e189b1b516b498 + Purchases: 17c95d62be02c876c56f6fe6b34fda2016a09063 + purchases_flutter: ba6f93f4e355fe67f4074fa2bf668cc4ab056dfd PODFILE CHECKSUM: 3389836f37640698630b8f0670315d626d5f1469 diff --git a/ios/Classes/Common/RCCommonFunctionality.h b/ios/Classes/Common/RCCommonFunctionality.h index c51299db5..59c1adcb8 100644 --- a/ios/Classes/Common/RCCommonFunctionality.h +++ b/ios/Classes/Common/RCCommonFunctionality.h @@ -13,6 +13,8 @@ typedef void (^RCHybridResponseBlock)(NSDictionary * _Nullable, RCErrorContainer @interface RCCommonFunctionality : NSObject ++ (void)configure; + + (void)setAllowSharingStoreAccount:(BOOL)allowSharingStoreAccount; + (void)addAttributionData:(NSDictionary *)data network:(NSInteger)network networkUserId:(NSString *)networkUserId; @@ -39,15 +41,29 @@ typedef void (^RCHybridResponseBlock)(NSDictionary * _Nullable, RCErrorContainer + (BOOL)isAnonymous; -+ (void)purchaseProduct:(NSString *)productIdentifier completionBlock:(RCHybridResponseBlock)completion; ++ (void)purchaseProduct:(NSString *)productIdentifier signedDiscountTimestamp:(nullable NSString *)discountTimestamp completionBlock:(RCHybridResponseBlock)completion; -+ (void)purchasePackage:(NSString *)packageIdentifier offering:(NSString *)offeringIdentifier completionBlock:(RCHybridResponseBlock)completion; ++ (void)purchasePackage:(NSString *)packageIdentifier offering:(NSString *)offeringIdentifier signedDiscountTimestamp:(nullable NSString *)discountTimestamp completionBlock:(RCHybridResponseBlock)completion; + (void)makeDeferredPurchase:(RCDeferredPromotionalPurchaseBlock)deferredPurchase completionBlock:(RCHybridResponseBlock)completion; + (void)setFinishTransactions:(BOOL)finishTransactions; -+ (void)checkTrialOrIntroductoryPriceEligibility:(nonnull NSArray *)productIdentifiers completionBlock:(void (^)(NSDictionary *))completion; ++ (void)checkTrialOrIntroductoryPriceEligibility:(nonnull NSArray *)productIdentifiers completionBlock:(RCReceiveIntroEligibilityBlock)completion; + ++ (void)paymentDiscountForProductIdentifier:(NSString *)productIdentifier discount:(nullable NSString *)discountIdentifier completionBlock:(RCHybridResponseBlock)completion; + ++ (void)invalidatePurchaserInfoCache; + ++ (void)setAttributes:(NSDictionary *)attributes; + ++ (void)setEmail:(nullable NSString *)email; + ++ (void)setPhoneNumber:(nullable NSString *)phoneNumber; + ++ (void)setDisplayName:(nullable NSString *)displayName; + ++ (void)setPushToken:(nullable NSString *)pushToken; @end diff --git a/ios/Classes/Common/RCCommonFunctionality.m b/ios/Classes/Common/RCCommonFunctionality.m index eb8ffabee..6e35a6f4a 100644 --- a/ios/Classes/Common/RCCommonFunctionality.m +++ b/ios/Classes/Common/RCCommonFunctionality.m @@ -7,9 +7,35 @@ #import "RCErrorContainer.h" #import "RCOfferings+HybridAdditions.h" #import "RCPurchaserInfo+HybridAdditions.h" +#import "SKPaymentDiscount+HybridAdditions.h" +#import "RCPurchases+HybridAdditions.h" + + +API_AVAILABLE(ios(12.2)) +@interface RCCommonFunctionality () + +@property(class, readonly, nonatomic, retain) NSMutableDictionary *discounts; + +@end + @implementation RCCommonFunctionality +API_AVAILABLE(ios(12.2)) +static NSMutableDictionary *_discounts = nil; + + ++ (NSMutableDictionary *)discounts API_AVAILABLE(ios(12.2)) { + return _discounts; +} + ++ (void)configure +{ + if (@available(iOS 12.2, *)) { + _discounts = [NSMutableDictionary new]; + } +} + + (void)setAllowSharingStoreAccount:(BOOL)allowSharingStoreAccount { NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); @@ -101,10 +127,12 @@ + (BOOL)isAnonymous return [RCPurchases.sharedPurchases isAnonymous]; } -+ (void)purchaseProduct:(NSString *)productIdentifier completionBlock:(RCHybridResponseBlock)completion ++ (void)purchaseProduct:(NSString *)productIdentifier +signedDiscountTimestamp:(nullable NSString *)discountTimestamp + completionBlock:(RCHybridResponseBlock)completion { NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); - + void (^completionBlock)(SKPaymentTransaction *_Nullable, RCPurchaserInfo *_Nullable, NSError *_Nullable, BOOL) = ^(SKPaymentTransaction *_Nullable transaction, RCPurchaserInfo *_Nullable purchaserInfo, NSError *_Nullable error, BOOL userCancelled) { if (error) { completion(nil, [self payloadForError:error withExtraPayload:@{@"userCancelled": @(userCancelled)}]); @@ -115,28 +143,35 @@ + (void)purchaseProduct:(NSString *)productIdentifier completionBlock:(RCHybridR }, nil); } }; - - [RCPurchases.sharedPurchases productsWithIdentifiers:@[productIdentifier] - completionBlock:^(NSArray *_Nonnull products) { - NSMutableDictionary *productsByIdentifiers = [NSMutableDictionary new]; - for (SKProduct *p in products) { - productsByIdentifiers[p.productIdentifier] = p; - } - if (productsByIdentifiers[productIdentifier]) { - [RCPurchases.sharedPurchases purchaseProduct:productsByIdentifiers[productIdentifier] - withCompletionBlock:completionBlock]; - } else { - NSError *error = [NSError errorWithDomain:RCPurchasesErrorDomain - code:RCProductNotAvailableForPurchaseError - userInfo:@{ - NSLocalizedDescriptionKey: @"Couldn't find product." - }]; - completion(nil, [self payloadForError:error withExtraPayload:@{@"userCancelled": @(NO)}]); - } - }]; + + + [self productWithIdentifier:productIdentifier completionBlock:^(SKProduct *_Nullable aProduct) { + if (aProduct == nil) { + [self productNotFoundErrorWithDescription:@"Couldn't find product." userCancelled:[NSNumber numberWithBool:NO] completion:completion]; + return; + } + + if (@available(iOS 12.2, *)) { + if (discountTimestamp) { + SKPaymentDiscount *discount = self.discounts[discountTimestamp]; + if (discount == nil) { + [self productNotFoundErrorWithDescription:@"Couldn't find discount." userCancelled:[NSNumber numberWithBool:NO] completion:completion]; + return; + } + + [RCPurchases.sharedPurchases purchaseProduct:aProduct withDiscount:discount completionBlock:completionBlock]; + return; + } + } + + [RCPurchases.sharedPurchases purchaseProduct:aProduct withCompletionBlock:completionBlock]; + }]; } -+ (void)purchasePackage:(NSString *)packageIdentifier offering:(NSString *)offeringIdentifier completionBlock:(RCHybridResponseBlock)completion ++ (void)purchasePackage:(NSString *)packageIdentifier + offering:(NSString *)offeringIdentifier +signedDiscountTimestamp:(nullable NSString *)discountTimestamp + completionBlock:(RCHybridResponseBlock)completion { NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); @@ -145,24 +180,32 @@ + (void)purchasePackage:(NSString *)packageIdentifier offering:(NSString *)offer completion(nil, [self payloadForError:error withExtraPayload:@{@"userCancelled": @(userCancelled)}]); } else { completion(@{ - @"purchaserInfo": purchaserInfo.dictionary, - @"productIdentifier": transaction.payment.productIdentifier + @"purchaserInfo": purchaserInfo.dictionary, + @"productIdentifier": transaction.payment.productIdentifier }, nil); } }; - - [RCPurchases.sharedPurchases offeringsWithCompletionBlock:^(RCOfferings *offerings, NSError *error) { - RCPackage *aPackage = [[offerings offeringWithIdentifier:offeringIdentifier] packageWithIdentifier:packageIdentifier]; - if (aPackage) { - [RCPurchases.sharedPurchases purchasePackage:aPackage withCompletionBlock:completionBlock]; - } else { - NSError *error = [NSError errorWithDomain:RCPurchasesErrorDomain - code:RCProductNotAvailableForPurchaseError - userInfo:@{ - NSLocalizedDescriptionKey: @"Couldn't find package." - }]; - completion(nil, [self payloadForError:error withExtraPayload:@{@"userCancelled": @(NO)}]); + + [self packageWithIdentifier:packageIdentifier offeringIdentifier:offeringIdentifier completionBlock:^(RCPackage *_Nullable aPackage) { + if (aPackage == nil) { + [self productNotFoundErrorWithDescription:@"Couldn't find package." userCancelled:[NSNumber numberWithBool:NO] completion:completion]; + return; + } + + if (@available(iOS 12.2, *)) { + if (discountTimestamp) { + SKPaymentDiscount *discount = self.discounts[discountTimestamp]; + if (discount == nil) { + [self productNotFoundErrorWithDescription:@"Couldn't find discount." userCancelled:[NSNumber numberWithBool:NO] completion:completion]; + return; + } + + [RCPurchases.sharedPurchases purchasePackage:aPackage withDiscount:discount completionBlock:completionBlock]; + return; + } } + + [RCPurchases.sharedPurchases purchasePackage:aPackage withCompletionBlock:completionBlock]; }]; } @@ -181,31 +224,63 @@ + (void)makeDeferredPurchase:(RCDeferredPromotionalPurchaseBlock)deferredPurchas completion(nil, [self payloadForError:error withExtraPayload:@{@"userCancelled": @(userCancelled)}]); } else { completion(@{ - @"purchaserInfo": purchaserInfo.dictionary, - @"productIdentifier": transaction.payment.productIdentifier + @"purchaserInfo": purchaserInfo.dictionary, + @"productIdentifier": transaction.payment.productIdentifier }, nil); } }); } + (void)checkTrialOrIntroductoryPriceEligibility:(nonnull NSArray *)productIdentifiers - completionBlock:(void (^)(NSDictionary *))completion + completionBlock:(RCReceiveIntroEligibilityBlock)completion { NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); - + [RCPurchases.sharedPurchases checkTrialOrIntroductoryPriceEligibility:productIdentifiers completionBlock:^(NSDictionary * _Nonnull dictionary) { NSMutableDictionary *response = [NSMutableDictionary new]; for (NSString *productID in dictionary) { RCIntroEligibility *eligibility = dictionary[productID]; response[productID] = @{ - @"status": @(eligibility.status), - @"description": eligibility.description + @"status": @(eligibility.status), + @"description": eligibility.description }; } completion([NSDictionary dictionaryWithDictionary:response]); }]; } ++ (void)paymentDiscountForProductIdentifier:(NSString *)productIdentifier + discount:(nullable NSString *)discountIdentifier + completionBlock:(RCHybridResponseBlock)completion +{ + if (@available(iOS 12.2, *)) { + NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); + [self productWithIdentifier:productIdentifier completionBlock:^(SKProduct *_Nullable aProduct) { + if (aProduct) { + SKProductDiscount *discountToUse = [self discountWithIdentifier:discountIdentifier forProduct:aProduct]; + + if (discountToUse) { + [RCPurchases.sharedPurchases paymentDiscountForProductDiscount:discountToUse product:aProduct completion:^(SKPaymentDiscount *paymentDiscount, NSError *error) { + if (paymentDiscount) { + self.discounts[[paymentDiscount.timestamp stringValue]] = paymentDiscount; + completion(paymentDiscount.dictionary, nil); + } else { + completion(nil, [self payloadForError:error withExtraPayload:@{}]); + } + }]; + } else { + [self productNotFoundErrorWithDescription:@"Couldn't find discount." userCancelled:nil completion:completion]; + } + } else { + [self productNotFoundErrorWithDescription:@"Couldn't find product." userCancelled:nil completion:completion]; + } + + }]; + } else { + completion(nil, nil); + } +} + + (void (^)(RCPurchaserInfo *, NSError *))getPurchaserInfoCompletionBlock:(RCHybridResponseBlock)completion { return ^(RCPurchaserInfo *_Nullable purchaserInfo, NSError *_Nullable error) { @@ -217,6 +292,56 @@ + (void)checkTrialOrIntroductoryPriceEligibility:(nonnull NSArray *) }; } ++ (void)invalidatePurchaserInfoCache { + NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); + [RCPurchases.sharedPurchases invalidatePurchaserInfoCache]; +} + +#pragma mark Subcriber Attributes + ++ (void)setAttributes:(NSDictionary *)attributes { + NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); + NSMutableDictionary *nonNilAttributes = [[NSMutableDictionary alloc] init]; + for (NSString * key in attributes.allKeys) { + id object = attributes[key]; + NSString *nonNilAttribute = ([object isEqual:NSNull.null]) + ? @"" + : object; + nonNilAttributes[key] = nonNilAttribute; + } + [RCPurchases.sharedPurchases setAttributes:nonNilAttributes]; +} + ++ (void)setEmail:(nullable NSString *)email { + NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); + NSString *nonNSNullAttribute = [self nonNSNullAttribute:email]; + [RCPurchases.sharedPurchases setEmail:nonNSNullAttribute]; +} + ++ (void)setPhoneNumber:(nullable NSString *)phoneNumber { + NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); + NSString *nonNSNullAttribute = [self nonNSNullAttribute:phoneNumber]; + [RCPurchases.sharedPurchases setPhoneNumber:nonNSNullAttribute]; +} + ++ (void)setDisplayName:(nullable NSString *)displayName { + NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); + NSString *nonNSNullAttribute = [self nonNSNullAttribute:displayName]; + [RCPurchases.sharedPurchases setDisplayName:nonNSNullAttribute]; +} + ++ (void)setPushToken:(nullable NSString *)pushToken { + NSAssert(RCPurchases.sharedPurchases, @"You must call setup first."); + NSString *nonNSNullAttribute = [self nonNSNullAttribute:pushToken]; + [RCPurchases.sharedPurchases _setPushTokenString:nonNSNullAttribute]; +} + ++ (NSString * _Nullable)nonNSNullAttribute:(NSString * _Nullable)attribute { + return ([attribute isEqual:NSNull.null]) ? @"" : attribute; +} + +#pragma errors + + (RCErrorContainer *)payloadForError:(NSError *)error withExtraPayload:(NSDictionary *)extraPayload { NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:extraPayload]; @@ -234,4 +359,65 @@ + (RCErrorContainer *)payloadForError:(NSError *)error withExtraPayload:(NSDicti return [[RCErrorContainer alloc] initWithError:error info:dict]; } ++ (void)productNotFoundErrorWithDescription:(NSString *)errorDescription + userCancelled:(nullable NSNumber *)userCancelled + completion:(RCHybridResponseBlock)completion +{ + NSDictionary *extraPayload; + if (userCancelled == nil) { + extraPayload = @{}; + } else { + extraPayload = @{@"userCancelled": @([userCancelled boolValue])}; + } + + NSError *error = [NSError errorWithDomain:RCPurchasesErrorDomain + code:RCProductNotAvailableForPurchaseError + userInfo:@{ + NSLocalizedDescriptionKey: errorDescription + }]; + completion(nil, [self payloadForError:error withExtraPayload:extraPayload]); +} + +#pragma helpers + ++ (void)productWithIdentifier:(NSString *)productIdentifier + completionBlock:(void (^)(SKProduct *_Nullable))completion +{ + [RCPurchases.sharedPurchases productsWithIdentifiers:@[productIdentifier] + completionBlock:^(NSArray *_Nonnull products) { + SKProduct *aProduct = nil; + for (SKProduct *p in products) { + if ([productIdentifier isEqualToString:p.productIdentifier]) { + aProduct = p; + } + } + completion(aProduct); + }]; +} + ++ (void)packageWithIdentifier:(NSString *)packageIdentifier + offeringIdentifier:(NSString *)offeringIdentifier + completionBlock:(void (^)(RCPackage *_Nullable))completion +{ + [RCPurchases.sharedPurchases offeringsWithCompletionBlock:^(RCOfferings *offerings, NSError *error) { + completion([[offerings offeringWithIdentifier:offeringIdentifier] packageWithIdentifier:packageIdentifier]); + }]; +} + ++ (nullable SKProductDiscount *)discountWithIdentifier:(NSString *)identifier + forProduct:(SKProduct *)aProduct API_AVAILABLE(ios(12.2)) { + SKProductDiscount *discountToUse = nil; + NSArray *productDiscounts = aProduct.discounts; + if (identifier == nil && productDiscounts != nil && productDiscounts.count > 0) { + discountToUse = productDiscounts.firstObject; + } else { + for (SKProductDiscount *discount in productDiscounts) { + if (identifier == discount.identifier) { + discountToUse = discount; + } + } + } + return discountToUse; +} + @end diff --git a/ios/Classes/Common/RCPurchases+HybridAdditions.h b/ios/Classes/Common/RCPurchases+HybridAdditions.h new file mode 100644 index 000000000..23ce45dda --- /dev/null +++ b/ios/Classes/Common/RCPurchases+HybridAdditions.h @@ -0,0 +1,19 @@ +// +// Created by RevenueCat on 3/19/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + + +@interface RCPurchases (HybridAdditions) + +- (void)_setPushTokenString:(nullable NSString *)pushToken; + +@end + + +NS_ASSUME_NONNULL_END diff --git a/ios/Classes/Common/SKPaymentDiscount+HybridAdditions.h b/ios/Classes/Common/SKPaymentDiscount+HybridAdditions.h new file mode 100644 index 000000000..0682febbe --- /dev/null +++ b/ios/Classes/Common/SKPaymentDiscount+HybridAdditions.h @@ -0,0 +1,12 @@ +// +// Created by RevenueCat. +// Copyright © 2019 RevenueCat. All rights reserved. +// + +#import + +@interface SKPaymentDiscount (HybridAdditions) + +- (NSDictionary *)dictionary; + +@end diff --git a/ios/Classes/Common/SKPaymentDiscount+HybridAdditions.m b/ios/Classes/Common/SKPaymentDiscount+HybridAdditions.m new file mode 100644 index 000000000..2ceeb2f81 --- /dev/null +++ b/ios/Classes/Common/SKPaymentDiscount+HybridAdditions.m @@ -0,0 +1,21 @@ +// +// Created by RevenueCat. +// Copyright © 2019 RevenueCat. All rights reserved. +// + +#import "SKPaymentDiscount+HybridAdditions.h" + +@implementation SKPaymentDiscount (RCPurchases) + +- (NSDictionary *)dictionary +{ + return @{ + @"identifier": self.identifier, + @"keyIdentifier": self.keyIdentifier, + @"nonce": self.nonce.UUIDString, + @"signature": self.signature, + @"timestamp": self.timestamp, + }; +} + +@end diff --git a/ios/Classes/Common/SKProduct+HybridAdditions.h b/ios/Classes/Common/SKProduct+HybridAdditions.h index c25217b51..911bc4535 100644 --- a/ios/Classes/Common/SKProduct+HybridAdditions.h +++ b/ios/Classes/Common/SKProduct+HybridAdditions.h @@ -8,5 +8,7 @@ @interface SKProduct (HybridAdditions) - (NSDictionary *)dictionary; ++ (NSString *)normalizedSubscriptionPeriod:(SKProductSubscriptionPeriod *)subscriptionPeriod API_AVAILABLE(ios(11.2)); ++ (NSString *)normalizedSubscriptionPeriodUnit:(SKProductPeriodUnit)subscriptionPeriodUnit API_AVAILABLE(ios(11.2)); @end diff --git a/ios/Classes/Common/SKProduct+HybridAdditions.m b/ios/Classes/Common/SKProduct+HybridAdditions.m index 297a9e226..ebeb54892 100644 --- a/ios/Classes/Common/SKProduct+HybridAdditions.m +++ b/ios/Classes/Common/SKProduct+HybridAdditions.m @@ -4,6 +4,7 @@ // #import "SKProduct+HybridAdditions.h" +#import "SKProductDiscount+HybridAdditions.h" @implementation SKProduct (RCPurchases) @@ -29,29 +30,39 @@ - (NSDictionary *)dictionary @"currency_code": (self.rc_currencyCode) ? self.rc_currencyCode : [NSNull null] }]; + d[@"intro_price"] = [NSNull null]; + d[@"intro_price_string"] = [NSNull null]; + d[@"intro_price_period"] = [NSNull null]; + d[@"intro_price_period_unit"] = [NSNull null]; + d[@"intro_price_period_number_of_units"] = [NSNull null]; + d[@"intro_price_cycles"] = [NSNull null]; + d[@"introPrice"] = [NSNull null]; + if (@available(iOS 11.2, *)) { if (self.introductoryPrice) { d[@"intro_price"] = @(self.introductoryPrice.price.floatValue); d[@"intro_price_string"] = [formatter stringFromNumber:self.introductoryPrice.price]; - d[@"intro_price_period"] = [self normalizeSubscriptionPeriod:self.introductoryPrice.subscriptionPeriod]; - d[@"intro_price_period_unit"] = [self normalizeSubscriptionPeriodUnit:self.introductoryPrice.subscriptionPeriod.unit]; + d[@"intro_price_period"] = [SKProduct normalizedSubscriptionPeriod:self.introductoryPrice.subscriptionPeriod]; + d[@"intro_price_period_unit"] = [SKProduct normalizedSubscriptionPeriodUnit:self.introductoryPrice.subscriptionPeriod.unit]; d[@"intro_price_period_number_of_units"] = @(self.introductoryPrice.subscriptionPeriod.numberOfUnits); d[@"intro_price_cycles"] = @(self.introductoryPrice.numberOfPeriods); - return d; + d[@"introPrice"] = self.introductoryPrice.dictionary; + } + } + + d[@"discounts"] = [NSNull null]; + + if (@available(iOS 12.2, *)) { + d[@"discounts"] = [NSMutableArray new]; + for (SKProductDiscount* discount in self.discounts) { + [d[@"discounts"] addObject:discount.dictionary]; } } - - d[@"intro_price"] = [NSNull null]; - d[@"intro_price_string"] = [NSNull null]; - d[@"intro_price_period"] = [NSNull null]; - d[@"intro_price_period_unit"] = [NSNull null]; - d[@"intro_price_period_number_of_units"] = [NSNull null]; - d[@"intro_price_cycles"] = [NSNull null]; return d; } -- (NSString *)normalizeSubscriptionPeriod:(SKProductSubscriptionPeriod *)subscriptionPeriod API_AVAILABLE(ios(11.2)){ ++ (NSString *)normalizedSubscriptionPeriod:(SKProductSubscriptionPeriod *)subscriptionPeriod API_AVAILABLE(ios(11.2)){ NSString *unit; switch (subscriptionPeriod.unit) { case SKProductPeriodUnitDay: @@ -70,7 +81,7 @@ - (NSString *)normalizeSubscriptionPeriod:(SKProductSubscriptionPeriod *)subscri return [NSString stringWithFormat:@"%@%@%@", @"P", @(subscriptionPeriod.numberOfUnits), unit]; } -- (NSString *)normalizeSubscriptionPeriodUnit:(SKProductPeriodUnit)subscriptionPeriodUnit API_AVAILABLE(ios(11.2)){ ++ (NSString *)normalizedSubscriptionPeriodUnit:(SKProductPeriodUnit)subscriptionPeriodUnit API_AVAILABLE(ios(11.2)){ switch (subscriptionPeriodUnit) { case SKProductPeriodUnitDay: return @"DAY"; diff --git a/ios/Classes/Common/SKProductDiscount+HybridAdditions.h b/ios/Classes/Common/SKProductDiscount+HybridAdditions.h new file mode 100644 index 000000000..a19b450b4 --- /dev/null +++ b/ios/Classes/Common/SKProductDiscount+HybridAdditions.h @@ -0,0 +1,12 @@ +// +// Created by RevenueCat. +// Copyright © 2019 RevenueCat. All rights reserved. +// + +#import + +@interface SKProductDiscount (HybridAdditions) + +- (NSDictionary *)dictionary; + +@end diff --git a/ios/Classes/Common/SKProductDiscount+HybridAdditions.m b/ios/Classes/Common/SKProductDiscount+HybridAdditions.m new file mode 100644 index 000000000..a8775f06a --- /dev/null +++ b/ios/Classes/Common/SKProductDiscount+HybridAdditions.m @@ -0,0 +1,44 @@ +// +// Created by RevenueCat. +// Copyright © 2019 RevenueCat. All rights reserved. +// + +#import "SKProduct+HybridAdditions.h" +#import "SKProductDiscount+HybridAdditions.h" + + +@implementation SKProductDiscount (RCPurchases) + +- (nullable NSString *)rc_currencyCode { + if(@available(iOS 10.0, *)) { + return self.priceLocale.currencyCode; + } else { + return [self.priceLocale objectForKey:NSLocaleCurrencyCode]; + } +} + +- (NSDictionary *)dictionary +{ + NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init]; + formatter.numberStyle = NSNumberFormatterCurrencyStyle; + formatter.locale = self.priceLocale; + + NSMutableDictionary *d = [NSMutableDictionary dictionaryWithDictionary:@{ + @"price": @(self.price.floatValue), + @"priceString": [formatter stringFromNumber:self.price], + @"period": [SKProduct normalizedSubscriptionPeriod:self.subscriptionPeriod], + @"periodUnit": [SKProduct normalizedSubscriptionPeriodUnit:self.subscriptionPeriod.unit], + @"periodNumberOfUnits": @(self.subscriptionPeriod.numberOfUnits), + @"cycles": @(self.numberOfPeriods) + }]; + + if (@available(iOS 12.2, *)) { + if (self.identifier) { + d[@"identifier"] = self.identifier; + } + } + + return d; +} + +@end diff --git a/ios/Classes/PurchasesFlutterPlugin.m b/ios/Classes/PurchasesFlutterPlugin.m index 5b9b1371a..fc9fa834c 100644 --- a/ios/Classes/PurchasesFlutterPlugin.m +++ b/ios/Classes/PurchasesFlutterPlugin.m @@ -81,6 +81,23 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result [self isAnonymousWithResult:result]; } else if ([@"checkTrialOrIntroductoryPriceEligibility" isEqualToString:call.method]) { [self checkTrialOrIntroductoryPriceEligibility:arguments[@"productIdentifiers"] result:result]; + } else if ([@"invalidatePurchaserInfoCache" isEqualToString:call.method]) { + [self invalidatePurchaserInfoCacheWithResult:result]; + } else if ([@"setAttributes" isEqualToString:call.method]) { + NSDictionary *attributes = arguments[@"attributes"]; + [self setAttributes:attributes result:result]; + } else if ([@"setEmail" isEqualToString:call.method]) { + NSString *email = arguments[@"email"]; + [self setEmail:email result:result]; + } else if ([@"setPhoneNumber" isEqualToString:call.method]) { + NSString *phoneNumber = arguments[@"phoneNumber"]; + [self setPhoneNumber:phoneNumber result:result]; + } else if ([@"setDisplayName" isEqualToString:call.method]) { + NSString *displayName = arguments[@"displayName"]; + [self setDisplayName:displayName result:result]; + } else if ([@"setPushToken" isEqualToString:call.method]) { + NSString *pushToken = arguments[@"pushToken"]; + [self setPushToken:pushToken result:result]; } else { result(FlutterMethodNotImplemented); } @@ -128,14 +145,19 @@ - (void)getProductInfo:(NSArray *)products - (void)purchaseProduct:(NSString *)productIdentifier result:(FlutterResult)result { - [RCCommonFunctionality purchaseProduct:productIdentifier completionBlock:[self getResponseCompletionBlock:result]]; + [RCCommonFunctionality purchaseProduct:productIdentifier + signedDiscountTimestamp:nil + completionBlock:[self getResponseCompletionBlock:result]]; } - (void)purchasePackage:(NSString *)packageIdentifier offering:(NSString *)offeringIdentifier result:(FlutterResult)result { - [RCCommonFunctionality purchasePackage:packageIdentifier offering:offeringIdentifier completionBlock:[self getResponseCompletionBlock:result]]; + [RCCommonFunctionality purchasePackage:packageIdentifier + offering:offeringIdentifier + signedDiscountTimestamp:nil + completionBlock:[self getResponseCompletionBlock:result]]; } - (void)restoreTransactionsWithResult:(FlutterResult)result @@ -197,6 +219,44 @@ - (void)checkTrialOrIntroductoryPriceEligibility:(NSArray *)products }]; } +- (void)invalidatePurchaserInfoCacheWithResult:(FlutterResult)result +{ + [RCCommonFunctionality invalidatePurchaserInfoCache]; + result(nil); +} + +#pragma mark Subscriber Attributes + +- (void)setAttributes:(NSDictionary *)attributes result:(FlutterResult)result +{ + [RCCommonFunctionality setAttributes:attributes]; + result(nil); +} + +- (void)setEmail:(NSString *)email result:(FlutterResult)result +{ + [RCCommonFunctionality setEmail:email]; + result(nil); +} + +- (void)setPhoneNumber:(NSString *)phoneNumber result:(FlutterResult)result +{ + [RCCommonFunctionality setPhoneNumber:phoneNumber]; + result(nil); +} + +- (void)setDisplayName:(NSString *)displayName result:(FlutterResult)result +{ + [RCCommonFunctionality setDisplayName:displayName]; + result(nil); +} + +- (void)setPushToken:(NSString *)pushToken result:(FlutterResult)result +{ + [RCCommonFunctionality setPushToken:pushToken]; + result(nil); +} + #pragma mark - #pragma mark Delegate Methods diff --git a/ios/purchases_flutter.podspec b/ios/purchases_flutter.podspec index a85ef26c6..bd638a5d3 100644 --- a/ios/purchases_flutter.podspec +++ b/ios/purchases_flutter.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'purchases_flutter' - s.version = '1.0.5' + s.version = '1.1.0' s.summary = 'Cross-platform subscriptions framework for Flutter.' s.description = <<-DESC Client for the RevenueCat subscription and purchase tracking system, making implementing in-app subscriptions in Flutter easy - receipt validation and status tracking included! @@ -15,7 +15,7 @@ Pod::Spec.new do |s| s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.dependency 'Purchases', '~> 3.0.1' + s.dependency 'Purchases', '~> 3.2.2' s.ios.deployment_target = '9.0' end diff --git a/lib/purchases_flutter.dart b/lib/purchases_flutter.dart index 071d8a849..0c7500418 100644 --- a/lib/purchases_flutter.dart +++ b/lib/purchases_flutter.dart @@ -300,6 +300,57 @@ class Purchases { return eligibilityMap.map((key, value) => MapEntry(key as String, IntroEligibility.fromJson(value))); } + + /// Invalidates the cache for purchaser information. + /// This is useful for cases where purchaser information might have been updated outside of the app, like if a + /// promotional subscription is granted through the RevenueCat dashboard. + static Future invalidatePurchaserInfoCache() async { + return await _channel.invokeMethod('invalidatePurchaserInfoCache'); + } + + ///================================================================================ + /// Subscriber Attributes + ///================================================================================ + + /// Subscriber attributes are useful for storing additional, structured information on a user. + /// Since attributes are writable using a public key they should not be used for + /// managing secure or sensitive information such as subscription status, coins, etc. + /// + /// Key names starting with "$" are reserved names used by RevenueCat. For a full list of key + /// restrictions refer to our guide: https://docs.revenuecat.com/docs/subscriber-attributes + /// + /// [attributes] Map of attributes by key. Set the value as an empty string to delete an attribute. + static Future setAttributes(Map attributes) async { + await _channel.invokeMethod('setAttributes', {'attributes': attributes}); + } + + /// Subscriber attribute associated with the email address for the user + /// + /// [email] Empty String or null will delete the subscriber attribute. + static Future setEmail(String email) async { + await _channel.invokeMethod('setEmail', {'email': email}); + } + + /// Subscriber attribute associated with the phone number for the user + /// + /// [phoneNumber] Empty String or null will delete the subscriber attribute. + static Future setPhoneNumber(String phoneNumber) async { + await _channel.invokeMethod('setPhoneNumber', {'phoneNumber': phoneNumber}); + } + + /// Subscriber attribute associated with the display name for the user + /// + /// [displayName] Empty String or null will delete the subscriber attribute. + static Future setDisplayName(String displayName) async { + await _channel.invokeMethod('setDisplayName', {'displayName': displayName}); + } + + /// Subscriber attribute associated with the push token for the user + /// + /// [pushToken] Null will delete the subscriber attribute. + static Future setPushToken(String pushToken) async { + await _channel.invokeMethod('setPushToken', {'pushToken': pushToken}); + } } /// This class holds the information used when upgrading from another sku. diff --git a/pubspec.yaml b/pubspec.yaml index 30b32331d..ab393a30b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: purchases_flutter description: A Flutter plugin that makes implementing in-app subscriptions for iOS and Android simple – receipt validation and status tracking included! -version: 1.0.5 +version: 1.1.0 homepage: https://www.revenuecat.com/ repository: https://github.com/RevenueCat/purchases-flutter issue_tracker: https://github.com/RevenueCat/purchases-flutter/issues