diff --git a/Swift/SGAIClientSideExample/Podfile b/Swift/SGAIClientSideExample/Podfile index dae32e9..92f1e28 100755 --- a/Swift/SGAIClientSideExample/Podfile +++ b/Swift/SGAIClientSideExample/Podfile @@ -1,5 +1,3 @@ -source 'https://github.com/CocoaPods/Specs.git' - platform :tvos, '17' project 'SGAIClientSideExample.xcodeproj' diff --git a/Swift/SGAIClientSideExample/SGAIClientSideExample.xcodeproj/project.pbxproj b/Swift/SGAIClientSideExample/SGAIClientSideExample.xcodeproj/project.pbxproj index 6a9569d..4fe0d8f 100644 --- a/Swift/SGAIClientSideExample/SGAIClientSideExample.xcodeproj/project.pbxproj +++ b/Swift/SGAIClientSideExample/SGAIClientSideExample.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 128B5F3B2FB1396E00BC9E0C /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 128B5F3A2FB1396E00BC9E0C /* Secrets.swift */; }; C7B0262FB6644A3ABE00B648 /* libPods-SGAIClientSideExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A7EDE97B02BE38F086895B4E /* libPods-SGAIClientSideExample.a */; }; D21414D725755FA90019940F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21414D625755FA90019940F /* AppDelegate.swift */; }; D21414D925755FA90019940F /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21414D825755FA90019940F /* ViewController.swift */; }; @@ -14,6 +15,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 128B5F3A2FB1396E00BC9E0C /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; A7EDE97B02BE38F086895B4E /* libPods-SGAIClientSideExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SGAIClientSideExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; B8F57A6FE0CFC97F17FEE6EB /* Pods-SGAIClientSideExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SGAIClientSideExample.debug.xcconfig"; path = "Target Support Files/Pods-SGAIClientSideExample/Pods-SGAIClientSideExample.debug.xcconfig"; sourceTree = ""; }; D21414D325755FA90019940F /* SGAIClientSideExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SGAIClientSideExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -74,6 +76,7 @@ D21414D525755FA90019940F /* app */ = { isa = PBXGroup; children = ( + 128B5F3A2FB1396E00BC9E0C /* Secrets.swift */, D21414D625755FA90019940F /* AppDelegate.swift */, D21414D825755FA90019940F /* ViewController.swift */, D21414DD25755FAA0019940F /* Assets.xcassets */, @@ -112,7 +115,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1210; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 2600; TargetAttributes = { D21414D225755FA90019940F = { CreatedOnToolsVersion = 12.1; @@ -157,10 +160,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SGAIClientSideExample/Pods-SGAIClientSideExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SGAIClientSideExample/Pods-SGAIClientSideExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SGAIClientSideExample/Pods-SGAIClientSideExample-frameworks.sh\"\n"; @@ -196,6 +203,7 @@ buildActionMask = 2147483647; files = ( D21414D925755FA90019940F /* ViewController.swift in Sources */, + 128B5F3B2FB1396E00BC9E0C /* Secrets.swift in Sources */, D21414D725755FA90019940F /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -239,6 +247,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = Z95L3YYF93; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -260,6 +269,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = appletvos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TVOS_DEPLOYMENT_TARGET = 18.0; @@ -302,6 +312,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = Z95L3YYF93; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -316,6 +327,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = appletvos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TVOS_DEPLOYMENT_TARGET = 18.0; @@ -330,7 +342,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = Z95L3YYF93; INFOPLIST_FILE = app/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -351,7 +362,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = Z95L3YYF93; INFOPLIST_FILE = app/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Swift/SGAIClientSideExample/app/Secrets.swift b/Swift/SGAIClientSideExample/app/Secrets.swift new file mode 100644 index 0000000..ee2e870 --- /dev/null +++ b/Swift/SGAIClientSideExample/app/Secrets.swift @@ -0,0 +1,26 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +// Secrets provided by your Google Ad Manager account. +enum Secrets { + // Add your Stream Create Authentication Key from Google Ad Manager. + // See https://developers.google.com/ad-manager/dynamic-ad-insertion/api/pod-serving/live/stream-session-requests + static let streamCreateSecret = "" + + // Add your Pod Resource Authentication Key from Google Ad Manager. + // See https://developers.google.com/ad-manager/dynamic-ad-insertion/api/pod-serving/live/pod-manifest-requests + static let manifestSecret = "" +} diff --git a/Swift/SGAIClientSideExample/app/ViewController.swift b/Swift/SGAIClientSideExample/app/ViewController.swift index 843f9b9..c79d35c 100644 --- a/Swift/SGAIClientSideExample/app/ViewController.swift +++ b/Swift/SGAIClientSideExample/app/ViewController.swift @@ -16,6 +16,7 @@ // [START set_up_view_controller] import AVFoundation +import CryptoKit import GoogleInteractiveMediaAds import UIKit @@ -35,11 +36,11 @@ class ViewController: // or use the test network code and custom asset key with the DAI type "Pod serving manifest" // from [DAI sample streams](https://developers.google.com/ad-manager/dynamic-ad-insertion/streams#pod_serving_dai). - /// Google Ad Manager network code. - static let networkCode = "21775744923" + /// Replace with your Google Ad Manager network code. + static let networkCode = "YOUR_NETWORK_CODE" - /// Google DAI livestream custom asset key. - static let customAssetKey = "sgai-hls-live" + /// Replace with your Google Ad Manager custom asset key. + static let customAssetKey = "YOUR_CUSTOM_ASSET_KEY" // Set your ad break duration. static let adBreakDurationMs = 10000 @@ -68,6 +69,10 @@ class ViewController: /// The ad stream session ID, set when the ad stream is loaded. private var adStreamSessionId: String? + // Set the duration for the auth token to be valid, in seconds. This is set to 60 seconds but + // can be set to any value depending on your use case. + private var expirationDuration: TimeInterval = 60 + private var playerViewController: AVPlayerViewController! deinit { @@ -137,7 +142,7 @@ class ViewController: player.play() } - /// Makes a stream request to Google Ad Manager. + // Makes a stream request to Google Ad Manager. private func loadAdStream() { let streamRequest = IMAPodStreamRequest( networkCode: StreamParameters.networkCode, @@ -147,8 +152,30 @@ class ViewController: pictureInPictureProxy: nil, userContext: nil) - // Register a streaming session on Google Ad Manager DAI servers. - adsLoader.requestStream(with: streamRequest) + // Generate and set the authToken using the secret from Secrets.swift. + if !Secrets.streamCreateSecret.isEmpty { + let expirationTime = Int(Date().timeIntervalSince1970) + Int(expirationDuration) + let params: [String: Any] = [ + "custom_asset_key": StreamParameters.customAssetKey, + "exp": expirationTime, + "network_code": StreamParameters.networkCode, + ] + if let authToken = generateAuthToken( + params: params, + secret: Secrets.streamCreateSecret + ) { + streamRequest.authToken = authToken + print("AdsManager: Making PodStreamRequest with auth.") + // Register a streaming session on Google Ad Manager DAI servers. + adsLoader.requestStream(with: streamRequest) + } else { + print("AdsManager: Failed to generate auth token. Stream request aborted.") + } + } else { + print("AdsManager: Making PodStreamRequest without auth (streamCreateSecret is empty).") + // Register a streaming session on Google Ad Manager DAI servers. + adsLoader.requestStream(with: streamRequest) + } } // [END make_stream_request] @@ -179,13 +206,40 @@ class ViewController: seconds: player.currentTime().seconds + Double(secondsToAdBreakStart), preferredTimescale: 1) // Create an identifier to construct the ad pod request for the next ad break. - let adPodIdentifier = generatePodIdentifier(from: currentSeconds) + let adPodIdentifier = generateAdBreakID() + + // Generate HMAC token for the manifest URL using the secret from Secrets.swift. + let manifestSecret = Secrets.manifestSecret + guard !manifestSecret.isEmpty else { + print("Manifest secret is empty. Cannot generate auth token for ad pod manifest.") + return + } + + let expirationTime = Int( + Date.now.addingTimeInterval(TimeInterval(expirationDuration)).timeIntervalSince1970) + let params: [String: Any] = [ + "ad_break_id": adPodIdentifier.replacingOccurrences(of: "ad_break_id/", with: ""), + "custom_asset_key": StreamParameters.customAssetKey, + "exp": expirationTime, + "network_code": StreamParameters.networkCode, + "pd": StreamParameters.adBreakDurationMs, + ] + + guard let authToken = generateAuthToken(params: params, secret: manifestSecret) else { + print( + "Failed to generate auth token for ad pod manifest. Skipping insertion of \(adPodIdentifier)." + ) + return + } + + var components = CharacterSet.alphanumerics + components.insert(charactersIn: "-._~") + let encodedAuthToken = authToken.addingPercentEncoding(withAllowedCharacters: components) ?? "" - // Construct the ad pod request URL for the next ad break. guard let adPodManifestUrl = URL( string: - "https://dai.google.com/linear/pods/v1/hls/network/\(StreamParameters.networkCode)/custom_asset/\(StreamParameters.customAssetKey)/\(adPodIdentifier).m3u8?stream_id=\(streamID)&pd=\(StreamParameters.adBreakDurationMs)" + "https://dai.google.com/linear/pods/v1/hls/network/\(StreamParameters.networkCode)/custom_asset/\(StreamParameters.customAssetKey)/\(adPodIdentifier).m3u8?stream_id=\(streamID)&pd=\(StreamParameters.adBreakDurationMs)&auth-token=\(encodedAuthToken)" ) else { // If URL creation fails, verify that StreamParameters and streamID are not empty. @@ -208,16 +262,43 @@ class ViewController: } // [END schedule_ad_insertion] + // Generates an HMAC-SHA256 token based on the provided parameters and secret. + // [START generate_auth_token] + private func generateAuthToken(params: [String: Any], secret: String) -> String? { + let sortedKeys = params.keys.sorted() + var tokenPairs: [String] = [] + + for key in sortedKeys { + if let value = params[key] { + tokenPairs.append("\(key)=\(value)") + } + } + + let tokenString = tokenPairs.joined(separator: "~") + + guard let secretData = secret.data(using: .utf8), + let tokenData = tokenString.data(using: .utf8) + else { + print("Error: Could not convert secret or tokenString to Data.") + return nil + } + + let key = SymmetricKey(data: secretData) + let hmac = HMAC.authenticationCode(for: tokenData, using: key) + let hexHash = hmac.map { String(format: "%02x", $0) }.joined() + + return "\(tokenString)~hmac=\(hexHash)" + } + // [END generate_auth_token] + + // Helper Function to get an unused ad break ID. + // Ad Break ID is an alpha numeric string. + // In production the Ad Break ID is determined by your ad signalling stack, + // propagated through a SCTE35 or other cue markers. // [START generate_pod_id] - /// Generates a pod identifier based on the current time. - /// - /// See [HLS pod manifest parameters](https://developers.google.com/ad-manager/dynamic-ad-insertion/api/pod-serving/reference/live#path_parameters_3). - /// - /// - Returns: The pod identifier in either the format of "pod/{integer}" or - /// "ad_break_id/{string}". - private func generatePodIdentifier(from currentSeconds: Int) -> String { - let minute = Int(currentSeconds / 60) + 1 - return "ad_break_id/mid-roll-\(minute)" + private func generateAdBreakID() -> String { + let minutesSinceEpoch = Int(Date().timeIntervalSince1970 / expirationDuration) + return "ab\(minutesSinceEpoch)" } // [END generate_pod_id]