diff --git a/src/Packages/Audience/Editor/AudienceMobileBuildSettingsEditor.cs b/src/Packages/Audience/Editor/AudienceMobileBuildSettingsEditor.cs new file mode 100644 index 00000000..c17aa1df --- /dev/null +++ b/src/Packages/Audience/Editor/AudienceMobileBuildSettingsEditor.cs @@ -0,0 +1,36 @@ +#nullable enable + +using UnityEditor; + +namespace Immutable.Audience.Editor +{ + /// + /// Adds the mobile attribution toggles to . + /// + /// + /// The toggles read and write directly. + /// They are not fields on the asset, so an existing define set manually + /// (the old way) shows correctly the first time this asset is opened, + /// and nothing gets reset by updating the package. + /// + [CustomEditor(typeof(AudienceMobileBuildSettings))] + internal sealed class AudienceMobileBuildSettingsEditor : UnityEditor.Editor + { + public override void OnInspectorGUI() + { + EditorGUILayout.LabelField("Mobile Attribution", EditorStyles.boldLabel); + DrawToggle(BuildTargetGroup.iOS, "Enable for iOS"); + DrawToggle(BuildTargetGroup.Android, "Enable for Android"); + EditorGUILayout.Space(); + + DrawDefaultInspector(); + } + + private static void DrawToggle(BuildTargetGroup group, string label) + { + var enabled = MobileAttributionDefine.IsEnabled(group); + var toggled = EditorGUILayout.Toggle(label, enabled); + if (toggled != enabled) MobileAttributionDefine.SetEnabled(group, toggled); + } + } +} diff --git a/src/Packages/Audience/Editor/AudienceMobileBuildSettingsEditor.cs.meta b/src/Packages/Audience/Editor/AudienceMobileBuildSettingsEditor.cs.meta new file mode 100644 index 00000000..4fbdee1c --- /dev/null +++ b/src/Packages/Audience/Editor/AudienceMobileBuildSettingsEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2fdd843fb8feb4b1e9cc8c97d88748c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Editor/MobileAttributionDefine.cs b/src/Packages/Audience/Editor/MobileAttributionDefine.cs new file mode 100644 index 00000000..8843abbc --- /dev/null +++ b/src/Packages/Audience/Editor/MobileAttributionDefine.cs @@ -0,0 +1,52 @@ +#nullable enable + +using System.Collections.Generic; +using UnityEditor; + +namespace Immutable.Audience.Editor +{ + /// + /// Reads and writes the AUDIENCE_MOBILE_ATTRIBUTION scripting + /// define on Player Settings. + /// + /// + /// No state is cached or serialized here. Player Settings is the only + /// source of truth, so this always reflects whatever a studio has + /// already set, including manually, and nothing here can go stale. + /// + internal static class MobileAttributionDefine + { + internal const string Symbol = "AUDIENCE_MOBILE_ATTRIBUTION"; + + internal static bool IsEnabled(BuildTargetGroup group) => + Contains(PlayerSettings.GetScriptingDefineSymbolsForGroup(group)); + + internal static void SetEnabled(BuildTargetGroup group, bool enabled) + { + var current = PlayerSettings.GetScriptingDefineSymbolsForGroup(group); + PlayerSettings.SetScriptingDefineSymbolsForGroup(group, WithSymbol(current, enabled)); + } + + internal static bool Contains(string? defines) + { + foreach (var define in (defines ?? string.Empty).Split(';')) + { + if (define.Trim() == Symbol) return true; + } + return false; + } + + internal static string WithSymbol(string? defines, bool enabled) + { + var result = new List(); + foreach (var define in (defines ?? string.Empty).Split(';', System.StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = define.Trim(); + if (trimmed.Length == 0 || trimmed == Symbol) continue; + result.Add(trimmed); + } + if (enabled) result.Add(Symbol); + return string.Join(";", result); + } + } +} diff --git a/src/Packages/Audience/Editor/MobileAttributionDefine.cs.meta b/src/Packages/Audience/Editor/MobileAttributionDefine.cs.meta new file mode 100644 index 00000000..bb0f58b6 --- /dev/null +++ b/src/Packages/Audience/Editor/MobileAttributionDefine.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 225fcb5f29be241f4a71fb94096a09a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Editor/iOSFrameworkPostProcessor.cs b/src/Packages/Audience/Editor/iOSFrameworkPostProcessor.cs index c4c1f4b7..3fdb1808 100644 --- a/src/Packages/Audience/Editor/iOSFrameworkPostProcessor.cs +++ b/src/Packages/Audience/Editor/iOSFrameworkPostProcessor.cs @@ -50,7 +50,7 @@ internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltPr if (target != BuildTarget.iOS) return; #if UNITY_IOS - if (!AttributionDefineEnabled()) return; + if (!MobileAttributionDefine.IsEnabled(BuildTargetGroup.iOS)) return; var pbxPath = PBXProject.GetPBXProjectPath(pathToBuiltProject); if (!File.Exists(pbxPath)) @@ -75,18 +75,5 @@ internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltPr pbx.WriteToFile(pbxPath); #endif } - - // Reads the iOS-target define list specifically. The post-processor - // mutates iOS build output regardless of which target the editor is - // currently focused on. - private static bool AttributionDefineEnabled() - { - var defines = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.iOS) ?? string.Empty; - foreach (var define in defines.Split(';')) - { - if (define.Trim() == iOSInfoPlistPostProcessor.AttributionDefine) return true; - } - return false; - } } } diff --git a/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs b/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs index 4061373a..92718fb7 100644 --- a/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs +++ b/src/Packages/Audience/Editor/iOSInfoPlistPostProcessor.cs @@ -34,7 +34,6 @@ namespace Immutable.Audience.Editor internal static class iOSInfoPlistPostProcessor { internal const int CallbackOrder = 9050; - internal const string AttributionDefine = "AUDIENCE_MOBILE_ATTRIBUTION"; [PostProcessBuild(CallbackOrder)] internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject) @@ -42,7 +41,7 @@ internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltPr if (target != BuildTarget.iOS) return; #if UNITY_IOS - if (!AttributionDefineEnabled()) return; + if (!MobileAttributionDefine.IsEnabled(BuildTargetGroup.iOS)) return; var plistPath = Path.Combine(pathToBuiltProject, "Info.plist"); if (!File.Exists(plistPath)) @@ -68,12 +67,11 @@ internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltPr [MenuItem("Tools/Immutable/Audience/Validate iOS Build Settings")] private static void ValidateBuildSettings() { - if (!AttributionDefineEnabled()) + if (!MobileAttributionDefine.IsEnabled(BuildTargetGroup.iOS)) { Debug.LogWarning( - "[ImmutableAudience] AUDIENCE_MOBILE_ATTRIBUTION scripting define is not set " + - "for the iOS player target. The post-processor will not modify Info.plist. " + - "Add the define under Player Settings → Other Settings → Scripting Define Symbols."); + "[ImmutableAudience] Mobile attribution is not enabled for iOS. The post-processor " + + "will not modify Info.plist. Enable it on the AudienceMobileBuildSettings asset."); return; } @@ -92,19 +90,6 @@ private static void ValidateBuildSettings() : string.Concat(System.Array.ConvertAll(ids, id => $" - {id}\n")))); } - // Reads the iOS-target define list specifically. The post-processor - // mutates iOS build output regardless of which target the editor is - // currently focused on. - private static bool AttributionDefineEnabled() - { - var defines = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.iOS) ?? string.Empty; - foreach (var define in defines.Split(';')) - { - if (define.Trim() == AttributionDefine) return true; - } - return false; - } - #if UNITY_IOS internal static void ApplyTrackingUsageDescription( PlistElementDict root, diff --git a/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs b/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs index 16542cf8..96b89ec9 100644 --- a/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs +++ b/src/Packages/Audience/Editor/iOSPrivacyManifestPostProcessor.cs @@ -29,7 +29,7 @@ internal static void OnPostProcessBuild(BuildTarget target, string pathToBuiltPr if (target != BuildTarget.iOS) return; #if UNITY_IOS - if (!AttributionDefineEnabled()) return; + if (!MobileAttributionDefine.IsEnabled(BuildTargetGroup.iOS)) return; var builtManifestPath = FindBuiltManifest(pathToBuiltProject); if (builtManifestPath == null) @@ -119,15 +119,5 @@ v is PlistElementString s && purposes.AddString("NSPrivacyCollectedDataTypePurposeAnalytics"); } #endif - - private static bool AttributionDefineEnabled() - { - var defines = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.iOS) ?? string.Empty; - foreach (var define in defines.Split(';')) - { - if (define.Trim() == iOSInfoPlistPostProcessor.AttributionDefine) return true; - } - return false; - } } } diff --git a/src/Packages/Audience/Tests/Editor/MobileAttributionDefineTests.cs b/src/Packages/Audience/Tests/Editor/MobileAttributionDefineTests.cs new file mode 100644 index 00000000..ce737056 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/MobileAttributionDefineTests.cs @@ -0,0 +1,79 @@ +#nullable enable + +using NUnit.Framework; +using Immutable.Audience.Editor; + +namespace Immutable.Audience.Editor.Tests +{ + [TestFixture] + internal class MobileAttributionDefineTests + { + [Test] + public void Contains_NullOrEmpty_ReturnsFalse() + { + Assert.IsFalse(MobileAttributionDefine.Contains(null)); + Assert.IsFalse(MobileAttributionDefine.Contains("")); + } + + [Test] + public void Contains_SymbolAmongOthers_ReturnsTrue() + { + Assert.IsTrue(MobileAttributionDefine.Contains("FOO;AUDIENCE_MOBILE_ATTRIBUTION;BAR")); + } + + [Test] + public void Contains_SymbolWithStrayWhitespace_ReturnsTrue() + { + Assert.IsTrue(MobileAttributionDefine.Contains("FOO; AUDIENCE_MOBILE_ATTRIBUTION ;BAR")); + } + + [Test] + public void Contains_OtherDefinesOnly_ReturnsFalse() + { + Assert.IsFalse(MobileAttributionDefine.Contains("FOO;BAR")); + } + + [Test] + public void WithSymbol_EnableOnEmpty_AddsOnlySymbol() + { + Assert.AreEqual("AUDIENCE_MOBILE_ATTRIBUTION", MobileAttributionDefine.WithSymbol(null, enabled: true)); + } + + [Test] + public void WithSymbol_EnableWithOthers_KeepsOthersAndAddsSymbol() + { + var result = MobileAttributionDefine.WithSymbol("FOO;BAR", enabled: true); + + Assert.IsTrue(MobileAttributionDefine.Contains(result)); + Assert.IsTrue(result.Contains("FOO")); + Assert.IsTrue(result.Contains("BAR")); + } + + [Test] + public void WithSymbol_EnableWhenAlreadyPresent_DoesNotDuplicate() + { + var result = MobileAttributionDefine.WithSymbol("FOO;AUDIENCE_MOBILE_ATTRIBUTION;BAR", enabled: true); + + var symbolCount = System.Array.FindAll(result.Split(';'), d => d == "AUDIENCE_MOBILE_ATTRIBUTION").Length; + Assert.AreEqual(1, symbolCount); + } + + [Test] + public void WithSymbol_DisableRemovesSymbolOnly() + { + var result = MobileAttributionDefine.WithSymbol("FOO;AUDIENCE_MOBILE_ATTRIBUTION;BAR", enabled: false); + + Assert.IsFalse(MobileAttributionDefine.Contains(result)); + Assert.IsTrue(result.Contains("FOO")); + Assert.IsTrue(result.Contains("BAR")); + } + + [Test] + public void WithSymbol_DisableWhenAbsent_LeavesOthersUnchanged() + { + var result = MobileAttributionDefine.WithSymbol("FOO;BAR", enabled: false); + + Assert.AreEqual("FOO;BAR", result); + } + } +} diff --git a/src/Packages/Audience/Tests/Editor/MobileAttributionDefineTests.cs.meta b/src/Packages/Audience/Tests/Editor/MobileAttributionDefineTests.cs.meta new file mode 100644 index 00000000..7b3edd11 --- /dev/null +++ b/src/Packages/Audience/Tests/Editor/MobileAttributionDefineTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b4d32623b1c9476a9a1775f96542ab2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: