diff --git a/.benchmark/REPORT.md b/.benchmark/REPORT.md index b152b5f2a..c73f33452 100644 --- a/.benchmark/REPORT.md +++ b/.benchmark/REPORT.md @@ -4,17 +4,17 @@ Time from `preloadAllPaywalls()` until every paywall reaches `PaywallLoadingStat | Tier | Paywalls | Mean | Baseline | Δ | Limit | Cold mean | Warm mean | Median | StdDev | CV | Samples | Status | |------|----------|------|----------|---|-------|-----------|-----------|--------|--------|----|---------|--------| -| LOW | 10 | 5.95s | 6.34s | -6.2% | +30% | 8.08s | — | 5.95s | 3.30s | 40.8% | 10 (10×1) | 🟢 improved | -| MID | 10 | 7.08s | 8.78s | -19.3% | +15% | 7.20s | — | 7.08s | 1.62s | 22.5% | 10 (10×1) | 🟢 improved | -| HIGH | 10 | 6.39s | 6.72s | -4.9% | +20% | 7.87s | — | 6.39s | 3.16s | 40.2% | 10 (10×1) | 🟢 improved | +| LOW | 10 | 6.13s | 5.95s | +3.2% | +30% | 6.46s | — | 6.13s | 877ms | 13.6% | 10 (10×1) | ✅ OK | +| MID | 10 | 4.98s | 7.08s | -29.7% | +15% | 5.42s | — | 4.98s | 1.00s | 18.5% | 10 (10×1) | 🟢 improved | +| HIGH | 10 | 7.28s | 6.39s | +13.9% | +20% | 8.13s | — | 7.28s | 3.13s | 38.4% | 10 (10×1) | ✅ OK | ### Devices | CI tier | Model | API | CPU cores | SDK-classified tier | |---------|-------|-----|-----------|---------------------| | LOW | sdk_gphone64_x86_64 | 34 | 2 | mid | -| MID | sdk_gphone64_x86_64 | 34 | 3 | low | -| HIGH | sdk_gphone64_x86_64 | 34 | 4 | low | +| MID | sdk_gphone64_x86_64 | 34 | 3 | mid | +| HIGH | sdk_gphone64_x86_64 | 34 | 4 | mid | - A tier fails when its mean exceeds the baseline by more than the delta limit. - The SDK-classified tier column shows what `DeviceClassifier` thinks each emulator is — if it drifts from the CI tier label, adjust the emulator cores/RAM in the workflow matrix. diff --git a/.benchmark/results/preload-benchmark-high.json b/.benchmark/results/preload-benchmark-high.json index 019104596..2ef359eff 100644 --- a/.benchmark/results/preload-benchmark-high.json +++ b/.benchmark/results/preload-benchmark-high.json @@ -4,233 +4,233 @@ "cpuCores": 4, "fingerprint": "google/sdk_gphone64_x86_64/emu64xa:14/UE1A.230829.036.A4/12096271:user/release-keys", "model": "sdk_gphone64_x86_64", - "sdkClassifiedTier": "low", + "sdkClassifiedTier": "mid", "sdkInt": 34 }, "iterationsPerRun": 1, "runCount": 10, "samples": [ { - "allReadyMs": 15685, + "allReadyMs": 12970, "cold": true, - "configureMs": 2613, + "configureMs": 3327, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 15685, - "data-v3-0ed9-2024-04-04": 14353, - "free-trial-v3-d8d7-2024-04-04": 15124, - "inimai---open-app-copy-c50c-2024-11-28": 11154, - "localized-paywall-e901-2024-04-04": 14506, - "price-tester-1b8e-2024-04-04": 14767, - "products-v3-60c5-2024-04-04": 9961, - "superwall-template-9f9f-2024-05-21": 10738, - "url-paywall-v3-3810-2024-04-04": 12089, - "video-v3-72b4-2024-04-04": 11782 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 12970, + "data-v3-0ed9-2024-04-04": 9393, + "free-trial-v3-d8d7-2024-04-04": 10372, + "inimai---open-app-copy-c50c-2024-11-28": 7239, + "localized-paywall-e901-2024-04-04": 8828, + "price-tester-1b8e-2024-04-04": 9550, + "products-v3-60c5-2024-04-04": 9753, + "superwall-template-9f9f-2024-05-21": 7752, + "url-paywall-v3-3810-2024-04-04": 8267, + "video-v3-72b4-2024-04-04": 10007 }, - "preloadCompleteEventMs": 1489, + "preloadCompleteEventMs": 1523, "run": 1 }, { - "allReadyMs": 7912, + "allReadyMs": 13067, "cold": true, - "configureMs": 1537, + "configureMs": 3630, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6791, - "data-v3-0ed9-2024-04-04": 7403, - "free-trial-v3-d8d7-2024-04-04": 6232, - "inimai---open-app-copy-c50c-2024-11-28": 4704, - "localized-paywall-e901-2024-04-04": 7912, - "price-tester-1b8e-2024-04-04": 5365, - "products-v3-60c5-2024-04-04": 5979, - "superwall-template-9f9f-2024-05-21": 5060, - "url-paywall-v3-3810-2024-04-04": 6385, - "video-v3-72b4-2024-04-04": 4603 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 11493, + "data-v3-0ed9-2024-04-04": 13067, + "free-trial-v3-d8d7-2024-04-04": 10260, + "inimai---open-app-copy-c50c-2024-11-28": 9327, + "localized-paywall-e901-2024-04-04": 9683, + "price-tester-1b8e-2024-04-04": 10462, + "products-v3-60c5-2024-04-04": 10922, + "superwall-template-9f9f-2024-05-21": 12203, + "url-paywall-v3-3810-2024-04-04": 8969, + "video-v3-72b4-2024-04-04": 10719 }, - "preloadCompleteEventMs": 1153, + "preloadCompleteEventMs": 2354, "run": 2 }, { - "allReadyMs": 8799, + "allReadyMs": 8170, "cold": true, - "configureMs": 1896, + "configureMs": 1435, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 8799, - "data-v3-0ed9-2024-04-04": 5728, - "free-trial-v3-d8d7-2024-04-04": 4860, - "inimai---open-app-copy-c50c-2024-11-28": 5063, - "localized-paywall-e901-2024-04-04": 6695, - "price-tester-1b8e-2024-04-04": 5317, - "products-v3-60c5-2024-04-04": 4657, - "superwall-template-9f9f-2024-05-21": 6542, - "url-paywall-v3-3810-2024-04-04": 4297, - "video-v3-72b4-2024-04-04": 6948 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 7917, + "data-v3-0ed9-2024-04-04": 7511, + "free-trial-v3-d8d7-2024-04-04": 6842, + "inimai---open-app-copy-c50c-2024-11-28": 4942, + "localized-paywall-e901-2024-04-04": 6945, + "price-tester-1b8e-2024-04-04": 5767, + "products-v3-60c5-2024-04-04": 8170, + "superwall-template-9f9f-2024-05-21": 5455, + "url-paywall-v3-3810-2024-04-04": 6079, + "video-v3-72b4-2024-04-04": 6639 }, - "preloadCompleteEventMs": 1171, + "preloadCompleteEventMs": 1087, "run": 3 }, { - "allReadyMs": 10317, + "allReadyMs": 10477, "cold": true, - "configureMs": 2148, + "configureMs": 2896, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 9503, - "data-v3-0ed9-2024-04-04": 10317, - "free-trial-v3-d8d7-2024-04-04": 8227, - "inimai---open-app-copy-c50c-2024-11-28": 7349, - "localized-paywall-e901-2024-04-04": 8329, - "price-tester-1b8e-2024-04-04": 6686, - "products-v3-60c5-2024-04-04": 8840, - "superwall-template-9f9f-2024-05-21": 7145, - "url-paywall-v3-3810-2024-04-04": 8635, - "video-v3-72b4-2024-04-04": 9707 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 8595, + "data-v3-0ed9-2024-04-04": 10018, + "free-trial-v3-d8d7-2024-04-04": 10477, + "inimai---open-app-copy-c50c-2024-11-28": 6602, + "localized-paywall-e901-2024-04-04": 7826, + "price-tester-1b8e-2024-04-04": 7674, + "products-v3-60c5-2024-04-04": 8850, + "superwall-template-9f9f-2024-05-21": 6912, + "url-paywall-v3-3810-2024-04-04": 6496, + "video-v3-72b4-2024-04-04": 8029 }, - "preloadCompleteEventMs": 2077, + "preloadCompleteEventMs": 3942, "run": 4 }, { - "allReadyMs": 6060, + "allReadyMs": 8688, "cold": true, - "configureMs": 2361, + "configureMs": 2191, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5299, - "data-v3-0ed9-2024-04-04": 6060, - "free-trial-v3-d8d7-2024-04-04": 4536, - "inimai---open-app-copy-c50c-2024-11-28": 4080, - "localized-paywall-e901-2024-04-04": 4637, - "price-tester-1b8e-2024-04-04": 5705, - "products-v3-60c5-2024-04-04": 3978, - "superwall-template-9f9f-2024-05-21": 4282, - "url-paywall-v3-3810-2024-04-04": 3826, - "video-v3-72b4-2024-04-04": 4943 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6317, + "data-v3-0ed9-2024-04-04": 8688, + "free-trial-v3-d8d7-2024-04-04": 5454, + "inimai---open-app-copy-c50c-2024-11-28": 4787, + "localized-paywall-e901-2024-04-04": 5555, + "price-tester-1b8e-2024-04-04": 5301, + "products-v3-60c5-2024-04-04": 6732, + "superwall-template-9f9f-2024-05-21": 5098, + "url-paywall-v3-3810-2024-04-04": 4528, + "video-v3-72b4-2024-04-04": 6476 }, - "preloadCompleteEventMs": 1405, + "preloadCompleteEventMs": 1358, "run": 5 }, { - "allReadyMs": 5798, + "allReadyMs": 6386, "cold": true, - "configureMs": 1946, + "configureMs": 1964, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4681, - "data-v3-0ed9-2024-04-04": 5798, - "free-trial-v3-d8d7-2024-04-04": 5341, - "inimai---open-app-copy-c50c-2024-11-28": 3867, - "localized-paywall-e901-2024-04-04": 5443, - "price-tester-1b8e-2024-04-04": 4835, - "products-v3-60c5-2024-04-04": 4172, - "superwall-template-9f9f-2024-05-21": 5037, - "url-paywall-v3-3810-2024-04-04": 4070, - "video-v3-72b4-2024-04-04": 4323 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6133, + "data-v3-0ed9-2024-04-04": 6386, + "free-trial-v3-d8d7-2024-04-04": 5778, + "inimai---open-app-copy-c50c-2024-11-28": 4659, + "localized-paywall-e901-2024-04-04": 5066, + "price-tester-1b8e-2024-04-04": 4557, + "products-v3-60c5-2024-04-04": 5269, + "superwall-template-9f9f-2024-05-21": 4914, + "url-paywall-v3-3810-2024-04-04": 5371, + "video-v3-72b4-2024-04-04": 5879 }, - "preloadCompleteEventMs": 1076, + "preloadCompleteEventMs": 1425, "run": 6 }, { - "allReadyMs": 5850, + "allReadyMs": 4893, "cold": true, - "configureMs": 1926, + "configureMs": 1597, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5850, - "data-v3-0ed9-2024-04-04": 5291, - "free-trial-v3-d8d7-2024-04-04": 4734, - "inimai---open-app-copy-c50c-2024-11-28": 3611, - "localized-paywall-e901-2024-04-04": 4068, - "price-tester-1b8e-2024-04-04": 3509, - "products-v3-60c5-2024-04-04": 4379, - "superwall-template-9f9f-2024-05-21": 3864, - "url-paywall-v3-3810-2024-04-04": 4937, - "video-v3-72b4-2024-04-04": 4835 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4893, + "data-v3-0ed9-2024-04-04": 4691, + "free-trial-v3-d8d7-2024-04-04": 3871, + "inimai---open-app-copy-c50c-2024-11-28": 3466, + "localized-paywall-e901-2024-04-04": 3921, + "price-tester-1b8e-2024-04-04": 4387, + "products-v3-60c5-2024-04-04": 4184, + "superwall-template-9f9f-2024-05-21": 3668, + "url-paywall-v3-3810-2024-04-04": 3351, + "video-v3-72b4-2024-04-04": 4337 }, - "preloadCompleteEventMs": 676, + "preloadCompleteEventMs": 846, "run": 7 }, { - "allReadyMs": 6342, + "allReadyMs": 4954, "cold": true, - "configureMs": 1691, + "configureMs": 1189, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6342, - "data-v3-0ed9-2024-04-04": 4510, - "free-trial-v3-d8d7-2024-04-04": 5481, - "inimai---open-app-copy-c50c-2024-11-28": 4054, - "localized-paywall-e901-2024-04-04": 5278, - "price-tester-1b8e-2024-04-04": 3649, - "products-v3-60c5-2024-04-04": 5024, - "superwall-template-9f9f-2024-05-21": 3902, - "url-paywall-v3-3810-2024-04-04": 4872, - "video-v3-72b4-2024-04-04": 5177 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4599, + "data-v3-0ed9-2024-04-04": 4292, + "free-trial-v3-d8d7-2024-04-04": 3785, + "inimai---open-app-copy-c50c-2024-11-28": 3480, + "localized-paywall-e901-2024-04-04": 3582, + "price-tester-1b8e-2024-04-04": 3328, + "products-v3-60c5-2024-04-04": 3937, + "superwall-template-9f9f-2024-05-21": 4853, + "url-paywall-v3-3810-2024-04-04": 4038, + "video-v3-72b4-2024-04-04": 4954 }, - "preloadCompleteEventMs": 1032, + "preloadCompleteEventMs": 881, "run": 8 }, { - "allReadyMs": 6436, + "allReadyMs": 5487, "cold": true, - "configureMs": 1749, + "configureMs": 1487, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5472, - "data-v3-0ed9-2024-04-04": 4205, - "free-trial-v3-d8d7-2024-04-04": 4965, - "inimai---open-app-copy-c50c-2024-11-28": 6436, - "localized-paywall-e901-2024-04-04": 5117, - "price-tester-1b8e-2024-04-04": 5219, - "products-v3-60c5-2024-04-04": 4813, - "superwall-template-9f9f-2024-05-21": 4509, - "url-paywall-v3-3810-2024-04-04": 3646, - "video-v3-72b4-2024-04-04": 3901 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5487, + "data-v3-0ed9-2024-04-04": 4675, + "free-trial-v3-d8d7-2024-04-04": 4320, + "inimai---open-app-copy-c50c-2024-11-28": 3912, + "localized-paywall-e901-2024-04-04": 4421, + "price-tester-1b8e-2024-04-04": 4014, + "products-v3-60c5-2024-04-04": 3708, + "superwall-template-9f9f-2024-05-21": 3505, + "url-paywall-v3-3810-2024-04-04": 3861, + "video-v3-72b4-2024-04-04": 4828 }, - "preloadCompleteEventMs": 978, + "preloadCompleteEventMs": 865, "run": 9 }, { - "allReadyMs": 5456, + "allReadyMs": 6209, "cold": true, - "configureMs": 1853, + "configureMs": 1698, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5355, - "data-v3-0ed9-2024-04-04": 4646, - "free-trial-v3-d8d7-2024-04-04": 4190, - "inimai---open-app-copy-c50c-2024-11-28": 3478, - "localized-paywall-e901-2024-04-04": 5001, - "price-tester-1b8e-2024-04-04": 3529, - "products-v3-60c5-2024-04-04": 4342, - "superwall-template-9f9f-2024-05-21": 3783, - "url-paywall-v3-3810-2024-04-04": 4037, - "video-v3-72b4-2024-04-04": 5456 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5701, + "data-v3-0ed9-2024-04-04": 6057, + "free-trial-v3-d8d7-2024-04-04": 4785, + "inimai---open-app-copy-c50c-2024-11-28": 4175, + "localized-paywall-e901-2024-04-04": 4938, + "price-tester-1b8e-2024-04-04": 5243, + "products-v3-60c5-2024-04-04": 4379, + "superwall-template-9f9f-2024-05-21": 4022, + "url-paywall-v3-3810-2024-04-04": 5141, + "video-v3-72b4-2024-04-04": 6209 }, - "preloadCompleteEventMs": 710, + "preloadCompleteEventMs": 655, "run": 10 } ], "schemaVersion": 1, "stats": { - "coefficientOfVariationPct": 40.158560805785584, - "coldMeanMs": 7865.5, - "maxMs": 15685, - "meanMs": 7865.5, - "medianMs": 6389.0, - "minMs": 5456, + "coefficientOfVariationPct": 38.44018590825072, + "coldMeanMs": 8130.1, + "maxMs": 13067, + "meanMs": 8130.1, + "medianMs": 7278.0, + "minMs": 4893, "sampleCount": 10, - "stdDevMs": 3158.6716001790655, + "stdDevMs": 3125.2255545266917, "warmMeanMs": null }, "tier": "HIGH" diff --git a/.benchmark/results/preload-benchmark-low.json b/.benchmark/results/preload-benchmark-low.json index 827923ac2..f1aa1f950 100644 --- a/.benchmark/results/preload-benchmark-low.json +++ b/.benchmark/results/preload-benchmark-low.json @@ -11,226 +11,226 @@ "runCount": 10, "samples": [ { - "allReadyMs": 14606, + "allReadyMs": 8184, "cold": true, - "configureMs": 3492, + "configureMs": 2747, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 10294, - "data-v3-0ed9-2024-04-04": 14606, - "free-trial-v3-d8d7-2024-04-04": 11740, - "inimai---open-app-copy-c50c-2024-11-28": 10446, - "localized-paywall-e901-2024-04-04": 13413, - "price-tester-1b8e-2024-04-04": 11427, - "products-v3-60c5-2024-04-04": 12064, - "superwall-template-9f9f-2024-05-21": 10855, - "url-paywall-v3-3810-2024-04-04": 11269, - "video-v3-72b4-2024-04-04": 13670 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5752, + "data-v3-0ed9-2024-04-04": 6108, + "free-trial-v3-d8d7-2024-04-04": 6512, + "inimai---open-app-copy-c50c-2024-11-28": 8184, + "localized-paywall-e901-2024-04-04": 6967, + "price-tester-1b8e-2024-04-04": 4483, + "products-v3-60c5-2024-04-04": 5195, + "superwall-template-9f9f-2024-05-21": 4841, + "url-paywall-v3-3810-2024-04-04": 4430, + "video-v3-72b4-2024-04-04": 5044 }, - "preloadCompleteEventMs": 2884, + "preloadCompleteEventMs": 983, "run": 1 }, { - "allReadyMs": 12126, + "allReadyMs": 7945, "cold": true, - "configureMs": 2000, + "configureMs": 1991, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 12126, - "data-v3-0ed9-2024-04-04": 11156, - "free-trial-v3-d8d7-2024-04-04": 8785, - "inimai---open-app-copy-c50c-2024-11-28": 8276, - "localized-paywall-e901-2024-04-04": 9652, - "price-tester-1b8e-2024-04-04": 11719, - "products-v3-60c5-2024-04-04": 10738, - "superwall-template-9f9f-2024-05-21": 9291, - "url-paywall-v3-3810-2024-04-04": 10378, - "video-v3-72b4-2024-04-04": 10170 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6883, + "data-v3-0ed9-2024-04-04": 7945, + "free-trial-v3-d8d7-2024-04-04": 6524, + "inimai---open-app-copy-c50c-2024-11-28": 6012, + "localized-paywall-e901-2024-04-04": 7186, + "price-tester-1b8e-2024-04-04": 7591, + "products-v3-60c5-2024-04-04": 7389, + "superwall-template-9f9f-2024-05-21": 6316, + "url-paywall-v3-3810-2024-04-04": 7692, + "video-v3-72b4-2024-04-04": 7540 }, - "preloadCompleteEventMs": 1676, + "preloadCompleteEventMs": 2992, "run": 2 }, { - "allReadyMs": 9435, + "allReadyMs": 5889, "cold": true, - "configureMs": 2292, + "configureMs": 1455, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 8669, - "data-v3-0ed9-2024-04-04": 7984, - "free-trial-v3-d8d7-2024-04-04": 6281, - "inimai---open-app-copy-c50c-2024-11-28": 9435, - "localized-paywall-e901-2024-04-04": 7349, - "price-tester-1b8e-2024-04-04": 6433, - "products-v3-60c5-2024-04-04": 6839, - "superwall-template-9f9f-2024-05-21": 5927, - "url-paywall-v3-3810-2024-04-04": 5623, - "video-v3-72b4-2024-04-04": 6585 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5181, + "data-v3-0ed9-2024-04-04": 5889, + "free-trial-v3-d8d7-2024-04-04": 4114, + "inimai---open-app-copy-c50c-2024-11-28": 3557, + "localized-paywall-e901-2024-04-04": 4873, + "price-tester-1b8e-2024-04-04": 3912, + "products-v3-60c5-2024-04-04": 4722, + "superwall-template-9f9f-2024-05-21": 3760, + "url-paywall-v3-3810-2024-04-04": 4368, + "video-v3-72b4-2024-04-04": 4216 }, - "preloadCompleteEventMs": 1627, + "preloadCompleteEventMs": 876, "run": 3 }, { - "allReadyMs": 10147, + "allReadyMs": 6203, "cold": true, - "configureMs": 2330, + "configureMs": 1842, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 10147, - "data-v3-0ed9-2024-04-04": 8373, - "free-trial-v3-d8d7-2024-04-04": 9844, - "inimai---open-app-copy-c50c-2024-11-28": 6836, - "localized-paywall-e901-2024-04-04": 6532, - "price-tester-1b8e-2024-04-04": 7866, - "products-v3-60c5-2024-04-04": 7765, - "superwall-template-9f9f-2024-05-21": 7300, - "url-paywall-v3-3810-2024-04-04": 7967, - "video-v3-72b4-2024-04-04": 9692 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5748, + "data-v3-0ed9-2024-04-04": 6102, + "free-trial-v3-d8d7-2024-04-04": 5242, + "inimai---open-app-copy-c50c-2024-11-28": 4481, + "localized-paywall-e901-2024-04-04": 4989, + "price-tester-1b8e-2024-04-04": 4582, + "products-v3-60c5-2024-04-04": 5343, + "superwall-template-9f9f-2024-05-21": 4329, + "url-paywall-v3-3810-2024-04-04": 5495, + "video-v3-72b4-2024-04-04": 6203 }, - "preloadCompleteEventMs": 3117, + "preloadCompleteEventMs": 1163, "run": 4 }, { - "allReadyMs": 5874, + "allReadyMs": 6211, "cold": true, - "configureMs": 1462, + "configureMs": 1615, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5666, - "data-v3-0ed9-2024-04-04": 5205, - "free-trial-v3-d8d7-2024-04-04": 4952, - "inimai---open-app-copy-c50c-2024-11-28": 4187, - "localized-paywall-e901-2024-04-04": 5874, - "price-tester-1b8e-2024-04-04": 4340, - "products-v3-60c5-2024-04-04": 5357, - "superwall-template-9f9f-2024-05-21": 4035, - "url-paywall-v3-3810-2024-04-04": 3731, - "video-v3-72b4-2024-04-04": 4798 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4231, + "data-v3-0ed9-2024-04-04": 5450, + "free-trial-v3-d8d7-2024-04-04": 4789, + "inimai---open-app-copy-c50c-2024-11-28": 4433, + "localized-paywall-e901-2024-04-04": 4534, + "price-tester-1b8e-2024-04-04": 5704, + "products-v3-60c5-2024-04-04": 5805, + "superwall-template-9f9f-2024-05-21": 6211, + "url-paywall-v3-3810-2024-04-04": 5603, + "video-v3-72b4-2024-04-04": 5958 }, - "preloadCompleteEventMs": 666, + "preloadCompleteEventMs": 1063, "run": 5 }, { - "allReadyMs": 5911, + "allReadyMs": 5695, "cold": true, - "configureMs": 1871, + "configureMs": 1936, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5911, - "data-v3-0ed9-2024-04-04": 4898, - "free-trial-v3-d8d7-2024-04-04": 5404, - "inimai---open-app-copy-c50c-2024-11-28": 3781, - "localized-paywall-e901-2024-04-04": 5455, - "price-tester-1b8e-2024-04-04": 3883, - "products-v3-60c5-2024-04-04": 5050, - "superwall-template-9f9f-2024-05-21": 4136, - "url-paywall-v3-3810-2024-04-04": 5202, - "video-v3-72b4-2024-04-04": 5607 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5695, + "data-v3-0ed9-2024-04-04": 5291, + "free-trial-v3-d8d7-2024-04-04": 4632, + "inimai---open-app-copy-c50c-2024-11-28": 3922, + "localized-paywall-e901-2024-04-04": 4734, + "price-tester-1b8e-2024-04-04": 4125, + "products-v3-60c5-2024-04-04": 3517, + "superwall-template-9f9f-2024-05-21": 3770, + "url-paywall-v3-3810-2024-04-04": 5037, + "video-v3-72b4-2024-04-04": 4886 }, - "preloadCompleteEventMs": 746, + "preloadCompleteEventMs": 757, "run": 6 }, { - "allReadyMs": 5981, + "allReadyMs": 6064, "cold": true, - "configureMs": 1566, + "configureMs": 1539, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5010, - "data-v3-0ed9-2024-04-04": 5419, - "free-trial-v3-d8d7-2024-04-04": 4655, - "inimai---open-app-copy-c50c-2024-11-28": 4249, - "localized-paywall-e901-2024-04-04": 5677, - "price-tester-1b8e-2024-04-04": 4807, - "products-v3-60c5-2024-04-04": 5981, - "superwall-template-9f9f-2024-05-21": 3996, - "url-paywall-v3-3810-2024-04-04": 5880, - "video-v3-72b4-2024-04-04": 4351 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5912, + "data-v3-0ed9-2024-04-04": 5507, + "free-trial-v3-d8d7-2024-04-04": 5102, + "inimai---open-app-copy-c50c-2024-11-28": 4342, + "localized-paywall-e901-2024-04-04": 6064, + "price-tester-1b8e-2024-04-04": 3988, + "products-v3-60c5-2024-04-04": 3531, + "superwall-template-9f9f-2024-05-21": 4645, + "url-paywall-v3-3810-2024-04-04": 3380, + "video-v3-72b4-2024-04-04": 5254 }, - "preloadCompleteEventMs": 1033, + "preloadCompleteEventMs": 1274, "run": 7 }, { - "allReadyMs": 5645, + "allReadyMs": 5892, "cold": true, - "configureMs": 1827, + "configureMs": 1876, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5645, - "data-v3-0ed9-2024-04-04": 4936, - "free-trial-v3-d8d7-2024-04-04": 3415, - "inimai---open-app-copy-c50c-2024-11-28": 3213, - "localized-paywall-e901-2024-04-04": 4582, - "price-tester-1b8e-2024-04-04": 3973, - "products-v3-60c5-2024-04-04": 4125, - "superwall-template-9f9f-2024-05-21": 3669, - "url-paywall-v3-3810-2024-04-04": 3872, - "video-v3-72b4-2024-04-04": 5037 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5790, + "data-v3-0ed9-2024-04-04": 5430, + "free-trial-v3-d8d7-2024-04-04": 4671, + "inimai---open-app-copy-c50c-2024-11-28": 4417, + "localized-paywall-e901-2024-04-04": 4772, + "price-tester-1b8e-2024-04-04": 4468, + "products-v3-60c5-2024-04-04": 5025, + "superwall-template-9f9f-2024-05-21": 4215, + "url-paywall-v3-3810-2024-04-04": 5892, + "video-v3-72b4-2024-04-04": 5126 }, - "preloadCompleteEventMs": 857, + "preloadCompleteEventMs": 1122, "run": 8 }, { - "allReadyMs": 5500, + "allReadyMs": 5991, "cold": true, - "configureMs": 1556, + "configureMs": 1796, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4487, - "data-v3-0ed9-2024-04-04": 4892, - "free-trial-v3-d8d7-2024-04-04": 3829, - "inimai---open-app-copy-c50c-2024-11-28": 3321, - "localized-paywall-e901-2024-04-04": 5500, - "price-tester-1b8e-2024-04-04": 4031, - "products-v3-60c5-2024-04-04": 3981, - "superwall-template-9f9f-2024-05-21": 3575, - "url-paywall-v3-3810-2024-04-04": 4183, - "video-v3-72b4-2024-04-04": 4589 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5991, + "data-v3-0ed9-2024-04-04": 4824, + "free-trial-v3-d8d7-2024-04-04": 3961, + "inimai---open-app-copy-c50c-2024-11-28": 4164, + "localized-paywall-e901-2024-04-04": 4418, + "price-tester-1b8e-2024-04-04": 4316, + "products-v3-60c5-2024-04-04": 5587, + "superwall-template-9f9f-2024-05-21": 5333, + "url-paywall-v3-3810-2024-04-04": 5738, + "video-v3-72b4-2024-04-04": 4925 }, - "preloadCompleteEventMs": 713, + "preloadCompleteEventMs": 1130, "run": 9 }, { - "allReadyMs": 5604, + "allReadyMs": 6522, "cold": true, - "configureMs": 1880, + "configureMs": 1875, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4991, - "data-v3-0ed9-2024-04-04": 5604, - "free-trial-v3-d8d7-2024-04-04": 4636, - "inimai---open-app-copy-c50c-2024-11-28": 3975, - "localized-paywall-e901-2024-04-04": 5194, - "price-tester-1b8e-2024-04-04": 4127, - "products-v3-60c5-2024-04-04": 4738, - "superwall-template-9f9f-2024-05-21": 3823, - "url-paywall-v3-3810-2024-04-04": 3518, - "video-v3-72b4-2024-04-04": 5144 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6522, + "data-v3-0ed9-2024-04-04": 6116, + "free-trial-v3-d8d7-2024-04-04": 4190, + "inimai---open-app-copy-c50c-2024-11-28": 4393, + "localized-paywall-e901-2024-04-04": 4291, + "price-tester-1b8e-2024-04-04": 5001, + "products-v3-60c5-2024-04-04": 4038, + "superwall-template-9f9f-2024-05-21": 4646, + "url-paywall-v3-3810-2024-04-04": 3785, + "video-v3-72b4-2024-04-04": 5711 }, - "preloadCompleteEventMs": 667, + "preloadCompleteEventMs": 1062, "run": 10 } ], "schemaVersion": 1, "stats": { - "coefficientOfVariationPct": 40.77487389535154, - "coldMeanMs": 8082.9, - "maxMs": 14606, - "meanMs": 8082.9, - "medianMs": 5946.0, - "minMs": 5500, + "coefficientOfVariationPct": 13.574533824343057, + "coldMeanMs": 6459.6, + "maxMs": 8184, + "meanMs": 6459.6, + "medianMs": 6133.5, + "minMs": 5695, "sampleCount": 10, - "stdDevMs": 3295.792282087369, + "stdDevMs": 876.8605869172642, "warmMeanMs": null }, "tier": "LOW" diff --git a/.benchmark/results/preload-benchmark-mid.json b/.benchmark/results/preload-benchmark-mid.json index 275098502..eec6f125d 100644 --- a/.benchmark/results/preload-benchmark-mid.json +++ b/.benchmark/results/preload-benchmark-mid.json @@ -4,233 +4,233 @@ "cpuCores": 3, "fingerprint": "google/sdk_gphone64_x86_64/emu64xa:14/UE1A.230829.036.A4/12096271:user/release-keys", "model": "sdk_gphone64_x86_64", - "sdkClassifiedTier": "low", + "sdkClassifiedTier": "mid", "sdkInt": 34 }, "iterationsPerRun": 1, "runCount": 10, "samples": [ { - "allReadyMs": 9096, + "allReadyMs": 7259, "cold": true, - "configureMs": 2698, + "configureMs": 1947, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 8487, - "data-v3-0ed9-2024-04-04": 7204, - "free-trial-v3-d8d7-2024-04-04": 7878, - "inimai---open-app-copy-c50c-2024-11-28": 5677, - "localized-paywall-e901-2024-04-04": 9096, - "price-tester-1b8e-2024-04-04": 7980, - "products-v3-60c5-2024-04-04": 6543, - "superwall-template-9f9f-2024-05-21": 5984, - "url-paywall-v3-3810-2024-04-04": 6238, - "video-v3-72b4-2024-04-04": 8943 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4869, + "data-v3-0ed9-2024-04-04": 6497, + "free-trial-v3-d8d7-2024-04-04": 6752, + "inimai---open-app-copy-c50c-2024-11-28": 4972, + "localized-paywall-e901-2024-04-04": 6854, + "price-tester-1b8e-2024-04-04": 7006, + "products-v3-60c5-2024-04-04": 7259, + "superwall-template-9f9f-2024-05-21": 5226, + "url-paywall-v3-3810-2024-04-04": 6955, + "video-v3-72b4-2024-04-04": 6242 }, - "preloadCompleteEventMs": 1845, + "preloadCompleteEventMs": 1456, "run": 1 }, { - "allReadyMs": 7537, + "allReadyMs": 5954, "cold": true, - "configureMs": 1834, + "configureMs": 1705, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6166, - "data-v3-0ed9-2024-04-04": 7537, - "free-trial-v3-d8d7-2024-04-04": 6369, - "inimai---open-app-copy-c50c-2024-11-28": 5042, - "localized-paywall-e901-2024-04-04": 6624, - "price-tester-1b8e-2024-04-04": 6827, - "products-v3-60c5-2024-04-04": 6978, - "superwall-template-9f9f-2024-05-21": 4835, - "url-paywall-v3-3810-2024-04-04": 5349, - "video-v3-72b4-2024-04-04": 7181 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5499, + "data-v3-0ed9-2024-04-04": 5954, + "free-trial-v3-d8d7-2024-04-04": 4887, + "inimai---open-app-copy-c50c-2024-11-28": 4015, + "localized-paywall-e901-2024-04-04": 4634, + "price-tester-1b8e-2024-04-04": 5242, + "products-v3-60c5-2024-04-04": 4429, + "superwall-template-9f9f-2024-05-21": 4226, + "url-paywall-v3-3810-2024-04-04": 5039, + "video-v3-72b4-2024-04-04": 5140 }, - "preloadCompleteEventMs": 1166, + "preloadCompleteEventMs": 1188, "run": 2 }, { - "allReadyMs": 10535, + "allReadyMs": 6259, "cold": true, - "configureMs": 2912, + "configureMs": 1746, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 10535, - "data-v3-0ed9-2024-04-04": 9977, - "free-trial-v3-d8d7-2024-04-04": 8024, - "inimai---open-app-copy-c50c-2024-11-28": 7568, - "localized-paywall-e901-2024-04-04": 7670, - "price-tester-1b8e-2024-04-04": 8126, - "products-v3-60c5-2024-04-04": 8380, - "superwall-template-9f9f-2024-05-21": 7112, - "url-paywall-v3-3810-2024-04-04": 7366, - "video-v3-72b4-2024-04-04": 8584 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4932, + "data-v3-0ed9-2024-04-04": 5388, + "free-trial-v3-d8d7-2024-04-04": 6259, + "inimai---open-app-copy-c50c-2024-11-28": 4070, + "localized-paywall-e901-2024-04-04": 4578, + "price-tester-1b8e-2024-04-04": 4424, + "products-v3-60c5-2024-04-04": 3512, + "superwall-template-9f9f-2024-05-21": 4323, + "url-paywall-v3-3810-2024-04-04": 6003, + "video-v3-72b4-2024-04-04": 5034 }, - "preloadCompleteEventMs": 2304, + "preloadCompleteEventMs": 1143, "run": 3 }, { - "allReadyMs": 7187, + "allReadyMs": 3995, "cold": true, - "configureMs": 1759, + "configureMs": 1128, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6730, - "data-v3-0ed9-2024-04-04": 7187, - "free-trial-v3-d8d7-2024-04-04": 5158, - "inimai---open-app-copy-c50c-2024-11-28": 4548, - "localized-paywall-e901-2024-04-04": 6325, - "price-tester-1b8e-2024-04-04": 5005, - "products-v3-60c5-2024-04-04": 5463, - "superwall-template-9f9f-2024-05-21": 4803, - "url-paywall-v3-3810-2024-04-04": 4193, - "video-v3-72b4-2024-04-04": 5361 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 3844, + "data-v3-0ed9-2024-04-04": 3692, + "free-trial-v3-d8d7-2024-04-04": 3237, + "inimai---open-app-copy-c50c-2024-11-28": 2526, + "localized-paywall-e901-2024-04-04": 3086, + "price-tester-1b8e-2024-04-04": 2779, + "products-v3-60c5-2024-04-04": 3439, + "superwall-template-9f9f-2024-05-21": 2678, + "url-paywall-v3-3810-2024-04-04": 3338, + "video-v3-72b4-2024-04-04": 3995 }, - "preloadCompleteEventMs": 1421, + "preloadCompleteEventMs": 683, "run": 4 }, { - "allReadyMs": 6036, + "allReadyMs": 4709, "cold": true, - "configureMs": 1208, + "configureMs": 1655, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5529, - "data-v3-0ed9-2024-04-04": 6036, - "free-trial-v3-d8d7-2024-04-04": 4717, - "inimai---open-app-copy-c50c-2024-11-28": 4311, - "localized-paywall-e901-2024-04-04": 4463, - "price-tester-1b8e-2024-04-04": 3804, - "products-v3-60c5-2024-04-04": 4920, - "superwall-template-9f9f-2024-05-21": 4159, - "url-paywall-v3-3810-2024-04-04": 5072, - "video-v3-72b4-2024-04-04": 5224 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4709, + "data-v3-0ed9-2024-04-04": 4405, + "free-trial-v3-d8d7-2024-04-04": 4102, + "inimai---open-app-copy-c50c-2024-11-28": 3187, + "localized-paywall-e901-2024-04-04": 3697, + "price-tester-1b8e-2024-04-04": 3900, + "products-v3-60c5-2024-04-04": 3849, + "superwall-template-9f9f-2024-05-21": 3494, + "url-paywall-v3-3810-2024-04-04": 3288, + "video-v3-72b4-2024-04-04": 4507 }, - "preloadCompleteEventMs": 1198, + "preloadCompleteEventMs": 904, "run": 5 }, { - "allReadyMs": 6976, + "allReadyMs": 4871, "cold": true, - "configureMs": 1755, + "configureMs": 920, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 6976, - "data-v3-0ed9-2024-04-04": 6418, - "free-trial-v3-d8d7-2024-04-04": 4639, - "inimai---open-app-copy-c50c-2024-11-28": 5199, - "localized-paywall-e901-2024-04-04": 5351, - "price-tester-1b8e-2024-04-04": 4690, - "products-v3-60c5-2024-04-04": 4842, - "superwall-template-9f9f-2024-05-21": 5046, - "url-paywall-v3-3810-2024-04-04": 4436, - "video-v3-72b4-2024-04-04": 5503 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4871, + "data-v3-0ed9-2024-04-04": 4618, + "free-trial-v3-d8d7-2024-04-04": 4413, + "inimai---open-app-copy-c50c-2024-11-28": 3551, + "localized-paywall-e901-2024-04-04": 4261, + "price-tester-1b8e-2024-04-04": 3855, + "products-v3-60c5-2024-04-04": 3501, + "superwall-template-9f9f-2024-05-21": 3703, + "url-paywall-v3-3810-2024-04-04": 4210, + "video-v3-72b4-2024-04-04": 4719 }, - "preloadCompleteEventMs": 1563, + "preloadCompleteEventMs": 893, "run": 6 }, { - "allReadyMs": 7608, + "allReadyMs": 4903, "cold": true, - "configureMs": 1865, + "configureMs": 1661, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 7355, - "data-v3-0ed9-2024-04-04": 7608, - "free-trial-v3-d8d7-2024-04-04": 6643, - "inimai---open-app-copy-c50c-2024-11-28": 5933, - "localized-paywall-e901-2024-04-04": 6288, - "price-tester-1b8e-2024-04-04": 6744, - "products-v3-60c5-2024-04-04": 7049, - "superwall-template-9f9f-2024-05-21": 6136, - "url-paywall-v3-3810-2024-04-04": 6491, - "video-v3-72b4-2024-04-04": 6896 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4700, + "data-v3-0ed9-2024-04-04": 4903, + "free-trial-v3-d8d7-2024-04-04": 4043, + "inimai---open-app-copy-c50c-2024-11-28": 3437, + "localized-paywall-e901-2024-04-04": 3892, + "price-tester-1b8e-2024-04-04": 4195, + "products-v3-60c5-2024-04-04": 4397, + "superwall-template-9f9f-2024-05-21": 3639, + "url-paywall-v3-3810-2024-04-04": 4145, + "video-v3-72b4-2024-04-04": 4296 }, - "preloadCompleteEventMs": 1025, + "preloadCompleteEventMs": 980, "run": 7 }, { - "allReadyMs": 5963, + "allReadyMs": 4768, "cold": true, - "configureMs": 1686, + "configureMs": 1505, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5303, - "data-v3-0ed9-2024-04-04": 5759, - "free-trial-v3-d8d7-2024-04-04": 5049, - "inimai---open-app-copy-c50c-2024-11-28": 4084, - "localized-paywall-e901-2024-04-04": 5405, - "price-tester-1b8e-2024-04-04": 4491, - "products-v3-60c5-2024-04-04": 5963, - "superwall-template-9f9f-2024-05-21": 4339, - "url-paywall-v3-3810-2024-04-04": 3678, - "video-v3-72b4-2024-04-04": 4795 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4414, + "data-v3-0ed9-2024-04-04": 4110, + "free-trial-v3-d8d7-2024-04-04": 3702, + "inimai---open-app-copy-c50c-2024-11-28": 3550, + "localized-paywall-e901-2024-04-04": 3806, + "price-tester-1b8e-2024-04-04": 4617, + "products-v3-60c5-2024-04-04": 4718, + "superwall-template-9f9f-2024-05-21": 3449, + "url-paywall-v3-3810-2024-04-04": 4566, + "video-v3-72b4-2024-04-04": 4768 }, - "preloadCompleteEventMs": 1083, + "preloadCompleteEventMs": 934, "run": 8 }, { - "allReadyMs": 5148, + "allReadyMs": 6436, "cold": true, - "configureMs": 1856, + "configureMs": 1262, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5047, - "data-v3-0ed9-2024-04-04": 4387, - "free-trial-v3-d8d7-2024-04-04": 4539, - "inimai---open-app-copy-c50c-2024-11-28": 3930, - "localized-paywall-e901-2024-04-04": 5148, - "price-tester-1b8e-2024-04-04": 3169, - "products-v3-60c5-2024-04-04": 3321, - "superwall-template-9f9f-2024-05-21": 3625, - "url-paywall-v3-3810-2024-04-04": 4641, - "video-v3-72b4-2024-04-04": 4793 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5627, + "data-v3-0ed9-2024-04-04": 6436, + "free-trial-v3-d8d7-2024-04-04": 5222, + "inimai---open-app-copy-c50c-2024-11-28": 4513, + "localized-paywall-e901-2024-04-04": 4867, + "price-tester-1b8e-2024-04-04": 4765, + "products-v3-60c5-2024-04-04": 5374, + "superwall-template-9f9f-2024-05-21": 4664, + "url-paywall-v3-3810-2024-04-04": 4310, + "video-v3-72b4-2024-04-04": 5424 }, - "preloadCompleteEventMs": 731, + "preloadCompleteEventMs": 1784, "run": 9 }, { - "allReadyMs": 5949, + "allReadyMs": 5049, "cold": true, - "configureMs": 1684, + "configureMs": 1717, "index": 0, "paywallCount": 10, "perPaywallReadyMs": { - "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 5949, - "data-v3-0ed9-2024-04-04": 5239, - "free-trial-v3-d8d7-2024-04-04": 4782, - "inimai---open-app-copy-c50c-2024-11-28": 4070, - "localized-paywall-e901-2024-04-04": 4883, - "price-tester-1b8e-2024-04-04": 4172, - "products-v3-60c5-2024-04-04": 5492, - "superwall-template-9f9f-2024-05-21": 3918, - "url-paywall-v3-3810-2024-04-04": 5391, - "video-v3-72b4-2024-04-04": 4376 + "based-on-teleprompter-bigvu-template-2b72-2024-04-04": 4746, + "data-v3-0ed9-2024-04-04": 5049, + "free-trial-v3-d8d7-2024-04-04": 3733, + "inimai---open-app-copy-c50c-2024-11-28": 3378, + "localized-paywall-e901-2024-04-04": 4138, + "price-tester-1b8e-2024-04-04": 3936, + "products-v3-60c5-2024-04-04": 4037, + "superwall-template-9f9f-2024-05-21": 3581, + "url-paywall-v3-3810-2024-04-04": 3885, + "video-v3-72b4-2024-04-04": 4341 }, - "preloadCompleteEventMs": 1058, + "preloadCompleteEventMs": 932, "run": 10 } ], "schemaVersion": 1, "stats": { - "coefficientOfVariationPct": 22.531144514710828, - "coldMeanMs": 7203.5, - "maxMs": 10535, - "meanMs": 7203.5, - "medianMs": 7081.5, - "minMs": 5148, + "coefficientOfVariationPct": 18.52162269782826, + "coldMeanMs": 5420.3, + "maxMs": 7259, + "meanMs": 5420.3, + "medianMs": 4976.0, + "minMs": 3995, "sampleCount": 10, - "stdDevMs": 1623.0309951171946, + "stdDevMs": 1003.9275150903852, "warmMeanMs": null }, "tier": "MID" diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 95275ca45..2ea735435 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.2% \ No newline at end of file +branches36.3% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index ec50def2a..9076cc635 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.4% \ No newline at end of file +coverage45.6% \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fbf3527..8618d9c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## Unreleased + +## Enhancements +- Significantly improves paywall loading +- Implicit placements that map to no campaign (e.g. `app_launch`, `session_start`) no longer occupy the presentation queue ahead of `register()` calls while waiting for entitlements. +- Config refreshes now diff paywalls by cache key and only evict changed ones from the request cache, so unchanged preloaded paywalls stay warm across refreshes. +- The loading shimmer no longer forces a software layer. +- Logs are now delivered on a dedicated background thread, so neither the console write nor a delegate's `handleLog` implementation costs frames on the thread that produced the log. Ordering is preserved, and one log's output can no longer interleave with another's. +- Deciding whether a log is worth building no longer resolves the dependency container, making suppressed logs close to free. + +## Fixes +- A failed subresource (image, font, analytics beacon) no longer restarts the whole paywall page load or counts toward fallback-URL attempts; only main-frame failures (and failures of the paywall runtime bundle) do. Transient main-frame failures are now retried (bounded) on Android 8+, where previously they were not retried at all. +- The popup presentation style's entrance animation no longer stretches to the paywall's configured loading delay, and the delay no longer postpones hiding the spinner after a purchase completes. +- `paywall_resourceLoad_fail` events now report the failing resource's URL. +- Console logs carrying an `info` map no longer drop the log's message, and an empty `info` map no longer prints an empty `info: {}` line in its place. +- A `SuperwallDelegate.handleLog` implementation that throws no longer propagates the exception into whatever SDK code produced the log. + +## Breaking Changes +- Removes `PaywallViewCache.entries`, a non-functional property that only ever contained a stale construction-time snapshot. +- `SuperwallDelegate.handleLog` is now always called on a background thread. It could previously be called on any thread, including main, so implementations that touch UI directly must now dispatch to the main thread themselves. + ## 2.8.1 ## Fixes diff --git a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt index 24df1b021..ecedcb0f2 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt @@ -150,6 +150,7 @@ class ConfigManagerTests { coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { preloadPaywallsByNames(any(), any()) } just Runs coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + every { invalidatePreloadFingerprint() } just Runs } val configManager = @@ -192,6 +193,7 @@ class ConfigManagerTests { coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { preloadPaywallsByNames(any(), any()) } just Runs coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + every { invalidatePreloadFingerprint() } just Runs } val configManager = ConfigManagerUnderTest( diff --git a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt index f9a54ed5e..300fef0ed 100644 --- a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt +++ b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt @@ -27,6 +27,9 @@ class SdkContextImpl( } override suspend fun fetchAssignments() { + // Await config without a deadline first (identity now runs concurrently with the + // config fetch); the 30s budget applies only to the assignments call itself. + configManager().configState.awaitFirstValidConfig() withTimeoutOrNull(30.seconds) { configManager().getAssignments() } diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index bdd4659ac..cdcc333c4 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -87,7 +87,10 @@ import com.superwall.sdk.web.WebPaywallRedeemer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -715,11 +718,20 @@ class Superwall( dependencyContainer.storage.recordAppInstall { track(event = it) } - // Implicitly wait - dependencyContainer.configManager.fetchConfiguration() - dependencyContainer.identityManager.configure( - neverCalledStaticConfig = dependencyContainer.storage.neverCalledStaticConfig, - ) + // Identity awaits config internally where it needs it, so both + // can run concurrently. Implicitly wait for both. + coroutineScope { + listOf( + async { + dependencyContainer.configManager.fetchConfiguration() + }, + async { + dependencyContainer.identityManager.configure( + neverCalledStaticConfig = dependencyContainer.storage.neverCalledStaticConfig, + ) + }, + ).awaitAll() + } }.toResult().fold({ CoroutineScope(Dispatchers.Main).launch { completion?.invoke(Result.success(Unit)) diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt index d284724c2..706b81c49 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt @@ -4,6 +4,7 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.trackable.Trackable import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.analytics.superwall.SuperwallEventInfo +import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -152,6 +153,22 @@ private suspend fun Superwall.internallyHandleImplicitTrigger( type = PresentationRequestType.Presentation, ) + // Only DontTriggerPaywall may short-circuit here: any other outcome must + // still be re-evaluated after the waits, since fresh config can change + // the trigger set. + if (dependencyContainer.configManager.configState.value is ConfigState.Retrieved) { + val fastPathOutcome = + TrackingLogic.canTriggerPaywall( + event, + dependencyContainer.configManager.triggersByEventName.keys + .toSet(), + paywallView, + ) + if (fastPathOutcome == TrackingLogic.ImplicitTriggerOutcome.DontTriggerPaywall) { + return@withErrorTracking + } + } + try { waitForEntitlementsAndConfig(request, null, dependencyContainer) } catch (e: Throwable) { diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt index 3d3a91418..d5128505d 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt @@ -27,6 +27,8 @@ import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -36,6 +38,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @@ -142,11 +146,14 @@ class GoogleBillingWrapper( } } - override suspend fun queryAllPurchases(): List { - val apps = retryOrNull(QUERY_PURCHASES_MAX_RETRIES) { queryType(ProductType.INAPP).getOrThrow() } - val subs = retryOrNull(QUERY_PURCHASES_MAX_RETRIES) { queryType(ProductType.SUBS).getOrThrow() } - return (apps ?: emptyList()) + (subs ?: emptyList()) - } + override suspend fun queryAllPurchases(): List = + coroutineScope { + val apps = + async { retryOrNull(QUERY_PURCHASES_MAX_RETRIES) { queryType(ProductType.INAPP).getOrThrow() } } + val subs = + async { retryOrNull(QUERY_PURCHASES_MAX_RETRIES) { queryType(ProductType.SUBS).getOrThrow() } } + (apps.await() ?: emptyList()) + (subs.await() ?: emptyList()) + } override suspend fun consume(purchaseToken: String): Result = suspendCoroutine { cont -> @@ -329,33 +336,38 @@ class GoogleBillingWrapper( decomposedProductIdsBySubscriptionId: MutableMap>, callback: GetStoreProductsCallback, ) { - val typesRemaining = types.toMutableSet() - val type = typesRemaining.firstOrNull()?.also { typesRemaining.remove(it) } + if (types.isEmpty()) { + callback.onReceived(collectedStoreProducts) + return + } + + // Each type resolves exactly once, so onReceived can only fire once every type + // has succeeded; the flag keeps onError to a single delivery when several types fail. + val collected = ConcurrentLinkedQueue() + val typesRemaining = AtomicInteger(types.size) + val errored = AtomicBoolean(false) - type?.let { + types.forEach { type -> queryProductDetailsAsync( - productType = it, + productType = type, subscriptionIds = subscriptionIds, decomposedProductIdsBySubscriptionId = decomposedProductIdsBySubscriptionId, onReceive = { storeProducts -> dispatch { - getProductsOfTypes( - subscriptionIds, - typesRemaining, - collectedStoreProducts = collectedStoreProducts + storeProducts, - decomposedProductIdsBySubscriptionId = decomposedProductIdsBySubscriptionId, - callback, - ) + collected.addAll(storeProducts) + if (typesRemaining.decrementAndGet() == 0) { + callback.onReceived(collectedStoreProducts + collected) + } } }, - onError = { + onError = { error -> dispatch { - callback.onError(it) + if (errored.compareAndSet(false, true)) { + callback.onError(error) + } } }, ) - } ?: run { - callback.onReceived(collectedStoreProducts) } } diff --git a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt index 02d6cc340..13bec034b 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt @@ -199,43 +199,56 @@ class PaywallPreload( ) } + internal fun invalidatePreloadFingerprint() { + lastFingerprint.set(null) + } + internal suspend fun removeUnusedPaywallVCsFromCache( oldConfig: Config, newConfig: Config, ) { - val oldPaywalls = oldConfig.paywalls - val newPaywalls = newConfig.paywalls - val presentedPaywallId = paywallManager.currentView ?.state ?.paywall ?.identifier - val oldPaywallCacheIds: Map = - oldPaywalls - .map { it.identifier to it.cacheKey } - .toMap() - val newPaywallCacheIds: Map = - newPaywalls.map { it.identifier to it.cacheKey }.toMap() - - val removedIds: Set = - (oldPaywallCacheIds.keys - newPaywallCacheIds.keys).toSet() - - val changedIds = - removedIds + - newPaywalls - .filter { - val oldCacheKey = oldPaywallCacheIds[it.identifier] - val keyChanged = oldCacheKey != newPaywallCacheIds[it.identifier] - oldCacheKey != null && keyChanged - }.map { it.identifier } - presentedPaywallId - val toRemove = changedIds.toSet().filterNotNull() + val toRemove = + (changedPaywallIds(oldConfig, newConfig) - presentedPaywallId).filterNotNull() toRemove.forEach { paywallManager.removePaywallView(it) } if (toRemove.isNotEmpty()) { - lastFingerprint.set(null) + invalidatePreloadFingerprint() + } + } + + companion object { + /** + * Identifiers of paywalls that were removed in [newConfig] or whose + * [CacheKey] differs from [oldConfig] — i.e. whose cached content is stale. + */ + internal fun changedPaywallIds( + oldConfig: Config, + newConfig: Config, + ): Set { + val oldPaywallCacheIds: Map = + oldConfig.paywalls + .map { it.identifier to it.cacheKey } + .toMap() + val newPaywallCacheIds: Map = + newConfig.paywalls.map { it.identifier to it.cacheKey }.toMap() + + val removedIds: Set = + (oldPaywallCacheIds.keys - newPaywallCacheIds.keys).toSet() + + return removedIds + + newConfig.paywalls + .filter { + val oldCacheKey = oldPaywallCacheIds[it.identifier] + val keyChanged = oldCacheKey != newPaywallCacheIds[it.identifier] + oldCacheKey != null && keyChanged + }.map { it.identifier } } } } diff --git a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt index 4368b2306..0d5b7ad21 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt @@ -3,6 +3,7 @@ package com.superwall.sdk.config.models import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.ConfigContext import com.superwall.sdk.config.ConfigLogic +import com.superwall.sdk.config.PaywallPreload import com.superwall.sdk.config.options.computedShouldPreload import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope @@ -17,16 +18,15 @@ import com.superwall.sdk.misc.primitives.TypedAction import com.superwall.sdk.misc.then import com.superwall.sdk.misc.thenIf import com.superwall.sdk.models.config.Config -import com.superwall.sdk.models.enrichment.Enrichment import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.storage.DisableVerboseEvents import com.superwall.sdk.storage.LatestConfig import com.superwall.sdk.storage.LatestEnrichment import com.superwall.sdk.web.WebPaywallRedeemer import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.atomic.AtomicInteger import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -150,20 +150,14 @@ sealed class ConfigState { val attributesDeferred = scope.async { factory.makeSessionDeviceAttributes() } - val (configResultAny, enrichmentResultAny) = - listOf(configDeferred, enrichmentDeferred).awaitAll() + @Suppress("UNCHECKED_CAST") + val configResult = configDeferred.await() as Either val attributes = attributesDeferred.await() scope.launch { @Suppress("UNCHECKED_CAST") track(InternalSuperwallEvent.DeviceAttributes(attributes as HashMap)) } - @Suppress("UNCHECKED_CAST") - val configResult = configResultAny as Either - - @Suppress("UNCHECKED_CAST") - val enrichmentResult = enrichmentResultAny as Either - configResult .then { config -> track( @@ -175,7 +169,13 @@ sealed class ConfigState { ), ) }.then { config -> immediate(ApplyConfig(config)) } - .thenIf(testMode?.isTestMode != true) { + .then { config -> + // Fresh installs have no stored enrichment fallback, so give the + // in-flight enrichment a bounded window to land before publishing + // Retrieved; await() is idempotent, the retry launch below reuses it. + withTimeoutOrNull(1.seconds) { enrichmentDeferred.await() } + update(Updates.SetRetrieved(config)) + }.thenIf(testMode?.isTestMode != true) { sideEffect { webPaywallRedeemer().redeem(WebPaywallRedeemer.RedeemType.Existing) } @@ -183,24 +183,28 @@ sealed class ConfigState { if (testMode?.isTestMode != true && options.computedShouldPreload(deviceHelper.deviceTier) ) { + // Pure cache warm — StoreManager caches products and fetches + // per-paywall on demand, so this must not gate Retrieved. val productIds = config.paywalls.flatMap { it.productIds }.toSet() - try { - storeManager.products(productIds) - } catch (e: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.productsManager, - message = "Failed to preload products", - error = e, - ) + scope.launch { + try { + storeManager.products(productIds) + } catch (e: Throwable) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.productsManager, + message = "Failed to preload products", + error = e, + ) + } } } - config - }.then { config -> - update(Updates.SetRetrieved(config)) }.then { - if (isEnrichmentFromCache || enrichmentResult.getThrowable() != null) { - scope.launch { deviceHelper.getEnrichment(6, 1.seconds) } + scope.launch { + val enrichmentResult = enrichmentDeferred.await() + if (isEnrichmentFromCache || enrichmentResult.getThrowable() != null) { + deviceHelper.getEnrichment(6, 1.seconds) + } } }.fold( onSuccess = { @@ -252,9 +256,13 @@ sealed class ConfigState { result .then { newConfig -> - paywallManager.resetPaywallRequestCache() val previous = state.value.getConfig() if (previous != null) { + val changedPaywalls = PaywallPreload.changedPaywallIds(previous, newConfig) + if (changedPaywalls.isNotEmpty()) { + paywallManager.resetPaywallRequestCache(changedPaywalls) + paywallPreload.invalidatePreloadFingerprint() + } paywallPreload.removeUnusedPaywallVCsFromCache(previous, newConfig) } newConfig @@ -381,7 +389,13 @@ sealed class ConfigState { try { assignments .getAssignments(triggers) - .then { effect(PreloadIfEnabled) } + .then { + // The preload fingerprint carries no assignment state, so a + // fresh server assignment must invalidate it for the preload + // pass to run; already-cached paywalls are skipped there. + paywallPreload.invalidatePreloadFingerprint() + effect(PreloadIfEnabled) + } .onError { err -> Logger.debug( logLevel = LogLevel.error, diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt index bb7462a60..2752f9115 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt @@ -8,8 +8,33 @@ import com.superwall.sdk.paywall.presentation.PaywallInfo import java.net.URI class SuperwallDelegateAdapter { + companion object { + /** + * Mirrors "either delegate is set" so that the logger can decide whether a log is worth + * building without resolving the dependency container on every one of its call sites. + */ + @Volatile + internal var hasAnyDelegate: Boolean = false + private set + } + + init { + // A freshly constructed adapter holds no delegates yet, which keeps the flag correct + // across teardown/configure cycles. + hasAnyDelegate = false + } + var kotlinDelegate: SuperwallDelegate? = null + set(value) { + field = value + hasAnyDelegate = value != null || javaDelegate != null + } + var javaDelegate: SuperwallDelegateJava? = null + set(value) { + field = value + hasAnyDelegate = value != null || kotlinDelegate != null + } fun handleCustomPaywallAction(name: String) { kotlinDelegate?.handleCustomPaywallAction(name) diff --git a/superwall/src/main/java/com/superwall/sdk/logger/Logger.kt b/superwall/src/main/java/com/superwall/sdk/logger/Logger.kt index 8be47313a..1041ac210 100644 --- a/superwall/src/main/java/com/superwall/sdk/logger/Logger.kt +++ b/superwall/src/main/java/com/superwall/sdk/logger/Logger.kt @@ -2,6 +2,48 @@ package com.superwall.sdk.logger import com.superwall.sdk.Superwall import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.delegate.SuperwallDelegateAdapter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Delivers logs on a single background thread so that neither the console write nor the + * customer's `SuperwallDelegate.handleLog` runs on the thread that produced the log. + * + * A single-threaded dispatcher drains its queue in FIFO order, so log ordering is preserved + * and the multi-line console output of one log can no longer interleave with another's. + */ +internal object LogQueue { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) + + /** + * Set by tests so that assertions don't have to await the log thread. + */ + @Volatile + internal var synchronous: Boolean = false + + fun post(block: () -> Unit) { + if (synchronous) { + runSafely(block) + } else { + scope.launch { runSafely(block) } + } + } + + /** + * A throwing delegate must not take down the log thread, and must not be reported through + * [Logger] itself as that would re-enter this queue. + */ + private fun runSafely(block: () -> Unit) { + try { + block() + } catch (e: Throwable) { + println("[!!Superwall] Failed to deliver log: ${e.localizedMessage}") + } + } +} interface Loggable { companion object { @@ -23,29 +65,34 @@ interface Loggable { return exceedsCurrentLogLevel && (isInScope || allLogsActive) } - fun debug( + // True when a delegate will consume the log or it will be printed, + // i.e. when it is worth building the log's message and info. + @PublishedApi + internal fun willLog( logLevel: LogLevel, scope: LogScope, - message: String = "", - info: Map? = mapOf(), - error: Throwable? = null, - ) { -// Task.detached(priority = Task.Priority.utility) { - val output: MutableList = mutableListOf() - val dumping: MutableMap = mutableMapOf() - - message.let { output.add(it) } + ): Boolean = SuperwallDelegateAdapter.hasAnyDelegate || shouldPrint(logLevel, scope) - info?.let { - output.add(it.toString()) - dumping["info"] = it - } - - error?.let { - output.add(it.localizedMessage ?: "") - dumping["error"] = it + @PublishedApi + internal fun emit( + logLevel: LogLevel, + scope: LogScope, + message: String, + info: Map?, + error: Throwable?, + ) { + LogQueue.post { + deliver(logLevel, scope, message, info, error) } + } + private fun deliver( + logLevel: LogLevel, + scope: LogScope, + message: String, + info: Map?, + error: Throwable?, + ) { if (Superwall.initialized) { Superwall.instance.dependencyContainer.delegateAdapter.handleLog( level = logLevel.toString(), @@ -60,18 +107,44 @@ interface Loggable { return } - val name = - "\n${logLevel.getDescriptionEmoji()} [!!Superwall] [$scope] $logLevel${if (message != null) ": $message" else ""}\n" + println( + "\n${logLevel.getDescriptionEmoji()} [!!Superwall] [$scope] $logLevel: $message\n", + ) + + info?.takeIf { it.isNotEmpty() }?.let { println("info: $it") } + error?.let { println("error: $it") } + } + + /** + * Note that [info] is read on the log thread, so it must not be mutated after being + * passed in. + */ + fun debug( + logLevel: LogLevel, + scope: LogScope, + message: String = "", + info: Map? = mapOf(), + error: Throwable? = null, + ) { + emit(logLevel, scope, message, info, error) + } - if (dumping.isEmpty()) { - println(name) - } else { - dumping.forEach { (key, value) -> - println("$key: $value") - } + /** + * Builds [message] and [info] only when something will consume the log. Note that + * [info] is read on the log thread, so it must not be mutated after being passed in. + */ + inline fun debug( + logLevel: LogLevel, + scope: LogScope, + error: Throwable? = null, + info: () -> Map? = { mapOf() }, + message: () -> String, + ) { + if (!willLog(logLevel, scope)) { + return } + emit(logLevel, scope, message(), info(), error) } -// } } } @@ -90,4 +163,14 @@ object Logger : Loggable { ) { Loggable.debug(logLevel, scope, message, info, error) } + + inline fun debug( + logLevel: LogLevel, + scope: LogScope, + error: Throwable? = null, + info: () -> Map? = { null }, + message: () -> String, + ) { + Loggable.debug(logLevel, scope, error, info, message) + } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt index bba42bb30..d8d7f8b54 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt @@ -120,4 +120,8 @@ class PaywallManager( internal fun resetPaywallRequestCache() { paywallRequestManager.resetCache() } + + internal fun resetPaywallRequestCache(identifiers: Set) { + paywallRequestManager.removeCachedPaywalls(identifiers) + } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt index 6376a4cff..1319ab40c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt @@ -11,10 +11,6 @@ import com.superwall.sdk.paywall.view.PaywallShimmerView import com.superwall.sdk.paywall.view.PaywallView import com.superwall.sdk.paywall.view.ShimmerView import com.superwall.sdk.paywall.view.ViewStorage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking class PaywallViewCache( private val appCtx: Context, @@ -25,6 +21,8 @@ class PaywallViewCache( ) { private val ctx: Context get() = activityProvider.getCurrentActivity() ?: appCtx + + @Volatile private var _activePaywallVcKey: String? = null private val loadingView: LoadingView = LoadingView(context = ctx, loadingColor = loadingColor) private val shimmerView: ShimmerView = ShimmerView(context = ctx) @@ -34,38 +32,28 @@ class PaywallViewCache( store.storeView(ShimmerView.TAG, shimmerView) } - private val singleThreadContext = Dispatchers.IO - - val entries = - store.views.entries.associate { it.key to it.value } - - fun getAllPaywallViews(): List = - runBlocking(singleThreadContext) { - store.all().filterIsInstance().toList() - } + fun getAllPaywallViews(): List = store.all().filterIsInstance().toList() var activePaywallVcKey: String? - get() = runBlocking(singleThreadContext) { _activePaywallVcKey } + get() = _activePaywallVcKey set(value) { - CoroutineScope(singleThreadContext).launch { _activePaywallVcKey = value }.apply { } + _activePaywallVcKey = value } val activePaywallView: PaywallView? - get() = runBlocking(singleThreadContext) { _activePaywallVcKey?.let { store.retrieveView(it) as PaywallView? } } + get() = _activePaywallVcKey?.let { store.retrieveView(it) as PaywallView? } fun save( paywallView: PaywallView, identifier: PaywallIdentifier, ) { - CoroutineScope(singleThreadContext).launch { - store.storeView( - PaywallCacheLogic.key( - identifier, - locale = deviceHelper.locale, - ), - paywallView, - ) - } + store.storeView( + PaywallCacheLogic.key( + identifier, + locale = deviceHelper.locale, + ), + paywallView, + ) } fun acquireLoadingView(): PaywallPurchaseLoadingView { @@ -89,32 +77,25 @@ class PaywallViewCache( } fun getPaywallView(key: String): PaywallView? = - runBlocking(singleThreadContext) { - try { - store.retrieveView(key) as PaywallView? - } catch (e: Throwable) { - null - } + try { + store.retrieveView(key) as PaywallView? + } catch (e: Throwable) { + null } fun removePaywallView(identifier: PaywallIdentifier) { - CoroutineScope(singleThreadContext).launch { - store.removeView( - PaywallCacheLogic.key( - identifier, - locale = deviceHelper.locale, - ), - ) - } + store.removeView( + PaywallCacheLogic.key( + identifier, + locale = deviceHelper.locale, + ), + ) } fun removeAll() { - CoroutineScope(singleThreadContext).launch { - store.views.entries.forEach { it -> - val key = it.key - if (key != _activePaywallVcKey) { - store.removeView(key) - } + store.views.keys.forEach { key -> + if (key != _activePaywallVcKey) { + store.removeView(key) } } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt index 5cec969d6..d9dee297e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt @@ -40,53 +40,59 @@ suspend fun Superwall.presentPaywallView( debugInfo: Map, request: PresentationRequest, paywallStatePublisher: MutableSharedFlow, -) = withContext(Dispatchers.Main) { +) { val trackedEvent = InternalSuperwallEvent.PresentationRequest( eventData = request.presentationInfo.eventData, type = request.flags.type, status = PaywallPresentationRequestStatus.Presentation, statusReason = null, - factory = this@presentPaywallView.dependencyContainer, + factory = dependencyContainer, ) - track(trackedEvent) + // Launched before hopping to Main so the track gets a scheduling head start + // and lands ahead of the paywall-open events emitted during presentation. + ioScope.launch { + track(trackedEvent) + } - try { - paywallView.present( - presenter = presenter, - request = request, - unsavedOccurrence = unsavedOccurrence, - presentationStyleOverride = request.paywallOverrides?.presentationStyle, - paywallStatePublisher = paywallStatePublisher, - ) { isPresented -> - if (isPresented) { - val state = PaywallState.Presented(paywallView.info) - ioScope.launch { - paywallStatePublisher.emit(state) - } - } else { - Logger.debug( - logLevel = LogLevel.info, - scope = LogScope.paywallPresentation, - message = "Paywall Already Presented", - info = debugInfo, - ) - val error = - InternalPresentationLogic.presentationError( - domain = "SWKPresentationError", - code = 102, - title = "Paywall Already Presented", - value = "Trying to present paywall while another paywall is presented.", + withContext(Dispatchers.Main) { + try { + paywallView.present( + presenter = presenter, + request = request, + unsavedOccurrence = unsavedOccurrence, + presentationStyleOverride = request.paywallOverrides?.presentationStyle, + paywallStatePublisher = paywallStatePublisher, + ) { isPresented -> + if (isPresented) { + val state = PaywallState.Presented(paywallView.info) + ioScope.launch { + paywallStatePublisher.emit(state) + } + } else { + Logger.debug( + logLevel = LogLevel.info, + scope = LogScope.paywallPresentation, + message = "Paywall Already Presented", + info = debugInfo, ) - ioScope.launch { - paywallStatePublisher.emit(PaywallState.PresentationError(error)) + val error = + InternalPresentationLogic.presentationError( + domain = "SWKPresentationError", + code = 102, + title = "Paywall Already Presented", + value = "Trying to present paywall while another paywall is presented.", + ) + ioScope.launch { + paywallStatePublisher.emit(PaywallState.PresentationError(error)) + } + throw PaywallPresentationRequestStatusReason.PaywallAlreadyPresented() } - throw PaywallPresentationRequestStatusReason.PaywallAlreadyPresented() } + } catch (error: Throwable) { + logErrors(request, error = error) + throw error } - } catch (error: Throwable) { - logErrors(request, error = error) - throw error } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt index 88dedddca..2f6c8e06a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt @@ -15,6 +15,8 @@ import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationReques import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filter @@ -52,43 +54,6 @@ internal suspend fun waitForEntitlementsAndConfig( @Suppress("NAME_SHADOWING") val dependencyContainer = dependencyContainer ?: Superwall.instance.dependencyContainer - try { - withTimeout(5.seconds) { - request.flags.entitlements - .filter { it !is SubscriptionStatus.Unknown } - .first() - } - } catch (e: TimeoutCancellationException) { - // Handle exception, cancel the task, and log timeout and fail the request - dependencyContainer.ioScope().launch { - val trackedEvent = - InternalSuperwallEvent.PresentationRequest( - eventData = request.presentationInfo.eventData, - type = request.flags.type, - status = PaywallPresentationRequestStatus.Timeout, - statusReason = PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout(), - factory = dependencyContainer, - ) - dependencyContainer.track(trackedEvent) - } - Logger.debug( - logLevel = LogLevel.info, - scope = LogScope.paywallPresentation, - message = - "Timeout: Superwall.instance.entitlement.status has been \"unknown\" for " + - "over 5 seconds resulting in a failure.", - ) - val error = - InternalPresentationLogic.presentationError( - domain = "SWKPresentationError", - code = 105, - title = "Timeout", - value = "The entitlement status failed to change from \"unknown\".", - ) - paywallStatePublisher?.emit(PaywallState.PresentationError(error)) - throw PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout() - } - val configState = dependencyContainer.configManager.configState // In-flight states get one retry (1s initial window, then 5s fallback). @@ -103,46 +68,110 @@ internal suspend fun waitForEntitlementsAndConfig( 0 } - try { - configState.configOrThrow(retries) - } catch (e: Throwable) { - e.printStackTrace() - // Only track when config timed out — a Failed state is an immediate error, not a timeout. - if (e is TimeoutCancellationException) { + coroutineScope { + val entitlementsResult = + async { + runCatching { + withTimeout(5.seconds) { + request.flags.entitlements + .filter { it !is SubscriptionStatus.Unknown } + .first() + } + } + } + val configResult = + async { + runCatching { configState.configOrThrow(retries) } + } + + // When both waits fail, the entitlements failure must win — check it first. + entitlementsResult.await().onFailure { e -> + configResult.cancel() + if (e !is TimeoutCancellationException) throw e + // Handle exception, cancel the task, and log timeout and fail the request dependencyContainer.ioScope().launch { val trackedEvent = InternalSuperwallEvent.PresentationRequest( eventData = request.presentationInfo.eventData, type = request.flags.type, status = PaywallPresentationRequestStatus.Timeout, - statusReason = PaywallPresentationRequestStatusReason.NoConfig(), + statusReason = PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout(), factory = dependencyContainer, ) dependencyContainer.track(trackedEvent) } + Logger.debug( + logLevel = LogLevel.info, + scope = LogScope.paywallPresentation, + message = + "Timeout: Superwall.instance.entitlement.status has been \"unknown\" for " + + "over 5 seconds resulting in a failure.", + ) + val error = + InternalPresentationLogic.presentationError( + domain = "SWKPresentationError", + code = 105, + title = "Timeout", + value = "The entitlement status failed to change from \"unknown\".", + ) + paywallStatePublisher?.emit(PaywallState.PresentationError(error)) + throw PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout() } - Logger.debug( - logLevel = LogLevel.info, - scope = LogScope.paywallPresentation, - message = "Timeout: The config could not be retrieved in a reasonable time.", - ) - val errorValue = + + // The concurrent config wait shares its clock with the entitlements wait, so + // slow-resolving entitlements can starve the config windows that the old + // sequential code would have started afterwards. Now that entitlements have + // resolved, give a timed-out config one fresh sequential attempt before + // failing; non-timeout failures (e.g. ConfigState.Failed) stay immediate. + val configFailure = + configResult.await().exceptionOrNull()?.let { e -> + if (e is TimeoutCancellationException) { + runCatching { configState.configOrThrow(retries) }.exceptionOrNull() + } else { + e + } + } + + if (configFailure != null) { + val e = configFailure + e.printStackTrace() + // Only track when config timed out — a Failed state is an immediate error, not a timeout. if (e is TimeoutCancellationException) { - "Trying to present paywall without the Superwall config." - } else { - "Trying to present paywall without the Superwall config. Error: ${e.message}" + dependencyContainer.ioScope().launch { + val trackedEvent = + InternalSuperwallEvent.PresentationRequest( + eventData = request.presentationInfo.eventData, + type = request.flags.type, + status = PaywallPresentationRequestStatus.Timeout, + statusReason = PaywallPresentationRequestStatusReason.NoConfig(), + factory = dependencyContainer, + ) + dependencyContainer.track(trackedEvent) + } } - paywallStatePublisher?.emit( - PaywallState.PresentationError( - InternalPresentationLogic.presentationError( - domain = "SWKPresentationError", - code = 104, - title = "No Config", - value = errorValue, + Logger.debug( + logLevel = LogLevel.info, + scope = LogScope.paywallPresentation, + message = "Timeout: The config could not be retrieved in a reasonable time.", + ) + val errorValue = + if (e is TimeoutCancellationException) { + "Trying to present paywall without the Superwall config." + } else { + "Trying to present paywall without the Superwall config. Error: ${e.message}" + } + paywallStatePublisher?.emit( + PaywallState.PresentationError( + InternalPresentationLogic.presentationError( + domain = "SWKPresentationError", + code = 104, + title = "No Config", + value = errorValue, + ), ), - ), - ) - throw PaywallPresentationRequestStatusReason.NoConfig() + ) + throw PaywallPresentationRequestStatusReason.NoConfig() + } } // Defense in depth: if a Pending identity item (Seed / Assignments / etc.) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt index d8efe90df..f47bbbb6e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt @@ -12,6 +12,8 @@ import com.superwall.sdk.models.triggers.Trigger import com.superwall.sdk.models.triggers.TriggerRuleOccurrence import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule +import com.superwall.sdk.paywall.presentation.rule_logic.cel.models.PassableValue +import com.superwall.sdk.paywall.presentation.rule_logic.cel.toPassableValue import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluating import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.utilities.withErrorTracking @@ -117,8 +119,25 @@ class RuleLogic( val unmatchedRules = mutableListOf() + // Shared across the rules of this pass only - it excludes each rule's + // computed properties, which the evaluator resolves fresh per rule. + // Built lazily on the first CEL rule so a pass that matches earlier + // never pays for it; a failed build is not retried within the pass and + // leaves it null, letting the evaluator fall back to building + // attributes per rule (where failures are already contained). + var sharedAttributes: PassableValue.MapValue? = null + var sharedAttributesBuildAttempted = false + for (rule in trigger.rules) { - val outcome = expressionEvaluator.evaluateExpression(rule, event) + if (rule.expressionCEL != null && !sharedAttributesBuildAttempted) { + sharedAttributesBuildAttempted = true + sharedAttributes = + runCatching { + factory.makeRuleAttributes(event, emptyList()).toPassableValue() + }.getOrNull() + } + + val outcome = expressionEvaluator.evaluateExpression(rule, event, sharedAttributes) when (outcome) { is TriggerRuleOutcome.Match -> return RuleMatchOutcome.Matched(outcome.matchedItem) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt index 6dc11b02e..447b85380 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt @@ -7,6 +7,7 @@ import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.asyncWithTracking +import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.TriggerRuleOutcome @@ -56,6 +57,12 @@ internal class SuperscriptEvaluator( override suspend fun evaluateExpression( rule: TriggerRule, eventData: EventData?, + ): TriggerRuleOutcome = evaluateExpression(rule, eventData, sharedAttributes = null) + + override suspend fun evaluateExpression( + rule: TriggerRule, + eventData: EventData?, + sharedAttributes: PassableValue.MapValue?, ): TriggerRuleOutcome { if (rule.expressionCEL == null) { return rule.tryToMatchOccurrence(storage, true) @@ -63,13 +70,15 @@ internal class SuperscriptEvaluator( return ioScope .asyncWithTracking { - val factory = factory.makeRuleAttributes(eventData, rule.computedPropertyRequests) - val userAttributes = factory.toPassableValue() + val ruleAttributes = + sharedAttributes?.let { + overlayComputedProperties(it, eventData, rule.computedPropertyRequests) + } ?: factory.makeRuleAttributes(eventData, rule.computedPropertyRequests).toPassableValue() val expression = rule.expressionCEL val executionContext = ExecutionContext( - variables = PassableMap(map = userAttributes.value.toMap()), + variables = PassableMap(map = ruleAttributes.value.toMap()), expression = expression, device = availableDeviceProperties.associate { @@ -129,6 +138,33 @@ internal class SuperscriptEvaluator( } } } + + // Computed properties depend on occurrence counts and timestamps that can + // differ per rule and change between evaluations, so they are always + // resolved fresh and overlaid onto the shared attributes. + internal suspend fun overlayComputedProperties( + sharedAttributes: PassableValue.MapValue, + eventData: EventData?, + computedPropertyRequests: List, + ): PassableValue.MapValue { + if (computedPropertyRequests.isEmpty()) { + return sharedAttributes + } + val computedProperties = mutableMapOf() + for (request in computedPropertyRequests) { + storage.getComputedPropertySinceEvent(eventData, request)?.let { + computedProperties[request.type.prefix + request.eventName] = it.toPassableValue() + } + } + if (computedProperties.isEmpty()) { + return sharedAttributes + } + val device = + (sharedAttributes.value["device"] as? PassableValue.MapValue)?.value ?: emptyMap() + return PassableValue.MapValue( + sharedAttributes.value + ("device" to PassableValue.MapValue(device + computedProperties)), + ) + } } // PassableValues match the types in our Rust package diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/CombinedExpressionEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/CombinedExpressionEvaluator.kt index f71581580..aa815194a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/CombinedExpressionEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/CombinedExpressionEvaluator.kt @@ -5,6 +5,7 @@ import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.paywall.presentation.rule_logic.cel.SuperscriptEvaluator +import com.superwall.sdk.paywall.presentation.rule_logic.cel.models.PassableValue import com.superwall.sdk.paywall.presentation.rule_logic.tryToMatchOccurrence import com.superwall.sdk.storage.LocalStorage @@ -13,6 +14,15 @@ interface ExpressionEvaluating { rule: TriggerRule, eventData: EventData?, ): TriggerRuleOutcome + + // sharedAttributes must hold the user/device/params attributes WITHOUT any + // rule's computed properties, and must never outlive a single evaluation + // pass - user, device and occurrence state can change between passes. + suspend fun evaluateExpression( + rule: TriggerRule, + eventData: EventData?, + sharedAttributes: PassableValue.MapValue?, + ): TriggerRuleOutcome = evaluateExpression(rule, eventData) } internal class CombinedExpressionEvaluator( @@ -22,6 +32,12 @@ internal class CombinedExpressionEvaluator( override suspend fun evaluateExpression( rule: TriggerRule, eventData: EventData?, + ): TriggerRuleOutcome = evaluateExpression(rule, eventData, sharedAttributes = null) + + override suspend fun evaluateExpression( + rule: TriggerRule, + eventData: EventData?, + sharedAttributes: PassableValue.MapValue?, ): TriggerRuleOutcome { // Expression matches all if (rule.expressionJs == null && rule.expression == null && rule.expressionCEL == null) { @@ -32,7 +48,7 @@ internal class CombinedExpressionEvaluator( // and evaluate superscript only val celEvaluation = try { - superscriptEvaluator.evaluateExpression(rule, eventData) + superscriptEvaluator.evaluateExpression(rule, eventData, sharedAttributes) } catch (e: Exception) { TriggerRuleOutcome.noMatch(UnmatchedRule.Source.EXPRESSION, rule.experiment.id) } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt index 86084070b..840d9ef17 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt @@ -26,11 +26,15 @@ import com.superwall.sdk.store.StoreManager import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.util.Date +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -53,12 +57,20 @@ class PaywallRequestManager( private val getGlobalOverrides: () -> Map = { Superwall.instance.overrideProductsByName }, + // Single lane so paired lifecycle start/complete tracks reach the delegate + // (and get Date()-stamped) in launch order; the multi-threaded IO dispatcher + // could deliver them inverted when nothing separates the two launches. + private val trackScope: CoroutineScope = IOScope(Dispatchers.IO.limitedParallelism(1)), ) { - // Single thread context to make this class similar to an actor. All functions in this class - // must execute with this context. + // getPaywall runs on the multi-threaded IO dispatcher, so request dedup relies on + // ConcurrentHashMap's atomic putIfAbsent/remove(key, value) — not on a single thread. - private val activeTasks: MutableMap> = mutableMapOf() - private val paywallsByHash: MutableMap = mutableMapOf() + private val activeTasks = ConcurrentHashMap>() + private val paywallsByHash = ConcurrentHashMap() + + // Bumped on every cache invalidation; a fetch only writes its result into + // paywallsByHash when the generation it started under is still current. + private val cacheGeneration = AtomicLong(0) suspend fun getPaywall( request: PaywallRequest, @@ -99,8 +111,11 @@ class PaywallRequestManager( } } if (shouldRetry) { + val retryGeneration = cacheGeneration.get() paywall = addProducts(paywall, request) - if (paywall.productsLoadingInfo.failAt == null) { + if (paywall.productsLoadingInfo.failAt == null && + cacheGeneration.get() == retryGeneration + ) { paywallsByHash[requestHash] = paywall } } @@ -120,7 +135,7 @@ class PaywallRequestManager( return@withContext paywall } catch (e: kotlinx.coroutines.CancellationException) { // Clean up cancelled task and continue with new request - activeTasks.remove(requestHash) + activeTasks.remove(requestHash, existingTask) // Don't rethrow, let it continue to create a new task } } @@ -129,33 +144,64 @@ class PaywallRequestManager( paywall = suspendCancellableCoroutine { continuation -> val deferredTask = CompletableDeferred() - activeTasks[requestHash] = deferredTask - // Set up cancellation handler to clean up activeTasks + // Set up cancellation handler to clean up activeTasks. + // remove(key, value) so a slot claimed by a concurrent + // request is left untouched. continuation.invokeOnCancellation { - activeTasks.remove(requestHash) + activeTasks.remove(requestHash, deferredTask) deferredTask.cancel() } // Launch coroutine to handle async operations ioScope.launch { try { + // If the caller was cancelled before this launch ran, + // invokeOnCancellation has already fired (its remove was + // a no-op) — bail instead of running a zombie fetch. + if (!continuation.isActive || deferredTask.isCancelled) return@launch + + // Claim the in-flight slot atomically; if another + // coroutine won the race, await its result instead + // of fetching the same paywall again. + var winner = activeTasks.putIfAbsent(requestHash, deferredTask) + while (winner != null) { + try { + continuation.resume(winner.await()) + return@launch + } catch (e: kotlinx.coroutines.CancellationException) { + // Clean up cancelled task and race for the slot again + activeTasks.remove(requestHash, winner) + if (!continuation.isActive) return@launch + winner = activeTasks.putIfAbsent(requestHash, deferredTask) + } + } + + // Snapshot the cache generation so an invalidation racing + // this fetch keeps the (stale) result out of the cache. + val fetchGeneration = cacheGeneration.get() val rawPaywallResult = getRawPaywall(request, isPreloading) rawPaywallResult .then { val finalPaywall = addProducts(it, request) - saveRequestHash(requestHash, finalPaywall, request.isDebuggerLaunched) + saveRequestHash( + requestHash, + deferredTask, + finalPaywall, + request.isDebuggerLaunched, + fetchGeneration, + ) // Complete both the deferred task and the continuation deferredTask.complete(finalPaywall) continuation.resume(finalPaywall) }.onError { error -> - activeTasks.remove(requestHash) + activeTasks.remove(requestHash, deferredTask) deferredTask.completeExceptionally(error) continuation.resumeWithException(error) } } catch (error: Throwable) { - activeTasks.remove(requestHash) + activeTasks.remove(requestHash, deferredTask) deferredTask.completeExceptionally(error) continuation.resumeWithException(error) } @@ -187,13 +233,18 @@ class PaywallRequestManager( private suspend fun saveRequestHash( requestHash: String, + task: Deferred, paywall: Paywall, isDebuggerLaunched: Boolean, + fetchGeneration: Long, ) = withContext(ioScope.coroutineContext) { - activeTasks.remove(requestHash) - if (!isDebuggerLaunched) { + // Write the cache before releasing the in-flight slot so a concurrent + // request can't miss both and refetch. Skip the write if the cache was + // invalidated while this fetch was in flight — the result is stale. + if (!isDebuggerLaunched && cacheGeneration.get() == fetchGeneration) { paywallsByHash[requestHash] = paywall } + activeTasks.remove(requestHash, task) } suspend fun getRawPaywall( @@ -269,26 +320,30 @@ class PaywallRequestManager( } // MARK: - Analytics - private suspend fun trackResponseStarted(event: EventData?) = - withContext(ioScope.coroutineContext) { - val trackedEvent = - InternalSuperwallEvent.PaywallLoad( - state = InternalSuperwallEvent.PaywallLoad.State.Start(), - eventData = event, - ) - track(trackedEvent) - } + // Lifecycle events are tracked without awaiting so dispatcher hops and the app's + // delegate callback never sit on the load path. They all launch on the single-lane + // trackScope so paired start/complete events reach the delegate in launch order. + // Payloads are built eagerly at the call site so launched tracks can't observe + // later paywall mutation. + private fun trackResponseStarted(event: EventData?) { + val trackedEvent = + InternalSuperwallEvent.PaywallLoad( + state = InternalSuperwallEvent.PaywallLoad.State.Start(), + eventData = event, + ) + trackScope.launch { track(trackedEvent) } + } - private suspend fun trackResponseLoaded( + private fun trackResponseLoaded( paywallInfo: PaywallInfo, event: EventData?, - ) = withContext(ioScope.coroutineContext) { + ) { val responseLoadEvent = InternalSuperwallEvent.PaywallLoad( InternalSuperwallEvent.PaywallLoad.State.Complete(paywallInfo = paywallInfo), eventData = event, ) - track(responseLoadEvent) + trackScope.launch { track(responseLoadEvent) } } suspend fun addProducts( @@ -305,13 +360,13 @@ class PaywallRequestManager( fetchAndCacheCustomProducts(paywall) } catch (error: Throwable) { paywall.productsLoadingInfo.failAt = Date() - track( + val productLoadFailEvent = InternalSuperwallEvent.PaywallProductsLoad( state = InternalSuperwallEvent.PaywallProductsLoad.State.Fail(error.message), paywallInfo = paywall.getInfo(request.eventData), eventData = request.eventData, - ), - ) + ) + trackScope.launch { track(productLoadFailEvent) } throw error } paywall = getProducts(paywall, request) @@ -387,47 +442,49 @@ class PaywallRequestManager( } // Analytics - private suspend fun trackProductsLoadStart( + private fun trackProductsLoadStart( paywall: Paywall, request: PaywallRequest, - ): Paywall = - withContext(ioScope.coroutineContext) { - var paywall = paywall - paywall.productsLoadingInfo.startAt = Date() - val paywallInfo = paywall.getInfo(request.eventData) - val productLoadEvent = - InternalSuperwallEvent.PaywallProductsLoad( - state = InternalSuperwallEvent.PaywallProductsLoad.State.Start(), - paywallInfo, - request.eventData, - ) - track(productLoadEvent) - return@withContext paywall - } + ): Paywall { + paywall.productsLoadingInfo.startAt = Date() + val paywallInfo = paywall.getInfo(request.eventData) + val productLoadEvent = + InternalSuperwallEvent.PaywallProductsLoad( + state = InternalSuperwallEvent.PaywallProductsLoad.State.Start(), + paywallInfo, + request.eventData, + ) + trackScope.launch { track(productLoadEvent) } + return paywall + } - private suspend fun trackProductsLoadFinish( + private fun trackProductsLoadFinish( paywall: Paywall, event: EventData?, - ): Paywall = - withContext(ioScope.coroutineContext) { - var paywall = paywall - paywall.productsLoadingInfo.endAt = Date() - val paywallInfo = paywall.getInfo(event) - val productLoadEvent = - InternalSuperwallEvent.PaywallProductsLoad( - state = InternalSuperwallEvent.PaywallProductsLoad.State.Complete(), - paywallInfo, - event, - ) - track(productLoadEvent) + ): Paywall { + paywall.productsLoadingInfo.endAt = Date() + val paywallInfo = paywall.getInfo(event) + val productLoadEvent = + InternalSuperwallEvent.PaywallProductsLoad( + state = InternalSuperwallEvent.PaywallProductsLoad.State.Complete(), + paywallInfo, + event, + ) + trackScope.launch { track(productLoadEvent) } - return@withContext paywall - } + return paywall + } internal fun resetCache() { + cacheGeneration.incrementAndGet() paywallsByHash.clear() } + fun removeCachedPaywalls(identifiers: Set) { + cacheGeneration.incrementAndGet() + paywallsByHash.entries.removeAll { it.value.identifier in identifiers } + } + /** * Converts productOverrides to the format expected by StoreManager.getProducts. * This function handles ProductOverride.ByProduct objects by extracting the StoreProduct, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index 13b2b4130..3f1cd37c7 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt @@ -195,6 +195,12 @@ class PaywallView( controller.updateState(SetLoadingState(value)) } + // The last loading state handled by `loadingStateDidChange` while presented. + private var lastHandledLoadingState: PaywallLoadingState = PaywallLoadingState.Unknown + + // Pending delayed hide of the loading/shimmer views, cancelled on the next state change. + private var delayedHideJob: Job? = null + val backgroundColor: Int get() { @@ -847,8 +853,13 @@ class PaywallView( internal fun loadingStateDidChange() { if (state.isPresented) { + val previousLoadingState = lastHandledLoadingState + val currentLoadingState = loadingState + lastHandledLoadingState = currentLoadingState + delayedHideJob?.cancel() + delayedHideJob = null mainScope.launch { - when (loadingState) { + when (currentLoadingState) { is PaywallLoadingState.Unknown -> { } @@ -863,13 +874,26 @@ class PaywallView( } is PaywallLoadingState.Ready -> { - ioScope.launch { - delay(state.paywall.presentation.delay) - mainScope.launch { - showRefreshButtonAfterTimeout(false) - hideLoadingView() - hideShimmerView() - } + val isInitialReveal = + previousLoadingState is PaywallLoadingState.LoadingURL || + previousLoadingState is PaywallLoadingState.Unknown + if (isInitialReveal) { + // The presentation delay only applies to the first shimmer + // -> content reveal, not to hiding the purchase spinner. + delayedHideJob = + ioScope.launch { + delay(state.paywall.presentation.delay) + mainScope.launch { + showRefreshButtonAfterTimeout(false) + hideLoadingView() + hideShimmerView() + } + controller.updateState(ResetCrashRetry) + } + } else { + showRefreshButtonAfterTimeout(false) + hideLoadingView() + hideShimmerView() controller.updateState(ResetCrashRetry) } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/ShimmerView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/ShimmerView.kt index 77301c886..6ca6a11c0 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/ShimmerView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/ShimmerView.kt @@ -4,10 +4,19 @@ import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Shader import android.graphics.drawable.VectorDrawable import android.util.AttributeSet import android.view.View import android.view.ViewTreeObserver +import android.view.animation.LinearInterpolator import android.view.animation.PathInterpolator import android.widget.ImageView import androidx.core.content.ContextCompat @@ -23,10 +32,24 @@ class ShimmerView( ) : ImageView(context.applicationContext, attrs), PaywallShimmerView { private var animator: ValueAnimator? = null + private var pulseAnimator: ValueAnimator? = null private var vectorDrawable: VectorDrawable? = null + // Highlight band swept across the skeleton. SRC_ATOP keeps the band + // clipped to the skeleton shapes drawn inside the same saveLayer. + private val shimmerPaint = + Paint().apply { + xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) + } + private val shaderMatrix = Matrix() + private var gradientWidth = 0f + private var shimmerTranslate = 0f + companion object { internal const val TAG = "ShimmerView" + private const val SWEEP_DURATION_MS = 1500L + private const val PULSE_DURATION_MS = 3000L + private const val GRADIENT_WIDTH_RATIO = 0.4f } private var currentOrientation: Int = Configuration.ORIENTATION_UNDEFINED @@ -40,7 +63,6 @@ class ShimmerView( var tintColorFilter: android.graphics.ColorFilter? = null init { - setLayerType(LAYER_TYPE_SOFTWARE, null) setTag(TAG) checkForOrientationChanges() } @@ -116,15 +138,74 @@ class ShimmerView( stopShimmer() } + override fun onSizeChanged( + w: Int, + h: Int, + oldw: Int, + oldh: Int, + ) { + super.onSizeChanged(w, h, oldw, oldh) + // Gradient width is derived from the view width, so rebuild on resize. + if (w != oldw && animator != null) { + startShimmer() + } + } + + override fun onDraw(canvas: Canvas) { + if (animator == null || shimmerPaint.shader == null) { + super.onDraw(canvas) + return + } + // The background color is drawn outside this layer, so the band only + // tints the skeleton pixels, not the whole surface. + val checkpoint = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null) + super.onDraw(canvas) + shaderMatrix.setTranslate(shimmerTranslate, 0f) + shimmerPaint.shader.setLocalMatrix(shaderMatrix) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), shimmerPaint) + canvas.restoreToCount(checkpoint) + } + fun startShimmer() { stopShimmer() + if (width == 0) { + // Not laid out yet; the global layout listener will start us. + return + } + gradientWidth = width * GRADIENT_WIDTH_RATIO + val base = if (tintColor != 0) tintColor else Color.WHITE + val highlightAlpha = if (isLightBackground) 128 else 96 + val highlight = Color.argb(highlightAlpha, Color.red(base), Color.green(base), Color.blue(base)) + shimmerPaint.shader = + LinearGradient( + 0f, + 0f, + gradientWidth, + 0f, + intArrayOf(Color.TRANSPARENT, highlight, Color.TRANSPARENT), + floatArrayOf(0f, 0.5f, 1f), + Shader.TileMode.CLAMP, + ) animator = + ValueAnimator.ofFloat(-gradientWidth, width.toFloat()).apply { + duration = SWEEP_DURATION_MS + repeatCount = ValueAnimator.INFINITE + interpolator = LinearInterpolator() + addUpdateListener { + shimmerTranslate = it.animatedValue as Float + invalidate() + } + start() + } + // Gentle skeleton fade layered under the sweep. Animates imageAlpha + // (drawable only) so the view background doesn't pulse with it. + pulseAnimator = ValueAnimator.ofFloat(1f, 0.5f, 1f).apply { - duration = 3000 + duration = PULSE_DURATION_MS repeatCount = ValueAnimator.INFINITE interpolator = PathInterpolator(0.4f, 0f, 0.6f, 1f) - addUpdateListener { animation -> - drawable.alpha = (animation.animatedValue as Float * 255).toInt() + addUpdateListener { + imageAlpha = ((it.animatedValue as Float) * 255).toInt() } start() } @@ -132,5 +213,10 @@ class ShimmerView( fun stopShimmer() { animator?.cancel() + animator = null + pulseAnimator?.cancel() + pulseAnimator = null + imageAlpha = 255 + invalidate() } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 6c832ce4f..692078419 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt @@ -568,11 +568,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { val animatorSet = AnimatorSet().apply { playTogether(scaleX, scaleY, fadeIn) - duration = paywallView() - ?.state - ?.paywall - ?.presentation - ?.delay ?: DEFAULT_DELAY + duration = DEFAULT_DELAY interpolator = OvershootInterpolator(1.1f) } animatorSet.start() diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt index d460dd44e..e38147e8f 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt @@ -25,6 +25,20 @@ internal open class DefaultWebviewClient( val webviewClientEvents: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 10, replay = 2) + // True once the currently loading page suffered a page-level failure (main frame + // or its essential runtime bundle). Set synchronously on the WebViewClient callback + // thread — before the corresponding OnError coroutine is launched — so consumers of + // OnPageFinished can consult it without racing the async event; cleared when a new + // page starts. + @Volatile + internal var hadMainFrameError = false + private set + + // The paywall runtime JS bundle is essential — without it the page is broken — + // so its failure is treated as page-level rather than as a mere resource error. + protected fun isPageLevelFailure(request: WebResourceRequest?): Boolean = + request?.isForMainFrame == true || request?.url?.toString()?.contains("runtime") == true + override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest?, @@ -46,6 +60,7 @@ internal open class DefaultWebviewClient( favicon: Bitmap?, ) { super.onPageStarted(view, url, favicon) + hadMainFrameError = false view?.let(onPageStartedHook) } @@ -64,10 +79,14 @@ internal open class DefaultWebviewClient( request: WebResourceRequest?, errorResponse: WebResourceResponse?, ) { - val requestUrl = request?.url.toString() - if (requestUrl.contains("favicon.ico")) { + val requestUrl = request?.url?.toString() + if (requestUrl?.contains("favicon.ico") == true) { return } + val isPageLevel = isPageLevelFailure(request) + if (isPageLevel) { + hadMainFrameError = true + } ioScope.launch { Logger.debug( LogLevel.error, @@ -75,17 +94,21 @@ internal open class DefaultWebviewClient( "Paywall loading failed due to network error. Url: $requestUrl - Code: ${errorResponse?.statusCode} for ${errorResponse?.reasonPhrase}", ) + val error = + WebviewError.NetworkError( + errorResponse?.statusCode ?: -1, + errorResponse?.let { + val body = it.data?.bufferedReader()?.use { it.readText() } ?: "Unknown" + "Error: ${errorResponse.reasonPhrase} -\n $body" + } ?: "Unknown error", + if (isPageLevel) forUrl else requestUrl ?: forUrl, + ) webviewClientEvents.emit( - WebviewClientEvent.OnError( - WebviewError.NetworkError( - errorResponse?.statusCode ?: -1, - errorResponse?.let { - val body = it.data?.bufferedReader()?.use { it.readText() } ?: "Unknown" - "Error: ${errorResponse.reasonPhrase} -\n $body" - } ?: "Unknown error", - forUrl, - ), - ), + if (isPageLevel) { + WebviewClientEvent.OnError(error) + } else { + WebviewClientEvent.OnResourceError(error) + }, ) } } @@ -103,22 +126,23 @@ internal open class DefaultWebviewClient( request: WebResourceRequest?, error: WebResourceError, ) { + val requestUrl = request?.url?.toString() + val isPageLevel = isPageLevelFailure(request) + if (isPageLevel) { + hadMainFrameError = true + } ioScope.launch { - if (request?.url?.toString()?.contains("runtime") == true) { - val (code, desc) = - error?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val code = it.errorCode - val description = it.description.toString() - code to description - } else { - -1 to "Error description unavailable, Android API version < 23" - } - } ?: (-1 to "Unknown error") + val (code, desc) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + error.errorCode to error.description.toString() + } else { + -1 to "Error description unavailable, Android API version < 23" + } + if (isPageLevel) { Logger.debug( LogLevel.debug, LogScope.paywallView, - "Paywall loading failed due to network error. Url: ${request?.url?.toString()} - Code: $code for $desc", + "Paywall loading failed due to network error. Url: $requestUrl - Code: $code for $desc", ) webviewClientEvents.emit( WebviewClientEvent.OnError( @@ -132,19 +156,11 @@ internal open class DefaultWebviewClient( } else { webviewClientEvents.emit( WebviewClientEvent.OnResourceError( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - WebviewError.NetworkError( - error.errorCode, - error.description.toString(), - forUrl, - ) - } else { - WebviewError.NetworkError( - -1, - "Error description unavailable, Android API version < 23", - forUrl, - ) - }, + WebviewError.NetworkError( + code, + desc, + requestUrl ?: forUrl, + ), ), ) } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt index 730c8151f..7760e4e58 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt @@ -114,6 +114,7 @@ class SWWebView( { i, e -> } private companion object ChromeClient : WebChromeClient() { + const val MAX_LOAD_RETRIES = 3 val posterBmp by lazy { createBitmap(10, 10) } private class ChromeClient( @@ -178,6 +179,7 @@ class SWWebView( private var lastWebViewClient: WebViewClient? = null private var lastLoadedUrl: String? = null + private var loadRetryCount = 0 // The device preload script seeds `window.__SW_DEVICE_PRELOAD__` as soon as // the page starts loading, so translated paywalls render in the device locale @@ -258,9 +260,7 @@ class SWWebView( onPageStartedHook = onPageStartedPreloadHook, ) this.webViewClient = client - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - lastWebViewClient = client - } + lastWebViewClient = client listenToWebviewClientEvents(client) client.loadWithFallback() } @@ -324,10 +324,7 @@ class SWWebView( onPageStartedHook = onPageStartedPreloadHook, ) this.webViewClient = client - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - lastWebViewClient = client - } + lastWebViewClient = client listenToWebviewClientEvents(client) super.loadUrl(transformUri(url)) @@ -423,17 +420,21 @@ class SWWebView( ) if (lastLoadedUrl != null) { when (lastWebViewClient) { - is WebviewFallbackClient -> {} - is DefaultWebviewClient -> { - log( - "Paywall loading failed - retrying $lastLoadedUrl", - ) - loadUrl(lastLoadedUrl!!) + is WebviewFallbackClient -> { + // NO-OP as it has internal fallback } - else -> { - // NO-OP as it has internal fallback + is DefaultWebviewClient -> { + if (loadRetryCount < MAX_LOAD_RETRIES) { + loadRetryCount += 1 + log( + "Paywall loading failed - retrying $lastLoadedUrl", + ) + loadUrl(lastLoadedUrl!!) + } } + + else -> {} } } } @@ -458,6 +459,12 @@ class SWWebView( } is WebviewClientEvent.OnPageFinished -> { + // The client records page-level failures synchronously on the + // WebViewClient callback thread, so this can't miss an error + // whose async OnError event hasn't been processed yet. + if (!client.hadMainFrameError) { + loadRetryCount = 0 + } asEither { mainScope.launch { this@SWWebView.scrollTo(0, 0) @@ -586,6 +593,7 @@ class SWWebView( ) { this.onRenderCrashed = onRenderCrashed scrollEnabled = delegate?.state?.paywall?.isScrollEnabled ?: true + loadRetryCount = 0 mainScope.launch { val state = delegate?.state ?: return@launch if (state.paywall.onDeviceCache is OnDeviceCaching.Enabled) { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt index 2f08a7015..914146646 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt @@ -158,7 +158,9 @@ internal class WebviewFallbackClient( request: WebResourceRequest?, errorResponse: WebResourceResponse?, ) { - timeoutFlow.update { UrlState.PageError } + if (isPageLevelFailure(request)) { + timeoutFlow.update { UrlState.PageError } + } super.onReceivedHttpError(view, request, errorResponse) } @@ -226,6 +228,10 @@ internal class WebviewFallbackClient( request: WebResourceRequest?, error: WebResourceError, ) { + if (!isPageLevelFailure(request)) { + super.onReceivedError(view, request, error) + return + } timeoutFlow.update { UrlState.PageError } super.onReceivedError(view, request, error) loadWithFallback() diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index 3f496e9ea..1446d9f89 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt @@ -105,8 +105,7 @@ class PaywallMessageHandler( Logger.debug( LogLevel.debug, LogScope.superwallCore, - "SWWebViewInterface: $message", - ) + ) { "SWWebViewInterface: $message" } // Attempt to parse the message to json parseWrappedPaywallMessages(message) @@ -115,8 +114,7 @@ class PaywallMessageHandler( Logger.debug( LogLevel.debug, LogScope.superwallCore, - "SWWebViewInterface: ${paywallMessage.javaClass.simpleName}", - ) + ) { "SWWebViewInterface: ${paywallMessage.javaClass.simpleName}" } handle(paywallMessage) } }, { @@ -134,14 +132,12 @@ class PaywallMessageHandler( Logger.debug( LogLevel.debug, LogScope.superwallCore, - "!! PaywallMessageHandler: Handling message: $message ${messageHandler?.state?.paywall}, delegeate: $messageHandler", - ) + ) { "!! PaywallMessageHandler: Handling message: $message ${messageHandler?.state?.paywall}, delegeate: $messageHandler" } val paywall = messageHandler?.state?.paywall ?: return Logger.debug( LogLevel.debug, LogScope.superwallCore, - "!! PaywallMessageHandler: Paywall: $paywall, delegeate: $messageHandler", - ) + ) { "!! PaywallMessageHandler: Paywall: $paywall, delegeate: $messageHandler" } when (message) { is PaywallMessage.TemplateParamsAndUserAttributes -> ioScope.launch { passTemplatesToWebView(paywall) } @@ -375,8 +371,8 @@ class PaywallMessageHandler( Logger.debug( logLevel = LogLevel.debug, scope = LogScope.paywallView, - message = "Posting Message", - info = mapOf("message" to templateScript), + info = { mapOf("message" to templateScript) }, + message = { "Posting Message" }, ) withContext(Dispatchers.Main) { @@ -385,9 +381,9 @@ class PaywallMessageHandler( Logger.debug( logLevel = LogLevel.error, scope = LogScope.paywallView, - message = "Error Evaluating JS", - info = mapOf("message" to templateScript), error = java.lang.Exception(error), + info = { mapOf("message" to templateScript) }, + message = { "Error Evaluating JS" }, ) } } @@ -446,14 +442,13 @@ class PaywallMessageHandler( Logger.debug( LogLevel.debug, LogScope.superwallCore, - "!! PaywallMessageHandler: $scriptSrc", - ) + ) { "!! PaywallMessageHandler: $scriptSrc" } Logger.debug( logLevel = LogLevel.debug, scope = LogScope.paywallView, - message = "Posting Message", - info = mapOf("message" to scriptSrc), + info = { mapOf("message" to scriptSrc) }, + message = { "Posting Message" }, ) mainScope.launch { @@ -462,9 +457,9 @@ class PaywallMessageHandler( Logger.debug( logLevel = LogLevel.error, scope = LogScope.paywallView, - message = "Error Evaluating JS", - info = mapOf("message" to scriptSrc), error = java.lang.Exception(error), + info = { mapOf("message" to scriptSrc) }, + message = { "Error Evaluating JS" }, ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/TemplateLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/TemplateLogic.kt index a3f075a7b..9674b44fb 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/TemplateLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/TemplateLogic.kt @@ -81,8 +81,7 @@ object TemplateLogic { Logger.debug( LogLevel.debug, LogScope.superwallCore, - "!!! Template Logic: $templatesString", - ) + ) { "!!! Template Logic: $templatesString" } return encodeToBase64(templatesString) } diff --git a/superwall/src/main/java/com/superwall/sdk/storage/core_data/CoreDataManager.kt b/superwall/src/main/java/com/superwall/sdk/storage/core_data/CoreDataManager.kt index 87d61383d..b62dc1930 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/core_data/CoreDataManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/core_data/CoreDataManager.kt @@ -21,13 +21,17 @@ class CoreDataManager( context: Context, ) : CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO.limitedParallelism(1) + + // Event data inserts happen on every tracked event, so they get their own lane + // to keep occurrence writes from queueing behind them. + private val eventDataScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) private val superwallDatabase by lazy { SuperwallDatabase.getDatabase(context = context) } fun saveEventData( eventData: EventData, completion: ((ManagedEventData) -> Unit)? = null, ) { - launch { + eventDataScope.launch { try { // Create a new EventData object val managedEventData = @@ -81,9 +85,22 @@ class CoreDataManager( } fun deleteAllEntities() { + // Each delete stays on the lane that inserts into its table, so it runs + // after any pending inserts for that table. launch { try { superwallDatabase.managedTriggerRuleOccurrenceDao().deleteAll() + } catch (error: Throwable) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.coreData, + message = "Could not delete entities in Room database.", + error = error, + ) + } + } + eventDataScope.launch { + try { superwallDatabase.managedEventDataDao().deleteAll() } catch (error: Throwable) { Logger.debug( @@ -157,7 +174,7 @@ class CoreDataManager( } suspend fun countTriggerRuleOccurrences(ruleOccurrence: TriggerRuleOccurrence): Int { - val dao = superwallDatabase.managedTriggerRuleOccurrenceDao() // Replace with your actual database instance retrieval method + val dao = superwallDatabase.managedTriggerRuleOccurrenceDao() return when (ruleOccurrence.interval) { is TriggerRuleOccurrence.Interval.Minutes -> { @@ -165,20 +182,13 @@ class CoreDataManager( calendar.add(Calendar.MINUTE, -(ruleOccurrence.interval).minutes) val date = calendar.time - // Fetch occurrences based on date and key - val occurrences = - dao.getManagedTriggerRuleOccurrencesSinceDate( - key = ruleOccurrence.key, - date = date, - ) - - occurrences.size // This gives you the count of occurrences that match the conditions - } - TriggerRuleOccurrence.Interval.Infinity -> { - // Fetch all occurrences with the given key - val occurrences = dao.getManagedTriggerRuleOccurrencesByKey(ruleOccurrence.key) - occurrences.size ?: 0 // If null, return 0 + dao.countTriggerRuleOccurrencesSinceDate( + key = ruleOccurrence.key, + date = date, + ) } + TriggerRuleOccurrence.Interval.Infinity -> + dao.countTriggerRuleOccurrencesByKey(ruleOccurrence.key) } } diff --git a/superwall/src/main/java/com/superwall/sdk/storage/core_data/entities/ManagedTriggerRuleOccurrence.kt b/superwall/src/main/java/com/superwall/sdk/storage/core_data/entities/ManagedTriggerRuleOccurrence.kt index dcad38dd8..b67346a3d 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/core_data/entities/ManagedTriggerRuleOccurrence.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/core_data/entities/ManagedTriggerRuleOccurrence.kt @@ -32,6 +32,15 @@ interface ManagedTriggerRuleOccurrenceDao { key: String, ): List + @Query("SELECT COUNT(*) FROM ManagedTriggerRuleOccurrence WHERE occurrenceKey = :key") + suspend fun countTriggerRuleOccurrencesByKey(key: String): Int + + @Query("SELECT COUNT(*) FROM ManagedTriggerRuleOccurrence WHERE createdAt >= :date AND occurrenceKey = :key") + suspend fun countTriggerRuleOccurrencesSinceDate( + date: Date, + key: String, + ): Int + @Transaction @Query("DELETE FROM ManagedTriggerRuleOccurrence") suspend fun deleteAll() diff --git a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt index 1c2f523c8..77ba72ce5 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt @@ -34,6 +34,8 @@ import com.superwall.sdk.store.transactions.PlayBillingErrors import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -75,12 +77,17 @@ class AutomaticPurchaseController( PurchasesUpdatedListener { companion object { private const val QUERY_TIMEOUT_MS = 10_000L + private const val CONNECTION_TIMEOUT_MS = 10_000L private const val MAX_RETRIES = 3 } private var billingClient: BillingClient = getBilling(context, this) - private val isConnected = MutableStateFlow(false) + // Tri-state so waiters can short-circuit when the connection is known to + // have failed instead of blocking until the timeout + private enum class ConnectionState { Connecting, Connected, Failed } + + private val connectionState = MutableStateFlow(ConnectionState.Connecting) private val purchaseResults = MutableStateFlow(null) // how long before the data source tries to reconnect to Google play @@ -99,13 +106,17 @@ class AutomaticPurchaseController( billingClient.startConnection( object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { - isConnected.value = - billingResult.responseCode == BillingClient.BillingResponseCode.OK + connectionState.value = + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + ConnectionState.Connected + } else { + ConnectionState.Failed + } syncSubscriptionStatus() } override fun onBillingServiceDisconnected() { - isConnected.value = false + connectionState.value = ConnectionState.Connecting Logger.debug( LogLevel.error, @@ -128,6 +139,9 @@ class AutomaticPurchaseController( }, ) } catch (e: IllegalStateException) { + // The connection will never be established, so fail fast instead of + // leaving waiters blocked until their timeout + connectionState.value = ConnectionState.Failed Logger.debug( LogLevel.error, LogScope.nativePurchaseController, @@ -262,7 +276,7 @@ class AutomaticPurchaseController( ) // Wait until the billing client becomes connected - isConnected.first { it } + connectionState.first { it == ConnectionState.Connected } Logger.debug( logLevel = LogLevel.info, @@ -328,12 +342,23 @@ class AutomaticPurchaseController( //region Private private suspend fun syncSubscriptionStatusAndWait(count: Int = 0) { - // We await for configuration to be set so our entitlements are available - Superwall.instance.configurationStateListener.first { it is ConfigurationStatus.Configured } - val subscriptionPurchases = - retryOrNull(MAX_RETRIES) { queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrThrow() } - val inAppPurchases = - retryOrNull(MAX_RETRIES) { queryPurchasesOfType(BillingClient.ProductType.INAPP).getOrThrow() } + // Queries fail instantly while the billing client is still connecting, + // so wait (bounded) for the connection attempt to resolve before querying. + // A Failed connection falls through immediately - the queries below fail + // fast and retryOrNull remains the safety net. + withTimeoutOrNull(CONNECTION_TIMEOUT_MS) { connectionState.first { it != ConnectionState.Connecting } } + val (subscriptionPurchases, inAppPurchases) = + coroutineScope { + val subs = + async { + retryOrNull(MAX_RETRIES) { queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrThrow() } + } + val inApps = + async { + retryOrNull(MAX_RETRIES) { queryPurchasesOfType(BillingClient.ProductType.INAPP).getOrThrow() } + } + subs.await() to inApps.await() + } val failed = subscriptionPurchases == null || inAppPurchases == null val allPurchases = (subscriptionPurchases ?: emptyList()) + (inAppPurchases ?: emptyList()) val hasActivePurchaseOrSubscription = @@ -352,6 +377,8 @@ class AutomaticPurchaseController( val status: SubscriptionStatus = if (hasActivePurchaseOrSubscription) { + // We await for configuration to be set so our entitlements are available + Superwall.instance.configurationStateListener.first { it is ConfigurationStatus.Configured } allPurchases .flatMap { it.products @@ -377,6 +404,10 @@ class AutomaticPurchaseController( } } } else { + // Wait until config has finished applying (or failed) so flags like + // test mode are known before publishing Inactive - otherwise we'd + // fire a spurious status change in test-mode apps + Superwall.instance.configurationStateListener.first { it !is ConfigurationStatus.Pending } SubscriptionStatus.Inactive } if (!Superwall.initialized) { diff --git a/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt b/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt index ded8598d6..d5c2ae1bd 100644 --- a/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt @@ -1,6 +1,8 @@ package com.superwall.sdk import com.superwall.sdk.config.ConfigManager +import com.superwall.sdk.config.models.ConfigState +import com.superwall.sdk.models.config.Config import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -8,6 +10,9 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test @@ -43,6 +48,8 @@ class SdkContextImplTest { runTest { val manager = mockk { + every { configState } returns + MutableStateFlow(ConfigState.Retrieved(mockk())) coEvery { getAssignments() } just Runs } val ctx = SdkContextImpl(configManager = { manager }) @@ -52,6 +59,29 @@ class SdkContextImplTest { coVerify(exactly = 1) { manager.getAssignments() } } + @Test + fun `fetchAssignments waits for a valid config without a deadline before delegating`() = + runTest { + val state = MutableStateFlow(ConfigState.Retrieving) + val manager = + mockk { + every { configState } returns state + coEvery { getAssignments() } just Runs + } + val ctx = SdkContextImpl(configManager = { manager }) + + val job = launch { ctx.fetchAssignments() } + runCurrent() + coVerify(exactly = 0) { manager.getAssignments() } + + // Config lands well past the old 30s budget — the wait must not time out. + testScheduler.advanceTimeBy(60_000L) + state.value = ConfigState.Retrieved(mockk()) + job.join() + + coVerify(exactly = 1) { manager.getAssignments() } + } + @Test fun `configManager factory is invoked lazily so teardown-reconfigure swaps are observable`() = runTest { diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt index 33af093c3..5c1aa7efb 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt @@ -77,6 +77,7 @@ class ConfigManagerTest { val network: SuperwallAPI, val storage: Storage, val preload: PaywallPreload, + val paywallManager: PaywallManager, val storeManager: StoreManager, val webRedeemer: WebPaywallRedeemer, val deviceHelper: DeviceHelper, @@ -144,6 +145,7 @@ class ConfigManagerTest { coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { preloadPaywallsByNames(any(), any()) } just Runs coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + every { invalidatePreloadFingerprint() } just Runs every { lastFingerprint } returns java.util.concurrent.atomic.AtomicReference(null) } val paywallManager = mockk(relaxed = true) @@ -199,6 +201,7 @@ class ConfigManagerTest { network, storage, preload, + paywallManager, storeManager, webRedeemer, deviceHelper, @@ -749,10 +752,12 @@ class ConfigManagerTest { coEvery { s.preload.preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { s.preload.preloadPaywallsByNames(any(), any()) } just Runs coEvery { s.preload.removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + every { s.preload.invalidatePreloadFingerprint() } just Runs s.manager.getAssignments() advanceUntilIdle() + verify(atLeast = 1) { s.preload.invalidatePreloadFingerprint() } coVerify(atLeast = 1) { s.preload.preloadAllPaywalls(any(), any(), any()) } } @@ -868,49 +873,51 @@ class ConfigManagerTest { @Test fun `refreshConfiguration success resets paywall request cache and removes unused`() = runTest(timeout = 30.seconds) { - val oldConfig = config(buildId = "old", enableRefresh = true) - val newConfig = config(buildId = "new", enableRefresh = true) - val paywallManager = mockk(relaxed = true) - val preload = mockk(relaxed = true) { - coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs - coEvery { preloadPaywallsByNames(any(), any()) } just Runs - coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + val oldConfig = config(buildId = "old", enableRefresh = true).copy( + paywalls = listOf( + com.superwall.sdk.models.paywall.Paywall.stub().copy(identifier = "keep", cacheKey = "keep-ck"), + com.superwall.sdk.models.paywall.Paywall.stub().copy(identifier = "changed", cacheKey = "old-ck"), + com.superwall.sdk.models.paywall.Paywall.stub().copy(identifier = "removed", cacheKey = "removed-ck"), + ), + ) + val newConfig = config(buildId = "new", enableRefresh = true).copy( + paywalls = listOf( + com.superwall.sdk.models.paywall.Paywall.stub().copy(identifier = "keep", cacheKey = "keep-ck"), + com.superwall.sdk.models.paywall.Paywall.stub().copy(identifier = "changed", cacheKey = "new-ck"), + ), + ) + val s = setup(backgroundScope, networkConfig = Either.Success(newConfig)) + s.manager.applyRetrievedConfigForTesting(oldConfig) + + s.manager.refreshConfiguration() + advanceUntilIdle() + + verify(atLeast = 1) { + s.paywallManager.resetPaywallRequestCache(setOf("changed", "removed")) } - val s = setup(backgroundScope) - val mgr = ConfigManagerForTest( - context = mockk(relaxed = true), - storage = s.storage, - network = mockk { - coEvery { getConfig(any()) } returns Either.Success(newConfig) - coEvery { getEnrichment(any(), any(), any()) } returns Either.Success(Enrichment.stub()) - }, - deviceHelper = s.deviceHelper, - paywallManager = paywallManager, - storeManager = s.storeManager, - preload = preload, - webRedeemer = s.webRedeemer, - factory = mockk(relaxed = true) { - coEvery { makeSessionDeviceAttributes() } returns HashMap() - }, - entitlements = mockk(relaxed = true) { - every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown) - every { entitlementsByProductId } returns emptyMap() - }, - assignments = mockk(relaxed = true), - options = SuperwallOptions().apply { paywalls.shouldPreload = false }, - ioScope = backgroundScope, - testMode = null, - tracker = {}, - setSubscriptionStatus = null, - activateTestMode = { _, _ -> }, + verify(exactly = 0) { s.paywallManager.resetPaywallRequestCache() } + verify(atLeast = 1) { s.preload.invalidatePreloadFingerprint() } + coVerify(atLeast = 1) { s.preload.removeUnusedPaywallVCsFromCache(oldConfig, newConfig) } + } + + @Test + fun `refreshConfiguration with unchanged paywalls leaves request cache untouched`() = + runTest(timeout = 30.seconds) { + val paywalls = listOf( + com.superwall.sdk.models.paywall.Paywall.stub().copy(identifier = "keep", cacheKey = "keep-ck"), ) - mgr.applyRetrievedConfigForTesting(oldConfig) + val oldConfig = config(buildId = "old", enableRefresh = true).copy(paywalls = paywalls) + val newConfig = config(buildId = "new", enableRefresh = true).copy(paywalls = paywalls) + val s = setup(backgroundScope, networkConfig = Either.Success(newConfig)) + s.manager.applyRetrievedConfigForTesting(oldConfig) - mgr.refreshConfiguration() + s.manager.refreshConfiguration() advanceUntilIdle() - verify(atLeast = 1) { paywallManager.resetPaywallRequestCache() } - coVerify(atLeast = 1) { preload.removeUnusedPaywallVCsFromCache(oldConfig, newConfig) } + verify(exactly = 0) { s.paywallManager.resetPaywallRequestCache() } + verify(exactly = 0) { s.paywallManager.resetPaywallRequestCache(any()) } + verify(exactly = 0) { s.preload.invalidatePreloadFingerprint() } + coVerify(atLeast = 1) { s.preload.removeUnusedPaywallVCsFromCache(oldConfig, newConfig) } } @Test diff --git a/superwall/src/test/java/com/superwall/sdk/config/PaywallPreloadTest.kt b/superwall/src/test/java/com/superwall/sdk/config/PaywallPreloadTest.kt index 96a6baf72..fc932efd2 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/PaywallPreloadTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/PaywallPreloadTest.kt @@ -23,6 +23,8 @@ import io.mockk.verify import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class PaywallPreloadTest { @@ -137,4 +139,60 @@ class PaywallPreloadTest { } } } + + @Test + fun `changedPaywallIds returns removed and cacheKey-changed identifiers`() { + Given("old and new configs with removed, changed, unchanged and added paywalls") { + val oldConfig = + Config.stub().copy( + paywalls = + listOf( + Paywall.stub().copy(identifier = "keep", cacheKey = "keep-ck"), + Paywall.stub().copy(identifier = "remove", cacheKey = "remove-ck"), + Paywall.stub().copy(identifier = "changed", cacheKey = "old-ck"), + ), + ) + val newConfig = + Config.stub().copy( + paywalls = + listOf( + Paywall.stub().copy(identifier = "keep", cacheKey = "keep-ck"), + Paywall.stub().copy(identifier = "changed", cacheKey = "new-ck"), + Paywall.stub().copy(identifier = "added", cacheKey = "added-ck"), + ), + ) + + When("changedPaywallIds is computed") { + val changed = PaywallPreload.changedPaywallIds(oldConfig, newConfig) + + Then("only removed and cacheKey-changed identifiers are returned") { + assertEquals(setOf("remove", "changed"), changed) + } + } + } + } + + @Test + fun `invalidatePreloadFingerprint clears lastFingerprint`() { + Given("a preload with a recorded fingerprint") { + val preload = + PaywallPreload( + factory = mockk(relaxed = true), + scope = IOScope(testDispatcher), + storage = mockk(relaxed = true), + assignments = mockk(relaxed = true), + paywallManager = mockk(relaxed = true), + track = {}, + ) + preload.lastFingerprint.set("fingerprint") + + When("invalidatePreloadFingerprint is called") { + preload.invalidatePreloadFingerprint() + + Then("the fingerprint is cleared") { + assertNull(preload.lastFingerprint.get()) + } + } + } + } } diff --git a/superwall/src/test/java/com/superwall/sdk/logger/LoggerTest.kt b/superwall/src/test/java/com/superwall/sdk/logger/LoggerTest.kt new file mode 100644 index 000000000..6cff518a7 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/logger/LoggerTest.kt @@ -0,0 +1,245 @@ +package com.superwall.sdk.logger + +import com.superwall.sdk.And +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.assertFalse +import com.superwall.sdk.assertTrue +import com.superwall.sdk.delegate.SuperwallDelegate +import com.superwall.sdk.delegate.SuperwallDelegateAdapter +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals + +/** + * Records what was printed and which thread printed it. + */ +private class RecordingPrintStream : PrintStream(ByteArrayOutputStream()) { + val lines = CopyOnWriteArrayList() + val threads = CopyOnWriteArrayList() + + private fun record(value: Any?) { + lines.add(value?.toString() ?: "") + threads.add(Thread.currentThread().name) + } + + override fun println(x: Any?) = record(x) + + override fun println(x: String?) = record(x) +} + +class LoggerTest { + private val originalOut = System.out + private lateinit var out: RecordingPrintStream + + /** + * Drains the log queue by posting a sentinel behind the logs under test. The queue is + * FIFO on a single thread, so once the sentinel runs everything before it has been + * delivered. + */ + private fun awaitLogQueue() { + val latch = CountDownLatch(1) + LogQueue.post { latch.countDown() } + assertTrue(latch.await(5, TimeUnit.SECONDS)) + } + + @Before + fun setUp() { + out = RecordingPrintStream() + System.setOut(out) + // A fresh adapter clears the global "a delegate is listening" flag. + SuperwallDelegateAdapter() + } + + @After + fun tearDown() { + System.setOut(originalOut) + LogQueue.synchronous = false + SuperwallDelegateAdapter() + } + + @Test + fun `does not build a log that nothing will consume`() { + Given("no delegate is registered and Superwall is not configured") { + var built = false + + When("logging below the default console level of warn") { + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = { + built = true + "expensive" + }, + ) + } + + Then("the message is never built") { + assertFalse(built) + } + + And("nothing is printed") { + awaitLogQueue() + assertTrue(out.lines.isEmpty()) + } + } + } + + @Test + fun `builds a log when a delegate is listening`() { + Given("a registered delegate") { + val adapter = SuperwallDelegateAdapter() + adapter.kotlinDelegate = object : SuperwallDelegate {} + var built = false + + When("logging below the console level") { + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = { + built = true + "expensive" + }, + ) + } + + Then("the message is built for the delegate") { + assertTrue(built) + } + } + } + + @Test + fun `clears the delegate flag when the delegate is removed`() { + Given("a registered delegate") { + val adapter = SuperwallDelegateAdapter() + adapter.kotlinDelegate = object : SuperwallDelegate {} + assertTrue(SuperwallDelegateAdapter.hasAnyDelegate) + + When("the delegate is set back to null") { + adapter.kotlinDelegate = null + } + + Then("no delegate is listening") { + assertFalse(SuperwallDelegateAdapter.hasAnyDelegate) + } + } + } + + @Test + fun `delivers logs off the calling thread`() { + Given("a log printed at the default console level") { + val callingThread = Thread.currentThread().name + + When("logging an error") { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.paywallView, + message = "off thread", + ) + awaitLogQueue() + } + + Then("it was printed from another thread") { + assertTrue(out.threads.isNotEmpty()) + assertTrue(out.threads.none { it == callingThread }) + } + } + } + + @Test + fun `preserves log order`() { + Given("many logs from one thread") { + When("logging in sequence") { + repeat(50) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.paywallView, + message = "message $it", + ) + } + awaitLogQueue() + } + + Then("they are printed in submission order") { + val printed = out.lines.filter { it.contains("message ") } + assertEquals(50, printed.size) + printed.forEachIndexed { index, line -> + assertTrue(line.contains("message $index")) + } + } + } + } + + @Test + fun `prints the message alongside its info`() { + Given("a log carrying info") { + When("logging with a non-empty info map") { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.paywallView, + message = "the message", + info = mapOf("key" to "value"), + ) + awaitLogQueue() + } + + Then("the message is printed") { + assertTrue(out.lines.any { it.contains("the message") }) + } + + And("the info is printed") { + assertTrue(out.lines.any { it.contains("key") && it.contains("value") }) + } + } + } + + @Test + fun `omits an empty info map`() { + Given("a log with an empty info map") { + When("logging") { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.paywallView, + message = "no info", + info = emptyMap(), + ) + awaitLogQueue() + } + + Then("the message is printed") { + assertTrue(out.lines.any { it.contains("no info") }) + } + + And("no info line is printed") { + assertFalse(out.lines.any { it.startsWith("info:") }) + } + } + } + + @Test + fun `keeps delivering after a listener throws`() { + Given("a log delivery that throws") { + When("posting a throwing block followed by a healthy one") { + LogQueue.post { throw IllegalStateException("boom") } + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.paywallView, + message = "survives", + ) + awaitLogQueue() + } + + Then("the later log is still delivered") { + assertTrue(out.lines.any { it.contains("survives") }) + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerTest.kt index 3e5a4667e..2c61dba8d 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerTest.kt @@ -88,7 +88,6 @@ class PaywallManagerTest { every { mockView1.destroyWebview() } just Runs every { mockView2.destroyWebview() } just Runs every { cache.getAllPaywallViews() } returns listOf(mockView1, mockView2) - every { cache.entries } returns emptyMap() every { cache.activePaywallVcKey } returns null every { cache.removeAll() } just Runs @@ -345,6 +344,16 @@ class PaywallManagerTest { verify { paywallRequestManager.resetCache() } } + @Test + fun test_resetPaywallRequestCache_withIdentifiers_removesOnlyThose() { + every { paywallRequestManager.removeCachedPaywalls(any()) } just Runs + + paywallManager.resetPaywallRequestCache(setOf("paywall_one", "paywall_two")) + + verify { paywallRequestManager.removeCachedPaywalls(setOf("paywall_one", "paywall_two")) } + verify(exactly = 0) { paywallRequestManager.resetCache() } + } + @Test fun test_cacheIsCreatedLazily() { val manager = PaywallManager(factory, paywallRequestManager) diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRulesOperatorTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRulesOperatorTest.kt index ac934cf74..60a3a71e2 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRulesOperatorTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRulesOperatorTest.kt @@ -78,7 +78,7 @@ class EvaluateRulesOperatorTest { val matchedVariant = Experiment.Variant(id = "v1", type = Experiment.Variant.VariantType.TREATMENT, paywallId = "paywall") every { storage.getConfirmedAssignments() } returns mapOf("exp" to matchedVariant) every { assignments.unconfirmedAssignments } returns emptyMap() - coEvery { evaluator.evaluateExpression(triggerRule, event) } returns TriggerRuleOutcome.Match(MatchedItem(triggerRule)) + coEvery { evaluator.evaluateExpression(triggerRule, event, null) } returns TriggerRuleOutcome.Match(MatchedItem(triggerRule)) val result = When("evaluateRules is invoked") { diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt index 13a84869e..e985671f8 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test @@ -289,4 +290,140 @@ class WaitForSubsStatusAndConfigTest { } } } + + @Test + fun `waitForEntitlementsAndConfig surfaces the entitlements timeout when both waits time out`() = + runTest { + Given("entitlements stay Unknown and config stays Retrieving") { + val dependencyContainer = mockk(relaxed = true) + every { dependencyContainer.ioScope() } returns com.superwall.sdk.misc.IOScope(coroutineContext) + + val configState = MutableStateFlow(ConfigState.Retrieving) + every { dependencyContainer.configManager.configState } returns configState + + mockkStatic("com.superwall.sdk.analytics.internal.TrackingKt") + + val publisher = MutableSharedFlow(replay = 1) + val request = + PresentationRequest( + presentationInfo = PresentationInfo.ExplicitTrigger(EventData.stub()), + flags = + PresentationRequest.Flags( + isDebuggerLaunched = false, + entitlements = MutableStateFlow(SubscriptionStatus.Unknown), + isPaywallPresented = false, + type = PresentationRequestType.Presentation, + ), + ) + + When("waitForEntitlementsAndConfig executes") { + assertFailsWith { + waitForEntitlementsAndConfig(request, paywallStatePublisher = publisher, dependencyContainer = dependencyContainer) + } + + Then("the entitlements timeout error is emitted, not the config one") { + val emitted = publisher.replayCache.lastOrNull() + assert(emitted is PaywallState.PresentationError) + val message = (emitted as PaywallState.PresentationError).error.message ?: "" + assert(message.contains("105")) + } + } + } + } + + @Test + fun `waitForEntitlementsAndConfig awaits entitlements and config concurrently`() = + runTest { + Given("entitlements resolve at 4s while config stays Retrieving forever") { + val dependencyContainer = mockk(relaxed = true) + every { dependencyContainer.ioScope() } returns com.superwall.sdk.misc.IOScope(coroutineContext) + + val configState = MutableStateFlow(ConfigState.Retrieving) + every { dependencyContainer.configManager.configState } returns configState + + mockkStatic("com.superwall.sdk.analytics.internal.TrackingKt") + + val entitlements = MutableStateFlow(SubscriptionStatus.Unknown) + backgroundScope.launch { + delay(4.seconds) + entitlements.value = SubscriptionStatus.Inactive + } + + val publisher = MutableSharedFlow(replay = 1) + val request = + PresentationRequest( + presentationInfo = PresentationInfo.ExplicitTrigger(EventData.stub()), + flags = + PresentationRequest.Flags( + isDebuggerLaunched = false, + entitlements = entitlements, + isPaywallPresented = false, + type = PresentationRequestType.Presentation, + ), + ) + + When("waitForEntitlementsAndConfig executes") { + val start = currentTime + assertFailsWith { + waitForEntitlementsAndConfig(request, paywallStatePublisher = publisher, dependencyContainer = dependencyContainer) + } + + Then("the config timeout window overlapped the entitlements wait") { + // Fully sequential waits would take 4s + 6s + 6s retry = 16s; + // overlapped, the first config window ends at 6s and the + // post-entitlements retry exhausts at 12s. + assert(currentTime - start <= 13_000) + } + } + } + } + + @Test + fun `waitForEntitlementsAndConfig succeeds when config lands during the post-entitlements retry`() = + runTest { + Given("entitlements resolve at 4500ms and config resolves at 8s") { + val dependencyContainer = mockk(relaxed = true) + every { dependencyContainer.ioScope() } returns com.superwall.sdk.misc.IOScope(coroutineContext) + + val configState = MutableStateFlow(ConfigState.Retrieving) + every { dependencyContainer.configManager.configState } returns configState + + val identityManager = mockk(relaxed = true) + coEvery { identityManager.awaitLatestIdentity() } returns mockk(relaxed = true) + every { dependencyContainer.identityManager } returns identityManager + + val entitlements = MutableStateFlow(SubscriptionStatus.Unknown) + backgroundScope.launch { + delay(4500.milliseconds) + entitlements.value = SubscriptionStatus.Inactive + } + backgroundScope.launch { + delay(8.seconds) + configState.value = ConfigState.Retrieved(Config.stub()) + } + + val publisher = MutableSharedFlow(replay = 1) + val request = + PresentationRequest( + presentationInfo = PresentationInfo.ExplicitTrigger(EventData.stub()), + flags = + PresentationRequest.Flags( + isDebuggerLaunched = false, + entitlements = entitlements, + isPaywallPresented = false, + type = PresentationRequestType.Presentation, + ), + ) + + When("waitForEntitlementsAndConfig executes") { + // The concurrent config windows expire at 6s (starved by the slow + // entitlements), but the fresh sequential retry picks up the 8s config. + waitForEntitlementsAndConfig(request, paywallStatePublisher = publisher, dependencyContainer = dependencyContainer) + + Then("it completes without throwing and emits no error") { + assert(publisher.replayCache.isEmpty()) + } + } + } + } } diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogicTest.kt index 75cff6c0f..fe5e28226 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogicTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogicTest.kt @@ -18,9 +18,11 @@ import com.superwall.sdk.models.triggers.TriggerRuleOccurrence import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.models.triggers.VariantOption +import com.superwall.sdk.paywall.presentation.rule_logic.cel.models.PassableValue import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluating import com.superwall.sdk.storage.LocalStorage import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -38,6 +40,7 @@ class RuleLogicTest { private fun rule( experimentId: String, experimentGroupId: String = "group", + expressionCEL: String? = null, ): TriggerRule = TriggerRule( experimentId = experimentId, @@ -51,9 +54,28 @@ class RuleLogicTest { paywallId = "paywall", ), ), + expressionCEL = expressionCEL, preload = TriggerRule.TriggerPreload(TriggerPreloadBehavior.ALWAYS), ) + private class RecordingEvaluator : ExpressionEvaluating { + val receivedSharedAttributes = mutableListOf() + + override suspend fun evaluateExpression( + rule: TriggerRule, + eventData: EventData?, + ): TriggerRuleOutcome = evaluateExpression(rule, eventData, null) + + override suspend fun evaluateExpression( + rule: TriggerRule, + eventData: EventData?, + sharedAttributes: PassableValue.MapValue?, + ): TriggerRuleOutcome { + receivedSharedAttributes.add(sharedAttributes) + return TriggerRuleOutcome.NoMatch(UnmatchedRule(UnmatchedRule.Source.EXPRESSION, rule.experiment.id)) + } + } + private fun trigger(rule: TriggerRule): Trigger = Trigger(eventName = "event", rules = listOf(rule)) @Test @@ -83,7 +105,7 @@ class RuleLogicTest { val rule = rule("exp") val triggers = mapOf("event" to trigger(rule)) val event = EventData.stub().copy(name = "event") - coEvery { evaluator.evaluateExpression(rule, event) } returns + coEvery { evaluator.evaluateExpression(rule, event, null) } returns TriggerRuleOutcome.NoMatch(UnmatchedRule(UnmatchedRule.Source.EXPRESSION, "exp")) val result = @@ -108,7 +130,7 @@ class RuleLogicTest { val rule = rule("missing") val triggers = mapOf("event" to trigger(rule)) val event = EventData.stub().copy(name = "event") - coEvery { evaluator.evaluateExpression(rule, event) } returns TriggerRuleOutcome.Match(MatchedItem(rule)) + coEvery { evaluator.evaluateExpression(rule, event, null) } returns TriggerRuleOutcome.Match(MatchedItem(rule)) every { storage.getConfirmedAssignments() } returns emptyMap() every { assignments.unconfirmedAssignments } returns emptyMap() @@ -136,7 +158,7 @@ class RuleLogicTest { val triggers = mapOf("event" to trigger(rule)) val event = EventData.stub().copy(name = "event") val matchedOccurrence = TriggerRuleOccurrence.stub() - coEvery { evaluator.evaluateExpression(rule, event) } returns + coEvery { evaluator.evaluateExpression(rule, event, null) } returns TriggerRuleOutcome.Match(MatchedItem(rule, matchedOccurrence)) every { storage.getConfirmedAssignments() } returns emptyMap() val holdoutVariant = Experiment.Variant(id = "h1", type = Experiment.Variant.VariantType.HOLDOUT, paywallId = null) @@ -167,7 +189,7 @@ class RuleLogicTest { val rule = rule(experimentId) val triggers = mapOf("event" to trigger(rule)) val event = EventData.stub().copy(name = "event") - coEvery { evaluator.evaluateExpression(rule, event) } returns TriggerRuleOutcome.Match(MatchedItem(rule)) + coEvery { evaluator.evaluateExpression(rule, event, null) } returns TriggerRuleOutcome.Match(MatchedItem(rule)) val treatmentVariant = Experiment.Variant(id = "t1", type = Experiment.Variant.VariantType.TREATMENT, paywallId = "paywall") every { storage.getConfirmedAssignments() } returns mapOf(experimentId to treatmentVariant) every { assignments.unconfirmedAssignments } returns emptyMap() @@ -187,4 +209,119 @@ class RuleLogicTest { } } } + + @Test + fun buildsSharedAttributesOncePerPassAndRecomputesThemAcrossPasses() = + runTest { + Given("a trigger with two CEL rules and user attributes that change between passes") { + val ruleOne = rule("expOne", expressionCEL = "user.age > 18") + val ruleTwo = rule("expTwo", expressionCEL = "user.age > 21") + val triggers = mapOf("event" to Trigger(eventName = "event", rules = listOf(ruleOne, ruleTwo))) + val event = EventData.stub().copy(name = "event") + var age = 20 + coEvery { factory.makeRuleAttributes(event, emptyList()) } answers { + mapOf( + "user" to mapOf("age" to age), + "device" to emptyMap(), + "params" to "", + ) + } + val recordingEvaluator = RecordingEvaluator() + val logic = RuleLogic(assignments, storage, factory, recordingEvaluator) + + When("evaluateRules runs twice with the attributes changing in between") { + logic.evaluateRules(event, triggers) + age = 21 + logic.evaluateRules(event, triggers) + } + + Then("attributes are built once per pass and shared by both rules of that pass") { + coVerify(exactly = 2) { factory.makeRuleAttributes(event, emptyList()) } + val received = recordingEvaluator.receivedSharedAttributes + assertEquals(4, received.size) + assertTrue(received[0] === received[1]) + assertTrue(received[2] === received[3]) + + fun ageOf(attributes: PassableValue.MapValue?): PassableValue? = + (attributes?.value?.get("user") as? PassableValue.MapValue)?.value?.get("age") + assertEquals(PassableValue.IntValue(20), ageOf(received[0])) + assertEquals(PassableValue.IntValue(21), ageOf(received[2])) + } + } + } + + @Test + fun skipsSharedAttributesWhenNoRuleHasCelExpression() = + runTest { + Given("a trigger whose rules have no CEL expressions") { + val plainRule = rule("exp") + val triggers = mapOf("event" to Trigger(eventName = "event", rules = listOf(plainRule))) + val event = EventData.stub().copy(name = "event") + val recordingEvaluator = RecordingEvaluator() + val logic = RuleLogic(assignments, storage, factory, recordingEvaluator) + + When("evaluateRules executes") { + logic.evaluateRules(event, triggers) + } + + Then("no attributes are built and the evaluator receives none") { + coVerify(exactly = 0) { factory.makeRuleAttributes(any(), any()) } + assertEquals(listOf(null), recordingEvaluator.receivedSharedAttributes) + } + } + } + + @Test + fun skipsSharedAttributesWhenNonCelRuleMatchesBeforeAnyCelRule() = + runTest { + Given("a trigger whose first, non-CEL rule matches ahead of a CEL rule") { + val plainRule = rule("plain") + val celRule = rule("cel", expressionCEL = "user.age > 18") + val triggers = mapOf("event" to Trigger(eventName = "event", rules = listOf(plainRule, celRule))) + val event = EventData.stub().copy(name = "event") + coEvery { evaluator.evaluateExpression(plainRule, event, null) } returns TriggerRuleOutcome.Match(MatchedItem(plainRule)) + every { storage.getConfirmedAssignments() } returns emptyMap() + val treatmentVariant = Experiment.Variant(id = "t1", type = Experiment.Variant.VariantType.TREATMENT, paywallId = "paywall") + every { assignments.unconfirmedAssignments } returns mapOf("plain" to treatmentVariant) + + val result = + When("evaluateRules executes") { + RuleLogic(assignments, storage, factory, evaluator).evaluateRules(event, triggers) + } + + Then("no attributes are built and the paywall outcome is returned") { + coVerify(exactly = 0) { factory.makeRuleAttributes(any(), any()) } + assertTrue(result is Either.Success) + val outcome = (result as Either.Success).value + assertTrue(outcome.triggerResult is InternalTriggerResult.Paywall) + } + } + } + + @Test + fun degradesToNullSharedAttributesWhenBuildingThemFails() = + runTest { + Given("a trigger with CEL rules whose shared attribute build throws") { + val ruleOne = rule("expOne", expressionCEL = "user.age > 18") + val ruleTwo = rule("expTwo", expressionCEL = "user.age > 21") + val triggers = mapOf("event" to Trigger(eventName = "event", rules = listOf(ruleOne, ruleTwo))) + val event = EventData.stub().copy(name = "event") + coEvery { factory.makeRuleAttributes(event, emptyList()) } throws IllegalStateException("attributes unavailable") + val recordingEvaluator = RecordingEvaluator() + val logic = RuleLogic(assignments, storage, factory, recordingEvaluator) + + val result = + When("evaluateRules executes") { + logic.evaluateRules(event, triggers) + } + + Then("the build is attempted once and both rules degrade to per-rule attributes") { + coVerify(exactly = 1) { factory.makeRuleAttributes(event, emptyList()) } + assertEquals(listOf(null, null), recordingEvaluator.receivedSharedAttributes) + assertTrue(result is Either.Success) + val outcome = (result as Either.Success).value + assertTrue(outcome.triggerResult is InternalTriggerResult.NoAudienceMatch) + } + } + } } diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluatorTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluatorTest.kt new file mode 100644 index 000000000..95e98ca46 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluatorTest.kt @@ -0,0 +1,91 @@ +package com.superwall.sdk.paywall.presentation.rule_logic.cel + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.assertTrue +import com.superwall.sdk.dependencies.RuleAttributesFactory +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.models.config.ComputedPropertyRequest +import com.superwall.sdk.paywall.presentation.rule_logic.cel.models.PassableValue +import com.superwall.sdk.storage.core_data.CoreDataManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +class SuperscriptEvaluatorTest { + private val json = Json { ignoreUnknownKeys = true } + private val storage = mockk() + private val factory = mockk(relaxed = true) + private val evaluator = + SuperscriptEvaluator( + json = json, + ioScope = IOScope(), + storage = storage, + factory = factory, + ) + + private fun sharedAttributes(): PassableValue.MapValue = + PassableValue.MapValue( + mapOf( + "user" to PassableValue.MapValue(mapOf("age" to PassableValue.IntValue(20))), + "device" to PassableValue.MapValue(mapOf("os" to PassableValue.StringValue("Android"))), + "params" to PassableValue.StringValue(""), + ), + ) + + @Test + fun returnsSharedAttributesUnchangedWithoutComputedProperties() = + runTest { + Given("shared attributes and a rule without computed property requests") { + val base = sharedAttributes() + + val result = + When("computed properties are overlaid") { + evaluator.overlayComputedProperties(base, null, emptyList()) + } + + Then("the shared attributes are returned as-is and storage is never queried") { + assertTrue(result === base) + coVerify(exactly = 0) { storage.getComputedPropertySinceEvent(any(), any()) } + } + } + } + + @Test + fun resolvesComputedPropertiesFreshOnEveryOverlay() = + runTest { + Given("shared attributes and a computed property whose value changes between evaluations") { + val base = sharedAttributes() + val request = + ComputedPropertyRequest( + type = ComputedPropertyRequest.ComputedPropertyRequestType.DAYS_SINCE, + eventName = "campaign_trigger", + ) + coEvery { storage.getComputedPropertySinceEvent(null, request) } returns 1 andThen 2 + + val first = + When("the same shared attributes are overlaid twice") { + evaluator.overlayComputedProperties(base, null, listOf(request)) + } + val second = evaluator.overlayComputedProperties(base, null, listOf(request)) + + Then("each overlay resolves the computed property fresh") { + fun deviceOf(attributes: PassableValue.MapValue): Map = + (attributes.value["device"] as PassableValue.MapValue).value + + coVerify(exactly = 2) { storage.getComputedPropertySinceEvent(null, request) } + assertEquals(PassableValue.IntValue(1), deviceOf(first)["daysSince_campaign_trigger"]) + assertEquals(PassableValue.IntValue(2), deviceOf(second)["daysSince_campaign_trigger"]) + + assertEquals(PassableValue.StringValue("Android"), deviceOf(first)["os"]) + assertEquals(null, deviceOf(base)["daysSince_campaign_trigger"]) + assertTrue(base.value["user"] === first.value["user"]) + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt index 329503488..b85711b88 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt @@ -66,6 +66,9 @@ class PaywallRequestManagerTest { ioScope = ioScope, track = { trackedEvents.add(it) }, getGlobalOverrides = { globalOverrides }, + // Unconfined so lifecycle tracks run inline and assertions on + // trackedEvents don't race the real single-lane IO dispatcher. + trackScope = IOScope(Dispatchers.Unconfined), ) } @@ -430,6 +433,91 @@ class PaywallRequestManagerTest { coVerify(exactly = 2) { network.getPaywall(any(), any()) } } + @Test + fun test_removeCachedPaywalls_removesOnlyMatchingIdentifiers() = + runTest { + val paywallOne = Paywall.stub().copy(identifier = "paywall_one") + val paywallTwo = Paywall.stub().copy(identifier = "paywall_two") + val requestOne = + mockk { + every { responseIdentifiers } returns ResponseIdentifiers(paywallId = "paywall_one") + every { eventData } returns null + every { overrides } returns PaywallRequest.Overrides(products = null, isFreeTrial = null) + every { isDebuggerLaunched } returns false + every { presentationSourceType } returns null + } + val requestTwo = + mockk { + every { responseIdentifiers } returns ResponseIdentifiers(paywallId = "paywall_two") + every { eventData } returns null + every { overrides } returns PaywallRequest.Overrides(products = null, isFreeTrial = null) + every { isDebuggerLaunched } returns false + every { presentationSourceType } returns null + } + + coEvery { network.getPaywall("paywall_one", any()) } returns Either.Success(paywallOne) + coEvery { network.getPaywall("paywall_two", any()) } returns Either.Success(paywallTwo) + coEvery { storeManager.getProducts(any(), any(), any()) } returns + mockk { + every { productItems } returns emptyList() + every { productsByFullId } returns emptyMap() + every { this@mockk.paywall } returns null + } + + // Populate the cache with both paywalls + requestManager.getPaywall(requestOne) + requestManager.getPaywall(requestTwo) + coVerify(exactly = 1) { network.getPaywall("paywall_one", any()) } + coVerify(exactly = 1) { network.getPaywall("paywall_two", any()) } + + requestManager.removeCachedPaywalls(setOf("paywall_one")) + + // Removed paywall hits the network again, the other stays cached + requestManager.getPaywall(requestOne) + requestManager.getPaywall(requestTwo) + coVerify(exactly = 2) { network.getPaywall("paywall_one", any()) } + coVerify(exactly = 1) { network.getPaywall("paywall_two", any()) } + } + + @Test + fun test_getPaywall_skipsCacheWrite_whenCacheInvalidatedMidFetch() = + runTest { + val paywall = Paywall.stub().copy(identifier = "test_paywall") + val request = + mockk { + every { responseIdentifiers } returns ResponseIdentifiers(paywallId = "test_paywall") + every { eventData } returns null + every { overrides } returns PaywallRequest.Overrides(products = null, isFreeTrial = null) + every { isDebuggerLaunched } returns false + every { presentationSourceType } returns null + } + + var networkCalls = 0 + coEvery { network.getPaywall(any(), any()) } coAnswers { + networkCalls++ + if (networkCalls == 1) { + // Config refresh invalidates the cache while the fetch is in flight + requestManager.removeCachedPaywalls(setOf("test_paywall")) + } + Either.Success(paywall) + } + coEvery { storeManager.getProducts(any(), any(), any()) } returns + mockk { + every { productItems } returns emptyList() + every { productsByFullId } returns emptyMap() + every { this@mockk.paywall } returns null + } + + // First call: fetch started before the invalidation, so its (stale) + // result is still returned but must NOT be written to the cache + val result1 = requestManager.getPaywall(request) + assertTrue(result1 is Either.Success) + + // Second call must refetch instead of hitting a stale cache entry + requestManager.getPaywall(request) + coVerify(exactly = 2) { network.getPaywall(any(), any()) } + } + @Test fun test_getPaywall_usesStaticPaywall_whenAvailable() = runTest {