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
15 changes: 4 additions & 11 deletions AGENTS.md

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions Assets/SdkHarness/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The SDK harness

`Assets/SdkHarness/` is a scene with nothing in it but a `RuntimePipelineManager` (port pinned to 7900) and `AdaptySdkHarness`, which registers four `[CliCommand]`s in the player: `harness_activate`, `harness_call` (any public static `Adapty` or `AdaptyUI` method by name, arguments as a JSON object keyed by parameter name, the overload chosen by exactly that key set — an argument written as `"$GetFlow"` or `"$GetPaywallProducts[0]"` is the object the last successful call of that method handed back, which is how a flow reaches `GetPaywallProducts` and a product reaches `MakePurchase`; do not paste a returned model's JSON back instead, the SDK never reads a response model from JSON — a product goes out as `AdaptyPaywallProductRequest`, its own shape — so the dump has no reader and fails on the subscription offer while proving nothing), `harness_events` (what the listeners received, decoded), `harness_status`. It exists because the desktop suite feeds the SDK fixtures, and only a player can say whether the C# layer decodes what the *native* SDK actually returns, on IL2CPP, after stripping — which is why the harness build adds a link.xml of its own (`SdkHarnessBuild`, not a file under `Assets/`, which would go into the demo's and `StrippingBuild`'s players too) that keeps only what the harness reaches by reflection — `Adapty`'s and `AdaptyUI`'s methods, `AdaptyJson`, the request-side types it builds from JSON or by member name — and leaves the response models and converters to the linker; a new request-side parameter type goes on that list. A callback with a value-type payload (`bool`, an enum) needs no AOT hint: IL2CPP's full generic sharing runs the `Capture<T>` the harness makes at run time. Commands run on the request thread and post the call to the main one, because the SDK delivers callbacks through `SynchronizationContext.Post` and a main-thread wait would deadlock every call; the wait budget (`--timeout_ms`, 20 s by default) stays under the scene's 120 s request timeout; the first calls after an activation with a customer id have taken 8–20 s while the backend merged the profile, so give those more. It does not expose the raw bridge JSON — the request/response bytes live inside `AdaptyRequest` — only what the SDK decoded. It activates once per process: relaunch the app for another activation. The scenario is data, not code, so a new test is a new payload and needs no rebuild. The screen is not empty for the person watching: a UI Toolkit ledger built in code (the only asset is `SdkHarnessPanel.asset` with its `unity-theme://default` theme) lists every request (→), reply (←), failure (×), late reply (← or × with "late" — the command already returned as timed out; the object still becomes the handle) and delivered event («) with its timestamp, so a run can be followed on the simulator and read back when the agent is stuck.
`Assets/SdkHarness/` is a scene with nothing in it but `AdaptySdkHarness`, which registers four `[CliCommand]`s in the player: `harness_activate`, `harness_call` (any public static `Adapty` or `AdaptyUI` method by name, arguments as a JSON object keyed by parameter name, the overload chosen by exactly that key set — an argument written as `"$GetFlow"` or `"$GetPaywallProducts[0]"` is the object the last successful call of that method handed back, which is how a flow reaches `GetPaywallProducts` and a product reaches `MakePurchase`; do not paste a returned model's JSON back instead, the SDK never reads a response model from JSON — a product goes out as `AdaptyPaywallProductRequest`, its own shape — so the dump has no reader and fails on the subscription offer while proving nothing), `harness_events` (what the listeners received, decoded), `harness_status`. It exists because the desktop suite feeds the SDK fixtures, and only a player can say whether the C# layer decodes what the *native* SDK actually returns, on IL2CPP, after stripping — which is why the harness build adds a link.xml of its own (`SdkHarnessBuild`, not a file under `Assets/`, which would go into the demo's and `StrippingBuild`'s players too) that keeps only what the harness reaches by reflection — `Adapty`'s and `AdaptyUI`'s methods, `AdaptyJson`, the request-side types it builds from JSON or by member name — and leaves the response models and converters to the linker; a new request-side parameter type goes on that list. A callback with a value-type payload (`bool`, an enum) needs no AOT hint: IL2CPP's full generic sharing runs the `Capture<T>` the harness makes at run time. Commands run on the request thread and post the call to the main one, because the SDK delivers callbacks through `SynchronizationContext.Post` and a main-thread wait would deadlock every call; the wait budget (`--timeout_ms`, 20 s by default) stays under the runtime server's 120 s request timeout (`requestTimeoutMs` in `RuntimePipelineConfig.json`); the first calls after an activation with a customer id have taken 8–20 s while the backend merged the profile, so give those more. It does not expose the raw bridge JSON — the request/response bytes live inside `AdaptyRequest` — only what the SDK decoded. It activates once per process: relaunch the app for another activation. The scenario is data, not code, so a new test is a new payload and needs no rebuild. The screen is not empty for the person watching: a UI Toolkit ledger built in code (the only asset is `SdkHarnessPanel.asset` with its `unity-theme://default` theme) lists every request (→), reply (←), failure (×), late reply (← or × with "late" — the command already returned as timed out; the object still becomes the handle) and delivered event («) with its timestamp, so a run can be followed on the simulator and read back when the agent is stuck.

## Build and run

Expand All @@ -27,4 +27,19 @@ Open the **workspace**: External Dependency Manager writes a Podfile, and the ba

## What the commands can and cannot take

Some argument shapes have no JSON reader in the SDK and get one in the harness: a type whose instances are public static members (`AdaptyPlacementFetchPolicy`, `AdaptyExternalAttributionProvider`) is addressed by member name, and a string for a type with a one-string constructor becomes that instance (a provider by identifier); a `TimeSpan` parameter is taken as seconds (inside an object it is Newtonsoft's `"00:00:10"`); enums go through the SDK's own converter as strings; a request-only model with a nested `Builder` (`AdaptyProfileParameters`, `AdaptyConfiguration`) is built from a JSON object — constructor arguments by parameter name, every other key a `Set<Key>` call, `customAttributes` an object of string or number values — so `UpdateProfile` takes `{"param":{"firstName":"A","customAttributes":{"plan":"pro","score":3}}}`. Models with public fields and a parameterless constructor (`AdaptyUICreateFlowViewParameters`, `AdaptyUIDialogConfiguration`, `AdaptyPurchaseParameters`) read as plain JSON. Not reachable this way: `AdaptyCustomAsset` values (factory methods, no members), custom tags and timers on `CreateFlowView` (setters, not fields), `SetFallback` (needs a file in the app), and the legacy onboarding events (`IAdaptyOnboardingsEventsListener` is obsolete; the harness does not register it). `Activate` is refused by `harness_call` because activation is once per process — `harness_activate` builds the configuration, with `--configuration` for the Builder keys it has no flag for. Only iOS: `harness_build` targets the simulator SDK, an Android build is not written. The player's own command set adds `console`, `runtime_status`, `simulate_pointer`; `eval` is registered there but compiled out on anything that is not the Editor or a standalone development build, which is why the harness's commands are compiled ones. Nothing here runs on a real device without a package change: the runtime server writes its descriptor into the app bundle and stops itself when that write fails.
Some argument shapes have no JSON reader in the SDK and get one in the harness: a type whose instances are public static members (`AdaptyPlacementFetchPolicy`, `AdaptyExternalAttributionProvider`) is addressed by member name, and a string for a type with a one-string constructor becomes that instance (a provider by identifier); a `TimeSpan` parameter is taken as seconds (inside an object it is Newtonsoft's `"00:00:10"`); enums go through the SDK's own converter as strings; a request-only model with a nested `Builder` (`AdaptyProfileParameters`, `AdaptyConfiguration`) is built from a JSON object — constructor arguments by parameter name, every other key a `Set<Key>` call, `customAttributes` an object of string or number values — so `UpdateProfile` takes `{"param":{"firstName":"A","customAttributes":{"plan":"pro","score":3}}}`. Models with public fields and a parameterless constructor (`AdaptyUICreateFlowViewParameters`, `AdaptyUIDialogConfiguration`, `AdaptyPurchaseParameters`) read as plain JSON. Not reachable this way: `AdaptyCustomAsset` values (factory methods, no members), custom tags and timers on `CreateFlowView` (setters, not fields), `SetFallback` (needs a file in the app), and the legacy onboarding events (`IAdaptyOnboardingsEventsListener` is obsolete; the harness does not register it). `Activate` is refused by `harness_call` because activation is once per process — `harness_activate` builds the configuration, with `--configuration` for the Builder keys it has no flag for. `harness_build` targets the iOS simulator SDK, and refuses to run unless the Editor is on iOS, since the post-processor that adds the local StoreKit is compiled for the active platform; `harness_build_android` targets an Android device (see below). The player's own command set adds `console`, `runtime_status`, `simulate_pointer`; `eval` is registered there but compiled out on anything that is not the Editor or a standalone development build, which is why the harness's commands are compiled ones. An iOS device is out of reach without a package change: the runtime server writes its descriptor into the app bundle and stops itself when that write fails.

## Android

`harness_build_android` (`Assets/SdkHarness/Editor/`) builds the same scene into `android-harness-build.apk`: Development, ARM64, `com.adaptytest`. Pipeline 0.6.0 starts the runtime server from Project Settings > Pipeline > Runtime (`ProjectSettings/Packages/com.unity.pipeline/RuntimePipelineConfig.json`, port 7900), not from a scene component — the package no longer has the `RuntimePipelineManager` the scene used to carry. Its runtime assembly has no define constraint, so Enable In Builds puts the server into every player built while it is on, a release build in batchmode with only a warning. The project keeps it off; both harness builds turn it on for the build and put it back, together with `EditorUserBuildSettings.development` (the Pipeline build processor reads that flag rather than the build options, and with Enable In Builds on and the flag off it stops an interactive build on a Continue / Cancel / Disable Pipeline dialog that nothing driving the Editor from the CLI can answer), the iOS SDK, the Android architectures and application id.

The stock runtime server cannot run on a device: it writes its descriptor next to `Application.dataPath`, which on Android is the APK, the write throws and the server stops itself. `AndroidHarnessServer` (`Assets/SdkHarness/AndroidHarnessServer.cs`, Android players only, not the Editor) turns the driver's autostart off at `AfterSceneLoad` and runs a subclass that writes the descriptor into the app's internal files directory, with a token of its own since the package's token manager is internal. Reaching it from the host:
```bash
adb install -r android-harness-build.apk && adb shell monkey -p com.adaptytest -c android.intent.category.LAUNCHER 1
adb shell run-as com.adaptytest cat files/.unity-pipeline-runtime-port > rt/.unity-pipeline-runtime-port # a Development build is debuggable
adb forward tcp:7900 tcp:7900
unity command harness_status --runtime-path rt
```
Two things the CLI checks on the pulled copy, and deletes it when they fail: `pid` has to be a live process of the current user on the host (the device pid is meaningless here, and pid 1 is refused) — rewrite it to a long-lived one such as the Hub's; and `lastHeartbeat` has to be recent — rewrite it before each command, the device server does not touch its file on heartbeats. The Editor's Android Logcat restarts the adb server, which drops the forward: re-add it before each command too. `adb shell input tap`/`keyevent` do reach the flow.

A flow is an Activity of its own on Android, and Unity's player loop is paused underneath it: `harness_call` (a post to the main thread) times out until the flow is gone, `harness_events` still answers. The SDK's callbacks are delivered through the Unity thread's native looper, which keeps running through the pause, so a `flow_view_did_perform_action` for Back or close shows up in `harness_events` within seconds — that is the check that the bridge delivers while paused, and it is what the demo app relies on to dismiss the view on close. The way back goes through the same looper: the harness grants a permission and reports an observer-mode purchase from a pool thread, as an app's OS or store callback would, and logs it (`SdkHarness: purchase reported from a pool thread`); in Observer mode the native `observerModeHandler: onStartPurchase called` follows in logcat within milliseconds while the flow is on screen. `adb shell input tap` does nothing on some devices (Xiaomi needs USB debugging's security settings); `input keyevent KEYCODE_TAB` until the button has focus, then `KEYCODE_ENTER`, presses it. The harness itself dismisses nothing: bring `UnityPlayerActivity` back with `am start -f 0x20000000`, which ends the flow and resumes the loop.
21 changes: 15 additions & 6 deletions Assets/SdkHarness/AdaptySdkHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ namespace AdaptyExample.SdkHarness
/// Commands run on the request thread, not the main one. A call is posted to the main thread and
/// awaited here, because the SDK delivers its callback through <c>SynchronizationContext.Post</c>
/// and the main loop has to stay free to run it — waiting on the main thread would deadlock every
/// call. The wait budget stays below the scene's 120 s request timeout.
/// call. The wait budget stays below the runtime server's 120 s request timeout.
///
/// A player discovers commands by reflection and reaches the harness from nothing else, so the
/// harness build's own link.xml (<c>SdkHarnessBuild</c>) keeps the namespace and what the
Expand Down Expand Up @@ -803,25 +803,34 @@ public void FlowViewDidReceiveAnalyticEvent(AdaptyUIFlowView view, string name,

/// <summary>
/// Recorded and granted: the harness has no user to ask, and a request left unanswered would hold
/// the view open for the rest of the run.
/// the view open for the rest of the run. Granted from a pool thread, where an OS permission
/// callback lands, so a run also checks that the answer reaches a view that is on screen.
/// </summary>
public void FlowViewDidAskPermission(AdaptyUIFlowView view, string permission, IReadOnlyDictionary<string, string> customArgs, Action<bool, string> respond)
{
Record("flow_view_did_ask_permission", ("view", view), ("permission", permission), ("customArgs", customArgs));
respond(true, null);
ThreadPool.QueueUserWorkItem(_ =>
{
Debug.Log("SdkHarness: permission answered from a pool thread");
respond(true, null);
});
}

public void FlowViewDidRequestAppReview(AdaptyUIFlowView view) => Record("flow_view_did_request_app_review", ("view", view));

/// <summary>
/// Observer mode leaves the purchase to the app; the harness has none, so it reports both stages
/// at once and lets the view continue.
/// at once and lets the view continue - from a pool thread, as a store callback would.
/// </summary>
public void FlowViewDidInitiatePurchase(AdaptyUIFlowView view, AdaptyPaywallProduct product, Action onStartPurchase, Action onFinishPurchase)
{
Record("flow_view_did_initiate_purchase", ("view", view), ("product", product));
onStartPurchase();
onFinishPurchase();
ThreadPool.QueueUserWorkItem(_ =>
{
Debug.Log("SdkHarness: purchase reported from a pool thread");
onStartPurchase();
onFinishPurchase();
});
}

public void FlowViewDidInitiateRestore(AdaptyUIFlowView view, Action onStartRestore, Action onFinishRestore)
Expand Down
Loading
Loading