@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,674 @@
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: Platform-free F3 sections auto-capture state machine. Consumes
13
+ * section/item visibility snapshots from UIKit/SwiftUI samplers and
14
+ * emits raw_section_visible/raw_section_hidden pairs, sustained
15
+ * raw_content_impression confirmations, and focus-driven
16
+ * raw_content_dwell events through an injected emission closure.
17
+ * Time and timers flow through an injectable scheduler seam so
18
+ * tests control the clock; dedupe, caps, and ordering follow the
19
+ * cross-platform F3 parity rules (hidden-flush before dedupe reset
20
+ * on screen change, explicit order over first-seen order, and
21
+ * visibility percentages sampled at confirmation time).
22
+ */
23
+
24
+ import Foundation
25
+ #if canImport(NPAWForgeCore)
26
+ import NPAWForgeCore
27
+ #endif
28
+
29
+ /// Injectable clock + one-shot timer seam for the sections engine. Production
30
+ /// uses ``ForgeMainQueueSectionsScheduler``; tests drive a manual clock.
31
+ public protocol SectionsScheduling: AnyObject {
32
+ func nowMs() -> Int64
33
+ @discardableResult
34
+ func schedule(afterMs: Int64, _ block: @escaping () -> Void) -> SectionsScheduledTask
35
+ }
36
+
37
+ /// Cancellable handle for a scheduled sections callback.
38
+ public protocol SectionsScheduledTask: AnyObject {
39
+ func cancel()
40
+ }
41
+
42
+ /// Default production scheduler: wall clock plus main-queue one-shot timers.
43
+ public final class ForgeMainQueueSectionsScheduler: SectionsScheduling {
44
+ public init() {}
45
+
46
+ public func nowMs() -> Int64 {
47
+ ForgeClock.nowMs()
48
+ }
49
+
50
+ @discardableResult
51
+ public func schedule(afterMs: Int64, _ block: @escaping () -> Void) -> SectionsScheduledTask {
52
+ let work = DispatchWorkItem(block: block)
53
+ let delayMs = Int(clamping: max(0, afterMs))
54
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: work)
55
+ return WorkItemTask(work: work)
56
+ }
57
+
58
+ private final class WorkItemTask: SectionsScheduledTask {
59
+ private let work: DispatchWorkItem
60
+
61
+ init(work: DispatchWorkItem) {
62
+ self.work = work
63
+ }
64
+
65
+ func cancel() {
66
+ work.cancel()
67
+ }
68
+ }
69
+ }
70
+
71
+ /// Engine knobs mapped from the kernel capture plan sections block. Values are
72
+ /// clamped into safe ranges so a hostile plan cannot produce negative timers.
73
+ public struct SectionsKnobs: Equatable {
74
+ public var visibilityThresholdPct: Int
75
+ public var impressionMinVisibleMs: Int64
76
+ public var dwellMs: Int64
77
+ public var maxTrackedItems: Int
78
+ public var maxSections: Int
79
+
80
+ public init(
81
+ visibilityThresholdPct: Int = 50,
82
+ impressionMinVisibleMs: Int64 = 1000,
83
+ dwellMs: Int64 = 1000,
84
+ maxTrackedItems: Int = 200,
85
+ maxSections: Int = 20
86
+ ) {
87
+ self.visibilityThresholdPct = min(100, max(0, visibilityThresholdPct))
88
+ self.impressionMinVisibleMs = max(0, impressionMinVisibleMs)
89
+ self.dwellMs = max(0, dwellMs)
90
+ self.maxTrackedItems = max(0, maxTrackedItems)
91
+ self.maxSections = max(0, maxSections)
92
+ }
93
+
94
+ public init(plan: ForgeCapturePlan.Sections) {
95
+ self.init(
96
+ visibilityThresholdPct: plan.visibilityThresholdPct,
97
+ impressionMinVisibleMs: plan.impressionMinVisibleMs,
98
+ dwellMs: plan.dwellMs,
99
+ maxTrackedItems: plan.maxTrackedItems,
100
+ maxSections: plan.maxSections
101
+ )
102
+ }
103
+ }
104
+
105
+ /// Origin of a snapshot feed. Each source owns its reported set: a section or
106
+ /// item disappears when its source stops reporting it (full-set `apply`) or
107
+ /// removes it explicitly (SwiftUI `onDisappear`).
108
+ public enum SectionsFeedSource: String, CaseIterable {
109
+ case uikit
110
+ case swiftui
111
+ }
112
+
113
+ /// Latest observed state of one section container.
114
+ public struct SectionSnapshot: Equatable {
115
+ public var key: String
116
+ public var name: String
117
+ /// Explicit order from registration/marker; `nil` uses first-seen order.
118
+ public var order: Int?
119
+ /// Visible-area ratio 0-100.
120
+ public var visibilityPct: Int
121
+ /// Sampler-known visible item count; the engine emits the maximum of this
122
+ /// value and the item count it derives from its own item store.
123
+ public var itemCount: Int
124
+
125
+ public init(key: String, name: String, order: Int?, visibilityPct: Int, itemCount: Int) {
126
+ self.key = key
127
+ self.name = name
128
+ self.order = order
129
+ self.visibilityPct = visibilityPct
130
+ self.itemCount = itemCount
131
+ }
132
+ }
133
+
134
+ /// Latest observed state of one content item.
135
+ public struct ItemSnapshot: Equatable {
136
+ public var key: String
137
+ public var contentId: String?
138
+ public var position: Int
139
+ public var sectionKey: String?
140
+ /// Visible-area ratio 0-100.
141
+ public var visibilityPct: Int
142
+ public var title: String?
143
+
144
+ public init(
145
+ key: String,
146
+ contentId: String?,
147
+ position: Int,
148
+ sectionKey: String?,
149
+ visibilityPct: Int,
150
+ title: String? = nil
151
+ ) {
152
+ self.key = key
153
+ self.contentId = contentId
154
+ self.position = position
155
+ self.sectionKey = sectionKey
156
+ self.visibilityPct = visibilityPct
157
+ self.title = title
158
+ }
159
+ }
160
+
161
+ /// Pure sections state machine (no UIKit): visible/hidden pairing, sustained
162
+ /// impressions, single-slot dwell, per-screen dedupe, and capped tracking.
163
+ /// All emissions leave through the injected closure outside the internal lock.
164
+ public final class SectionsEngine {
165
+ public typealias Emission = (_ kind: String, _ params: [String: String]) -> Void
166
+
167
+ private struct SectionRuntime {
168
+ var name: String
169
+ var order: Int
170
+ var visibleEmitted: Bool
171
+ }
172
+
173
+ private struct ImpressionCandidate {
174
+ let armedAtMs: Int64
175
+ /// Dedupe key captured at arm time so section-state changes between
176
+ /// arm and fire cannot drift the key and double-fire.
177
+ let dedupeKey: String
178
+ let task: SectionsScheduledTask
179
+ }
180
+
181
+ private struct DwellSlot {
182
+ let itemKey: String
183
+ let armedAtMs: Int64
184
+ /// Dedupe key captured at arm time (same drift protection as
185
+ /// impressions).
186
+ let dedupeKey: String
187
+ let task: SectionsScheduledTask
188
+ }
189
+
190
+ private let lock = NSRecursiveLock()
191
+ private let scheduler: SectionsScheduling
192
+ private let emitClosure: Emission
193
+ private let logClosure: (String) -> Void
194
+ private var knobs: SectionsKnobs
195
+
196
+ private var sectionFeeds: [SectionsFeedSource: [SectionSnapshot]] = [:]
197
+ private var itemFeeds: [SectionsFeedSource: [ItemSnapshot]] = [:]
198
+ private var sectionStates: [String: SectionRuntime] = [:]
199
+ private var nextFirstSeenOrder = 0
200
+ private var impressionCandidates: [String: ImpressionCandidate] = [:]
201
+ private var firedImpressionKeys: Set<String> = []
202
+ /// Per-screen dedupe keys frozen at first dedupe-relevant use per item, so
203
+ /// a late-registering section cannot drift `"|id"` into `"Rail|id"` and
204
+ /// re-fire an already-counted impression/dwell for the same item.
205
+ private var capturedDedupeKeys: [String: String] = [:]
206
+ private var dwellSlot: DwellSlot?
207
+ private var firedDwellKeys: Set<String> = []
208
+ private var trackedSectionKeys: Set<String> = []
209
+ private var trackedItemKeys: Set<String> = []
210
+ private var capLogEmittedThisScreen = false
211
+
212
+ public init(
213
+ knobs: SectionsKnobs,
214
+ scheduler: SectionsScheduling = ForgeMainQueueSectionsScheduler(),
215
+ emit: @escaping Emission,
216
+ log: @escaping (String) -> Void = { _ in }
217
+ ) {
218
+ self.knobs = knobs
219
+ self.scheduler = scheduler
220
+ self.emitClosure = emit
221
+ self.logClosure = log
222
+ }
223
+
224
+ // MARK: - Feeds
225
+
226
+ /// Replaces the full reported set for one source (UIKit sampler path).
227
+ /// Sections/items previously reported by the source and absent from this
228
+ /// call count as gone.
229
+ public func apply(source: SectionsFeedSource, sections: [SectionSnapshot], items: [ItemSnapshot]) {
230
+ mutateAndEmit {
231
+ self.sectionFeeds[source] = sections
232
+ self.itemFeeds[source] = items
233
+ return self.recomputeLocked()
234
+ }
235
+ }
236
+
237
+ /// Upserts one section for a source (SwiftUI appear/update path).
238
+ public func update(source: SectionsFeedSource, section: SectionSnapshot) {
239
+ mutateAndEmit {
240
+ var feed = self.sectionFeeds[source] ?? []
241
+ if let index = feed.firstIndex(where: { $0.key == section.key }) {
242
+ feed[index] = section
243
+ } else {
244
+ feed.append(section)
245
+ }
246
+ self.sectionFeeds[source] = feed
247
+ return self.recomputeLocked()
248
+ }
249
+ }
250
+
251
+ /// Upserts one item for a source (SwiftUI appear/update path).
252
+ public func update(source: SectionsFeedSource, item: ItemSnapshot) {
253
+ mutateAndEmit {
254
+ var feed = self.itemFeeds[source] ?? []
255
+ if let index = feed.firstIndex(where: { $0.key == item.key }) {
256
+ feed[index] = item
257
+ } else {
258
+ feed.append(item)
259
+ }
260
+ self.itemFeeds[source] = feed
261
+ return self.recomputeLocked()
262
+ }
263
+ }
264
+
265
+ /// Drops one section reported by a source (SwiftUI disappear path).
266
+ public func remove(source: SectionsFeedSource, sectionKey: String) {
267
+ mutateAndEmit {
268
+ self.sectionFeeds[source] = (self.sectionFeeds[source] ?? []).filter { $0.key != sectionKey }
269
+ return self.recomputeLocked()
270
+ }
271
+ }
272
+
273
+ /// Drops one item reported by a source (SwiftUI disappear path).
274
+ public func remove(source: SectionsFeedSource, itemKey: String) {
275
+ mutateAndEmit {
276
+ self.itemFeeds[source] = (self.itemFeeds[source] ?? []).filter { $0.key != itemKey }
277
+ return self.recomputeLocked()
278
+ }
279
+ }
280
+
281
+ /// Recomputes transitions from the current stores (used after screen
282
+ /// context settles and after knob hot-apply).
283
+ public func revalidate() {
284
+ mutateAndEmit { self.recomputeLocked() }
285
+ }
286
+
287
+ /// Hot-applies new knobs; threshold changes re-evaluate visible/hidden and
288
+ /// candidate arming immediately. Already-armed timers keep their original
289
+ /// deadline and re-verify against the new threshold at fire.
290
+ public func updateKnobs(_ knobs: SectionsKnobs) {
291
+ mutateAndEmit {
292
+ self.knobs = knobs
293
+ return self.recomputeLocked()
294
+ }
295
+ }
296
+
297
+ // MARK: - Dwell (focus driven)
298
+
299
+ /// Arms the single dwell slot for a tracked item; a different armed item is
300
+ /// superseded silently. Arming an already-armed item keeps its deadline.
301
+ public func armDwell(itemKey: String) {
302
+ mutateAndEmit {
303
+ if self.dwellSlot?.itemKey == itemKey { return [] }
304
+ self.cancelDwellLocked()
305
+ guard let item = self.mergedItemLocked(itemKey), self.trackedItemKeys.contains(itemKey) else {
306
+ return []
307
+ }
308
+ let dedupeKey = self.dedupeKeyLocked(for: item)
309
+ if self.firedDwellKeys.contains(dedupeKey) { return [] }
310
+ let task = self.scheduler.schedule(afterMs: self.knobs.dwellMs) { [weak self] in
311
+ self?.confirmDwell(itemKey: itemKey)
312
+ }
313
+ self.dwellSlot = DwellSlot(
314
+ itemKey: itemKey,
315
+ armedAtMs: self.scheduler.nowMs(),
316
+ dedupeKey: dedupeKey,
317
+ task: task
318
+ )
319
+ return []
320
+ }
321
+ }
322
+
323
+ /// Cancels the armed dwell slot (focus moved off tracked content).
324
+ public func clearDwell() {
325
+ mutateAndEmit {
326
+ self.cancelDwellLocked()
327
+ return []
328
+ }
329
+ }
330
+
331
+ // MARK: - Screen lifecycle
332
+
333
+ /// Screen-change parity rule: emit `raw_section_hidden` for still-visible
334
+ /// sections FIRST, then reset dedupe, caps, dwell, candidates, and order
335
+ /// assignment. Source stores are kept; the next revalidate re-emits
336
+ /// whatever remains visible under the new screen context.
337
+ public func onScreenChanged() {
338
+ mutateAndEmit {
339
+ var emissions: [(String, [String: String])] = []
340
+ // Total ordering (section key breaks order ties) so the hidden
341
+ // flush is deterministic when sections share an explicit order.
342
+ let stillVisible = self.sectionStates
343
+ .filter(\.value.visibleEmitted)
344
+ .sorted { ($0.value.order, $0.key) < ($1.value.order, $1.key) }
345
+ for (_, state) in stillVisible {
346
+ emissions.append(self.sectionHiddenEmissionLocked(state))
347
+ }
348
+ self.sectionStates.removeAll()
349
+ self.nextFirstSeenOrder = 0
350
+ self.impressionCandidates.values.forEach { $0.task.cancel() }
351
+ self.impressionCandidates.removeAll()
352
+ self.firedImpressionKeys.removeAll()
353
+ self.capturedDedupeKeys.removeAll()
354
+ self.cancelDwellLocked()
355
+ self.firedDwellKeys.removeAll()
356
+ self.trackedSectionKeys.removeAll()
357
+ self.trackedItemKeys.removeAll()
358
+ self.capLogEmittedThisScreen = false
359
+ return emissions
360
+ }
361
+ }
362
+
363
+ /// Silent teardown for uninstall: cancels timers and clears every store
364
+ /// without emitting.
365
+ public func reset() {
366
+ mutateAndEmit {
367
+ self.impressionCandidates.values.forEach { $0.task.cancel() }
368
+ self.impressionCandidates.removeAll()
369
+ self.cancelDwellLocked()
370
+ self.sectionFeeds.removeAll()
371
+ self.itemFeeds.removeAll()
372
+ self.sectionStates.removeAll()
373
+ self.nextFirstSeenOrder = 0
374
+ self.firedImpressionKeys.removeAll()
375
+ self.capturedDedupeKeys.removeAll()
376
+ self.firedDwellKeys.removeAll()
377
+ self.trackedSectionKeys.removeAll()
378
+ self.trackedItemKeys.removeAll()
379
+ self.capLogEmittedThisScreen = false
380
+ return []
381
+ }
382
+ }
383
+
384
+ // MARK: - Enrichment lookups
385
+
386
+ /// Context of the armed dwell slot for interaction enrichment
387
+ /// (tvOS remote-key/select), or `nil` when nothing is armed.
388
+ public func dwellEnrichment() -> [String: String]? {
389
+ lock.lock()
390
+ defer { lock.unlock() }
391
+ guard let slot = dwellSlot, let item = mergedItemLocked(slot.itemKey) else { return nil }
392
+ return itemEnrichmentFieldsLocked(item)
393
+ }
394
+
395
+ /// Section/item context for a resolved item key, or `nil` when untracked.
396
+ public func itemEnrichment(itemKey: String) -> [String: String]? {
397
+ lock.lock()
398
+ defer { lock.unlock() }
399
+ guard let item = mergedItemLocked(itemKey) else { return nil }
400
+ return itemEnrichmentFieldsLocked(item)
401
+ }
402
+
403
+ /// Section context for a resolved section key, or `nil` when untracked.
404
+ public func sectionEnrichment(sectionKey: String) -> [String: String]? {
405
+ lock.lock()
406
+ defer { lock.unlock() }
407
+ guard let state = sectionStates[sectionKey] else { return nil }
408
+ return ["section": state.name, "sectionOrder": String(state.order)]
409
+ }
410
+
411
+ // MARK: - Timer confirmations
412
+
413
+ private func confirmImpression(itemKey: String) {
414
+ mutateAndEmit {
415
+ guard let candidate = self.impressionCandidates.removeValue(forKey: itemKey) else { return [] }
416
+ // Re-verify at fire: the item must still be reported and still at
417
+ // or above the threshold; the emitted pct is the confirmation-time
418
+ // value, not the arming-time value.
419
+ guard let item = self.mergedItemLocked(itemKey), self.qualifiesLocked(item.visibilityPct) else {
420
+ return []
421
+ }
422
+ // Dedupe on the arm-time key, never a recomputed one.
423
+ guard !self.firedImpressionKeys.contains(candidate.dedupeKey) else { return [] }
424
+ self.firedImpressionKeys.insert(candidate.dedupeKey)
425
+ var params: [String: String] = [
426
+ "position": String(item.position),
427
+ "visibilityPct": String(item.visibilityPct),
428
+ ]
429
+ if let sectionFields = self.sectionFieldsLocked(for: item) {
430
+ params.merge(sectionFields) { current, _ in current }
431
+ }
432
+ if let contentId = item.contentId {
433
+ params["contentId"] = contentId
434
+ }
435
+ if let title = item.title {
436
+ params["contentTitle"] = title
437
+ }
438
+ return [(RawEventName.contentImpression, params)]
439
+ }
440
+ }
441
+
442
+ private func confirmDwell(itemKey: String) {
443
+ mutateAndEmit {
444
+ guard let slot = self.dwellSlot, slot.itemKey == itemKey else { return [] }
445
+ self.dwellSlot = nil
446
+ // Re-verify at fire: the item must still be reported and visible.
447
+ guard let item = self.mergedItemLocked(itemKey), item.visibilityPct > 0 else { return [] }
448
+ // Dedupe on the arm-time key, never a recomputed one.
449
+ guard !self.firedDwellKeys.contains(slot.dedupeKey) else { return [] }
450
+ self.firedDwellKeys.insert(slot.dedupeKey)
451
+ var params: [String: String] = [
452
+ "position": String(item.position),
453
+ "dwellMs": String(max(0, self.scheduler.nowMs() - slot.armedAtMs)),
454
+ ]
455
+ if let sectionName = self.sectionNameLocked(for: item) {
456
+ params["section"] = sectionName
457
+ }
458
+ if let contentId = item.contentId {
459
+ params["contentId"] = contentId
460
+ }
461
+ return [(RawEventName.contentDwell, params)]
462
+ }
463
+ }
464
+
465
+ // MARK: - Core recompute
466
+
467
+ private func recomputeLocked() -> [(String, [String: String])] {
468
+ var emissions: [(String, [String: String])] = []
469
+ let sections = mergedSectionsLocked()
470
+ let items = mergedItemsLocked()
471
+ let sectionKeys = Set(sections.map(\.key))
472
+ let itemKeys = Set(items.map(\.key))
473
+
474
+ // Section tracking, ordering, and visible/hidden transitions.
475
+ for snapshot in sections {
476
+ if !trackedSectionKeys.contains(snapshot.key) {
477
+ guard trackedSectionKeys.count < knobs.maxSections else {
478
+ logCapOnceLocked()
479
+ continue
480
+ }
481
+ trackedSectionKeys.insert(snapshot.key)
482
+ }
483
+ var state = sectionStates[snapshot.key] ?? SectionRuntime(
484
+ name: snapshot.name,
485
+ order: snapshot.order ?? nextFirstSeenOrderLocked(),
486
+ visibleEmitted: false
487
+ )
488
+ state.name = snapshot.name
489
+ // Explicit order wins over first-seen order, including a later
490
+ // explicit order arriving for a first-seen section.
491
+ if let explicit = snapshot.order {
492
+ state.order = explicit
493
+ }
494
+ let visible = qualifiesLocked(snapshot.visibilityPct)
495
+ if visible, !state.visibleEmitted {
496
+ state.visibleEmitted = true
497
+ emissions.append((
498
+ RawEventName.sectionVisible,
499
+ [
500
+ "section": state.name,
501
+ "sectionOrder": String(state.order),
502
+ "visibilityPct": String(snapshot.visibilityPct),
503
+ "itemCount": String(itemCountLocked(sectionKey: snapshot.key, snapshotCount: snapshot.itemCount, items: items)),
504
+ ]
505
+ ))
506
+ } else if !visible, state.visibleEmitted {
507
+ state.visibleEmitted = false
508
+ emissions.append(sectionHiddenEmissionLocked(state))
509
+ }
510
+ sectionStates[snapshot.key] = state
511
+ }
512
+
513
+ // Sections that stopped being reported entirely: hidden if visible.
514
+ // Runtime state is kept so re-appearing sections keep a stable
515
+ // first-seen order for the rest of the screen.
516
+ for (key, state) in sectionStates where !sectionKeys.contains(key) && state.visibleEmitted {
517
+ var updated = state
518
+ updated.visibleEmitted = false
519
+ sectionStates[key] = updated
520
+ emissions.append(sectionHiddenEmissionLocked(updated))
521
+ }
522
+
523
+ // Item tracking and impression sustain arming/cancelling.
524
+ for item in items {
525
+ if !trackedItemKeys.contains(item.key) {
526
+ guard trackedItemKeys.count < knobs.maxTrackedItems else {
527
+ logCapOnceLocked()
528
+ continue
529
+ }
530
+ trackedItemKeys.insert(item.key)
531
+ }
532
+ let qualifies = qualifiesLocked(item.visibilityPct)
533
+ if qualifies {
534
+ let dedupeKey = dedupeKeyLocked(for: item)
535
+ if impressionCandidates[item.key] == nil, !firedImpressionKeys.contains(dedupeKey) {
536
+ let key = item.key
537
+ let task = scheduler.schedule(afterMs: knobs.impressionMinVisibleMs) { [weak self] in
538
+ self?.confirmImpression(itemKey: key)
539
+ }
540
+ impressionCandidates[item.key] = ImpressionCandidate(
541
+ armedAtMs: scheduler.nowMs(),
542
+ dedupeKey: dedupeKey,
543
+ task: task
544
+ )
545
+ }
546
+ } else if let candidate = impressionCandidates.removeValue(forKey: item.key) {
547
+ // Sustain broken before the deadline: cancel the candidate.
548
+ candidate.task.cancel()
549
+ }
550
+ }
551
+
552
+ // Items that stopped being reported: their sustain is broken too.
553
+ for (key, candidate) in impressionCandidates where !itemKeys.contains(key) {
554
+ candidate.task.cancel()
555
+ impressionCandidates.removeValue(forKey: key)
556
+ }
557
+
558
+ return emissions
559
+ }
560
+
561
+ // MARK: - Locked helpers
562
+
563
+ private func mutateAndEmit(_ body: () -> [(String, [String: String])]) {
564
+ lock.lock()
565
+ let emissions = body()
566
+ lock.unlock()
567
+ for (kind, params) in emissions {
568
+ emitClosure(kind, params)
569
+ }
570
+ }
571
+
572
+ private func mergedSectionsLocked() -> [SectionSnapshot] {
573
+ var seen = Set<String>()
574
+ var merged: [SectionSnapshot] = []
575
+ for source in SectionsFeedSource.allCases {
576
+ for snapshot in sectionFeeds[source] ?? [] where !seen.contains(snapshot.key) {
577
+ seen.insert(snapshot.key)
578
+ merged.append(snapshot)
579
+ }
580
+ }
581
+ return merged
582
+ }
583
+
584
+ private func mergedItemsLocked() -> [ItemSnapshot] {
585
+ var seen = Set<String>()
586
+ var merged: [ItemSnapshot] = []
587
+ for source in SectionsFeedSource.allCases {
588
+ for item in itemFeeds[source] ?? [] where !seen.contains(item.key) {
589
+ seen.insert(item.key)
590
+ merged.append(item)
591
+ }
592
+ }
593
+ return merged
594
+ }
595
+
596
+ private func mergedItemLocked(_ key: String) -> ItemSnapshot? {
597
+ for source in SectionsFeedSource.allCases {
598
+ if let item = (itemFeeds[source] ?? []).first(where: { $0.key == key }) {
599
+ return item
600
+ }
601
+ }
602
+ return nil
603
+ }
604
+
605
+ private func cancelDwellLocked() {
606
+ dwellSlot?.task.cancel()
607
+ dwellSlot = nil
608
+ }
609
+
610
+ private func qualifiesLocked(_ visibilityPct: Int) -> Bool {
611
+ visibilityPct > 0 && visibilityPct >= knobs.visibilityThresholdPct
612
+ }
613
+
614
+ private func nextFirstSeenOrderLocked() -> Int {
615
+ defer { nextFirstSeenOrder += 1 }
616
+ return nextFirstSeenOrder
617
+ }
618
+
619
+ private func itemCountLocked(sectionKey: String, snapshotCount: Int, items: [ItemSnapshot]) -> Int {
620
+ let derived = items.filter { $0.sectionKey == sectionKey && $0.visibilityPct > 0 }.count
621
+ return max(snapshotCount, derived)
622
+ }
623
+
624
+ private func sectionHiddenEmissionLocked(_ state: SectionRuntime) -> (String, [String: String]) {
625
+ (
626
+ RawEventName.sectionHidden,
627
+ ["section": state.name, "sectionOrder": String(state.order)]
628
+ )
629
+ }
630
+
631
+ private func sectionNameLocked(for item: ItemSnapshot) -> String? {
632
+ guard let sectionKey = item.sectionKey else { return nil }
633
+ return sectionStates[sectionKey]?.name
634
+ }
635
+
636
+ private func sectionFieldsLocked(for item: ItemSnapshot) -> [String: String]? {
637
+ guard let sectionKey = item.sectionKey, let state = sectionStates[sectionKey] else { return nil }
638
+ return ["section": state.name, "sectionOrder": String(state.order)]
639
+ }
640
+
641
+ private func itemEnrichmentFieldsLocked(_ item: ItemSnapshot) -> [String: String] {
642
+ var fields: [String: String] = ["position": String(item.position)]
643
+ if let sectionFields = sectionFieldsLocked(for: item) {
644
+ fields.merge(sectionFields) { current, _ in current }
645
+ }
646
+ if let contentId = item.contentId {
647
+ fields["contentId"] = contentId
648
+ }
649
+ return fields
650
+ }
651
+
652
+ /// Dedupe key parity contract: `section|contentId` per screen, falling
653
+ /// back to the stable item key when the item carries no content id. The
654
+ /// key is captured on first dedupe-relevant use per item and reused for
655
+ /// the rest of the screen: without the freeze, a section registering
656
+ /// after its item already fired would drift `"|id"` into `"Rail|id"` and
657
+ /// the same item could fire a second impression/dwell on re-crossing.
658
+ private func dedupeKeyLocked(for item: ItemSnapshot) -> String {
659
+ if let captured = capturedDedupeKeys[item.key] {
660
+ return captured
661
+ }
662
+ let key = "\(sectionNameLocked(for: item) ?? "")|\(item.contentId ?? item.key)"
663
+ capturedDedupeKeys[item.key] = key
664
+ return key
665
+ }
666
+
667
+ private func logCapOnceLocked() {
668
+ guard !capLogEmittedThisScreen else { return }
669
+ capLogEmittedThisScreen = true
670
+ logClosure(
671
+ "sections capture caps reached (maxSections=\(knobs.maxSections), maxTrackedItems=\(knobs.maxTrackedItems)); further targets ignored for this screen"
672
+ )
673
+ }
674
+ }