diff --git a/com.onesignal.unity.android/Editor/OneSignalConfig.androidlib/consumer-proguard.pro b/com.onesignal.unity.android/Editor/OneSignalConfig.androidlib/consumer-proguard.pro
index 1eb572fe4..d1bea67cc 100644
--- a/com.onesignal.unity.android/Editor/OneSignalConfig.androidlib/consumer-proguard.pro
+++ b/com.onesignal.unity.android/Editor/OneSignalConfig.androidlib/consumer-proguard.pro
@@ -1,4 +1,9 @@
-keep class com.onesignal.** { *; }
# Work around for IllegalStateException with kotlinx-coroutines-android
--keep class kotlinx.coroutines.android.AndroidDispatcherFactory {*;}
\ No newline at end of file
+-keep class kotlinx.coroutines.android.AndroidDispatcherFactory {*;}
+
+# WorkManager initializes a Room database through AndroidX Startup before Unity starts.
+# Unity release builds run R8, so keep the generated database implementation reachable.
+-keep class androidx.work.impl.WorkDatabase* { *; }
+-keep class androidx.work.impl.model.** { *; }
diff --git a/examples/demo/.env.example b/examples/demo/.env.example
index 674a938f9..b0c98cee0 100644
--- a/examples/demo/.env.example
+++ b/examples/demo/.env.example
@@ -1 +1,4 @@
+# Default App ID (used when ONESIGNAL_APP_ID is empty or missing): 77e32082-ea27-42e3-a898-c72e141824ef
+ONESIGNAL_APP_ID=your-onesignal-app-id
ONESIGNAL_API_KEY=your_rest_api_key
+E2E_MODE=false
diff --git a/examples/demo/Assets/App/Editor/iOS/BuildPostProcessor.cs b/examples/demo/Assets/App/Editor/iOS/BuildPostProcessor.cs
index 1beb3d097..7185d335e 100644
--- a/examples/demo/Assets/App/Editor/iOS/BuildPostProcessor.cs
+++ b/examples/demo/Assets/App/Editor/iOS/BuildPostProcessor.cs
@@ -34,6 +34,7 @@
using UnityEditor.iOS.Xcode.Extensions;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
namespace App.Editor.iOS
{
@@ -159,12 +160,63 @@ static void AddWidgetExtensionToPodFile(string outputPath)
return;
}
+ // Keep the widget extension pinned to the same OneSignalXCFramework version as the
+ // core plugin so CocoaPods can resolve a single shared version across targets.
+ var requiredVersion = ResolveOneSignalXCFrameworkVersion();
+ var versionConstraint =
+ requiredVersion != null ? $"'{requiredVersion}'" : "'>= 5.0.2', '< 6.0.0'";
+ var requiredTarget =
+ $"target '{WidgetExtensionTargetName}' do\n pod 'OneSignalXCFramework', {versionConstraint}\nend\n";
+
var podfile = File.ReadAllText(podfilePath);
- podfile +=
- $"target '{WidgetExtensionTargetName}' do\n pod 'OneSignalXCFramework', '>= 5.0.2', '< 6.0.0'\nend\n";
+ var podfileRegex = new Regex(
+ $@"target '{WidgetExtensionTargetName}' do\n pod 'OneSignalXCFramework', '(.+)'\nend\n"
+ );
+
+ if (!podfileRegex.IsMatch(podfile))
+ podfile += requiredTarget;
+ else
+ {
+ var podfileTarget = podfileRegex.Match(podfile).ToString();
+ podfile = podfile.Replace(podfileTarget, requiredTarget);
+ }
+
File.WriteAllText(podfilePath, podfile);
}
+ static string ResolveOneSignalXCFrameworkVersion()
+ {
+ var dependenciesFilePath = Path.Combine(
+ "Packages",
+ "com.onesignal.unity.ios",
+ "Editor",
+ "OneSignaliOSDependencies.xml"
+ );
+
+ if (!File.Exists(dependenciesFilePath))
+ {
+ Debug.LogWarning(
+ $"Could not find {dependenciesFilePath}; falling back to default OneSignalXCFramework version range."
+ );
+ return null;
+ }
+
+ var dependenciesFile = File.ReadAllText(dependenciesFilePath);
+ var dependenciesRegex = new Regex(
+ "(?<=)"
+ );
+
+ if (!dependenciesRegex.IsMatch(dependenciesFile))
+ {
+ Debug.LogWarning(
+ $"Could not read OneSignalXCFramework version from {dependenciesFilePath}; falling back to default version range."
+ );
+ return null;
+ }
+
+ return dependenciesRegex.Match(dependenciesFile).ToString();
+ }
+
static void CopyFileOrDirectory(string sourcePath, string destinationPath)
{
var file = new FileInfo(sourcePath);
diff --git a/examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs b/examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs
new file mode 100644
index 000000000..e42d5453f
--- /dev/null
+++ b/examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs
@@ -0,0 +1,154 @@
+#if UNITY_IOS
+
+using System.IO;
+using UnityEditor;
+using UnityEditor.Build;
+using UnityEditor.Build.Reporting;
+using UnityEditor.iOS.Xcode;
+using UnityEngine;
+
+namespace App.Editor.iOS
+{
+ ///
+ /// Final iOS post-processor for the demo app. Runs AFTER the OneSignal
+ /// SDK and demo widget post-processors so it can correct things they set:
+ ///
+ /// 1. Flips the main target's aps-environment from "production" (the SDK
+ /// default) to "development". The demo only ever runs on simulator or
+ /// a development device; "production" mismatches the simulator's APNS
+ /// environment and triggers iOS's "Keep receiving notifications?"
+ /// tuning prompt on first delivery (matches what the Flutter demo
+ /// ships with).
+ ///
+ /// 2. Normalizes extension bundle IDs to short suffixes (`.NSE`, `.LA`)
+ /// to match the Flutter demo and keep provisioning profile names
+ /// consistent across SDKs.
+ ///
+ /// 3. Pins DEVELOPMENT_TEAM on all targets so a future Manual signing
+ /// setup with the OneSignal-owned profiles works without manual
+ /// fix-up in Xcode.
+ ///
+ public class SigningPostProcessor : IPostprocessBuildWithReport
+ {
+ private const string AppleTeamId = "99SW8E36CT";
+ private const string ApsEnvironment = "development";
+
+ private const string NseTargetName = "OneSignalNotificationServiceExtension";
+ private const string WidgetTargetName = "OneSignalWidget";
+
+ // Short bundle-id suffixes (match the Flutter demo).
+ private const string NseBundleSuffix = "NSE";
+ private const string WidgetBundleSuffix = "LA";
+
+ // Run after both demo widget post-processor (45) and SDK
+ // post-processor (45). 100 puts us after pod install (50) too.
+ public int callbackOrder => 100;
+
+ public void OnPostprocessBuild(BuildReport report)
+ {
+ if (report.summary.platform != BuildTarget.iOS)
+ return;
+
+ var outputPath = report.summary.outputPath;
+ FixupApsEnvironment(outputPath);
+ FixupSigningAndBundleIds(outputPath);
+ }
+
+ private static void FixupApsEnvironment(string outputPath)
+ {
+ var project = new PBXProject();
+ var projectPath = PBXProject.GetPBXProjectPath(outputPath);
+ project.ReadFromString(File.ReadAllText(projectPath));
+
+ var mainTargetGuid = project.GetUnityMainTargetGuid();
+ var relPath = project.GetBuildPropertyForAnyConfig(
+ mainTargetGuid,
+ "CODE_SIGN_ENTITLEMENTS"
+ );
+
+ if (string.IsNullOrEmpty(relPath))
+ {
+ Debug.LogWarning(
+ "[SigningPostProcessor] Main target has no CODE_SIGN_ENTITLEMENTS; "
+ + "skipping aps-environment fixup."
+ );
+ return;
+ }
+
+ var fullPath = Path.Combine(outputPath, relPath);
+ if (!File.Exists(fullPath))
+ {
+ Debug.LogWarning(
+ $"[SigningPostProcessor] Entitlements file not found at {fullPath}; skipping."
+ );
+ return;
+ }
+
+ var plist = new PlistDocument();
+ plist.ReadFromFile(fullPath);
+ plist.root.SetString("aps-environment", ApsEnvironment);
+ plist.WriteToFile(fullPath);
+
+ Debug.Log(
+ $"[SigningPostProcessor] Set aps-environment=\"{ApsEnvironment}\" in {relPath}"
+ );
+ }
+
+ private static void FixupSigningAndBundleIds(string outputPath)
+ {
+ var project = new PBXProject();
+ var projectPath = PBXProject.GetPBXProjectPath(outputPath);
+ project.ReadFromString(File.ReadAllText(projectPath));
+
+ var appId = PlayerSettings.GetApplicationIdentifier(BuildTargetGroup.iOS);
+
+ ApplyTeamId(project, project.GetUnityMainTargetGuid(), "Unity-iPhone");
+
+ ApplyExtensionFixup(
+ project,
+ NseTargetName,
+ $"{appId}.{NseBundleSuffix}"
+ );
+ ApplyExtensionFixup(
+ project,
+ WidgetTargetName,
+ $"{appId}.{WidgetBundleSuffix}"
+ );
+
+ File.WriteAllText(projectPath, project.WriteToString());
+ }
+
+ private static void ApplyTeamId(PBXProject project, string targetGuid, string label)
+ {
+ if (string.IsNullOrEmpty(targetGuid))
+ return;
+
+ project.SetBuildProperty(targetGuid, "DEVELOPMENT_TEAM", AppleTeamId);
+ Debug.Log($"[SigningPostProcessor] Pinned DEVELOPMENT_TEAM={AppleTeamId} on {label}");
+ }
+
+ private static void ApplyExtensionFixup(
+ PBXProject project,
+ string targetName,
+ string bundleId
+ )
+ {
+ var guid = project.TargetGuidByName(targetName);
+ if (string.IsNullOrEmpty(guid))
+ {
+ Debug.LogWarning(
+ $"[SigningPostProcessor] Target '{targetName}' not found; skipping."
+ );
+ return;
+ }
+
+ project.SetBuildProperty(guid, "PRODUCT_BUNDLE_IDENTIFIER", bundleId);
+ ApplyTeamId(project, guid, targetName);
+ Debug.Log(
+ $"[SigningPostProcessor] Set {targetName} PRODUCT_BUNDLE_IDENTIFIER={bundleId}"
+ );
+ }
+ }
+}
+
+#endif
diff --git a/examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs.meta b/examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs.meta
new file mode 100644
index 000000000..8cc7d24d8
--- /dev/null
+++ b/examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2e20b362e592d44d6a523d29a9c059f6
\ No newline at end of file
diff --git a/examples/demo/Assets/Plugins/Android/OneSignalConfig.androidlib/consumer-proguard.pro b/examples/demo/Assets/Plugins/Android/OneSignalConfig.androidlib/consumer-proguard.pro
index 1eb572fe4..d1bea67cc 100644
--- a/examples/demo/Assets/Plugins/Android/OneSignalConfig.androidlib/consumer-proguard.pro
+++ b/examples/demo/Assets/Plugins/Android/OneSignalConfig.androidlib/consumer-proguard.pro
@@ -1,4 +1,9 @@
-keep class com.onesignal.** { *; }
# Work around for IllegalStateException with kotlinx-coroutines-android
--keep class kotlinx.coroutines.android.AndroidDispatcherFactory {*;}
\ No newline at end of file
+-keep class kotlinx.coroutines.android.AndroidDispatcherFactory {*;}
+
+# WorkManager initializes a Room database through AndroidX Startup before Unity starts.
+# Unity release builds run R8, so keep the generated database implementation reachable.
+-keep class androidx.work.impl.WorkDatabase* { *; }
+-keep class androidx.work.impl.model.** { *; }
diff --git a/examples/demo/Assets/Plugins/Android/OneSignalConfig.androidlib/src/main/java/com/onesignal/onesignalsdk/OneSignalUnityE2EAccessibility.java b/examples/demo/Assets/Plugins/Android/OneSignalConfig.androidlib/src/main/java/com/onesignal/onesignalsdk/OneSignalUnityE2EAccessibility.java
new file mode 100644
index 000000000..f6555e801
--- /dev/null
+++ b/examples/demo/Assets/Plugins/Android/OneSignalConfig.androidlib/src/main/java/com/onesignal/onesignalsdk/OneSignalUnityE2EAccessibility.java
@@ -0,0 +1,312 @@
+package com.onesignal.onesignalsdk;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewConfiguration;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+public final class OneSignalUnityE2EAccessibility {
+ private static final String UNITY_OBJECT = "OneSignalAccessibilityBridge";
+ private static final String TAG = "OneSignalUnityE2E";
+ private static final Map entries = new HashMap<>();
+ private static FrameLayout overlay;
+ private static int generation;
+ private static boolean loggedOverlayReady;
+
+ private OneSignalUnityE2EAccessibility() {}
+
+ public static void beginSync() {
+ Activity activity = getActivity();
+ if (activity == null) return;
+ activity.runOnUiThread(
+ () -> {
+ ensureOverlay(activity);
+ generation++;
+ });
+ }
+
+ public static void syncElement(
+ String id,
+ String text,
+ boolean active,
+ int x,
+ int y,
+ int width,
+ int height,
+ String role,
+ boolean enabled) {
+ Activity activity = getActivity();
+ if (activity == null || id == null || id.length() == 0) return;
+ activity.runOnUiThread(
+ () -> {
+ ensureOverlay(activity);
+ Entry entry = entries.get(id);
+ if (entry == null || !entry.role.equals(role)) {
+ if (entry != null) overlay.removeView(entry.view);
+ entry = new Entry(createView(activity, id, role), role);
+ entries.put(id, entry);
+ overlay.addView(entry.view);
+ }
+
+ entry.generation = generation;
+ entry.view.setVisibility(active ? View.VISIBLE : View.GONE);
+ entry.view.setEnabled(enabled);
+ entry.view.setContentDescription(text == null ? "" : text);
+ applyText(entry.view, text == null ? "" : text);
+
+ FrameLayout.LayoutParams params =
+ new FrameLayout.LayoutParams(Math.max(1, width), Math.max(1, height));
+ params.leftMargin = x;
+ params.topMargin = y;
+ entry.view.setLayoutParams(params);
+ });
+ }
+
+ public static void endSync() {
+ Activity activity = getActivity();
+ if (activity == null) return;
+ activity.runOnUiThread(
+ () -> {
+ if (overlay == null) return;
+ Iterator> it = entries.entrySet().iterator();
+ while (it.hasNext()) {
+ Entry entry = it.next().getValue();
+ if (entry.generation == generation) continue;
+ overlay.removeView(entry.view);
+ it.remove();
+ }
+ });
+ }
+
+ private static void ensureOverlay(Activity activity) {
+ if (overlay != null) return;
+ overlay = new E2EOverlay(activity);
+ overlay.setClipChildren(false);
+ overlay.setClipToPadding(false);
+ overlay.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
+ activity.addContentView(
+ overlay,
+ new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ if (!loggedOverlayReady) {
+ loggedOverlayReady = true;
+ Log.d(TAG, "Native accessibility overlay ready");
+ }
+ }
+
+ private static View createView(Activity activity, String id, String role) {
+ TextView view;
+ if ("input".equals(role)) {
+ E2EEditText input = new E2EEditText(activity, id);
+ // // Hide the blinking caret; the Unity-drawn TextField renders its own.
+ // input.setCursorVisible(false);
+ // Suppress the spell-checker's red underline span. The Unity TextField
+ // is the user-visible input; the overlay only needs to forward
+ // characters back to Unity via the TextWatcher.
+ input.setInputType(
+ InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ input.addTextChangedListener(
+ new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ if (!input.isApplyingUnityValue()) sendToUnity(id, "setValue", s.toString());
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {}
+ });
+ view = input;
+ } else if ("toggle".equals(role)) {
+ E2ECheckBox toggle = new E2ECheckBox(activity, id);
+ // Hide the default square check indicator; the Unity-drawn pill switch
+ // is the visual. CheckBox class, isChecked() state, viewIdResourceName,
+ // and click handling are preserved for Appium/UiAutomator2.
+ toggle.setButtonDrawable(null);
+ toggle.setOnClickListener(v -> sendToUnity(id, "click", ""));
+ view = toggle;
+ } else {
+ view = new E2ETextView(activity, id);
+ if ("button".equals(role)) {
+ view.setClickable(true);
+ view.setOnClickListener(v -> sendToUnity(id, "click", ""));
+ }
+ }
+
+ view.setGravity(Gravity.CENTER);
+ view.setBackgroundColor(Color.TRANSPARENT);
+ view.setTextColor(Color.TRANSPARENT);
+ view.setHintTextColor(Color.TRANSPARENT);
+ view.setIncludeFontPadding(false);
+ view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ return view;
+ }
+
+ private static void applyText(View view, String text) {
+ if (view instanceof E2ECheckBox) {
+ ((E2ECheckBox) view).setChecked("1".equals(text) || "true".equalsIgnoreCase(text));
+ ((E2ECheckBox) view).setText(text);
+ return;
+ }
+
+ if (view instanceof E2EEditText) {
+ E2EEditText input = (E2EEditText) view;
+ if (!text.contentEquals(input.getText())) input.setUnityText(text);
+ return;
+ }
+
+ if (view instanceof TextView) ((TextView) view).setText(text);
+ }
+
+ private static void sendToUnity(String id, String action, String value) {
+ try {
+ Class> unityPlayer = Class.forName("com.unity3d.player.UnityPlayer");
+ Method sendMessage =
+ unityPlayer.getMethod("UnitySendMessage", String.class, String.class, String.class);
+ sendMessage.invoke(
+ null, UNITY_OBJECT, "HandleAndroidAccessibilityAction", id + "\n" + action + "\n" + value);
+ } catch (Exception ignored) {
+ }
+ }
+
+ private static Activity getActivity() {
+ try {
+ Class> unityPlayer = Class.forName("com.unity3d.player.UnityPlayer");
+ Field currentActivity = unityPlayer.getField("currentActivity");
+ Object activity = currentActivity.get(null);
+ return activity instanceof Activity ? (Activity) activity : null;
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private static final class Entry {
+ final View view;
+ final String role;
+ int generation;
+
+ Entry(View view, String role) {
+ this.view = view;
+ this.role = role;
+ }
+ }
+
+ private static final class E2EOverlay extends FrameLayout {
+ private final int gutterWidth;
+ private final int touchSlop;
+ private boolean trackingGutterDrag;
+ private float startY;
+
+ E2EOverlay(Activity activity) {
+ super(activity);
+ // The test swipe lane is x=10. Keep this narrow so dialog checkboxes
+ // near the left edge still receive normal clicks.
+ gutterWidth = 24;
+ touchSlop = ViewConfiguration.get(activity).getScaledTouchSlop();
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ trackingGutterDrag = event.getX() <= gutterWidth;
+ startY = event.getY();
+ if (trackingGutterDrag) return true;
+ }
+
+ if (!trackingGutterDrag) return super.dispatchTouchEvent(event);
+
+ if (event.getActionMasked() == MotionEvent.ACTION_UP) {
+ float deltaY = event.getY() - startY;
+ if (Math.abs(deltaY) > touchSlop) {
+ sendToUnity("main_scroll_view", "scroll", deltaY < 0 ? "down" : "up");
+ }
+ trackingGutterDrag = false;
+ return true;
+ }
+
+ if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
+ trackingGutterDrag = false;
+ return true;
+ }
+
+ return true;
+ }
+ }
+
+ private static class E2ETextView extends TextView {
+ private final String id;
+
+ E2ETextView(Activity activity, String id) {
+ super(activity);
+ this.id = id;
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ info.setViewIdResourceName(id);
+ }
+ }
+
+ private static final class E2EEditText extends EditText {
+ private final String id;
+ private boolean applyingUnityValue;
+
+ E2EEditText(Activity activity, String id) {
+ super(activity);
+ this.id = id;
+ setSingleLine(false);
+ }
+
+ boolean isApplyingUnityValue() {
+ return applyingUnityValue;
+ }
+
+ void setUnityText(String text) {
+ applyingUnityValue = true;
+ setText(text);
+ setSelection(getText().length());
+ applyingUnityValue = false;
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ info.setViewIdResourceName(id);
+ }
+ }
+
+ private static final class E2ECheckBox extends CheckBox {
+ private final String id;
+
+ E2ECheckBox(Activity activity, String id) {
+ super(activity);
+ this.id = id;
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ info.setViewIdResourceName(id);
+ }
+ }
+}
diff --git a/examples/demo/Assets/Scripts/Repositories.meta b/examples/demo/Assets/Plugins/iOS.meta
similarity index 77%
rename from examples/demo/Assets/Scripts/Repositories.meta
rename to examples/demo/Assets/Plugins/iOS.meta
index 5b2a5fbfa..585a379ce 100644
--- a/examples/demo/Assets/Scripts/Repositories.meta
+++ b/examples/demo/Assets/Plugins/iOS.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 0068a692b631446fc90e311f87a661b9
+guid: e50fcac533b954faeab65c9328164d33
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/examples/demo/Assets/Plugins/iOS/OneSignalDemoKeyboard.mm b/examples/demo/Assets/Plugins/iOS/OneSignalDemoKeyboard.mm
new file mode 100644
index 000000000..4a1bf8331
--- /dev/null
+++ b/examples/demo/Assets/Plugins/iOS/OneSignalDemoKeyboard.mm
@@ -0,0 +1,8 @@
+#import
+
+// Resign first responder so the iOS keyboard view tears down immediately.
+// UIToolkit's TextField.Blur() and TouchScreenKeyboard APIs don't reliably
+// dismiss the UIKit keyboard when a UIToolkit-owned modal closes.
+extern "C" void OneSignalDemoEndEditing() {
+ [UIApplication.sharedApplication.keyWindow endEditing:YES];
+}
diff --git a/examples/demo/Assets/Plugins/iOS/OneSignalDemoKeyboard.mm.meta b/examples/demo/Assets/Plugins/iOS/OneSignalDemoKeyboard.mm.meta
new file mode 100644
index 000000000..cd4e14061
--- /dev/null
+++ b/examples/demo/Assets/Plugins/iOS/OneSignalDemoKeyboard.mm.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0db794f4e84ab424fab6b119789907d0
\ No newline at end of file
diff --git a/examples/demo/Assets/Resources/LogView.uss b/examples/demo/Assets/Resources/LogView.uss
deleted file mode 100644
index bfba8c623..000000000
--- a/examples/demo/Assets/Resources/LogView.uss
+++ /dev/null
@@ -1,95 +0,0 @@
-.log-container {
- background-color: #1A1B1E;
- width: 100%;
- border-bottom-width: 1px;
- border-bottom-color: #333;
-}
-
-.log-header {
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
-}
-
-.log-header-left {
- flex-direction: row;
- align-items: center;
-}
-
-.log-header-right {
- flex-direction: row;
- align-items: center;
-}
-
-.log-header-title {
- color: white;
- -unity-font-style: bold;
-}
-
-.log-header-count {
- color: #9E9E9E;
- margin-left: 8px;
-}
-
-.log-clear-button {
- width: 24px;
- height: 24px;
- background-color: transparent;
- border-width: 0;
- color: #9E9E9E;
- font-size: 18px;
- -unity-font-definition: resource("MaterialIcons-Regular");
- -unity-text-align: middle-center;
- padding: 0;
-}
-
-.log-scroll {
- height: 100px;
-}
-
-.log-entry {
- flex-direction: row;
- padding: 1px 12px;
- align-items: flex-start;
-}
-
-.log-timestamp {
- color: #676E7B;
- min-width: 70px;
- margin-right: 4px;
-}
-
-.log-level {
- min-width: 14px;
- margin-right: 4px;
- -unity-font-style: bold;
-}
-
-.log-level-d { color: #82AAFF; }
-.log-level-i { color: #C3E88D; }
-.log-level-w { color: #FFCB6B; }
-.log-level-e { color: #FF5370; }
-
-.log-message {
- color: white;
- flex-grow: 1;
- flex-shrink: 1;
- white-space: nowrap;
-}
-
-.log-empty {
- color: #9E9E9E;
- -unity-text-align: middle-center;
- padding: 8px;
-}
-
-.log-scroll Scroller {
- width: 0;
- height: 0;
- min-width: 0;
- min-height: 0;
- max-width: 0;
- max-height: 0;
- overflow: hidden;
-}
diff --git a/examples/demo/Assets/Resources/LogView.uss.meta b/examples/demo/Assets/Resources/LogView.uss.meta
deleted file mode 100644
index 78250750c..000000000
--- a/examples/demo/Assets/Resources/LogView.uss.meta
+++ /dev/null
@@ -1,12 +0,0 @@
-fileFormatVersion: 2
-guid: 04a9d6a4976ea436eaa10d669d6d4753
-ScriptedImporter:
- internalIDToNameTable: []
- externalObjects: {}
- serializedVersion: 2
- userData:
- assetBundleName:
- assetBundleVariant:
- script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
- disableValidation: 0
- unsupportedSelectorAction: 0
diff --git a/examples/demo/Assets/Resources/Theme.uss b/examples/demo/Assets/Resources/Theme.uss
index b03402fab..b9ecdd8d7 100644
--- a/examples/demo/Assets/Resources/Theme.uss
+++ b/examples/demo/Assets/Resources/Theme.uss
@@ -106,6 +106,14 @@
color: var(--os-text-primary);
}
+.hint-text {
+ font-size: 12px;
+ color: var(--os-text-hint);
+ -unity-text-align: middle-center;
+ margin-top: 4px;
+ white-space: normal;
+}
+
.text-warning-link {
font-size: 12px;
-unity-font-style: bold;
@@ -141,6 +149,10 @@
border-bottom-width: 0;
}
+.app-bar-left {
+ justify-content: flex-start;
+}
+
.app-bar-logo {
width: 99px;
height: 22px;
@@ -158,6 +170,12 @@
color: white;
}
+.app-bar-shadow {
+ height: 4px;
+ background-color: #B33A3C;
+ flex-shrink: 0;
+}
+
.section-container {
}
@@ -178,8 +196,13 @@
}
.info-button {
- width: 18px;
- height: 18px;
+ /* 44px touch target meets the iOS HIG minimum. The Material icon
+ glyph is still rendered at 18px (font-size), centered via text-align,
+ so the button looks unchanged but absorbs small Appium tap-coord
+ drift after ScrollView momentum / accessibility-frame settle. The
+ transparent background keeps it visually identical. */
+ width: 44px;
+ height: 44px;
background-color: rgba(0, 0, 0, 0);
color: var(--os-grey-500);
font-size: 18px;
@@ -482,8 +505,9 @@
top: 0;
bottom: 0;
background-color: var(--os-backdrop);
- justify-content: center;
+ justify-content: flex-start;
align-items: center;
+ padding-top: 80px;
}
.dialog-container {
@@ -652,26 +676,6 @@
background-color: var(--os-primary);
}
-.loading-overlay {
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- background-color: var(--os-backdrop);
- justify-content: center;
- align-items: center;
-}
-
-.loading-spinner {
- width: 40px;
- height: 40px;
- border-radius: 20px;
- border-width: 4px;
- border-color: var(--os-divider);
- border-top-color: var(--os-primary);
-}
-
.toast-container {
position: absolute;
bottom: 32px;
diff --git a/examples/demo/Assets/Scripts/AppBootstrapper.cs b/examples/demo/Assets/Scripts/AppBootstrapper.cs
index 71f68141c..cfc801e49 100644
--- a/examples/demo/Assets/Scripts/AppBootstrapper.cs
+++ b/examples/demo/Assets/Scripts/AppBootstrapper.cs
@@ -1,4 +1,3 @@
-using OneSignalDemo.Repositories;
using OneSignalDemo.Services;
using OneSignalDemo.ViewModels;
using OneSignalSDK;
@@ -6,13 +5,15 @@
using OneSignalSDK.InAppMessages;
using OneSignalSDK.LiveActivities;
using OneSignalSDK.Notifications;
+using OneSignalSDK.Notifications.Models;
using UnityEngine;
namespace OneSignalDemo
{
public class AppBootstrapper : MonoBehaviour
{
- private const string OneSignalAppId = "77e32082-ea27-42e3-a898-c72e141824ef";
+ private const string DefaultAppId = "77e32082-ea27-42e3-a898-c72e141824ef";
+ private const string PlaceholderAppId = "your-onesignal-app-id";
private const string Tag = "AppBootstrapper";
[SerializeField]
@@ -20,7 +21,6 @@ public class AppBootstrapper : MonoBehaviour
private PreferencesService _prefs;
private OneSignalApiService _apiService;
- private OneSignalRepository _repository;
private void Awake()
{
@@ -29,21 +29,38 @@ private void Awake()
private async void Start()
{
+ DotEnv.Load();
+
+ // iOS cancels in-progress touches when the OS suspends the app, and
+ // Unity's Input System raises "Touch was already deallocated" on the
+ // next frame. In dev builds, that pops the engine's Development
+ // Console overlay on top of the UI and occludes subsequent test taps.
+ // Appium's live-activity spec deliberately locks the screen mid-tap,
+ // so suppress the overlay when E2E mode is on; the exception itself
+ // still logs to stdout for debugging. developerConsoleEnabled
+ // prevents future pops; developerConsoleVisible hides one that's
+ // already showing.
+ if (DotEnv.IsE2EMode)
+ {
+ Debug.developerConsoleEnabled = false;
+ Debug.developerConsoleVisible = false;
+ }
+
_prefs = new PreferencesService();
_apiService = new OneSignalApiService();
- _repository = new OneSignalRepository(_apiService);
- var appId = _prefs.AppId;
- if (string.IsNullOrEmpty(appId))
- {
- appId = OneSignalAppId;
- _prefs.AppId = appId;
- }
+ var envAppId = DotEnv.Get("ONESIGNAL_APP_ID");
+ var appId =
+ string.IsNullOrWhiteSpace(envAppId) || envAppId == PlaceholderAppId
+ ? DefaultAppId
+ : envAppId;
_apiService.SetAppId(appId);
- _apiService.LoadApiKey();
OneSignal.Debug.LogLevel = LogLevel.Verbose;
+#if UNITY_ANDROID && !UNITY_EDITOR
+ SetAndroidWebViewDebugging(false);
+#endif
OneSignal.ConsentRequired = _prefs.ConsentRequired;
OneSignal.ConsentGiven = _prefs.PrivacyConsent;
OneSignal.Initialize(appId);
@@ -58,22 +75,86 @@ private async void Start()
);
#endif
+ RegisterSdkListeners();
+
OneSignal.InAppMessages.Paused = _prefs.IamPaused;
OneSignal.Location.IsShared = _prefs.LocationShared;
- RegisterSdkListeners();
-
- _viewModel.Init(_repository, _prefs);
+ _viewModel.Init(_prefs, _apiService);
_viewModel.LoadInitialState();
await _viewModel.LoadInitialDataAsync();
- if (!_viewModel.HasPermission)
- _viewModel.PromptPush();
+ PromptPushForStartup();
_ = TooltipHelper.Instance.InitAsync();
- LogManager.Instance.Info(Tag, "App initialized");
+ Debug.Log($"[{Tag}] App initialized");
}
+ private void PromptPushForStartup()
+ {
+#if UNITY_ANDROID && !UNITY_EDITOR
+ if (DotEnv.IsE2EMode)
+ {
+ RequestAndroidPostNotificationsPermission();
+ return;
+ }
+#endif
+ _viewModel.PromptPush();
+ }
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+ private static void RequestAndroidPostNotificationsPermission()
+ {
+ using var version = new AndroidJavaClass("android.os.Build$VERSION");
+ if (version.GetStatic("SDK_INT") < 33)
+ return;
+
+ using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+ using var activity = unityPlayer.GetStatic("currentActivity");
+ activity.Call(
+ "requestPermissions",
+ new[] { "android.permission.POST_NOTIFICATIONS" },
+ 1001
+ );
+ }
+
+ private static void SetAndroidWebViewDebugging(bool enabled)
+ {
+ if (!DotEnv.IsE2EMode)
+ return;
+
+ try
+ {
+ using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+ using var activity = unityPlayer.GetStatic("currentActivity");
+ if (activity == null)
+ return;
+
+ activity.Call(
+ "runOnUiThread",
+ new AndroidJavaRunnable(() =>
+ {
+ try
+ {
+ using var webView = new AndroidJavaClass("android.webkit.WebView");
+ webView.CallStatic("setWebContentsDebuggingEnabled", enabled);
+ }
+ catch (AndroidJavaException ex)
+ {
+ Debug.LogWarning(
+ $"[{Tag}] Could not set WebView debugging: {ex.Message}"
+ );
+ }
+ })
+ );
+ }
+ catch (AndroidJavaException ex)
+ {
+ Debug.LogWarning($"[{Tag}] Could not set WebView debugging: {ex.Message}");
+ }
+ }
+#endif
+
private void RegisterSdkListeners()
{
OneSignal.InAppMessages.WillDisplay += OnIamWillDisplay;
@@ -99,30 +180,40 @@ private void OnDestroy()
OneSignal.Notifications.ForegroundWillDisplay -= OnNotificationForegroundWillDisplay;
}
- private void OnIamWillDisplay(object sender, InAppMessageWillDisplayEventArgs e) =>
- LogManager.Instance.Info(Tag, $"IAM will display: {e.Message.MessageId}");
+ private void OnIamWillDisplay(object sender, InAppMessageWillDisplayEventArgs e)
+ {
+#if UNITY_ANDROID && !UNITY_EDITOR
+ SetAndroidWebViewDebugging(true);
+#endif
+ Debug.Log($"[{Tag}] IAM will display: {e.Message.MessageId}");
+ }
private void OnIamDidDisplay(object sender, InAppMessageDidDisplayEventArgs e) =>
- LogManager.Instance.Info(Tag, $"IAM did display: {e.Message.MessageId}");
+ Debug.Log($"[{Tag}] IAM did display: {e.Message.MessageId}");
private void OnIamWillDismiss(object sender, InAppMessageWillDismissEventArgs e) =>
- LogManager.Instance.Info(Tag, $"IAM will dismiss: {e.Message.MessageId}");
+ Debug.Log($"[{Tag}] IAM will dismiss: {e.Message.MessageId}");
- private void OnIamDidDismiss(object sender, InAppMessageDidDismissEventArgs e) =>
- LogManager.Instance.Info(Tag, $"IAM did dismiss: {e.Message.MessageId}");
+ private void OnIamDidDismiss(object sender, InAppMessageDidDismissEventArgs e)
+ {
+ Debug.Log($"[{Tag}] IAM did dismiss: {e.Message.MessageId}");
+#if UNITY_ANDROID && !UNITY_EDITOR
+ SetAndroidWebViewDebugging(false);
+#endif
+ }
private void OnIamClicked(object sender, InAppMessageClickEventArgs e) =>
- LogManager.Instance.Info(Tag, $"IAM clicked: {e.Result.ActionId}");
+ Debug.Log($"[{Tag}] IAM clicked: {e.Result.ActionId}");
private void OnNotificationClicked(object sender, NotificationClickEventArgs e) =>
- LogManager.Instance.Info(Tag, $"Notification clicked: {e.Result.ActionId}");
+ Debug.Log($"[{Tag}] Notification clicked: {e.Result.ActionId}");
private void OnNotificationForegroundWillDisplay(
object sender,
NotificationWillDisplayEventArgs e
)
{
- LogManager.Instance.Info(Tag, "Notification received in foreground");
+ Debug.Log($"[{Tag}] Notification received in foreground");
e.Notification.Display();
}
}
diff --git a/examples/demo/Assets/Scripts/Editor/BuildScript.cs b/examples/demo/Assets/Scripts/Editor/BuildScript.cs
index 9a17cd278..623a932e0 100644
--- a/examples/demo/Assets/Scripts/Editor/BuildScript.cs
+++ b/examples/demo/Assets/Scripts/Editor/BuildScript.cs
@@ -77,9 +77,13 @@ public static void BuildiOSSimulator()
PlayerSettings.SetScriptingBackend(NamedBuildTarget.iOS, ScriptingImplementation.IL2CPP);
PlayerSettings.iOS.sdkVersion = iOSSdkVersion.SimulatorSDK;
+ // run-local.sh forces ARCHS=arm64 on Apple Silicon hosts. Match that
+ // here so Unity emits an arm64-slice baselib instead of the project
+ // default (x86_64), which fails to link in xcodebuild.
+ PlayerSettings.iOS.simulatorSdkArchitecture = AppleMobileArchitectureSimulator.Universal;
Debug.Log(
- $"[BuildScript] iOS sdk={PlayerSettings.iOS.sdkVersion} backend={PlayerSettings.GetScriptingBackend(NamedBuildTarget.iOS)}"
+ $"[BuildScript] iOS sdk={PlayerSettings.iOS.sdkVersion} simArch={PlayerSettings.iOS.simulatorSdkArchitecture} backend={PlayerSettings.GetScriptingBackend(NamedBuildTarget.iOS)}"
);
PlayerSettings.SetIl2CppCodeGeneration(
diff --git a/examples/demo/Assets/Scripts/Models/NotificationType.cs b/examples/demo/Assets/Scripts/Models/NotificationType.cs
index c8810a39a..b760ce0e0 100644
--- a/examples/demo/Assets/Scripts/Models/NotificationType.cs
+++ b/examples/demo/Assets/Scripts/Models/NotificationType.cs
@@ -4,6 +4,7 @@ public enum NotificationType
{
Simple,
WithImage,
+ WithSound,
Custom,
}
}
diff --git a/examples/demo/Assets/Scripts/Repositories/OneSignalRepository.cs b/examples/demo/Assets/Scripts/Repositories/OneSignalRepository.cs
deleted file mode 100644
index 82b744336..000000000
--- a/examples/demo/Assets/Scripts/Repositories/OneSignalRepository.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using Newtonsoft.Json.Linq;
-using OneSignalDemo.Models;
-using OneSignalDemo.Services;
-using OneSignalSDK;
-
-namespace OneSignalDemo.Repositories
-{
- public class OneSignalRepository
- {
- private readonly OneSignalApiService _apiService;
-
- public OneSignalRepository(OneSignalApiService apiService)
- {
- _apiService = apiService;
- }
-
- public void LoginUser(string externalUserId) => OneSignal.Login(externalUserId);
-
- public void LogoutUser() => OneSignal.Logout();
-
- public void AddAlias(string label, string id) => OneSignal.User.AddAlias(label, id);
-
- public void AddAliases(Dictionary aliases) =>
- OneSignal.User.AddAliases(aliases);
-
- public void AddEmail(string email) => OneSignal.User.AddEmail(email);
-
- public void RemoveEmail(string email) => OneSignal.User.RemoveEmail(email);
-
- public void AddSms(string smsNumber) => OneSignal.User.AddSms(smsNumber);
-
- public void RemoveSms(string smsNumber) => OneSignal.User.RemoveSms(smsNumber);
-
- public void AddTag(string key, string value) => OneSignal.User.AddTag(key, value);
-
- public void AddTags(Dictionary tags) => OneSignal.User.AddTags(tags);
-
- public void RemoveTag(string key) => OneSignal.User.RemoveTag(key);
-
- public void RemoveTags(List keys) => OneSignal.User.RemoveTags(keys.ToArray());
-
- public Dictionary GetTags() => OneSignal.User.GetTags();
-
- public void AddTrigger(string key, string value) =>
- OneSignal.InAppMessages.AddTrigger(key, value);
-
- public void AddTriggers(Dictionary triggers) =>
- OneSignal.InAppMessages.AddTriggers(triggers);
-
- public void RemoveTrigger(string key) => OneSignal.InAppMessages.RemoveTrigger(key);
-
- public void RemoveTriggers(List keys) =>
- OneSignal.InAppMessages.RemoveTriggers(keys.ToArray());
-
- public void ClearTriggers() => OneSignal.InAppMessages.ClearTriggers();
-
- public void SendOutcome(string name) => OneSignal.Session.AddOutcome(name);
-
- public void SendUniqueOutcome(string name) => OneSignal.Session.AddUniqueOutcome(name);
-
- public void SendOutcomeWithValue(string name, float value) =>
- OneSignal.Session.AddOutcomeWithValue(name, value);
-
- public string GetPushSubscriptionId() => OneSignal.User.PushSubscription.Id;
-
- public bool IsPushOptedIn() => OneSignal.User.PushSubscription.OptedIn;
-
- public void OptInPush() => OneSignal.User.PushSubscription.OptIn();
-
- public void OptOutPush() => OneSignal.User.PushSubscription.OptOut();
-
- public void ClearAllNotifications() => OneSignal.Notifications.ClearAllNotifications();
-
- public bool HasPermission() => OneSignal.Notifications.Permission;
-
- public Task RequestPermissionAsync(bool fallbackToSettings) =>
- OneSignal.Notifications.RequestPermissionAsync(fallbackToSettings);
-
- public void SetInAppMessagesPaused(bool paused) => OneSignal.InAppMessages.Paused = paused;
-
- public bool IsInAppMessagesPaused() => OneSignal.InAppMessages.Paused;
-
- public void SetLocationShared(bool shared) => OneSignal.Location.IsShared = shared;
-
- public bool IsLocationShared() => OneSignal.Location.IsShared;
-
- public void RequestLocationPermission() => OneSignal.Location.RequestPermission();
-
- public void SetConsentRequired(bool required) => OneSignal.ConsentRequired = required;
-
- public void SetConsentGiven(bool granted) => OneSignal.ConsentGiven = granted;
-
- public string GetExternalId() => OneSignal.User.ExternalId;
-
- public string GetOnesignalId() => OneSignal.User.OneSignalId;
-
- public void TrackEvent(string name, Dictionary properties = null) =>
- OneSignal.User.TrackEvent(name, properties);
-
- public async Task SendNotification(NotificationType type)
- {
- var subId = GetPushSubscriptionId();
- return await _apiService.SendNotification(type, subId);
- }
-
- public async Task SendCustomNotification(string title, string body)
- {
- var subId = GetPushSubscriptionId();
- return await _apiService.SendCustomNotification(title, body, subId);
- }
-
- public async Task FetchUser(string onesignalId) =>
- await _apiService.FetchUser(onesignalId);
-
- public bool HasApiKey() => _apiService.HasApiKey();
-
- public void StartDefaultLiveActivity(
- string activityId,
- IDictionary attributes,
- IDictionary content
- ) => OneSignal.LiveActivities.StartDefault(activityId, attributes, content);
-
- public async Task UpdateLiveActivity(
- string activityId,
- string eventType,
- JObject eventUpdates = null
- ) => await _apiService.UpdateLiveActivity(activityId, eventType, eventUpdates);
- }
-}
diff --git a/examples/demo/Assets/Scripts/Repositories/OneSignalRepository.cs.meta b/examples/demo/Assets/Scripts/Repositories/OneSignalRepository.cs.meta
deleted file mode 100644
index 2583b6ec4..000000000
--- a/examples/demo/Assets/Scripts/Repositories/OneSignalRepository.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: af7149b19604643618be0ff85429fdf7
\ No newline at end of file
diff --git a/examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs b/examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs
new file mode 100644
index 000000000..2539411a2
--- /dev/null
+++ b/examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs
@@ -0,0 +1,1131 @@
+#if UNITY_IOS || UNITY_ANDROID
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.Accessibility;
+using UnityEngine.UIElements;
+
+namespace OneSignalDemo.Services
+{
+ ///
+ /// Mirrors the demo's UI Toolkit VisualElement tree into Unity's
+ /// AccessibilityHierarchy so that XCUITest (iOS) and UiAutomator2
+ /// (Android) can locate elements by their VisualElement.name. UI
+ /// Toolkit renders to a single texture-backed view and is invisible to
+ /// platform accessibility services unless we publish a parallel a11y
+ /// tree via this bridge. Active only in E2E mode.
+ ///
+ public class AccessibilityBridge : MonoBehaviour
+ {
+ // Backstop frame refresh tick. The hot path is GeometryChangedEvent
+ // (registered in BuildHierarchy) which dispatches a same-frame
+ // refresh whenever anything in the tree resizes, scrolls, or
+ // re-layouts. The timer only catches drift from sources that don't
+ // raise GeometryChangedEvent — animation curves, opacity tweens, and
+ // value mutations on TextField/Toggle/Label.
+ private const float FrameRefreshIntervalSeconds = 0.05f;
+ private const float TapMarkerSize = 28f;
+ private const int MaxTapMarkers = 40;
+ private const float ScrollOffsetEpsilon = 0.5f;
+ private const double ScrollSettleLayoutChangeDelayMs = 150.0;
+ // Backstop structural poll. The hot path is DetachFromPanelEvent
+ // (per node, see WalkAndAdd) which schedules an immediate resync the
+ // moment a tracked element leaves the tree. The timer catches
+ // additions, since a freshly-created VisualElement has nothing for
+ // us to subscribe to until BuildHierarchy walks it.
+ private const float StructurePollIntervalSeconds = 0.1f;
+ private const string GameObjectName = "OneSignalAccessibilityBridge";
+
+ private static AccessibilityBridge _instance;
+#if UNITY_ANDROID && !UNITY_EDITOR
+ // Named-element click registry. UiAutomator2 only sees a flat
+ // accessibility tree, so a "click" arriving from the OS for element
+ // foo_button must be routed to the C# Action that the foo button's
+ // builder wired up. The dict is keyed by element instance and grows
+ // for the life of the app; named-element churn is bounded by the
+ // number of distinct UI builders, not test iterations.
+ private static readonly Dictionary AndroidClickTargets
+ = new();
+ private static AndroidJavaClass _androidAccessibilityBridge;
+ private static bool _androidSyncErrorLogged;
+#endif
+#if UNITY_IOS && !UNITY_EDITOR
+ // iOS-only name-keyed dispatch table for "*_info_icon" Labels.
+ // Plain Label has no Clickable manipulator, so XCUITest taps reach
+ // the target via UI Toolkit's hit-test but no AtTarget callback runs.
+ // Worse, after iOS Appium injects a mobile:scroll before the tap,
+ // AtTarget dispatch on the Label is observed to drop entirely — the
+ // panel root sees the PointerDown with the correct target, but the
+ // target's own callback never fires. We bypass AtTarget by dispatching
+ // from a TrickleDown handler installed once on the panel root (see
+ // BuildHierarchy), keyed by element name so modal overlays can't
+ // bleed through.
+ private static readonly Dictionary _iosInfoTapByName
+ = new();
+#endif
+
+ private VisualElement _root;
+ private AccessibilityHierarchy _hierarchy;
+ private readonly Dictionary _map = new();
+ private bool _resyncScheduled;
+ private bool _frameRefreshScheduled;
+ private float _frameRefreshTimer;
+ private float _structurePollTimer;
+ private int _lastStructureSignature = -1;
+ private int _tapMarkerCount;
+ private VisualElement _tapMarkerOverlay;
+ private Vector2 _lastScrollOffset;
+ private double _lastScrollChangeMs = -1.0;
+ private bool _scrollLayoutChangePending;
+ private readonly EventCallback _onGeometryChanged;
+ private readonly EventCallback _onDetachFromPanel;
+ private readonly EventCallback _onTapMarkerPointerDown;
+ // Set true after the one-shot ScrollView taming hooks (WheelEvent
+ // block + clamp settings) are installed; BuildHierarchy can fire many
+ // times per session and we only want one subscription.
+ private bool _scrollViewHooksInstalled;
+#if UNITY_IOS && !UNITY_EDITOR
+ private readonly EventCallback _onIosInfoIconPointerDown;
+#endif
+ // E2E only: cached ScrollView ref + stale-capture watchdog. A self-
+ // destroying child (e.g. triggers_remove) can leave ScrollView's
+ // pending-capture latch set because the matching PointerUp is dropped
+ // when its target detaches mid-touch. The next unrelated pointer event
+ // is then interpreted as a continuation of the prior drag, scrolling
+ // the content out from under the test's tap. We reset the latch via
+ // reflection if no pointer activity is observed for a short window.
+ private ScrollView _mainSv;
+ private double _lastTouchActivityMs = -1.0;
+ private const double StalePointerCaptureWindowMs = 200.0;
+ private static System.Reflection.FieldInfo _svPointerCaptureScheduledField;
+ private static System.Reflection.FieldInfo _svPressedField;
+ private static bool _svReflectionResolved;
+
+ public AccessibilityBridge()
+ {
+ _onGeometryChanged = _ => ScheduleFrameRefresh();
+ _onDetachFromPanel = _ => ScheduleResync();
+ _onTapMarkerPointerDown = e => AddTapMarker(new Vector2(e.position.x, e.position.y));
+#if UNITY_IOS && !UNITY_EDITOR
+ _onIosInfoIconPointerDown = OnIosInfoIconPointerDown;
+#endif
+ }
+
+#if UNITY_IOS && !UNITY_EDITOR
+ private static void OnIosInfoIconPointerDown(PointerDownEvent e)
+ {
+ if (!(e.target is VisualElement t)
+ || string.IsNullOrEmpty(t.name)
+ || !t.name.EndsWith("_info_icon"))
+ return;
+ if (!_iosInfoTapByName.TryGetValue(t.name, out var entry))
+ return;
+ if (!entry.IsEnabled())
+ return;
+ entry.Action();
+ e.StopPropagation();
+ }
+#endif
+
+ // Empirically, Unity's iOS accessibility plugin treats the Rect we
+ // hand to AccessibilityNode.frameGetter as if it were already in
+ // physical screen pixels, then divides by the device scale to get
+ // UIKit points. UI Toolkit, however, reports VisualElement.worldBound
+ // in panel-local coordinates (reference resolution scaled to fit), so
+ // unscaled frames render ~1/scale of where the UI is actually drawn.
+ // This factor brings panel coords into the same space the plugin
+ // expects; recomputed on every BuildHierarchy in case the panel size
+ // changes (rotation, safe-area shift, multi-display).
+ private float _panelToScreenScale = 1f;
+
+ ///
+ /// Idempotent entry point. Safe to call multiple times; only the first
+ /// call wires the bridge. No-ops outside E2E_MODE.
+ ///
+ public static void EnableForE2E(VisualElement root)
+ {
+ if (root == null)
+ return;
+ if (!DotEnv.IsE2EMode)
+ {
+#if UNITY_ANDROID && !UNITY_EDITOR
+ Debug.LogWarning("[OneSignalDemo] E2E accessibility bridge disabled");
+#endif
+ return;
+ }
+
+ if (_instance == null)
+ {
+ var go = new GameObject(GameObjectName);
+ DontDestroyOnLoad(go);
+ _instance = go.AddComponent();
+ }
+
+ _instance.Initialize(root);
+#if UNITY_ANDROID && !UNITY_EDITOR
+ Debug.Log("[OneSignalDemo] E2E accessibility bridge enabled");
+#endif
+ }
+
+ ///
+ /// Registers a named element as a click target. On Android, reports
+ /// the element as role=button to UiAutomator2 and routes incoming
+ /// "click" actions by name. On iOS, only "*_info_icon" Labels need
+ /// special handling — they have no Clickable manipulator, so their
+ /// action is dispatched via the bridge's panel-root PointerDown
+ /// handler. All other iOS taps ride UI Toolkit's standard Clickable.
+ ///
+ public static void RegisterE2ETapTarget(
+ VisualElement target,
+ Func isEnabled,
+ Action action
+ )
+ {
+#if UNITY_ANDROID && !UNITY_EDITOR
+ if (target == null || action == null)
+ return;
+ AndroidClickTargets[target] = new AndroidClickTarget(
+ isEnabled ?? (() => true),
+ action
+ );
+ target.RegisterCallback(OnAndroidClickTargetDetached);
+#elif UNITY_IOS && !UNITY_EDITOR
+ if (target == null || action == null || string.IsNullOrEmpty(target.name))
+ return;
+ if (!target.name.EndsWith("_info_icon"))
+ return;
+ _iosInfoTapByName[target.name] = new IosInfoTap(
+ isEnabled ?? (() => true),
+ action
+ );
+#endif
+ }
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+ private static void OnAndroidClickTargetDetached(DetachFromPanelEvent e)
+ {
+ if (e.target is VisualElement target)
+ AndroidClickTargets.Remove(target);
+ }
+#endif
+
+ public static void RequestResync()
+ {
+ _instance?.ScheduleResync();
+ }
+
+ public static void RequestImmediateResync()
+ {
+ _instance?.BuildHierarchy();
+ }
+
+ private void Initialize(VisualElement root)
+ {
+ if (_root != root)
+ {
+ UnregisterTreeCallbacks();
+ _mainSv = null;
+ _lastTouchActivityMs = -1.0;
+ _scrollViewHooksInstalled = false;
+ }
+
+ _root = root;
+ _lastStructureSignature = -1; // force first poll to rebuild
+
+ // Without this override, AssistiveSupport.activeHierarchy is auto-cleared
+ // whenever the OS reports VoiceOver/TalkBack as off. XCUITest reads the
+ // a11y tree without enabling VoiceOver, so we have to lie.
+ AssistiveSupport.screenReaderStatusOverride =
+ AssistiveSupport.ScreenReaderStatusOverride.ForceEnabled;
+ AssistiveSupport.screenReaderStatusChanged -= OnScreenReaderStatusChanged;
+ AssistiveSupport.screenReaderStatusChanged += OnScreenReaderStatusChanged;
+
+ BuildHierarchy();
+ }
+
+ private void OnDestroy()
+ {
+ AssistiveSupport.screenReaderStatusChanged -= OnScreenReaderStatusChanged;
+ AssistiveSupport.activeHierarchy = null;
+ AssistiveSupport.screenReaderStatusOverride =
+ AssistiveSupport.ScreenReaderStatusOverride.OSDriven;
+ UnregisterTreeCallbacks();
+ if (_instance == this)
+ _instance = null;
+ }
+
+ // After returnToApp() the test runner's first query can read a stale
+ // accessibility snapshot: WDA on iOS (and to a lesser extent
+ // UiAutomator2 on Android) caches the tree, and neither
+ // BuildHierarchy's "structure changed" check nor the OS's own
+ // notifications reliably invalidate that cache when the tree is
+ // identical to before backgrounding. Rebuild and broadcast a
+ // screen-changed notification unconditionally on every foreground so
+ // the next XCUITest/UiAutomator2 query returns fresh data without the
+ // Appium side having to spin on a fixed sleep.
+ private void OnApplicationFocus(bool hasFocus)
+ {
+ if (!hasFocus || _hierarchy == null || _root == null)
+ return;
+ BuildHierarchy();
+ AssistiveSupport.notificationDispatcher?.SendScreenChanged();
+ }
+
+ private void UnregisterTreeCallbacks()
+ {
+ if (_root != null)
+ {
+ _root.UnregisterCallback(_onGeometryChanged, TrickleDown.TrickleDown);
+ // Disabled: purple tap-marker overlay (debug visual for E2E taps).
+ // _root.UnregisterCallback(_onTapMarkerPointerDown, TrickleDown.TrickleDown);
+#if UNITY_IOS && !UNITY_EDITOR
+ var visualTree = _root.panel?.visualTree;
+ if (visualTree != null)
+ visualTree.UnregisterCallback(_onIosInfoIconPointerDown, TrickleDown.TrickleDown);
+#endif
+ }
+ foreach (var el in _map.Keys)
+ el.UnregisterCallback(_onDetachFromPanel);
+ }
+
+ private void ScheduleFrameRefresh()
+ {
+ if (_frameRefreshScheduled || !isActiveAndEnabled)
+ return;
+ _frameRefreshScheduled = true;
+ StartCoroutine(RefreshFramesEndOfFrame());
+ }
+
+ private IEnumerator RefreshFramesEndOfFrame()
+ {
+ yield return new WaitForEndOfFrame();
+ _frameRefreshScheduled = false;
+ if (_hierarchy != null)
+ {
+ _hierarchy.RefreshNodeFrames();
+ SyncAndroidNativeAccessibility();
+ }
+ }
+
+ private void Update()
+ {
+ if (_hierarchy == null || _root == null)
+ return;
+
+ // Cheap structural-change check: count named descendants and
+ // rebuild on diff. Catches dialog open/close, scene swap, section
+ // refresh — every test-relevant mutation creates or removes named
+ // VisualElements. Avoids hot event hooks during layout.
+ _structurePollTimer += Time.unscaledDeltaTime;
+ // Stale-pointer-capture watchdog: see field comment for context.
+ if (_lastTouchActivityMs > 0.0)
+ {
+ double now = Time.realtimeSinceStartupAsDouble * 1000.0;
+ if (now - _lastTouchActivityMs > StalePointerCaptureWindowMs)
+ {
+ ResetScrollViewPanState(_mainSv);
+ _lastTouchActivityMs = -1.0;
+ }
+ }
+ RefreshScrollAccessibilityState();
+
+ if (_structurePollTimer >= StructurePollIntervalSeconds)
+ {
+ _structurePollTimer = 0f;
+ int signature = ComputeStructureSignature(_root);
+ if (signature != _lastStructureSignature)
+ {
+ _lastStructureSignature = signature;
+ ScheduleResync();
+ }
+ }
+
+ // Live frames + values: scroll/animation moves elements and
+ // toggles/labels mutate text without altering tree structure.
+ // RefreshNodeFrames covers geometry; AccessibilityNode.value is
+ // a plain field with no per-node "value changed" notification
+ // (the dispatcher only exposes Layout/Screen/Page/Announcement),
+ // so writes don't push to the platform unless we re-publish the
+ // whole hierarchy. We compare against last-known values per tick
+ // and rebuild only when something actually changed — avoids the
+ // every-frame churn the Unity perf docs warn against.
+ _frameRefreshTimer += Time.unscaledDeltaTime;
+ if (_frameRefreshTimer >= FrameRefreshIntervalSeconds)
+ {
+ _frameRefreshTimer = 0f;
+ // Only push to the platform a11y service when something actually
+ // changed. Unconditional RefreshNodeFrames + Android sync every
+ // 50ms emits a continuous stream of TYPE_WINDOW_CONTENT_CHANGED
+ // AccessibilityEvents that prevent UiAutomator2's wait-for-idle
+ // from observing 500ms of quiet, stalling every click for the
+ // full waitForIdleTimeout (default 10s).
+ if (RefreshNodeValuesAndActive())
+ {
+ _hierarchy.RefreshNodeFrames();
+ SyncAndroidNativeAccessibility();
+ }
+ }
+ }
+
+ private bool RefreshNodeValuesAndActive()
+ {
+ bool anyChanged = false;
+ foreach (var kvp in _map)
+ {
+ var el = kvp.Key;
+ var node = kvp.Value;
+ var newValue = ExtractValue(el);
+ var newActive = IsVisible(el);
+ bool valueChanged = node.value != newValue;
+ bool activeChanged = node.isActive != newActive;
+ if (!valueChanged && !activeChanged)
+ continue;
+
+ node.value = newValue;
+ node.isActive = newActive;
+ anyChanged = true;
+ }
+ return anyChanged;
+ }
+
+ // Order-sensitive FNV-1a hash over named descendants. A bare count missed
+ // dialog→section transitions where the closing dialog and the freshly
+ // revealed section exposed the same number of named descendants — the
+ // bridge would skip BuildHierarchy and XCUITest would keep reading a
+ // stale tree (e.g. add_multiple_tags_button stuck at active=0, rect=0x0)
+ // until the next unrelated mutation. Hashing names catches structural
+ // identity changes even when the cardinality is unchanged.
+ private static int ComputeStructureSignature(VisualElement el)
+ {
+ unchecked
+ {
+ int hash = (int)2166136261u;
+ AccumulateNameHash(el, ref hash);
+ return hash;
+ }
+ }
+
+ private static void AccumulateNameHash(VisualElement el, ref int hash)
+ {
+ if (el == null)
+ return;
+ unchecked
+ {
+ if (!string.IsNullOrEmpty(el.name))
+ {
+ var name = el.name;
+ for (int i = 0; i < name.Length; i++)
+ {
+ hash ^= name[i];
+ hash *= 16777619;
+ }
+ hash ^= '|';
+ hash *= 16777619;
+ }
+ for (int i = 0; i < el.childCount; i++)
+ AccumulateNameHash(el[i], ref hash);
+ }
+ }
+
+ private void OnScreenReaderStatusChanged(bool _)
+ {
+ // Unity nulls activeHierarchy when the OS reports screen reader off;
+ // reassert immediately since our override should have prevented it.
+ if (_hierarchy != null)
+ AssistiveSupport.activeHierarchy = _hierarchy;
+ }
+
+ private void ScheduleResync()
+ {
+ if (_resyncScheduled || !isActiveAndEnabled)
+ return;
+ _resyncScheduled = true;
+ StartCoroutine(ResyncEndOfFrame());
+ }
+
+ private IEnumerator ResyncEndOfFrame()
+ {
+ // Wait until current frame finishes laying out so child mutations
+ // batch into a single rebuild.
+ yield return new WaitForEndOfFrame();
+ _resyncScheduled = false;
+ BuildHierarchy();
+ }
+
+ private void BuildHierarchy()
+ {
+ if (_root == null)
+ return;
+
+ // Disabled: purple tap-marker overlay (debug visual for E2E taps).
+ // EnsureTapMarkerOverlay();
+ UnregisterTreeCallbacks();
+
+ _hierarchy ??= new AccessibilityHierarchy();
+
+ // Recompute the panel→screen scale before walking the tree so each
+ // node's frameGetter sees the correct ratio.
+ var rootBound = _root.worldBound;
+ _panelToScreenScale =
+ rootBound.width > 0 ? Screen.width / rootBound.width : 1f;
+
+ // TrickleDown so we receive geometry events bubbling up from
+ // every descendant without having to register per-element. One
+ // callback drives a coalesced end-of-frame RefreshNodeFrames so
+ // a tap fired immediately after a scroll sees fresh frames
+ // instead of a 50ms-stale snapshot.
+ _root.RegisterCallback(_onGeometryChanged, TrickleDown.TrickleDown);
+ // Disabled: purple tap-marker overlay (debug visual for E2E taps).
+ // Drops a circular marker at every tap location so the demo
+ // overlay matches XCUITest's reported coordinates. Does not
+ // dispatch any action; UI Toolkit's hit-test handles the real
+ // tap routing.
+ // _root.RegisterCallback(_onTapMarkerPointerDown, TrickleDown.TrickleDown);
+#if UNITY_IOS && !UNITY_EDITOR
+ // Register on the panel's visualTree (the absolute topmost
+ // dispatch point), not _root. Anything in TrickleDown above _root
+ // — including ScrollView's own pan-capture handlers — could
+ // StopPropagation before the event reaches a descendant
+ // registration. AtTarget drops on the info Label (observed after
+ // iOS Appium injects mobile:scroll mid-test) would then take the
+ // tooltip tap with them. See _iosInfoTapByName field comment.
+ var visualTree = _root.panel?.visualTree;
+ if (visualTree != null)
+ visualTree.RegisterCallback(_onIosInfoIconPointerDown, TrickleDown.TrickleDown);
+#endif
+
+ // One-shot per root ScrollView taming: BuildHierarchy can run
+ // dozens of times per scene (every dialog open/close, every
+ // section refresh), but a scene/root swap needs fresh hooks.
+ if (!_scrollViewHooksInstalled)
+ {
+ var mainSv = _root.Q("main_scroll_view");
+ if (mainSv != null)
+ {
+ _mainSv = mainSv;
+ _lastScrollOffset = mainSv.scrollOffset;
+ _lastScrollChangeMs = -1.0;
+ _scrollLayoutChangePending = false;
+ InstallScrollViewE2EHooks(mainSv);
+ _scrollViewHooksInstalled = true;
+ }
+ }
+
+ // Incremental rebuild: preserve AccessibilityNode identities by
+ // name across rebuilds. _hierarchy.Clear() + re-add invalidates
+ // every WDA-cached element ref on iOS, producing a burst of
+ // "stale element - terminating request" warnings whenever a
+ // dialog opens/closes — even when most nodes are unchanged.
+ // We diff by name: reuse existing nodes (just re-bind their
+ // frameGetter to the current VisualElement instance), add new
+ // ones, remove ones whose names disappeared.
+ var existingByName = new Dictionary(_map.Count);
+ foreach (var kvp in _map)
+ {
+ var n = kvp.Value;
+ if (n != null && !string.IsNullOrEmpty(kvp.Key?.name))
+ existingByName[kvp.Key.name] = n;
+ }
+
+ _map.Clear();
+ var seenNames = new HashSet();
+ bool addedAny = false;
+ WalkAndUpsert(_root, existingByName, seenNames, ref addedAny);
+
+ bool removedAny = false;
+ foreach (var kvp in existingByName)
+ {
+ if (seenNames.Contains(kvp.Key))
+ continue;
+ try { _hierarchy.RemoveNode(kvp.Value); }
+ catch { /* node may already be detached */ }
+ removedAny = true;
+ }
+
+ _lastStructureSignature = ComputeStructureSignature(_root);
+
+ if (AssistiveSupport.activeHierarchy != _hierarchy)
+ AssistiveSupport.activeHierarchy = _hierarchy;
+ // SendScreenChanged tells VoiceOver/XCUITest to refresh its
+ // element snapshot, which also invalidates cached refs. Skip it
+ // when we only re-bound existing nodes — frame/value updates
+ // ride out via the normal RefreshNodeFrames path.
+ if (addedAny || removedAny)
+ AssistiveSupport.notificationDispatcher?.SendScreenChanged();
+ _hierarchy.RefreshNodeFrames();
+ SyncAndroidNativeAccessibility();
+ }
+
+ public void HandleAndroidAccessibilityAction(string payload)
+ {
+#if UNITY_ANDROID && !UNITY_EDITOR
+ if (string.IsNullOrEmpty(payload) || _root == null)
+ return;
+
+ var firstBreak = payload.IndexOf('\n');
+ if (firstBreak < 0)
+ return;
+ var secondBreak = payload.IndexOf('\n', firstBreak + 1);
+ if (secondBreak < 0)
+ return;
+
+ var id = payload.Substring(0, firstBreak);
+ var action = payload.Substring(firstBreak + 1, secondBreak - firstBreak - 1);
+ var value = payload.Substring(secondBreak + 1);
+
+ if (action == "setValue")
+ {
+ var field = _root.Q(id);
+ if (field != null && field.value != value)
+ field.value = value;
+ return;
+ }
+
+ if (action == "click")
+ {
+ InvokeAndroidNativeAction(id);
+ return;
+ }
+
+ if (action == "scroll")
+ InvokeAndroidNativeScroll(value);
+#endif
+ }
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+ private static AndroidJavaClass AndroidAccessibilityBridge
+ {
+ get
+ {
+ _androidAccessibilityBridge ??= new AndroidJavaClass(
+ "com.onesignal.onesignalsdk.OneSignalUnityE2EAccessibility"
+ );
+ return _androidAccessibilityBridge;
+ }
+ }
+
+ private void SyncAndroidNativeAccessibility()
+ {
+ try
+ {
+ AndroidAccessibilityBridge.CallStatic("beginSync");
+ foreach (var kvp in _map)
+ {
+ var el = kvp.Key;
+ if (el == null || string.IsNullOrEmpty(el.name))
+ continue;
+
+ var rect = GetScreenRect(el);
+ AndroidAccessibilityBridge.CallStatic(
+ "syncElement",
+ el.name,
+ ExtractValue(el),
+ IsVisible(el) && rect.width > 0f && rect.height > 0f,
+ Mathf.RoundToInt(rect.x),
+ Mathf.RoundToInt(rect.y),
+ Mathf.RoundToInt(rect.width),
+ Mathf.RoundToInt(rect.height),
+ AndroidNativeRole(el),
+ el.enabledInHierarchy
+ );
+ }
+ AndroidAccessibilityBridge.CallStatic("endSync");
+ }
+ catch (Exception ex)
+ {
+ if (!_androidSyncErrorLogged)
+ {
+ _androidSyncErrorLogged = true;
+ Debug.LogWarning(
+ $"[OneSignalDemo] Android E2E accessibility sync failed: {ex.Message}"
+ );
+ }
+ }
+ }
+
+ private void InvokeAndroidNativeAction(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ return;
+
+ var switchToggle = _root.Q(id);
+ if (switchToggle != null)
+ {
+ switchToggle.SetValueAndNotify(!switchToggle.Value);
+ return;
+ }
+
+ // BaseBoolField is the common base of Toggle AND RadioButton in
+ // UI Toolkit. Q would miss radios entirely, leaving the
+ // overlay click as a no-op (clicking the unique/with-value radio
+ // wouldn't actually select that outcome type).
+ var boolField = _root.Q(id);
+ if (boolField != null)
+ {
+ boolField.value = !boolField.value;
+ return;
+ }
+
+ Action actionToInvoke = null;
+ List detachedTargets = null;
+ foreach (var kvp in AndroidClickTargets)
+ {
+ var el = kvp.Key;
+ if (el == null || el.panel == null || !IsDescendantOfRoot(el))
+ {
+ if (el != null)
+ {
+ detachedTargets ??= new List();
+ detachedTargets.Add(el);
+ }
+ continue;
+ }
+ if (el.name != id || !IsVisible(el) || !kvp.Value.IsEnabled())
+ continue;
+ actionToInvoke = kvp.Value.Action;
+ break;
+ }
+ if (detachedTargets != null)
+ {
+ foreach (var target in detachedTargets)
+ AndroidClickTargets.Remove(target);
+ }
+ actionToInvoke?.Invoke();
+ }
+
+ private bool IsDescendantOfRoot(VisualElement el)
+ {
+ for (var current = el; current != null; current = current.hierarchy.parent)
+ {
+ if (current == _root)
+ return true;
+ }
+ return false;
+ }
+
+ private void InvokeAndroidNativeScroll(string direction)
+ {
+ if (_mainSv == null)
+ return;
+
+ var viewportHeight = _mainSv.contentViewport?.layout.height ?? _mainSv.layout.height;
+ var contentHeight = _mainSv.contentContainer?.layout.height ?? 0f;
+ if (viewportHeight <= 0f || contentHeight <= viewportHeight)
+ return;
+
+ var current = _mainSv.scrollOffset;
+ var delta = Mathf.Max(80f, viewportHeight * 0.6f);
+ var maxY = Mathf.Max(0f, contentHeight - viewportHeight);
+ var nextY = direction == "up" ? current.y - delta : current.y + delta;
+ _mainSv.scrollOffset = new Vector2(current.x, Mathf.Clamp(nextY, 0f, maxY));
+
+ RefreshNodeValuesAndActive();
+ _hierarchy?.RefreshNodeFrames();
+ SyncAndroidNativeAccessibility();
+ }
+
+ private static string AndroidNativeRole(VisualElement el)
+ {
+ if (AndroidClickTargets.ContainsKey(el))
+ return "button";
+
+ return el switch
+ {
+ TextField => "input",
+ BaseBoolField => "toggle",
+ OneSignalDemo.UI.SwitchToggle => "toggle",
+ Button => "button",
+ _ => "text",
+ };
+ }
+#else
+ private void SyncAndroidNativeAccessibility() { }
+#endif
+
+ private void RefreshScrollAccessibilityState()
+ {
+ if (_mainSv == null || _hierarchy == null)
+ return;
+
+ var scrollOffset = _mainSv.scrollOffset;
+ if ((scrollOffset - _lastScrollOffset).sqrMagnitude > ScrollOffsetEpsilon * ScrollOffsetEpsilon)
+ {
+ _lastScrollOffset = scrollOffset;
+ _lastScrollChangeMs = Time.realtimeSinceStartupAsDouble * 1000.0;
+ _scrollLayoutChangePending = true;
+ RefreshNodeValuesAndActive();
+ _hierarchy.RefreshNodeFrames();
+ // See settle branch below for rationale. Also ping during the
+ // scroll so a getLocation that races with the gesture's tail
+ // sees fresh data — the settle ping fires 150ms after the
+ // last change, which can land after the test's refetch.
+ AssistiveSupport.notificationDispatcher?.SendScreenChanged();
+ return;
+ }
+
+ if (!_scrollLayoutChangePending || _lastScrollChangeMs < 0.0)
+ return;
+
+ double now = Time.realtimeSinceStartupAsDouble * 1000.0;
+ if (now - _lastScrollChangeMs < ScrollSettleLayoutChangeDelayMs)
+ return;
+
+ _scrollLayoutChangePending = false;
+ _hierarchy.RefreshNodeFrames();
+ // RefreshNodeFrames updates Unity's a11y node positions, but WDA
+ // keeps a cached element-frame snapshot until the OS reports an
+ // a11y notification. Without this ping, scrollToEl's refetch
+ // reads pre-scroll coordinates and openModal's click() lands on
+ // the icon's old off-screen spot — the modal never opens. Firing
+ // only on settle (not on every intermediate scroll frame) keeps
+ // the notification rate sane and gives WDA one clean signal that
+ // frames are stable and re-readable.
+ AssistiveSupport.notificationDispatcher?.SendScreenChanged();
+ }
+
+ private void EnsureTapMarkerOverlay()
+ {
+ if (_tapMarkerOverlay?.panel != null)
+ return;
+
+ var mainSv = _root.Q("main_scroll_view");
+ var parent = mainSv?.contentContainer ?? _root;
+
+ _tapMarkerOverlay = new VisualElement();
+ _tapMarkerOverlay.pickingMode = PickingMode.Ignore;
+ _tapMarkerOverlay.style.position = Position.Absolute;
+ _tapMarkerOverlay.style.left = 0;
+ _tapMarkerOverlay.style.top = 0;
+ _tapMarkerOverlay.style.right = 0;
+ _tapMarkerOverlay.style.bottom = 0;
+ parent.Add(_tapMarkerOverlay);
+ }
+
+ private void AddTapMarker(Vector2 position)
+ {
+ EnsureTapMarkerOverlay();
+ var localPosition = _tapMarkerOverlay.WorldToLocal(position);
+
+ var marker = new VisualElement();
+ marker.pickingMode = PickingMode.Ignore;
+ marker.style.position = Position.Absolute;
+ marker.style.width = TapMarkerSize;
+ marker.style.height = TapMarkerSize;
+ marker.style.left = localPosition.x - TapMarkerSize / 2f;
+ marker.style.top = localPosition.y - TapMarkerSize / 2f;
+ marker.style.borderTopLeftRadius = TapMarkerSize / 2f;
+ marker.style.borderTopRightRadius = TapMarkerSize / 2f;
+ marker.style.borderBottomLeftRadius = TapMarkerSize / 2f;
+ marker.style.borderBottomRightRadius = TapMarkerSize / 2f;
+ marker.style.borderTopWidth = 2;
+ marker.style.borderRightWidth = 2;
+ marker.style.borderBottomWidth = 2;
+ marker.style.borderLeftWidth = 2;
+ var markerColor = new Color(0.55f, 0f, 1f, 1f);
+ marker.style.borderTopColor = markerColor;
+ marker.style.borderRightColor = markerColor;
+ marker.style.borderBottomColor = markerColor;
+ marker.style.borderLeftColor = markerColor;
+ marker.style.backgroundColor = new Color(0.55f, 0f, 1f, 0.18f);
+
+ var label = new Label((++_tapMarkerCount).ToString());
+ label.pickingMode = PickingMode.Ignore;
+ label.style.unityTextAlign = TextAnchor.MiddleCenter;
+ label.style.color = markerColor;
+ label.style.fontSize = 10;
+ marker.Add(label);
+
+ _tapMarkerOverlay.Add(marker);
+ while (_tapMarkerOverlay.childCount > MaxTapMarkers)
+ _tapMarkerOverlay.RemoveAt(0);
+ }
+
+ ///
+ /// Tames the main ScrollView so XCUITest taps land deterministically
+ /// on iOS. Two distinct sources can shift content during a queued tap
+ /// and make the click land on the wrong element:
+ /// 1. iOS XCUITest performs an implicit "scroll-to-visible" before
+ /// dispatching the tap. The scroll arrives in UI Toolkit as a
+ /// synthetic WheelEvent injected by DefaultEventSystem
+ /// (SendPositionBasedEvent ← InputForUIProcessor.ProcessPointerEvent).
+ /// The ScrollView consumes it and scrolls scrollOffset by ~120pt
+ /// mid-tap, leaving the queued click on empty space.
+ /// 2. After a swipe gesture, ScrollView schedules
+ /// `PostPointerUpAnimation` (elasticity bounce + inertia) via
+ /// TimerEventScheduler. That tick fires AFTER the swipe ends and
+ /// can run DURING the next queued tap, scrolling the content a
+ /// few points so the click lands on `unity-content-container`.
+ /// Block both: stop WheelEvent at the ScrollView, and clamp the touch
+ /// scroll behaviour so there is no post-pointer-up animation. Manual
+ /// swipe gestures still work because they go through the
+ /// PointerDown/Move/Up path; the content just stops the moment the
+ /// finger lifts.
+ ///
+ private void InstallScrollViewE2EHooks(ScrollView mainSv)
+ {
+ mainSv.RegisterCallback(
+ e =>
+ {
+ e.StopImmediatePropagation();
+ },
+ TrickleDown.TrickleDown
+ );
+ // Touch-activity watchdog: any real Down/Move/Up resets the timer.
+ // The Update tick clears ScrollView's stuck pan-capture latch if no
+ // touch event arrives within StalePointerCaptureWindowMs after a
+ // Down — that's the only way the latch can outlive a self-
+ // destroying child whose PointerUp is dropped by the dispatcher.
+ mainSv.RegisterCallback(
+ e =>
+ {
+ _lastTouchActivityMs = Time.realtimeSinceStartupAsDouble * 1000.0;
+ },
+ TrickleDown.TrickleDown
+ );
+ mainSv.RegisterCallback(
+ e =>
+ {
+ _lastTouchActivityMs = Time.realtimeSinceStartupAsDouble * 1000.0;
+ },
+ TrickleDown.TrickleDown
+ );
+ mainSv.RegisterCallback(
+ e =>
+ {
+ _lastTouchActivityMs = -1.0;
+ },
+ TrickleDown.TrickleDown
+ );
+ mainSv.touchScrollBehavior = ScrollView.TouchScrollBehavior.Clamped;
+ mainSv.scrollDecelerationRate = 0f;
+ mainSv.elasticity = 0f;
+ }
+
+ // Reflection-based reset for ScrollView's stuck pan-capture latches.
+ // We deliberately only clear m_PointerCaptureScheduled and m_Pressed —
+ // those represent "a touch is currently captured / pending capture",
+ // which is the state we need to release when a child detaches mid-
+ // touch and PointerUp never arrives. m_TouchPointerMoveAllowed is per-
+ // gesture state owned by PointerDown/PointerUp; clearing it here
+ // disables touch scrolling for the *next* swipe, since iOS XCUITest
+ // sometimes drops PointerUp on the ScrollView and the watchdog ends
+ // up running mid-swipe with that flag set true. Leave it alone so the
+ // next swipe's PointerDown can manage it normally.
+ // Field names match Unity 2022+ UI Toolkit; resolve once and cache.
+ private static void ResetScrollViewPanState(ScrollView sv)
+ {
+ if (sv == null) return;
+ if (!_svReflectionResolved)
+ {
+ var t = typeof(ScrollView);
+ var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
+ _svPointerCaptureScheduledField = t.GetField("m_PointerCaptureScheduled", flags);
+ _svPressedField = t.GetField("m_Pressed", flags);
+ _svReflectionResolved = true;
+ }
+ try
+ {
+ _svPointerCaptureScheduledField?.SetValue(sv, false);
+ _svPressedField?.SetValue(sv, false);
+ }
+ catch
+ {
+ }
+ }
+
+ private void WalkAndUpsert(
+ VisualElement el,
+ Dictionary existingByName,
+ HashSet seenNames,
+ ref bool addedAny
+ )
+ {
+ if (el == null)
+ return;
+
+ // Publish every named element as a direct child of the hierarchy
+ // root (parent: null) — iOS UIAccessibility requires container
+ // elements to themselves be non-elements. Nesting our nodes makes
+ // ancestors opaque hit targets that swallow visibility tests for
+ // descendants (XCUITest reports visible="false" even when the
+ // child's frame is on screen). The test framework only ever looks
+ // up by name, so containment doesn't affect reachability.
+ if (!string.IsNullOrEmpty(el.name) && !_map.ContainsKey(el))
+ {
+ var name = el.name;
+ // Two distinct VisualElements with the same name (e.g.
+ // freshly-rebuilt dialog) — first one wins; only one
+ // accessibility node per name to keep ids unambiguous.
+ if (seenNames.Add(name))
+ {
+ AccessibilityNode node;
+ if (!existingByName.TryGetValue(name, out node))
+ {
+ node = _hierarchy.AddNode(name, parent: null);
+ addedAny = true;
+ }
+ var captured = el;
+ node.frameGetter = () => GetScreenRect(captured);
+ node.role = MapRole(el);
+ node.value = ExtractValue(el);
+ node.isActive = IsVisible(el);
+ _map[el] = node;
+ // Detach fires the moment a tracked element leaves the
+ // panel — Dismiss(), section refresh, scene swap. Drives
+ // an immediate ScheduleResync so XCUITest can't read a
+ // node whose owner is already gone.
+ el.RegisterCallback(_onDetachFromPanel);
+ }
+ }
+
+ for (int i = 0; i < el.childCount; i++)
+ WalkAndUpsert(el[i], existingByName, seenNames, ref addedAny);
+ }
+
+ private static AccessibilityRole MapRole(VisualElement el)
+ {
+ if (IsSectionAnchor(el))
+ return AccessibilityRole.StaticText;
+
+ return el switch
+ {
+ Button => AccessibilityRole.Button,
+ BaseBoolField => AccessibilityRole.Toggle,
+ OneSignalDemo.UI.SwitchToggle => AccessibilityRole.Toggle,
+ // ScrollView intentionally NOT exposed as AccessibilityRole.ScrollView.
+ // XCUITest's `XCUIElement.tap()` performs an implicit
+ // `scrollToVisible` on the nearest accessibility ancestor that
+ // claims a scrollable role, which under our setup invoked Unity's
+ // ScrollView mid-tap, shifted the entire content by ~120pt, and
+ // made the queued touch land on empty space (root cause of the
+ // "no PointerDown, button moved 120pt" failure on the multiple
+ // trigger test). Test code drives scrolling via raw swipes, so
+ // dropping the role is a no-op for navigation.
+ Slider => AccessibilityRole.Slider,
+ TextField => AccessibilityRole.SearchField,
+ Label => AccessibilityRole.StaticText,
+ Image => AccessibilityRole.Image,
+ _ => AccessibilityRole.None,
+ };
+ }
+
+ private static string ExtractValue(VisualElement el) => el switch
+ {
+ TextField tf => tf.value ?? string.Empty,
+ BaseBoolField b => b.value ? "1" : "0",
+ // The demo uses a custom SwitchToggle (VisualElement) that does
+ // not inherit from UnityEngine.UIElements.Toggle, so the platform
+ // a11y value would be empty without this case. The selectors
+ // helper reads `value`/`checked` and expects "1"/"0" on iOS.
+ OneSignalDemo.UI.SwitchToggle st => st.Value ? "1" : "0",
+ Label l => l.text ?? string.Empty,
+ Button b => b.text ?? string.Empty,
+ _ => string.Empty,
+ };
+
+ private static bool IsVisible(VisualElement el)
+ {
+ var s = el.resolvedStyle;
+ if (!el.enabledInHierarchy
+ || s.display == DisplayStyle.None
+ || s.visibility == Visibility.Hidden
+ || s.opacity <= 0f)
+ return false;
+
+ if (IsSectionAnchor(el))
+ return true;
+
+ // An element whose worldBound has been clipped away by an ancestor
+ // ScrollView/clip container is visually invisible. Other SDKs get
+ // this for free because they render through native UIScrollView /
+ // android.widget.ScrollView whose accessibilityFrame already
+ // excludes clipped children. UI Toolkit reports children's panel
+ // coordinates regardless of clip, so we have to intersect here.
+ var visible = ComputeVisibleWorldBound(GetFrameElement(el));
+ return visible.width > 0f && visible.height > 0f;
+ }
+
+ ///
+ /// Intersects an element's worldBound with every ancestor that clips
+ /// (ScrollView's contentViewport, or any element with overflow !=
+ /// Visible). Returns Rect.zero when the element is fully clipped.
+ ///
+ private static Rect ComputeVisibleWorldBound(VisualElement el)
+ {
+ var r = el.worldBound;
+ if (float.IsNaN(r.x) || float.IsNaN(r.y) || r.width <= 0f || r.height <= 0f)
+ return Rect.zero;
+
+ var p = el.hierarchy.parent;
+ while (p != null)
+ {
+ if (p is ScrollView sv)
+ {
+ var clip = sv.contentViewport.worldBound;
+ float x = Mathf.Max(r.x, clip.x);
+ float y = Mathf.Max(r.y, clip.y);
+ float right = Mathf.Min(r.x + r.width, clip.x + clip.width);
+ float bottom = Mathf.Min(r.y + r.height, clip.y + clip.height);
+ if (right <= x || bottom <= y)
+ return Rect.zero;
+ r = new Rect(x, y, right - x, bottom - y);
+ }
+ p = p.hierarchy.parent;
+ }
+ return r;
+ }
+
+ private static VisualElement GetFrameElement(VisualElement el)
+ {
+ if (IsSectionAnchor(el) && el.childCount > 0)
+ return el[0];
+ return el;
+ }
+
+ private static bool IsSectionAnchor(VisualElement el) =>
+ !string.IsNullOrEmpty(el?.name)
+ && el.name.EndsWith("_section", StringComparison.Ordinal);
+
+ private Rect GetScreenRect(VisualElement el)
+ {
+ if (el?.panel == null)
+ return Rect.zero;
+
+ var wb = ComputeVisibleWorldBound(GetFrameElement(el));
+ if (wb.width <= 0f || wb.height <= 0f)
+ return Rect.zero;
+
+ float s = _panelToScreenScale;
+ return new Rect(wb.x * s, wb.y * s, wb.width * s, wb.height * s);
+ }
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+ private sealed class AndroidClickTarget
+ {
+ public readonly Func IsEnabled;
+ public readonly Action Action;
+
+ public AndroidClickTarget(Func isEnabled, Action action)
+ {
+ IsEnabled = isEnabled;
+ Action = action;
+ }
+ }
+#endif
+#if UNITY_IOS && !UNITY_EDITOR
+ private sealed class IosInfoTap
+ {
+ public readonly Func IsEnabled;
+ public readonly Action Action;
+
+ public IosInfoTap(Func isEnabled, Action action)
+ {
+ IsEnabled = isEnabled;
+ Action = action;
+ }
+ }
+#endif
+ }
+}
+#endif
diff --git a/examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs.meta b/examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs.meta
new file mode 100644
index 000000000..f3ae9d32e
--- /dev/null
+++ b/examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f731f7ecdbc0d44a083358496883f941
\ No newline at end of file
diff --git a/examples/demo/Assets/Scripts/Services/DotEnv.cs b/examples/demo/Assets/Scripts/Services/DotEnv.cs
new file mode 100644
index 000000000..d0dba6863
--- /dev/null
+++ b/examples/demo/Assets/Scripts/Services/DotEnv.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using UnityEngine;
+
+namespace OneSignalDemo.Services
+{
+ /// Loads key/value pairs from the demo's .env file (StreamingAssets in players, project root in editor).
+ public static class DotEnv
+ {
+ private static readonly Dictionary _values = new();
+ private static bool _loaded;
+
+ public static void Load()
+ {
+ if (_loaded)
+ return;
+ _loaded = true;
+
+ try
+ {
+ var content = ReadEnvContent();
+ if (string.IsNullOrEmpty(content))
+ return;
+
+ foreach (var line in content.Split('\n'))
+ {
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0 || trimmed.StartsWith("#") || !trimmed.Contains("="))
+ continue;
+
+ var eqIndex = trimmed.IndexOf('=');
+ var key = trimmed.Substring(0, eqIndex).Trim();
+ var value = trimmed.Substring(eqIndex + 1).Trim();
+
+ int commentIdx = value.IndexOf('#');
+ if (commentIdx >= 0)
+ value = value.Substring(0, commentIdx).Trim();
+
+ if (
+ value.Length >= 2
+ && (
+ (value[0] == '"' && value[value.Length - 1] == '"')
+ || (value[0] == '\'' && value[value.Length - 1] == '\'')
+ )
+ )
+ value = value.Substring(1, value.Length - 2);
+
+ _values[key] = value;
+ }
+ }
+ catch (Exception)
+ {
+ // .env not bundled or unreadable -- keys remain empty
+ }
+ }
+
+ public static string Get(string key)
+ {
+ // Lazy-load so callers that race AppBootstrapper.Start (e.g. UI
+ // controllers' OnEnable) still see env values on first access.
+ Load();
+ return _values.TryGetValue(key, out var value) ? value : "";
+ }
+
+ public static bool IsE2EMode =>
+ string.Equals(Get("E2E_MODE"), "true", StringComparison.OrdinalIgnoreCase);
+
+ private static string ReadEnvContent()
+ {
+#if UNITY_EDITOR
+ var editorPath = Path.Combine(Application.dataPath, "..", ".env");
+ if (File.Exists(editorPath))
+ return File.ReadAllText(editorPath);
+#endif
+#if UNITY_ANDROID && !UNITY_EDITOR
+ return ReadAndroidAsset(".env");
+#else
+ var streamingPath = Path.Combine(Application.streamingAssetsPath, ".env");
+ if (File.Exists(streamingPath))
+ return File.ReadAllText(streamingPath);
+
+ return null;
+#endif
+ }
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+ private static string ReadAndroidAsset(string path)
+ {
+ using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+ using var activity = unityPlayer.GetStatic("currentActivity");
+ using var assets = activity.Call("getAssets");
+ using var stream = assets.Call("open", path);
+ using var inputStreamReader = new AndroidJavaObject("java.io.InputStreamReader", stream);
+ using var reader = new AndroidJavaObject(
+ "java.io.BufferedReader",
+ inputStreamReader
+ );
+ var content = new StringBuilder();
+
+ string line;
+ while ((line = reader.Call("readLine")) != null)
+ content.AppendLine(line);
+
+ return content.ToString();
+ }
+#endif
+ }
+}
diff --git a/examples/demo/Assets/Scripts/Services/DotEnv.cs.meta b/examples/demo/Assets/Scripts/Services/DotEnv.cs.meta
new file mode 100644
index 000000000..478c25779
--- /dev/null
+++ b/examples/demo/Assets/Scripts/Services/DotEnv.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0fbac43cdfb045fd9aaafd0d5088a6de
diff --git a/examples/demo/Assets/Scripts/Services/LogManager.cs b/examples/demo/Assets/Scripts/Services/LogManager.cs
deleted file mode 100644
index 0c29306c4..000000000
--- a/examples/demo/Assets/Scripts/Services/LogManager.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace OneSignalDemo.Services
-{
- public enum LogEntryLevel
- {
- Debug,
- Info,
- Warn,
- Error,
- }
-
- public class LogEntry
- {
- public DateTime Timestamp { get; }
- public LogEntryLevel Level { get; }
- public string Tag { get; }
- public string Message { get; }
-
- public LogEntry(LogEntryLevel level, string tag, string message)
- {
- Timestamp = DateTime.Now;
- Level = level;
- Tag = tag;
- Message = message;
- }
-
- public string LevelChar =>
- Level switch
- {
- LogEntryLevel.Debug => "D",
- LogEntryLevel.Info => "I",
- LogEntryLevel.Warn => "W",
- LogEntryLevel.Error => "E",
- _ => "?",
- };
- }
-
- public class LogManager
- {
- private static readonly LogManager _instance = new();
- public static LogManager Instance => _instance;
-
- private readonly List _entries = new();
- public IReadOnlyList Entries => _entries;
-
- public event Action OnLogAdded;
-
- private LogManager() { }
-
- public void Debug(string tag, string message) => Add(LogEntryLevel.Debug, tag, message);
-
- public void Info(string tag, string message) => Add(LogEntryLevel.Info, tag, message);
-
- public void Warn(string tag, string message) => Add(LogEntryLevel.Warn, tag, message);
-
- public void Error(string tag, string message) => Add(LogEntryLevel.Error, tag, message);
-
- public void Clear()
- {
- _entries.Clear();
- OnLogAdded?.Invoke(null);
- }
-
- private void Add(LogEntryLevel level, string tag, string message)
- {
- var entry = new LogEntry(level, tag, message);
- _entries.Add(entry);
-
- switch (level)
- {
- case LogEntryLevel.Debug:
- UnityEngine.Debug.Log($"[{tag}] {message}");
- break;
- case LogEntryLevel.Info:
- UnityEngine.Debug.Log($"[{tag}] {message}");
- break;
- case LogEntryLevel.Warn:
- UnityEngine.Debug.LogWarning($"[{tag}] {message}");
- break;
- case LogEntryLevel.Error:
- UnityEngine.Debug.LogError($"[{tag}] {message}");
- break;
- }
-
- OnLogAdded?.Invoke(entry);
- }
- }
-}
diff --git a/examples/demo/Assets/Scripts/Services/LogManager.cs.meta b/examples/demo/Assets/Scripts/Services/LogManager.cs.meta
deleted file mode 100644
index 182bdc4c3..000000000
--- a/examples/demo/Assets/Scripts/Services/LogManager.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: fc4bdb847b1f244cbb31560877ae4540
\ No newline at end of file
diff --git a/examples/demo/Assets/Scripts/Services/OneSignalApiService.cs b/examples/demo/Assets/Scripts/Services/OneSignalApiService.cs
index 5ad84aadb..8112c1e1e 100644
--- a/examples/demo/Assets/Scripts/Services/OneSignalApiService.cs
+++ b/examples/demo/Assets/Scripts/Services/OneSignalApiService.cs
@@ -1,10 +1,8 @@
using System;
-using System.IO;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using OneSignalDemo.Models;
-using UnityEngine;
using UnityEngine.Networking;
namespace OneSignalDemo.Services
@@ -12,7 +10,6 @@ namespace OneSignalDemo.Services
public class OneSignalApiService
{
private string _appId;
- private string _apiKey;
private const string NotificationImageUrl =
"https://media.onesignal.com/automated_push_templates/ratings_template.png";
@@ -23,38 +20,13 @@ public class OneSignalApiService
public string GetAppId() => _appId;
- public void LoadApiKey()
+ public bool HasApiKey()
{
- var envPath = Path.Combine(Application.dataPath, "..", ".env");
-#if !UNITY_EDITOR
- var streamingPath = Path.Combine(Application.streamingAssetsPath, ".env");
- if (File.Exists(streamingPath))
- envPath = streamingPath;
-#endif
- if (!File.Exists(envPath))
- return;
-
- foreach (var line in File.ReadAllLines(envPath))
- {
- var trimmed = line.Trim();
- if (trimmed.StartsWith("#") || !trimmed.Contains("="))
- continue;
-
- var eqIndex = trimmed.IndexOf('=');
- var key = trimmed.Substring(0, eqIndex).Trim();
- var value = trimmed.Substring(eqIndex + 1).Trim();
- int commentIdx = value.IndexOf('#');
- if (commentIdx >= 0)
- value = value.Substring(0, commentIdx).Trim();
- value = value.Trim('"', '\'');
-
- if (key == "ONESIGNAL_API_KEY")
- _apiKey = value;
- }
+ var key = DotEnv.Get("ONESIGNAL_API_KEY");
+ return !string.IsNullOrWhiteSpace(key) && key != PlaceholderApiKey;
}
- public bool HasApiKey() =>
- !string.IsNullOrEmpty(_apiKey) && _apiKey != PlaceholderApiKey;
+ private static string GetApiKey() => DotEnv.Get("ONESIGNAL_API_KEY");
public async Task SendNotification(NotificationType type, string subscriptionId)
{
@@ -63,6 +35,8 @@ public async Task SendNotification(NotificationType type, string subscript
string title,
body;
+ JObject extra = null;
+
switch (type)
{
case NotificationType.Simple:
@@ -72,23 +46,31 @@ public async Task SendNotification(NotificationType type, string subscript
case NotificationType.WithImage:
title = "Image Notification";
body = "This notification includes an image";
+ extra = new JObject
+ {
+ ["big_picture"] = NotificationImageUrl,
+ ["ios_attachments"] = new JObject { ["image"] = NotificationImageUrl },
+ ["mutable_content"] = true,
+ };
+ break;
+ case NotificationType.WithSound:
+ title = "Sound Notification";
+ body = "This notification plays a custom sound";
+ extra = new JObject
+ {
+ ["ios_sound"] = "vine_boom.wav",
+ ["android_channel_id"] = "b3b015d9-c050-4042-8548-dcc34aa44aa4",
+ };
break;
default:
return false;
}
- var payload = new JObject
+ var payload = BuildBasePayload(title, body, subscriptionId);
+ if (extra != null)
{
- ["app_id"] = _appId,
- ["include_subscription_ids"] = new JArray(subscriptionId),
- ["headings"] = new JObject { ["en"] = title },
- ["contents"] = new JObject { ["en"] = body },
- };
-
- if (type == NotificationType.WithImage)
- {
- payload["big_picture"] = NotificationImageUrl;
- payload["ios_attachments"] = new JObject { ["image"] = NotificationImageUrl };
+ foreach (var prop in extra.Properties())
+ payload[prop.Name] = prop.Value;
}
return await PostNotification(payload.ToString());
@@ -103,14 +85,7 @@ string subscriptionId
if (string.IsNullOrEmpty(subscriptionId) || string.IsNullOrEmpty(_appId))
return false;
- var payload = new JObject
- {
- ["app_id"] = _appId,
- ["include_subscription_ids"] = new JArray(subscriptionId),
- ["headings"] = new JObject { ["en"] = title },
- ["contents"] = new JObject { ["en"] = body },
- };
-
+ var payload = BuildBasePayload(title, body, subscriptionId);
return await PostNotification(payload.ToString());
}
@@ -170,18 +145,13 @@ public async Task UpdateLiveActivity(
payload["event_updates"] = eventUpdates;
if (eventType == "end")
- {
- var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- payload["dismissal_date"] = unixTimestamp;
- }
+ payload["dismissal_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- var jsonPayload = payload.ToString();
var request = new UnityWebRequest(url, "POST");
- var bodyRaw = Encoding.UTF8.GetBytes(jsonPayload);
- request.uploadHandler = new UploadHandlerRaw(bodyRaw);
+ request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(payload.ToString()));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
- request.SetRequestHeader("Authorization", $"Key {_apiKey}");
+ request.SetRequestHeader("Authorization", $"Key {GetApiKey()}");
var tcs = new TaskCompletionSource();
var operation = request.SendWebRequest();
@@ -193,11 +163,19 @@ public async Task UpdateLiveActivity(
return success;
}
+ private JObject BuildBasePayload(string title, string body, string subscriptionId) =>
+ new()
+ {
+ ["app_id"] = _appId,
+ ["include_subscription_ids"] = new JArray(subscriptionId),
+ ["headings"] = new JObject { ["en"] = title },
+ ["contents"] = new JObject { ["en"] = body },
+ };
+
private async Task PostNotification(string jsonPayload)
{
var request = new UnityWebRequest("https://onesignal.com/api/v1/notifications", "POST");
- var bodyRaw = Encoding.UTF8.GetBytes(jsonPayload);
- request.uploadHandler = new UploadHandlerRaw(bodyRaw);
+ request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(jsonPayload));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.SetRequestHeader("Accept", "application/vnd.onesignal.v1+json");
diff --git a/examples/demo/Assets/Scripts/Services/PreferencesService.cs b/examples/demo/Assets/Scripts/Services/PreferencesService.cs
index 405c21bce..bf77bfc44 100644
--- a/examples/demo/Assets/Scripts/Services/PreferencesService.cs
+++ b/examples/demo/Assets/Scripts/Services/PreferencesService.cs
@@ -4,19 +4,12 @@ namespace OneSignalDemo.Services
{
public class PreferencesService
{
- private const string KeyAppId = "onesignal_app_id";
private const string KeyConsentRequired = "consent_required";
private const string KeyPrivacyConsent = "privacy_consent";
private const string KeyExternalUserId = "external_user_id";
private const string KeyLocationShared = "location_shared";
private const string KeyIamPaused = "iam_paused";
- public string AppId
- {
- get => GetString(KeyAppId, "");
- set => SetString(KeyAppId, value);
- }
-
public bool ConsentRequired
{
get => GetBool(KeyConsentRequired, false);
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/CustomNotificationDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/CustomNotificationDialog.cs
index edbc59d2e..3111e61bb 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/CustomNotificationDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/CustomNotificationDialog.cs
@@ -28,7 +28,7 @@ protected override void BuildContent(VisualElement container)
container.Add(titleLabel);
_titleField = new TextField();
- _titleField.name = "custom_notif_title";
+ _titleField.name = "custom_notification_title_input";
_titleField.AddToClassList("input-field");
_titleField.RegisterValueChangedCallback(_ => ValidateInput());
container.Add(_titleField);
@@ -39,7 +39,7 @@ protected override void BuildContent(VisualElement container)
container.Add(bodyLabel);
_bodyField = new TextField();
- _bodyField.name = "custom_notif_body";
+ _bodyField.name = "custom_notification_body_input";
_bodyField.AddToClassList("input-field");
_bodyField.RegisterValueChangedCallback(_ => ValidateInput());
container.Add(_bodyField);
@@ -50,7 +50,7 @@ protected override void BuildContent(VisualElement container)
actions.Add(CreateCancelButton());
_confirmButton = CreateConfirmButton("Send", OnConfirm);
- _confirmButton.name = "custom_notif_confirm";
+ _confirmButton.name = "custom_notification_send_button";
_confirmButton.SetEnabled(false);
actions.Add(_confirmButton);
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/DialogBase.cs b/examples/demo/Assets/Scripts/UI/Dialogs/DialogBase.cs
index be6bc25b0..17d05433f 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/DialogBase.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/DialogBase.cs
@@ -1,16 +1,32 @@
using System;
+using System.Runtime.InteropServices;
+using OneSignalDemo.Services;
using UnityEngine.UIElements;
namespace OneSignalDemo.UI.Dialogs
{
public abstract class DialogBase
{
+#if UNITY_IOS && !UNITY_EDITOR
+ // Native bridge in Assets/Plugins/iOS/OneSignalDemoKeyboard.mm.
+ // Calls [keyWindow endEditing:YES] to dismiss the iOS keyboard view
+ // immediately. UIToolkit's TextField.Blur() and TouchScreenKeyboard.
+ // Open("").active = false don't reliably tear down the UIKit keyboard
+ // when a UIToolkit-owned modal dismisses, leaving it floating over the
+ // app and blocking taps on controls behind it.
+ [DllImport("__Internal")]
+ private static extern void OneSignalDemoEndEditing();
+#endif
+
protected VisualElement Overlay { get; private set; }
protected VisualElement Container { get; private set; }
private VisualElement _parent;
public void Show(VisualElement parent)
{
+ if (parent?.Q(className: "dialog-overlay") != null)
+ return;
+
_parent = parent;
Overlay = new VisualElement();
@@ -28,11 +44,42 @@ public void Show(VisualElement parent)
Overlay.Add(Container);
parent.Add(Overlay);
+ AccessibilityBridge.RequestImmediateResync();
}
public void Dismiss()
{
- Overlay?.RemoveFromHierarchy();
+ var textField = Container?.Q();
+ textField?.Blur();
+
+#if UNITY_IOS && !UNITY_EDITOR
+ // Force UIKit to resign first responder so the keyboard view tears
+ // down immediately. Without this the keyboard stays floating over
+ // the app after the modal closes and blocks taps on controls
+ // behind it (e.g. the logout button after login).
+ OneSignalDemoEndEditing();
+#endif
+
+ // Release any pointer capture the dismissed Overlay still holds
+ // before removing it. Without this UIToolkit can keep delivering
+ // the in-flight pointer sequence to the captured target after
+ // RemoveFromHierarchy, which swallows the next synthetic tap.
+ var overlay = Overlay;
+ if (overlay != null)
+ {
+ var panel = overlay.panel;
+ if (panel != null)
+ {
+ for (int id = 0; id < PointerId.maxPointers; id++)
+ {
+ if (panel.GetCapturingElement(id) == overlay)
+ overlay.ReleasePointer(id);
+ }
+ }
+ overlay.RemoveFromHierarchy();
+ AccessibilityBridge.RequestImmediateResync();
+ }
+ Overlay = null;
}
protected abstract void BuildContent(VisualElement container);
@@ -61,6 +108,11 @@ protected Button CreateConfirmButton(string text, Action onClick)
btn.text = text;
btn.AddToClassList("dialog-confirm-button");
btn.AddToClassList("text-dialog-action");
+ AccessibilityBridge.RegisterE2ETapTarget(
+ btn,
+ () => btn.enabledInHierarchy,
+ onClick
+ );
return btn;
}
@@ -70,6 +122,7 @@ protected Button CreateCancelButton(string text = "Cancel")
btn.text = text;
btn.AddToClassList("dialog-cancel-button");
btn.AddToClassList("text-dialog-action");
+ AccessibilityBridge.RegisterE2ETapTarget(btn, () => btn.enabledInHierarchy, Dismiss);
return btn;
}
}
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/LoginDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/LoginDialog.cs
index a87702370..c7db3f3cb 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/LoginDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/LoginDialog.cs
@@ -29,7 +29,7 @@ protected override void BuildContent(VisualElement container)
container.Add(label);
_externalIdField = new TextField();
- _externalIdField.name = "login_external_id";
+ _externalIdField.name = "login_user_id_input";
_externalIdField.AddToClassList("input-field");
_externalIdField.RegisterValueChangedCallback(_ => ValidateInput());
container.Add(_externalIdField);
@@ -40,7 +40,7 @@ protected override void BuildContent(VisualElement container)
actions.Add(CreateCancelButton());
_confirmButton = CreateConfirmButton(_isSwitchUser ? "Switch" : "Login", OnConfirm);
- _confirmButton.name = "login_confirm_button";
+ _confirmButton.name = "singleinput_confirm_button";
_confirmButton.SetEnabled(false);
actions.Add(_confirmButton);
@@ -49,14 +49,15 @@ protected override void BuildContent(VisualElement container)
private void ValidateInput()
{
- _confirmButton?.SetEnabled(!string.IsNullOrEmpty(_externalIdField?.value));
+ _confirmButton?.SetEnabled(!string.IsNullOrWhiteSpace(_externalIdField?.value));
}
private void OnConfirm()
{
- var value = _externalIdField?.value;
+ var value = _externalIdField?.value?.Trim();
if (!string.IsNullOrEmpty(value))
{
+ _externalIdField?.Blur();
_onConfirm?.Invoke(value);
Dismiss();
}
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/MultiPairInputDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/MultiPairInputDialog.cs
index 52770d6e5..987ffd903 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/MultiPairInputDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/MultiPairInputDialog.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
+using OneSignalDemo.Services;
using OneSignalDemo.UI;
+using UnityEngine;
using UnityEngine.UIElements;
namespace OneSignalDemo.UI.Dialogs
@@ -15,6 +17,14 @@ public class MultiPairInputDialog : DialogBase
private readonly List<(TextField key, TextField value, VisualElement row)> _rows = new();
private VisualElement _rowsContainer;
private Button _confirmButton;
+ private bool _submitted;
+ private double _lastAddRowMs = -1.0;
+ private const double AddRowDedupeMs = 500.0;
+ // Monotonic — we cannot derive ids from _rows.Count because RemoveRow
+ // does not renumber survivors, so a new row would collide with an
+ // existing name and AccessibilityBridge.WalkAndUpsert (first-wins by
+ // name) would silently drop it from the a11y tree.
+ private int _nextRowIndex;
public MultiPairInputDialog(
string title,
@@ -43,8 +53,14 @@ protected override void BuildContent(VisualElement container)
AddRow();
- var addRowButton = new Button(AddRow);
+ var addRowButton = new Button(InvokeAddRow);
+ addRowButton.name = "multipair_add_row_button";
addRowButton.AddToClassList("dialog-add-row-button");
+ AccessibilityBridge.RegisterE2ETapTarget(
+ addRowButton,
+ () => addRowButton.enabledInHierarchy,
+ InvokeAddRow
+ );
var addIcon = new Label(MaterialIcons.Add);
addIcon.AddToClassList("dialog-add-row-icon");
addRowButton.Add(addIcon);
@@ -59,6 +75,7 @@ protected override void BuildContent(VisualElement container)
actions.Add(CreateCancelButton());
_confirmButton = CreateConfirmButton(_confirmText, OnConfirm);
+ _confirmButton.name = "multipair_confirm_button";
_confirmButton.SetEnabled(false);
actions.Add(_confirmButton);
@@ -77,15 +94,17 @@ private void AddRow()
var row = new VisualElement();
row.AddToClassList("dialog-row");
+ var rowIndex = _nextRowIndex++;
+
var keyField = new TextField();
- keyField.name = $"multi_key_{_rows.Count}";
+ keyField.name = $"multipair_key_{rowIndex}";
keyField.AddToClassList("input-field");
keyField.AddToClassList("dialog-field-group-left");
keyField.textEdition.placeholder = _keyLabel;
keyField.RegisterValueChangedCallback(_ => ValidateAll());
var valueField = new TextField();
- valueField.name = $"multi_value_{_rows.Count}";
+ valueField.name = $"multipair_value_{rowIndex}";
valueField.AddToClassList("input-field");
valueField.AddToClassList("dialog-field-group");
valueField.textEdition.placeholder = _valueLabel;
@@ -108,6 +127,17 @@ private void AddRow()
ValidateAll();
}
+ private void InvokeAddRow()
+ {
+ double now = Time.realtimeSinceStartupAsDouble * 1000.0;
+ if (_lastAddRowMs >= 0.0 && now - _lastAddRowMs < AddRowDedupeMs)
+ return;
+
+ _lastAddRowMs = now;
+ AddRow();
+ AccessibilityBridge.RequestImmediateResync();
+ }
+
private void RemoveRow((TextField key, TextField value, VisualElement row) entry)
{
_rows.Remove(entry);
@@ -143,7 +173,7 @@ private void ValidateAll()
bool allValid = _rows.Count > 0;
foreach (var (key, value, _) in _rows)
{
- if (string.IsNullOrEmpty(key.value) || string.IsNullOrEmpty(value.value))
+ if (string.IsNullOrWhiteSpace(key.value) || string.IsNullOrWhiteSpace(value.value))
{
allValid = false;
break;
@@ -154,18 +184,25 @@ private void ValidateAll()
private void OnConfirm()
{
+ if (_submitted)
+ return;
+
var dict = new Dictionary();
foreach (var (key, value, _) in _rows)
{
- if (!string.IsNullOrEmpty(key.value) && !string.IsNullOrEmpty(value.value))
- dict[key.value] = value.value;
+ var trimmedKey = key.value?.Trim();
+ var trimmedValue = value.value?.Trim();
+ if (!string.IsNullOrEmpty(trimmedKey) && !string.IsNullOrEmpty(trimmedValue))
+ dict[trimmedKey] = trimmedValue;
}
- if (dict.Count > 0)
- {
- _onConfirm?.Invoke(dict);
- Dismiss();
- }
+ if (dict.Count == 0)
+ return;
+
+ _submitted = true;
+ _confirmButton?.SetEnabled(false);
+ _onConfirm?.Invoke(dict);
+ Dismiss();
}
}
}
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/MultiSelectRemoveDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/MultiSelectRemoveDialog.cs
index 4daf69fbe..c6a91177e 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/MultiSelectRemoveDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/MultiSelectRemoveDialog.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using OneSignalDemo.Services;
+using UnityEngine;
using UnityEngine.UIElements;
namespace OneSignalDemo.UI.Dialogs
@@ -11,7 +13,9 @@ public class MultiSelectRemoveDialog : DialogBase
private readonly List> _items;
private readonly Action> _onConfirm;
private readonly Dictionary _toggles = new();
+ private readonly Dictionary _lastToggleChangeMs = new();
private Button _confirmButton;
+ private const double ToggleDedupeMs = 500.0;
public MultiSelectRemoveDialog(
string title,
@@ -37,9 +41,11 @@ protected override void BuildContent(VisualElement container)
row.AddToClassList("checkbox-row");
var toggle = new Toggle();
- toggle.name = $"select_{item.Key}";
+ toggle.name = $"remove_checkbox_{item.Key}";
toggle.RegisterValueChangedCallback(evt =>
{
+ _lastToggleChangeMs[item.Key] =
+ Time.realtimeSinceStartupAsDouble * 1000.0;
toggle.EnableInClassList("checkbox--checked", evt.newValue);
UpdateCount();
});
@@ -51,6 +57,16 @@ protected override void BuildContent(VisualElement container)
row.Add(label);
_toggles[item.Key] = toggle;
+ AccessibilityBridge.RegisterE2ETapTarget(
+ row,
+ () => toggle.enabledInHierarchy,
+ () => ToggleSelection(item.Key, toggle)
+ );
+ AccessibilityBridge.RegisterE2ETapTarget(
+ toggle,
+ () => toggle.enabledInHierarchy,
+ () => ToggleSelection(item.Key, toggle)
+ );
container.Add(row);
}
@@ -60,6 +76,7 @@ protected override void BuildContent(VisualElement container)
actions.Add(CreateCancelButton());
_confirmButton = CreateConfirmButton("Remove (0)", OnConfirm);
+ _confirmButton.name = "multiselect_confirm_button";
_confirmButton.SetEnabled(false);
actions.Add(_confirmButton);
@@ -73,6 +90,18 @@ private void UpdateCount()
_confirmButton.SetEnabled(count > 0);
}
+ private void ToggleSelection(string key, Toggle toggle)
+ {
+ double now = Time.realtimeSinceStartupAsDouble * 1000.0;
+ if (_lastToggleChangeMs.TryGetValue(key, out var last) && now - last < ToggleDedupeMs)
+ return;
+
+ _lastToggleChangeMs[key] = now;
+ toggle.value = !toggle.value;
+ toggle.EnableInClassList("checkbox--checked", toggle.value);
+ UpdateCount();
+ }
+
private void OnConfirm()
{
var selected = _toggles.Where(kvp => kvp.Value.value).Select(kvp => kvp.Key).ToList();
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/OutcomeDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/OutcomeDialog.cs
index a93b1739e..0db76afaa 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/OutcomeDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/OutcomeDialog.cs
@@ -36,9 +36,9 @@ protected override void BuildContent(VisualElement container)
var radioGroup = new VisualElement();
- _normalRadio = CreateRadio("Normal Outcome", "outcome_normal", true);
- _uniqueRadio = CreateRadio("Unique Outcome", "outcome_unique", false);
- _withValueRadio = CreateRadio("Outcome with Value", "outcome_with_value", false);
+ _normalRadio = CreateRadio("Normal Outcome", "outcome_type_normal_radio", true);
+ _uniqueRadio = CreateRadio("Unique Outcome", "outcome_type_unique_radio", false);
+ _withValueRadio = CreateRadio("Outcome with Value", "outcome_type_value_radio", false);
_normalRadio.RegisterValueChangedCallback(e =>
{
@@ -62,7 +62,7 @@ protected override void BuildContent(VisualElement container)
container.Add(radioGroup);
_nameField = new TextField();
- _nameField.name = "outcome_name";
+ _nameField.name = "outcome_name_input";
_nameField.AddToClassList("input-field");
_nameField.textEdition.placeholder = "Outcome Name";
_nameField.RegisterValueChangedCallback(_ => ValidateInput());
@@ -72,7 +72,7 @@ protected override void BuildContent(VisualElement container)
_valueContainer.style.display = DisplayStyle.None;
_valueField = new TextField();
- _valueField.name = "outcome_value";
+ _valueField.name = "outcome_value_input";
_valueField.AddToClassList("input-field");
_valueField.textEdition.placeholder = "Value";
_valueField.RegisterValueChangedCallback(_ => ValidateInput());
@@ -86,7 +86,7 @@ protected override void BuildContent(VisualElement container)
actions.Add(CreateCancelButton());
_confirmButton = CreateConfirmButton("Send", OnConfirm);
- _confirmButton.name = "outcome_confirm_button";
+ _confirmButton.name = "outcome_send_button";
_confirmButton.SetEnabled(false);
actions.Add(_confirmButton);
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/PairInputDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/PairInputDialog.cs
index 168a3cab0..220c0f3db 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/PairInputDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/PairInputDialog.cs
@@ -15,6 +15,7 @@ public class PairInputDialog : DialogBase
private TextField _keyField;
private TextField _valueField;
private Button _confirmButton;
+ private bool _submitted;
public PairInputDialog(
string title,
@@ -79,6 +80,7 @@ protected override void BuildContent(VisualElement container)
actions.Add(CreateCancelButton());
_confirmButton = CreateConfirmButton(_confirmText, OnConfirm);
+ _confirmButton.name = "singlepair_confirm_button";
_confirmButton.SetEnabled(false);
actions.Add(_confirmButton);
@@ -88,20 +90,28 @@ protected override void BuildContent(VisualElement container)
private void ValidateInput()
{
bool valid =
- !string.IsNullOrEmpty(_keyField?.value)
- && !string.IsNullOrEmpty(_valueField?.value);
+ !string.IsNullOrWhiteSpace(_keyField?.value)
+ && !string.IsNullOrWhiteSpace(_valueField?.value);
_confirmButton?.SetEnabled(valid);
}
private void OnConfirm()
{
- var key = _keyField?.value;
- var value = _valueField?.value;
- if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value))
- {
- _onConfirm?.Invoke(key, value);
- Dismiss();
- }
+ // Guard against double-fire: rapid double-taps and the E2E
+ // accessibility tap fallback can both deliver a second click
+ // before Dismiss() tears the dialog down.
+ if (_submitted)
+ return;
+
+ var key = _keyField?.value?.Trim();
+ var value = _valueField?.value?.Trim();
+ if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value))
+ return;
+
+ _submitted = true;
+ _confirmButton?.SetEnabled(false);
+ _onConfirm?.Invoke(key, value);
+ Dismiss();
}
}
}
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/SingleInputDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/SingleInputDialog.cs
index df9c47814..ed26c9f30 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/SingleInputDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/SingleInputDialog.cs
@@ -52,6 +52,7 @@ protected override void BuildContent(VisualElement container)
actions.Add(CreateCancelButton());
_confirmButton = CreateConfirmButton(_confirmText, OnConfirm);
+ _confirmButton.name = "singleinput_confirm_button";
_confirmButton.SetEnabled(false);
actions.Add(_confirmButton);
@@ -60,12 +61,12 @@ protected override void BuildContent(VisualElement container)
private void ValidateInput()
{
- _confirmButton?.SetEnabled(!string.IsNullOrEmpty(_inputField?.value));
+ _confirmButton?.SetEnabled(!string.IsNullOrWhiteSpace(_inputField?.value));
}
private void OnConfirm()
{
- var value = _inputField?.value;
+ var value = _inputField?.value?.Trim();
if (!string.IsNullOrEmpty(value))
{
_onConfirm?.Invoke(value);
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/TooltipDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/TooltipDialog.cs
index ef12bc32b..b22e47344 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/TooltipDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/TooltipDialog.cs
@@ -15,6 +15,7 @@ public TooltipDialog(TooltipData data)
protected override void BuildContent(VisualElement container)
{
var title = new Label(_data.Title ?? "Info");
+ title.name = "tooltip_title";
title.AddToClassList("dialog-title");
title.AddToClassList("text-dialog-title");
container.Add(title);
@@ -22,6 +23,7 @@ protected override void BuildContent(VisualElement container)
if (!string.IsNullOrEmpty(_data.Description))
{
var desc = new Label(_data.Description);
+ desc.name = "tooltip_description";
desc.AddToClassList("tooltip-description");
desc.AddToClassList("text-body-medium");
container.Add(desc);
@@ -56,7 +58,9 @@ protected override void BuildContent(VisualElement container)
var actions = new VisualElement();
actions.AddToClassList("dialog-actions");
- actions.Add(CreateCancelButton("OK"));
+ var okButton = CreateCancelButton("OK");
+ okButton.name = "tooltip_ok_button";
+ actions.Add(okButton);
container.Add(actions);
}
}
diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/TrackEventDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/TrackEventDialog.cs
index 101707a9d..b8547424a 100644
--- a/examples/demo/Assets/Scripts/UI/Dialogs/TrackEventDialog.cs
+++ b/examples/demo/Assets/Scripts/UI/Dialogs/TrackEventDialog.cs
@@ -32,7 +32,7 @@ protected override void BuildContent(VisualElement container)
container.Add(nameLabel);
_nameField = new TextField();
- _nameField.name = "event_name";
+ _nameField.name = "event_name_input";
_nameField.AddToClassList("input-field");
_nameField.RegisterValueChangedCallback(_ => ValidateInput());
container.Add(_nameField);
@@ -43,7 +43,7 @@ protected override void BuildContent(VisualElement container)
container.Add(propsLabel);
_propertiesField = new TextField();
- _propertiesField.name = "event_properties";
+ _propertiesField.name = "event_properties_input";
_propertiesField.AddToClassList("input-field");
_propertiesField.textEdition.placeholder = "{\"key\": \"value\"}";
_propertiesField.RegisterValueChangedCallback(_ => ValidateInput());
@@ -60,7 +60,7 @@ protected override void BuildContent(VisualElement container)
actions.Add(CreateCancelButton());
_confirmButton = CreateConfirmButton("Track", OnConfirm);
- _confirmButton.name = "event_confirm_button";
+ _confirmButton.name = "event_track_button";
_confirmButton.SetEnabled(false);
actions.Add(_confirmButton);
diff --git a/examples/demo/Assets/Scripts/UI/HomeScreenController.cs b/examples/demo/Assets/Scripts/UI/HomeScreenController.cs
index 80b23bca3..b499bc037 100644
--- a/examples/demo/Assets/Scripts/UI/HomeScreenController.cs
+++ b/examples/demo/Assets/Scripts/UI/HomeScreenController.cs
@@ -21,10 +21,6 @@ public class HomeScreenController : MonoBehaviour
private VisualElement _root;
private VisualElement _contentRoot;
- private VisualElement _loadingOverlay;
- private VisualElement _spinner;
- private IVisualElementScheduledItem _spinnerAnim;
- private LogViewController _logView;
private ToastView _toastView;
private AppSectionController _appSection;
@@ -39,7 +35,7 @@ public class HomeScreenController : MonoBehaviour
private TagsSectionController _tagsSection;
private OutcomesSectionController _outcomesSection;
private TriggersSectionController _triggersSection;
- private TrackEventSectionController _trackEventSection;
+ private CustomEventsSectionController _customEventsSection;
private LocationSectionController _locationSection;
private LiveActivitiesSectionController _liveActivitiesSection;
@@ -59,12 +55,15 @@ private void OnEnable()
if (themeSheet != null)
_root.styleSheets.Add(themeSheet);
- var logViewSheet = Resources.Load("LogView");
- if (logViewSheet != null)
- _root.styleSheets.Add(logViewSheet);
-
BuildScreen();
WireEvents();
+
+#if UNITY_IOS || UNITY_ANDROID
+ // E2E only: publish the VisualElement tree to platform a11y so
+ // Appium (XCUITest / UiAutomator2) can locate elements by name.
+ // No-op outside E2E_MODE.
+ OneSignalDemo.Services.AccessibilityBridge.EnableForE2E(_root);
+#endif
}
private void OnDisable()
@@ -74,7 +73,6 @@ private void OnDisable()
_viewModel.OnStateChanged -= RefreshAll;
_viewModel.OnToastMessage -= ShowToast;
}
- _logView?.Destroy();
}
private void Update()
@@ -117,11 +115,7 @@ private void ApplySafeArea()
var safe = Screen.safeArea;
float scale = rootHeight / Screen.height;
float top = (Screen.height - safe.yMax) * scale;
- float bottom = safe.y * scale;
- var screenRoot = _root.Q("screen_root");
- if (screenRoot != null)
- screenRoot.style.paddingBottom = bottom;
var statusSpacer = _root.Q("status_bar_spacer");
if (statusSpacer != null)
statusSpacer.style.height = top;
@@ -155,9 +149,12 @@ private void BuildScreen()
screenRoot.Add(appBar);
- _logView = new LogViewController(screenRoot);
+ var appBarShadow = new VisualElement();
+ appBarShadow.AddToClassList("app-bar-shadow");
+ screenRoot.Add(appBarShadow);
var scrollView = new ScrollView(ScrollViewMode.Vertical);
+ scrollView.name = "main_scroll_view";
scrollView.AddToClassList("flex-grow");
_contentRoot = new VisualElement();
@@ -168,17 +165,6 @@ private void BuildScreen()
scrollView.Add(_contentRoot);
screenRoot.Add(scrollView);
- _loadingOverlay = new VisualElement();
- _loadingOverlay.name = "loading_overlay";
- _loadingOverlay.AddToClassList("loading-overlay");
- _loadingOverlay.style.display = DisplayStyle.None;
-
- _spinner = new VisualElement();
- _spinner.AddToClassList("loading-spinner");
- _loadingOverlay.Add(_spinner);
-
- screenRoot.Add(_loadingOverlay);
-
_toastView = new ToastView(screenRoot);
_root.Add(screenRoot);
@@ -246,10 +232,10 @@ private void BuildSections()
_triggersSection.OnRemoveSelectedTap = ShowRemoveSelectedTriggersDialog;
_contentRoot.Add(_triggersSection.Root);
- _trackEventSection = new TrackEventSectionController(_viewModel);
- _trackEventSection.OnInfoTap = () => ShowTooltip("trackEvent");
- _trackEventSection.OnTrackEventTap = ShowTrackEventDialog;
- _contentRoot.Add(_trackEventSection.Root);
+ _customEventsSection = new CustomEventsSectionController(_viewModel);
+ _customEventsSection.OnInfoTap = () => ShowTooltip("customEvents");
+ _customEventsSection.OnTrackEventTap = ShowTrackEventDialog;
+ _contentRoot.Add(_customEventsSection.Root);
_locationSection = new LocationSectionController(_viewModel);
_locationSection.OnInfoTap = () => ShowTooltip("location");
@@ -289,26 +275,6 @@ private void RefreshAll()
_triggersSection?.Refresh();
_locationSection?.Refresh();
_liveActivitiesSection?.Refresh();
-
- var showLoading = _viewModel.IsLoading;
- _loadingOverlay.style.display = showLoading ? DisplayStyle.Flex : DisplayStyle.None;
-
- if (showLoading && _spinnerAnim == null)
- {
- float angle = 0f;
- _spinnerAnim = _spinner
- .schedule.Execute(() =>
- {
- angle = (angle + 12f) % 360f;
- _spinner.style.rotate = new Rotate(Angle.Degrees(angle));
- })
- .Every(16);
- }
- else if (!showLoading && _spinnerAnim != null)
- {
- _spinnerAnim.Pause();
- _spinnerAnim = null;
- }
}
private void ShowToast(string message) => _toastView?.Show(message);
@@ -328,8 +294,8 @@ private void ShowAddAliasDialog()
"Add Alias",
"Label",
"ID",
- "alias_key",
- "alias_value",
+ "alias_label_input",
+ "alias_id_input",
"Add",
(key, value) => _viewModel.AddAlias(key, value)
);
@@ -378,8 +344,8 @@ private void ShowAddTagDialog()
"Add Tag",
"Key",
"Value",
- "tag_key",
- "tag_value",
+ "tag_key_input",
+ "tag_value_input",
"Add",
(key, value) => _viewModel.AddTag(key, value)
);
@@ -418,8 +384,8 @@ private void ShowAddTriggerDialog()
"Add Trigger",
"Key",
"Value",
- "trigger_key",
- "trigger_value",
+ "trigger_key_input",
+ "trigger_value_input",
"Add",
(key, value) => _viewModel.AddTrigger(key, value)
);
diff --git a/examples/demo/Assets/Scripts/UI/LogViewController.cs b/examples/demo/Assets/Scripts/UI/LogViewController.cs
deleted file mode 100644
index 4be22bf0f..000000000
--- a/examples/demo/Assets/Scripts/UI/LogViewController.cs
+++ /dev/null
@@ -1,155 +0,0 @@
-using System;
-using OneSignalDemo.Services;
-using UnityEngine;
-using UnityEngine.UIElements;
-
-namespace OneSignalDemo.UI
-{
- public class LogViewController
- {
- private readonly VisualElement _container;
- private readonly VisualElement _scrollContent;
- private readonly ScrollView _scrollView;
- private readonly Label _countLabel;
- private readonly Label _emptyLabel;
- private readonly Button _clearButton;
- private readonly Button _chevronButton;
- private bool _expanded = true;
-
- public LogViewController(VisualElement root)
- {
- _container = new VisualElement();
- _container.name = "log_view_container";
- _container.AddToClassList("log-container");
-
- var header = new VisualElement();
- header.name = "log_view_header";
- header.AddToClassList("log-header");
- header.RegisterCallback(_ => ToggleExpand());
-
- var headerLeft = new VisualElement();
- headerLeft.AddToClassList("log-header-left");
-
- var title = new Label("LOGS");
- title.AddToClassList("log-header-title");
- title.AddToClassList("text-label-small");
- headerLeft.Add(title);
-
- _countLabel = new Label("(0)");
- _countLabel.name = "log_view_count";
- _countLabel.AddToClassList("log-header-count");
- _countLabel.AddToClassList("text-label-small");
- headerLeft.Add(_countLabel);
-
- header.Add(headerLeft);
-
- var headerRight = new VisualElement();
- headerRight.AddToClassList("log-header-right");
-
- _clearButton = new Button(ClearLogs);
- _clearButton.name = "log_view_clear_button";
- _clearButton.text = MaterialIcons.Delete;
- _clearButton.AddToClassList("log-clear-button");
- headerRight.Add(_clearButton);
-
- _chevronButton = new Button(ToggleExpand);
- _chevronButton.name = "log_view_toggle";
- _chevronButton.text = MaterialIcons.ExpandLess;
- _chevronButton.AddToClassList("log-clear-button");
- headerRight.Add(_chevronButton);
-
- header.Add(headerRight);
-
- _container.Add(header);
-
- _scrollView = new ScrollView(ScrollViewMode.VerticalAndHorizontal);
- _scrollView.name = "log_view_list";
- _scrollView.AddToClassList("log-scroll");
-
- _scrollContent = new VisualElement();
- _scrollView.Add(_scrollContent);
-
- _emptyLabel = new Label("No logs yet");
- _emptyLabel.name = "log_view_empty";
- _emptyLabel.AddToClassList("log-empty");
- _scrollContent.Add(_emptyLabel);
-
- _container.Add(_scrollView);
-
- LogManager.Instance.OnLogAdded += OnLogAdded;
- Refresh();
-
- root.Add(_container);
- }
-
- private void ToggleExpand()
- {
- _expanded = !_expanded;
- _scrollView.style.display = _expanded ? DisplayStyle.Flex : DisplayStyle.None;
- _chevronButton.text = _expanded ? MaterialIcons.ExpandLess : MaterialIcons.ExpandMore;
- }
-
- private void ClearLogs()
- {
- LogManager.Instance.Clear();
- Refresh();
- }
-
- private void OnLogAdded(LogEntry entry)
- {
- Refresh();
- }
-
- private void Refresh()
- {
- _scrollContent.Clear();
- var entries = LogManager.Instance.Entries;
- _countLabel.text = $"({entries.Count})";
- _clearButton.style.display = entries.Count > 0 ? DisplayStyle.Flex : DisplayStyle.None;
-
- if (entries.Count == 0)
- {
- var empty = new Label("No logs yet");
- empty.name = "log_view_empty";
- empty.AddToClassList("log-empty");
- _scrollContent.Add(empty);
- return;
- }
-
- for (int i = entries.Count - 1; i >= 0; i--)
- {
- var entry = entries[i];
- var displayIndex = entries.Count - 1 - i;
- var row = new VisualElement();
- row.name = $"log_entry_{displayIndex}";
- row.AddToClassList("log-entry");
-
- var ts = new Label(entry.Timestamp.ToString("HH:mm:ss"));
- ts.name = $"log_entry_{displayIndex}_timestamp";
- ts.AddToClassList("log-timestamp");
- ts.AddToClassList("text-label-small");
-
- var level = new Label(entry.LevelChar);
- level.name = $"log_entry_{displayIndex}_level";
- level.AddToClassList("log-level");
- level.AddToClassList("text-label-small");
- level.AddToClassList($"log-level-{entry.LevelChar.ToLower()}");
-
- var msg = new Label($"{entry.Tag}: {entry.Message}");
- msg.name = $"log_entry_{displayIndex}_message";
- msg.AddToClassList("log-message");
- msg.AddToClassList("text-label-small");
-
- row.Add(ts);
- row.Add(level);
- row.Add(msg);
- _scrollContent.Add(row);
- }
- }
-
- public void Destroy()
- {
- LogManager.Instance.OnLogAdded -= OnLogAdded;
- }
- }
-}
diff --git a/examples/demo/Assets/Scripts/UI/LogViewController.cs.meta b/examples/demo/Assets/Scripts/UI/LogViewController.cs.meta
deleted file mode 100644
index 16b44d65c..000000000
--- a/examples/demo/Assets/Scripts/UI/LogViewController.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: a70e72c2d0afa4e2ead6f2a31ae65601
\ No newline at end of file
diff --git a/examples/demo/Assets/Scripts/UI/SecondaryScreenController.cs b/examples/demo/Assets/Scripts/UI/SecondaryScreenController.cs
index 8a3b808d9..7070edceb 100644
--- a/examples/demo/Assets/Scripts/UI/SecondaryScreenController.cs
+++ b/examples/demo/Assets/Scripts/UI/SecondaryScreenController.cs
@@ -9,25 +9,42 @@ public class SecondaryScreenController : MonoBehaviour
[SerializeField]
private UIDocument _uiDocument;
+ private VisualElement _root;
+
private void OnEnable()
{
- var root = _uiDocument.rootVisualElement;
- root.Clear();
+ _root = _uiDocument.rootVisualElement;
+ _root.Clear();
var themeSheet = Resources.Load("Theme");
if (themeSheet != null)
- root.styleSheets.Add(themeSheet);
+ _root.styleSheets.Add(themeSheet);
var screenRoot = new VisualElement();
screenRoot.AddToClassList("screen-root");
+ var statusSpacer = new VisualElement();
+ statusSpacer.name = "status_bar_spacer";
+ statusSpacer.AddToClassList("status-bar-spacer");
+ statusSpacer.style.height = 0;
+ screenRoot.Add(statusSpacer);
+
var appBar = new VisualElement();
appBar.AddToClassList("app-bar");
+ appBar.AddToClassList("app-bar-left");
- var backButton = new Button(() => SceneManager.LoadScene("Main"));
+ void GoBack() => SceneManager.LoadScene("Main");
+ var backButton = new Button(GoBack);
backButton.name = "back_button";
backButton.text = MaterialIcons.ArrowBack;
backButton.AddToClassList("back-button");
+#if UNITY_ANDROID && !UNITY_EDITOR
+ OneSignalDemo.Services.AccessibilityBridge.RegisterE2ETapTarget(
+ backButton,
+ () => backButton.enabledInHierarchy,
+ GoBack
+ );
+#endif
appBar.Add(backButton);
var title = new Label("Secondary Screen");
@@ -36,16 +53,47 @@ private void OnEnable()
screenRoot.Add(appBar);
+ var appBarShadow = new VisualElement();
+ appBarShadow.AddToClassList("app-bar-shadow");
+ screenRoot.Add(appBarShadow);
+
var content = new VisualElement();
content.AddToClassList("centered-content");
var heading = new Label("Secondary Screen");
- heading.name = "secondary_heading";
+ heading.name = "secondary_activity_label";
heading.AddToClassList("page-heading");
content.Add(heading);
screenRoot.Add(content);
- root.Add(screenRoot);
+ _root.Add(screenRoot);
+
+#if UNITY_IOS || UNITY_ANDROID
+ OneSignalDemo.Services.AccessibilityBridge.EnableForE2E(_root);
+#endif
+ }
+
+ private void Update()
+ {
+ ApplySafeArea();
+ }
+
+ private void ApplySafeArea()
+ {
+ if (_root == null)
+ return;
+
+ float rootHeight = _root.resolvedStyle.height;
+ if (float.IsNaN(rootHeight) || rootHeight <= 0 || Screen.height <= 0)
+ return;
+
+ var safe = Screen.safeArea;
+ float scale = rootHeight / Screen.height;
+ float top = (Screen.height - safe.yMax) * scale;
+
+ var statusSpacer = _root.Q("status_bar_spacer");
+ if (statusSpacer != null)
+ statusSpacer.style.height = top;
}
}
}
diff --git a/examples/demo/Assets/Scripts/UI/Sections/AliasesSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/AliasesSectionController.cs
index d1f4f8f0b..b20c02ff3 100644
--- a/examples/demo/Assets/Scripts/UI/Sections/AliasesSectionController.cs
+++ b/examples/demo/Assets/Scripts/UI/Sections/AliasesSectionController.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using OneSignalDemo.ViewModels;
using UnityEngine.UIElements;
@@ -39,14 +38,14 @@ private VisualElement BuildSection()
section.Add(
SectionBuilder.CreatePrimaryButton(
- "ADD",
+ "ADD ALIAS",
"add_alias_button",
() => OnAddTap?.Invoke()
)
);
section.Add(
SectionBuilder.CreatePrimaryButton(
- "ADD MULTIPLE",
+ "ADD MULTIPLE ALIASES",
"add_multiple_aliases_button",
() => OnAddMultipleTap?.Invoke()
)
@@ -60,24 +59,13 @@ private VisualElement BuildSection()
private void RefreshList()
{
- _listContainer.Clear();
- var aliases = _viewModel.Aliases;
-
- if (aliases.Count == 0)
- {
- _listContainer.Add(SectionBuilder.CreateEmptyState("No Aliases Added"));
- return;
- }
-
- for (int i = 0; i < aliases.Count; i++)
- {
- if (i > 0)
- _listContainer.Add(SectionBuilder.CreateDivider(tight: true));
- var kvp = aliases[i];
- _listContainer.Add(
- SectionBuilder.CreateKeyValueItem(kvp.Key, kvp.Value, $"alias_{i}")
- );
- }
+ SectionBuilder.RenderPairList(
+ _listContainer,
+ _viewModel.Aliases,
+ "No Aliases Added",
+ "aliases",
+ loading: _viewModel.IsLoading
+ );
}
}
}
diff --git a/examples/demo/Assets/Scripts/UI/Sections/TrackEventSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/CustomEventsSectionController.cs
similarity index 82%
rename from examples/demo/Assets/Scripts/UI/Sections/TrackEventSectionController.cs
rename to examples/demo/Assets/Scripts/UI/Sections/CustomEventsSectionController.cs
index 0aad14fd3..6a82241de 100644
--- a/examples/demo/Assets/Scripts/UI/Sections/TrackEventSectionController.cs
+++ b/examples/demo/Assets/Scripts/UI/Sections/CustomEventsSectionController.cs
@@ -4,7 +4,7 @@
namespace OneSignalDemo.UI.Sections
{
- public class TrackEventSectionController
+ public class CustomEventsSectionController
{
private readonly AppViewModel _viewModel;
private readonly VisualElement _root;
@@ -12,7 +12,7 @@ public class TrackEventSectionController
public Action OnInfoTap;
public Action OnTrackEventTap;
- public TrackEventSectionController(AppViewModel viewModel)
+ public CustomEventsSectionController(AppViewModel viewModel)
{
_viewModel = viewModel;
_root = BuildSection();
@@ -23,8 +23,8 @@ public TrackEventSectionController(AppViewModel viewModel)
private VisualElement BuildSection()
{
var section = SectionBuilder.CreateSection(
- "Track Event",
- "track_event_section",
+ "Custom Events",
+ "custom_events_section",
() => OnInfoTap?.Invoke()
);
diff --git a/examples/demo/Assets/Scripts/UI/Sections/CustomEventsSectionController.cs.meta b/examples/demo/Assets/Scripts/UI/Sections/CustomEventsSectionController.cs.meta
new file mode 100644
index 000000000..18e004d44
--- /dev/null
+++ b/examples/demo/Assets/Scripts/UI/Sections/CustomEventsSectionController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1e85e0f98a331481a88a6a6ff46a9f7a
diff --git a/examples/demo/Assets/Scripts/UI/Sections/EmailsSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/EmailsSectionController.cs
index 31c5a1124..4336fb996 100644
--- a/examples/demo/Assets/Scripts/UI/Sections/EmailsSectionController.cs
+++ b/examples/demo/Assets/Scripts/UI/Sections/EmailsSectionController.cs
@@ -58,7 +58,11 @@ private void RefreshList()
if (emails.Count == 0)
{
- _listContainer.Add(SectionBuilder.CreateEmptyState("No Emails Added"));
+ _listContainer.Add(
+ _viewModel.IsLoading
+ ? SectionBuilder.CreateLoadingState("emails")
+ : SectionBuilder.CreateEmptyState("No Emails Added", "emails")
+ );
return;
}
@@ -73,7 +77,7 @@ private void RefreshList()
_listContainer.Add(
SectionBuilder.CreateSingleItem(
email,
- $"email_{i}",
+ "emails",
() => _viewModel.RemoveEmail(email)
)
);
diff --git a/examples/demo/Assets/Scripts/UI/Sections/InAppSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/InAppSectionController.cs
index ee8d14874..b9dfc4439 100644
--- a/examples/demo/Assets/Scripts/UI/Sections/InAppSectionController.cs
+++ b/examples/demo/Assets/Scripts/UI/Sections/InAppSectionController.cs
@@ -33,7 +33,7 @@ private VisualElement BuildSection()
var toggleRow = SectionBuilder.CreateToggleRow(
"Pause In-App Messages",
"Toggle in-app message display",
- "iam_paused_toggle",
+ "pause_iam_toggle",
_viewModel.InAppMessagesPaused,
OnPauseChanged
);
diff --git a/examples/demo/Assets/Scripts/UI/Sections/LiveActivitiesSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/LiveActivitiesSectionController.cs
index 00413d6dc..910a03d1c 100644
--- a/examples/demo/Assets/Scripts/UI/Sections/LiveActivitiesSectionController.cs
+++ b/examples/demo/Assets/Scripts/UI/Sections/LiveActivitiesSectionController.cs
@@ -13,6 +13,7 @@ public class LiveActivitiesSectionController
private Button _startButton;
private Button _updateButton;
private Button _endButton;
+ private Label _apiKeyHint;
public Action OnInfoTap;
@@ -48,7 +49,7 @@ private VisualElement BuildSection()
var orderNumberRow = CreateInlineInputRow(
"Order #",
"ORD-1234",
- "live_activity_order_input"
+ "live_activity_order_number_input"
);
_orderNumberField = orderNumberRow.Q();
inputCard.Add(orderNumberRow);
@@ -76,6 +77,11 @@ private VisualElement BuildSection()
);
section.Add(_endButton);
+ _apiKeyHint = new Label("Set ONESIGNAL_API_KEY in .env to enable update & end");
+ _apiKeyHint.name = "live_activity_api_key_hint";
+ _apiKeyHint.AddToClassList("hint-text");
+ section.Add(_apiKeyHint);
+
RefreshButtonStates();
return section;
}
@@ -90,14 +96,18 @@ private void RefreshButtonStates()
bool hasActivityId = !string.IsNullOrEmpty(_activityIdField?.value);
bool hasApiKey = _viewModel.HasApiKey;
- _startButton?.SetEnabled(hasActivityId && !_viewModel.IsLiveActivityUpdating);
+ _startButton?.SetEnabled(hasActivityId);
- bool canUpdate = hasActivityId && hasApiKey && !_viewModel.IsLiveActivityUpdating;
- _updateButton?.SetEnabled(canUpdate);
+ _updateButton?.SetEnabled(
+ hasActivityId && hasApiKey && !_viewModel.IsLiveActivityUpdating
+ );
if (_updateButton != null)
_updateButton.text = $"UPDATE \u2192 {_viewModel.NextStatusLabel}";
- _endButton?.SetEnabled(hasActivityId && hasApiKey && !_viewModel.IsLiveActivityUpdating);
+ _endButton?.SetEnabled(hasActivityId && hasApiKey);
+
+ if (_apiKeyHint != null)
+ _apiKeyHint.style.display = hasApiKey ? DisplayStyle.None : DisplayStyle.Flex;
}
private void OnStartTap()
diff --git a/examples/demo/Assets/Scripts/UI/Sections/LocationSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/LocationSectionController.cs
index 3d0d6cb17..46ccad632 100644
--- a/examples/demo/Assets/Scripts/UI/Sections/LocationSectionController.cs
+++ b/examples/demo/Assets/Scripts/UI/Sections/LocationSectionController.cs
@@ -49,6 +49,14 @@ private VisualElement BuildSection()
)
);
+ section.Add(
+ SectionBuilder.CreatePrimaryButton(
+ "CHECK LOCATION SHARED",
+ "check_location_button",
+ () => _viewModel.CheckLocationShared()
+ )
+ );
+
return section;
}
diff --git a/examples/demo/Assets/Scripts/UI/Sections/PushSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/PushSectionController.cs
index d21628aa8..4c9234bca 100644
--- a/examples/demo/Assets/Scripts/UI/Sections/PushSectionController.cs
+++ b/examples/demo/Assets/Scripts/UI/Sections/PushSectionController.cs
@@ -36,9 +36,9 @@ private VisualElement BuildSection()
var pushIdRow = SectionBuilder.CreateInlineKeyValue(
"Push ID",
_viewModel.PushSubscriptionId ?? "\u2013",
- "push_subscription_id"
+ "push_id"
);
- _pushIdLabel = pushIdRow.Q