From 156b0b80914e8f2bcfb63368208955afc7abdf57 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 7 May 2026 14:24:04 +1000 Subject: [PATCH 1/5] ci(audience): route all matrix jobs through set-matrix, harden Windows checkout (SDK-330) - Replaces the cross-product matrix (unity x target x backend with axis-matching include items) on `playmode` with a `set-matrix` helper job that emits a fully-specified JSON matrix. - The cross-product approach silently expanded to zero playmode cells on every run since SDK-327, so Windows and macOS PlayMode tests have not actually run on PRs (verified on the SDK-327 merge commit and on PR #748). Root cause: a unity-keyed include item that has no cell to augment after the conditional `exclude` removes Unity 2022 on PR runs spawns an orphan combination missing `target`, `backend`, and `runner`; `runs-on: ${{ matrix.runner }}` then evaluates to empty and GitHub aborts the matrix. - `set-matrix` runs on ubuntu-latest, defines the full 12-cell playmode matrix, 6-cell playmode-linux matrix and 6-cell mobile matrix inline as JSON, and uses jq to strip Unity 2022.3.62f2 cells when the trigger is pull_request. Schedule and workflow_dispatch get the full sets. - All three matrix-driven jobs now declare `needs: set-matrix` and consume `matrix.include: fromJSON(needs.set-matrix.outputs.)`. Each cell carries every key the steps need, so no axis-match augmentation step can silently drop keys, and the workflow graph shows all three jobs hanging off set-matrix in one place. - `mobile-build` PR runs drop Unity 2022.3.62f2 to match the playmode and playmode-linux trim. Schedule and workflow_dispatch keep all 3 Unity versions. Steps unchanged. - `playmode-linux` matrix moved from cross-product + conditional exclude to the same set-matrix-fed include pattern. Same 6 cells on schedule, same 4 cells on PR; behaviour unchanged. - Hardens the Windows pre-checkout cleanup. The previous Kill-stale step only covered Unity-family processes and slept 2 seconds, then handed off to actions/checkout@v4 which would die with EBUSY on stuck files in examples/audience. New step adds bee_backend and mono to the kill list, sleeps 3 seconds, and force-removes the workspace contents in a retry loop so checkout's own cleanup is left with nothing to do. Linear: https://linear.app/imtbl/issue/SDK-330 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/test-audience-sample-app.yml | 127 +++++++++++++----- 1 file changed, 90 insertions(+), 37 deletions(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 104f7d4dd..9263173ef 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -26,11 +26,67 @@ concurrency: cancel-in-progress: true jobs: - # Reduced matrix on pull_request, full matrix on schedule and - # workflow_dispatch. The self-hosted Windows runner pool is small, so - # trimming PR cells keeps PR feedback fast. `matrix.exclude` below is - # the source of truth for which cells are dropped on pull_request. + # The playmode, playmode-linux and mobile-build matrices are built here + # and consumed by the dependent jobs via fromJSON. PR runs trim Unity + # 2022.3.62f2 cells; schedule and workflow_dispatch run the full set. + set-matrix: + runs-on: ubuntu-latest + outputs: + playmode: ${{ steps.set.outputs.playmode }} + playmode_linux: ${{ steps.set.outputs.playmode_linux }} + mobile: ${{ steps.set.outputs.mobile }} + steps: + - id: set + shell: bash + run: | + playmode_full='[ + {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","Windows","X64"]}, + {"target":"StandaloneWindows64","backend":"Mono2x","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","Windows","X64"]}, + {"target":"StandaloneOSX","backend":"IL2CPP","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","macOS","ARM64"]}, + {"target":"StandaloneOSX","backend":"Mono2x","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","macOS","ARM64"]}, + {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","Windows","X64"]}, + {"target":"StandaloneWindows64","backend":"Mono2x","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","Windows","X64"]}, + {"target":"StandaloneOSX","backend":"IL2CPP","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","macOS","ARM64"]}, + {"target":"StandaloneOSX","backend":"Mono2x","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","macOS","ARM64"]}, + {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","Windows","X64"]}, + {"target":"StandaloneWindows64","backend":"Mono2x","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","Windows","X64"]}, + {"target":"StandaloneOSX","backend":"IL2CPP","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","macOS","ARM64"]}, + {"target":"StandaloneOSX","backend":"Mono2x","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","macOS","ARM64"]} + ]' + playmode_linux_full='[ + {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"2021.3.45f2"}, + {"target":"StandaloneLinux64","backend":"Mono2x","unity":"2021.3.45f2"}, + {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"6000.4.0f1"}, + {"target":"StandaloneLinux64","backend":"Mono2x","unity":"6000.4.0f1"}, + {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"2022.3.62f2"}, + {"target":"StandaloneLinux64","backend":"Mono2x","unity":"2022.3.62f2"} + ]' + mobile_full='[ + {"target":"Android","unity":"2021.3.45f2","method":"AndroidBuilder.Build"}, + {"target":"Android","unity":"2022.3.62f2","method":"AndroidBuilder.Build"}, + {"target":"Android","unity":"6000.4.0f1","method":"AndroidBuilder.Build"}, + {"target":"iOS","unity":"2021.3.45f2","method":"IosBuilder.Build"}, + {"target":"iOS","unity":"2022.3.62f2","method":"IosBuilder.Build"}, + {"target":"iOS","unity":"6000.4.0f1","method":"IosBuilder.Build"} + ]' + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + filter='[.[] | select(.unity != "2022.3.62f2")]' + playmode=$(jq -c "$filter" <<<"$playmode_full") + playmode_linux=$(jq -c "$filter" <<<"$playmode_linux_full") + mobile=$(jq -c "$filter" <<<"$mobile_full") + else + playmode=$(jq -c '.' <<<"$playmode_full") + playmode_linux=$(jq -c '.' <<<"$playmode_linux_full") + mobile=$(jq -c '.' <<<"$mobile_full") + fi + { + echo "playmode=$playmode" + echo "playmode_linux=$playmode_linux" + echo "mobile=$mobile" + } >> "$GITHUB_OUTPUT" + playmode: + needs: set-matrix if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || github.event_name == 'schedule' @@ -39,21 +95,7 @@ jobs: strategy: fail-fast: false matrix: - unity: ['2021.3.45f2', '6000.4.0f1', '2022.3.62f2'] - target: [StandaloneWindows64, StandaloneOSX] - backend: [IL2CPP, Mono2x] - include: - - unity: '2021.3.45f2' - changeset: 88f88f591b2e - - unity: '6000.4.0f1' - changeset: 8cf496087c8f - - unity: '2022.3.62f2' - changeset: 7670c08855a9 - - target: StandaloneWindows64 - runner: [self-hosted, Windows, X64] - - target: StandaloneOSX - runner: [self-hosted, macOS, ARM64] - exclude: ${{ fromJSON(github.event_name == 'pull_request' && '[{"unity":"2022.3.62f2"}]' || '[]') }} + include: ${{ fromJSON(needs.set-matrix.outputs.playmode) }} runs-on: ${{ matrix.runner }} # Healthy cells finish in ~10 min. 30 min covers cold caches + # IL2CPP + Unity 6 startup; anything past that is a hang. Capping @@ -62,25 +104,43 @@ jobs: timeout-minutes: 30 steps: - - name: Kill stale Unity processes (Windows pre-checkout) + - name: Clean Windows workspace (pre-checkout) if: runner.os == 'Windows' shell: pwsh continue-on-error: true run: | - # actions/checkout@v4 deletes the prior workspace before cloning. If a - # previous run's Unity Editor / IL2CPP build process is still holding - # handles inside examples/audience, checkout dies with EBUSY. Kill any - # leftover Unity-family process here so checkout's cleanup succeeds. + # actions/checkout@v4 removes the prior workspace before cloning. If + # a previous run's Unity build / IL2CPP linker / bee_backend / shader + # compiler is still holding handles, checkout dies with EBUSY on + # examples/audience. Kill known offenders, then force-remove the + # workspace contents ourselves so checkout's cleanup succeeds. Get-Process | Where-Object { $_.Name -like 'Unity*' -or $_.Name -like 'il2cpp*' -or $_.Name -like 'UnityShaderCompiler*' -or - $_.Name -like 'UnityCrashHandler*' + $_.Name -like 'UnityCrashHandler*' -or + $_.Name -like 'bee_backend*' -or + $_.Name -like 'mono*' } | ForEach-Object { Write-Host "Killing stale process: $($_.Name) (pid $($_.Id))" Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } - Start-Sleep -Seconds 2 + Start-Sleep -Seconds 3 + + $ws = "$env:GITHUB_WORKSPACE" + if (-not (Test-Path $ws)) { return } + for ($i = 1; $i -le 6; $i++) { + try { + Get-ChildItem -Path $ws -Force -ErrorAction Stop | + Remove-Item -Recurse -Force -ErrorAction Stop + Write-Host "Cleaned $ws on attempt ${i}" + return + } catch { + Write-Host "Attempt ${i}: $($_.Exception.Message)" + Start-Sleep -Seconds 3 + } + } + Write-Host "::warning::Workspace not fully cleaned; checkout may fail" - uses: actions/checkout@v4 with: @@ -406,6 +466,7 @@ jobs: examples/audience/Logs/** playmode-linux: + needs: set-matrix if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || github.event_name == 'schedule' @@ -415,10 +476,7 @@ jobs: strategy: fail-fast: false matrix: - unity: ['2021.3.45f2', '6000.4.0f1', '2022.3.62f2'] - target: [StandaloneLinux64] - backend: [IL2CPP, Mono2x] - exclude: ${{ fromJSON(github.event_name == 'pull_request' && '[{"unity":"2022.3.62f2"}]' || '[]') }} + include: ${{ fromJSON(needs.set-matrix.outputs.playmode_linux) }} steps: - uses: actions/checkout@v4 @@ -468,6 +526,7 @@ jobs: # Scope: IL2CPP compile pipeline only. Runtime tests require a real device and # are out of scope until a device farm is available. mobile-build: + needs: set-matrix if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || github.event_name == 'schedule' @@ -477,13 +536,7 @@ jobs: strategy: fail-fast: false matrix: - target: [Android, iOS] - unity: ['2021.3.45f2', '2022.3.62f2', '6000.4.0f1'] - include: - - target: Android - method: AndroidBuilder.Build - - target: iOS - method: IosBuilder.Build + include: ${{ fromJSON(needs.set-matrix.outputs.mobile) }} steps: - uses: actions/checkout@v4 From af03b5da8f65155481bc34b7424f11142ac84b37 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Thu, 7 May 2026 22:22:39 +1200 Subject: [PATCH 2/5] feat(audience-sdk): add Android Play Install Referrer support (SDK-310) Captures the Google Play install referrer via the vendored installreferrer-2.2 AAR and ships it as a dedicated install_referrer_received event (decoupled from game_launch). The async fetch typically completes after the first Init has fired, so the event lands on the next launch from the on-disk cache; an idempotency marker ensures it fires exactly once per install. Both the build-time AUDIENCE_MOBILE_ATTRIBUTION scripting define and the runtime AudienceConfig.EnableMobileAttribution flag must be set for the fetch to run. The Init-time consent gate blocks the network call when consent is None, regardless of the flag. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/README.md | 12 + .../Audience/Runtime/Core/AudiencePaths.cs | 8 + .../Audience/Runtime/ImmutableAudience.cs | 59 ++++- .../Audience/Runtime/Plugins/Android.meta | 8 + .../Plugins/Android/installreferrer-2.2.aar | Bin 0 -> 7948 bytes .../Android/installreferrer-2.2.aar.meta | 33 +++ .../Runtime/Plugins/Android/proguard-user.txt | 11 + .../Plugins/Android/proguard-user.txt.meta | 7 + .../Runtime/Unity/AudienceUnityHooks.cs | 25 +++ .../Unity/Mobile/InstallReferrerBridge.cs | 211 ++++++++++++++++++ .../Mobile/InstallReferrerBridge.cs.meta | 11 + src/Packages/Audience/Runtime/Utility/Log.cs | 8 + .../Tests/Runtime/ImmutableAudienceTests.cs | 198 +++++++++++++++- .../Unity/InstallReferrerBridgeTests.cs | 169 ++++++++++++++ .../Unity/InstallReferrerBridgeTests.cs.meta | 11 + 15 files changed, 765 insertions(+), 6 deletions(-) create mode 100644 src/Packages/Audience/Runtime/Plugins/Android.meta create mode 100644 src/Packages/Audience/Runtime/Plugins/Android/installreferrer-2.2.aar create mode 100644 src/Packages/Audience/Runtime/Plugins/Android/installreferrer-2.2.aar.meta create mode 100644 src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt create mode 100644 src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt.meta create mode 100644 src/Packages/Audience/Runtime/Unity/Mobile/InstallReferrerBridge.cs create mode 100644 src/Packages/Audience/Runtime/Unity/Mobile/InstallReferrerBridge.cs.meta create mode 100644 src/Packages/Audience/Tests/Runtime/Unity/InstallReferrerBridgeTests.cs create mode 100644 src/Packages/Audience/Tests/Runtime/Unity/InstallReferrerBridgeTests.cs.meta diff --git a/src/Packages/Audience/README.md b/src/Packages/Audience/README.md index 81891a954..936bdb801 100644 --- a/src/Packages/Audience/README.md +++ b/src/Packages/Audience/README.md @@ -47,6 +47,18 @@ Press Play; `ImmutableAudience.Initialized` returns `true` and `AnonymousId` bec - Integration guide and API reference: - Sample Unity project: [`examples/audience`](https://github.com/immutable/unity-immutable-sdk/tree/main/examples/audience) +## Vendored dependencies + +The package vendors prebuilt third-party AARs for mobile attribution. They are only included in your build when the corresponding scripting define is set; without the define they're stripped via `defineConstraints` on the plugin meta files. + +| File | Version | Source | Required define | +| --- | --- | --- | --- | +| `Runtime/Plugins/Android/installreferrer-2.2.aar` | 2.2 | [maven.google.com](https://maven.google.com/web/index.html#com.android.installreferrer:installreferrer:2.2) | `AUDIENCE_MOBILE_ATTRIBUTION` | + +`Runtime/Plugins/Android/proguard-user.txt` ships explicit R8 keep rules for the Install Referrer Library. Unity's gradle build merges it automatically when the AAR is included. + +Before tagging a release, check `maven.google.com` for newer versions of any vendored dependency and bump the pinned filename if needed. + ## License See the repository [LICENSE](https://github.com/immutable/unity-immutable-sdk/blob/main/LICENSE.md). diff --git a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs index 52fb40c33..c8cc00528 100644 --- a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs +++ b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs @@ -8,6 +8,8 @@ internal static class AudiencePaths private const string IdentityFileName = "identity"; private const string ConsentFileName = "consent"; private const string QueueDirName = "queue"; + private const string InstallReferrerFileName = "install_referrer"; + private const string InstallReferrerSentFileName = "install_referrer_sent"; internal static string AudienceDir(string persistentDataPath) => Path.Combine(persistentDataPath, RootDirName); @@ -20,5 +22,11 @@ internal static string ConsentFile(string persistentDataPath) => internal static string QueueDir(string persistentDataPath) => Path.Combine(AudienceDir(persistentDataPath), QueueDirName); + + internal static string InstallReferrerFile(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), InstallReferrerFileName); + + internal static string InstallReferrerSentFile(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), InstallReferrerSentFileName); } } diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index f01285294..f6b531513 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -63,6 +63,13 @@ public static class ImmutableAudience // non-iOS platforms (the public API resolves to NotDetermined). internal static volatile Func>? TrackingAuthorizationRequestProvider; + // Called during Init when config.EnableMobileAttribution is true. + // Returns the cached Android Play Install Referrer string, or null if + // not yet cached (first launch, async fetch may complete after + // game_launch fires) or none exists for this install. Set by the Unity + // layer; null in pure-C# environments and on non-Android platforms. + internal static volatile Func? MobileInstallReferrerProvider; + // Active session. Created at Init (or on upgrade from None) and disposed // on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see // assignments from SetConsent without taking _initLock. @@ -220,12 +227,14 @@ public static void Init(AudienceConfig config) sessionToStart?.Start(); // Consent gate before invoking attribution providers: SKAN - // registration is a network side effect and IDFA / ATT status - // reads are privacy-sensitive. CanTrack() == false (consent - // None) means we have no licence to do either, regardless of - // whether EnableMobileAttribution is set in config. + // registration and Install Referrer fetch are network side + // effects, and IDFA / ATT status reads are privacy-sensitive. + // CanTrack() == false (consent None) means we have no licence + // to run any of them, regardless of whether EnableMobileAttribution + // is set in config. bool? skanRegistered = null; IReadOnlyDictionary? attributionContext = null; + string? installReferrer = null; if (config.EnableMobileAttribution && consentAtInit.CanTrack()) { try { skanRegistered = MobileAttributionProvider?.Invoke(); } @@ -233,9 +242,20 @@ public static void Init(AudienceConfig config) try { attributionContext = MobileAttributionContextProvider?.Invoke(); } catch (Exception ex) { Log.Warn(AudienceLogs.MobileAttributionContextProviderThrew(ex)); } + + try { installReferrer = MobileInstallReferrerProvider?.Invoke(); } + catch (Exception ex) { Log.Warn(AudienceLogs.MobileInstallReferrerProviderThrew(ex)); } } FireGameLaunch(config, consentAtInit, skanRegistered, attributionContext); + + // Fires once per install. installReferrer lands asynchronously + // from Google Play Services; on the first launch the cache is + // usually still empty when game_launch fires, so we ship a + // dedicated event after Init when the value first becomes + // observable. Idempotent across launches via an on-disk marker. + if (!string.IsNullOrEmpty(installReferrer)) + FireInstallReferrerReceivedOnce(config, installReferrer!); } // Pause/Resume hooks for the Unity lifecycle bridge. @@ -1120,5 +1140,36 @@ private static void FireGameLaunch( // via eventTimestamp with the session_start that fires just before. Track("game_launch", properties.Count > 0 ? properties : null); } + + // Fires install_referrer_received exactly once per install. Cache + // file presence alone isn't enough — on first launch the bridge may + // write the cache after Init has already run, so the event must be + // dispatched at the next Init that observes a cache hit. The on-disk + // "sent" marker provides idempotency across that boundary. + private static void FireInstallReferrerReceivedOnce(AudienceConfig config, string installReferrer) + { + var sentFile = AudiencePaths.InstallReferrerSentFile(config.PersistentDataPath!); + if (File.Exists(sentFile)) return; + + Track("install_referrer_received", new Dictionary + { + ["installReferrer"] = installReferrer, + }); + + try + { + var dir = Path.GetDirectoryName(sentFile); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(sentFile, string.Empty); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + // Marker write failed — the event will re-fire on the next + // launch. Pipeline-side dedup or the cost of one duplicate is + // less bad than never sending the event at all. + Log.Warn(AudienceLogs.InstallReferrerSentMarkerWriteFailed(ex)); + } + } } } diff --git a/src/Packages/Audience/Runtime/Plugins/Android.meta b/src/Packages/Audience/Runtime/Plugins/Android.meta new file mode 100644 index 000000000..a42353531 --- /dev/null +++ b/src/Packages/Audience/Runtime/Plugins/Android.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/Plugins/Android/installreferrer-2.2.aar b/src/Packages/Audience/Runtime/Plugins/Android/installreferrer-2.2.aar new file mode 100644 index 0000000000000000000000000000000000000000..ea4f702e58e265f9007a3a6270392b41cdee3f14 GIT binary patch literal 7948 zcmaKxRZtyDv#4=r;XxPf?(QzZ-QC^Y-Q9vN+zAp0?zV8Z;1Ghl6LPc9zPIjqIRELX znVy+ysqUAVs!v4$8U`Bz0s`UhBonmhnlQWiy9O`UA5EZser zeHK-D-+QkT`hSNTbO!Xps-pjE28*hHfT^H1}L{3-l> zXz^4I{}@}C!9^FGRLCo#zA2r{e6%{(&>oOokBKV%O*S;~I|%RdLA{+?5aPgyxJsZH zD)zLWhCWmO!yrYh?g0aLR(NMLKwTIUxgG@;4=$O>FS)SA!81Iafvmpz#Cc1jg<=Jq zp%~mdwtX8c39)196TwY5Gh8$c=+WX-#La^giax8mfHPokGavsDc>N~H@^VxEno>zG zl2!S8PX*`J@KYpXjoPQa*?+W`@ zsWE%_c>H7XZwqnVQdIRV5yNANARv@2As~?dnwUG7y1QGtGuxTEZ3+1Y=xh7#WLhX> z9cI7B?Zg>3QB5Y@DdlIM(>9ptdNGaIB8_Ao5y~VocBJKVMke0kg=Y$xCS!AhLp()) z5y+mGnYQ-Z_$D)?sjhWj2|USmc6P2dQCXV1`%xA6&%Vs??YwLs z3$$~j`-S6|^%ZoMI@|iaNI`}$BX1+39`u1+I5R#6J@gs96K$^}CqiX3y-B0PRQWP` z@Np8Mj$}nu;u2#G(NX+@{WZ0XI%0OCL5mYR7PXaIBu$IePZZNgJ-B}&tASl?riQH@ zRh0`d-BR16VWFEJPf3BS%C^Z!7qV4}D)*tfiQyWoj(6>%`hn>!*Y*vv~5 zOQjC7;UqIc$3kRdBhrgS@h|spdEfTo40FH0S4EGM3JHf5--t+Xqb-yACPb5Kr_4x{ zNJG(7PRHiS)=S&}5f)_*lMbQf%qY#{5@yysICow&xvG17B?4ZQ_tzFI^IECLZb@4KWrM){gj7aq{3sM?Qc&^2aX)Z}bZNrX|+k zh+(53PR#hSB;>i3MqPUofUc=vZ;V3K6-iDD`RSC^aKT92IAfz|w=@3R_meq7De|P2iSpsjtl^0fyL(}yCx%9`1g7wi?71L(y%ev&)SO?&hJQOXFb9?D zXGw~KWc&rG{@N`a&M9$@tY6o02{#H{h>>57zgli7K$J7Y8QEgYBCn!UI1-Z)gtzCw zuxppeW?ExCxD??X<4D*f$!PCY7rwmL4Xeq6H;Y86;0qLKH23}yQ6o22s}hLokPguQ z&Ckha)EQs5H@s3mgPWsbV;&|trq{mjEs#}D>L3b@ApJ{n4`QvaV5rR$6&p$A(xqW& zhR|TmyJ^FKUV9r5dS6gK%lzilBXJ{!i)dhISViX*#O=u4}5i&`5?F|%+vdGvIV z9KjlvM&71{S^FW&R8u=!a5A5$HR$A$Q)R}r#q&`cO^nY*^UQ-`PvHRmtv;JWetjCa zpi*!`t#N&Yeq!oj9iCn{nDv=;MU*ub+lx>Fn<5)w_j1SSxT-R|~szN-~2J^;gMu%g(ndzkMhEmHgsZuhxUOnN1+2Fwo%;i(< zDV^O`#dbc0cX~8_WO<%Gb+7j>Z4D1r(bkrYYlDIfjTE^wje@K>?NW(-HPDGBsF;Iw zKp=`SA>}AZnt1R_V|(6`m4*%Ckz)F~5&|QCbm9mt#-Jpia-#zI~$>A1O7ECQ9Hcc>Xjb(# zw}fv!Gc{Krs$?2SexT=cy5#C!LEH$Jy3QlOexER!sP;wYfue}d0kZgPzT}r~_37~N zk*vnJr`zZsoCSraCUgMBx^#F*1j?&CD$= zSijBDcYGJyxz)uSGm}EfvS#6i0C_3JRW}|^^3z?ux8;g7P_Ij{In4lLC>2@zg3Lc- zsJ(LkQK9J>=PfhdY^o4jy_VrocA*z9IaY$&W-h5+r+6Ykg9Y|8eOd8v4>Yw*`~Ylx z&DwsG0TiaHF;JyNJA3}M-}qy$nfEk3*L=H4)*;orP0Vxsv&D?Zoy_As$1vb*l~~C; z0q+!JcihT-(0Y=V_ikLG(-8*y^G}-qTcyv8!|5@_hV;L*j6;I(ScjOAxC=p$j3WmM zs%#ud$h;{E+R^i7xL8dpUV_`Pg8td1Ei@^5hOC36rvI0WxnUefU&Qq34uObCk;19CBA7t4PtitdPQC zZK8r%t3q)hi?mN^xUnrLzm3S-N?oR)3PMW0k_`n{l25o~!FFAagbh0dei8ge3mxUa z6*Vfv}}rEn?jyJ!juD9z|dtvkGvN3daa83&<}IB zJP%2tLp&4PxUpIRIxVBbhE~Uvk)bmhVB4stjaT;}UN>w#4?F1E&8a~RuRCgRce9C;cl&7k(%4hh0a!4pjKr`K#tlR(TP<2k+eofYaBXj*Q|? zCZ+AY3cj%^c1>qP{&@=wIz1U{2{;G9q>oMx{t$PpL+u3&x?zv%5ffRtD0EVt

~Yed6O+^Jl5 zW;OH8N=jOL6sPW_q?Mah_<+!i*Kexl(v9n}?ho}ltis*yRT6~Gz}^?#S=-t3I%}^& z33f4|xVoJTTUDSs!zO}t zHM^1*U0ma7kgmD?YHf2>oD$DMPv(OQ@lieHbZ(Bku_Ycf+B(ibzk%K0(|S@ z#1(Oi&>f5CV?@Q6p}mD*GqobrOHbV(U%JZ}!ClW0Ode3Y4T%_9TP^F6Ba1G8I#h!H z2_m+S{wWtDE2s6Ev9YW{j|^()(|3eq(2st)1+y~Hgqa=!3(}+pnx$kkm~xIbL53xM zdoA)ihL(ze3uZAPwuq)J=J<-}HKMFm|3QixJWH*8#3VwHlsG|s5l&(8Z~;R|$t=1# z&o<+hkAjki5;-HqyP&-P&CRk3XLYfluqI}q^1$ZQwU&>-N(`lS38*$yLy!z}y?9r8 z-Zs)(Ui=bQ*2kVHUUH~U7W@A4jVR{5{LiM5u|W4Kit-T40hn3REIzc=) zNN>V8(wJiV&69Glm7SdIJ9;ux$+bd5Gx|{Evduj`xcZ4K+6pqoLs5YpdmMS*=Na>E zF&N8`gd9nr6zR>4Qf^cBkymcqB}zK#yecRS=6G&y8x-z;;)~ssCCc~mY)3gD`nwIn z_V6$oUdvoIFO~MhP9RJdyCbEKS`>|b7Kd%1GfNu$EzDs~=MGx7kR5Kf=7WX@r&@q@26w zyB7Nzja=!ADNXGIN#&yDn=&kZTpUble5AX#c#SWP>4%2HP)m2xhO;8uB7}A(OTQ_Z zigB4SqtF*WqlmX0q?DlXrR#H~)s^mE@}B#rXU6V`4dZVZ=I^t4ds5F~5O3Is&|bs2 zot_wCOes26pIQ+=4vBT)8xPB9zcxE?Q9iU9k9=G^Rcv(3ywV3GYMiewduG)&V0>Ux zZCc6^$!-z^OA`JmHDCl!SiACL2Kk+q*% zjzkk=jIZRNLRGX7jUQYEWQg$KmxOJ-HpoFr0bsUXA7&%NKQh z66*LBM8bm06m5XMz32MiCXS|JWB4GiW}O`D@D{!m&?Kl2RtRuR!0R+njP2%rqXLSw zJSnD`+*JKww!CQNbrM%zZ&8Wfp=b=j>3i&jvWz-=J*)dI3_ge4^UR8DApyuvN0J;I zPF}y2nWF*>q4TCK1l$Of`2W5DtpF$VIdjJ->-BJWr=hYI1HU;f=B>Q6t)YF+wjN|O zCDaXq6*3>(pYP22-=^CDXsDfkKdIOQ{njPc~a*<@3Ubx>gRN5Sm%TaGKj|oZt zEq1sNr1L!X_jOlp81e+825%o5M2om>ILVf|+M_z{MB-LfjbR)EeRSc^u1Ajhe-6zD z(1qV;!x{Grzhb10?P?0A0OUo7f;9!fTagGm;RqxW5z6~5fodw9%w`eVJYTa47p9Dd z^NfG;*%}W-T}`pB_W24q=O#+|V5?7N!56jf52z6;x)nmCf94Mc{Jk>xKHK{w%(!7tZh@=qH?Ln( zV`h<}wne@?N2I0wd@Ylcas@|(d+YU#B8_}FJubVg2Y+~ml$>0WgEeg_^i;>%N44ql zGoVgp^@GVo-;{#^{hWFDbYyIyWk}u~Dva-1nGxIjn+s zyG%6uDUQ8|NmGvs_-4|5>}AoV;z&_a>WWI5fI{E4=)wM%@EF7in|x!jSwABru8Ot2 z%FF4fnMX8f_?XYoz zapbz(guoaQQQ1MooCghlCh&1m4P&ivYj#MZe~8d%9A;S(8&6~{`BNrVIjexoEe z2T0O}uRzUtl)(Ik*YAxjk(uP@1Q~P&(L(!#pv^jm(34#Bq(8!;LJ-LOfI8q_=j zS%HWXI3Le`4?q8t+^Ok0J%hGshn`l$NVB3=!^6iU>A~ zomm95qNvx(6DXcI6Cvl>`908F+H%{TqcK!L-jHNp%agB#D9W_m?spB>+7n&o>l@eU zs@;+Z1ia?iYU98!&&*dDu3aA6evt~*KB>|VSJd}b``u%hp+6G_^^YmPLyPid-WNS@ ziS-=sw%8)W&S>3g>#tkHA|2*TtxrPb2?=hfyu8MUpl@k>X(J*hyJfnuzcAoN@X(hLk`y9I?#7C1wh?eK@{;p6pb*W#Dm zfkiC>;HYmA@|#wv--zOPy;xy~Tpmm;G8vM77TJP0t;I6CKzW_AW-S<7UOqD>A8v`z zJ?&?8zh;@Cn_US3QoL#;Aj_$_bm!;>7W&~b-t3%Qw{5nWs{XX_JYc^mVgH8@n@lYK7l)Tpb6czm z4RUOSs6o07=!Y6k&5n`hK-_zMT6d_9jK^+{CKd)>+Zyg>iv&cr0Itvaei92kd;S(# z$%l6VhHXLDX5>MUmnAga1&glmIG81Nj2YP0m^U;H;OWr(CEj(uu{ebs2S!-S1*mXt&M=NZkH4qNTTCvaCF zD8FmIJ@go^#74I?*-u*p;OV1Zxdw{42$-s2hcw&)S!tW#{?RRIxeFA%FtOdT5=f z@2q%Y(No`tWHAEhD6-+C5nE%-g{y*y=~_!5PCa=Yx>ow>U@ta1)-ZJ(>{jC@LVvTBOJIqy}D-x`v_8y^l#x7*Rbt&(R>N`BEo=13tEmcc z>ZCjnx=R_i(NM!fd74Uvcv@5S=Og@YAE*a@co}a26S{7twN^QdP zG6QbV7xAX~*tH)KvQ5wptT>~iu$u?Pm++KooTCZ&+$1&S#aIW+E?B2jQSm)+fI0)_}Y z1b{X^=L>fMube8tY%CmF^@WQHpDy5xHfRtu*2cpNBLNr2$(Uw-^aIeecQrLP6=37> zyd6=YK!9I>fRfx;2e#)G~xpaSVU#_46C1rd;eB9Ij;x%>m4ycbjeZyP6nE>UZNE5SxK&E z?ODTi-CI`M#KOewX45$wPIIGrUh~qCsoZQy66>iYdc%mTz+*cK2=F-6qY*(B2*hSRz2P&C z@*)xTa+ysNzd-5p7Hsq!uNk_^GYVMb>U`HW8DdPQH`;v#$`xZpp>EWdQP5QiwyFmUb zK3YOGSK&xw>qhOVImg*fOmnB7)tAI$AK(a-vR@K+wsM$m_iH~XsGanEdps2EG{>Qp z^5dR24J==Q1X3<|r^@bFeN}$2UhB?5T`_-73+uA(3+woD1TjqR7n^MSM|=RTzFqR>IaOn54eeTtNqq{ZO%$lJMiwE7^@qRnveZ+ z{rJYeEtSe1Yqgxe13qFI`Gn&3!Rivc45p3D5p(+$lVhKGT+0&p+gyO+nPHIJ z)JEGk%qe>{*v|)i@l^N-6DFZvSt;H$4$GG7cJm{Y;xQGpm1#9vyE zj>OEgdaMvw9sbOjQ9|@W@mTJ%_RaYh2z1xrLSFc}V*jS<>~@??o72yC{ac1?Z1z(J zGq8qJ`-?R-tYNg#+tJxcpY@J(QL}av10lWfNrB_D1ZW&LfT2sRcb0#1Is7%PdrNN} zbPH@>2NdyaXY*{+!xsmz*?2)T=EeIdRy9)`Aw#qPdpbH{8lg43DgzD-+qy_rGwy$T zgOf5FsVBYOGdth@l&~M!WH=j)C;hCRDcR{OKL4DX_2uGx5?(&Ftma%fHz)VvsviWk z$eC$K+hWmdsH2Qs#8G;#nENB;;v)WXS{;~?lHs4AD|^M?^O97GndbIJj%@od^)nA& zUKZ?P{z>iO{x^~^{aEq+d5ifkw5;t}?Z<}JAI&{|w=QxQyq06~L!_HG_RbnSSXR`N zYkxqcfIoo%&G|eM&_Xcue}St#k>IRBTU{{Ni+6G{Cq`~Py0`0MiDxa$9w>z{)D6?BLTu?OJ)D-Ra# RpIex}W9P5NCI091e*i6i#q Application.persistentDataPath; // Captured once on main thread; ReadOnlyDictionary blocks downstream mutation. @@ -34,9 +41,27 @@ private static void Install() ImmutableAudience.TrackingAuthorizationRequestProvider = () => ATTBridge.RequestAsync(); #endif +#if UNITY_ANDROID && !UNITY_EDITOR + ImmutableAudience.MobileInstallReferrerProvider = ProvideInstallReferrer; +#endif + UnityLifecycleBridge.EnsureExists(); if (Log.Writer == null) Log.Writer = Debug.Log; } + + // Warms the install referrer cache for the next launch and returns + // the currently cached value if any. Returns null on first launch + // (cache miss while async fetch is in flight) or when the device + // reports no referrer for this install. Exceptions propagate to + // ImmutableAudience.Init's MobileInstallReferrerProviderThrew handler. + private static string? ProvideInstallReferrer() + { + var path = _persistentDataPath; + if (string.IsNullOrEmpty(path)) return null; + + InstallReferrerBridge.EnsureFetchStarted(path!); + return InstallReferrerBridge.GetCachedInstallReferrer(path!); + } } } diff --git a/src/Packages/Audience/Runtime/Unity/Mobile/InstallReferrerBridge.cs b/src/Packages/Audience/Runtime/Unity/Mobile/InstallReferrerBridge.cs new file mode 100644 index 000000000..47eb5625b --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/Mobile/InstallReferrerBridge.cs @@ -0,0 +1,211 @@ +#nullable enable + +using System; +using System.IO; +using System.Threading; +using Immutable.Audience; +#if UNITY_ANDROID && AUDIENCE_MOBILE_ATTRIBUTION +using UnityEngine; +#endif + +namespace Immutable.Audience.Unity.Mobile +{ + ///

+ /// Bridge to Google Play's Install Referrer Library. The referrer + /// describes where the install came from (Play Store campaign, organic, + /// deep link, etc.) and is the highest-value attribution signal on + /// Android. + /// + /// The Install Referrer service returns the same value for the lifetime + /// of the install (until uninstall), so we fetch once and cache to disk + /// (file missing = not yet fetched; file present = terminal state, with + /// empty content meaning known-no-referrer). First app launch likely + /// won't include the referrer in game_launch (the async fetch + /// usually completes after the launch event has fired); subsequent + /// launches read from cache. + /// + internal static class InstallReferrerBridge + { + // Test seams. Tests inject without touching disk or the Android API. + internal static Func ReadCachedImpl = ReadCachedFromDisk; + internal static Action StartFetchImpl = StartFetchNative; + + // Per-process gate so a second EnsureFetchStarted call during a + // single session doesn't double up the Android Java connection. + private static int _fetchStarted; + + /// + /// Returns the cached install referrer, or null if not yet fetched + /// or the device reported no referrer. + /// + internal static string? GetCachedInstallReferrer(string persistentDataPath) + { + if (string.IsNullOrEmpty(persistentDataPath)) return null; + return ReadCachedImpl(persistentDataPath); + } + + /// + /// Starts the async fetch from Google Play if no terminal cache entry + /// exists yet. Idempotent within a process; idempotent across launches + /// once a terminal state is cached. + /// + internal static void EnsureFetchStarted(string persistentDataPath) + { + if (string.IsNullOrEmpty(persistentDataPath)) return; + if (Interlocked.CompareExchange(ref _fetchStarted, 1, 0) != 0) return; + + // If we already have a terminal cache entry, skip the fetch. + // The cache survives across launches; once written it never + // changes (Google's referrer is stable for the install). + if (File.Exists(AudiencePaths.InstallReferrerFile(persistentDataPath))) + return; + + try + { + StartFetchImpl(persistentDataPath); + } + catch (Exception) + { + // Re-arm the gate so a later EnsureFetchStarted retries. + Interlocked.Exchange(ref _fetchStarted, 0); + throw; + } + } + + // Test-only reset; production code never calls this. Lets fixtures + // start each test from a clean state (cache file + in-process gate). + internal static void ResetForTesting() + { + Interlocked.Exchange(ref _fetchStarted, 0); + } + + private static string? ReadCachedFromDisk(string persistentDataPath) + { + try + { + var path = AudiencePaths.InstallReferrerFile(persistentDataPath); + if (!File.Exists(path)) return null; + var content = File.ReadAllText(path); + return string.IsNullOrEmpty(content) ? null : content; + } + catch (Exception) + { + return null; + } + } + + // Writes a terminal cache entry. Empty string marks "fetched, no + // referrer" so subsequent launches don't re-call the service for a + // permanent no-data state (organic install, FEATURE_NOT_SUPPORTED, + // etc.). Transient errors must NOT call this. + internal static void WriteCacheEntry(string persistentDataPath, string referrerOrEmpty) + { + try + { + var path = AudiencePaths.InstallReferrerFile(persistentDataPath); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var tmp = path + ".tmp"; + File.WriteAllText(tmp, referrerOrEmpty); + if (File.Exists(path)) File.Delete(path); + File.Move(tmp, path); + } + catch (Exception) + { + // Cache miss costs one extra service call next launch. + } + } + +#if UNITY_ANDROID && AUDIENCE_MOBILE_ATTRIBUTION + // Response codes from com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse. + private const int ResponseOk = 0; + private const int ResponseServiceUnavailable = 1; + private const int ResponseFeatureNotSupported = 2; + private const int ResponseDeveloperError = 3; + private const int ResponseServiceDisconnected = 4; + private const int ResponsePermissionError = 5; + + private static void StartFetchNative(string persistentDataPath) + { + // currentActivity is the standard Unity → Android entry point. + // The Install Referrer client binds to Google Play and calls + // back on a worker thread; we never touch Unity APIs from there. + // + // Each AndroidJavaClass / AndroidJavaObject holds a JNI global + // reference; leaking them stranded a JNI handle every Init. + // `client` is the exception — ownership transfers to the + // listener which disposes it in its endConnection finally. + using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var activity = unityPlayer.GetStatic("currentActivity")) + using (var clientClass = new AndroidJavaClass("com.android.installreferrer.api.InstallReferrerClient")) + using (var builder = clientClass.CallStatic("newBuilder", activity)) + { + var client = builder.Call("build"); + var listener = new ReferrerStateListener(client, persistentDataPath); + client.Call("startConnection", listener); + } + } + + private class ReferrerStateListener : AndroidJavaProxy + { + private readonly AndroidJavaObject _client; + private readonly string _persistentDataPath; + + public ReferrerStateListener(AndroidJavaObject client, string persistentDataPath) + : base("com.android.installreferrer.api.InstallReferrerStateListener") + { + _client = client; + _persistentDataPath = persistentDataPath; + } + + // Java method name; AndroidJavaProxy dispatches by name. + public void onInstallReferrerSetupFinished(int responseCode) + { + try + { + switch (responseCode) + { + case ResponseOk: + using (var details = _client.Call("getInstallReferrer")) + { + var referrer = details.Call("getInstallReferrer"); + WriteCacheEntry(_persistentDataPath, referrer ?? string.Empty); + } + break; + + case ResponseFeatureNotSupported: + case ResponseDeveloperError: + case ResponsePermissionError: + // Permanent: never retry on this device/app. + WriteCacheEntry(_persistentDataPath, string.Empty); + break; + + case ResponseServiceUnavailable: + case ResponseServiceDisconnected: + default: + // Transient: leave cache missing so next launch retries. + break; + } + } + finally + { + try { _client.Call("endConnection"); } catch { /* swallow */ } + _client.Dispose(); + } + } + + // The service can drop after a successful setup. We don't depend + // on the live connection (we already wrote the cache), so this + // is just a no-op. + public void onInstallReferrerServiceDisconnected() { } + } +#else + private static void StartFetchNative(string persistentDataPath) + { + // Editor / non-Android / build-time gate not set: no-op. + } +#endif + } +} diff --git a/src/Packages/Audience/Runtime/Unity/Mobile/InstallReferrerBridge.cs.meta b/src/Packages/Audience/Runtime/Unity/Mobile/InstallReferrerBridge.cs.meta new file mode 100644 index 000000000..42630f8a4 --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/Mobile/InstallReferrerBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 6af114ced..2be696d75 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -148,5 +148,13 @@ internal static string MobileAttributionContextProviderThrew(Exception ex) => internal static string TrackingAuthorizationRequestThrew(Exception ex) => $"RequestTrackingAuthorizationAsync threw {ex.GetType().Name}: {ex.Message}. " + "Returning NotDetermined."; + + internal static string MobileInstallReferrerProviderThrew(Exception ex) => + $"MobileInstallReferrerProvider threw {ex.GetType().Name}: {ex.Message}. " + + "install_referrer_received will not fire on this launch."; + + internal static string InstallReferrerSentMarkerWriteFailed(Exception ex) => + $"Failed to write install_referrer_sent marker: {ex.GetType().Name}: {ex.Message}. " + + "install_referrer_received may re-fire on the next launch."; } } diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 56c34ea41..971e3dac0 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -32,6 +32,7 @@ public void TearDown() ImmutableAudience.MobileAttributionProvider = null; ImmutableAudience.MobileAttributionContextProvider = null; ImmutableAudience.TrackingAuthorizationRequestProvider = null; + ImmutableAudience.MobileInstallReferrerProvider = null; Identity.Reset(_testDir); if (Directory.Exists(_testDir)) Directory.Delete(_testDir, recursive: true); @@ -1361,16 +1362,23 @@ public void Init_GameLaunch_OmitsAttributionContext_WhenMobileAttributionDisable public void Init_AttributionProviders_NotCalled_WhenConsentNone() { // Consent gate must precede attribution side effects: SKAN - // registration (network call) and IDFA/ATT reads must not fire - // when the user hasn't authorized any tracking. + // registration (network call), IDFA/ATT reads, and the install + // referrer fetch must not fire when the user hasn't authorized + // any tracking. var skanCallCount = 0; var contextCallCount = 0; + var installReferrerCallCount = 0; ImmutableAudience.MobileAttributionProvider = () => { skanCallCount++; return true; }; ImmutableAudience.MobileAttributionContextProvider = () => { contextCallCount++; return new Dictionary { ["attStatus"] = "authorized" }; }; + ImmutableAudience.MobileInstallReferrerProvider = () => + { + installReferrerCallCount++; + return "utm_source=test"; + }; var config = MakeConfig(ConsentLevel.None); config.EnableMobileAttribution = true; @@ -1381,6 +1389,8 @@ public void Init_AttributionProviders_NotCalled_WhenConsentNone() "MobileAttributionProvider must not run when consent is None"); Assert.AreEqual(0, contextCallCount, "MobileAttributionContextProvider must not run when consent is None"); + Assert.AreEqual(0, installReferrerCallCount, + "MobileInstallReferrerProvider must not run when consent is None"); } [Test] @@ -1400,6 +1410,190 @@ public void Init_GameLaunch_AttributionContextProviderThrows_DoesNotPreventEvent Assert.IsFalse(launchFile.Contains("attStatus")); } + // ----------------------------------------------------------------- + // install_referrer_received + // + // Dedicated event (not a game_launch property): install attribution + // is install-level state, decoupled from launch / session lifecycles. + // Fires once per install — cache landing late on first launch is + // recovered by the next Init via the on-disk "sent" marker. + // ----------------------------------------------------------------- + + [Test] + public void Init_FiresInstallReferrerReceived_WhenProviderReturnsReferrer() + { + ImmutableAudience.MobileInstallReferrerProvider = () => + "utm_source=google-play&utm_medium=organic"; + var config = MakeConfig(); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsTrue(blobs.Any(c => + c.Contains("\"install_referrer_received\"") && + c.Contains("\"installReferrer\":\"utm_source=google-play&utm_medium=organic\"")), + "install_referrer_received must ship with the installReferrer property"); + } + + [Test] + public void Init_GameLaunch_NeverIncludesInstallReferrer() + { + // installReferrer is exclusively on the dedicated event; ensure + // we don't regress and start leaking it onto game_launch. + ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=test"; + var config = MakeConfig(); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText) + .First(c => c.Contains("\"game_launch\"")); + Assert.IsFalse(launchFile.Contains("installReferrer"), + "game_launch must never carry installReferrer — it ships on its own event"); + } + + [Test] + public void Init_DoesNotFireInstallReferrerReceived_WhenProviderReturnsNull() + { + // First-launch cache miss: bridge hasn't resolved yet. No event + // ships; sent marker is not written; next launch can fire it. + ImmutableAudience.MobileInstallReferrerProvider = () => null; + var config = MakeConfig(); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\""))); + Assert.IsFalse(File.Exists(AudiencePaths.InstallReferrerSentFile(_testDir)), + "sent marker must not be written when the event did not fire"); + } + + [Test] + public void Init_DoesNotFireInstallReferrerReceived_WhenProviderReturnsEmpty() + { + // Empty cache file marks "fetched, no referrer" — bridge maps + // that to null on read, but defend the contract here too. + ImmutableAudience.MobileInstallReferrerProvider = () => string.Empty; + var config = MakeConfig(); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\""))); + } + + [Test] + public void Init_DoesNotFireInstallReferrerReceived_WhenAlreadyFired() + { + // Simulate the second launch: cache is populated, marker is set + // by the previous Init. Event must not refire. + ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=test"; + var config = MakeConfig(); + config.EnableMobileAttribution = true; + + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + // Drop the queue from the first Init so we count only the second. + var queueDir = AudiencePaths.QueueDir(_testDir); + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + + var config2 = MakeConfig(); + config2.EnableMobileAttribution = true; + ImmutableAudience.Init(config2); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\"")), + "install_referrer_received must fire exactly once per install"); + } + + [Test] + public void Init_DoesNotFireInstallReferrerReceived_WhenMobileAttributionDisabled() + { + var callCount = 0; + ImmutableAudience.MobileInstallReferrerProvider = () => + { + callCount++; + return "utm_source=should_not_ship"; + }; + var config = MakeConfig(); + config.EnableMobileAttribution = false; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + Assert.AreEqual(0, callCount, + "MobileInstallReferrerProvider must not be called when EnableMobileAttribution is false"); + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\""))); + } + + [Test] + public void Init_FiresInstallReferrerReceived_OnSecondLaunch_WhenFirstMissedCache() + { + // First launch: bridge fetch in flight, provider returns null. + // Second launch: cache populated, provider returns the referrer. + // Event must fire on the second Init even though it missed the first. + string? firstCallReturn = null; + string? secondCallReturn = "utm_source=second_launch"; + var callCount = 0; + ImmutableAudience.MobileInstallReferrerProvider = () => + ++callCount == 1 ? firstCallReturn : secondCallReturn; + + var config = MakeConfig(); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + // Confirm the first launch did not ship it. + var firstBlobs = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(firstBlobs.Any(c => c.Contains("\"install_referrer_received\"")), + "first Init should not ship the event when the cache is empty"); + + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + + var config2 = MakeConfig(); + config2.EnableMobileAttribution = true; + ImmutableAudience.Init(config2); + ImmutableAudience.Shutdown(); + + var secondBlobs = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsTrue(secondBlobs.Any(c => + c.Contains("\"install_referrer_received\"") && + c.Contains("\"installReferrer\":\"utm_source=second_launch\"")), + "second Init must ship install_referrer_received when the cache landed late"); + } + + [Test] + public void Init_InstallReferrerProviderThrows_DoesNotPreventGameLaunch() + { + ImmutableAudience.MobileInstallReferrerProvider = () => + throw new InvalidOperationException("bridge exploded"); + var config = MakeConfig(); + config.EnableMobileAttribution = true; + + Assert.DoesNotThrow(() => ImmutableAudience.Init(config)); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsTrue(blobs.Any(c => c.Contains("\"game_launch\"")), + "game_launch must still ship when the install referrer provider throws"); + Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\""))); + } + // ----------------------------------------------------------------- // RequestTrackingAuthorizationAsync // ----------------------------------------------------------------- diff --git a/src/Packages/Audience/Tests/Runtime/Unity/InstallReferrerBridgeTests.cs b/src/Packages/Audience/Tests/Runtime/Unity/InstallReferrerBridgeTests.cs new file mode 100644 index 000000000..7f7dd1242 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Unity/InstallReferrerBridgeTests.cs @@ -0,0 +1,169 @@ +#nullable enable + +using System; +using System.IO; +using NUnit.Framework; +using Immutable.Audience.Unity.Mobile; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class InstallReferrerBridgeTests + { + private string _testDir = null!; + private Func _originalReadCachedImpl = null!; + private Action _originalStartFetchImpl = null!; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + + _originalReadCachedImpl = InstallReferrerBridge.ReadCachedImpl; + _originalStartFetchImpl = InstallReferrerBridge.StartFetchImpl; + InstallReferrerBridge.ResetForTesting(); + } + + [TearDown] + public void TearDown() + { + InstallReferrerBridge.ReadCachedImpl = _originalReadCachedImpl; + InstallReferrerBridge.StartFetchImpl = _originalStartFetchImpl; + InstallReferrerBridge.ResetForTesting(); + + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + // ----------------------------------------------------------------- + // GetCachedInstallReferrer + // ----------------------------------------------------------------- + + [Test] + public void GetCachedInstallReferrer_NoFile_ReturnsNull() + { + Assert.IsNull(InstallReferrerBridge.GetCachedInstallReferrer(_testDir)); + } + + [Test] + public void GetCachedInstallReferrer_NonEmptyFile_ReturnsContent() + { + const string referrer = "utm_source=google-play&utm_medium=organic"; + InstallReferrerBridge.WriteCacheEntry(_testDir, referrer); + + Assert.AreEqual(referrer, InstallReferrerBridge.GetCachedInstallReferrer(_testDir)); + } + + [Test] + public void GetCachedInstallReferrer_EmptyFile_ReturnsNull() + { + // Empty file marks "fetched, no referrer" — caller treats it as + // "nothing to emit" but EnsureFetchStarted treats it as terminal. + InstallReferrerBridge.WriteCacheEntry(_testDir, string.Empty); + + Assert.IsNull(InstallReferrerBridge.GetCachedInstallReferrer(_testDir)); + } + + [Test] + public void GetCachedInstallReferrer_NullPath_ReturnsNull() + { + Assert.IsNull(InstallReferrerBridge.GetCachedInstallReferrer(null!)); + Assert.IsNull(InstallReferrerBridge.GetCachedInstallReferrer(string.Empty)); + } + + // ----------------------------------------------------------------- + // EnsureFetchStarted + // ----------------------------------------------------------------- + + [Test] + public void EnsureFetchStarted_NoCacheFile_InvokesFetch() + { + var fetchCalls = 0; + InstallReferrerBridge.StartFetchImpl = _ => fetchCalls++; + + InstallReferrerBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, fetchCalls); + } + + [Test] + public void EnsureFetchStarted_CalledTwiceInSameProcess_FetchesOnce() + { + var fetchCalls = 0; + InstallReferrerBridge.StartFetchImpl = _ => fetchCalls++; + + InstallReferrerBridge.EnsureFetchStarted(_testDir); + InstallReferrerBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, fetchCalls, + "Per-process gate must prevent double-fetch in one session"); + } + + [Test] + public void EnsureFetchStarted_TerminalCacheExists_SkipsFetch() + { + // Simulate a previous launch that wrote a cache entry. The fetch + // must NOT run again — Google's referrer is stable per install. + InstallReferrerBridge.WriteCacheEntry(_testDir, "utm_source=test"); + + var fetchCalls = 0; + InstallReferrerBridge.StartFetchImpl = _ => fetchCalls++; + + InstallReferrerBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(0, fetchCalls); + } + + [Test] + public void EnsureFetchStarted_EmptyCacheExists_SkipsFetch() + { + // Empty cache = "fetched, no referrer" — terminal state, no retry. + InstallReferrerBridge.WriteCacheEntry(_testDir, string.Empty); + + var fetchCalls = 0; + InstallReferrerBridge.StartFetchImpl = _ => fetchCalls++; + + InstallReferrerBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(0, fetchCalls); + } + + [Test] + public void EnsureFetchStarted_NullPath_NoOp() + { + var fetchCalls = 0; + InstallReferrerBridge.StartFetchImpl = _ => fetchCalls++; + + InstallReferrerBridge.EnsureFetchStarted(null!); + InstallReferrerBridge.EnsureFetchStarted(string.Empty); + + Assert.AreEqual(0, fetchCalls); + } + + // ----------------------------------------------------------------- + // WriteCacheEntry + // ----------------------------------------------------------------- + + [Test] + public void WriteCacheEntry_CreatesAudienceDirIfMissing() + { + // Disk persistence routes through AudiencePaths so the file lives + // under imtbl_audience/. WriteCacheEntry must create the dir on + // first attribution write since previous launches may not have + // touched it (consent None never creates the audience dir). + InstallReferrerBridge.WriteCacheEntry(_testDir, "ref"); + + Assert.IsNotNull(InstallReferrerBridge.GetCachedInstallReferrer(_testDir)); + } + + [Test] + public void WriteCacheEntry_OverwritesExistingFile() + { + InstallReferrerBridge.WriteCacheEntry(_testDir, "old=value"); + InstallReferrerBridge.WriteCacheEntry(_testDir, "new=value"); + + Assert.AreEqual("new=value", InstallReferrerBridge.GetCachedInstallReferrer(_testDir)); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Unity/InstallReferrerBridgeTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Unity/InstallReferrerBridgeTests.cs.meta new file mode 100644 index 000000000..c4e606fa1 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Unity/InstallReferrerBridgeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 55d25a13741dab9a986c086c8ebca4e9998790ca Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Fri, 8 May 2026 14:24:08 +1200 Subject: [PATCH 3/5] feat(audience-sdk): add Android GAID collection (SDK-309) Capture the Google Advertising ID and limitAdTracking flag via JNI to AdvertisingIdClient. Ships on game_launch when AUDIENCE_MOBILE_ATTRIBUTION is defined, EnableMobileAttribution is on, and the studio has added play-services-ads-identifier to their gradle dependencies. Async fetch runs on a dedicated worker thread; first launch ships nothing, launch #2 onwards ships the previously cached value. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/audience/Assets/Plugins.meta | 8 + examples/audience/Assets/Plugins/Android.meta | 8 + .../Plugins/Android/mainTemplate.gradle | 79 +++++++ .../Plugins/Android/mainTemplate.gradle.meta | 7 + .../ProjectSettings/ProjectSettings.asset | 5 +- .../Editor/AndroidManifestPostProcessor.cs | 13 +- .../Audience/Runtime/Core/AudiencePaths.cs | 4 + .../Runtime/Plugins/Android/proguard-user.txt | 9 + .../Runtime/Unity/AudienceUnityHooks.cs | 23 +++ .../Unity/Mobile/AttributionContext.cs | 62 ++++-- .../Runtime/Unity/Mobile/GAIDBridge.cs | 168 +++++++++++++++ .../Runtime/Unity/Mobile/GAIDBridge.cs.meta | 11 + src/Packages/Audience/Runtime/Utility/Log.cs | 4 + .../Tests/Runtime/ImmutableAudienceTests.cs | 49 +++++ .../Tests/Runtime/Unity/ATTBridgeTests.cs | 31 +++ .../Tests/Runtime/Unity/GAIDBridgeTests.cs | 195 ++++++++++++++++++ .../Runtime/Unity/GAIDBridgeTests.cs.meta | 11 + 17 files changed, 667 insertions(+), 20 deletions(-) create mode 100644 examples/audience/Assets/Plugins.meta create mode 100644 examples/audience/Assets/Plugins/Android.meta create mode 100644 examples/audience/Assets/Plugins/Android/mainTemplate.gradle create mode 100644 examples/audience/Assets/Plugins/Android/mainTemplate.gradle.meta create mode 100644 src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs create mode 100644 src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs.meta create mode 100644 src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs create mode 100644 src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs.meta diff --git a/examples/audience/Assets/Plugins.meta b/examples/audience/Assets/Plugins.meta new file mode 100644 index 000000000..8ac17dd85 --- /dev/null +++ b/examples/audience/Assets/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 534f898df82b946809603784fb72739a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/Plugins/Android.meta b/examples/audience/Assets/Plugins/Android.meta new file mode 100644 index 000000000..2aafca840 --- /dev/null +++ b/examples/audience/Assets/Plugins/Android.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 13c2daed7011c4a8eb35e6e4b9a89aee +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/Plugins/Android/mainTemplate.gradle b/examples/audience/Assets/Plugins/Android/mainTemplate.gradle new file mode 100644 index 000000000..795cadd4d --- /dev/null +++ b/examples/audience/Assets/Plugins/Android/mainTemplate.gradle @@ -0,0 +1,79 @@ +// Custom gradle template for the Audience sample. +// +// Studios who enable AUDIENCE_MOBILE_ATTRIBUTION on Android need +// play-services-ads-identifier so the SDK's GAIDBridge can call +// AdvertisingIdClient.getAdvertisingIdInfo via JNI. Without this +// dependency the class is missing at runtime and `gaid` never lands +// on game_launch (the bridge logs ClassNotFoundException and exits). +// +// To replicate this in your own project: +// 1. Player Settings → Publishing Settings → enable "Custom Main +// Gradle Template". Unity will create this file at +// Assets/Plugins/Android/mainTemplate.gradle. +// 2. Add the play-services-ads-identifier line in the dependencies +// block below. +// +// Studios who do NOT enable AUDIENCE_MOBILE_ATTRIBUTION can omit the +// dependency entirely; the SDK's JNI code is stripped at compile time. + +apply plugin: 'com.android.library' +**APPLY_PLUGINS** + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) +**DEPS** + // Uncomment to enable GAID collection (requires AUDIENCE_MOBILE_ATTRIBUTION + // scripting define + AudienceConfig.EnableMobileAttribution). Without this + // line the SDK still builds and runs, but `gaid` / `gaidLimitAdTracking` + // never ship and a one-line ClassNotFoundException is logged on Init. + // Skip this line if your studio doesn't need install attribution. + // implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' +} + +android { + namespace "com.unity3d.player" + ndkPath "**NDKPATH**" + + compileSdkVersion **APIVERSION** + buildToolsVersion '**BUILDTOOLS**' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion **MINSDKVERSION** + targetSdkVersion **TARGETSDKVERSION** + ndk { + abiFilters **ABIFILTERS** + } + versionCode **VERSIONCODE** + versionName '**VERSIONNAME**' + consumerProguardFiles 'proguard-unity.txt'**USER_PROGUARD** + } + + lintOptions { + abortOnError false + } + + aaptOptions { + noCompress = **NON_COMPRESSED_ASSETS** + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + }**SIGN** + + **PACKAGING_OPTIONS** + + buildTypes { + debug { + minifyEnabled **MINIFY_DEBUG** + proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGN_CONFIG** + jniDebuggable true + } + release { + minifyEnabled **MINIFY_RELEASE** + proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGN_CONFIG** + } + }**PACKAGING** +}**REPOSITORIES****SOURCE_BUILD_SETUP** +**IL_CPP_BUILD_SETUP** diff --git a/examples/audience/Assets/Plugins/Android/mainTemplate.gradle.meta b/examples/audience/Assets/Plugins/Android/mainTemplate.gradle.meta new file mode 100644 index 000000000..eb8d1f4be --- /dev/null +++ b/examples/audience/Assets/Plugins/Android/mainTemplate.gradle.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 18f54af952c1847ea9f566dc6ba76ca8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/ProjectSettings/ProjectSettings.asset b/examples/audience/ProjectSettings/ProjectSettings.asset index ebc4caf75..7cfafd9ae 100644 --- a/examples/audience/ProjectSettings/ProjectSettings.asset +++ b/examples/audience/ProjectSettings/ProjectSettings.asset @@ -164,7 +164,7 @@ PlayerSettings: Standalone: 0 iPhone: 0 tvOS: 0 - overrideDefaultApplicationIdentifier: 0 + overrideDefaultApplicationIdentifier: 1 AndroidBundleVersionCode: 1 AndroidMinSdkVersion: 22 AndroidTargetSdkVersion: 0 @@ -244,7 +244,7 @@ PlayerSettings: templateDefaultScene: Assets/Scenes/SampleScene.unity useCustomMainManifest: 0 useCustomLauncherManifest: 0 - useCustomMainGradleTemplate: 0 + useCustomMainGradleTemplate: 1 useCustomLauncherGradleManifest: 0 useCustomBaseGradleTemplate: 0 useCustomGradlePropertiesTemplate: 0 @@ -809,6 +809,7 @@ PlayerSettings: webGLDecompressionFallback: 0 webGLPowerPreference: 2 scriptingDefineSymbols: + Android: AUDIENCE_MOBILE_ATTRIBUTION iPhone: AUDIENCE_MOBILE_ATTRIBUTION additionalCompilerArguments: {} platformArchitecture: {} diff --git a/src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs b/src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs index f7f1b78d1..75d026e89 100644 --- a/src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs +++ b/src/Packages/Audience/Editor/AndroidManifestPostProcessor.cs @@ -6,12 +6,15 @@ namespace Immutable.Audience.Editor { - // Injects android.permission.INTERNET into the generated unityLibrary manifest. + // Injects android.permission.INTERNET into the generated unityLibrary + // manifest. The SDK sends events via System.Net.Http.HttpClient (not + // UnityWebRequest), so Unity does not auto-add INTERNET. // - // The SDK sends events via System.Net.Http.HttpClient, not UnityWebRequest, so - // Unity does not auto-add INTERNET. This post-processor ensures the permission - // is always present regardless of how the package is installed (file:, git, or - // UPM registry), without requiring the studio to set ForceInternetPermission. + // AD_ID is intentionally NOT injected here. It comes from the + // play-services-ads-identifier AAR's own manifest via AGP merging when + // the studio adds the Maven dependency. Injecting it ourselves would + // declare the permission for studios who never pull in the AAR (and so + // can never collect GAID), creating a Play Store Data Safety mismatch. internal sealed class AndroidManifestPostProcessor : IPostGenerateGradleAndroidProject { private const string InternetPermission = "android.permission.INTERNET"; diff --git a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs index c8cc00528..82b20f58c 100644 --- a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs +++ b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs @@ -10,6 +10,7 @@ internal static class AudiencePaths private const string QueueDirName = "queue"; private const string InstallReferrerFileName = "install_referrer"; private const string InstallReferrerSentFileName = "install_referrer_sent"; + private const string GAIDFileName = "gaid"; internal static string AudienceDir(string persistentDataPath) => Path.Combine(persistentDataPath, RootDirName); @@ -28,5 +29,8 @@ internal static string InstallReferrerFile(string persistentDataPath) => internal static string InstallReferrerSentFile(string persistentDataPath) => Path.Combine(AudienceDir(persistentDataPath), InstallReferrerSentFileName); + + internal static string GAIDFile(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), GAIDFileName); } } diff --git a/src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt b/src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt index 96ea05705..f90a9b305 100644 --- a/src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt +++ b/src/Packages/Audience/Runtime/Plugins/Android/proguard-user.txt @@ -9,3 +9,12 @@ # minification regardless of fullMode behaviour. -keep class com.android.installreferrer.** { *; } -keep interface com.android.installreferrer.** { *; } + +# Keep Google Play Services AdvertisingIdClient symbols (GAID). +# +# AdvertisingIdClient and its inner Info class are reflected via JNI from +# GAIDBridge — they have no managed-side reference for R8 to follow, so +# fullMode in studio projects can drop them. Defensive rules ensure the +# getAdvertisingIdInfo / getId / isLimitAdTrackingEnabled surface survives. +-keep class com.google.android.gms.ads.identifier.** { *; } +-keep interface com.google.android.gms.ads.identifier.** { *; } diff --git a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs index a26d16f11..1699e2d08 100644 --- a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs +++ b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs @@ -43,6 +43,12 @@ private static void Install() #if UNITY_ANDROID && !UNITY_EDITOR ImmutableAudience.MobileInstallReferrerProvider = ProvideInstallReferrer; +#if AUDIENCE_MOBILE_ATTRIBUTION + // Gated on the define so a build that disables GAID at compile + // time can't read a stale cache file left over from a prior + // install where the define was on. + ImmutableAudience.MobileAttributionContextProvider = ProvideAndroidAttributionContext; +#endif #endif UnityLifecycleBridge.EnsureExists(); @@ -63,5 +69,22 @@ private static void Install() InstallReferrerBridge.EnsureFetchStarted(path!); return InstallReferrerBridge.GetCachedInstallReferrer(path!); } + +#if UNITY_ANDROID && !UNITY_EDITOR && AUDIENCE_MOBILE_ATTRIBUTION + // Kicks off a background GAID fetch for the next launch (Google + // requires getAdvertisingIdInfo run off the main thread) and returns + // whatever was cached by the previous launch. First launch returns + // an empty dict; launch #2+ ships gaid + gaidLimitAdTracking. + // Exceptions propagate to ImmutableAudience.Init's + // MobileAttributionContextProviderThrew handler. + private static IReadOnlyDictionary? ProvideAndroidAttributionContext() + { + var path = _persistentDataPath; + if (string.IsNullOrEmpty(path)) return AttributionContext.Capture(); + + GAIDBridge.EnsureFetchStarted(path!); + return AttributionContext.Capture(path); + } +#endif } } diff --git a/src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs b/src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs index 231974556..c1a97e072 100644 --- a/src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs +++ b/src/Packages/Audience/Runtime/Unity/Mobile/AttributionContext.cs @@ -4,10 +4,17 @@ namespace Immutable.Audience.Unity.Mobile { - // Builds the iOS attribution snapshot that ships on game_launch when - // EnableMobileAttribution is true. ATT status is always read; IDFA is - // only included when status is authorized (Apple returns the all-zeros - // UUID otherwise, which the native bridge filters to null). + // Builds the platform attribution snapshot that ships on game_launch when + // EnableMobileAttribution is true. + // + // iOS: ATT status is always read; IDFA is only included when status is + // authorized (Apple returns the all-zeros UUID otherwise, which the native + // bridge filters to null). + // + // Android: gaid + gaidLimitAdTracking are read from the GAIDBridge disk + // cache populated by the previous launch's background fetch (Google's + // AdvertisingIdClient is sync + must run off main thread, so first launch + // ships nothing — gaidLimitAdTracking shows up on launch #2 onwards). internal static class AttributionContext { // Maps Apple's ATTrackingManagerAuthorizationStatus to the wire @@ -25,17 +32,21 @@ internal static string AttStatusToString(int status) } } - // Always returns a non-null dictionary — at minimum - // { attStatus: "notDetermined" }. The provider field type is - // nullable for forward-compat with future implementations that may - // want to opt out, but this implementation never returns null. - internal static IReadOnlyDictionary Capture() + // persistentDataPath is required for Android (GAID disk cache); iOS + // ignores it. Returns a possibly-empty dict — never null — so callers + // can merge unconditionally. + internal static IReadOnlyDictionary Capture(string? persistentDataPath = null) { + var props = new Dictionary(); + +#if UNITY_IOS || UNITY_EDITOR + // Compiled in on iOS device builds AND in the editor (any target) + // so AttributionContextTests can drive Capture() via the ATTBridge + // test seams. Excluded on real Android device builds so attStatus + // never ships there. Native ATTBridge calls are themselves gated + // by #if UNITY_IOS, so non-iOS editor targets get the safe stubs. var status = ATTBridge.GetStatus(); - var props = new Dictionary - { - ["attStatus"] = AttStatusToString(status), - }; + props["attStatus"] = AttStatusToString(status); // Only ship IDFA when the user has authorized tracking. The native // bridge already returns null for the zero-UUID case, but gating @@ -47,8 +58,33 @@ internal static IReadOnlyDictionary Capture() if (!string.IsNullOrEmpty(idfa)) props["idfa"] = idfa!; } +#endif + +#if UNITY_ANDROID && !UNITY_EDITOR && AUDIENCE_MOBILE_ATTRIBUTION + // Gated on AUDIENCE_MOBILE_ATTRIBUTION so a build that disables + // GAID at compile time can't read a stale cache file written by + // a previous install where the define was on. + if (!string.IsNullOrEmpty(persistentDataPath)) + { + var info = GAIDBridge.GetCached(persistentDataPath!); + if (info.HasValue) + EmitGaidProps(info.Value, props); + } +#endif return props; } + + // Defensive emission gate: even if a stale cache from a pre-fix build + // retained a non-empty GAID under opt-out, this method never ships + // the raw identifier when LimitAdTracking is true. gaidLimitAdTracking + // always ships so the pipeline can distinguish "fetched, opted out" + // from "not fetched yet". + internal static void EmitGaidProps(GAIDInfo info, IDictionary props) + { + if (!info.LimitAdTracking && !string.IsNullOrEmpty(info.Gaid)) + props["gaid"] = info.Gaid; + props["gaidLimitAdTracking"] = info.LimitAdTracking; + } } } diff --git a/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs b/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs new file mode 100644 index 000000000..8c7fcb5b4 --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs @@ -0,0 +1,168 @@ +#nullable enable + +using System; +using System.IO; +using System.Threading; +using Immutable.Audience; +#if UNITY_ANDROID && AUDIENCE_MOBILE_ATTRIBUTION +using UnityEngine; +#endif + +namespace Immutable.Audience.Unity.Mobile +{ + /// + /// Reads gaid + limitAdTracking via AdvertisingIdClient. Google requires + /// the call run off the main thread, so we dispatch on a dedicated worker + /// and cache the result to disk for the next launch. GAID can change + /// (user reset), so we refresh every launch — first launch ships nothing, + /// launch #2+ ships the previously-cached value. + /// + internal static class GAIDBridge + { + // Test seams. + internal static Func ReadCachedImpl = ReadCachedFromDisk; + internal static Action StartFetchImpl = StartFetchNative; + + // Per-process gate: one fetch per session. + private static int _fetchStarted; + + internal static GAIDInfo? GetCached(string persistentDataPath) + { + if (string.IsNullOrEmpty(persistentDataPath)) return null; + return ReadCachedImpl(persistentDataPath); + } + + internal static void EnsureFetchStarted(string persistentDataPath) + { + if (string.IsNullOrEmpty(persistentDataPath)) return; + if (Interlocked.CompareExchange(ref _fetchStarted, 1, 0) != 0) return; + + try + { + StartFetchImpl(persistentDataPath); + } + catch (Exception) + { + // Re-arm so a later EnsureFetchStarted retries. + Interlocked.Exchange(ref _fetchStarted, 0); + throw; + } + } + + // Test-only. + internal static void ResetForTesting() + { + Interlocked.Exchange(ref _fetchStarted, 0); + } + + // Cache: line 1 = gaid (empty on opt-out), line 2 = "1"|"0" for limit flag. + // File missing = no fetch yet. + internal static void WriteCacheEntry(string persistentDataPath, string gaidOrEmpty, bool limitAdTracking) + { + try + { + var path = AudiencePaths.GAIDFile(persistentDataPath); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var content = (gaidOrEmpty ?? string.Empty) + "\n" + (limitAdTracking ? "1" : "0"); + var tmp = path + ".tmp"; + File.WriteAllText(tmp, content); + if (File.Exists(path)) File.Delete(path); + File.Move(tmp, path); + } + catch (Exception) + { + // Cache miss costs one wasted fetch on next launch. + } + } + + private static GAIDInfo? ReadCachedFromDisk(string persistentDataPath) + { + try + { + var path = AudiencePaths.GAIDFile(persistentDataPath); + if (!File.Exists(path)) return null; + + var lines = File.ReadAllText(path).Split('\n'); + var gaid = lines.Length > 0 ? lines[0] : string.Empty; + var limit = lines.Length > 1 && lines[1] == "1"; + + return new GAIDInfo(gaid, limit); + } + catch (Exception) + { + return null; + } + } + +#if UNITY_ANDROID && AUDIENCE_MOBILE_ATTRIBUTION + private static void StartFetchNative(string persistentDataPath) + { + // Dedicated Thread (not ThreadPool) so Attach/Detach pair on the + // same one-shot worker — ThreadPool reuse strands JVM state. + var thread = new Thread(() => FetchOnWorkerThread(persistentDataPath)) + { + IsBackground = true, + Name = "Audience.GAIDFetch", + }; + thread.Start(); + } + + private static void FetchOnWorkerThread(string persistentDataPath) + { + // Unity 2021 does not auto-attach managed threads to the JVM; + // first JNI call segfaults libunity.so without this. + AndroidJNI.AttachCurrentThread(); + try + { + using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var activity = unityPlayer.GetStatic("currentActivity")) + using (var clientClass = new AndroidJavaClass("com.google.android.gms.ads.identifier.AdvertisingIdClient")) + using (var info = clientClass.CallStatic("getAdvertisingIdInfo", activity)) + { + var gaid = info.Call("getId"); + var limit = info.Call("isLimitAdTrackingEnabled"); + // Honor the user's opt-out at the cache layer: never + // persist the raw GAID when isLimitAdTrackingEnabled + // returned true, even though Google's API still hands it + // back. AttributionContext also filters at emission, but + // dropping it here keeps an opted-out identifier off disk. + var cachedGaid = limit ? string.Empty : (gaid ?? string.Empty); + WriteCacheEntry(persistentDataPath, cachedGaid, limit); + } + } + catch (Exception ex) + { + // Play Services missing, network, or user disabled ads. + // Cache stays empty; next launch retries. + Log.Warn(AudienceLogs.GAIDFetchThrew(ex)); + } + finally + { + // After using-block disposal (DeleteGlobalRef needs an + // attached thread); detaching first would crash dispose. + AndroidJNI.DetachCurrentThread(); + } + } +#else + private static void StartFetchNative(string persistentDataPath) + { + // Editor / non-Android / define off: no-op. + } +#endif + } + + internal readonly struct GAIDInfo + { + internal readonly string Gaid; + internal readonly bool LimitAdTracking; + + internal GAIDInfo(string gaid, bool limitAdTracking) + { + Gaid = gaid; + LimitAdTracking = limitAdTracking; + } + } +} diff --git a/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs.meta b/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs.meta new file mode 100644 index 000000000..9bfca1709 --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/Mobile/GAIDBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 189447c8508a54568b9fb9fd96ed1115 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 2be696d75..222c2ca73 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -156,5 +156,9 @@ internal static string MobileInstallReferrerProviderThrew(Exception ex) => internal static string InstallReferrerSentMarkerWriteFailed(Exception ex) => $"Failed to write install_referrer_sent marker: {ex.GetType().Name}: {ex.Message}. " + "install_referrer_received may re-fire on the next launch."; + + internal static string GAIDFetchThrew(Exception ex) => + $"GAID fetch threw {ex.GetType().Name}: {ex.Message}. " + + "gaid will not ship on game_launch this session; next launch retries."; } } diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 971e3dac0..e8855cac9 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -1410,6 +1410,55 @@ public void Init_GameLaunch_AttributionContextProviderThrows_DoesNotPreventEvent Assert.IsFalse(launchFile.Contains("attStatus")); } + [Test] + public void Init_GameLaunch_IncludesGaidAndLimitFlag_WhenContextProviderReturns() + { + // Android attribution shape: gaid + gaidLimitAdTracking. Ships + // from launch #2 onwards (Google's getAdvertisingIdInfo is async + // off-main-thread; first launch's cache is empty when game_launch + // fires). Test asserts the wire shape, not the async timing. + ImmutableAudience.MobileAttributionContextProvider = () => + new Dictionary + { + ["gaid"] = "abcdef01-2345-6789-abcd-ef0123456789", + ["gaidLimitAdTracking"] = false, + }; + var config = MakeConfig(); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText) + .First(c => c.Contains("\"game_launch\"")); + StringAssert.Contains("\"gaid\":\"abcdef01-2345-6789-abcd-ef0123456789\"", launchFile); + StringAssert.Contains("\"gaidLimitAdTracking\":false", launchFile); + } + + [Test] + public void Init_GameLaunch_OmitsGaid_WhenUserOptedOut() + { + // User opted out: gaid omitted, but gaidLimitAdTracking=true ships + // so the pipeline can distinguish "fetched, opted out" from + // "not fetched yet" (both absent). + ImmutableAudience.MobileAttributionContextProvider = () => + new Dictionary + { + ["gaidLimitAdTracking"] = true, + }; + var config = MakeConfig(); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText) + .First(c => c.Contains("\"game_launch\"")); + StringAssert.Contains("\"gaidLimitAdTracking\":true", launchFile); + Assert.IsFalse(launchFile.Contains("\"gaid\""), + "gaid must not appear when the user has opted out"); + } + // ----------------------------------------------------------------- // install_referrer_received // diff --git a/src/Packages/Audience/Tests/Runtime/Unity/ATTBridgeTests.cs b/src/Packages/Audience/Tests/Runtime/Unity/ATTBridgeTests.cs index 21b62261c..be91edbda 100644 --- a/src/Packages/Audience/Tests/Runtime/Unity/ATTBridgeTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Unity/ATTBridgeTests.cs @@ -167,5 +167,36 @@ public void AttStatusToString_KnownValues() Assert.AreEqual("denied", AttributionContext.AttStatusToString(2)); Assert.AreEqual("authorized", AttributionContext.AttStatusToString(3)); } + + [Test] + public void EmitGaidProps_LimitAdTrackingTrue_OmitsRawGaid() + { + var props = new Dictionary(); + AttributionContext.EmitGaidProps(new GAIDInfo("aaaa-bbbb", limitAdTracking: true), props); + + Assert.IsFalse(props.ContainsKey("gaid"), + "must never ship the raw GAID when the user has opted out via isLimitAdTrackingEnabled"); + Assert.AreEqual(true, props["gaidLimitAdTracking"]); + } + + [Test] + public void EmitGaidProps_LimitAdTrackingFalse_ShipsGaid() + { + var props = new Dictionary(); + AttributionContext.EmitGaidProps(new GAIDInfo("aaaa-bbbb", limitAdTracking: false), props); + + Assert.AreEqual("aaaa-bbbb", props["gaid"]); + Assert.AreEqual(false, props["gaidLimitAdTracking"]); + } + + [Test] + public void EmitGaidProps_EmptyGaidLimitFalse_OmitsGaidKeepsFlag() + { + var props = new Dictionary(); + AttributionContext.EmitGaidProps(new GAIDInfo(string.Empty, limitAdTracking: false), props); + + Assert.IsFalse(props.ContainsKey("gaid")); + Assert.AreEqual(false, props["gaidLimitAdTracking"]); + } } } diff --git a/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs b/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs new file mode 100644 index 000000000..a2c0c81bc --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs @@ -0,0 +1,195 @@ +#nullable enable + +using System; +using System.IO; +using NUnit.Framework; +using Immutable.Audience.Unity.Mobile; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class GAIDBridgeTests + { + private string _testDir = null!; + private Func _originalReadCachedImpl = null!; + private Action _originalStartFetchImpl = null!; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + + _originalReadCachedImpl = GAIDBridge.ReadCachedImpl; + _originalStartFetchImpl = GAIDBridge.StartFetchImpl; + GAIDBridge.ResetForTesting(); + } + + [TearDown] + public void TearDown() + { + GAIDBridge.ReadCachedImpl = _originalReadCachedImpl; + GAIDBridge.StartFetchImpl = _originalStartFetchImpl; + GAIDBridge.ResetForTesting(); + + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + // ----------------------------------------------------------------- + // GetCached + // ----------------------------------------------------------------- + + [Test] + public void GetCached_NoFile_ReturnsNull() + { + Assert.IsNull(GAIDBridge.GetCached(_testDir)); + } + + [Test] + public void GetCached_NonEmptyFile_ReturnsGaidAndFlag() + { + const string gaid = "abcdef01-2345-6789-abcd-ef0123456789"; + GAIDBridge.WriteCacheEntry(_testDir, gaid, limitAdTracking: false); + + var info = GAIDBridge.GetCached(_testDir); + + Assert.IsTrue(info.HasValue); + Assert.AreEqual(gaid, info!.Value.Gaid); + Assert.IsFalse(info.Value.LimitAdTracking); + } + + [Test] + public void GetCached_LimitAdTrackingTrue_PreservedAcrossWriteRead() + { + const string gaid = "00000000-0000-0000-0000-000000000000"; + GAIDBridge.WriteCacheEntry(_testDir, gaid, limitAdTracking: true); + + var info = GAIDBridge.GetCached(_testDir); + + Assert.IsTrue(info.HasValue); + Assert.IsTrue(info!.Value.LimitAdTracking); + } + + [Test] + public void GetCached_OptOutEntry_ReturnsEmptyGaidWithFlag() + { + // User opted out → empty gaid string + limitAdTracking=true. The + // pipeline reads "fetched, opted out" rather than "not fetched". + GAIDBridge.WriteCacheEntry(_testDir, string.Empty, limitAdTracking: true); + + var info = GAIDBridge.GetCached(_testDir); + + Assert.IsTrue(info.HasValue); + Assert.AreEqual(string.Empty, info!.Value.Gaid); + Assert.IsTrue(info.Value.LimitAdTracking); + } + + [Test] + public void GetCached_NullPath_ReturnsNull() + { + Assert.IsNull(GAIDBridge.GetCached(null!)); + Assert.IsNull(GAIDBridge.GetCached(string.Empty)); + } + + // ----------------------------------------------------------------- + // EnsureFetchStarted + // ----------------------------------------------------------------- + + [Test] + public void EnsureFetchStarted_FirstCall_InvokesFetch() + { + var fetchCalls = 0; + GAIDBridge.StartFetchImpl = _ => fetchCalls++; + + GAIDBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, fetchCalls); + } + + [Test] + public void EnsureFetchStarted_CalledTwiceInSameProcess_FetchesOnce() + { + var fetchCalls = 0; + GAIDBridge.StartFetchImpl = _ => fetchCalls++; + + GAIDBridge.EnsureFetchStarted(_testDir); + GAIDBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, fetchCalls, + "Per-process gate must prevent duplicate JNI workers in one session"); + } + + [Test] + public void EnsureFetchStarted_TerminalCacheExists_StillFetches() + { + // Unlike the install referrer (terminal once written), GAID can + // change (user reset) — we always refresh the cache for the next + // launch even when a value already exists. + GAIDBridge.WriteCacheEntry(_testDir, "stale-gaid", limitAdTracking: false); + + var fetchCalls = 0; + GAIDBridge.StartFetchImpl = _ => fetchCalls++; + + GAIDBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, fetchCalls); + } + + [Test] + public void EnsureFetchStarted_StartFetchThrows_RearmsGate() + { + // A synchronous failure (e.g. JNI attach) must re-arm the gate + // so a later call this session can retry. + GAIDBridge.StartFetchImpl = _ => throw new InvalidOperationException("boom"); + + Assert.Throws(() => GAIDBridge.EnsureFetchStarted(_testDir)); + + var retryCalls = 0; + GAIDBridge.StartFetchImpl = _ => retryCalls++; + GAIDBridge.EnsureFetchStarted(_testDir); + + Assert.AreEqual(1, retryCalls); + } + + [Test] + public void EnsureFetchStarted_NullPath_NoOp() + { + var fetchCalls = 0; + GAIDBridge.StartFetchImpl = _ => fetchCalls++; + + GAIDBridge.EnsureFetchStarted(null!); + GAIDBridge.EnsureFetchStarted(string.Empty); + + Assert.AreEqual(0, fetchCalls); + } + + // ----------------------------------------------------------------- + // WriteCacheEntry + // ----------------------------------------------------------------- + + [Test] + public void WriteCacheEntry_CreatesAudienceDirIfMissing() + { + // First-launch attribution write must create the imtbl_audience/ + // directory; consent None never touches disk so the dir may not + // exist yet. + GAIDBridge.WriteCacheEntry(_testDir, "abc", limitAdTracking: false); + + Assert.IsNotNull(GAIDBridge.GetCached(_testDir)); + } + + [Test] + public void WriteCacheEntry_OverwritesExistingFile() + { + GAIDBridge.WriteCacheEntry(_testDir, "old-gaid", limitAdTracking: true); + GAIDBridge.WriteCacheEntry(_testDir, "new-gaid", limitAdTracking: false); + + var info = GAIDBridge.GetCached(_testDir); + + Assert.IsTrue(info.HasValue); + Assert.AreEqual("new-gaid", info!.Value.Gaid); + Assert.IsFalse(info.Value.LimitAdTracking); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs.meta new file mode 100644 index 000000000..5019803bd --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Unity/GAIDBridgeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da60a259f491343fb8c9c73caae34aec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 176076ab5e272ab2a228b615ab393ba848975004 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Fri, 8 May 2026 16:08:19 +1200 Subject: [PATCH 4/5] feat(audience-sdk): tighten attribution consent - idfa/gaid/installReferrer to Full-only (SDK-331) idfa, gaid, and installReferrer are cross-app / campaign-source identifiers in the same privacy class as userId. Previously they shipped at Anonymous+Full (CanTrack); this sweep re-tiers them to Full-only (CanIdentify), matching the existing userId gate. State-class properties (attStatus, gaidLimitAdTracking, skanRegistered) are non-identifying and remain at Anonymous+Full unchanged. The install_referrer_received sent-marker is intentionally not written when consent is Anonymous, so a later upgrade to Full can fire the event on the next launch. Co-Authored-By: Claude Sonnet 4.6 --- .../Audience/Runtime/ImmutableAudience.cs | 13 +- .../Tests/Runtime/ImmutableAudienceTests.cs | 130 ++++++++++++++++-- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index f6b531513..e79a39b05 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -254,7 +254,10 @@ public static void Init(AudienceConfig config) // usually still empty when game_launch fires, so we ship a // dedicated event after Init when the value first becomes // observable. Idempotent across launches via an on-disk marker. - if (!string.IsNullOrEmpty(installReferrer)) + // installReferrer encodes campaign attribution source, same privacy + // class as userId. Only ship at Full; don't write the sent marker + // at Anonymous so a later consent upgrade can fire the event. + if (!string.IsNullOrEmpty(installReferrer) && consentAtInit.CanIdentify()) FireInstallReferrerReceivedOnce(config, installReferrer!); } @@ -1130,10 +1133,18 @@ private static void FireGameLaunch( // iOS ATT/IDFA snapshot — merged after Unity context so attribution // keys are authoritative if both sources happen to set the same key. + // idfa and gaid are cross-app device identifiers, same privacy class + // as userId; gate them at Full-only. State-class keys (attStatus, + // gaidLimitAdTracking) are non-identifying and ship at Anon+Full. if (attributionContext != null) { + var canIdentify = consentAtInit.CanIdentify(); foreach (var kvp in attributionContext) + { + if ((kvp.Key == "idfa" || kvp.Key == "gaid") && !canIdentify) + continue; properties[kvp.Key] = kvp.Value; + } } // No sessionId on game_launch per Event Reference. Pipeline correlates diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index e8855cac9..70894976e 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -1302,7 +1302,7 @@ public void Init_GameLaunch_IncludesAttStatusAndIdfa_WhenContextProviderReturns( ["attStatus"] = "authorized", ["idfa"] = "11111111-2222-3333-4444-555555555555", }; - var config = MakeConfig(); + var config = MakeConfig(ConsentLevel.Full); config.EnableMobileAttribution = true; ImmutableAudience.Init(config); ImmutableAudience.Shutdown(); @@ -1423,7 +1423,7 @@ public void Init_GameLaunch_IncludesGaidAndLimitFlag_WhenContextProviderReturns( ["gaid"] = "abcdef01-2345-6789-abcd-ef0123456789", ["gaidLimitAdTracking"] = false, }; - var config = MakeConfig(); + var config = MakeConfig(ConsentLevel.Full); config.EnableMobileAttribution = true; ImmutableAudience.Init(config); ImmutableAudience.Shutdown(); @@ -1459,6 +1459,64 @@ public void Init_GameLaunch_OmitsGaid_WhenUserOptedOut() "gaid must not appear when the user has opted out"); } + // ----------------------------------------------------------------- + // Consent-tier tightening: idfa, gaid => Full-only + // + // idfa and gaid are cross-app device identifiers, same privacy class + // as userId. They ship only when consent is Full. State-class keys + // (attStatus, gaidLimitAdTracking) are non-identifying and ship at + // Anonymous+Full (CanTrack). + // ----------------------------------------------------------------- + + [Test] + public void Init_GameLaunch_StripsIdfa_WhenConsentAnonymous() + { + ImmutableAudience.MobileAttributionContextProvider = () => + new Dictionary + { + ["attStatus"] = "authorized", + ["idfa"] = "11111111-2222-3333-4444-555555555555", + }; + var config = MakeConfig(ConsentLevel.Anonymous); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText) + .First(c => c.Contains("\"game_launch\"")); + StringAssert.Contains("\"attStatus\":\"authorized\"", launchFile, + "attStatus must ship at Anonymous: it is non-identifying state"); + Assert.IsFalse(launchFile.Contains("\"idfa\""), + "idfa must not ship at Anonymous: it is a cross-app device identifier"); + } + + [Test] + public void Init_GameLaunch_StripsGaid_WhenConsentAnonymous() + { + // gaid is stripped at Anonymous; gaidLimitAdTracking is non-identifying + // state and must still ship so the pipeline can distinguish + // "fetched, opted out" from "not fetched yet". + ImmutableAudience.MobileAttributionContextProvider = () => + new Dictionary + { + ["gaid"] = "abcdef01-2345-6789-abcd-ef0123456789", + ["gaidLimitAdTracking"] = false, + }; + var config = MakeConfig(ConsentLevel.Anonymous); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var launchFile = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText) + .First(c => c.Contains("\"game_launch\"")); + StringAssert.Contains("\"gaidLimitAdTracking\":false", launchFile, + "gaidLimitAdTracking must ship at Anonymous: it is non-identifying state"); + Assert.IsFalse(launchFile.Contains("\"gaid\""), + "gaid must not ship at Anonymous: it is a cross-app device identifier"); + } + // ----------------------------------------------------------------- // install_referrer_received // @@ -1473,7 +1531,7 @@ public void Init_FiresInstallReferrerReceived_WhenProviderReturnsReferrer() { ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=google-play&utm_medium=organic"; - var config = MakeConfig(); + var config = MakeConfig(ConsentLevel.Full); config.EnableMobileAttribution = true; ImmutableAudience.Init(config); ImmutableAudience.Shutdown(); @@ -1492,7 +1550,7 @@ public void Init_GameLaunch_NeverIncludesInstallReferrer() // installReferrer is exclusively on the dedicated event; ensure // we don't regress and start leaking it onto game_launch. ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=test"; - var config = MakeConfig(); + var config = MakeConfig(ConsentLevel.Full); config.EnableMobileAttribution = true; ImmutableAudience.Init(config); ImmutableAudience.Shutdown(); @@ -1544,7 +1602,7 @@ public void Init_DoesNotFireInstallReferrerReceived_WhenAlreadyFired() // Simulate the second launch: cache is populated, marker is set // by the previous Init. Event must not refire. ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=test"; - var config = MakeConfig(); + var config = MakeConfig(ConsentLevel.Full); config.EnableMobileAttribution = true; ImmutableAudience.Init(config); @@ -1554,7 +1612,7 @@ public void Init_DoesNotFireInstallReferrerReceived_WhenAlreadyFired() var queueDir = AudiencePaths.QueueDir(_testDir); foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); - var config2 = MakeConfig(); + var config2 = MakeConfig(ConsentLevel.Full); config2.EnableMobileAttribution = true; ImmutableAudience.Init(config2); ImmutableAudience.Shutdown(); @@ -1598,7 +1656,7 @@ public void Init_FiresInstallReferrerReceived_OnSecondLaunch_WhenFirstMissedCach ImmutableAudience.MobileInstallReferrerProvider = () => ++callCount == 1 ? firstCallReturn : secondCallReturn; - var config = MakeConfig(); + var config = MakeConfig(ConsentLevel.Full); config.EnableMobileAttribution = true; ImmutableAudience.Init(config); ImmutableAudience.Shutdown(); @@ -1612,7 +1670,7 @@ public void Init_FiresInstallReferrerReceived_OnSecondLaunch_WhenFirstMissedCach foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); - var config2 = MakeConfig(); + var config2 = MakeConfig(ConsentLevel.Full); config2.EnableMobileAttribution = true; ImmutableAudience.Init(config2); ImmutableAudience.Shutdown(); @@ -1643,6 +1701,62 @@ public void Init_InstallReferrerProviderThrows_DoesNotPreventGameLaunch() Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\""))); } + [Test] + public void Init_DoesNotFireInstallReferrerReceived_WhenConsentAnonymous() + { + // installReferrer encodes campaign attribution source; Full-only. + // The sent marker must NOT be written so a later upgrade to Full + // can fire the event. + ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=google-play"; + var config = MakeConfig(ConsentLevel.Anonymous); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var blobs = Directory.GetFiles(AudiencePaths.QueueDir(_testDir), "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(blobs.Any(c => c.Contains("\"install_referrer_received\"")), + "install_referrer_received must not fire when consent is Anonymous"); + Assert.IsFalse(File.Exists(AudiencePaths.InstallReferrerSentFile(_testDir)), + "sent marker must not be written at Anonymous so a Full upgrade can fire the event"); + } + + [Test] + public void Init_FiresInstallReferrerReceived_AfterConsentUpgradedToFull() + { + // First launch at Anonymous: referrer is available but event is + // gated; no event fires and no sent marker is written. + // Second launch at Full: event fires and marker is written. + ImmutableAudience.MobileInstallReferrerProvider = () => "utm_source=upgrade_test"; + + var config = MakeConfig(ConsentLevel.Anonymous); + config.EnableMobileAttribution = true; + ImmutableAudience.Init(config); + ImmutableAudience.Shutdown(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var firstBlobs = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(firstBlobs.Any(c => c.Contains("\"install_referrer_received\"")), + "event must not ship on first launch when consent is Anonymous"); + Assert.IsFalse(File.Exists(AudiencePaths.InstallReferrerSentFile(_testDir)), + "sent marker must not exist after Anonymous launch"); + + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + + var config2 = MakeConfig(ConsentLevel.Full); + config2.EnableMobileAttribution = true; + ImmutableAudience.Init(config2); + ImmutableAudience.Shutdown(); + + var secondBlobs = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsTrue(secondBlobs.Any(c => + c.Contains("\"install_referrer_received\"") && + c.Contains("\"installReferrer\":\"utm_source=upgrade_test\"")), + "event must fire on the first Full-consent launch after an Anonymous launch"); + } + // ----------------------------------------------------------------- // RequestTrackingAuthorizationAsync // ----------------------------------------------------------------- From 0bdc64e92108063a97c56de7a0b5a4e9df881dc3 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Mon, 11 May 2026 00:35:36 +1000 Subject: [PATCH 5/5] ci(audience): run linux PlayMode under xvfb (SDK-317 / SDK-318) - Reworks playmode-linux to run inside a docker container under xvfb, via .github/scripts/audience/playmode-linux.sh. game-ci/unity-test-runner@v4 hardcodes -nographics, so PlayMode tests came back inconclusive and silently passed. - Watchdog SIGTERMs Unity 30s after "Test run completed" so cells exit on suite finish; handles Unity 6's known shutdown hang. - -force-glcore at runtime skips the Unity 6 Vulkan init and matches the Unity 2021.3 default path. - Suppresses in-app log pane on Unity 6 Linux to skip llvmpipe rasterising UI Toolkit triangles per frame. - Stamps CI build info into Player.log on player startup; gated to CI runs only. - Mirrors SDK output and OnError fires to Debug.Log so failures land in Player.log. - DiskStore.ReadBatch treats missing queue dir as empty (matches existing guards). - Live-fire test SetUp ignores cleanup-time OnError fires so background flush cancellations do not fail unrelated tests. - Trims 30 unused packages from the sample-app manifest. - Extracts the macOS and Windows playmode runners and the Windows VS Build Tools setup to .github/scripts/, mirroring the Linux pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scripts/audience/ensure-msvc-windows.ps1 | 59 ++ .../scripts/audience/install-unity-macos.sh | 56 ++ .../audience/install-unity-windows.ps1 | 39 ++ .github/scripts/audience/matrix-shared.json | 20 + .../audience/playmode-linux-container.sh | 118 ++++ .github/scripts/audience/playmode-linux.sh | 27 + .github/scripts/audience/playmode-macos.sh | 45 ++ .github/scripts/audience/playmode-windows.ps1 | 51 ++ .../workflows/test-audience-sample-app.yml | 546 +++--------------- .../SampleApp/Scripts/AudienceSample.cs | 51 +- .../Tests/Runtime/LinuxLogPaneSuppression.cs | 48 ++ .../Runtime/LinuxLogPaneSuppression.cs.meta | 11 + .../Tests/Runtime/SampleAppLiveFireTests.cs | 40 +- .../Audience/Runtime/Transport/DiskStore.cs | 10 +- 14 files changed, 626 insertions(+), 495 deletions(-) create mode 100644 .github/scripts/audience/ensure-msvc-windows.ps1 create mode 100755 .github/scripts/audience/install-unity-macos.sh create mode 100644 .github/scripts/audience/install-unity-windows.ps1 create mode 100644 .github/scripts/audience/matrix-shared.json create mode 100755 .github/scripts/audience/playmode-linux-container.sh create mode 100755 .github/scripts/audience/playmode-linux.sh create mode 100755 .github/scripts/audience/playmode-macos.sh create mode 100644 .github/scripts/audience/playmode-windows.ps1 create mode 100644 examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs create mode 100644 examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta diff --git a/.github/scripts/audience/ensure-msvc-windows.ps1 b/.github/scripts/audience/ensure-msvc-windows.ps1 new file mode 100644 index 000000000..8dbba22f6 --- /dev/null +++ b/.github/scripts/audience/ensure-msvc-windows.ps1 @@ -0,0 +1,59 @@ +# Ensures Visual Studio Build Tools (VC.Tools + Win10 SDK) are present on the runner. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (Windows IL2CPP cells). + +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + +# Match Unity's detection logic: vswhere requires VC.Tools (any version), registry +# probe for any Win10 SDK at v10.0/InstallationFolder. Pinning a specific SDK +# version in -requires is too strict; VCTools ships with whatever Win10 SDK is +# current, and Unity accepts any. +function Test-Toolchain { + $vc = if (Test-Path $vswhere) { + & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null + } else { '' } + $sdk = (Get-ItemProperty 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows\v10.0' -ErrorAction SilentlyContinue).InstallationFolder + return @{ VcTools = $vc; Win10Sdk = $sdk } +} + +$state = Test-Toolchain +if ($state.VcTools -and $state.Win10Sdk) { + Write-Output "VC.Tools at: $($state.VcTools)" + Write-Output "Win10 SDK at: $($state.Win10Sdk)" + exit 0 +} +Write-Output "Toolchain incomplete. VC.Tools='$($state.VcTools)' Win10Sdk='$($state.Win10Sdk)'" + +Write-Output "::group::Install VS 2022 Build Tools (VCTools + Win10 SDK)" +$installer = "$env:RUNNER_TEMP\vs_BuildTools.exe" +Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_BuildTools.exe' -OutFile $installer + +$installArgs = @( + '--quiet','--wait','--norestart','--nocache', + '--add','Microsoft.VisualStudio.Workload.VCTools', + '--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '--add','Microsoft.VisualStudio.Component.Windows10SDK.20348', + '--includeRecommended' +) +$p = Start-Process -FilePath $installer -ArgumentList $installArgs -Wait -PassThru -NoNewWindow +# 3010 = success, reboot pending (tools are usable without reboot). +if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { + Write-Output "::error::VS Build Tools installer exited $($p.ExitCode)" + exit $p.ExitCode +} +Write-Output "::endgroup::" + +$state = Test-Toolchain +if (-not ($state.VcTools -and $state.Win10Sdk)) { + Write-Output "::group::diagnostic" + Write-Output "VC.Tools path (vswhere): '$($state.VcTools)'" + Write-Output "Win10 SDK (registry v10.0/InstallationFolder): '$($state.Win10Sdk)'" + Write-Output "--- all VS installations ---" + if (Test-Path $vswhere) { & $vswhere -all -products * -format json } + Write-Output "--- HKLM Win10 SDK roots ---" + Get-ChildItem 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows' -ErrorAction SilentlyContinue | Format-List + Write-Output "::endgroup::" + Write-Output "::error::Install reported success but VC.Tools or Win10 SDK still not detected. Runner service account likely lacks admin to install system-wide. Install VS Build Tools manually on IMX_SDKBUILD: vs_BuildTools.exe --quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" + exit 1 +} +Write-Output "Verified VC.Tools at: $($state.VcTools)" +Write-Output "Verified Win10 SDK at: $($state.Win10Sdk)" diff --git a/.github/scripts/audience/install-unity-macos.sh b/.github/scripts/audience/install-unity-macos.sh new file mode 100755 index 000000000..a54a4db6f --- /dev/null +++ b/.github/scripts/audience/install-unity-macos.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Installs the Unity editor and (for IL2CPP cells) the mac-il2cpp module. +# Idempotent. Sets UNITY_PATH in GITHUB_ENV so the playmode step picks it up. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# +# Inputs (env): UNITY_VERSION, UNITY_CHANGESET, BACKEND. + +set -uo pipefail + +HUB="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub" + +echo "::group::install editor" +"$HUB" -- --headless install \ + --version "$UNITY_VERSION" --changeset "$UNITY_CHANGESET" --architecture arm64 \ + || echo "(install non-zero, OK if 'Editor already installed in this location')" +echo "::endgroup::" + +if [ "$BACKEND" = "IL2CPP" ]; then + echo "::group::install mac-il2cpp module" + "$HUB" -- --headless install-modules \ + --version "$UNITY_VERSION" --changeset "$UNITY_CHANGESET" --architecture arm64 \ + --module mac-il2cpp \ + || echo "(install-modules non-zero, OK if 'No modules found to install')" + echo "::endgroup::" +fi + +EDITOR_APP="" +for cand in \ + "/Applications/Unity/Hub/Editor/$UNITY_VERSION-arm64/Unity.app" \ + "/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app"; do + if [ -x "$cand/Contents/MacOS/Unity" ]; then EDITOR_APP="$cand"; break; fi +done + +IL2CPP_DIR="" +if [ "$BACKEND" = "IL2CPP" ] && [ -n "$EDITOR_APP" ]; then + for d in \ + "$EDITOR_APP/Contents/PlaybackEngines/MacStandaloneSupport/Variations/macos_arm64_player_nondevelopment_il2cpp" \ + "$EDITOR_APP/Contents/PlaybackEngines/MacStandaloneSupport/Variations/macos_x64_player_nondevelopment_il2cpp"; do + if [ -d "$d" ]; then IL2CPP_DIR="$d"; break; fi + done +fi + +MISSING="" +[ -z "$EDITOR_APP" ] && MISSING="editor" +[ "$BACKEND" = "IL2CPP" ] && [ -z "$IL2CPP_DIR" ] && MISSING="${MISSING:+$MISSING+}mac-il2cpp" +if [ -n "$MISSING" ]; then + echo "::error::Unity $UNITY_VERSION missing: $MISSING" + ls -la /Applications/Unity/Hub/Editor/ 2>&1 || true + "$HUB" -- --headless editors --installed 2>&1 || true + exit 1 +fi + +UNITY_PATH="$EDITOR_APP/Contents/MacOS/Unity" +echo "Found Unity: $UNITY_PATH" +[ -n "$IL2CPP_DIR" ] && echo "Found IL2CPP: $IL2CPP_DIR" +echo "UNITY_PATH=$UNITY_PATH" >> "$GITHUB_ENV" diff --git a/.github/scripts/audience/install-unity-windows.ps1 b/.github/scripts/audience/install-unity-windows.ps1 new file mode 100644 index 000000000..ebceef1d4 --- /dev/null +++ b/.github/scripts/audience/install-unity-windows.ps1 @@ -0,0 +1,39 @@ +# Installs the Unity editor and (for IL2CPP cells) the windows-il2cpp module. +# Idempotent. Sets UNITY_PATH in GITHUB_ENV so the playmode step picks it up. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# +# Inputs (env): UNITY_VERSION, UNITY_CHANGESET, BACKEND. + +$ErrorActionPreference = 'Continue' +$hub = "C:\Program Files\Unity Hub\Unity Hub.exe" + +Write-Output "::group::install editor" +& $hub -- --headless install --version $env:UNITY_VERSION --changeset $env:UNITY_CHANGESET --architecture x86_64 2>&1 | Write-Output +if ($LASTEXITCODE -ne 0) { Write-Output "(install non-zero, OK if 'Editor already installed in this location')" } +$global:LASTEXITCODE = 0 +Write-Output "::endgroup::" + +if ($env:BACKEND -eq 'IL2CPP') { + Write-Output "::group::install windows-il2cpp module" + & $hub -- --headless install-modules --version $env:UNITY_VERSION --changeset $env:UNITY_CHANGESET --architecture x86_64 --module windows-il2cpp 2>&1 | Write-Output + if ($LASTEXITCODE -ne 0) { Write-Output "(install-modules non-zero, OK if 'No modules found to install')" } + $global:LASTEXITCODE = 0 + Write-Output "::endgroup::" +} + +$editor = "C:\Program Files\Unity\Hub\Editor\$env:UNITY_VERSION\Editor\Unity.exe" +$il2cpp = "C:\Program Files\Unity\Hub\Editor\$env:UNITY_VERSION\Editor\Data\PlaybackEngines\windowsstandalonesupport\Variations\win64_player_nondevelopment_il2cpp" +$missing = @() +if (-not (Test-Path $editor)) { $missing += 'editor' } +if ($env:BACKEND -eq 'IL2CPP' -and -not (Test-Path $il2cpp)) { $missing += 'windows-il2cpp' } +if ($missing.Count -gt 0) { + Write-Output "::error::Unity $env:UNITY_VERSION missing: $($missing -join '+')" + Get-ChildItem "C:\Program Files\Unity\Hub\Editor\" -ErrorAction SilentlyContinue | Format-Table + & $hub -- --headless editors --installed + exit 1 +} + +Write-Output "Found Unity: $editor" +if ($env:BACKEND -eq 'IL2CPP') { Write-Output "Found IL2CPP: $il2cpp" } + +"UNITY_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 diff --git a/.github/scripts/audience/matrix-shared.json b/.github/scripts/audience/matrix-shared.json new file mode 100644 index 000000000..91018b4f9 --- /dev/null +++ b/.github/scripts/audience/matrix-shared.json @@ -0,0 +1,20 @@ +{ + "unity_versions": [ + { "version": "2021.3.45f2", "changeset": "88f88f591b2e" }, + { "version": "6000.4.0f1", "changeset": "8cf496087c8f" }, + { "version": "2022.3.62f2", "changeset": "7670c08855a9" } + ], + "scripting_backends": ["IL2CPP", "Mono2x"], + "desktop_targets": [ + { "target": "StandaloneWindows64", "runner": ["self-hosted", "Windows", "X64"], "install_unity_script": ".github/scripts/audience/install-unity-windows.ps1", "run_playmode_script": ".github/scripts/audience/playmode-windows.ps1" }, + { "target": "StandaloneOSX", "runner": ["self-hosted", "macOS", "ARM64"], "install_unity_script": ".github/scripts/audience/install-unity-macos.sh", "run_playmode_script": ".github/scripts/audience/playmode-macos.sh" }, + { "target": "StandaloneLinux64", "runner": "ubuntu-latest-8-cores", "install_unity_script": "", "run_playmode_script": ".github/scripts/audience/playmode-linux.sh" } + ], + "mobile_targets": [ + { "target": "Android", "build_player_method": "AndroidBuilder.Build" }, + { "target": "iOS", "build_player_method": "IosBuilder.Build" } + ], + "pr_exclude": [ + { "unity": { "version": "2022.3.62f2", "changeset": "7670c08855a9" } } + ] +} diff --git a/.github/scripts/audience/playmode-linux-container.sh b/.github/scripts/audience/playmode-linux-container.sh new file mode 100755 index 000000000..991f73eeb --- /dev/null +++ b/.github/scripts/audience/playmode-linux-container.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Audience SDK PlayMode test runner for Linux: in-container body. +# Runs inside the unityci/editor:ubuntu-X-linux-il2cpp-3 container. +# Caller: .github/scripts/audience/playmode-linux.sh (host-side docker wrapper). + +set -uo pipefail + +LOG=/github/workspace/artifacts/unity.log +ACTIVATION_LOG=/github/workspace/artifacts/activation.log +RESULTS=/github/workspace/artifacts/test-results.xml +PROJECT=/github/workspace/examples/audience + +test_rc=1 + +activate_license() { + unity-editor -batchmode -nographics -quit \ + -username "$UNITY_EMAIL" \ + -password "$UNITY_PASSWORD" \ + -serial "$UNITY_SERIAL" \ + -logFile - 2>&1 | tee "$ACTIVATION_LOG" || true + + if grep -qE "License activation has failed|\[Licensing::Client\] Error: Code [0-9]+" "$ACTIVATION_LOG"; then + echo "::error::Unity license activation failed." + exit 1 + fi + if ! grep -qE "Successfully activated the entitlement license" "$ACTIVATION_LOG"; then + echo "::error::Unity license activation: no success marker in log." + exit 1 + fi +} + +run_tests_with_watchdog() { + # xvfb-run gives Unity a virtual X display. UI Toolkit needs GLX + render; + # llvmpipe in the image provides software OpenGL so no GPU is needed. + # -force-glcore skips the Unity 6 Vulkan init and matches the Unity 2021.3 default path. + xvfb-run -a --server-args="-ac +extension GLX +render -noreset" -- \ + unity-editor \ + -batchmode \ + -force-glcore \ + -screen-fullscreen 0 \ + -screen-width 320 \ + -screen-height 240 \ + -projectPath "$PROJECT" \ + -runTests \ + -testPlatform StandaloneLinux64 \ + -testResults "$RESULTS" \ + -logFile "$LOG" & + local unity_pid=$! + + # Mirror Unity log to job stdout while the editor is alive. + tail --pid=$unity_pid -F "$LOG" 2>/dev/null & + + # Watchdog (vs fixed timeout) because per-version run length varies wildly: + # Unity 2021.3 cells finish in ~2 min, Unity 6 in ~22 min, and Unity 6 has a + # known post-test shutdown hang. SIGTERM 30 s after "Test run completed" so + # each cell exits as soon as its suite finishes. 40 min hard cap as fallback. + local deadline=$((SECONDS + 2400)) + local flush_deadline=0 + local kill_reason="" + while kill -0 "$unity_pid" 2>/dev/null; do + if [ "$SECONDS" -ge "$deadline" ]; then + kill_reason="hard-cap-40m" + break + fi + if [ "$flush_deadline" -eq 0 ] && grep -q "Test run completed" "$LOG" 2>/dev/null; then + flush_deadline=$((SECONDS + 30)) + echo "[watchdog] saw \"Test run completed\" at ${SECONDS}s; SIGTERM after 30s flush window" + fi + if [ "$flush_deadline" -gt 0 ] && [ "$SECONDS" -ge "$flush_deadline" ]; then + kill_reason="flush-window-elapsed" + break + fi + sleep 5 + done + + if [ -n "$kill_reason" ]; then + echo "[watchdog] sending SIGTERM to Unity (reason: $kill_reason)" + kill -TERM "$unity_pid" 2>/dev/null || true + # 15 s grace, then SIGKILL. + for _ in 1 2 3; do + kill -0 "$unity_pid" 2>/dev/null || break + sleep 5 + done + if kill -0 "$unity_pid" 2>/dev/null; then + echo "[watchdog] SIGTERM not honored, sending SIGKILL" + kill -KILL "$unity_pid" 2>/dev/null || true + fi + fi + + wait "$unity_pid" 2>/dev/null + test_rc=$? + if [ "$kill_reason" = "hard-cap-40m" ]; then + echo "::warning::Unity hit the 40 min hard cap without logging \"Test run completed\". Inspect Player.log." + fi +} + +capture_player_log() { + # Player runs in a separate process from the editor; copy its Player.log so + # HTTP traces and OnError fires are captured. Glob across companies / products. + find /root/.config/unity3d -name "Player.log" 2>/dev/null | while IFS= read -r f; do + co=$(basename "$(dirname "$(dirname "$f")")") + pr=$(basename "$(dirname "$f")") + cp "$f" "/github/workspace/artifacts/Player-${co}-${pr}.log" 2>/dev/null || true + done +} + +return_license() { + # Always return the seat to keep the activation pool from exhausting on reruns. + unity-editor -batchmode -nographics -quit -returnlicense -logFile - 2>&1 || true +} + +activate_license +run_tests_with_watchdog +capture_player_log +return_license + +# Unity exits 2 on test failure or inconclusive; propagate so the step fails. +exit "$test_rc" diff --git a/.github/scripts/audience/playmode-linux.sh b/.github/scripts/audience/playmode-linux.sh new file mode 100755 index 000000000..057821793 --- /dev/null +++ b/.github/scripts/audience/playmode-linux.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Audience SDK PlayMode test runner for Linux: host-side docker wrapper. +# Runs unityci/editor with the env and volume mounts the inner +# playmode-linux-container.sh expects. Lives outside the container so the +# workflow can launch all 3 desktop platforms from one matrix-shared.json entry. +# +# Manual docker run because game-ci/unity-test-runner@v4 hardcodes +# -nographics. Without a virtual display every PlayMode test comes back +# inconclusive, and the action's USE_EXIT_CODE=false suppresses Unity +# exit 2, so cells went silently green. +# +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# Inputs (env): UNITY_VERSION, UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL, +# AUDIENCE_TEST_PUBLISHABLE_KEY, AUDIENCE_SCRIPTING_BACKEND. + +set -uo pipefail +mkdir -p artifacts + +docker run --rm \ + --workdir /github/workspace \ + --env UNITY_EMAIL --env UNITY_PASSWORD --env UNITY_SERIAL \ + --env AUDIENCE_TEST_PUBLISHABLE_KEY --env AUDIENCE_SCRIPTING_BACKEND \ + --env AUDIENCE_TEST_RUN_ID --env AUDIENCE_TEST_CELL_ID \ + --volume "$PWD":/github/workspace:z \ + --cpus=8 --memory=30487m \ + "unityci/editor:ubuntu-${UNITY_VERSION}-linux-il2cpp-3" \ + bash /github/workspace/.github/scripts/audience/playmode-linux-container.sh diff --git a/.github/scripts/audience/playmode-macos.sh b/.github/scripts/audience/playmode-macos.sh new file mode 100755 index 000000000..881e817d1 --- /dev/null +++ b/.github/scripts/audience/playmode-macos.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Runs the audience PlayMode tests on macOS. Captures Player.log into artifacts/. +# Surfaces Unity compile errors as ::error:: annotations. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# +# Inputs (env): UNITY_PATH (set by install-unity-macos.sh), TARGET. + +set -uo pipefail + +LOG=artifacts/unity.log +RESULTS="$(pwd)/artifacts/test-results.xml" + +mkdir -p artifacts + +# Tee Unity stdout to artifacts/unity.log so the annotation step has a file +# to scan; pipefail propagates Unity's exit code through tee. +"$UNITY_PATH" \ + -batchmode -nographics \ + -projectPath examples/audience \ + -runTests \ + -testPlatform "$TARGET" \ + -testResults "$RESULTS" \ + -logFile - 2>&1 | tee "$LOG" +test_rc=${PIPESTATUS[0]} + +# Player runs as a separate process; copy its Player.log so HTTP traces and +# OnError fires are captured. Glob across companies and products. +src="$HOME/Library/Logs" +if [ -d "$src" ]; then + find "$src" -name "Player.log" 2>/dev/null | while IFS= read -r f; do + cp "$f" "artifacts/Player-$(basename "$(dirname "$f")").log" 2>/dev/null || true + done +fi + +# Promote Unity compile errors to ::error:: annotations. Sanitize '::' so log +# lines containing workflow commands cannot terminate the annotation early. +if [ -f "$LOG" ]; then + grep -E '(error CS[0-9]+:|Compilation failed:)' "$LOG" | sort -u | while IFS= read -r line; do + trimmed="${line#"${line%%[![:space:]]*}"}" + sanitized="${trimmed//::/%3A%3A}" + echo "::error::$sanitized" + done || true +fi + +exit "$test_rc" diff --git a/.github/scripts/audience/playmode-windows.ps1 b/.github/scripts/audience/playmode-windows.ps1 new file mode 100644 index 000000000..5b718d05c --- /dev/null +++ b/.github/scripts/audience/playmode-windows.ps1 @@ -0,0 +1,51 @@ +# Runs the audience PlayMode tests on Windows. Captures Player.log into artifacts/. +# Surfaces Unity compile errors as ::error:: annotations. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# +# Inputs (env): UNITY_PATH (set by install-unity-windows.ps1), TARGET. + +$ErrorActionPreference = 'Continue' +$logFile = "$pwd\artifacts\unity.log" +$resultsFile = "$pwd\artifacts\test-results.xml" + +New-Item -ItemType Directory -Force -Path artifacts | Out-Null + +$unityArgs = @( + '-batchmode','-nographics', + '-projectPath','examples/audience', + '-runTests', + '-testPlatform',$env:TARGET, + '-testResults',$resultsFile, + '-logFile',$logFile +) +Write-Output "Launching Unity: $env:UNITY_PATH $($unityArgs -join ' ')" +$p = Start-Process -FilePath $env:UNITY_PATH -ArgumentList $unityArgs -Wait -PassThru -NoNewWindow +Write-Output "::group::Unity log" +Get-Content $logFile -ErrorAction SilentlyContinue | Write-Output +Write-Output "::endgroup::" +Write-Output "Unity exited with code $($p.ExitCode)" + +# Copy Player.log files into artifacts so HTTP traces and OnError fires survive. +$src = "$env:USERPROFILE\AppData\LocalLow" +if (Test-Path $src) { + Get-ChildItem -Path $src -Recurse -Filter "Player.log" -ErrorAction SilentlyContinue | + ForEach-Object { + $name = $_.Directory.Name + Copy-Item -Path $_.FullName -Destination "artifacts/Player-$name.log" -ErrorAction SilentlyContinue + } +} + +# Promote Unity compile errors to ::error:: annotations. Sanitize '::' so log +# lines containing workflow commands cannot terminate the annotation early. +if (Test-Path $logFile) { + Get-Content $logFile | + Select-String -Pattern '(error CS\d+:|Compilation failed:)' | + ForEach-Object { $_.Line.Trim() } | + Sort-Object -Unique | + ForEach-Object { + $sanitized = $_ -replace '::', '%3A%3A' + Write-Output "::error::$sanitized" + } +} + +exit $p.ExitCode diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 9263173ef..5ab2cf34f 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -1,4 +1,4 @@ -name: Audience SDK — PlayMode (IL2CPP + Mono) +name: Audience SDK PlayMode (IL2CPP + Mono) on: pull_request: @@ -13,11 +13,7 @@ on: - 'examples/audience/ProjectSettings/**' - '.github/workflows/test-audience-sample-app.yml' schedule: - # Weekly full-matrix run on the default branch. - # Cron is UTC; cron has no DST awareness, so the local time shifts by one - # hour twice a year. Saturday 14:00 UTC maps to: - # Sun 00:00 Sydney AEST / Sun 02:00 NZ NZST (winter, Apr to Oct) - # Sun 01:00 Sydney AEDT / Sun 03:00 NZ NZDT (summer, Oct to Apr) + # Weekly full-matrix run on the default branch. Saturday 14:00 UTC. - cron: '0 14 * * 6' workflow_dispatch: @@ -25,123 +21,61 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# CI run id stamped into the player for CDP filtering. Per-cell id set on jobs below. +env: + AUDIENCE_TEST_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + jobs: - # The playmode, playmode-linux and mobile-build matrices are built here - # and consumed by the dependent jobs via fromJSON. PR runs trim Unity - # 2022.3.62f2 cells; schedule and workflow_dispatch run the full set. - set-matrix: + # SSOT for the unity matrix and the PR-only Unity 2022 exclude. Both + # playmode and mobile-build consume these outputs via fromJSON. Source + # data lives in .github/scripts/audience/matrix-shared.json. + setup: + if: | + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) + || github.event_name == 'schedule' + || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest outputs: - playmode: ${{ steps.set.outputs.playmode }} - playmode_linux: ${{ steps.set.outputs.playmode_linux }} - mobile: ${{ steps.set.outputs.mobile }} + unity_versions: ${{ steps.set.outputs.unity_versions }} + scripting_backends: ${{ steps.set.outputs.scripting_backends }} + desktop_targets: ${{ steps.set.outputs.desktop_targets }} + mobile_targets: ${{ steps.set.outputs.mobile_targets }} + pr_exclude: ${{ steps.set.outputs.pr_exclude }} steps: + - uses: actions/checkout@v4 - id: set shell: bash run: | - playmode_full='[ - {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneWindows64","backend":"Mono2x","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneOSX","backend":"IL2CPP","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneOSX","backend":"Mono2x","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneWindows64","backend":"Mono2x","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneOSX","backend":"IL2CPP","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneOSX","backend":"Mono2x","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneWindows64","backend":"Mono2x","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneOSX","backend":"IL2CPP","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneOSX","backend":"Mono2x","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","macOS","ARM64"]} - ]' - playmode_linux_full='[ - {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"2021.3.45f2"}, - {"target":"StandaloneLinux64","backend":"Mono2x","unity":"2021.3.45f2"}, - {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"6000.4.0f1"}, - {"target":"StandaloneLinux64","backend":"Mono2x","unity":"6000.4.0f1"}, - {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"2022.3.62f2"}, - {"target":"StandaloneLinux64","backend":"Mono2x","unity":"2022.3.62f2"} - ]' - mobile_full='[ - {"target":"Android","unity":"2021.3.45f2","method":"AndroidBuilder.Build"}, - {"target":"Android","unity":"2022.3.62f2","method":"AndroidBuilder.Build"}, - {"target":"Android","unity":"6000.4.0f1","method":"AndroidBuilder.Build"}, - {"target":"iOS","unity":"2021.3.45f2","method":"IosBuilder.Build"}, - {"target":"iOS","unity":"2022.3.62f2","method":"IosBuilder.Build"}, - {"target":"iOS","unity":"6000.4.0f1","method":"IosBuilder.Build"} - ]' + f=.github/scripts/audience/matrix-shared.json + for key in unity_versions scripting_backends desktop_targets mobile_targets; do + echo "$key=$(jq -c ".$key" "$f")" >> "$GITHUB_OUTPUT" + done if [[ "${{ github.event_name }}" == "pull_request" ]]; then - filter='[.[] | select(.unity != "2022.3.62f2")]' - playmode=$(jq -c "$filter" <<<"$playmode_full") - playmode_linux=$(jq -c "$filter" <<<"$playmode_linux_full") - mobile=$(jq -c "$filter" <<<"$mobile_full") + # Drop Unity 2022.3.62f2 for PRs. The full set runs on schedule and workflow_dispatch. + echo "pr_exclude=$(jq -c .pr_exclude "$f")" >> "$GITHUB_OUTPUT" else - playmode=$(jq -c '.' <<<"$playmode_full") - playmode_linux=$(jq -c '.' <<<"$playmode_linux_full") - mobile=$(jq -c '.' <<<"$mobile_full") + echo 'pr_exclude=[]' >> "$GITHUB_OUTPUT" fi - { - echo "playmode=$playmode" - echo "playmode_linux=$playmode_linux" - echo "mobile=$mobile" - } >> "$GITHUB_OUTPUT" playmode: - needs: set-matrix - if: | - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) - || github.event_name == 'schedule' - || github.event_name == 'workflow_dispatch' - name: ${{ matrix.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity }} + needs: setup + name: ${{ matrix.platform.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity.version }} strategy: fail-fast: false matrix: - include: ${{ fromJSON(needs.set-matrix.outputs.playmode) }} - runs-on: ${{ matrix.runner }} - # Healthy cells finish in ~10 min. 30 min covers cold caches + - # IL2CPP + Unity 6 startup; anything past that is a hang. Capping - # short releases the self-hosted runner sooner so queued cells can - # progress instead of waiting 60 min on a stuck job. - timeout-minutes: 30 + unity: ${{ fromJSON(needs.setup.outputs.unity_versions) }} + platform: ${{ fromJSON(needs.setup.outputs.desktop_targets) }} + backend: ${{ fromJSON(needs.setup.outputs.scripting_backends) }} + exclude: ${{ fromJSON(needs.setup.outputs.pr_exclude) }} + runs-on: ${{ matrix.platform.runner }} + timeout-minutes: 45 + env: + AUDIENCE_TEST_CELL_ID: ${{ matrix.platform.target }}-${{ matrix.backend }}-${{ matrix.unity.version }} + # Single-source scope strings: empty when the step does not run, target-or-target+backend label when it does. + INSTALL_UNITY_SCOPE: ${{ matrix.platform.install_unity_script != '' && matrix.platform.target || '' }} + INSTALL_VS_TOOLS_SCOPE: ${{ matrix.platform.target == 'StandaloneWindows64' && matrix.backend == 'IL2CPP' && format('{0} {1}', matrix.platform.target, matrix.backend) || '' }} steps: - - name: Clean Windows workspace (pre-checkout) - if: runner.os == 'Windows' - shell: pwsh - continue-on-error: true - run: | - # actions/checkout@v4 removes the prior workspace before cloning. If - # a previous run's Unity build / IL2CPP linker / bee_backend / shader - # compiler is still holding handles, checkout dies with EBUSY on - # examples/audience. Kill known offenders, then force-remove the - # workspace contents ourselves so checkout's cleanup succeeds. - Get-Process | Where-Object { - $_.Name -like 'Unity*' -or - $_.Name -like 'il2cpp*' -or - $_.Name -like 'UnityShaderCompiler*' -or - $_.Name -like 'UnityCrashHandler*' -or - $_.Name -like 'bee_backend*' -or - $_.Name -like 'mono*' - } | ForEach-Object { - Write-Host "Killing stale process: $($_.Name) (pid $($_.Id))" - Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue - } - Start-Sleep -Seconds 3 - - $ws = "$env:GITHUB_WORKSPACE" - if (-not (Test-Path $ws)) { return } - for ($i = 1; $i -le 6; $i++) { - try { - Get-ChildItem -Path $ws -Force -ErrorAction Stop | - Remove-Item -Recurse -Force -ErrorAction Stop - Write-Host "Cleaned $ws on attempt ${i}" - return - } catch { - Write-Host "Attempt ${i}: $($_.Exception.Message)" - Start-Sleep -Seconds 3 - } - } - Write-Host "::warning::Workspace not fully cleaned; checkout may fail" - - uses: actions/checkout@v4 with: lfs: true @@ -150,393 +84,63 @@ jobs: uses: actions/cache@v4 with: path: examples/audience/Library - key: Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} + key: Library-${{ matrix.backend }}-${{ matrix.platform.target }}-${{ matrix.unity.version }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} restore-keys: | - Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}- - Library-${{ matrix.backend }}-${{ matrix.target }}- - - - name: Ensure MSVC + Windows 10 SDK (Windows IL2CPP) - if: runner.os == 'Windows' && matrix.backend == 'IL2CPP' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - - # Match Unity's detection logic exactly: vswhere requires VC.Tools - # (any version), registry probe for any Win10 SDK at v10.0/InstallationFolder. - # Pinning a specific SDK version in -requires is too strict — VCTools - # ships with whatever Win10 SDK is current, and Unity accepts any. - function Test-Toolchain { - $vc = if (Test-Path $vswhere) { - & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null - } else { '' } - $sdk = (Get-ItemProperty 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows\v10.0' -ErrorAction SilentlyContinue).InstallationFolder - return @{ VcTools = $vc; Win10Sdk = $sdk } - } - - $state = Test-Toolchain - if ($state.VcTools -and $state.Win10Sdk) { - Write-Host "VC.Tools at: $($state.VcTools)" - Write-Host "Win10 SDK at: $($state.Win10Sdk)" - exit 0 - } - Write-Host "Toolchain incomplete. VC.Tools='$($state.VcTools)' Win10Sdk='$($state.Win10Sdk)'" - - Write-Host "::group::Install VS 2022 Build Tools (VCTools + Win10 SDK)" - $installer = "$env:RUNNER_TEMP\vs_BuildTools.exe" - Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_BuildTools.exe' -OutFile $installer - - $installArgs = @( - '--quiet','--wait','--norestart','--nocache', - '--add','Microsoft.VisualStudio.Workload.VCTools', - '--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64', - '--add','Microsoft.VisualStudio.Component.Windows10SDK.20348', - '--includeRecommended' - ) - $p = Start-Process -FilePath $installer -ArgumentList $installArgs -Wait -PassThru -NoNewWindow - # 3010 = success, reboot pending (tools are usable without reboot). - if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { - Write-Host "::error::VS Build Tools installer exited $($p.ExitCode)" - exit $p.ExitCode - } - Write-Host "::endgroup::" - - $state = Test-Toolchain - if (-not ($state.VcTools -and $state.Win10Sdk)) { - Write-Host "::group::diagnostic" - Write-Host "VC.Tools path (vswhere): '$($state.VcTools)'" - Write-Host "Win10 SDK (registry v10.0/InstallationFolder): '$($state.Win10Sdk)'" - Write-Host "--- all VS installations ---" - if (Test-Path $vswhere) { & $vswhere -all -products * -format json } - Write-Host "--- HKLM Win10 SDK roots ---" - Get-ChildItem 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows' -ErrorAction SilentlyContinue | Format-List - Write-Host "::endgroup::" - Write-Host "::error::Install reported success but VC.Tools or Win10 SDK still not detected — runner service account likely lacks admin to install system-wide. Install VS Build Tools manually on IMX_SDKBUILD: vs_BuildTools.exe --quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" - exit 1 - } - Write-Host "Verified VC.Tools at: $($state.VcTools)" - Write-Host "Verified Win10 SDK at: $($state.Win10Sdk)" - - - name: Resolve Unity ${{ matrix.unity }} (macOS) - if: runner.os == 'macOS' - shell: bash - env: - UNITY_VER: ${{ matrix.unity }} - UNITY_CS: ${{ matrix.changeset }} - run: | - set -uo pipefail - HUB="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub" - - echo "::group::install editor" - "$HUB" -- --headless install \ - --version "$UNITY_VER" --changeset "$UNITY_CS" --architecture arm64 \ - || echo "(install non-zero — OK if 'Editor already installed in this location')" - echo "::endgroup::" + Library-${{ matrix.backend }}-${{ matrix.platform.target }}-${{ matrix.unity.version }}- + Library-${{ matrix.backend }}-${{ matrix.platform.target }}- - if [ "${{ matrix.backend }}" = "IL2CPP" ]; then - echo "::group::install mac-il2cpp module" - "$HUB" -- --headless install-modules \ - --version "$UNITY_VER" --changeset "$UNITY_CS" --architecture arm64 \ - --module mac-il2cpp \ - || echo "(install-modules non-zero — OK if 'No modules found to install')" - echo "::endgroup::" - fi - - EDITOR_APP="" - for cand in \ - "/Applications/Unity/Hub/Editor/$UNITY_VER-arm64/Unity.app" \ - "/Applications/Unity/Hub/Editor/$UNITY_VER/Unity.app"; do - if [ -x "$cand/Contents/MacOS/Unity" ]; then EDITOR_APP="$cand"; break; fi - done - - IL2CPP_DIR="" - if [ "${{ matrix.backend }}" = "IL2CPP" ] && [ -n "$EDITOR_APP" ]; then - for d in \ - "$EDITOR_APP/Contents/PlaybackEngines/MacStandaloneSupport/Variations/macos_arm64_player_nondevelopment_il2cpp" \ - "$EDITOR_APP/Contents/PlaybackEngines/MacStandaloneSupport/Variations/macos_x64_player_nondevelopment_il2cpp"; do - if [ -d "$d" ]; then IL2CPP_DIR="$d"; break; fi - done - fi - - MISSING="" - [ -z "$EDITOR_APP" ] && MISSING="editor" - [ "${{ matrix.backend }}" = "IL2CPP" ] && [ -z "$IL2CPP_DIR" ] && MISSING="${MISSING:+$MISSING+}mac-il2cpp" - if [ -n "$MISSING" ]; then - echo "::error::Unity $UNITY_VER missing: $MISSING" - ls -la /Applications/Unity/Hub/Editor/ 2>&1 || true - "$HUB" -- --headless editors --installed 2>&1 || true - exit 1 - fi - - echo "Found Unity: $EDITOR_APP/Contents/MacOS/Unity" - [ -n "$IL2CPP_DIR" ] && echo "Found IL2CPP: $IL2CPP_DIR" - echo "UNITY_PATH=$EDITOR_APP/Contents/MacOS/Unity" >> "$GITHUB_ENV" - - - name: Resolve Unity ${{ matrix.unity }} (Windows) - if: runner.os == 'Windows' - shell: pwsh - env: - UNITY_VER: ${{ matrix.unity }} - UNITY_CS: ${{ matrix.changeset }} - run: | - $hub = "C:\Program Files\Unity Hub\Unity Hub.exe" - - Write-Host "::group::install editor" - $installArgs = @('--','--headless','install','--version',$env:UNITY_VER,'--changeset',$env:UNITY_CS,'--architecture','x86_64') - & $hub @installArgs 2>&1 | Write-Host - if ($LASTEXITCODE -ne 0) { Write-Host "(install non-zero — OK if 'Editor already installed in this location')" } - $global:LASTEXITCODE = 0 - Write-Host "::endgroup::" - - if ('${{ matrix.backend }}' -eq 'IL2CPP') { - Write-Host "::group::install windows-il2cpp module" - $modArgs = @('--','--headless','install-modules','--version',$env:UNITY_VER,'--changeset',$env:UNITY_CS,'--architecture','x86_64','--module','windows-il2cpp') - & $hub @modArgs 2>&1 | Write-Host - if ($LASTEXITCODE -ne 0) { Write-Host "(install-modules non-zero — OK if 'No modules found to install')" } - $global:LASTEXITCODE = 0 - Write-Host "::endgroup::" - } - - $editor = "C:\Program Files\Unity\Hub\Editor\$env:UNITY_VER\Editor\Unity.exe" - $il2cpp = "C:\Program Files\Unity\Hub\Editor\$env:UNITY_VER\Editor\Data\PlaybackEngines\windowsstandalonesupport\Variations\win64_player_nondevelopment_il2cpp" - $missing = @() - if (-not (Test-Path $editor)) { $missing += 'editor' } - if ('${{ matrix.backend }}' -eq 'IL2CPP' -and -not (Test-Path $il2cpp)) { $missing += 'windows-il2cpp' } - if ($missing.Count -gt 0) { - Write-Host "::error::Unity $env:UNITY_VER missing: $($missing -join '+')" - Get-ChildItem "C:\Program Files\Unity\Hub\Editor\" -ErrorAction SilentlyContinue | Format-Table - & $hub -- --headless editors --installed - exit 1 - } - - Write-Host "Found Unity: $editor" - if ('${{ matrix.backend }}' -eq 'IL2CPP') { Write-Host "Found IL2CPP: $il2cpp" } - "UNITY_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Run PlayMode tests (macOS) - if: runner.os == 'macOS' - shell: bash - env: - AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} - AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} - run: | - set -euo pipefail - mkdir -p artifacts - # Tee Unity's stdout to artifacts/unity.log so the annotation step has a - # file to scan, while still streaming progress to the job log. pipefail - # propagates Unity's exit code through tee. The annotation step reads this - # file in-job; the actions/upload-artifact step below also uploads it so - # compile failures retain a full post-mortem (annotations are matched-line - # only and drop IL2CPP linker output, build config dumps, etc). - "$UNITY_PATH" \ - -batchmode -nographics \ - -projectPath examples/audience \ - -runTests \ - -testPlatform ${{ matrix.target }} \ - -testResults "$(pwd)/artifacts/test-results.xml" \ - -logFile - 2>&1 | tee "$(pwd)/artifacts/unity.log" - - - name: Run PlayMode tests (Windows) - if: runner.os == 'Windows' - shell: pwsh + - name: Detect or Install Unity${{ env.INSTALL_UNITY_SCOPE && format(' ({0} only)', env.INSTALL_UNITY_SCOPE) || '' }} + if: env.INSTALL_UNITY_SCOPE env: - AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} - AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} - run: | - New-Item -ItemType Directory -Force -Path artifacts | Out-Null - $logFile = "$pwd\artifacts\unity.log" - $unityArgs = @( - '-batchmode','-nographics', - '-projectPath','examples/audience', - '-runTests', - '-testPlatform','${{ matrix.target }}', - '-testResults',"$pwd\artifacts\test-results.xml", - '-logFile',$logFile - ) - Write-Host "Launching Unity: $env:UNITY_PATH $($unityArgs -join ' ')" - $p = Start-Process -FilePath $env:UNITY_PATH -ArgumentList $unityArgs -Wait -PassThru -NoNewWindow - $exit = $p.ExitCode - Write-Host "::group::Unity log" - Get-Content $logFile -ErrorAction SilentlyContinue | Write-Host - Write-Host "::endgroup::" - Write-Host "Unity exited with code $exit" - if ($exit -ne 0) { exit $exit } - - - name: Mark workspace safe for git (Windows) - if: always() && runner.os == 'Windows' - shell: pwsh - run: | - git config --global --add safe.directory $env:GITHUB_WORKSPACE.Replace('\','/') - - - name: Capture player log (macOS) - if: always() && runner.os == 'macOS' - shell: bash - run: | - # The test-runner builds + launches a player binary that writes its own - # log separately from Unity's editor log. When the editor reports - # "Test execution timed out. No activity received from the player ..." - # the editor unity.log alone cannot tell us whether the player crashed, - # hung, or never started. Copy whatever Player.log files Unity wrote - # into artifacts/ so the upload-artifact step preserves them. - mkdir -p artifacts - src="$HOME/Library/Logs" - if [ -d "$src" ]; then - find "$src" -name "Player.log" 2>/dev/null | while IFS= read -r f; do - cp "$f" "artifacts/Player-$(basename "$(dirname "$f")").log" 2>/dev/null || true - done - fi - - - name: Capture player log (Windows) - if: always() && runner.os == 'Windows' - shell: pwsh - run: | - # See macOS counterpart for rationale. Windows player log location: - # %USERPROFILE%\AppData\LocalLow\\\Player.log - New-Item -ItemType Directory -Force -Path artifacts | Out-Null - $src = "$env:USERPROFILE\AppData\LocalLow" - if (Test-Path $src) { - Get-ChildItem -Path $src -Recurse -Filter "Player.log" -ErrorAction SilentlyContinue | - ForEach-Object { - $name = $_.Directory.Name - Copy-Item -Path $_.FullName -Destination "artifacts/Player-$name.log" -ErrorAction SilentlyContinue - } - } + UNITY_VERSION: ${{ matrix.unity.version }} + UNITY_CHANGESET: ${{ matrix.unity.changeset }} + BACKEND: ${{ matrix.backend }} + run: ${{ matrix.platform.install_unity_script }} - - name: Surface Unity compile errors as annotations (macOS) - if: always() && runner.os == 'macOS' - shell: bash - run: | - set -uo pipefail - # Unity writes compile errors as 'error CS####:' or 'Compilation failed: '. - # When a cell fails compile (vs fails a test), the test-results.xml is empty - # and the only signal otherwise is the artifact zip. Promote those lines to - # ::error:: annotations so the PR UI shows the cause inline. - LOG_FILE="artifacts/unity.log" - if [ ! -f "$LOG_FILE" ]; then - echo "::notice::No Unity log file at $LOG_FILE." - exit 0 - fi - # `|| true` guards the success path: with `pipefail`, grep exits 1 when no - # matches (the clean-build case), which would otherwise propagate as the - # step's exit code and falsely mark every green cell red. - grep -E '(error CS[0-9]+:|Compilation failed:)' "$LOG_FILE" | sort -u | while IFS= read -r line; do - trimmed="${line#"${line%%[![:space:]]*}"}" - # Sanitize '::' so log lines containing workflow commands (e.g. ::endgroup::) - # cannot terminate the annotation early or inject other commands. - sanitized="${trimmed//::/%3A%3A}" - echo "::error::$sanitized" - done || true - - - name: Surface Unity compile errors as annotations (Windows) - if: always() && runner.os == 'Windows' + - name: Detect or Install VS Build Tools${{ env.INSTALL_VS_TOOLS_SCOPE && format(' ({0} only)', env.INSTALL_VS_TOOLS_SCOPE) || '' }} + if: env.INSTALL_VS_TOOLS_SCOPE shell: pwsh - run: | - $logFile = "artifacts\unity.log" - if (-not (Test-Path $logFile)) { - Write-Host "::notice::No Unity log file at $logFile." - exit 0 - } - Get-Content $logFile | - Select-String -Pattern '(error CS\d+:|Compilation failed:)' | - ForEach-Object { $_.Line.Trim() } | - Sort-Object -Unique | - ForEach-Object { - # Sanitize '::' so log lines containing workflow commands cannot - # terminate the annotation early or inject other commands. - $sanitized = $_ -replace '::', '%3A%3A' - Write-Host "::error::$sanitized" - } - - - name: Publish test report - uses: dorny/test-reporter@v3 - if: always() - with: - name: PlayMode (${{ matrix.backend }} / ${{ matrix.target }}) - path: artifacts/test-results.xml - reporter: dotnet-nunit - fail-on-error: true - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playmode-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }} - path: | - artifacts/test-results.xml - artifacts/unity.log - artifacts/Player-*.log - examples/audience/Logs/** + run: .github/scripts/audience/ensure-msvc-windows.ps1 - playmode-linux: - needs: set-matrix - if: | - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) - || github.event_name == 'schedule' - || github.event_name == 'workflow_dispatch' - name: ${{ matrix.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity }} - runs-on: ubuntu-latest-8-cores - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.set-matrix.outputs.playmode_linux) }} - - steps: - - uses: actions/checkout@v4 - with: - lfs: true - - - uses: actions/cache@v4 - with: - path: examples/audience/Library - key: Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} - restore-keys: | - Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}- - Library-${{ matrix.backend }}-${{ matrix.target }}- - - - uses: game-ci/unity-test-runner@v4 - id: playmode + - name: Run PlayMode tests env: + UNITY_VERSION: ${{ matrix.unity.version }} + TARGET: ${{ matrix.platform.target }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} - with: - unityVersion: ${{ matrix.unity }} - targetPlatform: ${{ matrix.target }} - projectPath: examples/audience - testMode: playmode - githubToken: ${{ secrets.GITHUB_TOKEN }} + run: ${{ matrix.platform.run_playmode_script }} - name: Publish test report uses: dorny/test-reporter@v3 if: always() with: - name: PlayMode (${{ matrix.backend }} / ${{ matrix.target }}) - path: ${{ steps.playmode.outputs.artifactsPath }}/playmode-results.xml + name: PlayMode (${{ matrix.backend }} / ${{ matrix.platform.target }}) + path: artifacts/test-results.xml reporter: dotnet-nunit fail-on-error: true - uses: actions/upload-artifact@v4 if: always() with: - name: playmode-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }} - path: ${{ steps.playmode.outputs.artifactsPath }} + name: playmode-${{ matrix.backend }}-${{ matrix.platform.target }}-${{ matrix.unity.version }} + path: | + artifacts/** + examples/audience/Logs/** - # Mobile IL2CPP build validation — runs on GitHub-hosted Ubuntu via GameCI Docker - # containers so self-hosted macOS/Windows machines are not occupied. - # Scope: IL2CPP compile pipeline only. Runtime tests require a real device and - # are out of scope until a device farm is available. + # Mobile IL2CPP build validation. Compile-only; runtime tests need real devices. mobile-build: - needs: set-matrix - if: | - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) - || github.event_name == 'schedule' - || github.event_name == 'workflow_dispatch' - name: ${{ matrix.target }} / IL2CPP / Unity ${{ matrix.unity }} + needs: setup + name: ${{ matrix.platform.target }} / IL2CPP / Unity ${{ matrix.unity.version }} runs-on: ubuntu-latest-8-cores strategy: fail-fast: false matrix: - include: ${{ fromJSON(needs.set-matrix.outputs.mobile) }} + unity: ${{ fromJSON(needs.setup.outputs.unity_versions) }} + platform: ${{ fromJSON(needs.setup.outputs.mobile_targets) }} + exclude: ${{ fromJSON(needs.setup.outputs.pr_exclude) }} steps: - uses: actions/checkout@v4 @@ -546,10 +150,10 @@ jobs: - uses: actions/cache@v4 with: path: examples/audience/Library - key: Library-mobile-${{ matrix.target }}-${{ matrix.unity }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} + key: Library-mobile-${{ matrix.platform.target }}-${{ matrix.unity.version }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} restore-keys: | - Library-mobile-${{ matrix.target }}-${{ matrix.unity }}- - Library-mobile-${{ matrix.target }}- + Library-mobile-${{ matrix.platform.target }}-${{ matrix.unity.version }}- + Library-mobile-${{ matrix.platform.target }}- - uses: game-ci/unity-builder@v4 env: @@ -557,15 +161,15 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} with: - unityVersion: ${{ matrix.unity }} - targetPlatform: ${{ matrix.target }} + unityVersion: ${{ matrix.unity.version }} + targetPlatform: ${{ matrix.platform.target }} projectPath: examples/audience - buildMethod: Immutable.Audience.Samples.SampleApp.Editor.${{ matrix.method }} + buildMethod: Immutable.Audience.Samples.SampleApp.Editor.${{ matrix.platform.build_player_method }} - uses: actions/upload-artifact@v4 if: always() with: - name: mobile-build-${{ matrix.target }}-${{ matrix.unity }} + name: mobile-build-${{ matrix.platform.target }}-${{ matrix.unity.version }} if-no-files-found: ignore path: | examples/audience/Builds/Android/*.apk diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index 7764d8dc5..1f3121239 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -8,7 +8,7 @@ namespace Immutable.Audience.Samples.SampleApp { - // Audience SDK sample — UI Toolkit port of the web sample-app. Exercises + // Audience SDK sample, UI Toolkit port of the web sample-app. Exercises // every public ImmutableAudience API plus an event log that mirrors SDK // debug output. // @@ -16,13 +16,13 @@ namespace Immutable.Audience.Samples.SampleApp // // AudienceSample.cs SDK calls, On* handlers, mirror state, SDK // callbacks, config builders. Reads UXML - // state ONLY via UI's Capture*Form accessors - // — never touches a UXML field directly. + // state ONLY via UI's Capture*Form accessors. + // Never touches a UXML field directly. // AudienceSample.UI.cs UXML fields, binding, rendering, log pane, // Refresh* methods, Capture*Form accessors. // No SDK calls, no mirror-state knowledge. // AudienceSample.Events.cs Catalogue, typed-event factory, props - // builder. Pure factory — no UXML, no SDK. + // builder. Pure factory: no UXML, no SDK. public sealed partial class AudienceSample : MonoBehaviour { // ---- State ---- @@ -38,9 +38,20 @@ public sealed partial class AudienceSample : MonoBehaviour // ---- Lifecycle ---- + // Logs CI build info (buildGuid, runId, cellId) to Player.log on player startup. CI-only. + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void LogCiBuildInfo() + { + var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID") ?? string.Empty; + var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID") ?? string.Empty; + if (string.IsNullOrEmpty(runId) && string.IsNullOrEmpty(cellId)) return; + UnityEngine.Debug.Log( + $"[CI] buildGuid={Application.buildGUID} runId={runId} cellId={cellId}"); + } + private void Awake() { - // InitializeUi must precede the Log.Writer swap — _logView has + // InitializeUi must precede the Log.Writer swap. _logView has // to be bound before any Log.Warn can land in RouteSdkLogToPane. InitializeUi(); _priorSdkLogWriter = Immutable.Audience.Log.Writer; @@ -121,7 +132,7 @@ private async Task OnRequestAttAsync() // Prefers the typed overload for the four events with public C# // classes (Progression, Resource, Purchase, MilestoneReached); the // rest stay on the string overload. Typed validation errors are - // expected for user input — let them propagate through RunAndLog. + // expected for user input. Let them propagate through RunAndLog. private void OnSendCatalogueEvent(EventSpec spec, Dictionary inputs) => RunAndLog("track()", () => { @@ -194,7 +205,7 @@ private void OnIdentify() => RunAndLog("identify()", () => var traits = ParseTraits(f.RawTraits); ImmutableAudience.Identify(f.Id, ParseIdentityType(f.Type), traits); // SDK drops via Log.Warn when id is empty or consent < Full. Mirror - // only when accepted — otherwise the panel would show stale state. + // only when accepted; otherwise the panel would show stale state. var accepted = !string.IsNullOrEmpty(f.Id) && string.Equals(ImmutableAudience.UserId, f.Id, StringComparison.Ordinal); if (accepted) { _mirrorIdentityType = f.Type; _mirrorTraits = traits; } @@ -212,7 +223,7 @@ private void OnIdentify() => RunAndLog("identify()", () => private void OnIdentifyTraits() => RunAndLog("identify(traits)", () => { var userId = ImmutableAudience.UserId; - if (string.IsNullOrEmpty(userId)) throw new InvalidOperationException("no active identity — call Identify first"); + if (string.IsNullOrEmpty(userId)) throw new InvalidOperationException("no active identity; call Identify first"); var traits = ParseTraits(CaptureTraitsUpdate()); if (traits == null || traits.Count == 0) throw new InvalidOperationException("traits required"); ImmutableAudience.Identify(userId, ParseIdentityType(_mirrorIdentityType), traits); @@ -246,15 +257,19 @@ private void OnAlias() => RunAndLog("alias()", () => // Fires from background flush threads; AppendLog marshals to main. // Body is JSON for parity with handler "Copy" output. - private void OnSdkError(AudienceError err) => - AppendLog("onError", Json.Serialize(new Dictionary + // Mirrors to Debug.LogError so failures land in Player.log, not just the in-app pane. + private void OnSdkError(AudienceError err) + { + var body = Json.Serialize(new Dictionary { ["code"] = err.Code.ToString(), ["message"] = err.Message, - }, 2), LogLevel.Err, LogSource.Sdk); + }, 2); + UnityEngine.Debug.LogError($"[Audience.OnError] {body}"); + AppendLog("onError", body, LogLevel.Err, LogSource.Sdk); + } - // SDK Log.Writer adapter. May fire from any thread; AppendLog handles - // the main-thread marshal. + // SDK Log.Writer adapter. Mirrors to Debug.Log so SDK output reaches Player.log. private void RouteSdkLogToPane(string msg) { const string warnTag = "[ImmutableAudience] WARN:"; @@ -265,10 +280,16 @@ private void RouteSdkLogToPane(string msg) { level = LogLevel.Warn; body = msg.Substring(warnTag.Length).TrimStart(); + UnityEngine.Debug.LogWarning($"[Audience] {body}"); } else if (msg.StartsWith(prefix, StringComparison.Ordinal)) { body = msg.Substring(prefix.Length).TrimStart(); + UnityEngine.Debug.Log($"[Audience] {body}"); + } + else + { + UnityEngine.Debug.Log($"[Audience] {body}"); } AppendLog("sdk", body, level, LogSource.Sdk); } @@ -293,7 +314,7 @@ private static void GuardConsentForTrack() var consent = ImmutableAudience.CurrentConsent; if (!consent.CanTrack()) throw new InvalidOperationException( - $"track dropped — consent is {consent.ToLowercaseString()}; raise to anonymous or full to queue events"); + $"track dropped: consent is {consent.ToLowercaseString()}; raise to anonymous or full to queue events"); } // Refresh* are idempotent reads, so calling all four every time is @@ -326,7 +347,7 @@ private AudienceConfig BuildAudienceConfig(InitForm form, Action if (form.FlushIntervalMs is int flushMs && flushMs > 0) { if (flushMs < 1000) - AppendLog("INIT", $"flushInterval {flushMs}ms below 1s — clamped", LogLevel.Warn, LogSource.App); + AppendLog("INIT", $"flushInterval {flushMs}ms below 1s, clamped", LogLevel.Warn, LogSource.App); config.FlushIntervalSeconds = Math.Max(1, flushMs / 1000); } if (form.FlushSize is int flushSize && flushSize > 0) diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs new file mode 100644 index 000000000..fc037df46 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs @@ -0,0 +1,48 @@ +#nullable enable + +#if UNITY_STANDALONE_LINUX && UNITY_6000_0_OR_NEWER +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.UIElements; + +namespace Immutable.Audience.Samples.SampleApp.Tests +{ + // Hides log pane on Unity 6 Linux. Skips llvmpipe rasterising + // thousands of UI Toolkit triangles per frame. + [SetUpFixture] + public sealed class LinuxLogPaneSuppression + { + [OneTimeSetUp] + public void RegisterSceneHook() + { + SceneManager.sceneLoaded += HideLogPane; + } + + [OneTimeTearDown] + public void DeregisterSceneHook() + { + SceneManager.sceneLoaded -= HideLogPane; + } + + // Fires on every scene load. Idempotent. + private static void HideLogPane(Scene scene, LoadSceneMode mode) + { + var sample = Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (sample == null) return; + + var doc = sample.GetComponent(); + if (doc == null) return; + + var root = doc.rootVisualElement; + if (root == null) return; + + var log = root.Q(SampleAppUi.LogScrollView); + if (log == null) return; + + log.style.display = new StyleEnum(DisplayStyle.None); + Debug.Log("[LinuxLogPaneSuppression] log pane hidden for Linux PlayMode test run."); + } + } +} +#endif diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta new file mode 100644 index 000000000..375feaffe --- /dev/null +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a7e9d4b8c1f5a6e2b8d9c0e1f2a3b4c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index aef4b65be..7a50968b4 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -24,8 +24,12 @@ internal class SampleAppLiveFireTests [SetUp] public void SetUp() { + // Don't fail tests on cleanup-time OnError fires when in-flight HTTP + // gets cancelled. Errors still land in Player.log via Debug.LogError. + LogAssert.ignoreFailingMessages = true; + // ImmutableAudience is a static; tests must reset between runs. - // ResetState is internal — reached via reflection (BindingFlags.NonPublic + // ResetState is internal, reached via reflection (BindingFlags.NonPublic // bypasses C# access checks; no InternalsVisibleTo required). var t = typeof(ImmutableAudience); var m = t.GetMethod("ResetState", @@ -128,6 +132,30 @@ private IEnumerator SetConsentVia(string consentButtonName) // ---- Tests ---- + // Emits a CDP marker row so this run's events can be filtered out of analytics. + [UnityTest] + public IEnumerator AudienceCiTestMarker_EmitsRunMetadata() + { + var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID"); + var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID"); + if (string.IsNullOrEmpty(runId) && string.IsNullOrEmpty(cellId)) + { + Assert.Ignore("Not running in CI."); + yield break; + } + + yield return LoadAndInit(); + + ImmutableAudience.Track("audience_ci_test_marker", new System.Collections.Generic.Dictionary + { + ["source"] = "ci", + ["ciRunId"] = runId ?? string.Empty, + ["ciCellId"] = cellId ?? string.Empty, + }); + + yield return null; + } + [UnityTest] public IEnumerator InitTrackFlush_AgainstSandbox_FlushReportsOk() { @@ -197,7 +225,7 @@ private IEnumerator DriveTypedEventAndFlush( [UnityTest] public IEnumerator Identify_AndFlush_FlushReportsOk() { - // Identify requires consent ≥ Full — set it on the initial-consent + // Identify requires consent >= Full. Set it on the initial-consent // dropdown before Init rather than upgrading mid-test. yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full); @@ -224,7 +252,7 @@ public IEnumerator Alias_AndFlush_FlushReportsOk() [UnityTest] public IEnumerator SetConsent_None_PurgesQueueAndPersists() { - // Init at default Anonymous; enqueue an event; revoke; flush — no errors. + // Init at default Anonymous; enqueue an event; revoke; flush. No errors. yield return LoadAndInit(); _root!.Q