@digia-engage/core 2.0.3 → 2.2.0

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 (65) hide show
  1. package/DigiaEngageReactNative.podspec +1 -1
  2. package/android/.project +28 -0
  3. package/android/bin/.gradle/8.13/fileHashes/fileHashes.lock +0 -0
  4. package/android/bin/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  5. package/android/bin/.gradle/buildOutputCleanup/cache.properties +2 -0
  6. package/android/bin/.project +34 -0
  7. package/android/bin/build/generated/source/buildConfig/debug/com/digia/engage/rn/BuildConfig.class +0 -0
  8. package/android/bin/build/generated/source/codegen/java/com/digia/engage/rn/NativeDigiaEngageSpec.class +0 -0
  9. package/android/bin/build.gradle +97 -0
  10. package/android/bin/gradle/wrapper/gradle-wrapper.jar +0 -0
  11. package/android/bin/gradle/wrapper/gradle-wrapper.properties +5 -0
  12. package/android/bin/gradle.properties +2 -0
  13. package/android/bin/gradlew +185 -0
  14. package/android/bin/gradlew.bat +89 -0
  15. package/android/bin/local.properties +1 -0
  16. package/android/bin/settings.gradle +25 -0
  17. package/android/bin/src/main/AndroidManifest.xml +2 -0
  18. package/android/bin/src/main/java/com/digia/engage/rn/DigiaAnchorViewManager.kt +90 -0
  19. package/android/bin/src/main/java/com/digia/engage/rn/DigiaModule.kt +309 -0
  20. package/android/bin/src/main/java/com/digia/engage/rn/DigiaPackage.kt +70 -0
  21. package/android/bin/src/main/java/com/digia/engage/rn/DigiaSlotViewManager.kt +183 -0
  22. package/android/bin/src/main/java/com/digia/engage/rn/DigiaViewManager.kt +64 -0
  23. package/android/build.gradle +1 -1
  24. package/android/src/main/java/com/digia/engage/rn/DigiaModule.kt +37 -13
  25. package/ios/DigiaEngageModule.m +25 -26
  26. package/ios/DigiaModule.swift +71 -10
  27. package/lib/commonjs/Digia.js +13 -2
  28. package/lib/commonjs/Digia.js.map +1 -1
  29. package/lib/commonjs/DigiaAnchorView.js +7 -3
  30. package/lib/commonjs/DigiaAnchorView.js.map +1 -1
  31. package/lib/commonjs/DigiaGuideController.js.map +1 -1
  32. package/lib/commonjs/DigiaProvider.js +70 -26
  33. package/lib/commonjs/DigiaProvider.js.map +1 -1
  34. package/lib/commonjs/digiaAnchorRegistry.js +15 -1
  35. package/lib/commonjs/digiaAnchorRegistry.js.map +1 -1
  36. package/lib/commonjs/interpolate.js +41 -0
  37. package/lib/commonjs/interpolate.js.map +1 -0
  38. package/lib/module/Digia.js +13 -2
  39. package/lib/module/Digia.js.map +1 -1
  40. package/lib/module/DigiaAnchorView.js +7 -3
  41. package/lib/module/DigiaAnchorView.js.map +1 -1
  42. package/lib/module/DigiaGuideController.js.map +1 -1
  43. package/lib/module/DigiaProvider.js +72 -27
  44. package/lib/module/DigiaProvider.js.map +1 -1
  45. package/lib/module/digiaAnchorRegistry.js +15 -1
  46. package/lib/module/digiaAnchorRegistry.js.map +1 -1
  47. package/lib/module/interpolate.js +34 -0
  48. package/lib/module/interpolate.js.map +1 -0
  49. package/lib/typescript/Digia.d.ts +1 -0
  50. package/lib/typescript/Digia.d.ts.map +1 -1
  51. package/lib/typescript/DigiaGuideController.d.ts +2 -0
  52. package/lib/typescript/DigiaGuideController.d.ts.map +1 -1
  53. package/lib/typescript/DigiaProvider.d.ts.map +1 -1
  54. package/lib/typescript/digiaAnchorRegistry.d.ts +4 -0
  55. package/lib/typescript/digiaAnchorRegistry.d.ts.map +1 -1
  56. package/lib/typescript/interpolate.d.ts +4 -0
  57. package/lib/typescript/interpolate.d.ts.map +1 -0
  58. package/package.json +4 -5
  59. package/react-native.config.js +1 -1
  60. package/src/Digia.ts +15 -2
  61. package/src/DigiaAnchorView.tsx +8 -1
  62. package/src/DigiaGuideController.ts +2 -0
  63. package/src/DigiaProvider.tsx +185 -145
  64. package/src/digiaAnchorRegistry.ts +16 -1
  65. package/src/interpolate.ts +44 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * DigiaModule
3
+ *
4
+ * React Native NativeModule that bridges the Digia Engage Android SDK.
5
+ *
6
+ * Exposed methods (callable from JS via NativeModules.DigiaEngageModule):
7
+ *
8
+ * initialize(apiKey, environment, logLevel, baseUrl): Promise<void> register(): void
9
+ * setCurrentScreen(name): void triggerCampaign(id, content, cepContext): void
10
+ * triggerCampaignById(campaignId): void invalidateCampaign(campaignId): void
11
+ *
12
+ * Architecture ──────────── The RN bridge mirrors the native Digia.initialize / Digia.register /
13
+ * Digia.setCurrentScreen flow exactly. An internal [RNEventBridgePlugin] is the single native
14
+ * DigiaCEPPlugin registered via Digia.register().
15
+ *
16
+ * When the SDK calls plugin.setup(delegate), the bridge stores that delegate reference. JS plugins
17
+ * that need to push campaigns into the Compose overlay call triggerCampaign / invalidateCampaign
18
+ * which forward to delegate.onCampaignTriggered / delegate.onCampaignInvalidated.
19
+ *
20
+ * Overlay lifecycle events (impressed / clicked / dismissed) are forwarded from the native
21
+ * plugin.notifyEvent() to JS via DeviceEventEmitter so that pure-JS CEP plugins (e.g.
22
+ * DigiaMoEngagePlugin) can report analytics.
23
+ */
24
+ package com.digia.engage.rn
25
+
26
+ import androidx.lifecycle.LifecycleOwner
27
+ import androidx.lifecycle.ViewModelStoreOwner
28
+ import androidx.lifecycle.setViewTreeLifecycleOwner
29
+ import androidx.lifecycle.setViewTreeViewModelStoreOwner
30
+ import androidx.savedstate.SavedStateRegistryOwner
31
+ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
32
+ import com.digia.engage.DiagnosticReport
33
+ import com.digia.engage.Digia
34
+ import com.digia.engage.DigiaCEPDelegate
35
+ import com.digia.engage.DigiaCEPPlugin
36
+ import com.digia.engage.DigiaConfig
37
+ import com.digia.engage.DigiaEnvironment
38
+ import com.digia.engage.DigiaExperienceEvent
39
+ import com.digia.engage.DigiaHostView
40
+ import com.digia.engage.DigiaLogLevel
41
+ import com.digia.engage.InAppPayload
42
+ import com.facebook.react.bridge.Arguments
43
+ import com.facebook.react.bridge.Promise
44
+ import com.facebook.react.bridge.ReactApplicationContext
45
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
46
+ import com.facebook.react.bridge.ReactMethod
47
+ import com.facebook.react.bridge.ReadableMap
48
+ import com.facebook.react.bridge.UiThreadUtil
49
+ import com.facebook.react.modules.core.DeviceEventManagerModule
50
+
51
+ internal class DigiaModule(
52
+ private val reactContext: ReactApplicationContext,
53
+ ) : ReactContextBaseJavaModule(reactContext) {
54
+
55
+ private val rnPlugin = RNEventBridgePlugin(reactContext)
56
+
57
+ override fun getName(): String = MODULE_NAME
58
+
59
+ // ─── initialize ───────────────────────────────────────────────────────────
60
+
61
+ @ReactMethod
62
+ fun initialize(
63
+ apiKey: String,
64
+ environment: String,
65
+ logLevel: String,
66
+ baseUrl: String?,
67
+ fontFamily: String?,
68
+ promise: Promise
69
+ ) {
70
+ try {
71
+ val cleanBaseUrl =
72
+ baseUrl.trim().trimEnd('/').removeSuffix("/api/v1").takeIf { it.isNotBlank() }
73
+ val config =
74
+ DigiaConfig(
75
+ apiKey = apiKey,
76
+ baseUrl = cleanBaseUrl,
77
+ environment =
78
+ when (environment.lowercase()) {
79
+ "sandbox" -> DigiaEnvironment.SANDBOX
80
+ else -> DigiaEnvironment.PRODUCTION
81
+ },
82
+ logLevel =
83
+ when (logLevel.lowercase()) {
84
+ "verbose" -> DigiaLogLevel.VERBOSE
85
+ "none" -> DigiaLogLevel.NONE
86
+ else -> DigiaLogLevel.ERROR
87
+ },
88
+ baseUrl = baseUrl?.takeIf { it.isNotBlank() },
89
+ fontFamily = fontFamily?.takeIf { it.isNotBlank() },
90
+ )
91
+ Digia.initialize(reactContext.applicationContext, config)
92
+
93
+ UiThreadUtil.runOnUiThread {
94
+ // Mount the Compose overlay ABOVE the ReactRootView via addContentView().
95
+ // This keeps it outside Fabric's shadow tree entirely so Fabric hit-testing
96
+ // never sees it. Touch pass-through is handled by DigiaHostView.dispatchTouchEvent
97
+ // returning false at the native Android level — which works correctly at this
98
+ // level of the hierarchy, unlike inside Fabric where pointerEvents="none" on
99
+ // non-ReactViewGroup views is not respected during shadow-tree hit-testing.
100
+ mountDigiaHost()
101
+ promise.resolve(null)
102
+ }
103
+ } catch (e: Exception) {
104
+ promise.reject("DIGIA_INIT_ERROR", e.message ?: "Initialisation failed", e)
105
+ }
106
+ }
107
+
108
+ // ─── register ─────────────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Registers [RNEventBridgePlugin] with the native Digia SDK.
112
+ *
113
+ * This is bridge infrastructure — not a user-facing CEP plugin. Called once by the JS
114
+ * `Digia.register()` wrapper on the first plugin registration so that
115
+ * [RNEventBridgePlugin.delegate] is populated before any triggerCampaign / invalidateCampaign
116
+ * calls arrive from JS.
117
+ */
118
+ @ReactMethod
119
+ fun registerBridge() {
120
+ Digia.register(rnPlugin)
121
+ }
122
+
123
+ // ─── setCurrentScreen ─────────────────────────────────────────────────────
124
+
125
+ @ReactMethod
126
+ fun setCurrentScreen(name: String) {
127
+ Digia.setCurrentScreen(name)
128
+ }
129
+
130
+ // ─── triggerCampaign ──────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Forwards a campaign payload to the native DigiaCEPDelegate.
134
+ *
135
+ * This is called by the JS DigiaDelegate.onCampaignTriggered() implementation when a JS CEP
136
+ * plugin (e.g. DigiaMoEngagePlugin) delivers a campaign. The delegate routes it into the
137
+ * Compose overlay for rendering.
138
+ */
139
+ @ReactMethod
140
+ fun triggerCampaign(id: String, content: ReadableMap, cepContext: ReadableMap) {
141
+ val delegate = rnPlugin.delegate ?: return
142
+ delegate.onCampaignTriggered(
143
+ InAppPayload(
144
+ id = id,
145
+ content = content.toHashMap().toMap(),
146
+ cepContext = cepContext.toHashMap().toMap(),
147
+ )
148
+ )
149
+ }
150
+
151
+ /** Triggers a fetched Digia campaign directly by campaign id/key. */
152
+ @ReactMethod
153
+ fun triggerCampaignById(campaignId: String) {
154
+ Digia.triggerCampaign(campaignId)
155
+ }
156
+
157
+ // ─── invalidateCampaign ───────────────────────────────────────────────────
158
+
159
+ /** Forwards a campaign invalidation to the native DigiaCEPDelegate. */
160
+ @ReactMethod
161
+ fun invalidateCampaign(campaignId: String) {
162
+ rnPlugin.delegate?.onCampaignInvalidated(campaignId)
163
+ }
164
+
165
+ // ─── Anchor registration ──────────────────────────────────────────────────
166
+
167
+ @ReactMethod
168
+ fun registerAnchor(key: String, x: Int, y: Int, width: Int, height: Int) {
169
+ Digia.registerAnchor(key, x, y, width, height)
170
+ }
171
+
172
+ @ReactMethod
173
+ fun unregisterAnchor(key: String) {
174
+ Digia.unregisterAnchor(key)
175
+ }
176
+
177
+ @ReactMethod
178
+ fun getRegisteredComponents(promise: Promise) {
179
+ promise.resolve(Arguments.createArray())
180
+ }
181
+
182
+ // ─── Internal: mount the Compose overlay host ─────────────────────────────
183
+
184
+ private fun mountDigiaHost() {
185
+ val activity =
186
+ reactContext.currentActivity
187
+ ?: run {
188
+ android.util.Log.w(
189
+ "DigiaHost",
190
+ "[mountDigiaHost] no current activity — skipping"
191
+ )
192
+ return
193
+ }
194
+
195
+ val contentRoot = activity.window.decorView.findViewWithTag<DigiaHostView>(DIGIA_HOST_TAG)
196
+ if (contentRoot != null) {
197
+ android.util.Log.d(
198
+ "DigiaHost",
199
+ "[mountDigiaHost] already mounted (tag found) — skipping"
200
+ )
201
+ return
202
+ }
203
+
204
+ android.util.Log.d("DigiaHost", "[mountDigiaHost] mounting native overlay on DecorView")
205
+
206
+ // Mount on the DecorView (not content area) so the overlay covers the full screen
207
+ // including status bar and navigation bar. This also ensures the Compose Popup's
208
+ // canvas y=0 aligns with the absolute screen y=0, matching getLocationOnScreen()
209
+ // coordinates used by DigiaAnchorView.
210
+ val decorView =
211
+ activity.window.decorView as? android.view.ViewGroup
212
+ ?: run {
213
+ android.util.Log.w(
214
+ "DigiaHost",
215
+ "[mountDigiaHost] decorView not a ViewGroup — skipping"
216
+ )
217
+ return
218
+ }
219
+
220
+ val composeView =
221
+ DigiaHostView(activity).apply {
222
+ tag = DIGIA_HOST_TAG
223
+ if (activity is LifecycleOwner) setViewTreeLifecycleOwner(activity)
224
+ if (activity is ViewModelStoreOwner) setViewTreeViewModelStoreOwner(activity)
225
+ if (activity is SavedStateRegistryOwner)
226
+ setViewTreeSavedStateRegistryOwner(activity)
227
+ }
228
+
229
+ decorView.addView(
230
+ composeView,
231
+ android.view.ViewGroup.LayoutParams(
232
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
233
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
234
+ ),
235
+ )
236
+ android.util.Log.d(
237
+ "DigiaHost",
238
+ "[mountDigiaHost] done — DecorView child count: ${decorView.childCount}"
239
+ )
240
+ }
241
+
242
+ companion object {
243
+ const val MODULE_NAME = "DigiaEngageModule"
244
+ private const val DIGIA_HOST_TAG = "digia_host_compose_view"
245
+ }
246
+ }
247
+
248
+ /**
249
+ * RNEventBridgePlugin
250
+ *
251
+ * The single native DigiaCEPPlugin used in React Native.
252
+ *
253
+ * When Digia.register(rnPlugin) is called, the SDK calls setup(delegate) which gives us the
254
+ * DigiaCEPDelegate reference. This delegate is used by triggerCampaign / invalidateCampaign bridge
255
+ * methods to push campaigns into the native rendering engine.
256
+ *
257
+ * Overlay lifecycle events (impressed / clicked / dismissed) received via notifyEvent() are emitted
258
+ * to JS as DeviceEventEmitter events so that JS CEP plugins can report analytics back to their
259
+ * platform.
260
+ */
261
+ internal class RNEventBridgePlugin(
262
+ private val reactContext: ReactApplicationContext,
263
+ ) : DigiaCEPPlugin {
264
+
265
+ override val identifier: String = "rn-event-bridge"
266
+
267
+ /** Delegate received from the SDK via setup(). Used by DigiaModule bridge methods. */
268
+ var delegate: DigiaCEPDelegate? = null
269
+ private set
270
+
271
+ private fun emit(event: String, params: com.facebook.react.bridge.WritableMap) {
272
+ if (reactContext.hasActiveReactInstance()) {
273
+ reactContext
274
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
275
+ .emit(event, params)
276
+ }
277
+ }
278
+
279
+ override fun setup(delegate: DigiaCEPDelegate) {
280
+ this.delegate = delegate
281
+ }
282
+
283
+ override fun forwardScreen(name: String) {
284
+ /* forwarded by Digia.setCurrentScreen() on the native side */
285
+ }
286
+
287
+ override fun notifyEvent(event: DigiaExperienceEvent, payload: InAppPayload) {
288
+ val params =
289
+ Arguments.createMap().apply {
290
+ putString("campaignId", payload.id)
291
+ when (event) {
292
+ is DigiaExperienceEvent.Impressed -> putString("type", "impressed")
293
+ is DigiaExperienceEvent.Clicked -> {
294
+ putString("type", "clicked")
295
+ event.elementId?.let { putString("elementId", it) }
296
+ }
297
+ is DigiaExperienceEvent.Dismissed -> putString("type", "dismissed")
298
+ }
299
+ }
300
+ emit("digiaEngageEvent", params)
301
+ }
302
+
303
+ override fun teardown() {
304
+ delegate = null
305
+ }
306
+
307
+ override fun healthCheck(): DiagnosticReport =
308
+ DiagnosticReport(isHealthy = true, metadata = mapOf("identifier" to identifier))
309
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * DigiaPackage
3
+ *
4
+ * Registers the Digia Engage native module and view managers with React Native.
5
+ *
6
+ * Extends TurboReactPackage so the package works transparently on both Old Architecture (bridge)
7
+ * and New Architecture (TurboModules / JSI).
8
+ *
9
+ * Why is this needed? ──────────────────── React Native's auto-linker reads react-native.config.js,
10
+ * which declares `packageInstance: 'new DigiaPackage()'`. At host-app compile time, RN injects this
11
+ * into the generated `PackageList`, making our native module and view managers available to JS
12
+ * without any manual MainApplication edits.
13
+ *
14
+ * JS: NativeModules.DigiaEngageModule ──► DigiaModule.kt JS:
15
+ * requireNativeComponent('DigiaHostView') ──► DigiaViewManager.kt JS:
16
+ * requireNativeComponent('DigiaSlotView') ──► DigiaSlotViewManager.kt
17
+ */
18
+ package com.digia.engage.rn
19
+
20
+ import com.facebook.react.BaseReactPackage
21
+ import com.facebook.react.bridge.NativeModule
22
+ import com.facebook.react.bridge.ReactApplicationContext
23
+ import com.facebook.react.module.model.ReactModuleInfo
24
+ import com.facebook.react.module.model.ReactModuleInfoProvider
25
+ import com.facebook.react.uimanager.ViewManager
26
+
27
+ class DigiaPackage : BaseReactPackage() {
28
+
29
+ // ─── Native Modules ───────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Called by New Architecture (TurboModules) to instantiate the module by name. Old Architecture
33
+ * continues to use createNativeModules() which TurboReactPackage implements internally by
34
+ * delegating to this method via getReactModuleInfoProvider.
35
+ */
36
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
37
+ when (name) {
38
+ DigiaModule.MODULE_NAME -> DigiaModule(reactContext)
39
+ else -> null
40
+ }
41
+
42
+ /**
43
+ * Metadata table: tells the RN runtime which modules this package provides and whether they
44
+ * should be treated as TurboModules (New Architecture).
45
+ */
46
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
47
+ mapOf(
48
+ DigiaModule.MODULE_NAME to
49
+ ReactModuleInfo(
50
+ /* name */ DigiaModule.MODULE_NAME,
51
+ /* className */ DigiaModule.MODULE_NAME,
52
+ /* canOverrideExistingModule */ false,
53
+ /* needsEagerInit */ false,
54
+ /* isCxxModule */ false,
55
+ /* isTurboModule */ false,
56
+ ),
57
+ )
58
+ }
59
+
60
+ // ─── View Managers ────────────────────────────────────────────────────────
61
+
62
+ override fun createViewManagers(
63
+ reactContext: ReactApplicationContext,
64
+ ): List<ViewManager<*, *>> =
65
+ listOf(
66
+ DigiaViewManager(),
67
+ DigiaSlotViewManager(),
68
+ DigiaAnchorViewManager(),
69
+ )
70
+ }
@@ -0,0 +1,183 @@
1
+ package com.digia.engage.rn
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+ import android.view.ViewTreeObserver
6
+ import android.widget.FrameLayout
7
+ import androidx.lifecycle.LifecycleOwner
8
+ import androidx.lifecycle.ViewModelStoreOwner
9
+ import androidx.lifecycle.setViewTreeLifecycleOwner
10
+ import androidx.lifecycle.setViewTreeViewModelStoreOwner
11
+ import androidx.savedstate.SavedStateRegistryOwner
12
+ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
13
+ import com.digia.engage.DigiaSlotView
14
+ import com.facebook.react.bridge.Arguments
15
+ import com.facebook.react.bridge.WritableMap
16
+ import com.facebook.react.uimanager.SimpleViewManager
17
+ import com.facebook.react.uimanager.ThemedReactContext
18
+ import com.facebook.react.uimanager.UIManagerHelper
19
+ import com.facebook.react.uimanager.annotations.ReactProp
20
+ import com.facebook.react.uimanager.events.Event
21
+ import java.util.concurrent.atomic.AtomicInteger
22
+
23
+ // ── ContentSizeChangeEvent ────────────────────────────────────────────────────
24
+
25
+ private class ContentSizeChangeEvent(
26
+ surfaceId: Int,
27
+ viewTag: Int,
28
+ private val heightDp: Double,
29
+ private val widthDp: Double,
30
+ ) : Event<ContentSizeChangeEvent>(surfaceId, viewTag) {
31
+ override fun getEventName(): String = "onContentSizeChange"
32
+ override fun getEventData(): WritableMap =
33
+ Arguments.createMap().apply {
34
+ putDouble("height", heightDp)
35
+ putDouble("width", widthDp)
36
+ }
37
+ }
38
+
39
+ // ── DigiaSlotContainerView ────────────────────────────────────────────────────
40
+
41
+ // Plain FrameLayout wrapper: Fabric can measure it before window attachment.
42
+ // The inner DigiaSlotView (ComposeView) is created lazily in onAttachedToWindow.
43
+ internal class DigiaSlotContainerView(context: Context) : FrameLayout(context) {
44
+
45
+ var rnContext: ThemedReactContext? = null
46
+
47
+ private var _slotView: DigiaSlotView? = null
48
+ private val lastReportedHeightPx = AtomicInteger(-1)
49
+
50
+ var placementKey: String = ""
51
+ set(value) {
52
+ field = value
53
+ val slot = _slotView ?: return
54
+ slot.placementKey = value
55
+ lastReportedHeightPx.set(-1)
56
+ // Defer measure so the slot has a non-zero width. Do NOT call requestLayout() here —
57
+ // it sets PFLAG_FORCE_LAYOUT on the container and RN bypasses a full traversal,
58
+ // causing subsequent Compose requestLayout() calls to be swallowed.
59
+ post { measureAndDispatch() }
60
+ postDelayed({
61
+ lastReportedHeightPx.set(-1)
62
+ measureAndDispatch()
63
+ }, DELAYED_MEASURE_MS)
64
+ }
65
+
66
+ private val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
67
+ measureAndDispatch()
68
+ }
69
+
70
+ // preDrawListener catches Compose content changes that globalLayoutListener misses in RN's
71
+ // layout model: Compose's invalidate() propagates to ViewRootImpl even when requestLayout()
72
+ // is blocked by PFLAG_FORCE_LAYOUT.
73
+ private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
74
+ measureAndDispatch()
75
+ true
76
+ }
77
+
78
+ override fun onAttachedToWindow() {
79
+ super.onAttachedToWindow()
80
+ if (_slotView == null) createSlotView()
81
+ viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
82
+ viewTreeObserver.addOnPreDrawListener(preDrawListener)
83
+ post { measureAndDispatch() }
84
+ // Retry: catches campaigns that arrive slightly after mount.
85
+ postDelayed({
86
+ if (lastReportedHeightPx.get() <= 0) {
87
+ lastReportedHeightPx.set(-1)
88
+ measureAndDispatch()
89
+ }
90
+ }, DELAYED_MEASURE_MS)
91
+ }
92
+
93
+ override fun onDetachedFromWindow() {
94
+ viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener)
95
+ viewTreeObserver.removeOnPreDrawListener(preDrawListener)
96
+ super.onDetachedFromWindow()
97
+ lastReportedHeightPx.set(-1)
98
+ }
99
+
100
+ // Defer measureAndDispatch to avoid re-measuring during Compose's composition phase,
101
+ // which crashes with "pending composition has not been applied".
102
+ override fun requestLayout() {
103
+ super.requestLayout()
104
+ post { measureAndDispatch() }
105
+ }
106
+
107
+ private fun createSlotView() {
108
+ val themedCtx = context as? ThemedReactContext
109
+ val activityCtx: Context = themedCtx?.currentActivity ?: context
110
+
111
+ val slot = DigiaSlotView(activityCtx)
112
+
113
+ val activity = themedCtx?.currentActivity
114
+ if (activity is LifecycleOwner) slot.setViewTreeLifecycleOwner(activity)
115
+ if (activity is ViewModelStoreOwner) slot.setViewTreeViewModelStoreOwner(activity)
116
+ if (activity is SavedStateRegistryOwner) slot.setViewTreeSavedStateRegistryOwner(activity)
117
+
118
+ slot.placementKey = placementKey
119
+ slot.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
120
+ slot.addOnLayoutChangeListener { _: View, l: Int, _: Int, r: Int, _: Int,
121
+ _: Int, _: Int, _: Int, _: Int ->
122
+ if (r - l > 0) measureAndDispatch()
123
+ }
124
+
125
+ addView(slot)
126
+ _slotView = slot
127
+ }
128
+
129
+ fun measureAndDispatch() {
130
+ val slot = _slotView ?: return
131
+ val ctx = rnContext ?: return
132
+ val viewWidth = width
133
+ if (viewWidth <= 0) return
134
+
135
+ val widthSpec = MeasureSpec.makeMeasureSpec(viewWidth, MeasureSpec.EXACTLY)
136
+ val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
137
+ slot.measure(widthSpec, heightSpec)
138
+ val intrinsicHeightPx = slot.measuredHeight
139
+
140
+ if (intrinsicHeightPx == lastReportedHeightPx.get()) return
141
+ lastReportedHeightPx.set(intrinsicHeightPx)
142
+
143
+ val density = resources.displayMetrics.density
144
+ val heightDp = intrinsicHeightPx / density
145
+
146
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(ctx, id) ?: return
147
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
148
+ dispatcher.dispatchEvent(ContentSizeChangeEvent(surfaceId, id, heightDp.toDouble(), 0.0))
149
+ }
150
+
151
+ companion object {
152
+ private const val DELAYED_MEASURE_MS = 300L
153
+ }
154
+ }
155
+
156
+ // ── DigiaSlotViewManager ──────────────────────────────────────────────────────
157
+
158
+ internal class DigiaSlotViewManager : SimpleViewManager<DigiaSlotContainerView>() {
159
+
160
+ override fun getName(): String = VIEW_NAME
161
+
162
+ override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> =
163
+ mapOf("onContentSizeChange" to mapOf("registrationName" to "onContentSizeChange"))
164
+
165
+ override fun createViewInstance(context: ThemedReactContext): DigiaSlotContainerView {
166
+ val container = DigiaSlotContainerView(context)
167
+ container.rnContext = context
168
+ container.layoutParams = FrameLayout.LayoutParams(
169
+ FrameLayout.LayoutParams.MATCH_PARENT,
170
+ FrameLayout.LayoutParams.MATCH_PARENT,
171
+ )
172
+ return container
173
+ }
174
+
175
+ @ReactProp(name = "placementKey")
176
+ fun setPlacementKey(view: DigiaSlotContainerView, placementKey: String?) {
177
+ view.placementKey = placementKey.orEmpty()
178
+ }
179
+
180
+ companion object {
181
+ const val VIEW_NAME = "DigiaSlotView"
182
+ }
183
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * DigiaViewManager
3
+ *
4
+ * React Native ViewManager that exposes `DigiaHostView` (from the Digia Android SDK) as the native
5
+ * view behind the JS `<DigiaHostView>` component.
6
+ *
7
+ * The view hosts the `DigiaHost` composable which manages dialog and bottom-sheet overlays driven
8
+ * by Digia CEP plugins.
9
+ */
10
+ package com.digia.engage.rn
11
+
12
+ import android.content.Context
13
+ import android.widget.FrameLayout
14
+ import androidx.lifecycle.LifecycleOwner
15
+ import androidx.lifecycle.ViewModelStoreOwner
16
+ import androidx.lifecycle.setViewTreeLifecycleOwner
17
+ import androidx.lifecycle.setViewTreeViewModelStoreOwner
18
+ import androidx.savedstate.SavedStateRegistryOwner
19
+ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
20
+ import com.digia.engage.DigiaHostView
21
+ import com.facebook.react.uimanager.SimpleViewManager
22
+ import com.facebook.react.uimanager.ThemedReactContext
23
+
24
+ internal class DigiaViewManager : SimpleViewManager<DigiaHostView>() {
25
+
26
+ override fun getName(): String = VIEW_NAME
27
+
28
+ override fun createViewInstance(context: ThemedReactContext): DigiaHostView {
29
+ // Prefer the current Activity as the context provider so that the Compose
30
+ // runtime can access a proper LifecycleOwner / ViewModelStoreOwner.
31
+ val activityContext: Context = context.currentActivity ?: context
32
+
33
+ val view = DigiaHostView(activityContext)
34
+
35
+ // Explicitly wire the Android Architecture Component owners so that the
36
+ // Compose runtime (which uses ViewTree* APIs) works correctly regardless
37
+ // of React Native's internal view hierarchy wiring.
38
+ val activity = context.currentActivity
39
+ if (activity is LifecycleOwner) {
40
+ view.setViewTreeLifecycleOwner(activity)
41
+ }
42
+ if (activity is ViewModelStoreOwner) {
43
+ view.setViewTreeViewModelStoreOwner(activity)
44
+ }
45
+ if (activity is SavedStateRegistryOwner) {
46
+ view.setViewTreeSavedStateRegistryOwner(activity)
47
+ }
48
+
49
+ // React Native controls sizing via its layout system; fill whatever space
50
+ // the JS stylesheet allocates (typically StyleSheet.absoluteFill).
51
+ view.layoutParams =
52
+ FrameLayout.LayoutParams(
53
+ FrameLayout.LayoutParams.MATCH_PARENT,
54
+ FrameLayout.LayoutParams.MATCH_PARENT,
55
+ )
56
+
57
+ android.util.Log.d("DigiaHost", "[DigiaViewManager] RN-side NativeDigiaHostView instance created (id=${view.id})")
58
+ return view
59
+ }
60
+
61
+ companion object {
62
+ const val VIEW_NAME = "DigiaHostView"
63
+ }
64
+ }
@@ -71,7 +71,7 @@ android {
71
71
 
72
72
  dependencies {
73
73
  // Digia Engage Android library
74
- implementation 'tech.digia:engage:2.0.0'
74
+ implementation 'tech.digia:engage:2.1.0'
75
75
 
76
76
  // ── React Native ─────────────────────────────────────────────────────────
77
77
  // React Native is provided by the host app; mark as compileOnly so it is