diff --git a/IntegrationTests/Assets/Tests/EditMode/NativeBridgeParseFailureContractTests.cs b/IntegrationTests/Assets/Tests/EditMode/NativeBridgeParseFailureContractTests.cs new file mode 100644 index 00000000..33334613 --- /dev/null +++ b/IntegrationTests/Assets/Tests/EditMode/NativeBridgeParseFailureContractTests.cs @@ -0,0 +1,285 @@ +using System; +using System.IO; +using NUnit.Framework; +using UnityEngine; + +public class NativeBridgeParseFailureContractTests +{ + private string _androidSource; + private string _iosSource; + + [SetUp] + public void SetUp() + { + _androidSource = File.ReadAllText( + Path.Combine( + Application.dataPath, + "RevenueCat/Plugins/Android/PurchasesWrapper.java")); + _iosSource = File.ReadAllText( + Path.Combine( + Application.dataPath, + "RevenueCat/Plugins/iOS/PurchasesUnityHelper.m")); + } + + [Test] + public void AndroidGetProductsParseFailureReturnsEmptyProducts() + { + var function = GetAndroidFunction( + "public static void getProducts("); + var branch = GetLastJavaJsonCatch(function); + + AssertAndroidFallback( + function, + "Failure parsing product identifiers", + "RECEIVE_PRODUCTS"); + Assert.That(branch, Does.Contain("\"products\"")); + Assert.That(branch, Does.Contain("new JSONArray()")); + Assert.That( + branch, + Does.Contain("sendJSONObject(response, RECEIVE_PRODUCTS);")); + } + + [Test] + public void AndroidPurchasePackageParseFailureReturnsEmptyPurchase() + { + var function = GetAndroidFunction( + "public static void purchasePackage(String packageIdentifier,"); + var branch = GetLastJavaJsonCatch(function); + + AssertAndroidFallback( + function, + "logJSONException(e);", + "MAKE_PURCHASE"); + Assert.That( + branch, + Does.Contain("sendEmptyJSONObject(MAKE_PURCHASE);")); + } + + [Test] + public void AndroidEligibilityParseFailureReturnsEmptyObject() + { + var function = GetAndroidFunction( + "public static void checkTrialOrIntroductoryPriceEligibility("); + var branch = GetLastJavaJsonCatch(function); + + AssertAndroidFallback( + function, + "Failure parsing product identifiers", + "CHECK_ELIGIBILITY"); + Assert.That( + branch, + Does.Contain("sendEmptyJSONObject(CHECK_ELIGIBILITY);")); + } + + [Test] + public void AndroidCanMakePaymentsParseFailureReturnsFalse() + { + var function = GetAndroidFunction( + "public static void canMakePayments("); + var branch = GetLastJavaJsonCatch(function); + + AssertAndroidFallback( + function, + "logJSONException(e);", + "CAN_MAKE_PAYMENTS"); + Assert.That(branch, Does.Contain("\"canMakePayments\"")); + Assert.That(branch, Does.Contain("false")); + Assert.That( + branch, + Does.Contain("sendJSONObject(response, CAN_MAKE_PAYMENTS);")); + } + + [Test] + public void IosGetProductsParseFailureReturnsEmptyProducts() + { + var function = GetIosFunction("void _RCGetProducts("); + var branch = GetObjectiveCErrorBranch(function); + + AssertIosFallback( + function, + "Error parsing productIdentifiers JSON", + "RECEIVE_PRODUCTS", + "getProducts:"); + Assert.That(branch, Does.Contain("@\"products\": @[]")); + } + + [Test] + public void IosPurchasePackageParseFailureReturnsEmptyPurchase() + { + var function = GetIosFunction("void _RCPurchasePackage("); + var branch = GetObjectiveCErrorBranch(function); + + AssertIosFallback( + function, + "Error parsing presentedOfferingContext JSON", + "MAKE_PURCHASE", + "purchasePackage:"); + Assert.That(branch, Does.Contain("sendJSONObject:nil")); + } + + [Test] + public void IosEligibilityParseFailureReturnsEmptyObject() + { + var function = GetIosFunction( + "void _RCCheckTrialOrIntroductoryPriceEligibility("); + var branch = GetObjectiveCErrorBranch(function); + + AssertIosFallback( + function, + "Error parsing productIdentifiers JSON", + "CHECK_ELIGIBILITY", + "checkTrialOrIntroductoryPriceEligibility:"); + Assert.That(branch, Does.Contain("sendJSONObject:nil")); + } + + [Test] + public void IosCanMakePaymentsParseFailureReturnsFalse() + { + var function = GetIosFunction("void _RCCanMakePayments("); + var branch = GetObjectiveCErrorBranch(function); + + AssertIosFallback( + function, + "Error parsing features JSON", + "CAN_MAKE_PAYMENTS", + "canMakePaymentsWithFeatures:"); + Assert.That(branch, Does.Contain("@\"canMakePayments\": @NO")); + } + + private string GetAndroidFunction(string methodSignature) + { + return ExtractBlock(_androidSource, methodSignature); + } + + private string GetIosFunction(string functionSignature) + { + return ExtractBlock(_iosSource, functionSignature); + } + + private static string GetLastJavaJsonCatch(string function) + { + var catchIndex = function.LastIndexOf( + "catch (JSONException e)", + StringComparison.Ordinal); + Assert.That(catchIndex, Is.GreaterThanOrEqualTo(0)); + return ExtractBlock( + function, + "catch (JSONException e)", + catchIndex); + } + + private static string GetObjectiveCErrorBranch(string function) + { + return ExtractBlock(function, "if (error)"); + } + + private static void AssertAndroidFallback( + string function, + string logMarker, + string responseMethod) + { + var branch = GetLastJavaJsonCatch(function); + Assert.That(branch, Does.Contain(logMarker)); + Assert.That(branch, Does.Contain(responseMethod)); + Assert.That( + CountOccurrences(branch, "sendJSONObject(") + + CountOccurrences(branch, "sendEmptyJSONObject("), + Is.EqualTo(1)); + Assert.That(branch, Does.Not.Contain("CommonKt.")); + + var branchIndex = function.LastIndexOf( + branch, + StringComparison.Ordinal); + var afterCatch = function.Substring(branchIndex + branch.Length); + Assert.That( + afterCatch.Trim(), + Is.EqualTo("}"), + "The parse-failure catch must terminate the Java function."); + } + + private static void AssertIosFallback( + string function, + string logMarker, + string responseMethod, + string nativeOperation) + { + var branch = GetObjectiveCErrorBranch(function); + Assert.That(branch, Does.Contain(logMarker)); + Assert.That(branch, Does.Contain(responseMethod)); + Assert.That( + CountOccurrences(branch, "sendJSONObject:"), + Is.EqualTo(1)); + Assert.That(branch, Does.Not.Contain(nativeOperation)); + + var responseIndex = branch.IndexOf( + "sendJSONObject:", + StringComparison.Ordinal); + var returnIndex = branch.IndexOf( + "return;", + responseIndex, + StringComparison.Ordinal); + Assert.That( + returnIndex, + Is.GreaterThan(responseIndex), + "The parse-failure response must be followed by a return."); + Assert.That( + CountOccurrences(branch, "return;"), + Is.EqualTo(1)); + Assert.That( + branch.Substring(returnIndex + "return;".Length).Trim(), + Is.EqualTo("}"), + "The return must terminate the iOS parse-failure branch."); + } + + private static string ExtractBlock( + string source, + string marker, + int startIndex = 0) + { + var markerIndex = source.IndexOf( + marker, + startIndex, + StringComparison.Ordinal); + Assert.That(markerIndex, Is.GreaterThanOrEqualTo(0)); + var openingBrace = source.IndexOf('{', markerIndex); + Assert.That(openingBrace, Is.GreaterThanOrEqualTo(0)); + + var depth = 0; + for (var index = openingBrace; index < source.Length; index++) + { + if (source[index] == '{') + { + depth++; + } + else if (source[index] == '}') + { + depth--; + if (depth == 0) + { + return source.Substring( + markerIndex, + index - markerIndex + 1); + } + } + } + + Assert.Fail($"Unclosed block after marker '{marker}'."); + return null; + } + + private static int CountOccurrences(string source, string value) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf( + value, + index, + StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + + return count; + } +} diff --git a/IntegrationTests/Assets/Tests/EditMode/NativeBridgeParseFailureContractTests.cs.meta b/IntegrationTests/Assets/Tests/EditMode/NativeBridgeParseFailureContractTests.cs.meta new file mode 100644 index 00000000..15c8c4c4 --- /dev/null +++ b/IntegrationTests/Assets/Tests/EditMode/NativeBridgeParseFailureContractTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 84a526aa7774e48c1a6d5bfbfe618de1 \ No newline at end of file diff --git a/RevenueCat/Plugins/Android/PurchasesWrapper.java b/RevenueCat/Plugins/Android/PurchasesWrapper.java index 1f2c67e2..b132d17f 100644 --- a/RevenueCat/Plugins/Android/PurchasesWrapper.java +++ b/RevenueCat/Plugins/Android/PurchasesWrapper.java @@ -149,6 +149,13 @@ public void onError(ErrorContainer errorContainer) { }); } catch (JSONException e) { Log.e("Purchases", "Failure parsing product identifiers " + jsonProducts); + JSONObject response = new JSONObject(); + try { + response.put("products", new JSONArray()); + } catch (JSONException responseError) { + logJSONException(responseError); + } + sendJSONObject(response, RECEIVE_PRODUCTS); } } @@ -223,6 +230,7 @@ public void onError(ErrorContainer errorContainer) { }); } catch (JSONException e) { logJSONException(e); + sendEmptyJSONObject(MAKE_PURCHASE); } } @@ -445,6 +453,7 @@ public static void checkTrialOrIntroductoryPriceEligibility(String jsonProducts) sendJSONObject(MappersHelpersKt.convertToJson(map), CHECK_ELIGIBILITY); } catch (JSONException e) { Log.e("Purchases", "Failure parsing product identifiers " + jsonProducts); + sendEmptyJSONObject(CHECK_ELIGIBILITY); } } @@ -591,6 +600,13 @@ public void onError(ErrorContainer errorContainer) { }); } catch (JSONException e) { logJSONException(e); + JSONObject response = new JSONObject(); + try { + response.put("canMakePayments", false); + } catch (JSONException responseError) { + logJSONException(responseError); + } + sendJSONObject(response, CAN_MAKE_PAYMENTS); } } diff --git a/RevenueCat/Plugins/iOS/PurchasesUnityHelper.m b/RevenueCat/Plugins/iOS/PurchasesUnityHelper.m index b4b90f71..1a417589 100644 --- a/RevenueCat/Plugins/iOS/PurchasesUnityHelper.m +++ b/RevenueCat/Plugins/iOS/PurchasesUnityHelper.m @@ -901,6 +901,8 @@ void _RCGetProducts(const char *productIdentifiersJSON, const char *type) { if (error) { NSLog(@"Error parsing productIdentifiers JSON: %s %@", productIdentifiersJSON, error.localizedDescription); + [_RCUnityHelperShared() sendJSONObject:@{@"products": @[]} + toMethod:RECEIVE_PRODUCTS]; return; } @@ -918,6 +920,7 @@ void _RCPurchasePackage(const char *packageIdentifier, const char *presentedOffe if (error) { NSLog(@"Error parsing presentedOfferingContext JSON: %s %@", presentedOfferingContextJSON, error.localizedDescription); + [_RCUnityHelperShared() sendJSONObject:nil toMethod:MAKE_PURCHASE]; return; } @@ -1008,6 +1011,7 @@ void _RCCheckTrialOrIntroductoryPriceEligibility(const char *productIdentifiersJ if (error) { NSLog(@"Error parsing productIdentifiers JSON: %s %@", productIdentifiersJSON, error.localizedDescription); + [_RCUnityHelperShared() sendJSONObject:nil toMethod:CHECK_ELIGIBILITY]; return; } @@ -1154,6 +1158,8 @@ void _RCCanMakePayments(const char *featuresJSON) { if (error) { NSLog(@"Error parsing features JSON: %s %@", featuresJSON, error.localizedDescription); + [_RCUnityHelperShared() sendJSONObject:@{@"canMakePayments": @NO} + toMethod:CAN_MAKE_PAYMENTS]; return; }