@npaw/forge-react-native 9.7.1 → 9.7.3

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 (38) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +10 -0
  3. package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +59 -1
  4. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +33 -0
  5. package/android/src/test/java/com/npaw/forge/reactnative/ForgeInstanceRegistryTest.java +65 -0
  6. package/app.plugin.js +17 -0
  7. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +8 -8
  8. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +71 -0
  9. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  10. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +71 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +71 -0
  13. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  14. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +71 -0
  15. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  16. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +198 -1
  17. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +71 -0
  18. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +12 -0
  19. package/ios/ForgeApple/Sources/NPAWForgeCore/ConnectionType.swift +125 -0
  20. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +67 -1
  21. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +4 -0
  22. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +80 -3
  23. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +44 -0
  24. package/ios/ForgeApple/Sources/NPAWForgeCore/LocationObserver.swift +183 -0
  25. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +123 -2
  26. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +59 -0
  27. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +17 -1
  28. package/ios/ForgeInstanceRegistry.swift +69 -0
  29. package/ios/ForgeModuleImpl.swift +53 -6
  30. package/ios/NPAWForgeTurboModule.mm +6 -1
  31. package/package.json +3 -1
  32. package/plugin/index.js +82 -0
  33. package/plugin/mutations.js +140 -0
  34. package/src/NPAWForge.ts +12 -1
  35. package/src/events.ts +21 -1
  36. package/src/index.ts +11 -1
  37. package/src/sections.ts +292 -0
  38. package/src/types.ts +23 -2
@@ -0,0 +1,140 @@
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: Pure, idempotent host-project mutations applied by the Forge Expo config plugin; kept free of Expo imports so every one is unit-testable on its own.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const TAG_OPEN = '// @npaw/forge-react-native begin';
18
+ const TAG_CLOSE = '// @npaw/forge-react-native end';
19
+
20
+ const FORGE_MAVEN_URL = 'https://artifact.plugin.npaw.com/artifactory/plugins/android';
21
+ const KOTLIN_METADATA_FLAG = '-Xskip-metadata-version-check';
22
+
23
+ /**
24
+ * Replaces a previously inserted block, or inserts a new one after the line
25
+ * matching `anchor`.
26
+ *
27
+ * `expo prebuild` runs repeatedly and is expected to be idempotent, so every
28
+ * mutation is fenced by a tag pair: a second run rewrites the block between the
29
+ * fences instead of appending a duplicate. Without the fences a re-run would
30
+ * silently stack a second `maven { ... }` entry on every prebuild.
31
+ */
32
+ function mergeBlock(contents, { name, block, anchor }) {
33
+ const open = `${TAG_OPEN} ${name}`;
34
+ const close = `${TAG_CLOSE} ${name}`;
35
+ const fenced = `${open}\n${block}\n${close}`;
36
+
37
+ const openIndex = contents.indexOf(open);
38
+ if (openIndex !== -1) {
39
+ const closeIndex = contents.indexOf(close, openIndex);
40
+ if (closeIndex === -1) {
41
+ throw new Error(`Forge config plugin: unterminated "${name}" block; remove it and re-run prebuild`);
42
+ }
43
+ return contents.slice(0, openIndex) + fenced + contents.slice(closeIndex + close.length);
44
+ }
45
+
46
+ const match = contents.match(anchor);
47
+ if (!match || match.index === undefined) return null;
48
+ const insertAt = match.index + match[0].length;
49
+ return `${contents.slice(0, insertAt)}\n${fenced}${contents.slice(insertAt)}`;
50
+ }
51
+
52
+ /**
53
+ * Adds the NPAW Artifactory repository to the project-level build.gradle.
54
+ *
55
+ * The packages declare this repository themselves, so it is only load-bearing
56
+ * when the host centralises resolution — but adding it is harmless when it is
57
+ * already reachable, and the failure it prevents (an unresolvable Forge AAR at
58
+ * the first build) is opaque enough to be worth pre-empting.
59
+ */
60
+ function addForgeMavenRepository(contents) {
61
+ const block = [
62
+ 'allprojects {',
63
+ ' repositories {',
64
+ ` maven { url '${FORGE_MAVEN_URL}' }`,
65
+ ' }',
66
+ '}',
67
+ ].join('\n');
68
+
69
+ if (contents.includes(FORGE_MAVEN_URL) && !contents.includes(TAG_OPEN)) {
70
+ // Someone added the repository by hand before adopting the plugin. Leave
71
+ // their edit alone rather than producing a second declaration.
72
+ return contents;
73
+ }
74
+
75
+ // Anchored after the buildscript block so the insert lands at top level,
76
+ // never inside `buildscript { repositories { ... } }` where it would only
77
+ // affect plugin resolution and not the app's dependencies.
78
+ const merged = mergeBlock(contents, {
79
+ name: 'maven',
80
+ block,
81
+ anchor: /^\}\s*$/m,
82
+ });
83
+ return merged === null ? `${contents.trimEnd()}\n\n${TAG_OPEN} maven\n${block}\n${TAG_CLOSE} maven\n` : merged;
84
+ }
85
+
86
+ /**
87
+ * Adds the Kotlin metadata compiler flag to the application module.
88
+ *
89
+ * The published Forge Android artifacts are built with a newer Kotlin than the
90
+ * React Native and Expo templates use — the template in this repo pins
91
+ * `kotlinVersion = "1.9.24"` — so an application module compiling its own
92
+ * Kotlin against Forge types needs this flag. Applications already on Kotlin
93
+ * 2.2 or newer do not, which is why callers can turn it off.
94
+ */
95
+ function addKotlinMetadataFlag(contents) {
96
+ if (new RegExp(`freeCompilerArgs[^\\n]*${KOTLIN_METADATA_FLAG}`).test(contents)) {
97
+ return contents;
98
+ }
99
+
100
+ const block = [
101
+ ' kotlinOptions {',
102
+ ` freeCompilerArgs += ["${KOTLIN_METADATA_FLAG}"]`,
103
+ ' }',
104
+ ].join('\n');
105
+
106
+ const merged = mergeBlock(contents, {
107
+ name: 'kotlin',
108
+ block,
109
+ anchor: /^android\s*\{\s*$/m,
110
+ });
111
+ if (merged === null) {
112
+ throw new Error('Forge config plugin: no android { } block found in the app build.gradle');
113
+ }
114
+ return merged;
115
+ }
116
+
117
+ // Deliberately NOT automated here: turning bridgeless off.
118
+ //
119
+ // `react-native-video` v6 has no Fabric or bridgeless support, so applications
120
+ // using the video adapter package must disable bridgeless — but the documented
121
+ // mechanism is source-level, not a build setting: `load(bridgelessEnabled =
122
+ // false)` in MainApplication and a `bridgelessEnabled` override returning NO in
123
+ // AppDelegate. There is no gradle or Podfile property that does it.
124
+ //
125
+ // A mod writing some plausible-looking gradle property would be a silent no-op
126
+ // that reads as success, which is worse than leaving the step manual. Rewriting
127
+ // MainApplication and AppDelegate source is the alternative, and it breaks
128
+ // across React Native versions in ways prebuild cannot verify. The client
129
+ // documentation keeps this as a manual step until the v7 video adapter removes
130
+ // the constraint entirely.
131
+
132
+ module.exports = {
133
+ FORGE_MAVEN_URL,
134
+ KOTLIN_METADATA_FLAG,
135
+ TAG_OPEN,
136
+ TAG_CLOSE,
137
+ mergeBlock,
138
+ addForgeMavenRepository,
139
+ addKotlinMetadataFlag,
140
+ };
package/src/NPAWForge.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import NativeNPAWForge from './specs/NativeNPAWForge';
16
16
  import { ForgeVideo } from './ForgeVideo';
17
- import { subscribeInstanceEvents } from './events';
17
+ import { subscribeInstanceEvents, subscribeInstanceLogs } from './events';
18
18
  import type {
19
19
  ForgeConfig,
20
20
  ForgeEventSubscription,
@@ -22,6 +22,7 @@ import type {
22
22
  ForgeOutcome,
23
23
  ForgeParams,
24
24
  ForgeRecordedEvent,
25
+ ForgeRenderedLog,
25
26
  ForgeVideoOptions,
26
27
  } from './types';
27
28
 
@@ -153,6 +154,16 @@ export class NPAWForge {
153
154
  return subscribeInstanceEvents(this.instanceId, callback);
154
155
  }
155
156
 
157
+ /**
158
+ * Live feed of the kernel's rendered decision log for this instance - the
159
+ * same lines `logLevel: 'trace'` writes to the platform console, without
160
+ * needing native tooling to read them. The kernel level gates what is
161
+ * rendered, so raise it (`setLogLevel('trace')`) before expecting detail.
162
+ */
163
+ onLog(callback: (log: ForgeRenderedLog) => void): ForgeEventSubscription {
164
+ return subscribeInstanceLogs(this.instanceId, callback);
165
+ }
166
+
156
167
  // ---- video ----
157
168
 
158
169
  /**
package/src/events.ts CHANGED
@@ -14,9 +14,14 @@
14
14
 
15
15
  import { NativeEventEmitter, NativeModules } from 'react-native';
16
16
  import NativeNPAWForge from './specs/NativeNPAWForge';
17
- import type { ForgeEventSubscription, ForgeRecordedEvent } from './types';
17
+ import type {
18
+ ForgeEventSubscription,
19
+ ForgeRecordedEvent,
20
+ ForgeRenderedLog,
21
+ } from './types';
18
22
 
19
23
  export const FORGE_EVENT_CHANNEL = 'NPAWForgeEvent';
24
+ export const FORGE_LOG_CHANNEL = 'NPAWForgeLog';
20
25
 
21
26
  const emitter = new NativeEventEmitter(
22
27
  // The spec module implements addListener/removeListeners; iOS needs the
@@ -38,3 +43,18 @@ export function subscribeInstanceEvents(
38
43
  );
39
44
  return { remove: () => subscription.remove() };
40
45
  }
46
+
47
+ export function subscribeInstanceLogs(
48
+ instanceId: string,
49
+ callback: (log: ForgeRenderedLog) => void
50
+ ): ForgeEventSubscription {
51
+ const subscription = emitter.addListener(
52
+ FORGE_LOG_CHANNEL,
53
+ (log: ForgeRenderedLog) => {
54
+ if (log.instanceId === instanceId) {
55
+ callback(log);
56
+ }
57
+ }
58
+ );
59
+ return { remove: () => subscription.remove() };
60
+ }
package/src/index.ts CHANGED
@@ -15,22 +15,32 @@
15
15
  export { NPAWForge, DEFAULT_VIDEO_IDENTIFIER } from './NPAWForge';
16
16
  export { ForgeVideo } from './ForgeVideo';
17
17
  export { ForgeAdReporter } from './ForgeAdReporter';
18
- export { FORGE_EVENT_CHANNEL } from './events';
18
+ export { FORGE_EVENT_CHANNEL, FORGE_LOG_CHANNEL } from './events';
19
19
  export {
20
20
  installErrorCapture,
21
21
  trackScreenView,
22
22
  createNavigationObserver,
23
23
  } from './capture';
24
24
  export type { ErrorCaptureOptions, ScreenViewOptions } from './capture';
25
+ export { createSectionsCapture } from './sections';
26
+ export type {
27
+ ForgeSectionContent,
28
+ ForgeSectionsCaptureHandle,
29
+ ForgeSectionsCaptureOptions,
30
+ ForgeViewabilityCallbackInfo,
31
+ ForgeViewToken,
32
+ } from './sections';
25
33
  export type {
26
34
  ForgeConfig,
27
35
  ForgeEventSubscription,
36
+ ForgeLogLevelName,
28
37
  ForgeNetworkOutcome,
29
38
  ForgeNetworkRequestRecord,
30
39
  ForgeOutcome,
31
40
  ForgeOutcomeCode,
32
41
  ForgeParams,
33
42
  ForgeRecordedEvent,
43
+ ForgeRenderedLog,
34
44
  ForgeVideoOptions,
35
45
  } from './types';
36
46
 
@@ -0,0 +1,292 @@
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: SECTIONS capture for React Native virtualized lists - turns FlatList/SectionList viewability callbacks into the same four raw section events Web and Roku emit, with matching thresholds and dedupe.
13
+ */
14
+
15
+ import type { NPAWForge } from './NPAWForge';
16
+ import type { ForgeParams } from './types';
17
+
18
+ /**
19
+ * The shape React Native hands to `onViewableItemsChanged`.
20
+ *
21
+ * Declared structurally rather than imported from `react-native` so this module
22
+ * carries no dependency on the RN type surface and stays unit-testable without
23
+ * a renderer.
24
+ */
25
+ export type ForgeViewToken = {
26
+ key?: string | null;
27
+ index?: number | null;
28
+ isViewable: boolean;
29
+ item?: unknown;
30
+ };
31
+
32
+ export type ForgeViewabilityCallbackInfo = {
33
+ viewableItems: ForgeViewToken[];
34
+ changed: ForgeViewToken[];
35
+ };
36
+
37
+ /** Facts the integrator supplies for a row. Everything is optional but `contentId`. */
38
+ export type ForgeSectionContent = {
39
+ contentId?: string;
40
+ contentTitle?: string;
41
+ contentLink?: string;
42
+ /** Grid coordinates, when the list is not a simple single-axis rail. */
43
+ row?: number;
44
+ column?: number;
45
+ };
46
+
47
+ export type ForgeSectionsCaptureOptions = {
48
+ /** Logical section name — the rail's identity in reporting. */
49
+ section: string;
50
+ /** Zero-based order of this section within the screen. */
51
+ sectionOrder?: number;
52
+ /**
53
+ * Maps a list item to its reporting facts. Return `undefined` to skip an
54
+ * item entirely (placeholders, ad slots, loading skeletons).
55
+ */
56
+ resolveContent: (item: unknown, index: number) => ForgeSectionContent | undefined;
57
+ /**
58
+ * Percentage of a row that must be on screen to count as seen. Mirrors the
59
+ * Web threshold and RN's `itemVisiblePercentThreshold`. Default 50.
60
+ */
61
+ visibilityPercentThreshold?: number;
62
+ /**
63
+ * How long a row must stay visible before it is an impression. Mirrors RN's
64
+ * `minimumViewTime`. Default 1000 ms.
65
+ */
66
+ impressionMinVisibleMs?: number;
67
+ /** Minimum visible time that counts as dwell. Default 1000 ms. */
68
+ dwellMs?: number;
69
+ /** Upper bound on tracked rows, mirroring the Web cap. Default 200. */
70
+ maxTrackedItems?: number;
71
+ /**
72
+ * Local half of the capture gate. The kernel is the authority — it drops all
73
+ * four section kinds when the SECTIONS plan bit is off — but returning false
74
+ * here also spares the bridge the traffic. Default: always on.
75
+ */
76
+ enabled?: () => boolean;
77
+ /** Injectable clock, for tests. */
78
+ now?: () => number;
79
+ };
80
+
81
+ export type ForgeSectionsCaptureHandle = {
82
+ /** Pass straight to `FlatList.onViewableItemsChanged`. */
83
+ onViewableItemsChanged: (info: ForgeViewabilityCallbackInfo) => void;
84
+ /** Pass straight to `FlatList.viewabilityConfig`. */
85
+ viewabilityConfig: {
86
+ itemVisiblePercentThreshold: number;
87
+ minimumViewTime: number;
88
+ };
89
+ /** Call from a row's `onPress`. */
90
+ trackClick: (item: unknown, index: number) => void;
91
+ /**
92
+ * Clears dedupe state so the same rows can report again. Call on navigation,
93
+ * matching how the Web implementation resets per route.
94
+ */
95
+ reset: () => void;
96
+ /** Emits a closing `raw_section_hidden` if the section is visible, and stops. */
97
+ dispose: () => void;
98
+ };
99
+
100
+ // Kernel-mirrored defaults, identical to bindings/web/src/sections-capture.ts.
101
+ const DEFAULT_VISIBILITY_THRESHOLD_PCT = 50;
102
+ const DEFAULT_IMPRESSION_MIN_VISIBLE_MS = 1000;
103
+ const DEFAULT_DWELL_MS = 1000;
104
+ const DEFAULT_MAX_TRACKED_ITEMS = 200;
105
+
106
+ type TrackedRow = {
107
+ key: string;
108
+ index: number;
109
+ content: ForgeSectionContent;
110
+ visibleSince: number;
111
+ };
112
+
113
+ function tokenKey(token: ForgeViewToken, index: number): string {
114
+ return token.key ?? `index:${index}`;
115
+ }
116
+
117
+ /**
118
+ * Creates a sections capture handle for one list.
119
+ *
120
+ * React Native's virtualized lists are JavaScript-owned: rows are recycled and
121
+ * unmounted as they scroll, so the native Android and Apple sections observers
122
+ * see nothing usable. The viewability callback is the only reliable signal, and
123
+ * it already carries the two knobs the Web implementation derives from
124
+ * IntersectionObserver — a visibility percentage and a minimum view time — so
125
+ * the semantics map across rather than being re-invented.
126
+ *
127
+ * ```tsx
128
+ * const sections = createSectionsCapture(forge, {
129
+ * section: 'Continue watching',
130
+ * resolveContent: (item) => ({ contentId: item.id, contentTitle: item.title }),
131
+ * });
132
+ *
133
+ * <FlatList
134
+ * data={rows}
135
+ * onViewableItemsChanged={sections.onViewableItemsChanged}
136
+ * viewabilityConfig={sections.viewabilityConfig}
137
+ * renderItem={({ item, index }) => (
138
+ * <Row onPress={() => sections.trackClick(item, index)} />
139
+ * )}
140
+ * />
141
+ * ```
142
+ *
143
+ * One handle per list. Call `dispose` when the screen unmounts so the section's
144
+ * `raw_section_hidden` pairs with the `raw_section_visible` it emitted.
145
+ */
146
+ export function createSectionsCapture(
147
+ forge: NPAWForge,
148
+ options: ForgeSectionsCaptureOptions,
149
+ ): ForgeSectionsCaptureHandle {
150
+ const visibilityPercentThreshold =
151
+ options.visibilityPercentThreshold ?? DEFAULT_VISIBILITY_THRESHOLD_PCT;
152
+ const impressionMinVisibleMs =
153
+ options.impressionMinVisibleMs ?? DEFAULT_IMPRESSION_MIN_VISIBLE_MS;
154
+ const dwellMs = options.dwellMs ?? DEFAULT_DWELL_MS;
155
+ const maxTrackedItems = options.maxTrackedItems ?? DEFAULT_MAX_TRACKED_ITEMS;
156
+ const now = options.now ?? (() => Date.now());
157
+ const isEnabled = options.enabled ?? (() => true);
158
+
159
+ const tracked = new Map<string, TrackedRow>();
160
+ const impressionsSeen = new Set<string>();
161
+ const dwellSeen = new Set<string>();
162
+ let sectionVisible = false;
163
+ let disposed = false;
164
+
165
+ function emit(name: string, params: ForgeParams): void {
166
+ // Fire-and-forget: a rejected raw event must never surface inside a list
167
+ // render or a scroll handler.
168
+ void forge.trackRawEvent(name, params).catch(() => undefined);
169
+ }
170
+
171
+ function sectionParams(): ForgeParams {
172
+ const params: ForgeParams = { section: options.section };
173
+ if (options.sectionOrder !== undefined) params.sectionOrder = options.sectionOrder;
174
+ return params;
175
+ }
176
+
177
+ function dedupeKey(row: TrackedRow): string {
178
+ return row.content.contentId ?? `${options.section}#${row.key}`;
179
+ }
180
+
181
+ function contentParams(row: TrackedRow): ForgeParams {
182
+ const params: ForgeParams = { ...sectionParams(), position: row.index };
183
+ const { contentId, contentTitle, contentLink, row: gridRow, column } = row.content;
184
+ if (contentId !== undefined) params.contentId = contentId;
185
+ if (contentTitle !== undefined) params.contentTitle = contentTitle;
186
+ if (contentLink !== undefined) params.contentLink = contentLink;
187
+ if (gridRow !== undefined) params.row = gridRow;
188
+ if (column !== undefined) params.column = column;
189
+ return params;
190
+ }
191
+
192
+ function openSection(visibleCount: number): void {
193
+ if (sectionVisible) return;
194
+ sectionVisible = true;
195
+ emit('raw_section_visible', {
196
+ ...sectionParams(),
197
+ visibilityPct: visibilityPercentThreshold,
198
+ itemCount: visibleCount,
199
+ });
200
+ }
201
+
202
+ function closeSection(): void {
203
+ if (!sectionVisible) return;
204
+ sectionVisible = false;
205
+ emit('raw_section_hidden', sectionParams());
206
+ }
207
+
208
+ function rowBecameVisible(token: ForgeViewToken, fallbackIndex: number): void {
209
+ const index = token.index ?? fallbackIndex;
210
+ const key = tokenKey(token, index);
211
+ if (tracked.has(key)) return;
212
+ if (tracked.size >= maxTrackedItems) return;
213
+
214
+ const content = options.resolveContent(token.item, index);
215
+ if (!content) return;
216
+
217
+ tracked.set(key, { key, index, content, visibleSince: now() });
218
+ }
219
+
220
+ function rowBecameHidden(token: ForgeViewToken, fallbackIndex: number): void {
221
+ const index = token.index ?? fallbackIndex;
222
+ const key = tokenKey(token, index);
223
+ const row = tracked.get(key);
224
+ if (!row) return;
225
+ tracked.delete(key);
226
+
227
+ const elapsed = now() - row.visibleSince;
228
+ const key_ = dedupeKey(row);
229
+
230
+ // Impression first: a row that met the minimum view time was seen, whether
231
+ // or not it also earned a dwell.
232
+ if (elapsed >= impressionMinVisibleMs && !impressionsSeen.has(key_)) {
233
+ impressionsSeen.add(key_);
234
+ emit('raw_content_impression', {
235
+ ...contentParams(row),
236
+ visibilityPct: visibilityPercentThreshold,
237
+ });
238
+ }
239
+
240
+ if (elapsed >= dwellMs && !dwellSeen.has(key_)) {
241
+ dwellSeen.add(key_);
242
+ emit('raw_content_dwell', { ...contentParams(row), dwellMs: Math.round(elapsed) });
243
+ }
244
+ }
245
+
246
+ return {
247
+ viewabilityConfig: {
248
+ itemVisiblePercentThreshold: visibilityPercentThreshold,
249
+ minimumViewTime: impressionMinVisibleMs,
250
+ },
251
+
252
+ onViewableItemsChanged(info) {
253
+ if (disposed || !isEnabled()) return;
254
+
255
+ for (const [position, token] of (info.changed ?? []).entries()) {
256
+ if (token.isViewable) rowBecameVisible(token, position);
257
+ else rowBecameHidden(token, position);
258
+ }
259
+
260
+ const visible = (info.viewableItems ?? []).length;
261
+ if (visible > 0) openSection(visible);
262
+ else closeSection();
263
+ },
264
+
265
+ trackClick(item, index) {
266
+ if (disposed || !isEnabled()) return;
267
+ const content = options.resolveContent(item, index);
268
+ if (!content) return;
269
+ emit('raw_click', {
270
+ ...contentParams({ key: `index:${index}`, index, content, visibleSince: now() }),
271
+ });
272
+ },
273
+
274
+ reset() {
275
+ // Flush what is on screen before forgetting it, so a navigation away does
276
+ // not silently drop rows that had already earned an impression.
277
+ for (const row of [...tracked.values()]) {
278
+ rowBecameHidden({ key: row.key, index: row.index, isViewable: false }, row.index);
279
+ }
280
+ tracked.clear();
281
+ impressionsSeen.clear();
282
+ dwellSeen.clear();
283
+ closeSection();
284
+ },
285
+
286
+ dispose() {
287
+ if (disposed) return;
288
+ this.reset();
289
+ disposed = true;
290
+ },
291
+ };
292
+ }
package/src/types.ts CHANGED
@@ -33,6 +33,27 @@ export type ForgeOutcome = {
33
33
  details?: Record<string, string>;
34
34
  };
35
35
 
36
+ /** Log levels the kernel renders lines at, as they travel on the wire. */
37
+ export type ForgeLogLevelName =
38
+ | 'off'
39
+ | 'error'
40
+ | 'warning'
41
+ | 'info'
42
+ | 'debug'
43
+ | 'trace';
44
+
45
+ /**
46
+ * One line of the kernel's rendered decision log - the same text
47
+ * `logLevel: 'trace'` writes to the platform console. Already rendered and
48
+ * redacted natively; `tag` is the kernel's own scope tag.
49
+ */
50
+ export type ForgeRenderedLog = {
51
+ instanceId: string;
52
+ level: ForgeLogLevelName;
53
+ tag: string;
54
+ message: string;
55
+ };
56
+
36
57
  /** One kernel-recorded event from the bounded native snapshot mirror. */
37
58
  export type ForgeRecordedEvent = {
38
59
  instanceId: string;
@@ -48,8 +69,8 @@ export type ForgeRecordedEvent = {
48
69
  * aliases are intentionally not exposed); unknown keys are warn-and-ignore
49
70
  * on the native side for forward compatibility.
50
71
  *
51
- * iOS note: deviceUuid and autoDeviceIdentityEnabled are Android-only today
52
- * (the Swift Builder lacks the setters); iOS logs and ignores them.
72
+ * Every key applies on both platforms, including `deviceUuid` and
73
+ * `autoDeviceIdentityEnabled`, which iOS ignored before Forge 9.7.2.
53
74
  */
54
75
  export type ForgeConfig = {
55
76
  appName?: string;