@npaw/forge-react-native 9.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +10 -0
  2. package/NPAWForgeReactNative.podspec +44 -0
  3. package/README.md +51 -0
  4. package/android/build.gradle +77 -0
  5. package/android/src/main/AndroidManifest.xml +14 -0
  6. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +295 -0
  7. package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +61 -0
  8. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +735 -0
  9. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgePackage.java +53 -0
  10. package/android/src/test/java/com/npaw/forge/reactnative/ForgeDtoMapperTest.java +133 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +82 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/ForgeKernelFFI.h +17 -0
  13. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +873 -0
  14. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/module.modulemap +7 -0
  15. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  16. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/ForgeKernelFFI.h +17 -0
  17. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +873 -0
  18. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
  19. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  20. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/ForgeKernelFFI.h +17 -0
  21. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +873 -0
  22. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/module.modulemap +7 -0
  23. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  24. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/ForgeKernelFFI.h +17 -0
  25. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +873 -0
  26. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/module.modulemap +7 -0
  27. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  28. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +3931 -0
  29. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +873 -0
  30. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.modulemap +7 -0
  31. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAccessLogFacts.swift +152 -0
  32. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +877 -0
  33. package/ios/ForgeApple/Sources/NPAWForgeCapture/AttributionCapture.swift +158 -0
  34. package/ios/ForgeApple/Sources/NPAWForgeCapture/CapturePlanGate.swift +189 -0
  35. package/ios/ForgeApple/Sources/NPAWForgeCapture/CrashCapture.swift +120 -0
  36. package/ios/ForgeApple/Sources/NPAWForgeCapture/ForgeAutomaticCapture.swift +639 -0
  37. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsCapture.swift +1176 -0
  38. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsEngine.swift +674 -0
  39. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsMarkers.swift +105 -0
  40. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsSwiftUI.swift +284 -0
  41. package/ios/ForgeApple/Sources/NPAWForgeCapture/SwiftUICapture.swift +44 -0
  42. package/ios/ForgeApple/Sources/NPAWForgeCore/AdAdapter.swift +199 -0
  43. package/ios/ForgeApple/Sources/NPAWForgeCore/AdSemantics.swift +136 -0
  44. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +596 -0
  45. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +297 -0
  46. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeClock.swift +29 -0
  47. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +109 -0
  48. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeOutcome.swift +89 -0
  49. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +291 -0
  50. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeStorage.swift +70 -0
  51. package/ios/ForgeApple/Sources/NPAWForgeCore/ManualAdReporter.swift +180 -0
  52. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +453 -0
  53. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +594 -0
  54. package/ios/ForgeApple/Sources/NPAWForgeCore/NetworkRequestRecord.swift +191 -0
  55. package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +191 -0
  56. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +299 -0
  57. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerSemantics.swift +266 -0
  58. package/ios/ForgeApple/Sources/NPAWForgeCore/PrivacySanitizer.swift +59 -0
  59. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +478 -0
  60. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoOptions.swift +431 -0
  61. package/ios/ForgeApple/Sources/NPAWForgeNetworkCapture/ForgeNetworkCapture.swift +116 -0
  62. package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/NPAWForgeNetworkSwizzler.m +293 -0
  63. package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/include/NPAWForgeNetworkSwizzler.h +33 -0
  64. package/ios/ForgeModuleImpl.swift +1131 -0
  65. package/ios/NPAWForgeTurboModule.mm +399 -0
  66. package/package.json +33 -0
  67. package/src/ForgeAdReporter.ts +101 -0
  68. package/src/ForgeVideo.ts +169 -0
  69. package/src/NPAWForge.ts +177 -0
  70. package/src/capture.ts +192 -0
  71. package/src/events.ts +40 -0
  72. package/src/index.ts +37 -0
  73. package/src/specs/NativeNPAWForge.ts +168 -0
  74. package/src/types.ts +114 -0
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Copyright (C) NPAW - All Rights Reserved
3
+ *
4
+ * This source code is protected under international copyright law. All rights
5
+ * reserved and protected by the copyright holders.
6
+ * This file is confidential and only available to authorized individuals with the
7
+ * permission of the copyright holders. If you encounter this file and do not have
8
+ * permission, please contact the copyright holders and delete this file.
9
+ *
10
+ *
11
+ * @author: Victor Soto <victor.soto@nicepeopleatwork.com>
12
+ * @description: Apple video option builder and ordered override transport JSON writer.
13
+ */
14
+
15
+ import Foundation
16
+
17
+ public struct VideoOptions: Equatable {
18
+ public enum JoinTimeCalculationMode: String {
19
+ case `default` = "DEFAULT"
20
+ case legacy = "LEGACY"
21
+ }
22
+
23
+ public var metadata: [String: String]
24
+ public var joinTimeCalculationMode: JoinTimeCalculationMode?
25
+ public var adJoinFromAdBreakStart: Bool?
26
+ public var adConnectedRequiresAdJoin: Bool?
27
+ public var contentIsLiveNoSeek: Bool?
28
+ public var ignorePauseSmallEvents: Bool?
29
+ public var forceInit: Bool?
30
+ public var waitForMetadata: Bool?
31
+ public var waitForMetadataTimeout: Int64?
32
+ public var pendingMetadata: [String]?
33
+ public var overridesJson: String
34
+
35
+ public var hasContentDurationOverride: Bool {
36
+ metadata["mediaDuration"] != nil
37
+ }
38
+
39
+ public init(
40
+ metadata: [String: String] = [:],
41
+ joinTimeCalculationMode: JoinTimeCalculationMode? = nil,
42
+ adJoinFromAdBreakStart: Bool? = nil,
43
+ adConnectedRequiresAdJoin: Bool? = nil,
44
+ contentIsLiveNoSeek: Bool? = nil,
45
+ ignorePauseSmallEvents: Bool? = nil,
46
+ forceInit: Bool? = nil,
47
+ waitForMetadata: Bool? = nil,
48
+ waitForMetadataTimeout: Int64? = nil,
49
+ pendingMetadata: [String]? = nil,
50
+ overridesJson: String? = nil
51
+ ) {
52
+ // Mirror the Android VideoOptions(metadata) constructor: local option keys
53
+ // are extracted into typed controls before normalization filters them out
54
+ // of the wire metadata.
55
+ self.metadata = Self.normalize(metadata)
56
+ self.joinTimeCalculationMode = joinTimeCalculationMode
57
+ ?? metadata["joinTimeCalculationMode"].flatMap { JoinTimeCalculationMode(rawValue: $0.uppercased()) }
58
+ self.adJoinFromAdBreakStart = adJoinFromAdBreakStart ?? Self.boolOption(metadata["adJoinFromAdBreakStart"])
59
+ self.adConnectedRequiresAdJoin = adConnectedRequiresAdJoin ?? Self.boolOption(metadata["adConnectedRequiresAdJoin"])
60
+ self.contentIsLiveNoSeek = contentIsLiveNoSeek ?? Self.boolOption(metadata["contentIsLiveNoSeek"])
61
+ self.ignorePauseSmallEvents = ignorePauseSmallEvents ?? Self.boolOption(metadata["ignorePauseSmallEvents"])
62
+ let resolvedForceInit = forceInit ?? Self.boolOption(metadata["forceInit"])
63
+ let resolvedWaitForMetadata = waitForMetadata ?? Self.boolOption(metadata["waitForMetadata"])
64
+ let resolvedWaitForMetadataTimeout = waitForMetadataTimeout ?? metadata["waitForMetadataTimeout"].flatMap(Int64.init)
65
+ let resolvedPendingMetadata = pendingMetadata ?? metadata["pendingMetadata"].map {
66
+ $0.split(separator: ",")
67
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
68
+ .filter { !$0.isEmpty }
69
+ }
70
+ self.forceInit = resolvedForceInit
71
+ self.waitForMetadata = resolvedWaitForMetadata
72
+ self.waitForMetadataTimeout = resolvedWaitForMetadataTimeout
73
+ self.pendingMetadata = resolvedPendingMetadata
74
+ self.overridesJson = overridesJson ?? OverrideBundleTransport(metadata: self.metadata)
75
+ .withControl("forceInit", resolvedForceInit)
76
+ .withControl("waitForMetadata", resolvedWaitForMetadata)
77
+ .withControl("waitForMetadataTimeoutMs", resolvedWaitForMetadataTimeout)
78
+ .withControl("pendingMetadata", resolvedPendingMetadata)
79
+ .toJson()
80
+ }
81
+
82
+ private static func boolOption(_ value: String?) -> Bool? {
83
+ switch value?.lowercased() {
84
+ case "true": return true
85
+ case "false": return false
86
+ default: return nil
87
+ }
88
+ }
89
+
90
+ /// Mirrors the Android `withAdapterWaitForMetadataDefault`: applies the
91
+ /// adapter-level `waitForMetadata` default without overriding an explicit
92
+ /// integrator choice. Returns `self` unchanged when `waitForMetadata` was
93
+ /// set explicitly (true or false) or when the transport JSON already
94
+ /// carries the control key.
95
+ func withAdapterWaitForMetadataDefault() -> VideoOptions {
96
+ if waitForMetadata != nil { return self }
97
+ guard let patchedJson = Self.patchControlWaitForMetadata(overridesJson) else { return self }
98
+ var patched = self
99
+ patched.waitForMetadata = true
100
+ patched.overridesJson = patchedJson
101
+ return patched
102
+ }
103
+
104
+ /// Adds `control.waitForMetadata = true` to an existing override transport
105
+ /// JSON document. Returns `nil` when the JSON already carries the control
106
+ /// key or cannot be parsed, so callers keep the original options.
107
+ private static func patchControlWaitForMetadata(_ json: String) -> String? {
108
+ guard let data = json.data(using: .utf8),
109
+ let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
110
+ return nil
111
+ }
112
+ let existingControl = root["control"] as? [String: Any]
113
+ if existingControl?["waitForMetadata"] != nil { return nil }
114
+ var control = existingControl ?? [:]
115
+ control["waitForMetadata"] = true
116
+ var patched = root
117
+ patched["control"] = control
118
+ guard JSONSerialization.isValidJSONObject(patched),
119
+ let patchedData = try? JSONSerialization.data(withJSONObject: patched, options: [.sortedKeys]),
120
+ let patchedJson = String(data: patchedData, encoding: .utf8),
121
+ !patchedJson.isEmpty else {
122
+ return nil
123
+ }
124
+ return patchedJson
125
+ }
126
+
127
+ public static func builder() -> Builder {
128
+ Builder()
129
+ }
130
+
131
+ public final class Builder {
132
+ private var metadata: [String: String] = [:]
133
+ private var controls: [String: Any] = [:]
134
+ private var joinTimeCalculationMode: JoinTimeCalculationMode?
135
+ private var adJoinFromAdBreakStart: Bool?
136
+ private var adConnectedRequiresAdJoin: Bool?
137
+ private var contentIsLiveNoSeek: Bool?
138
+ private var ignorePauseSmallEvents: Bool?
139
+ private var forceInit: Bool?
140
+ private var waitForMetadata: Bool?
141
+ private var waitForMetadataTimeout: Int64?
142
+ private var pendingMetadata: [String]?
143
+
144
+ public init() {}
145
+
146
+ @discardableResult public func metadata(_ value: [String: String]?) -> Builder {
147
+ value.map(Self.normalize)?.forEach { metadata[$0.key] = $0.value }
148
+ return self
149
+ }
150
+
151
+ @discardableResult public func contentTitle(_ value: String?) -> Builder { api("title", value) }
152
+ @discardableResult public func contentId(_ value: String?) -> Builder { api("contentId", value) }
153
+ @discardableResult public func contentResource(_ value: String?) -> Builder { api("resource", value) }
154
+ @discardableResult public func contentDuration(_ value: Double?) -> Builder { api("mediaDuration", value) }
155
+ @discardableResult public func contentType(_ value: String?) -> Builder { api("contentType", value) }
156
+ @discardableResult public func live(_ value: Bool?) -> Builder { api("live", value) }
157
+ @discardableResult public func contentBitrate(_ value: Int64?) -> Builder { api("bitrate", value) }
158
+ @discardableResult public func contentRendition(_ value: String?) -> Builder { api("rendition", value) }
159
+ @discardableResult public func contentCustomDimensions(_ value: [String: Any]?) -> Builder { api("dimensions", value) }
160
+ @discardableResult public func contentCustomDimension1(_ value: String?) -> Builder { api("param1", value) }
161
+ @discardableResult public func adTitle(_ value: String?) -> Builder { api("adTitle", value) }
162
+ @discardableResult public func adResource(_ value: String?) -> Builder { api("adResource", value) }
163
+ @discardableResult public func adCampaign(_ value: String?) -> Builder { api("adCampaign", value) }
164
+ @discardableResult public func adCreativeId(_ value: String?) -> Builder { api("adCreativeId", value) }
165
+ @discardableResult public func adProvider(_ value: String?) -> Builder { api("adProvider", value) }
166
+ @discardableResult public func transportFormat(_ value: String?) -> Builder { api("transportFormat", value) }
167
+ @discardableResult public func urlToParse(_ value: String?) -> Builder { api("parsedResource", value) }
168
+
169
+ @discardableResult public func joinTimeCalculationMode(_ mode: JoinTimeCalculationMode?) -> Builder {
170
+ joinTimeCalculationMode = mode
171
+ return self
172
+ }
173
+
174
+ @discardableResult public func adJoinFromAdBreakStart(_ enabled: Bool?) -> Builder {
175
+ adJoinFromAdBreakStart = enabled
176
+ return self
177
+ }
178
+
179
+ @discardableResult public func adConnectedRequiresAdJoin(_ enabled: Bool?) -> Builder {
180
+ adConnectedRequiresAdJoin = enabled
181
+ return self
182
+ }
183
+
184
+ @discardableResult public func contentIsLiveNoSeek(_ enabled: Bool?) -> Builder {
185
+ contentIsLiveNoSeek = enabled
186
+ return self
187
+ }
188
+
189
+ @discardableResult public func ignorePauseSmallEvents(_ enabled: Bool?) -> Builder {
190
+ ignorePauseSmallEvents = enabled
191
+ return self
192
+ }
193
+
194
+ @discardableResult public func isForceInit(_ enabled: Bool?) -> Builder {
195
+ forceInit = enabled
196
+ controls["forceInit"] = enabled
197
+ return self
198
+ }
199
+
200
+ @discardableResult public func waitForMetadata(_ enabled: Bool?) -> Builder {
201
+ waitForMetadata = enabled
202
+ controls["waitForMetadata"] = enabled
203
+ return self
204
+ }
205
+
206
+ @discardableResult public func waitForMetadataTimeout(_ timeoutMs: Int64?) -> Builder {
207
+ waitForMetadataTimeout = timeoutMs
208
+ controls["waitForMetadataTimeoutMs"] = timeoutMs
209
+ return self
210
+ }
211
+
212
+ @discardableResult public func pendingMetadata(_ keys: [String]?) -> Builder {
213
+ pendingMetadata = keys
214
+ controls["pendingMetadata"] = keys
215
+ return self
216
+ }
217
+
218
+ public func build() -> VideoOptions {
219
+ var transport = OverrideBundleTransport(metadata: metadata)
220
+ controls.forEach { transport = transport.withControl($0.key, $0.value) }
221
+ return VideoOptions(
222
+ metadata: metadata,
223
+ joinTimeCalculationMode: joinTimeCalculationMode,
224
+ adJoinFromAdBreakStart: adJoinFromAdBreakStart,
225
+ adConnectedRequiresAdJoin: adConnectedRequiresAdJoin,
226
+ contentIsLiveNoSeek: contentIsLiveNoSeek,
227
+ ignorePauseSmallEvents: ignorePauseSmallEvents,
228
+ forceInit: forceInit,
229
+ waitForMetadata: waitForMetadata,
230
+ waitForMetadataTimeout: waitForMetadataTimeout,
231
+ pendingMetadata: pendingMetadata,
232
+ overridesJson: transport.toJson()
233
+ )
234
+ }
235
+
236
+ @discardableResult private func api(_ key: String, _ value: Any?) -> Builder {
237
+ guard let value, !Self.nonGoKeys.contains(key) else { return self }
238
+ metadata[key] = Self.encode(value)
239
+ return self
240
+ }
241
+
242
+ private static func normalize(_ value: [String: String]) -> [String: String] {
243
+ VideoOptions.normalize(value)
244
+ }
245
+
246
+ private static func encode(_ value: Any) -> String {
247
+ if let value = value as? String { return value }
248
+ if let value = value as? Bool { return String(value) }
249
+ if let value = value as? NSNumber { return value.stringValue }
250
+ if JSONSerialization.isValidJSONObject(value),
251
+ let data = try? JSONSerialization.data(withJSONObject: value),
252
+ let json = String(data: data, encoding: .utf8) {
253
+ return json
254
+ }
255
+ return String(describing: value)
256
+ }
257
+
258
+ private static let nonGoKeys: Set<String> = [
259
+ "sendTotalBytes",
260
+ "pluginLogs",
261
+ "triggeredEvents",
262
+ "experiments",
263
+ "preloadDuration",
264
+ "profile",
265
+ "bucket",
266
+ ]
267
+ }
268
+
269
+ private static func normalize(_ value: [String: String]) -> [String: String] {
270
+ var output: [String: String] = [:]
271
+ for (key, value) in value {
272
+ guard let normalized = normalizedKey(key) else { continue }
273
+ output[normalized] = normalizedTimeValue(key: key, value: value)
274
+ }
275
+ return output
276
+ }
277
+
278
+ /// Mirrors the Android `normalizedApiKey` rules (VideoOptions.kt): local-only
279
+ /// and internal adapter keys are filtered, legacy snake_case and dotted aliases
280
+ /// map to camelCase wire keys, custom-dimension prefixes map to param/extraparam
281
+ /// keys, and unknown keys containing '_' or '.' never leak onto the wire.
282
+ private static func normalizedKey(_ rawKey: String) -> String? {
283
+ let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
284
+ if localOptionKeys.contains(key) || internalOptionKeys.contains(key) || nonGoApiKeys.contains(key) {
285
+ return nil
286
+ }
287
+ switch key {
288
+ case "player_name": return "player"
289
+ case "player_version": return "playerVersion"
290
+ case "resource_url", "content.resource", "contentResource": return "resource"
291
+ case "content_type", "content.type", "contentType": return "contentType"
292
+ case "playhead_ms": return "playhead"
293
+ case "duration_ms": return "mediaDuration"
294
+ case "bitrate_kbps": return "bitrate"
295
+ case "dropped_frames": return "droppedFrames"
296
+ case "error_code": return "errorCode"
297
+ case "error_message": return "errorMsg"
298
+ case "creative_id": return "adCreativeId"
299
+ case "ad_duration_ms": return "adDuration"
300
+ case "ad_playhead_ms": return "adPlayhead"
301
+ case "insertion_type": return "adInsertionType"
302
+ case "content.id", "content_id": return "contentId"
303
+ case "content.title", "contentTitle": return "title"
304
+ case "content.isLive", "contentIsLive": return "live"
305
+ case "content.duration", "contentDuration": return "mediaDuration"
306
+ case "content.bitrate", "contentBitrate": return "bitrate"
307
+ case "content.rendition", "contentRendition": return "rendition"
308
+ case "content.language", "contentLanguage": return "contentLanguage"
309
+ case "content.metadata", "contentMetadata": return "properties"
310
+ case "content.metrics", "contentMetrics": return "metrics"
311
+ case "ad.resource", "adResource": return "adResource"
312
+ case "ad.title", "adTitle": return "adTitle"
313
+ case "ad.creativeId", "adCreativeId": return "adCreativeId"
314
+ case "ad.adapterVersion", "adAdapterVersion": return "adAdapterVersion"
315
+ case "ad.provider", "adProvider": return "adProvider"
316
+ case "ad.metadata", "adMetadata": return "adProperties"
317
+ default:
318
+ if key.hasPrefix("content.customDimension.") {
319
+ return Int(key.dropFirst("content.customDimension.".count)).map { "param\($0)" }
320
+ }
321
+ if key.hasPrefix("ad.customDimension.") {
322
+ return Int(key.dropFirst("ad.customDimension.".count)).map { "extraparam\($0)" }
323
+ }
324
+ return key.contains("_") || key.contains(".") ? nil : key
325
+ }
326
+ }
327
+
328
+ /// Option keys consumed by the binding itself (matching Android's
329
+ /// `localOptionKeys`); they configure behavior and never reach the wire.
330
+ private static let localOptionKeys: Set<String> = [
331
+ "joinTimeCalculationMode",
332
+ "adJoinFromAdBreakStart",
333
+ "adConnectedRequiresAdJoin",
334
+ "contentIsLiveNoSeek",
335
+ "ignorePauseSmallEvents",
336
+ "waitForMetadata",
337
+ "waitForMetadataTimeout",
338
+ "adPostRollTimeout",
339
+ "delegateToAdAdapter",
340
+ "autoRestartOnMaxTimeExpiration",
341
+ "parseManifest",
342
+ "parseCdnNode",
343
+ "parseCdnNodeDisableFallback",
344
+ "parseCdnNodeAllowedGetMethodOnRetry",
345
+ "parseManifestAuth",
346
+ "parseCdnNodeList",
347
+ "parseCdnNameHeaderList",
348
+ "parseCdnNodeHeaderList",
349
+ "parseCdnNodeRequestDelayed",
350
+ "errorsToIgnore",
351
+ "legacyErrorNames",
352
+ "fatalErrors",
353
+ "nonFatalErrors",
354
+ "forceInit",
355
+ "pendingMetadata",
356
+ ]
357
+
358
+ /// Adapter-internal descriptor keys (matching Android's `internalOptionKeys`);
359
+ /// they describe the integration, not the content, and never reach the wire.
360
+ private static let internalOptionKeys: Set<String> = [
361
+ "adapter_id",
362
+ "adapterId",
363
+ "video_adapter_id",
364
+ "videoAdapterId",
365
+ "ad_adapter_id",
366
+ "adAdapterId",
367
+ "player_family",
368
+ "playerFamily",
369
+ "category",
370
+ "version",
371
+ "adapterVersion",
372
+ ]
373
+
374
+ /// Non-Go API keys dropped on every platform (matching Android's `nonGoApiKeys`).
375
+ private static let nonGoApiKeys: Set<String> = [
376
+ "profile",
377
+ "bucket",
378
+ "experiments",
379
+ "preloadDuration",
380
+ "triggeredEvents",
381
+ "sendTotalBytes",
382
+ "pluginLogs",
383
+ ]
384
+
385
+ private static func normalizedTimeValue(key: String, value: String) -> String {
386
+ switch key {
387
+ case "playhead_ms", "duration_ms", "ad_duration_ms", "ad_playhead_ms":
388
+ guard let millis = Double(value) else { return value }
389
+ return String(millis / 1000.0)
390
+ default:
391
+ return value
392
+ }
393
+ }
394
+ }
395
+
396
+ struct OverrideBundleTransport: Equatable {
397
+ private var scalars: [String: String]
398
+ private var controls: [String: Any]
399
+
400
+ init(metadata: [String: String]) {
401
+ self.scalars = metadata
402
+ self.controls = [:]
403
+ }
404
+
405
+ func withControl(_ key: String, _ value: Any?) -> OverrideBundleTransport {
406
+ guard let value else { return self }
407
+ var copy = self
408
+ copy.controls[key] = value
409
+ return copy
410
+ }
411
+
412
+ func toJson() -> String {
413
+ var object: [String: Any] = [:]
414
+ if !scalars.isEmpty {
415
+ object["scalars"] = scalars
416
+ }
417
+ if !controls.isEmpty {
418
+ object["control"] = controls
419
+ }
420
+ guard JSONSerialization.isValidJSONObject(object),
421
+ let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]),
422
+ let json = String(data: data, encoding: .utf8) else {
423
+ return "{}"
424
+ }
425
+ return json
426
+ }
427
+
428
+ static func == (lhs: OverrideBundleTransport, rhs: OverrideBundleTransport) -> Bool {
429
+ lhs.toJson() == rhs.toJson()
430
+ }
431
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Copyright (C) NPAW - All Rights Reserved
3
+ *
4
+ * This source code is protected under international copyright law. All rights
5
+ * reserved and protected by the copyright holders.
6
+ * This file is confidential and only available to authorized individuals with the
7
+ * permission of the copyright holders. If you encounter this file and do not have
8
+ * permission, please contact the copyright holders and delete this file.
9
+ *
10
+ *
11
+ * @author: Victor Soto <victor.soto@nicepeopleatwork.com>
12
+ * @description: Swift network capture installer and payload adapter for Apple URLSession swizzling.
13
+ */
14
+
15
+ import Foundation
16
+ #if canImport(NPAWForgeCore)
17
+ import NPAWForgeCore
18
+ #endif
19
+ #if canImport(NPAWForgeNetworkCaptureObjC)
20
+ import NPAWForgeNetworkCaptureObjC
21
+ #endif
22
+
23
+ public struct NetworkCaptureConfiguration: Equatable {
24
+ public var enabled: Bool
25
+ public var privacy: NetworkPrivacyConfiguration
26
+ public var blockedHosts: Set<String>
27
+
28
+ public init(
29
+ enabled: Bool = false,
30
+ privacy: NetworkPrivacyConfiguration = NetworkPrivacyConfiguration(),
31
+ blockedHosts: Set<String> = ["lma.npaw.com"]
32
+ ) {
33
+ self.enabled = enabled
34
+ self.privacy = privacy
35
+ self.blockedHosts = blockedHosts.map { $0.lowercased() }.asSet
36
+ }
37
+ }
38
+
39
+ public enum ForgeNetworkCapture {
40
+ private static let lock = NSLock()
41
+ private static var activeForge: NPAWForge?
42
+ private static var activeConfiguration = NetworkCaptureConfiguration()
43
+
44
+ @discardableResult
45
+ public static func install(
46
+ forge: NPAWForge,
47
+ configuration: NetworkCaptureConfiguration = NetworkCaptureConfiguration(enabled: true)
48
+ ) -> ForgeOutcome {
49
+ lock.lock()
50
+ activeForge = forge
51
+ activeConfiguration = configuration
52
+ lock.unlock()
53
+ guard configuration.enabled else { return .ignoredInvalidState }
54
+ let installed = NPAWForgeNetworkSwizzler.install(
55
+ callback: { payload in
56
+ handle(payload: payload)
57
+ },
58
+ blockedHosts: configuration.blockedHosts
59
+ )
60
+ return installed ? .accepted : .failedWithDiagnostic("network_swizzler_install_failed")
61
+ }
62
+
63
+ /// Stops automatic capture and clears the active forge/configuration so
64
+ /// swizzled traffic and manual tracking no-op until the next install. The
65
+ /// underlying method swizzles stay installed for the process lifetime;
66
+ /// `install(forge:configuration:)` resumes capture by re-arming the callback.
67
+ @discardableResult
68
+ public static func uninstall() -> ForgeOutcome {
69
+ lock.lock()
70
+ activeForge = nil
71
+ activeConfiguration = NetworkCaptureConfiguration()
72
+ lock.unlock()
73
+ NPAWForgeNetworkSwizzler.uninstall()
74
+ return .accepted
75
+ }
76
+
77
+ public static func track(_ record: NetworkRequestRecord) -> ForgeOutcome {
78
+ lock.lock()
79
+ let forge = activeForge
80
+ let configuration = activeConfiguration
81
+ lock.unlock()
82
+ guard configuration.enabled else { return .ignoredInvalidState }
83
+ return forge?.trackNetworkRequest(record.sanitized(configuration: configuration.privacy)) ?? .ignoredInvalidState
84
+ }
85
+
86
+ private static func handle(payload: [String: String]) {
87
+ lock.lock()
88
+ let forge = activeForge
89
+ let configuration = activeConfiguration
90
+ lock.unlock()
91
+ guard configuration.enabled, let forge else { return }
92
+ var record = NetworkRequestRecord(
93
+ targetUrl: payload["targetUrl"] ?? "unknown",
94
+ method: payload["method"] ?? "GET",
95
+ responseStatusCode: Int(payload["responseStatusCode"] ?? "0") ?? 0,
96
+ durationMs: Int64(payload["durationMs"] ?? "0") ?? 0,
97
+ outcome: NetworkRequestOutcome(rawValue: payload["outcome"] ?? "unknown") ?? .unknown,
98
+ eventId: payload["eventId"] ?? UUID().uuidString,
99
+ source: NetworkRequestSource(rawValue: payload["source"] ?? "automatic_urlsession") ?? .automaticURLSession,
100
+ timestampMs: Int64(payload["timestampMs"] ?? "0") ?? 0
101
+ )
102
+ record.errorMessage = payload["errorMessage"]
103
+ record.requestBytes = payload["requestBytes"].flatMap(Int64.init)
104
+ record.responseBytes = payload["responseBytes"].flatMap(Int64.init)
105
+ record.responseHeaders = payload
106
+ .filter { $0.key.hasPrefix("responseHeader.") }
107
+ .reduce(into: [String: String]()) { output, element in
108
+ output[String(element.key.dropFirst("responseHeader.".count))] = element.value
109
+ }
110
+ _ = forge.trackNetworkRequest(record.sanitized(configuration: configuration.privacy))
111
+ }
112
+ }
113
+
114
+ private extension Array where Element == String {
115
+ var asSet: Set<String> { Set(self) }
116
+ }