From 8c6b8ef19612dc8e99379eef48145b65c4a5fd47 Mon Sep 17 00:00:00 2001 From: Kamo Spertsyan Date: Wed, 15 Jul 2026 19:53:01 +0300 Subject: [PATCH 1/6] Add one-click release workflows (DEV-1181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rollout of the release automation piloted in sandwich-sdk: release.yml (workflow_dispatch) runs the whole release via the shared workflow — version calculation, release PRs, bot approval, CI gate, merge — and publish_release.yml creates the GitHub release when the release PR merges to main, so the publish workflow fires on release:[released] as before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GjdKcoaoecn6TKWmnUZzji --- .github/workflows/publish_release.yml | 14 ++++++++++++ .github/workflows/release.yml | 32 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 .github/workflows/publish_release.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml new file mode 100644 index 0000000..0b4a6fb --- /dev/null +++ b/.github/workflows/publish_release.yml @@ -0,0 +1,14 @@ +name: Publish release + +on: + pull_request: + types: [closed] + branches: + - main + +jobs: + publish_release: + if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/') && contains(github.event.pull_request.labels.*.name, 'one-click-release') + uses: qonversion/shared-sdk-workflows/.github/workflows/publish_release.yml@main + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..502404a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: 'Version part to bump' + required: true + type: choice + options: + - patch + - minor + - major + default: patch + version: + description: 'Explicit release version like 5.13.2 (overrides bump)' + required: false + type: string + release_notes: + description: 'Release notes markdown (empty — auto-generated from pull requests)' + required: false + type: string + +jobs: + release: + uses: qonversion/shared-sdk-workflows/.github/workflows/release.yml@main + with: + bump: ${{ inputs.bump }} + version: ${{ inputs.version }} + release_notes: ${{ inputs.release_notes }} + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} From 4432f35bcdeed077aa6bf8888ce853e53d7ce3f2 Mon Sep 17 00:00:00 2001 From: Kamo Spertsyan Date: Wed, 15 Jul 2026 20:08:29 +0300 Subject: [PATCH 2/6] Remove legacy release workflows superseded by the one-click release (DEV-1181) Deleted: manual prerelease dispatch lanes (replaced by the Release workflow), tag-triggered release PR creation (prerelease/* tags no longer drive releases) and the Development Build prerelease on pushes to main (the GitHub release is now created directly by publish_release). Publish, checks, stale and upgrade_sandwich workflows are untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GjdKcoaoecn6TKWmnUZzji --- .github/workflows/manual_minor_prerelease.yml | 17 --------------- .github/workflows/manual_patch_prerelease.yml | 17 --------------- .github/workflows/prerelease_github.yml | 21 ------------------- .github/workflows/release_pull_requests.yml | 9 -------- 4 files changed, 64 deletions(-) delete mode 100644 .github/workflows/manual_minor_prerelease.yml delete mode 100644 .github/workflows/manual_patch_prerelease.yml delete mode 100644 .github/workflows/prerelease_github.yml delete mode 100644 .github/workflows/release_pull_requests.yml diff --git a/.github/workflows/manual_minor_prerelease.yml b/.github/workflows/manual_minor_prerelease.yml deleted file mode 100644 index 31156f4..0000000 --- a/.github/workflows/manual_minor_prerelease.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Manual Minor Prerelease - -on: - workflow_dispatch - -jobs: - patch-minor: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - ref: develop - - - name: Minor - run: | - fastlane minor diff --git a/.github/workflows/manual_patch_prerelease.yml b/.github/workflows/manual_patch_prerelease.yml deleted file mode 100644 index 21a1403..0000000 --- a/.github/workflows/manual_patch_prerelease.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Manual Patch Prerelease - -on: - workflow_dispatch - -jobs: - patch-prerelease: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - ref: develop - - - name: Patch - run: | - fastlane patch diff --git a/.github/workflows/prerelease_github.yml b/.github/workflows/prerelease_github.yml deleted file mode 100644 index 2747386..0000000 --- a/.github/workflows/prerelease_github.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Pre-release Github - -on: - push: - branches: - - "main" - -jobs: - pre-release: - runs-on: macos-latest - - steps: - - uses: "marvinpinto/action-automatic-releases@latest" - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "latest" - prerelease: true - title: "Development Build" - files: | - LICENSE.txt - *.jar \ No newline at end of file diff --git a/.github/workflows/release_pull_requests.yml b/.github/workflows/release_pull_requests.yml deleted file mode 100644 index e4e0569..0000000 --- a/.github/workflows/release_pull_requests.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: Release pull requests from dev by tag -on: - push: - tags: - - prerelease/* - -jobs: - handle_prerelease: - uses: qonversion/shared-sdk-workflows/.github/workflows/prerelease_handling.yml@main From c48fd9fee39eae56555a4dd521e5e4dcc08ab270 Mon Sep 17 00:00:00 2001 From: Kamo Spertsyan Date: Fri, 17 Jul 2026 13:12:50 +0300 Subject: [PATCH 3/6] Bridge No-Codes loadScreen, defaultVariables and custom action event (DEV-1192) Bump sandwich to 7.10.1. LoadScreen(contextKey, onSuccess, onError) returns a typed NoCodesScreen (id, contextKey, defaultSelectedProductId, typed defaultVariables); requests are correlated by contextKey carried in both success and failure payloads. Custom builder actions surface via the new optional NoCodesCustomActionDelegate interface (kept separate from NoCodesDelegate for source compatibility on Unity 2018.3, which has no default interface methods). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KQEJXqfVNxqoUcKXhSPhWn --- Editor/QonversionDependencies.xml | 4 +- .../unitywrapper/NoCodesWrapper.java | 31 ++++ .../NoCodes/Android/NoCodesWrapperAndroid.cs | 5 + Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs | 175 ++++++++++++++++++ .../Scripts/NoCodes/Dto/NoCodesScreen.cs.meta | 2 + Runtime/Scripts/NoCodes/INoCodes.cs | 13 ++ .../NoCodes/Internal/INoCodesWrapper.cs | 1 + .../NoCodes/Internal/NoCodesInternal.cs | 68 +++++++ .../Scripts/NoCodes/Internal/NoCodesMapper.cs | 52 ++++++ .../NoCodes/Internal/NoCodesWrapperNoop.cs | 5 + .../NoCodes/NoCodesCustomActionDelegate.cs | 22 +++ .../NoCodesCustomActionDelegate.cs.meta | 2 + .../Scripts/NoCodes/iOS/NoCodesWrapperIOS.cs | 10 + Runtime/iOS/Plugins/NoCodes/NoCodesBridge.m | 5 + .../iOS/Plugins/NoCodes/QNUNoCodesDelegate.h | 1 + .../iOS/Plugins/NoCodes/QNUNoCodesDelegate.m | 18 +- 16 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs create mode 100644 Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs.meta create mode 100644 Runtime/Scripts/NoCodes/NoCodesCustomActionDelegate.cs create mode 100644 Runtime/Scripts/NoCodes/NoCodesCustomActionDelegate.cs.meta diff --git a/Editor/QonversionDependencies.xml b/Editor/QonversionDependencies.xml index 076a981..4e7dc9e 100644 --- a/Editor/QonversionDependencies.xml +++ b/Editor/QonversionDependencies.xml @@ -1,11 +1,11 @@ - + - + diff --git a/Runtime/Android/Plugins/com/qonversion/unitywrapper/NoCodesWrapper.java b/Runtime/Android/Plugins/com/qonversion/unitywrapper/NoCodesWrapper.java index ade86b6..437ec6f 100644 --- a/Runtime/Android/Plugins/com/qonversion/unitywrapper/NoCodesWrapper.java +++ b/Runtime/Android/Plugins/com/qonversion/unitywrapper/NoCodesWrapper.java @@ -15,9 +15,13 @@ import java.util.HashMap; import java.util.Map; +import com.fasterxml.jackson.databind.node.ObjectNode; + import io.qonversion.sandwich.NoCodesEventListener; import io.qonversion.sandwich.NoCodesPurchaseDelegateBridge; import io.qonversion.sandwich.NoCodesSandwich; +import io.qonversion.sandwich.ResultListener; +import io.qonversion.sandwich.SandwichError; public class NoCodesWrapper { private static final String TAG = "NoCodesWrapper"; @@ -28,6 +32,9 @@ public class NoCodesWrapper { private static final String EVENT_ACTION_FINISHED = "OnNoCodesActionFinished"; private static final String EVENT_FINISHED = "OnNoCodesFinished"; private static final String EVENT_SCREEN_FAILED_TO_LOAD = "OnNoCodesScreenFailedToLoad"; + private static final String EVENT_CUSTOM_ACTION = "OnNoCodesCustomAction"; + private static final String EVENT_SCREEN_LOADED = "OnNoCodesScreenLoaded"; + private static final String EVENT_SCREEN_LOAD_FAILED = "OnNoCodesScreenLoadFailed"; private static final String EVENT_PURCHASE = "OnNoCodesPurchase"; private static final String EVENT_RESTORE = "OnNoCodesRestore"; @@ -112,6 +119,27 @@ public static synchronized void showScreen(String contextKey, String customVaria noCodesSandwich.showScreen(contextKey, customVariables); } + public static synchronized void loadScreen(final String contextKey) { + if (noCodesSandwich == null) { + Log.e(TAG, "NoCodesSandwich is not initialized"); + return; + } + + noCodesSandwich.loadScreen(contextKey, new ResultListener() { + @Override + public void onSuccess(@NonNull Map data) { + sendMessageToUnity(data, EVENT_SCREEN_LOADED); + } + + @Override + public void onError(@NonNull SandwichError error) { + ObjectNode payload = Utils.createErrorNode(error); + payload.put("contextKey", contextKey); + sendMessageToUnity(payload, EVENT_SCREEN_LOAD_FAILED); + } + }); + } + public static synchronized void close() { if (noCodesSandwich == null) { Log.e(TAG, "NoCodesSandwich is not initialized"); @@ -208,6 +236,9 @@ public void onNoCodesEvent(@NonNull Event event, @Nullable Map data) case ScreenFailedToLoad: methodName = EVENT_SCREEN_FAILED_TO_LOAD; break; + case CustomAction: + methodName = EVENT_CUSTOM_ACTION; + break; default: return; } diff --git a/Runtime/Scripts/NoCodes/Android/NoCodesWrapperAndroid.cs b/Runtime/Scripts/NoCodes/Android/NoCodesWrapperAndroid.cs index 2bfd47f..3fb822e 100644 --- a/Runtime/Scripts/NoCodes/Android/NoCodesWrapperAndroid.cs +++ b/Runtime/Scripts/NoCodes/Android/NoCodesWrapperAndroid.cs @@ -26,6 +26,11 @@ public void ShowScreen(string contextKey, string customVariablesJson) CallNoCodes("showScreen", contextKey, customVariablesJson ?? ""); } + public void LoadScreen(string contextKey) + { + CallNoCodes("loadScreen", contextKey); + } + public void Close() { CallNoCodes("close"); diff --git a/Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs b/Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs new file mode 100644 index 0000000..f14a255 --- /dev/null +++ b/Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs @@ -0,0 +1,175 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using QonversionUnity.MiniJSON; + +namespace QonversionUnity +{ + /// + /// Kind of a No-Codes screen default variable — what it was configured as in the builder. + /// The set may grow in future backend versions; values this SDK version does not know + /// are mapped to instead of failing. + /// + public enum NoCodesScreenVariableKind + { + /// A Screen Variable authored in the builder's Variables section. + Custom, + + /// + /// A product slot: the variable key is the slot name and the value is the default + /// Qonversion product id assigned to it. + /// + Product, + + /// + /// The screen's Default Product configured in the builder: the value is + /// the Qonversion product id selected by default. + /// + SelectedProduct, + + /// A kind introduced on the backend after this SDK version was released. + Unknown + } + + /// + /// A typed default variable of a No-Codes screen, configured in the builder and delivered + /// at screen load so it can be read by key. The value keeps its authored type + /// (bool / string / number) rather than being coerced to a string. + /// + public class NoCodesScreenVariable + { + /// What the variable represents — see . + public readonly NoCodesScreenVariableKind Kind; + + /// + /// Variable name it is addressed by (`variable.<key>` in the builder for custom + /// variables, the slot name for product slots). May contain spaces. + /// + public readonly string Key; + + /// Authored value type: "boolean", "string" or "number". + public readonly string Type; + + /// + /// The configured default value, preserving its native type (bool, string, long or double). + /// Null when no default value was authored. + /// + [CanBeNull] public readonly object Value; + + /// + /// The value rendered as a plain string regardless of its native type: "true"/"false" + /// for booleans, the string itself, a number without a trailing ".0" when integral, + /// or an empty string when no value was authored. + /// + public readonly string StringValue; + + public NoCodesScreenVariable(Dictionary dict) + { + Kind = ParseKind(dict.GetString("kind")); + Key = dict.GetString("key"); + Type = dict.GetString("type"); + dict.TryGetValue("value", out Value); + StringValue = dict.GetString("stringValue"); + } + + private static NoCodesScreenVariableKind ParseKind([CanBeNull] string kind) + { + switch (kind) + { + case "custom": return NoCodesScreenVariableKind.Custom; + case "product": return NoCodesScreenVariableKind.Product; + case "selected_product": return NoCodesScreenVariableKind.SelectedProduct; + default: return NoCodesScreenVariableKind.Unknown; + } + } + + public override string ToString() + { + return $"{nameof(Kind)}: {Kind}, " + + $"{nameof(Key)}: {Key}, " + + $"{nameof(Type)}: {Type}, " + + $"{nameof(Value)}: {Value}, " + + $"{nameof(StringValue)}: {StringValue}"; + } + } + + /// + /// A loaded No-Codes screen returned from . + /// + /// Exposes the screen identifiers and the typed default variables configured in the builder — + /// the screen content stays internal, as rendering remains the SDK's job via + /// . + /// + public class NoCodesScreen + { + /// Identifier of the screen. + public readonly string Id; + + /// The context key of the screen set in the No-Codes builder. + public readonly string ContextKey; + + /// + /// The Qonversion product id selected by default when the screen opens (the builder's + /// Default Product), or null when none is configured. + /// + [CanBeNull] public readonly string DefaultSelectedProductId; + + /// + /// Typed default variables of the screen configured in the builder: authored custom + /// variables and product slots. Read them by (may be empty). + /// + public readonly List DefaultVariables = new List(); + + public NoCodesScreen(Dictionary dict) + { + Id = dict.GetString("id"); + ContextKey = dict.GetString("contextKey"); + DefaultSelectedProductId = dict.TryGetValue("defaultSelectedProductId", out object productId) + ? productId as string + : null; + + if (dict.TryGetValue("defaultVariables", out object variablesValue) && variablesValue is List rawVariables) + { + foreach (object rawVariable in rawVariables) + { + if (rawVariable is Dictionary variableDict) + { + DefaultVariables.Add(new NoCodesScreenVariable(variableDict)); + } + } + } + } + + /// + /// Returns the default variable configured under the given key, or null when the screen + /// has no variable with that exact (case-sensitive) key. + /// + /// Keys are only unique within a kind — a custom variable and a product slot may share + /// a name — so pass to disambiguate; without it the first match + /// in payload order (custom variables, then product slots, then the selected product) + /// is returned. + /// + /// For the default selected product prefer — it needs no key. + /// + [CanBeNull] + public NoCodesScreenVariable DefaultVariable(string key, NoCodesScreenVariableKind? kind = null) + { + foreach (NoCodesScreenVariable variable in DefaultVariables) + { + if (variable.Key == key && (kind == null || variable.Kind == kind)) + { + return variable; + } + } + + return null; + } + + public override string ToString() + { + return $"{nameof(Id)}: {Id}, " + + $"{nameof(ContextKey)}: {ContextKey}, " + + $"{nameof(DefaultSelectedProductId)}: {DefaultSelectedProductId}, " + + $"{nameof(DefaultVariables)}: {string.Join("; ", DefaultVariables)}"; + } + } +} diff --git a/Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs.meta b/Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs.meta new file mode 100644 index 0000000..7da7b8b --- /dev/null +++ b/Runtime/Scripts/NoCodes/Dto/NoCodesScreen.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ac2daf1b3c146a4a8f458088984dd5c \ No newline at end of file diff --git a/Runtime/Scripts/NoCodes/INoCodes.cs b/Runtime/Scripts/NoCodes/INoCodes.cs index 4160ca4..db61fa5 100644 --- a/Runtime/Scripts/NoCodes/INoCodes.cs +++ b/Runtime/Scripts/NoCodes/INoCodes.cs @@ -24,6 +24,19 @@ public interface INoCodes /// and only applied to that screen. void ShowScreen(string contextKey, [CanBeNull] System.Collections.Generic.Dictionary customVariables = null); + /// + /// Load a No-Code screen (from cache or network) without presenting it, so you can decide + /// whether to present it or show your own fallback UI before any SDK screen appears. + /// Present the screen with — the loaded content is served from cache. + /// + /// The returned exposes the typed default variables configured + /// in the builder and the default selected product id. + /// + /// The context key of the screen to load. + /// Called with the loaded screen data. + /// Called when loading fails. + void LoadScreen(string contextKey, System.Action onSuccess, System.Action onError); + /// /// Close the current opened No-Code screen. /// diff --git a/Runtime/Scripts/NoCodes/Internal/INoCodesWrapper.cs b/Runtime/Scripts/NoCodes/Internal/INoCodesWrapper.cs index 26efd27..2724630 100644 --- a/Runtime/Scripts/NoCodes/Internal/INoCodesWrapper.cs +++ b/Runtime/Scripts/NoCodes/Internal/INoCodesWrapper.cs @@ -8,6 +8,7 @@ internal interface INoCodesWrapper void SetDelegate(); void SetScreenPresentationConfig(string configJson, [CanBeNull] string contextKey); void ShowScreen(string contextKey, [CanBeNull] string customVariablesJson); + void LoadScreen(string contextKey); void Close(); void SetLocale([CanBeNull] string locale); void SetTheme(string theme); diff --git a/Runtime/Scripts/NoCodes/Internal/NoCodesInternal.cs b/Runtime/Scripts/NoCodes/Internal/NoCodesInternal.cs index 1c6b80b..677a195 100644 --- a/Runtime/Scripts/NoCodes/Internal/NoCodesInternal.cs +++ b/Runtime/Scripts/NoCodes/Internal/NoCodesInternal.cs @@ -12,6 +12,17 @@ internal class NoCodesInternal : MonoBehaviour, INoCodes private NoCodesPurchaseDelegate _purchaseDelegate; private NoCodesConfig _config; + private struct LoadScreenCallbacks + { + public System.Action OnSuccess; + public System.Action OnError; + } + + // Load requests are correlated by contextKey: both the success payload (the screen map) + // and the native failure payload carry it. + private readonly System.Collections.Generic.Dictionary> _loadScreenCallbacks = + new System.Collections.Generic.Dictionary>(); + public static NoCodesInternal CreateInstance(NoCodesConfig config) { GameObject go = new GameObject(GameObjectName); @@ -58,6 +69,20 @@ public void ShowScreen(string contextKey, System.Collections.Generic.Dictionary< wrapper.ShowScreen(contextKey, customVariablesJson); } + public void LoadScreen(string contextKey, System.Action onSuccess, System.Action onError) + { + if (!_loadScreenCallbacks.TryGetValue(contextKey, out var callbacks)) + { + callbacks = new System.Collections.Generic.List(); + _loadScreenCallbacks[contextKey] = callbacks; + } + + callbacks.Add(new LoadScreenCallbacks { OnSuccess = onSuccess, OnError = onError }); + + INoCodesWrapper wrapper = GetNativeWrapper(); + wrapper.LoadScreen(contextKey); + } + public void Close() { INoCodesWrapper wrapper = GetNativeWrapper(); @@ -199,6 +224,49 @@ private void OnNoCodesScreenFailedToLoad(string jsonString) } } + private void OnNoCodesCustomAction(string jsonString) + { + if (!(_noCodesDelegate is NoCodesCustomActionDelegate customActionDelegate)) return; + + string value = NoCodesMapper.CustomActionValueFromJson(jsonString); + customActionDelegate.OnCustomAction(value); + } + + // Called from native with the loaded screen data + private void OnNoCodesScreenLoaded(string jsonString) + { + NoCodesScreen screen = NoCodesMapper.ScreenFromJson(jsonString); + if (screen == null) return; + + foreach (LoadScreenCallbacks callbacks in TakeLoadScreenCallbacks(screen.ContextKey)) + { + callbacks.OnSuccess?.Invoke(screen); + } + } + + // Called from native when loading a screen failed; payload: { contextKey, error } + private void OnNoCodesScreenLoadFailed(string jsonString) + { + string contextKey = NoCodesMapper.ContextKeyFromJson(jsonString); + NoCodesError error = NoCodesMapper.LoadScreenErrorFromJson(jsonString); + + foreach (LoadScreenCallbacks callbacks in TakeLoadScreenCallbacks(contextKey)) + { + callbacks.OnError?.Invoke(error); + } + } + + private System.Collections.Generic.List TakeLoadScreenCallbacks(string contextKey) + { + if (contextKey == null || !_loadScreenCallbacks.TryGetValue(contextKey, out var callbacks)) + { + return new System.Collections.Generic.List(); + } + + _loadScreenCallbacks.Remove(contextKey); + return callbacks; + } + // Called from native for purchase delegate private void OnNoCodesPurchase(string jsonString) { diff --git a/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs b/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs index 1917c1e..42d1479 100644 --- a/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs +++ b/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs @@ -43,6 +43,58 @@ internal static NoCodesError ErrorFromJson(string jsonStr) return new NoCodesError(dict); } + [CanBeNull] + internal static NoCodesScreen ScreenFromJson(string jsonStr) + { + if (!(Json.Deserialize(jsonStr) is Dictionary screenDict)) + { + Debug.LogError("Could not parse NoCodes screen"); + return null; + } + + return new NoCodesScreen(screenDict); + } + + internal static string CustomActionValueFromJson(string jsonStr) + { + if (!(Json.Deserialize(jsonStr) is Dictionary dict)) + { + Debug.LogError("Could not parse NoCodes custom action"); + return ""; + } + + return dict.GetString("value"); + } + + [CanBeNull] + internal static string ContextKeyFromJson(string jsonStr) + { + if (!(Json.Deserialize(jsonStr) is Dictionary dict)) + { + return null; + } + + return dict.GetString("contextKey"); + } + + internal static NoCodesError LoadScreenErrorFromJson(string jsonStr) + { + if (Json.Deserialize(jsonStr) is Dictionary dict && + dict.TryGetValue("error", out object errorValue) && + errorValue is Dictionary errorDict) + { + return new NoCodesError(errorDict); + } + + Debug.LogError("Could not parse NoCodes screen loading error"); + return new NoCodesError(new Dictionary + { + { "code", "Unknown" }, + { "description", "Failed to load No-Code screen" }, + { "additionalMessage", "Native error parsing failed." } + }); + } + [CanBeNull] internal static Product ProductFromJson(string jsonStr) { diff --git a/Runtime/Scripts/NoCodes/Internal/NoCodesWrapperNoop.cs b/Runtime/Scripts/NoCodes/Internal/NoCodesWrapperNoop.cs index 1f10b2b..da9842f 100644 --- a/Runtime/Scripts/NoCodes/Internal/NoCodesWrapperNoop.cs +++ b/Runtime/Scripts/NoCodes/Internal/NoCodesWrapperNoop.cs @@ -27,6 +27,11 @@ public void ShowScreen(string contextKey, string customVariablesJson) Debug.Log($"NoCodes.ShowScreen({contextKey}) called on unsupported platform."); } + public void LoadScreen(string contextKey) + { + Debug.Log($"NoCodes.LoadScreen({contextKey}) called on unsupported platform."); + } + public void Close() { // No-op diff --git a/Runtime/Scripts/NoCodes/NoCodesCustomActionDelegate.cs b/Runtime/Scripts/NoCodes/NoCodesCustomActionDelegate.cs new file mode 100644 index 0000000..ac44ebb --- /dev/null +++ b/Runtime/Scripts/NoCodes/NoCodesCustomActionDelegate.cs @@ -0,0 +1,22 @@ +namespace QonversionUnity +{ + /// + /// Optional companion to for receiving custom actions + /// configured in the No-Codes builder. + /// + /// Implement this interface on the same class as your — + /// it is a separate interface (instead of a new method) + /// to keep existing delegate implementations source-compatible. + /// + public interface NoCodesCustomActionDelegate + { + /// + /// Called when a custom action configured in the builder is triggered on the screen. + /// The No-Codes SDK does not execute anything itself — handle the value in your app code. + /// The screen stays open; close it using if needed. + /// + /// The string value configured for the custom action in the builder, + /// or an empty string if no value was configured. + void OnCustomAction(string value); + } +} diff --git a/Runtime/Scripts/NoCodes/NoCodesCustomActionDelegate.cs.meta b/Runtime/Scripts/NoCodes/NoCodesCustomActionDelegate.cs.meta new file mode 100644 index 0000000..10f2696 --- /dev/null +++ b/Runtime/Scripts/NoCodes/NoCodesCustomActionDelegate.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67063ead83f74bdfbf1d68ebdf1cc07f \ No newline at end of file diff --git a/Runtime/Scripts/NoCodes/iOS/NoCodesWrapperIOS.cs b/Runtime/Scripts/NoCodes/iOS/NoCodesWrapperIOS.cs index 53d5a51..2018bfd 100644 --- a/Runtime/Scripts/NoCodes/iOS/NoCodesWrapperIOS.cs +++ b/Runtime/Scripts/NoCodes/iOS/NoCodesWrapperIOS.cs @@ -19,6 +19,9 @@ internal class NoCodesWrapperIOS : INoCodesWrapper [DllImport("__Internal")] private static extern void _showNoCodesScreen(string contextKey, string customVariablesJson); + [DllImport("__Internal")] + private static extern void _loadNoCodesScreen(string contextKey); + [DllImport("__Internal")] private static extern void _closeNoCodes(); @@ -72,6 +75,13 @@ public void ShowScreen(string contextKey, string customVariablesJson) #endif } + public void LoadScreen(string contextKey) + { +#if UNITY_IOS + _loadNoCodesScreen(contextKey); +#endif + } + public void Close() { #if UNITY_IOS diff --git a/Runtime/iOS/Plugins/NoCodes/NoCodesBridge.m b/Runtime/iOS/Plugins/NoCodes/NoCodesBridge.m index 9dabbc8..141b06c 100644 --- a/Runtime/iOS/Plugins/NoCodes/NoCodesBridge.m +++ b/Runtime/iOS/Plugins/NoCodes/NoCodesBridge.m @@ -50,6 +50,11 @@ void _showNoCodesScreen(const char* contextKey, const char* customVariablesJson) [noCodesBridge showScreen:contextKeyStr customVariables:customVariables]; } +void _loadNoCodesScreen(const char* contextKey) { + NSString *contextKeyStr = [UtilityBridge convertCStringToNSString:contextKey]; + [noCodesBridge loadScreen:contextKeyStr]; +} + void _closeNoCodes() { [noCodesBridge close]; } diff --git a/Runtime/iOS/Plugins/NoCodes/QNUNoCodesDelegate.h b/Runtime/iOS/Plugins/NoCodes/QNUNoCodesDelegate.h index 0e6a179..ca23690 100644 --- a/Runtime/iOS/Plugins/NoCodes/QNUNoCodesDelegate.h +++ b/Runtime/iOS/Plugins/NoCodes/QNUNoCodesDelegate.h @@ -15,6 +15,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)setDelegate; - (void)setScreenPresentationConfig:(NSDictionary *)config contextKey:(NSString * _Nullable)contextKey; - (void)showScreen:(NSString *)contextKey customVariables:(NSDictionary * _Nullable)customVariables; +- (void)loadScreen:(NSString *)contextKey; - (void)close; - (void)setLocale:(NSString * _Nullable)locale; - (void)setTheme:(NSString * _Nullable)theme; diff --git a/Runtime/iOS/Plugins/NoCodes/QNUNoCodesDelegate.m b/Runtime/iOS/Plugins/NoCodes/QNUNoCodesDelegate.m index c6101ef..f3ba97a 100644 --- a/Runtime/iOS/Plugins/NoCodes/QNUNoCodesDelegate.m +++ b/Runtime/iOS/Plugins/NoCodes/QNUNoCodesDelegate.m @@ -12,6 +12,9 @@ static NSString *const kEventActionFinished = @"OnNoCodesActionFinished"; static NSString *const kEventFinished = @"OnNoCodesFinished"; static NSString *const kEventScreenFailedToLoad = @"OnNoCodesScreenFailedToLoad"; +static NSString *const kEventCustomAction = @"OnNoCodesCustomAction"; +static NSString *const kEventScreenLoaded = @"OnNoCodesScreenLoaded"; +static NSString *const kEventScreenLoadFailed = @"OnNoCodesScreenLoadFailed"; static NSString *const kEventPurchase = @"OnNoCodesPurchase"; static NSString *const kEventRestore = @"OnNoCodesRestore"; @@ -37,7 +40,8 @@ - (instancetype)initWithListenerName:(char *)unityListenerName { @"nocodes_action_failed": kEventActionFailed, @"nocodes_action_finished": kEventActionFinished, @"nocodes_finished": kEventFinished, - @"nocodes_screen_failed_to_load": kEventScreenFailedToLoad + @"nocodes_screen_failed_to_load": kEventScreenFailedToLoad, + @"nocodes_custom_action": kEventCustomAction }; } @@ -68,6 +72,18 @@ - (void)showScreen:(NSString *)contextKey customVariables:(NSDictionary * _Nullable result, SandwichError * _Nullable error) { + if (error) { + NSMutableDictionary *payload = [[UtilityBridge serializeSandwichError:error] mutableCopy]; + payload[@"contextKey"] = contextKey; + [UtilityBridge sendUnityMessage:payload toMethod:kEventScreenLoadFailed unityListener:noCodesListenerName]; + } else { + [UtilityBridge sendUnityMessage:result ?: @{} toMethod:kEventScreenLoaded unityListener:noCodesListenerName]; + } + }]; +} + - (void)close { [self.noCodesSandwich close]; } From 1c1c37be9f9a7157e614e4f1ed3dbdf6c7d7fc37 Mon Sep 17 00:00:00 2001 From: Kamo Spertsyan Date: Fri, 17 Jul 2026 13:52:14 +0300 Subject: [PATCH 4/6] Address review: no pending-callback leaks in LoadScreen, ClientError code (DEV-1192) An unparseable success payload now fails the correlated callbacks instead of leaving them pending forever, and unsupported platforms (noop wrapper) fail synchronously instead of never responding. Map the ClientError code both natives emit since sandwich 7.10.1. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KQEJXqfVNxqoUcKXhSPhWn --- Runtime/Scripts/NoCodes/Dto/NoCodesError.cs | 1 + .../Scripts/NoCodes/Dto/NoCodesErrorCode.cs | 3 ++- .../NoCodes/Internal/NoCodesInternal.cs | 23 +++++++++++++++++-- .../Scripts/NoCodes/Internal/NoCodesMapper.cs | 20 ++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Runtime/Scripts/NoCodes/Dto/NoCodesError.cs b/Runtime/Scripts/NoCodes/Dto/NoCodesError.cs index 6fcc9aa..39d3de9 100644 --- a/Runtime/Scripts/NoCodes/Dto/NoCodesError.cs +++ b/Runtime/Scripts/NoCodes/Dto/NoCodesError.cs @@ -85,6 +85,7 @@ private static NoCodesErrorCode ParseErrorCode(string code) case "RateLimitExceeded": return NoCodesErrorCode.RateLimitExceeded; case "ScreenLoadingFailed": return NoCodesErrorCode.ScreenLoadingFailed; case "SDKInitializationError": return NoCodesErrorCode.SdkInitializationError; + case "ClientError": return NoCodesErrorCode.ClientError; default: return NoCodesErrorCode.Unknown; } } diff --git a/Runtime/Scripts/NoCodes/Dto/NoCodesErrorCode.cs b/Runtime/Scripts/NoCodes/Dto/NoCodesErrorCode.cs index b080cd3..8073f43 100644 --- a/Runtime/Scripts/NoCodes/Dto/NoCodesErrorCode.cs +++ b/Runtime/Scripts/NoCodes/Dto/NoCodesErrorCode.cs @@ -30,6 +30,7 @@ public enum NoCodesErrorCode ProductsLoadingFailed, RateLimitExceeded, ScreenLoadingFailed, - SdkInitializationError + SdkInitializationError, + ClientError } } diff --git a/Runtime/Scripts/NoCodes/Internal/NoCodesInternal.cs b/Runtime/Scripts/NoCodes/Internal/NoCodesInternal.cs index 677a195..6340004 100644 --- a/Runtime/Scripts/NoCodes/Internal/NoCodesInternal.cs +++ b/Runtime/Scripts/NoCodes/Internal/NoCodesInternal.cs @@ -71,6 +71,15 @@ public void ShowScreen(string contextKey, System.Collections.Generic.Dictionary< public void LoadScreen(string contextKey, System.Action onSuccess, System.Action onError) { + INoCodesWrapper wrapper = GetNativeWrapper(); + if (wrapper is NoCodesWrapperNoop) + { + // The noop wrapper never sends anything back — fail synchronously + // instead of leaving the request pending forever. + onError?.Invoke(NoCodesMapper.UnsupportedPlatformError()); + return; + } + if (!_loadScreenCallbacks.TryGetValue(contextKey, out var callbacks)) { callbacks = new System.Collections.Generic.List(); @@ -79,7 +88,6 @@ public void LoadScreen(string contextKey, System.Action onSuccess callbacks.Add(new LoadScreenCallbacks { OnSuccess = onSuccess, OnError = onError }); - INoCodesWrapper wrapper = GetNativeWrapper(); wrapper.LoadScreen(contextKey); } @@ -236,7 +244,18 @@ private void OnNoCodesCustomAction(string jsonString) private void OnNoCodesScreenLoaded(string jsonString) { NoCodesScreen screen = NoCodesMapper.ScreenFromJson(jsonString); - if (screen == null) return; + if (screen == null) + { + // Don't leave the request pending on an unparseable payload — fail the + // callbacks the payload's contextKey still lets us correlate. + string contextKey = NoCodesMapper.ContextKeyFromJson(jsonString); + NoCodesError parseError = NoCodesMapper.ScreenParsingError(); + foreach (LoadScreenCallbacks callbacks in TakeLoadScreenCallbacks(contextKey)) + { + callbacks.OnError?.Invoke(parseError); + } + return; + } foreach (LoadScreenCallbacks callbacks in TakeLoadScreenCallbacks(screen.ContextKey)) { diff --git a/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs b/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs index 42d1479..9a49504 100644 --- a/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs +++ b/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs @@ -77,6 +77,26 @@ internal static string ContextKeyFromJson(string jsonStr) return dict.GetString("contextKey"); } + internal static NoCodesError ScreenParsingError() + { + return new NoCodesError(new Dictionary + { + { "code", "Deserialization" }, + { "description", "Failed to parse the loaded No-Code screen" }, + { "additionalMessage", "Native payload parsing failed." } + }); + } + + internal static NoCodesError UnsupportedPlatformError() + { + return new NoCodesError(new Dictionary + { + { "code", "Unknown" }, + { "description", "No-Codes is not supported on this platform" }, + { "additionalMessage", "" } + }); + } + internal static NoCodesError LoadScreenErrorFromJson(string jsonStr) { if (Json.Deserialize(jsonStr) is Dictionary dict && From 4a2779bf75d2c25b1624d347307a768d8f69b408 Mon Sep 17 00:00:00 2001 From: Kamo Spertsyan Date: Fri, 17 Jul 2026 14:05:54 +0300 Subject: [PATCH 5/6] Catch mapping exceptions in ScreenFromJson (DEV-1192) The pending-callback fix only covered the null-return path; an anomalous payload throwing inside the NoCodesScreen constructor still escaped into the UnitySendMessage handler and leaked the correlated callbacks. Return null on mapping failure so the existing parse-error path fails them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BVh5VVyvxemyHT2oRSyXWK --- Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs b/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs index 9a49504..4c1a557 100644 --- a/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs +++ b/Runtime/Scripts/NoCodes/Internal/NoCodesMapper.cs @@ -52,7 +52,17 @@ internal static NoCodesScreen ScreenFromJson(string jsonStr) return null; } - return new NoCodesScreen(screenDict); + try + { + return new NoCodesScreen(screenDict); + } + catch (System.Exception e) + { + // An anomalous payload (e.g. null field values) must fail the pending + // LoadScreen callbacks, not escape into the UnitySendMessage handler. + Debug.LogError("Could not map NoCodes screen: " + e.Message); + return null; + } } internal static string CustomActionValueFromJson(string jsonStr) From 02c7e7ff3ab0668d5567d0554bbcbbfde28f8007 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 17 Jul 2026 12:39:47 +0000 Subject: [PATCH 6/6] Bump version to 9.7.0 --- Runtime/Scripts/Internal/QonversionInternal.cs | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Runtime/Scripts/Internal/QonversionInternal.cs b/Runtime/Scripts/Internal/QonversionInternal.cs index 55df781..d7175ea 100644 --- a/Runtime/Scripts/Internal/QonversionInternal.cs +++ b/Runtime/Scripts/Internal/QonversionInternal.cs @@ -28,7 +28,7 @@ internal class QonversionInternal : MonoBehaviour, IQonversion private const string OnIsFallbackFileAccessibleMethodName = "OnIsFallbackFileAccessible"; private const string OnPromotionalOfferMethodName = "OnPromotionalOffer"; - private const string SdkVersion = "9.6.0"; + private const string SdkVersion = "9.7.0"; private const string SdkSource = "unity"; private const string DefaultRemoteConfigContextKey = ""; diff --git a/package.json b/package.json index 1cb9f0d..5d1bfc8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.qonversion.unity", "displayName": "Qonversion", - "version": "9.6.0", + "version": "9.7.0", "unity": "2018.3", "description": "Empower your mobile app marketing and product decisions with precise subscription data.", "author": {