diff --git a/Assets/CHANGELOG.md b/Assets/CHANGELOG.md index 20b61b7..1883ccf 100644 --- a/Assets/CHANGELOG.md +++ b/Assets/CHANGELOG.md @@ -5,5 +5,7 @@ * 接入 Dirichlet/TapADN 聚合 Unity SDK `4.2.5.0`。 * 新增 `TapadnAdController`、激励视频、插屏、开屏播放器,实现 `CC-Framework.Commercialization` 抽象层。 * 新增 Android 构建后处理:Manifest 权限、TapADN FileProvider、微信 OpenSDK WXEntryActivity/queries、AndroidX/Jetifier 属性。 +* 新增 iOS 构建后处理:CocoaPods、SKAdNetwork、ATT 文案、AppTrackingTransparency 弱链接、GDT 动态 framework 嵌入。 +* iOS 激励、插屏、开屏 auto API 增加 load-then-show 兼容 fallback,并补齐展示失败回调。 * 新增 `TapadnCommercialization` 便捷入口,由实现模块创建 controller 并初始化 `ADManager`。 * 官方 Demo Sample 从主包剔除,调试内容改为可选 `Samples~`。 diff --git a/Assets/DirichletMediation/Editor/DirichletMediationIOSPostProcessor.cs b/Assets/DirichletMediation/Editor/DirichletMediationIOSPostProcessor.cs index cf8c4f2..7fbd79e 100644 --- a/Assets/DirichletMediation/Editor/DirichletMediationIOSPostProcessor.cs +++ b/Assets/DirichletMediation/Editor/DirichletMediationIOSPostProcessor.cs @@ -25,12 +25,17 @@ namespace Dirichlet.Mediation.Editor /// public class DirichletMediationIOSPostProcessor { - private const string SDKVersion = "4.2.0.2"; + private const string DefaultIOSSDKVersion = "4.2.0.1"; private const string MinIOSVersion = "11.0"; + private const string DefaultTrackingUsageDescription = "该标识符将用于向您投放个性化广告"; // 环境变量 override(仅在解析失败或接入方工程极端定制时使用) private const string ENV_FRAMEWORK_TARGET = "DIRICHLET_UNITY_FRAMEWORK_TARGET"; private const string ENV_APP_TARGET = "DIRICHLET_UNITY_APP_TARGET"; + private const string ENV_IOS_SDK_VERSION = "DIRICHLET_IOS_SDK_VERSION"; + private const string ENV_ATT_DESCRIPTION = "DIRICHLET_IOS_ATT_DESCRIPTION"; + private const string PrefKeyIOSSDKVersion = "Dirichlet.iOS.SDKVersion"; + private const string PrefKeyATTDescription = "Dirichlet.iOS.TrackingUsageDescription"; /// /// 解析出的 target 信息 @@ -363,6 +368,7 @@ namespace Dirichlet.Mediation.Editor // Note: DirichletAdSDK (DRA adapter) is always enabled as core SDK var enableCsj = EditorPrefs.GetBool("Dirichlet.iOS.EnableCSJ", true); var enableGdt = EditorPrefs.GetBool("Dirichlet.iOS.EnableGDT", true); + var sdkVersion = ResolveIOSSDKVersion(); var podfileContent = new StringBuilder(); podfileContent.AppendLine("# Generated by Dirichlet Mediation Unity Plugin"); @@ -379,20 +385,20 @@ namespace Dirichlet.Mediation.Editor // 所有 pods 统一放到 Framework target // SDK 通过 NSClassFromString 查找 Adapter 类,必须在同一 target 中 podfileContent.AppendLine($"target '{targetInfo.FrameworkTargetName}' do"); - podfileContent.AppendLine($" pod 'DirichletMediationSDK', '{SDKVersion}'"); + podfileContent.AppendLine($" pod 'DirichletMediationSDK', '{sdkVersion}'"); if (enableCsj) { - podfileContent.AppendLine($" pod 'DirichletMediationAdapterCSJ', '{SDKVersion}'"); + podfileContent.AppendLine($" pod 'DirichletMediationAdapterCSJ', '{sdkVersion}'"); } if (enableGdt) { - podfileContent.AppendLine($" pod 'DirichletMediationAdapterGDT', '{SDKVersion}'"); + podfileContent.AppendLine($" pod 'DirichletMediationAdapterGDT', '{sdkVersion}'"); } // DirichletAdSDK (DRA adapter) is always included as core SDK - podfileContent.AppendLine($" pod 'DirichletMediationAdapterDRA', '{SDKVersion}'"); + podfileContent.AppendLine($" pod 'DirichletMediationAdapterDRA', '{sdkVersion}'"); podfileContent.AppendLine("end"); podfileContent.AppendLine(); @@ -411,7 +417,24 @@ namespace Dirichlet.Mediation.Editor File.WriteAllText(podfilePath, podfileContent.ToString()); Debug.Log($"[DirichletMediation] Generated Podfile at {podfilePath}"); - Debug.Log($"[DirichletMediation] All pods allocated to {targetInfo.FrameworkTargetName} (CSJ={enableCsj}, GDT={enableGdt}, DRA=always)"); + Debug.Log($"[DirichletMediation] All pods allocated to {targetInfo.FrameworkTargetName} (version={sdkVersion}, CSJ={enableCsj}, GDT={enableGdt}, DRA=always)"); + } + + private static string ResolveIOSSDKVersion() + { + var envVersion = System.Environment.GetEnvironmentVariable(ENV_IOS_SDK_VERSION); + if (!string.IsNullOrWhiteSpace(envVersion)) + { + return envVersion.Trim(); + } + + var prefVersion = EditorPrefs.GetString(PrefKeyIOSSDKVersion, string.Empty); + if (!string.IsNullOrWhiteSpace(prefVersion)) + { + return prefVersion.Trim(); + } + + return DefaultIOSSDKVersion; } /// @@ -466,13 +489,15 @@ namespace Dirichlet.Mediation.Editor Debug.Log($" Framework target: {targetInfo.FrameworkTargetName} (GUID: {targetGuid})"); Debug.Log($" App target: {targetInfo.AppTargetName} (GUID: {mainTargetGuid})"); - // NOTE: System frameworks (AdSupport, AVFoundation, WebKit, CoreVideo, etc.) + // NOTE: Most system frameworks (AdSupport, AVFoundation, WebKit, CoreVideo, etc.) // are declared in SDK podspecs and will be automatically linked by CocoaPods. - // No need to manually add them here. + // AppTrackingTransparency is referenced by the Unity bridge, so add it explicitly and weak-link it for iOS 11+. // - DirichletAdSDK.podspec: AdSupport, SystemConfiguration, Security // - DirichletCoreSDK.podspec: SystemConfiguration, Security // - DirichletMediationAdapterCSJ.podspec: CoreVideo // - Third-party SDKs (Ads-CN, GDTMobSDK) declare their own framework dependencies. + pbxProject.AddFrameworkToProject(targetGuid, "AppTrackingTransparency.framework", true); + pbxProject.AddFrameworkToProject(targetGuid, "AdSupport.framework", true); // Set build settings for framework target pbxProject.SetBuildProperty(targetGuid, "ENABLE_BITCODE", "NO"); @@ -591,16 +616,16 @@ namespace Dirichlet.Mediation.Editor // Note: The following Info.plist keys should be configured by the developer manually // to avoid potential App Store review issues: // - NSAppTransportSecurity: Configure based on your app's network requirements - // - NSUserTrackingUsageDescription: Required for iOS 14+ IDFA access, use your custom description // - NSLocationWhenInUseUsageDescription: Only add if your app uses location services // // Reference: https://ssp.dirichlet.cn/docs/dirichlet-mediation-sdk/dirichlet-mediation-sdk-guide-ios/ // Add SKAdNetwork identifiers for attribution tracking AddSKAdNetworkIds(rootDict); + AddTrackingUsageDescription(rootDict); plist.WriteToFile(plistPath); - Debug.Log("[DirichletMediation] Modified Info.plist (SKAdNetwork IDs only)"); + Debug.Log("[DirichletMediation] Modified Info.plist (SKAdNetwork IDs + ATT description)"); } private static void AddSKAdNetworkIds(PlistElementDict rootDict) @@ -631,6 +656,35 @@ namespace Dirichlet.Mediation.Editor Debug.Log($"[DirichletMediation] Added {commonSkAdNetworkIds.Length} SKAdNetwork IDs to Info.plist"); } + private static void AddTrackingUsageDescription(PlistElementDict rootDict) + { + if (rootDict.values.ContainsKey("NSUserTrackingUsageDescription")) + { + Debug.Log("[DirichletMediation] NSUserTrackingUsageDescription already exists, skipping"); + return; + } + + rootDict.SetString("NSUserTrackingUsageDescription", ResolveTrackingUsageDescription()); + Debug.Log("[DirichletMediation] Added NSUserTrackingUsageDescription to Info.plist"); + } + + private static string ResolveTrackingUsageDescription() + { + var envDescription = System.Environment.GetEnvironmentVariable(ENV_ATT_DESCRIPTION); + if (!string.IsNullOrWhiteSpace(envDescription)) + { + return envDescription.Trim(); + } + + var prefDescription = EditorPrefs.GetString(PrefKeyATTDescription, string.Empty); + if (!string.IsNullOrWhiteSpace(prefDescription)) + { + return prefDescription.Trim(); + } + + return DefaultTrackingUsageDescription; + } + private static void RunPodInstall(string projectPath) { var podfilePath = Path.Combine(projectPath, "Podfile"); diff --git a/Assets/DirichletMediation/Runtime/DirichletAdTypes.cs b/Assets/DirichletMediation/Runtime/DirichletAdTypes.cs index e7e18c9..6aa0019 100644 --- a/Assets/DirichletMediation/Runtime/DirichletAdTypes.cs +++ b/Assets/DirichletMediation/Runtime/DirichletAdTypes.cs @@ -366,6 +366,10 @@ namespace Dirichlet.Mediation private readonly IDirichletPlatformBridge bridge; private static readonly object RewardAutoSessionLock = new object(); private static readonly Dictionary RewardAutoSessions = new Dictionary(StringComparer.Ordinal); + private static readonly object InterstitialAutoSessionLock = new object(); + private static readonly Dictionary InterstitialAutoSessions = new Dictionary(StringComparer.Ordinal); + private static readonly object SplashAutoSessionLock = new object(); + private static readonly Dictionary SplashAutoSessions = new Dictionary(StringComparer.Ordinal); public static DirichletAdNative Create() => DirichletAdManager.CreateAdNative(); @@ -478,7 +482,7 @@ namespace Dirichlet.Mediation /// /// Shows an interstitial ad with automatic load-and-show logic. - /// Android only - iOS receives OnError with not_supported. + /// Android uses the native AutoAd API; iOS/editor use a load-then-show fallback without native cache. /// public void ShowInterstitialAutoAd(DirichletAdRequest request, IDirichletInterstitialAutoAdListener listener) { @@ -487,13 +491,17 @@ namespace Dirichlet.Mediation return; } +#if UNITY_ANDROID && !UNITY_EDITOR bridge.ShowInterstitialAutoAd(request, listener); +#else + ShowInterstitialLoadAndShowInternal(request, listener); +#endif } /// /// Shows a banner ad with automatic load + rotation logic. Container is created internally and /// anchored at the bottom of the screen by default. Use - /// to control rotation interval. Android only - iOS receives OnError with not_supported. + /// to control rotation interval. Native auto rotation is currently Android only. /// public void ShowBannerAutoAd(DirichletAdRequest request, IDirichletBannerAutoAdListener listener) { @@ -516,7 +524,7 @@ namespace Dirichlet.Mediation /// /// Shows a splash ad with automatic load logic. Container is a fullscreen overlay created internally. - /// Android only - iOS receives OnError with not_supported. + /// Android uses the native AutoAd API; iOS/editor use a load-then-show fallback without native cache. /// public void ShowSplashAutoAd(DirichletAdRequest request, IDirichletSplashAutoAdListener listener) { @@ -533,7 +541,11 @@ namespace Dirichlet.Mediation return; } +#if UNITY_ANDROID && !UNITY_EDITOR bridge.ShowSplashAutoAd(request, options ?? new DirichletAdShowOptions(), listener); +#else + ShowSplashLoadAndShowInternal(request, options ?? new DirichletAdShowOptions(), listener); +#endif } /// @@ -566,6 +578,7 @@ namespace Dirichlet.Mediation var session = new AutoRewardVideoSession(sessionId, ad, listener); RegisterRewardAutoSession(session); ad.SetInteractionListener(session); + ad.ShowFailed += session.OnShowFailed; // Load succeeded; show immediately. var shown = ad.Show(); @@ -580,6 +593,66 @@ namespace Dirichlet.Mediation }); } + private void ShowInterstitialLoadAndShowInternal(DirichletAdRequest request, IDirichletInterstitialAutoAdListener listener) + { + LoadInterstitialAd( + request, + ad => + { + if (ad == null) + { + listener?.OnError(new DirichletError("invalid_ad", "Load callback returned null ad")); + return; + } + + var sessionId = Guid.NewGuid().ToString("N"); + var session = new AutoInterstitialSession(sessionId, ad, listener); + RegisterInterstitialAutoSession(session); + ad.SetInteractionListener(session); + ad.ShowFailed += session.OnShowFailed; + + var shown = ad.Show(); + if (!shown) + { + session.FailAndDispose(new DirichletError("show_failed", "ShowInterstitialAd returned false")); + } + }, + error => + { + listener?.OnError(error ?? new DirichletError("load_failed", "LoadInterstitialAd failed")); + }); + } + + private void ShowSplashLoadAndShowInternal(DirichletAdRequest request, DirichletAdShowOptions options, IDirichletSplashAutoAdListener listener) + { + LoadSplashAd( + request, + ad => + { + if (ad == null) + { + listener?.OnError(new DirichletError("invalid_ad", "Load callback returned null ad")); + return; + } + + var sessionId = Guid.NewGuid().ToString("N"); + var session = new AutoSplashSession(sessionId, ad, listener); + RegisterSplashAutoSession(session); + ad.SetInteractionListener(session); + ad.ShowFailed += session.OnShowFailed; + + var shown = ad.Show(); + if (!shown) + { + session.FailAndDispose(new DirichletError("show_failed", "ShowSplashAd returned false")); + } + }, + error => + { + listener?.OnError(error ?? new DirichletError("load_failed", "LoadSplashAd failed")); + }); + } + private static bool ValidateRequest(DirichletAdRequest request, Action onFailure) { if (request == null) @@ -628,6 +701,58 @@ namespace Dirichlet.Mediation } } + private static void RegisterInterstitialAutoSession(AutoInterstitialSession session) + { + if (session == null) + { + return; + } + + lock (InterstitialAutoSessionLock) + { + InterstitialAutoSessions[session.SessionId] = session; + } + } + + private static void UnregisterInterstitialAutoSession(string sessionId) + { + if (string.IsNullOrEmpty(sessionId)) + { + return; + } + + lock (InterstitialAutoSessionLock) + { + InterstitialAutoSessions.Remove(sessionId); + } + } + + private static void RegisterSplashAutoSession(AutoSplashSession session) + { + if (session == null) + { + return; + } + + lock (SplashAutoSessionLock) + { + SplashAutoSessions[session.SessionId] = session; + } + } + + private static void UnregisterSplashAutoSession(string sessionId) + { + if (string.IsNullOrEmpty(sessionId)) + { + return; + } + + lock (SplashAutoSessionLock) + { + SplashAutoSessions.Remove(sessionId); + } + } + private sealed class AutoRewardVideoSession : IDirichletRewardAdInteractionListener { public string SessionId { get; } @@ -684,6 +809,11 @@ namespace Dirichlet.Mediation listener?.OnRewardVerify(args); } + public void OnShowFailed(DirichletError error) + { + FailAndDispose(error ?? new DirichletError("show_error", "Reward video ad failed to show")); + } + public void FailAndDispose(DirichletError error) { if (disposed) @@ -707,6 +837,8 @@ namespace Dirichlet.Mediation try { + ad.ShowFailed -= OnShowFailed; + ad.SetInteractionListener(null); ad?.Destroy(); } catch (Exception ex) @@ -715,6 +847,176 @@ namespace Dirichlet.Mediation } } } + + private sealed class AutoInterstitialSession : IDirichletInterstitialAdInteractionListener + { + public string SessionId { get; } + + private readonly DirichletInterstitialAd ad; + private readonly IDirichletInterstitialAutoAdListener listener; + private bool disposed; + + public AutoInterstitialSession(string sessionId, DirichletInterstitialAd ad, IDirichletInterstitialAutoAdListener listener) + { + SessionId = string.IsNullOrEmpty(sessionId) ? Guid.NewGuid().ToString("N") : sessionId; + this.ad = ad ?? throw new ArgumentNullException(nameof(ad)); + this.listener = listener; + } + + public void OnAdShow() + { + if (disposed) + { + return; + } + + listener?.OnAdShow(); + } + + public void OnAdClick() + { + if (disposed) + { + return; + } + + listener?.OnAdClick(); + } + + public void OnAdClose() + { + if (disposed) + { + return; + } + + listener?.OnAdClose(); + Dispose(); + } + + public void OnShowFailed(DirichletError error) + { + FailAndDispose(error ?? new DirichletError("show_error", "Interstitial ad failed to show")); + } + + public void FailAndDispose(DirichletError error) + { + if (disposed) + { + return; + } + + listener?.OnError(error ?? new DirichletError("unknown_error", "Unknown error")); + Dispose(); + } + + private void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + UnregisterInterstitialAutoSession(SessionId); + + try + { + ad.ShowFailed -= OnShowFailed; + ad.SetInteractionListener(null); + ad?.Destroy(); + } + catch (Exception ex) + { + Debug.LogWarning($"[Dirichlet] Auto interstitial ad Destroy failed: {ex.Message}"); + } + } + } + + private sealed class AutoSplashSession : IDirichletSplashAdInteractionListener + { + public string SessionId { get; } + + private readonly DirichletSplashAd ad; + private readonly IDirichletSplashAutoAdListener listener; + private bool disposed; + + public AutoSplashSession(string sessionId, DirichletSplashAd ad, IDirichletSplashAutoAdListener listener) + { + SessionId = string.IsNullOrEmpty(sessionId) ? Guid.NewGuid().ToString("N") : sessionId; + this.ad = ad ?? throw new ArgumentNullException(nameof(ad)); + this.listener = listener; + } + + public void OnAdShow() + { + if (disposed) + { + return; + } + + listener?.OnAdShow(); + } + + public void OnAdClick() + { + if (disposed) + { + return; + } + + listener?.OnAdClick(); + } + + public void OnAdClose() + { + if (disposed) + { + return; + } + + listener?.OnAdClose(); + Dispose(); + } + + public void OnShowFailed(DirichletError error) + { + FailAndDispose(error ?? new DirichletError("show_error", "Splash ad failed to show")); + } + + public void FailAndDispose(DirichletError error) + { + if (disposed) + { + return; + } + + listener?.OnError(error ?? new DirichletError("unknown_error", "Unknown error")); + Dispose(); + } + + private void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + UnregisterSplashAutoSession(SessionId); + + try + { + ad.ShowFailed -= OnShowFailed; + ad.SetInteractionListener(null); + ad?.Destroy(); + } + catch (Exception ex) + { + Debug.LogWarning($"[Dirichlet] Auto splash ad Destroy failed: {ex.Message}"); + } + } + } } /// @@ -1189,7 +1491,7 @@ namespace Dirichlet.Mediation /// /// Listener interface for auto interstitial ad callbacks. - /// Android only - iOS will receive OnError with not_supported error. + /// Android uses native auto cache; iOS/editor can emulate load-then-show. /// public interface IDirichletInterstitialAutoAdListener { @@ -1201,7 +1503,7 @@ namespace Dirichlet.Mediation /// /// Listener interface for auto banner ad callbacks. - /// Android only - iOS will receive OnError with not_supported error. + /// Native auto rotation is Android only. /// public interface IDirichletBannerAutoAdListener { @@ -1213,7 +1515,7 @@ namespace Dirichlet.Mediation /// /// Listener interface for auto splash ad callbacks. - /// Android only - iOS will receive OnError with not_supported error. + /// Android uses native auto cache; iOS/editor can emulate load-then-show. /// public interface IDirichletSplashAutoAdListener { @@ -1226,7 +1528,7 @@ namespace Dirichlet.Mediation /// /// Listener interface for auto reward video ad callbacks. /// Used with ShowRewardVideoAutoAd which combines load and show into one operation. - /// Android only - iOS will receive OnError with not_supported error. + /// Android uses native auto cache; iOS/editor can emulate load-then-show. /// public interface IDirichletRewardVideoAutoAdListener { diff --git a/Assets/DirichletMediation/Runtime/DirichletMediationSdk.cs b/Assets/DirichletMediation/Runtime/DirichletMediationSdk.cs index cf49d50..4733d69 100644 --- a/Assets/DirichletMediation/Runtime/DirichletMediationSdk.cs +++ b/Assets/DirichletMediation/Runtime/DirichletMediationSdk.cs @@ -580,12 +580,12 @@ namespace Dirichlet.Mediation /// /// Shows a reward video ad with automatic load-and-show logic. - /// Android only - iOS will call onFailure with not_supported error. + /// Android bridge maps to native auto cache; higher layers may emulate load-then-show elsewhere. /// void ShowRewardVideoAutoAd(DirichletAdRequest request, IDirichletRewardVideoAutoAdListener listener); /// - /// Shows an interstitial ad with automatic load-and-show logic. Android only. + /// Shows an interstitial ad with automatic load-and-show logic. Android bridge maps to native auto cache. /// void ShowInterstitialAutoAd(DirichletAdRequest request, IDirichletInterstitialAutoAdListener listener); @@ -595,7 +595,7 @@ namespace Dirichlet.Mediation void ShowBannerAutoAd(DirichletAdRequest request, DirichletAdShowOptions options, IDirichletBannerAutoAdListener listener); /// - /// Shows a splash ad with automatic load logic. Android only. + /// Shows a splash ad with automatic load logic. Android bridge maps to native auto cache. /// void ShowSplashAutoAd(DirichletAdRequest request, DirichletAdShowOptions options, IDirichletSplashAutoAdListener listener); @@ -1563,20 +1563,9 @@ namespace Dirichlet.Mediation } first = false; - jsonBuilder.Append($"\"{kv.Key}\":"); - - if (kv.Value is string) - { - jsonBuilder.Append($"\"{kv.Value}\""); - } - else if (kv.Value is bool) - { - jsonBuilder.Append(((bool)kv.Value) ? "true" : "false"); - } - else - { - jsonBuilder.Append(kv.Value.ToString()); - } + AppendJsonString(jsonBuilder, kv.Key); + jsonBuilder.Append(":"); + AppendJsonValue(jsonBuilder, kv.Value); } jsonBuilder.Append("}"); @@ -1589,6 +1578,84 @@ namespace Dirichlet.Mediation } } + private static void AppendJsonValue(System.Text.StringBuilder builder, object value) + { + if (value == null) + { + builder.Append("null"); + return; + } + + if (value is string stringValue) + { + AppendJsonString(builder, stringValue); + return; + } + + if (value is bool boolValue) + { + builder.Append(boolValue ? "true" : "false"); + return; + } + + if (value is IFormattable formattable) + { + builder.Append(formattable.ToString(null, CultureInfo.InvariantCulture)); + return; + } + + AppendJsonString(builder, value.ToString()); + } + + private static void AppendJsonString(System.Text.StringBuilder builder, string value) + { + builder.Append('"'); + + if (!string.IsNullOrEmpty(value)) + { + foreach (var c in value) + { + switch (c) + { + case '\\': + builder.Append("\\\\"); + break; + case '"': + builder.Append("\\\""); + break; + case '\b': + builder.Append("\\b"); + break; + case '\f': + builder.Append("\\f"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + default: + if (char.IsControl(c)) + { + builder.Append("\\u"); + builder.Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + builder.Append(c); + } + break; + } + } + } + + builder.Append('"'); + } + private void RemoveLoadCallback(string handleId) { if (string.IsNullOrEmpty(handleId)) @@ -2032,5 +2099,3 @@ namespace Dirichlet.Mediation #endregion } - - diff --git a/Assets/Plugins/iOS/DirichletMediationUnityBridge.mm b/Assets/Plugins/iOS/DirichletMediationUnityBridge.mm index 381171e..a4e98ce 100644 --- a/Assets/Plugins/iOS/DirichletMediationUnityBridge.mm +++ b/Assets/Plugins/iOS/DirichletMediationUnityBridge.mm @@ -10,6 +10,7 @@ #import "DirichletMediationUnityBridge.h" #import #import +#import // Unity callback interface extern "C" void UnitySendMessage(const char* obj, const char* method, const char* msg); @@ -119,6 +120,88 @@ static NSDictionary* ParseExtras(const char* extrasJson) { return error ? nil : dict; } +static void SendLoadErrorToUnity(NSString* handleId, NSString* adType, NSError* error, NSString* fallbackMessage) { + NSInteger code = error ? error.code : -1; + NSString* message = error.localizedDescription ?: fallbackMessage ?: @"Unknown error"; + NSDictionary* errorData = @{ + @"code": @(code), + @"message": message + }; + SendLoadCallbackToUnity(handleId, @"load_error", adType, errorData); +} + +static void SendShowErrorToUnity(NSString* handleId, NSString* adType, NSInteger code, NSString* message) { + NSDictionary* errorData = @{ + @"code": @(code), + @"message": message ?: @"Ad failed to show" + }; + SendEventToUnity(handleId, @"show_error", adType ?: @"unknown", errorData); +} + +static UIViewController* TopViewController(UIViewController* rootViewController) { + UIViewController* top = rootViewController; + while (top.presentedViewController) { + top = top.presentedViewController; + } + + if ([top isKindOfClass:[UINavigationController class]]) { + return TopViewController(((UINavigationController*)top).visibleViewController); + } + + if ([top isKindOfClass:[UITabBarController class]]) { + return TopViewController(((UITabBarController*)top).selectedViewController); + } + + return top; +} + +static UIViewController* CurrentRootViewController(void) { + UIWindow* keyWindow = nil; + + if (@available(iOS 13.0, *)) { + for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { + if (scene.activationState != UISceneActivationStateForegroundActive || + ![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + + UIWindowScene* windowScene = (UIWindowScene*)scene; + for (UIWindow* window in windowScene.windows) { + if (window.isKeyWindow) { + keyWindow = window; + break; + } + } + + if (keyWindow) { + break; + } + } + } + + if (!keyWindow) { + keyWindow = [UIApplication sharedApplication].keyWindow; + } + + return TopViewController(keyWindow.rootViewController); +} + +static NSString* AdTypeForObject(id ad) { + if ([ad isKindOfClass:[DRMRewardVideoAd class]]) { + return @"reward_video"; + } + if ([ad isKindOfClass:[DRMInterstitialAd class]]) { + return @"interstitial"; + } + if ([ad isKindOfClass:[DRMBannerAd class]]) { + return @"banner"; + } + if ([ad isKindOfClass:[DRMSplashAd class]]) { + return @"splash"; + } + return @"unknown"; +} + #pragma mark - Ad Instance Manager @interface DirichletMediationInstanceManager : NSObject @@ -336,6 +419,7 @@ bool DirichletMediationUnityBridge_Initialize( // Check if SDK is already initialized if ([DirichletMediation isInitialized]) { NSLog(@"[DirichletMediationUnityBridge] SDK already initialized"); + SendInitCallbackToUnity(YES, nil, @"already_initialized"); return true; } @@ -388,8 +472,29 @@ bool DirichletMediationUnityBridge_Initialize( } void DirichletMediationUnityBridge_RequestPermissionIfNeeded(void) { - // iOS 14+ ATT permission is handled internally by the SDK NSLog(@"[DirichletMediationUnityBridge] RequestPermissionIfNeeded called"); + + if (@available(iOS 14.0, *)) { + void (^requestBlock)(void) = ^{ + ATTrackingManagerAuthorizationStatus status = [ATTrackingManager trackingAuthorizationStatus]; + if (status != ATTrackingManagerAuthorizationStatusNotDetermined) { + NSLog(@"[DirichletMediationUnityBridge] ATT status already determined: %lu", (unsigned long)status); + return; + } + + [ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) { + NSLog(@"[DirichletMediationUnityBridge] ATT request completed with status: %lu", (unsigned long)status); + }]; + }; + + if ([NSThread isMainThread]) { + requestBlock(); + } else { + dispatch_async(dispatch_get_main_queue(), requestBlock); + } + } else { + NSLog(@"[DirichletMediationUnityBridge] ATT is not required below iOS 14"); + } } const char* DirichletMediationUnityBridge_GetSdkVersion(void) { @@ -438,11 +543,7 @@ const char* DirichletMediationUnityBridge_LoadRewardVideoAd(long long spaceId, c SendLoadCallbackToUnity(handleId, @"load_success", @"reward_video", nil); } else { NSLog(@"[DirichletMediationUnityBridge] RewardVideoAd load failed: %@", error.localizedDescription); - NSDictionary* errorData = @{ - @"code": @(error.code), - @"message": error.localizedDescription ?: @"Unknown error" - }; - SendLoadCallbackToUnity(handleId, @"load_error", @"reward_video", errorData); + SendLoadErrorToUnity(handleId, @"reward_video", error, @"RewardVideoAd load failed"); } }]; @@ -475,11 +576,7 @@ const char* DirichletMediationUnityBridge_LoadInterstitialAd(long long spaceId, SendLoadCallbackToUnity(handleId, @"load_success", @"interstitial", nil); } else { NSLog(@"[DirichletMediationUnityBridge] InterstitialAd load failed: %@", error.localizedDescription); - NSDictionary* errorData = @{ - @"code": @(error.code), - @"message": error.localizedDescription ?: @"Unknown error" - }; - SendLoadCallbackToUnity(handleId, @"load_error", @"interstitial", errorData); + SendLoadErrorToUnity(handleId, @"interstitial", error, @"InterstitialAd load failed"); } }]; @@ -520,11 +617,7 @@ const char* DirichletMediationUnityBridge_LoadBannerAd(long long spaceId, const SendLoadCallbackToUnity(handleId, @"load_success", @"banner", nil); } else { NSLog(@"[DirichletMediationUnityBridge] BannerAd load failed: %@", error.localizedDescription); - NSDictionary* errorData = @{ - @"code": @(error.code), - @"message": error.localizedDescription ?: @"Unknown error" - }; - SendLoadCallbackToUnity(handleId, @"load_error", @"banner", errorData); + SendLoadErrorToUnity(handleId, @"banner", error, @"BannerAd load failed"); } }]; @@ -565,11 +658,7 @@ const char* DirichletMediationUnityBridge_LoadSplashAd(long long spaceId, const SendLoadCallbackToUnity(handleId, @"load_success", @"splash", nil); } else { NSLog(@"[DirichletMediationUnityBridge] SplashAd load failed: %@", error.localizedDescription); - NSDictionary* errorData = @{ - @"code": @(error.code), - @"message": error.localizedDescription ?: @"Unknown error" - }; - SendLoadCallbackToUnity(handleId, @"load_error", @"splash", errorData); + SendLoadErrorToUnity(handleId, @"splash", error, @"SplashAd load failed"); } }]; @@ -584,12 +673,16 @@ bool DirichletMediationUnityBridge_ShowAd(const char* handleId, const char* extr NSLog(@"[DirichletMediationUnityBridge] ShowAd failed: Ad not found for handle %@", nsHandleId); return false; } - - // Ensure show is always called on main thread (aligned with Ad Unity implementation) - dispatch_async(dispatch_get_main_queue(), ^{ - UIViewController* rootVC = [[[UIApplication sharedApplication] keyWindow] rootViewController]; + + __block BOOL didStartShow = NO; + NSString* adType = AdTypeForObject(ad); + NSDictionary* extrasDict = ParseExtras(extras); + + void (^showBlock)(void) = ^{ + UIViewController* rootVC = CurrentRootViewController(); if (!rootVC) { NSLog(@"[DirichletMediationUnityBridge] ShowAd failed: Root view controller not found"); + SendShowErrorToUnity(nsHandleId, adType, -2, @"Root view controller not found"); return; } @@ -597,52 +690,86 @@ bool DirichletMediationUnityBridge_ShowAd(const char* handleId, const char* extr DRMRewardVideoAd* rewardAd = (DRMRewardVideoAd*)ad; if ([rewardAd isReady]) { [rewardAd showFromViewController:rootVC]; + didStartShow = YES; NSLog(@"[DirichletMediationUnityBridge] Showing reward video ad: %@", nsHandleId); + } else { + SendShowErrorToUnity(nsHandleId, @"reward_video", -3, @"Reward video ad is not ready"); } } else if ([ad isKindOfClass:[DRMInterstitialAd class]]) { DRMInterstitialAd* interstitialAd = (DRMInterstitialAd*)ad; if ([interstitialAd isReady]) { [interstitialAd showFromViewController:rootVC]; + didStartShow = YES; NSLog(@"[DirichletMediationUnityBridge] Showing interstitial ad: %@", nsHandleId); + } else { + SendShowErrorToUnity(nsHandleId, @"interstitial", -3, @"Interstitial ad is not ready"); } } else if ([ad isKindOfClass:[DRMBannerAd class]]) { DRMBannerAd* bannerAd = (DRMBannerAd*)ad; UIView* bannerView = bannerAd.view; if (bannerView) { - // Banner 广告需要将 view 添加到视图控制器上 - // 注意:Unity 侧需要通过 Unity UI 系统来处理 Banner 视图 - // 这里我们发送一个事件通知 Unity 侧,让 Unity 侧来处理视图的展示 - // 或者直接将视图添加到根视图控制器上(临时方案) + [bannerView removeFromSuperview]; [rootVC.view addSubview:bannerView]; bannerView.translatesAutoresizingMaskIntoConstraints = NO; - // 设置约束,让 Banner 显示在底部 + + NSInteger baseline = [extrasDict[@"banner_baseline"] integerValue]; + CGFloat offset = extrasDict[@"banner_offset"] ? [extrasDict[@"banner_offset"] floatValue] : 0; + NSLayoutYAxisAnchor* verticalAnchor = baseline == 0 + ? rootVC.view.safeAreaLayoutGuide.topAnchor + : rootVC.view.safeAreaLayoutGuide.bottomAnchor; + NSLayoutConstraint* verticalConstraint = baseline == 0 + ? [bannerView.topAnchor constraintEqualToAnchor:verticalAnchor constant:offset] + : [bannerView.bottomAnchor constraintEqualToAnchor:verticalAnchor constant:-offset]; + [NSLayoutConstraint activateConstraints:@[ - [bannerView.leadingAnchor constraintEqualToAnchor:rootVC.view.leadingAnchor], - [bannerView.trailingAnchor constraintEqualToAnchor:rootVC.view.trailingAnchor], - [bannerView.bottomAnchor constraintEqualToAnchor:rootVC.view.safeAreaLayoutGuide.bottomAnchor], + [bannerView.centerXAnchor constraintEqualToAnchor:rootVC.view.centerXAnchor], + [bannerView.widthAnchor constraintLessThanOrEqualToAnchor:rootVC.view.widthAnchor], + verticalConstraint, [bannerView.heightAnchor constraintEqualToConstant:bannerAd.size.height > 0 ? bannerAd.size.height : 50] ]]; + didStartShow = YES; NSLog(@"[DirichletMediationUnityBridge] Showing banner ad: %@", nsHandleId); } else { NSLog(@"[DirichletMediationUnityBridge] Banner ad view not available: %@", nsHandleId); + SendShowErrorToUnity(nsHandleId, @"banner", -4, @"Banner ad view not available"); } } else if ([ad isKindOfClass:[DRMSplashAd class]]) { DRMSplashAd* splashAd = (DRMSplashAd*)ad; if ([splashAd isReady]) { [splashAd showFromViewController:rootVC]; + didStartShow = YES; NSLog(@"[DirichletMediationUnityBridge] Showing splash ad: %@", nsHandleId); + } else { + SendShowErrorToUnity(nsHandleId, @"splash", -3, @"Splash ad is not ready"); } } else { NSLog(@"[DirichletMediationUnityBridge] ShowAd failed: Ad not ready or unknown type"); + SendShowErrorToUnity(nsHandleId, @"unknown", -5, @"Unknown ad type"); } - }); + }; + + if ([NSThread isMainThread]) { + showBlock(); + } else { + dispatch_sync(dispatch_get_main_queue(), showBlock); + } - return true; + return didStartShow; } void DirichletMediationUnityBridge_DestroyAd(const char* handleId) { NSString* nsHandleId = CreateNSString(handleId); NSLog(@"[DirichletMediationUnityBridge] Destroying ad: %@", nsHandleId); + + id ad = [[DirichletMediationInstanceManager shared] adForHandle:nsHandleId]; + if ([ad isKindOfClass:[DRMBannerAd class]]) { + DRMBannerAd* bannerAd = (DRMBannerAd*)ad; + if (bannerAd.view.superview) { + dispatch_async(dispatch_get_main_queue(), ^{ + [bannerAd.view removeFromSuperview]; + }); + } + } // Remove ad instance [[DirichletMediationInstanceManager shared] removeAdForHandle:nsHandleId]; @@ -679,4 +806,3 @@ bool DirichletMediationUnityBridge_IsAdValid(const char* handleId) { } } // extern "C" - diff --git a/GLOBAL_DESIGN.md b/GLOBAL_DESIGN.md index cc10ac3..3bebcf6 100644 --- a/GLOBAL_DESIGN.md +++ b/GLOBAL_DESIGN.md @@ -14,9 +14,10 @@ * `Assets/DirichletMediation`: 官方聚合 Unity SDK `4.2.5.0`,已删除官方 Sample。 * `Assets/Plugins/Android`: 官方 Android AAR、Manifest、ProGuard、本地微信 OpenSDK AAR。 -* `Assets/Plugins/iOS`: 官方 iOS bridge。 +* `Assets/Plugins/iOS`: iOS Objective-C++ bridge。 * `Assets/Tapadn_Adapter/Runtime/Scripts`: 商业化抽象层适配。 * `Assets/Tapadn_Adapter/Editor`: Android 构建后处理和依赖声明。 +* `Assets/DirichletMediation/Editor`: Android Gradle 后处理与 iOS Xcode/CocoaPods 后处理。 * `Assets/Samples~`: 可选调试样例预留,不随主包自动进入业务项目。 ## Runtime 设计 @@ -49,7 +50,7 @@ 不确定点: -* 官方文档说明 auto-ad 目前主要是 Android 能力,iOS auto-ad 会返回 `not_supported`。本模块保留手动 fallback,但真正 iOS 出包前需要用 TapADN iOS 账号和广告位做真机验证。 +* 官方文档说明 auto-ad 目前主要是 Android 能力。本模块在 iOS 对激励、插屏、开屏 auto API 做 load-then-show 兼容 fallback,但不承诺 Android native auto 缓存语义;真正 iOS 出包前需要用 TapADN iOS 账号和广告位做真机验证。 * `AD_Type` 抽象层没有 Banner 类型,所以本轮没有将 TapADN Banner 暴露到 `ADManager`。如果抽象层后续新增 Banner,可以直接复用官方 `ShowBannerAutoAd`。 ## 配置设计 @@ -82,6 +83,22 @@ * 方案 B:只放本地微信 AAR。风险是宿主 EDM4U 依赖图不可见。 * 当前选择:本地 AAR + `WXDependencies.xml` 同时保留。这样最接近 TopOn 当前工程,也能覆盖无 EDM4U 的构建场景。 +## iOS 构建设计 + +保留 iOS Objective-C++ bridge,并通过 `DirichletMediationIOSPostProcessor` 自动生成 Xcode 集成: + +* Pod 默认版本为官方 iOS 聚合 SDK `4.2.0.1`,可用 `DIRICHLET_IOS_SDK_VERSION` 或 `EditorPrefs("Dirichlet.iOS.SDKVersion")` 覆盖。 +* 所有 Dirichlet iOS Pods 放到 Unity Framework target,保持 bridge、SDK、adapter 在同一二进制上下文。 +* 自动补 `SKAdNetworkItems`、`NSUserTrackingUsageDescription`、`AppTrackingTransparency.framework`、`AdSupport.framework`。 +* 构建后执行 `pod install`,缺失 CocoaPods 或 Pod 源异常时让 Unity 构建失败,避免产出半配置 Xcode 工程。 +* GDT 动态 framework 在 `pod install` 后嵌入 App target。 + +iOS runtime 桥接负责: + +* 初始化时传入 `MediaId`、`MediaKey`、`MediaName`、`gameChannel`、`shakeEnabled`、`allowIDFAAccess`、`aTags`。 +* `RequestPermissionIfNecessary()` 在 iOS 14+ 请求 ATT。 +* 加载/展示激励、插屏、开屏;展示前检查 root view controller 和 `isReady`,失败时回传 `show_error`。 + ## 编辑器可见性 本模块不提供默认可见面板。构建自动化通过 `IPostGenerateGradleAndroidProject` 静默执行;调试能力放入 `Samples~`,由业务项目显式导入。 diff --git a/README.md b/README.md index bcf5d60..64fb79f 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,24 @@ ADManager.Instance.Init(callback, userId, adConfig, new TapadnAdController()); 包内不默认暴露可视化编辑面板;调试样例通过 `Samples~` 作为可选导入内容。 +## iOS 构建 + +iOS 侧通过 `DirichletMediationIOSPostProcessor` 在 Unity 导出 Xcode 工程后自动处理: + +* 生成 `Podfile`,默认接入 `DirichletMediationSDK`、`DirichletMediationAdapterDRA`、`DirichletMediationAdapterCSJ`、`DirichletMediationAdapterGDT` 的 iOS `4.2.0.1` Pod。 +* 将 Pods 放到 Unity Framework target,避免 adapter 被 strip 后运行时找不到类。 +* 补齐 `SKAdNetworkItems`、`NSUserTrackingUsageDescription`、`AppTrackingTransparency.framework`、`AdSupport.framework`。 +* 执行 `pod install`;若构建机没有 CocoaPods,会在日志里给出手动执行路径。 +* 将 GDT 的动态 framework 嵌入 App target。 + +可选覆盖: + +* `DIRICHLET_IOS_SDK_VERSION` 或 `EditorPrefs("Dirichlet.iOS.SDKVersion")`:临时切换 iOS Pod 版本。 +* `DIRICHLET_IOS_ATT_DESCRIPTION` 或 `EditorPrefs("Dirichlet.iOS.TrackingUsageDescription")`:替换 ATT 弹窗文案。 +* `DIRICHLET_UNITY_FRAMEWORK_TARGET` / `DIRICHLET_UNITY_APP_TARGET`:极端自定义 Xcode target 名称时手动指定。 + +iOS 的 auto-ad 原生接口仍按官方口径视为 Android 能力;本模块在 iOS 上对激励、插屏、开屏做了“load 成功后立即 show”的兼容 fallback,不承诺 native 缓存语义。正式联调仍需要用 TapADN iOS 媒体账号和 iOS 广告位做真机验证,重点看初始化、ATT、三类广告 load/show/close/reward 回调,以及无填充/未 ready 的失败收口。 + ## 智能预加载敏感度验收(默认次留 35%) 本模块包含一套本地仿真脚本,用于模拟 IAA 场景下不同 `PreloadThreshold` 与 `CooldownSeconds` 的收益差异,输出完整 CSV 与变化曲线。 diff --git a/TapADN自动加载策略分析报告.md b/TapADN自动加载策略分析报告.md index 5350927..a9758ba 100644 --- a/TapADN自动加载策略分析报告.md +++ b/TapADN自动加载策略分析报告.md @@ -59,7 +59,7 @@ TapADN / Dirichlet Unity 文档对 auto 的描述主要是“加载和展示合 本地 SDK 注释进一步约束了能力边界: * `PreLoad` 当前只有 type=3,即激励视频,会被 native SDK 处理。 -* iOS auto-ad / preload 在当前 wrapper 中返回不支持或 noop。 +* iOS 没有官方 native auto 缓存语义;当前模块会对激励、插屏、开屏 auto API 做 load-then-show 兼容 fallback,`PreLoad` 仍为 noop。 * Android bridge 中为 auto ad 保留了 singleton `DirichletAdNative`,用于保持 native 侧自动广告缓存。 当前接入严格遵循了这些 TapADN API,但没有也不能补出 TopOn 那种场景入口,因为当前 SDK wrapper 没有暴露同类接口。`ADManager.EnterAdScenario(...)` 对 TapADN 玩家侧目前没有平台上报效果,只能作为项目自身策略或日志的入口继续扩展。 @@ -73,7 +73,7 @@ TapADN / Dirichlet Unity 文档对 auto 的描述主要是“加载和展示合 * 开屏在中国安卓渠道更常见,但冷启动时长、首屏流失和合规压力都更敏感。若 auto show 触发后再加载,失败或超时都直接影响进主场景体验;生产上更适合手动加载、严格超时和无广告快速进入。 * 顶级 IAA 调优更关注 placement / scenario 维度,而不是“这个类型是不是 auto”。同一个激励视频,复活、翻倍金币、免费抽奖的 eCPM、转化率和留存影响都可能不同,需要可分桶、可频控、可 A/B。 -所以,模块层应该把 auto 当作“TapADN Android 的一种展示通道”,而不是全局商业化策略本身。 +所以,模块层应该把 auto 当作“TapADN Android 的一种展示通道”;iOS 兼容 fallback 只能保证回调链路完整,不应被当成同等的 native 缓存策略。 ## 推荐默认配置 @@ -136,7 +136,7 @@ tapadn.splash_prewarm_on_init=false ## 当前实现评价 -当前 TapADN auto 接入本身是合规且完整的 API 级接入,但默认策略偏激进。它适合样例工程、快速调试和 Android 单平台验证;对于正式 IAA 游戏商业化,建议把默认认知改成: +当前 TapADN auto 接入本身是合规且完整的 API 级接入,但默认策略偏激进。它适合样例工程和快速调试;对于 Android/iOS 同步上线的正式 IAA 游戏商业化,建议把默认认知改成: * TapADN auto 负责“SDK 内部 load + show + 缓存复用”。 * 项目商业化策略仍然负责“场景进入、ready gating、频控、冷却、奖励发放、A/B 分桶和兜底路径”。