@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.
- package/LICENSE +10 -0
- package/NPAWForgeReactNative.podspec +44 -0
- package/README.md +51 -0
- package/android/build.gradle +77 -0
- package/android/src/main/AndroidManifest.xml +14 -0
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +295 -0
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +61 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +735 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgePackage.java +53 -0
- package/android/src/test/java/com/npaw/forge/reactnative/ForgeDtoMapperTest.java +133 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +82 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +3931 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.modulemap +7 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAccessLogFacts.swift +152 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +877 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/AttributionCapture.swift +158 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/CapturePlanGate.swift +189 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/CrashCapture.swift +120 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/ForgeAutomaticCapture.swift +639 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsCapture.swift +1176 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsEngine.swift +674 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsMarkers.swift +105 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsSwiftUI.swift +284 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SwiftUICapture.swift +44 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/AdAdapter.swift +199 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/AdSemantics.swift +136 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +596 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +297 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeClock.swift +29 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +109 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeOutcome.swift +89 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +291 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeStorage.swift +70 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ManualAdReporter.swift +180 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +453 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +594 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NetworkRequestRecord.swift +191 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +191 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +299 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerSemantics.swift +266 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PrivacySanitizer.swift +59 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +478 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/VideoOptions.swift +431 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCapture/ForgeNetworkCapture.swift +116 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/NPAWForgeNetworkSwizzler.m +293 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/include/NPAWForgeNetworkSwizzler.h +33 -0
- package/ios/ForgeModuleImpl.swift +1131 -0
- package/ios/NPAWForgeTurboModule.mm +399 -0
- package/package.json +33 -0
- package/src/ForgeAdReporter.ts +101 -0
- package/src/ForgeVideo.ts +169 -0
- package/src/NPAWForge.ts +177 -0
- package/src/capture.ts +192 -0
- package/src/events.ts +40 -0
- package/src/index.ts +37 -0
- package/src/specs/NativeNPAWForge.ts +168 -0
- 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
|
+
}
|