Skip to content
Closed
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
3 changes: 3 additions & 0 deletions example/ios/Runner.xcworkspace/contents.xcworkspacedata

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

154 changes: 154 additions & 0 deletions ios/Classes/KontextInAppWebViewPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,18 @@ final class KontextInAppWebViewPlatformView: NSObject, FlutterPlatformView, WKNa
private let channel: FlutterMethodChannel
private let webView: WKWebView
private let settings: IOSInAppWebViewSettings
private let omService: OMManaging
private let omAudioSessionHelper: OMAudioSessionHelper
private var hasLoadedInitialUrl = false
private var isInitialCookieSeedingComplete: Bool
private var hasPendingInitialLoad = false
private let initialUrl: URL?
private var omCreativeType: OMCreativeType?
private var hasLoadedPage = false
private var pendingOpenMeasurementStart = false
private var activeOMSession: OMSession?
private var activeOMSessionUsesVideoAudioSession = false
private var lastContentURL: URL?

init(
frame: CGRect,
Expand All @@ -67,11 +75,18 @@ final class KontextInAppWebViewPlatformView: NSObject, FlutterPlatformView, WKNa
creationParams: [String: Any]?
) {
self.settings = IOSInAppWebViewSettings(creationParams: creationParams)
self.omService = OMManager.shared
self.omAudioSessionHelper = OMAudioSessionHelper.shared
if let urlString = ((creationParams?["initialUrlRequest"] as? [String: Any])?["url"] as? String) {
self.initialUrl = URL(string: urlString)
} else {
self.initialUrl = nil
}
if let creativeType = creationParams?["initialOmCreativeType"] as? String {
self.omCreativeType = OMCreativeType(rawValue: creativeType)
} else {
self.omCreativeType = nil
}
let initialCookiesToSeed: [HTTPCookie]
if #available(iOS 11.0, *), self.settings.sharedCookiesEnabled {
initialCookiesToSeed = HTTPCookieStorage.shared.cookies ?? []
Expand All @@ -89,6 +104,15 @@ final class KontextInAppWebViewPlatformView: NSObject, FlutterPlatformView, WKNa
forMainFrameOnly: false
)
)
if let omsdkJS = KontextInAppWebViewPlatformView.loadOpenMeasurementJavaScript() {
userContentController.addUserScript(
WKUserScript(
source: omsdkJS,
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
)
}
userContentController.addUserScript(
WKUserScript(
source: KontextInAppWebViewPlatformView.makeConsoleShimScript(),
Expand Down Expand Up @@ -155,6 +179,7 @@ final class KontextInAppWebViewPlatformView: NSObject, FlutterPlatformView, WKNa
}

deinit {
finishOpenMeasurementSession()
webView.configuration.userContentController.removeScriptMessageHandler(forName: kontextNativeBridgeName)
webView.configuration.userContentController.removeScriptMessageHandler(forName: kontextConsoleBridgeName)
channel.setMethodCallHandler(nil)
Expand All @@ -179,6 +204,27 @@ final class KontextInAppWebViewPlatformView: NSObject, FlutterPlatformView, WKNa
case "loadInitialUrl":
loadInitialUrl()
result(nil)
case "configureOpenMeasurement":
let args = call.arguments as? [String: Any]
if let creativeType = args?["creativeType"] as? String {
configureOpenMeasurement(creativeType: creativeType)
} else {
omCreativeType = nil
}
result(nil)
case "startOpenMeasurementSession":
startOpenMeasurementSession()
result(nil)
case "logOpenMeasurementError":
let args = call.arguments as? [String: Any]
logOpenMeasurementError(
errorType: args?["errorType"] as? String,
message: args?["message"] as? String
)
result(nil)
case "finishOpenMeasurementSession":
finishOpenMeasurementSession()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
Expand Down Expand Up @@ -278,7 +324,10 @@ final class KontextInAppWebViewPlatformView: NSObject, FlutterPlatformView, WKNa
}

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
hasLoadedPage = true
lastContentURL = webView.url ?? initialUrl
webView.evaluateJavaScript(kontextPlatformReadyScript, completionHandler: nil)
startOpenMeasurementSessionIfReady()
}

private func sendLoadError(_ error: Error, failingURL: URL?) {
Expand Down Expand Up @@ -336,6 +385,8 @@ final class KontextInAppWebViewPlatformView: NSObject, FlutterPlatformView, WKNa
}
guard !hasLoadedInitialUrl else { return }
hasLoadedInitialUrl = true
hasLoadedPage = false
lastContentURL = initialUrl

guard let initialUrl else { return }
webView.load(URLRequest(url: initialUrl))
Expand Down Expand Up @@ -428,6 +479,109 @@ final class KontextInAppWebViewPlatformView: NSObject, FlutterPlatformView, WKNa
"""
}

private func configureOpenMeasurement(creativeType: String) {
omCreativeType = OMCreativeType(rawValue: creativeType)
startOpenMeasurementSessionIfReady()
}

private func startOpenMeasurementSession() {
pendingOpenMeasurementStart = true
startOpenMeasurementSessionIfReady()
}

private func startOpenMeasurementSessionIfReady() {
guard activeOMSession == nil else {
return
}

guard pendingOpenMeasurementStart else {
return
}

guard let omCreativeType else {
return
}

guard hasLoadedPage else {
return
}

guard omService.activate() else {
return
}

let usesVideoAudioSession = omCreativeType == .video
if usesVideoAudioSession {
omAudioSessionHelper.acquireVideoSession()
}

do {
let session = try omService.createSession(
webView,
url: lastContentURL ?? webView.url ?? initialUrl,
creativeType: omCreativeType
)
session.start()
activeOMSession = session
activeOMSessionUsesVideoAudioSession = usesVideoAudioSession
pendingOpenMeasurementStart = false
} catch OMManager.OMError.sdkIsNotActive {
if usesVideoAudioSession {
omAudioSessionHelper.releaseVideoSession()
}
return
} catch {
pendingOpenMeasurementStart = false
if usesVideoAudioSession {
omAudioSessionHelper.releaseVideoSession()
}
}
}

private func logOpenMeasurementError(errorType: String?, message: String?) {
activeOMSession?.logError(errorType: errorType, message: message)
}

private func finishOpenMeasurementSession() {
pendingOpenMeasurementStart = false

guard let activeOMSession else {
return
}

let usesVideoAudioSession = activeOMSessionUsesVideoAudioSession
self.activeOMSession = nil
activeOMSessionUsesVideoAudioSession = false
activeOMSession.retire()
activeOMSession.finish()
OMRetentionPool.shared.retain(activeOMSession)
if usesVideoAudioSession {
omAudioSessionHelper.releaseVideoSession()
}
}

private static func loadOpenMeasurementJavaScript() -> String? {
let classBundle = Bundle(for: KontextInAppWebViewPlatformView.self)
let bundleCandidates: [Bundle] = [
Bundle.main,
classBundle,
classBundle.url(forResource: "kontext_flutter_sdk", withExtension: "bundle").flatMap(Bundle.init(url:)),
Bundle.main.url(forResource: "kontext_flutter_sdk", withExtension: "bundle").flatMap(Bundle.init(url:))
].compactMap { $0 }

for bundle in bundleCandidates {
guard let url = bundle.url(forResource: "omsdk-v1", withExtension: "js") else {
continue
}

if let source = try? String(contentsOf: url, encoding: .utf8) {
return source
}
}

return nil
}

private func urlArgument(_ url: URL?) -> Any {
url?.absoluteString ?? NSNull()
}
Expand Down
1 change: 1 addition & 0 deletions ios/Classes/KontextSdkPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class KontextSdkPlugin: NSObject, FlutterPlugin {
SKAdNetworkPlugin.register(with: registrar)
SKOverlayPlugin.register(with: registrar)
SKStoreProductPlugin.register(with: registrar)
OMSDKPlugin.register(with: registrar)
KontextInAppWebViewPlugin.register(with: registrar)
}
}
79 changes: 79 additions & 0 deletions ios/Classes/OMSDK/OMAudioSessionHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import AVFoundation

/// Manages AVAudioSession for OMID device volume change tracking in HTML video ads.
///
/// The OMID native SDK automatically detects device volume changes, but requires
/// an active audio session with `.mixWithOthers` to observe `outputVolume` via KVO.
/// Without this, device volume change events are not delivered to verification scripts.
///
/// Reference: IAB OMSDK demo WebViewVideoController.swift
final class OMAudioSessionHelper {
/// Shared helper used by all web views in the process.
static let shared = OMAudioSessionHelper()

private init() {}

/// Number of active OM video sessions currently holding the shared audio session.
///
/// Multiple web views can host HTML video ads at the same time. The counter keeps
/// the shared AVAudioSession active until the last tracked video session is released.
private var activeVideoSessionCount = 0
private var isAudioSessionActive = false

/// Records a video session that needs OMID device volume change tracking.
///
/// Activates the shared audio session on the first acquisition.
func acquireVideoSession() {
activeVideoSessionCount += 1
activateAudioSessionIfNeeded()
}

/// Releases a previously tracked video session.
///
/// Deactivates the shared audio session only after the last tracked session ends.
func releaseVideoSession() {
guard activeVideoSessionCount > 0 else {
return
}

activeVideoSessionCount -= 1

guard activeVideoSessionCount == 0 else {
return
}

deactivateAudioSessionIfNeeded()
}

private func activateAudioSessionIfNeeded() {
guard !isAudioSessionActive else {
return
}

let session = AVAudioSession.sharedInstance()

do {
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try session.setActive(true)
isAudioSessionActive = true
} catch {
NSLog("[OM] Failed to activate audio session: \(error)")
}
}

private func deactivateAudioSessionIfNeeded() {
guard isAudioSessionActive else {
return
}

let session = AVAudioSession.sharedInstance()

do {
try session.setActive(false, options: [.notifyOthersOnDeactivation])
} catch {
NSLog("[OM] Failed to deactivate audio session: \(error)")
}

isAudioSessionActive = false
}
}
8 changes: 8 additions & 0 deletions ios/Classes/OMSDK/OMConstants.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

enum OMConstants {
static let partnerName = "Kontextso"
static let integrationVersion = "1.0.0"
static let retentionInterval: TimeInterval = 1.0
static let channelName = "kontext_flutter_sdk/omsdk"
}
Loading