Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/skills/contract-conformance/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"AdaptyProfile.AccessLevel": "AdaptyProfile+AccessLevel",
"AdaptyProfile.NonSubscription": "AdaptyProfile+NonSubscription",
"AdaptyProfile.Subscription": "AdaptyProfile+Subscription",
"AdaptyProfile.SubscriptionCommitmentInfo": "AdaptyProfile+SubscriptionCommitmentInfo",
"AdaptyFlowPaywall.ProductReference": "AdaptyFlowPaywall+ProductReference",
# $assets variants: one contract object, several C# types. Their members are unioned.
"Color": "AdaptyCustomAssetColor",
Expand Down Expand Up @@ -71,6 +72,8 @@
"AdaptySubscriptionOffer.Identifier.Type": "AdaptySubscriptionOfferType",
"AdaptyWebPresentation": "AdaptyWebPresentation",
"AdaptyUI.DialogActionType": "AdaptyUIDialogActionType",
# The contract's "default" is a synonym for "auto" in iOS and has no member on purpose.
"StoreMessagesHandling": "AdaptyStoreMessagesHandling",
}


Expand Down
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This is a **Unity project** (Unity 6000.x) — the player is built and tested th

The package declares **Unity 6.0 (6000.0) and newer** as `unity` in `package.json` — raised from 2022.3 on 2026-09-01 so the package may use UI Toolkit runtime bindings (2023.2+) for the Editor mock UI — and that floor is what all package code may assume. Demo-only code under `Assets/` may go further (the project itself runs 6.6).

The floor raise is unreleased: 4.1.x shipped on 2022.3, and its install path — `.unitypackage` import into a clean project, then `Adapty SDK > Install Dependencies`, then a compile — was verified on 2022.3.62f3. Before the release that carries the raise, verify that path on a 6000.0.x build and say so in the changelog entry (there is one under Unreleased); do not widen it to claim device or build coverage on 6000.0. `MIGRATION-v3.17-to-v4.1.md` describes 4.1 as it shipped and is not edited for later floors.
The floor raise ships in 4.3.0. 4.1.x shipped on 2022.3, and its install path — `.unitypackage` import into a clean project, then `Adapty SDK > Install Dependencies`, then a compile — was verified on 2022.3.62f3; for 4.3.0 the same path was verified on 6000.0.74f1, headless, and the 4.3.0 changelog entry says so. Do not widen that to claim device or build coverage on 6000.0. `MIGRATION-v3.17-to-v4.1.md` describes 4.1 as it shipped and is not edited for later floors.

**Run the JSON layer tests:**
```bash
Expand Down Expand Up @@ -126,7 +126,7 @@ Not every `[RuntimeInitializeOnLoadMethod]` here is a reset. `Adapty.InitializeT

### Platform conditionals

There are 25 `#if` in the package, and each belongs to one of five kinds. **Compilation boundary**, 7 — a native symbol exists only there: the `_Adapty` and `_AdaptyCallbackAction` aliases, everything under `Plugins/iOS` and `Plugins/Android`, the Kids Mode post-processor. **Wire contract**, 12 — the contract itself differs: a `[DataMember]` the schema marks platform-only (`is_family_shareable` on both product responses), `offer_tags` read on Android alone, the offer id required on one more branch there. **Public API behaviour**, 3 — the iOS-only methods that on an Android device report `null`, meaning success. Their guard is `UNITY_IOS || UNITY_EDITOR` rather than `UNITY_IOS && !UNITY_EDITOR`, so the Editor reaches the no-op bridge like every other method instead of taking that branch; three `TransportTests` cases pin it. **Where a file actually is**, 2 — a `StreamingAssets` path is a different location on each platform, and the two places that resolve one do **not** agree about the Editor. `AdaptyCustomAssetPath.Resolve` has an `#else` and hands the path back unchanged. `Adapty.SetFallback` has none, so in the Editor it sends no `path` key at all — `transport-set-fallback.editor.approved.txt` is the whole request, `{"method": "set_fallback"}`. Nothing is lost by that: the Editor call reaches the no-op bridge and comes back with the not-supported error, having never needed a path. It is not an omission to repair. **The Kids Mode define**, 1 — `AdaptyConfiguration` forces `apple_idfa_collection_disabled` under `ADAPTY_KIDS_MODE && UNITY_IOS`, because the trait has compiled IDFA out of the binary and the request has to say so.
There are 30 `#if` in the package, and each belongs to one of five kinds. **Compilation boundary**, 7 — a native symbol exists only there: the `_Adapty` and `_AdaptyCallbackAction` aliases, everything under `Plugins/iOS` and `Plugins/Android`, the Kids Mode post-processor. **Wire contract**, 14 — the contract itself differs: a `[DataMember]` the schema marks platform-only (`is_family_shareable` on both product responses, `pricing_terms` read and handed back on iOS alone), a key the schema requires on one platform only (`currency_code` and `localized_string` of a price on iOS), `offer_tags` read on Android alone, the offer id required on one more branch there. **Public API behaviour**, 6 — the iOS-only methods that on an Android device report `null`, meaning success, `GetPendingStoreMessageTypes`, which reports a null list there, the filter `ShowStoreMessages` sends on iOS alone, because the Android bridge takes no filter, and the JWS `ReportTransaction` sends on iOS alone, because there is no StoreKit transaction on Android. Their guard is `UNITY_IOS || UNITY_EDITOR` rather than `UNITY_IOS && !UNITY_EDITOR`, so the Editor reaches the no-op bridge like every other method instead of taking that branch; `TransportTests` cases pin it. **Where a file actually is**, 2 — a `StreamingAssets` path is a different location on each platform, and the two places that resolve one do **not** agree about the Editor. `AdaptyCustomAssetPath.Resolve` has an `#else` and hands the path back unchanged. `Adapty.SetFallback` has none, so in the Editor it sends no `path` key at all — `transport-set-fallback.editor.approved.txt` is the whole request, `{"method": "set_fallback"}`. Nothing is lost by that: the Editor call reaches the no-op bridge and comes back with the not-supported error, having never needed a path. It is not an omission to repair. **The Kids Mode define**, 1 — `AdaptyConfiguration` forces `apple_idfa_collection_disabled` under `ADAPTY_KIDS_MODE && UNITY_IOS`, because the trait has compiled IDFA out of the binary and the request has to say so.

The last two kinds are not platform questions the layer above could have answered, which is why they are here rather than folded away. Nothing else qualifies. A constructor must not re-decide by define what the layer above already decided: `AdaptySubscriptionOffer` used to null `OfferTags` off Android although its only caller, the converter, passes null there anyway — the approved snapshots did not move when it went, which is what redundant means.

Expand Down Expand Up @@ -157,6 +157,8 @@ When releasing a new version, update:

Steps 1, 2 and 6 are the ones a release can get wrong without anything noticing — the tag and the artifact name are both derived from `package.json`, so a forgotten `SDKVersion` ships under a correct-looking name. `PackageManifestTests` compares all three against the manifest and fails the suite instead.

The `contract-conformance` skill compares the contract with the C# that restates it; run it whenever step 3 or step 4 moves. One key is worth naming here because a conformance run will keep reporting it: `CustomerIdentityParameters.obfuscated_profile_id` is declared in the contract, Android only, and implemented by nobody — not by this SDK, not by AdaptySDK-Android 4.1.0, and iOS has no such field at all. It was left unimplemented deliberately rather than guessed at. **Re-check it on every native bump**: the moment Android starts reading it, the Unity side has to carry it too, and that is a public API change to `AdaptyCustomerIdentity`, so it wants to land in a release that expects one.
The `contract-conformance` skill compares the contract with the C# that restates it; run it whenever step 3 or step 4 moves. One key is worth naming here because a conformance run will keep reporting it: `CustomerIdentityParameters.obfuscated_profile_id` is declared in the contract, Android only, and implemented by nobody — not by this SDK, not by AdaptySDK-Android through 4.3.3 (crossplatform 4.3.1 has no such key), and iOS has no such field at all. It was left unimplemented deliberately rather than guessed at. **Re-check it on every native bump**: the moment Android starts reading it, the Unity side has to carry it too, and that is a public API change to `AdaptyCustomerIdentity`, so it wants to land in a release that expects one.

`AdaptyErrorCode.unknownTransactionId` (1030) is the second such item, decided the same way. iOS 4.0.2 declares it, this enum does not name it, and no member is added — because nothing produces it: across the whole of `Sources/` at that tag the identifier appears only in `Sources/Errors/`, as a declaration, a description and a factory method with no call site. A code that cannot be raised needs no constant. **Re-check on every native bump** by grepping the tag for a throw site; the day one exists, the member is owed.
`AdaptyErrorCode.unknownTransactionId` (1030) is the second such item, decided the same way. iOS 4.0.2 declares it, this enum does not name it, and no member is added — because nothing produces it: across the whole of `Sources/` at that tag, and again at 4.3.1 and 4.3.2, the identifier appears only in `Sources/Errors/` and `Sources/StoreKit/Errors/StoreKitManagerError.swift`, as declarations, descriptions and factory methods with no call site. A code that cannot be raised needs no constant. **Re-check on every native bump** by grepping the tag for a throw site; the day one exists, the member is owed.

Three more are reported by every conformance run since 4.3 and are deliberate. `AdaptyProfile.Subscription.billing_plan_id` and `AdaptyPromotedProduct.purchase_intent_id` are required on iOS by the contract and read as optional: a profile or a promoted-product event that failed to decode would be lost without a trace, while a missing plan reads as null and a missing intent fails the purchase with a native error the caller sees. `StoreMessagesHandling.default` has no member, because iOS treats it as `auto` and no wrapper sends it.
39 changes: 38 additions & 1 deletion Assets/Scripts/AdaptyListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,31 @@ AdaptyError error
LogIncomingCall_AdaptyUIFlowView("FlowViewDidFinishWebPaymentNavigation", view, meta);
}

private readonly Dictionary<string, AdaptyUIFlowView> keptFlowViews =
new Dictionary<string, AdaptyUIFlowView>();

// Realtime: a paywall often pauses the game with timeScale = 0.
private System.Collections.IEnumerator PresentAgain(AdaptyUIFlowView view)
{
yield return new WaitForSecondsRealtime(3);

// A second Close within the wait has already released it.
if (this.keptFlowViews.ContainsKey(view.Id))
{
this.PresentFlowView(view, null);
}
}

// A kept view lives until it is destroyed, so one this listener can no longer show is released.
void OnDestroy()
{
foreach (var view in this.keptFlowViews.Values)
{
view.Destroy(null);
}
this.keptFlowViews.Clear();
}

public void FlowViewDidPerformAction(AdaptyUIFlowView view, AdaptyUIUserAction action)
{
LogIncomingCall_AdaptyUIFlowView(
Expand All @@ -732,7 +757,18 @@ public void FlowViewDidPerformAction(AdaptyUIFlowView view, AdaptyUIUserAction a
switch (action.Type)
{
case AdaptyUIUserActionType.Close:
view.Dismiss(null);
// Demo of a kept view: the first Close keeps it and shows it again in 3 s, the
// second one releases it.
if (this.keptFlowViews.TryAdd(view.Id, view))
{
view.Dismiss(false, null);
this.StartCoroutine(this.PresentAgain(view));
}
else
{
this.keptFlowViews.Remove(view.Id);
view.Dismiss(true, null);
}
break;
case AdaptyUIUserActionType.OpenUrl:
var urlString = action.Value;
Expand Down Expand Up @@ -801,6 +837,7 @@ AdaptyPurchaseResult purchasedResult
case AdaptyPurchaseResultType.Success:
try
{
this.keptFlowViews.Remove(view.Id);
view.Dismiss(null);

var profile = purchasedResult.Profile;
Expand Down
Loading
Loading