Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,12 @@ - (void)videoViewCompletedDisplay {
}

if (self.creativeModel.adConfiguration.presentAsInterstitial) {
// no companion ads so pass this event to the PBMModalManager
// and close video automatically
[self.modalManager creativeDisplayCompleted:self];
if (self.dismissInterstitialModalState) {
// No companion ads, so pass completion to the modal manager. Rewarded ads use
// rwdd.close.action; non-rewarded interstitials use the video controls setting.
[self.modalManager creativeDisplayCompleted:self];
if (!self.creativeModel.adConfiguration.isRewarded &&
self.creativeModel.adConfiguration.videoControlsConfig.isAutoCloseOnCompletionEnabled &&
self.dismissInterstitialModalState) {
self.dismissInterstitialModalState();
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,11 @@ - (void)completeVideoViewDisplayWith:(PBMTrackingEvent)trackingEvent {
self.progressBar.hidden = YES;
}

if (self.adConfiguration.isBuiltInVideo && !self.creative.creativeModel.hasCompanionAd) {
BOOL shouldOfferReplay = self.adConfiguration.isBuiltInVideo ||
(self.adConfiguration.presentAsInterstitial &&
!self.adConfiguration.isRewarded &&
!self.adConfiguration.videoControlsConfig.isAutoCloseOnCompletionEnabled);
if (shouldOfferReplay && !self.creative.creativeModel.hasCompanionAd) {
// UI: need to give some time to hide the interstitial before showing the Watch Again
if (self.adConfiguration.presentAsInterstitial) {
@weakify(self);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ public class VideoControlsConfiguration: NSObject {

/// This property indicates whether mute controls is visible on the screen.
public var isSoundButtonVisible = false

/// Indicates whether a non-rewarded full-screen video without a companion ad closes automatically when playback completes.
///
/// The default value is `true`, preserving the SDK's existing auto-close behavior. Set this property to `false`
/// to keep the interstitial open and display a **Watch Again** button until the user closes the ad. Rewarded ads
/// use the `rwdd.close.action` configuration instead.
/// Obtained from `ext.prebid.passthrough[].adConfiguration.isautocloseoncompletionenabled` or set by the user.
public var isAutoCloseOnCompletionEnabled = true

/// Use to initialize video controls with server values.
public func initialize(with ortbAdConfiguration: ORTBAdConfiguration?) {
Expand Down Expand Up @@ -127,6 +135,10 @@ public class VideoControlsConfiguration: NSObject {
if let ortbSkipDelay = ortbAdConfiguration.skipDelay {
skipDelay = ortbSkipDelay.doubleValue
}

if let ortbIsAutoCloseOnCompletionEnabled = ortbAdConfiguration.isAutoCloseOnCompletionEnabled {
isAutoCloseOnCompletionEnabled = ortbIsAutoCloseOnCompletionEnabled.boolValue
}
}

// MARK: - Private properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ public class InterstitialRenderingAdUnit: NSObject, BaseInterstitialAdUnitProtoc
get { adUnitConfig.adConfiguration.videoControlsConfig.isSoundButtonVisible }
set { adUnitConfig.adConfiguration.videoControlsConfig.isSoundButtonVisible = newValue }
}

/// Controls whether full-screen video ads without an end card close when playback completes.
/// Set to `false` to keep the ad open with a **Watch Again** button. The default value is `true`.
public var isAutoCloseOnCompletionEnabled: Bool {
get { adUnitConfig.adConfiguration.videoControlsConfig.isAutoCloseOnCompletionEnabled }
set { adUnitConfig.adConfiguration.videoControlsConfig.isAutoCloseOnCompletionEnabled = newValue }
}

// MARK: Private properties

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public class RewardedAdUnit: NSObject, BaseInterstitialAdUnitProtocol {
get { adUnitConfig.adConfiguration.videoControlsConfig.isSoundButtonVisible }
set { adUnitConfig.adConfiguration.videoControlsConfig.isSoundButtonVisible = newValue }
}

// MARK: Internal Properties

// Note: exposed for tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public class MediationBaseInterstitialAdUnit : NSObject {
get { adUnitConfig.adConfiguration.videoControlsConfig.isSoundButtonVisible }
set { adUnitConfig.adConfiguration.videoControlsConfig.isSoundButtonVisible = newValue }
}

/// The area for the close button in the video ad.
public var closeButtonArea: Double {
get { adUnitConfig.adConfiguration.videoControlsConfig.closeButtonArea }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ public class MediationInterstitialAdUnit: MediationBaseInterstitialAdUnit {
get { adUnitConfig.adConfiguration.videoControlsConfig.skipDelay }
set { adUnitConfig.adConfiguration.videoControlsConfig.skipDelay = newValue }
}

/// Controls whether full-screen video ads without an end card close when playback completes.
/// Set to `false` to keep the ad open with a **Watch Again** button. The default value is `true`.
public var isAutoCloseOnCompletionEnabled: Bool {
get { adUnitConfig.adConfiguration.videoControlsConfig.isAutoCloseOnCompletionEnabled }
set { adUnitConfig.adConfiguration.videoControlsConfig.isAutoCloseOnCompletionEnabled = newValue }
}

// MARK: - Public Methods

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Foundation
@objc public var skipButtonArea: NSNumber?
@objc public var skipButtonPosition: String?
@objc public var skipDelay: NSNumber?
@objc public var isAutoCloseOnCompletionEnabled: NSNumber?

private enum KeySet: String {
case maxvideoduration
Expand All @@ -33,6 +34,7 @@ import Foundation
case skipbuttonarea
case skipbuttonposition
case skipdelay
case isautocloseoncompletionenabled
}

@objc public override init() {
Expand All @@ -48,6 +50,7 @@ import Foundation
skipButtonArea = json[.skipbuttonarea]
skipButtonPosition = json[.skipbuttonposition]
skipDelay = json[.skipdelay]
isAutoCloseOnCompletionEnabled = json[.isautocloseoncompletionenabled]
}

@objc public var jsonDictionary: [String : Any] {
Expand All @@ -60,6 +63,7 @@ import Foundation
json[.skipbuttonarea] = skipButtonArea
json[.skipbuttonposition] = skipButtonPosition
json[.skipdelay] = skipDelay
json[.isautocloseoncompletionenabled] = isAutoCloseOnCompletionEnabled

return json.dict
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@property (nonatomic, weak, nullable) PBMVideoCreative *creative;
@property (nonatomic, strong) PBMAdViewButtonDecorator * _Nonnull skipButtonDecorator;
@property (nonatomic, strong, nonnull) NSNumber * progressBarDuration;
@property (nonatomic, strong, nullable) UIButton *btnWatchAgain;

- (void)updateControls;
- (CGFloat)requiredVideoDuration;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,42 @@ class PBMVideoViewPlaybackStateTest: XCTestCase, CreativeResolutionDelegate {
}
}

func testFullscreenVideoWithAutoCloseDisabledOffersReplay() {
runWithStartedPlayback(
isInterstitial: true,
autoCloseOnCompletion: false,
hasCompanionAd: false,
beforeAssertions: { $0.handleDidPlayToEndTime() },
delayBeforeAssertions: 0.6
) { videoView in
XCTAssertNotNil(videoView.btnWatchAgain)
}
}

func testFullscreenVideoWithAutoCloseEnabledDoesNotOfferReplay() {
runWithStartedPlayback(
isInterstitial: true,
hasCompanionAd: false,
beforeAssertions: { $0.handleDidPlayToEndTime() },
delayBeforeAssertions: 0.6
) { videoView in
XCTAssertNil(videoView.btnWatchAgain)
}
}

func testRewardedVideoIgnoresInterstitialAutoCloseSetting() {
runWithStartedPlayback(
isInterstitial: true,
isRewarded: true,
autoCloseOnCompletion: false,
hasCompanionAd: false,
beforeAssertions: { $0.handleDidPlayToEndTime() },
delayBeforeAssertions: 0.6
) { videoView in
XCTAssertNil(videoView.btnWatchAgain)
}
}

// MARK: - App lifecycle transitions

func testResignActiveWhilePlayingSetsPausedByBackground() {
Expand Down Expand Up @@ -369,10 +405,20 @@ class PBMVideoViewPlaybackStateTest: XCTestCase, CreativeResolutionDelegate {
// Loads the video creative, puts it on screen, starts playback and runs
// assertions one second later, when the player is actually playing.
private func runWithStartedPlayback(isInterstitial: Bool = false,
isRewarded: Bool = false,
autoCloseOnCompletion: Bool = true,
hasCompanionAd: Bool = true,
beforeAssertions: ((PBMVideoView) -> Void)? = nil,
delayBeforeAssertions: TimeInterval = 0,
file: StaticString = #file,
line: UInt = #line,
assertions: @escaping (PBMVideoView) -> Void) {
setupVideoCreative(isInterstitial: isInterstitial)
setupVideoCreative(
isInterstitial: isInterstitial,
isRewarded: isRewarded,
autoCloseOnCompletion: autoCloseOnCompletion,
hasCompanionAd: hasCompanionAd
)
self.videoCreative.creativeModel.displayDurationInSeconds = 6

self.expectationCreativeReady = self.expectation(description: "expectationCreativeReady")
Expand All @@ -396,8 +442,11 @@ class PBMVideoViewPlaybackStateTest: XCTestCase, CreativeResolutionDelegate {
let expectationAssertionsCompleted = expectation(description: "expectationAssertionsCompleted")

DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
assertions(videoView)
expectationAssertionsCompleted.fulfill()
beforeAssertions?(videoView)
DispatchQueue.main.asyncAfter(deadline: .now() + delayBeforeAssertions) {
assertions(videoView)
expectationAssertionsCompleted.fulfill()
}
})

waitForExpectations(timeout: 5, handler: nil)
Expand Down Expand Up @@ -443,7 +492,10 @@ class PBMVideoViewPlaybackStateTest: XCTestCase, CreativeResolutionDelegate {

private func setupVideoCreative(videoFileURL: String = "http://get_video/small.mp4",
localVideoFileName: String = "small.mp4",
isInterstitial: Bool = false) {
isInterstitial: Bool = false,
isRewarded: Bool = false,
autoCloseOnCompletion: Bool = true,
hasCompanionAd: Bool = true) {
let rule = MockServerRule(urlNeedle: videoFileURL,
mimeType: MockServerMimeType.MP4.rawValue,
connectionID: connection.internalID,
Expand All @@ -452,8 +504,11 @@ class PBMVideoViewPlaybackStateTest: XCTestCase, CreativeResolutionDelegate {

let adConfiguration = AdConfiguration()
adConfiguration.isInterstitialAd = isInterstitial
adConfiguration.isRewarded = isRewarded
adConfiguration.videoControlsConfig.isAutoCloseOnCompletionEnabled = autoCloseOnCompletion

let model = CreativeModel(adConfiguration: adConfiguration)
model.hasCompanionAd = hasCompanionAd
model.videoFileURL = videoFileURL

self.expectationDownloadCompleted = self.expectation(description: "expectationDownloadCompleted")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ import AVFoundation

@testable @_spi(PBMInternal) import PrebidMobile

private final class CompletionTrackingModalManager: MockModalManager {
private(set) var completedCreative: AbstractCreative?

override func creativeDisplayCompleted(_ creative: AbstractCreative) {
completedCreative = creative
}
}

class VideoCreativeDelegateTest: XCTestCase, CreativeResolutionDelegate, CreativeViewDelegate, PBMVideoViewDelegate {

var videoCreative:PBMVideoCreative!
Expand Down Expand Up @@ -223,6 +231,83 @@ class VideoCreativeDelegateTest: XCTestCase, CreativeResolutionDelegate, Creativ

waitForExpectations(timeout: 1, handler: nil)
}

func testInterstitialWithoutCompanionAutoClosesByDefault() {
let adConfiguration = AdConfiguration()
adConfiguration.isInterstitialAd = true
let model = CreativeModel(adConfiguration: adConfiguration)
model.hasCompanionAd = false

videoCreative = PBMVideoCreative(
creativeModel: model,
transaction: UtilitiesForTesting.createEmptyTransaction(),
videoData: Data()
)

let modalManager = MockModalManager()
videoCreative.modalManager = modalManager
videoCreative.showAsInterstitial(
fromRootViewController: UIViewController(),
displayProperties: InterstitialDisplayProperties()
)
XCTAssertEqual(modalManager.modalStateStack.count, 1)

videoCreative.videoViewCompletedDisplay()

XCTAssertTrue(modalManager.modalStateStack.isEmpty)
}

func testInterstitialWithoutCompanionCanDisableAutoClose() {
let adConfiguration = AdConfiguration()
adConfiguration.isInterstitialAd = true
adConfiguration.videoControlsConfig.isAutoCloseOnCompletionEnabled = false
let model = CreativeModel(adConfiguration: adConfiguration)
model.hasCompanionAd = false

videoCreative = PBMVideoCreative(
creativeModel: model,
transaction: UtilitiesForTesting.createEmptyTransaction(),
videoData: Data()
)

let modalManager = MockModalManager()
videoCreative.modalManager = modalManager
videoCreative.showAsInterstitial(
fromRootViewController: UIViewController(),
displayProperties: InterstitialDisplayProperties()
)
XCTAssertEqual(modalManager.modalStateStack.count, 1)

videoCreative.videoViewCompletedDisplay()

XCTAssertEqual(modalManager.modalStateStack.count, 1)
}

func testRewardedCompletionDefersDismissalToRewardedConfiguration() {
let adConfiguration = AdConfiguration()
adConfiguration.isInterstitialAd = true
adConfiguration.isRewarded = true
let model = CreativeModel(adConfiguration: adConfiguration)
model.hasCompanionAd = false

videoCreative = PBMVideoCreative(
creativeModel: model,
transaction: UtilitiesForTesting.createEmptyTransaction(),
videoData: Data()
)

let modalManager = CompletionTrackingModalManager()
videoCreative.modalManager = modalManager
videoCreative.showAsInterstitial(
fromRootViewController: UIViewController(),
displayProperties: InterstitialDisplayProperties()
)

videoCreative.videoViewCompletedDisplay()

XCTAssertTrue(modalManager.completedCreative === videoCreative)
XCTAssertEqual(modalManager.modalStateStack.count, 1)
}

func testSkipOffset() {
let expectation = self.expectation(description: "Should push Modal")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ class VideoControlsConfigTests: XCTestCase {
XCTAssertTrue(adConfiguration.isSoundButtonVisible == false)
}

func testAutoCloseOnCompletion() {
let adConfiguration = VideoControlsConfiguration()
XCTAssertTrue(adConfiguration.isAutoCloseOnCompletionEnabled)

adConfiguration.isAutoCloseOnCompletionEnabled = false
XCTAssertFalse(adConfiguration.isAutoCloseOnCompletionEnabled)
}

func testCloseButtonArea() {
let adConfiguration = VideoControlsConfiguration()
XCTAssertEqual(adConfiguration.closeButtonArea, PrebidConstants.BUTTON_AREA_DEFAULT.doubleValue)
Expand Down Expand Up @@ -62,6 +70,7 @@ class VideoControlsConfigTests: XCTestCase {
ortbAdConfig.skipButtonPosition = "topleft"
ortbAdConfig.closeButtonArea = 0.3
ortbAdConfig.closeButtonPosition = "topleft"
ortbAdConfig.isAutoCloseOnCompletionEnabled = false

adConfiguration.initialize(with: ortbAdConfig)

Expand All @@ -71,6 +80,7 @@ class VideoControlsConfigTests: XCTestCase {
XCTAssertEqual(adConfiguration.skipButtonPosition, .topLeft)
XCTAssertEqual(adConfiguration.closeButtonArea, 0.3)
XCTAssertEqual(adConfiguration.closeButtonPosition, .topLeft)
XCTAssertFalse(adConfiguration.isAutoCloseOnCompletionEnabled)

}
}
2 changes: 2 additions & 0 deletions PrebidMobileTests/ResponseParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class ResponseParsingTests: XCTestCase {
"skipbuttonarea" : 4,
"skipbuttonposition" : "_skipbuttonposition",
"skipdelay" : 5,
"isautocloseoncompletionenabled" : false,
]
}

Expand Down Expand Up @@ -249,6 +250,7 @@ class ResponseParsingTests: XCTestCase {
XCTAssertEqual(entity.skipButtonArea, 4)
XCTAssertEqual(entity.skipButtonPosition, "_skipbuttonposition")
XCTAssertEqual(entity.skipDelay, 5)
XCTAssertEqual(entity.isAutoCloseOnCompletionEnabled?.boolValue, false)

XCTAssertEqual(entity.jsonDictionary as NSDictionary, json as NSDictionary)
}
Expand Down
Loading