-
Notifications
You must be signed in to change notification settings - Fork 9.8k
[in_app_purchase][iOS] fix iOS promotional offers (SKPaymentDiscountWrapper was not used properly) #6541
[in_app_purchase][iOS] fix iOS promotional offers (SKPaymentDiscountWrapper was not used properly) #6541
Conversation
@@ -277,7 +277,7 @@ + (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map | |||
return nil; | |||
} | |||
|
|||
if (!timestamp || ![timestamp isKindOfClass:NSNumber.class] || [timestamp intValue] <= 0) { | |||
if (!timestamp || ![timestamp isKindOfClass:NSNumber.class] || [timestamp longLongValue] <= 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch. Can we add an XCUnitTest for this? Maybe have the timestamp to be a very big long long number?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. I've added XCUnitTest using current timestamp number value. It's enough to cause the intValue
overflow in negative number. If I use really big long long number, the intValue overflow goes positive again and passes the test. I'm not sure of the usefulness of this "not negative int" check in the original code.
@@ -405,7 +405,8 @@ class SKPaymentWrapper { | |||
'applicationUsername': applicationUsername, | |||
'requestData': requestData, | |||
'quantity': quantity, | |||
'simulatesAskToBuyInSandbox': simulatesAskToBuyInSandbox | |||
'simulatesAskToBuyInSandbox': simulatesAskToBuyInSandbox, | |||
'paymentDiscount': paymentDiscount?.toMap(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should have some test for the toMap
method, can we update the test to include the discount? If we don't have an existing test, do you mind adding a test for the toMap
method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. I've added a new test checking the values of the SKPaymentWrapper
object and the result of the toMap
method explicitly.
@@ -75,7 +75,10 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { | |||
purchaseParam is AppStorePurchaseParam ? purchaseParam.quantity : 1, | |||
applicationUsername: purchaseParam.applicationUserName, | |||
simulatesAskToBuyInSandbox: purchaseParam is AppStorePurchaseParam && | |||
purchaseParam.simulatesAskToBuyInSandbox)); | |||
purchaseParam.simulatesAskToBuyInSandbox, | |||
paymentDiscount: purchaseParam is AppStorePurchaseParam |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should test that the paymentDiscount
is encoded and sent to method channel.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see your point, that in fact the bug was that the discount parameter was not sent trough the method channel. But I am not sure on how to construct such a test?
In my understanding: now the discount is send trough AppStorePurchaseParam
in the buyNonConsumable
method, which is not returning a useful result to test. This method constructs SKPaymentWrapper
with a discount parameter and sends it to _skPaymentQueueWrapper.addPayment
which turns the SKPaymentWrapper
to a map (which we test in the newly created test for the toMap method) and sends it trough the method channel. But the channel is not returning useful result for testing either. The encoding of the paymentDiscount
is in the toMap
method which we test now.
Can you elaborate please?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might be able to intercept the method call in the FakeStoreKitPlatform
and inspect the value?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I got your idea. Thanks! Done in the last commit.
…r SKPaymentWrapper.toMap()
packages/in_app_purchase/in_app_purchase_storekit/example/ios/RunnerTests/TranslatorTests.m
Show resolved
Hide resolved
@cyanglaz I don't understand while the last commit failed at the |
"submit-queue" is not related to this PR. It is an indication of the current tree's status. This CI is intended to block merging when tree is red (when the build is failing on master). Usually, if there is a "submit-queue" error, someone on the flutter team is working on a fix, the best action is to wait. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM mod nits, @stuartmorgan could you do a secondary review?
@@ -32,4 +33,7 @@ class AppStorePurchaseParam extends PurchaseParam { | |||
|
|||
/// Quantity of the product user requested to buy. | |||
final int quantity; | |||
|
|||
/// Discount applied to the product (optional). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nits, the "optional" keyword is probably not necessary as the nullability of this parameter already indicates it is optional.
However, it might be better to add an explanation what does it mean to be null, something like:
///
/// The value is `null` when the product does not have a discount.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. Changed in the last commit.
@@ -30,6 +30,7 @@ class FakeStoreKitPlatform { | |||
PlatformException? restoreException; | |||
SKError? testRestoredError; | |||
bool queueIsActive = false; | |||
Map<String, dynamic>? discountReceived; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nits: Alternatively, we can make it non-nullable and initialized it to empty map, this can avoid many unwrapping.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, collections should only be nullable if null and empty are conceptually different.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. Changed in the last commit.
@@ -30,6 +30,7 @@ class FakeStoreKitPlatform { | |||
PlatformException? restoreException; | |||
SKError? testRestoredError; | |||
bool queueIsActive = false; | |||
Map<String, dynamic>? discountReceived; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, collections should only be nullable if null and empty are conceptually different.
@@ -1,3 +1,7 @@ | |||
## 0.3.3 | |||
|
|||
* Fixes `SKProductDiscountWrapper` with being sent trough the method channel as the expected parameter. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo: through
But higher level, the changelog should describe the issue from the client perspective, not the implementation details of the bug.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed in the last commit.
@@ -237,7 +237,7 @@ + (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront | |||
|
|||
+ (SKPaymentDiscount *)getSKPaymentDiscountFromMap:(NSDictionary *)map | |||
withError:(NSString **)error { | |||
if (!map || map.count <= 0) { | |||
if (!map || [map isKindOfClass:[NSNull class]] || map.count <= 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code is checking for a type violation; NSNull
is not a NSDictionary
, so if this happens the caller has made an invalid call. If you have a path where this is happening, it needs to be fixed at the original argument extraction point, not here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The original code never transferred through the method channel the discount parameter. So this check was stopped at the !map
check, never trying the map.count <= 0
check and directly returning nil
afterwards.
What happened when I transferred the discount key through the channel was that, when I was buying a product without discount, it was NULL
but it did not stop at the !map
it went to the next check map.count <= 0
and crashed.
So here I do not try to check for a type violation, but I am shielding the crashing check that comes next.
As for if it should be done here or in the caller. Why it is better in the caller?
Shouldn't the method take care of the argument's validation, as it does in the checks that come below?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when I was buying a product without discount, it was
NULL
You mean it was NSNull
, presumably, given the code you added. NULL
is a typedef for 0
, and is essentially the C equivalent of nil
. NSNull
is not nil
/NULL
, it is singleton class.
but it did not stop at the
!map
That is expected; a pointer that is pointing to an NSNull
instance is not nil
.
it went to the next check
map.count <= 0
and crashed.
Presumably the crash was an unrecognized selector sent to instance
exception, because count
is not a method of NSNull
.
So here I do not try to check for a type violation
Yes, that is exactly what you are checking for. You are taking a pointer of type NSDictionary
and checking whether it contains an NSNull
instance instead of an NSDictionary
instance. Which would be a type violation.
As for if it should be done here or in the caller. Why it is better in the caller?
This method takes an NSDictionary*
, not an id
. It is better in the caller because it is a type violation to pass an argument to this method that is not an NSDictionary
, and an instance of NSNull
is not an NSDictionary
.
The comparable Dart code is:
class NSNull {
}
void methodHandler(dynamic args) {
expectsAMap(args);
}
void expectsAMap(Map? map) {
if (map != null) {
print(map.length);
}
}
void main() {
NSNull nullArg = NSNull();
methodHandler(nullArg);
}
The bug is this Dart code is methodHandler
passing args
to expectsAMap
without checking whether it's a Map
, not when expectsAMap
uses map
as if it were its declared type.
Shouldn't the method take care of the argument's validation, as it does in the checks that come below?
Methods should not have to validate that they haven't been called with types that violate their declarations. Other validation (like whether it's nil
), sure.
The code below this is also wrong; values with unknown types should be type checked before assigning to a variable declared as a specific type, not after. Either the values can only be NSString
s, in which case the checks are pointless, or (more likely) they could be NSString
s or NSNull
s, in which case the variables should be declared id
, not NSString*
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see your point. Thanks for the detailed explanation. Much appreciated! I've pushed a commit where I move the check out of there and into it's caller.
@@ -2,7 +2,7 @@ name: in_app_purchase_storekit | |||
description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework. | |||
repository: https://github.com/flutter/plugins/tree/main/packages/in_app_purchase/in_app_purchase_storekit | |||
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 | |||
version: 0.3.2+2 | |||
version: 0.3.3 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a bugfix, so the version should be 0.3.2+3.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adds a new field to the AppStorePurchaseParam
so it should be a new feature. The PR description or CHANGELOG should probably be something like "Supports adding discount information to AppStorePurchaseParam
", instead of "fixing".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the correction there; I didn't look closely enough at the details and thought it was just populating something that was previously added but not wired up.
@@ -169,6 +171,25 @@ class FakeStoreKitPlatform { | |||
case '-[InAppPurchasePlugin addPayment:result:]': | |||
final String id = call.arguments['productIdentifier'] as String; | |||
final int quantity = call.arguments['quantity'] as int; | |||
|
|||
// in case of testing paymentDiscount |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comments should be full grammatically correct sentences, with capitalization and punctuation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed in the last commit
call.arguments['paymentDiscount']; | ||
discountReceived = <String, dynamic>{}; | ||
// can't cast directly the argument to Map<String,dynamic>, will receive: | ||
// PlatformException(error, type '_InternalLinkedHashMap<Object?, Object?>' is not a subtype of type 'Map<String?, dynamic>' in type cast, null, null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment sounds like you attempted to cast with as
instead of cast<...>
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True. Changed it to a cast<>
now. Thanks!
packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart
Outdated
Show resolved
Hide resolved
0b58c4f
to
badbd1d
Compare
@cyanglaz / @stuartmorgan should I squash all the commits before you accept the changes and decide to merge? |
That's not necessary, GitHub will squash when it merges. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good overall, just one more type issue.
SKPaymentDiscount *paymentDiscount = [FIAObjectTranslator | ||
getSKPaymentDiscountFromMap:[paymentMap objectForKey:@"paymentDiscount"] | ||
withError:&error]; | ||
NSDictionary *paymentDiscountMap = [paymentMap objectForKey:@"paymentDiscount"]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is still a type violation, it's just more local. If the right hand side of this assignment is NSNull
, then it is incorrect to assign it to an NSDictionary*
.
Either this should be an id
, or you should use a helper like https://github.com/flutter/plugins/blob/main/packages/file_selector/file_selector_macos/macos/Classes/FileSelectorPlugin.swift#L198-L201 (written as ObjC of course) to do the extract/check/convert/assign in a single line here.
Doing the latter would allow you to remove the extra conditional you had to add at this layer since the called code already handles nil
correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(@cyanglaz Any chance you could plan to convert this plugin to Pigeon sometime in the nearish term, like early 2023? It seems like there's a fair amount of this kind of problem in the plugin, and we could eliminate all of it with a Pigeon conversion.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. I did it with ObjC version of the helper that you've shown, one line without the condition check. Thanks! :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks for the iteration on the type issues!
…tDiscountWrapper was not used properly) (flutter/plugins#6541)
…rapper was not used properly) (flutter#6541)
…rapper was not used properly) (flutter#6541)
Make iOS Promotional Offers in in_app_purchase plugin usable.
SKPaymentDiscountWrapper
was not being sent trough the method channel.This is an update to an old (already closed) PR here
Resolves: [in-app-purchase][iOS] Promotional Offers in iOS not working
Pre-launch Checklist
dart format
.)[shared_preferences]
pubspec.yaml
with an appropriate new version according to the pub versioning philosophy, or this PR is exempt from version changes.CHANGELOG.md
to add a description of the change, following repository CHANGELOG style.///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.