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 @@
-
\ No newline at end of file
+
\ 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 @@
-
\ No newline at end of file
+
\ 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 {