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