@kontextso/sdk-react-native 3.3.0 → 3.4.0-rc.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.
@@ -86,4 +86,5 @@ dependencies {
86
86
  implementation "com.facebook.react:react-android"
87
87
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
88
88
  implementation "androidx.preference:preference:1.2.1"
89
+ implementation "com.google.android.gms:play-services-ads-identifier:18.0.1"
89
90
  }
@@ -1,2 +1,3 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
- </manifest>
2
+ <uses-permission android:name="com.google.android.gms.permission.AD_ID" />
3
+ </manifest>
@@ -1,16 +1,50 @@
1
1
  package so.kontext.react
2
2
 
3
3
  import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
4
6
  import android.media.AudioManager
5
7
  import androidx.preference.PreferenceManager
6
8
  import com.facebook.react.bridge.Arguments
7
9
  import com.facebook.react.bridge.Promise
8
10
  import com.facebook.react.bridge.ReactApplicationContext
9
11
  import com.facebook.react.bridge.WritableMap
12
+ import com.google.android.gms.ads.identifier.AdvertisingIdClient
13
+ import java.util.concurrent.Executors
10
14
 
11
15
 
12
16
  class RNKontextModuleImpl(private val reactContext: ReactApplicationContext) {
13
17
 
18
+ private val mainHandler = Handler(Looper.getMainLooper())
19
+ private val executor = Executors.newSingleThreadExecutor()
20
+
21
+ fun getAdvertisingId(promise: Promise?) {
22
+ if (promise == null) return
23
+ executor.execute {
24
+ val id = try {
25
+ val info = AdvertisingIdClient.getAdvertisingIdInfo(reactContext.applicationContext)
26
+ if (info.isLimitAdTrackingEnabled) null else info.id
27
+ } catch (_: Exception) {
28
+ null
29
+ }
30
+ mainHandler.post { promise.resolve(id) }
31
+ }
32
+ }
33
+
34
+ // Android has no IDFV equivalent — iOS only
35
+ fun getVendorId(promise: Promise?) {
36
+ promise?.resolve(null)
37
+ }
38
+
39
+ // Android has no ATT — iOS only
40
+ fun getTrackingAuthorizationStatus(promise: Promise?) {
41
+ promise?.resolve(4) // TrackingStatus.NotSupported
42
+ }
43
+
44
+ fun requestTrackingAuthorization(promise: Promise?) {
45
+ promise?.resolve(4) // TrackingStatus.NotSupported
46
+ }
47
+
14
48
  fun isSoundOn(promise: Promise?) {
15
49
  val audioManager = reactContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
16
50
  val current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
@@ -12,6 +12,22 @@ class RNKontextModule(reactContext: ReactApplicationContext) :
12
12
 
13
13
  override fun getName(): String = NAME
14
14
 
15
+ override fun getAdvertisingId(promise: Promise?) {
16
+ impl.getAdvertisingId(promise)
17
+ }
18
+
19
+ override fun getVendorId(promise: Promise?) {
20
+ impl.getVendorId(promise)
21
+ }
22
+
23
+ override fun getTrackingAuthorizationStatus(promise: Promise?) {
24
+ impl.getTrackingAuthorizationStatus(promise)
25
+ }
26
+
27
+ override fun requestTrackingAuthorization(promise: Promise?) {
28
+ impl.requestTrackingAuthorization(promise)
29
+ }
30
+
15
31
  override fun isSoundOn(promise: Promise?) {
16
32
  impl.isSoundOn(promise)
17
33
  }
@@ -14,6 +14,26 @@ class RNKontextModule(reactContext: ReactApplicationContext) :
14
14
 
15
15
  override fun getName(): String = NAME
16
16
 
17
+ @ReactMethod
18
+ fun getAdvertisingId(promise: Promise?) {
19
+ impl.getAdvertisingId(promise)
20
+ }
21
+
22
+ @ReactMethod
23
+ fun getVendorId(promise: Promise?) {
24
+ impl.getVendorId(promise)
25
+ }
26
+
27
+ @ReactMethod
28
+ fun getTrackingAuthorizationStatus(promise: Promise?) {
29
+ impl.getTrackingAuthorizationStatus(promise)
30
+ }
31
+
32
+ @ReactMethod
33
+ fun requestTrackingAuthorization(promise: Promise?) {
34
+ impl.requestTrackingAuthorization(promise)
35
+ }
36
+
17
37
  @ReactMethod
18
38
  fun isSoundOn(promise: Promise?) {
19
39
  impl.isSoundOn(promise)
package/dist/index.js CHANGED
@@ -57,14 +57,16 @@ function handleIframeMessage(handler, opts) {
57
57
  // src/formats/Format.tsx
58
58
  var import_sdk_react = require("@kontextso/sdk-react");
59
59
  var import_react2 = require("react");
60
- var import_react_native4 = require("react-native");
60
+ var import_react_native5 = require("react-native");
61
61
 
62
62
  // src/frame-webview.tsx
63
63
  var import_react = require("react");
64
+ var import_react_native = require("react-native");
64
65
  var import_react_native_webview = require("react-native-webview");
65
66
  var import_jsx_runtime = require("react/jsx-runtime");
66
67
  var FrameWebView = (0, import_react.forwardRef)(
67
68
  ({ iframeUrl, onMessage, style, onError, onLoad }, forwardedRef) => {
69
+ const isLoadedRef = (0, import_react.useRef)(false);
68
70
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
69
71
  import_react_native_webview.WebView,
70
72
  {
@@ -89,9 +91,25 @@ var FrameWebView = (0, import_react.forwardRef)(
89
91
  window.ReactNativeWebView.postMessage(JSON.stringify(event.data));
90
92
  }
91
93
  }, false);
94
+
92
95
  `,
93
- onError,
94
- onLoad
96
+ onError: (event) => onError(
97
+ new Error(`${event.nativeEvent.title}: ${event.nativeEvent.description} [${event.nativeEvent.url}]`),
98
+ true
99
+ ),
100
+ onLoad: () => {
101
+ isLoadedRef.current = true;
102
+ onLoad();
103
+ },
104
+ onShouldStartLoadWithRequest: (request) => {
105
+ if (!isLoadedRef.current || request.url.match(/^about:/) || request.url.startsWith(iframeUrl)) {
106
+ return true;
107
+ }
108
+ import_react_native.Linking.openURL(request.url).catch((err) => {
109
+ onError(new Error(`Failed to open URL: ${request.url} [${err.message}]`));
110
+ });
111
+ return false;
112
+ }
95
113
  }
96
114
  );
97
115
  }
@@ -99,11 +117,11 @@ var FrameWebView = (0, import_react.forwardRef)(
99
117
  var frame_webview_default = FrameWebView;
100
118
 
101
119
  // src/services/SkOverlay.ts
102
- var import_react_native2 = require("react-native");
120
+ var import_react_native3 = require("react-native");
103
121
 
104
122
  // src/NativeRNKontext.ts
105
- var import_react_native = require("react-native");
106
- var NativeRNKontext_default = import_react_native.TurboModuleRegistry.getEnforcing("RNKontext");
123
+ var import_react_native2 = require("react-native");
124
+ var NativeRNKontext_default = import_react_native2.TurboModuleRegistry.getEnforcing("RNKontext");
107
125
 
108
126
  // src/services/utils.ts
109
127
  var isValidAppStoreId = (id) => {
@@ -115,7 +133,7 @@ var isValidPosition = (p) => {
115
133
  return p === "bottom" || p === "bottomRaised";
116
134
  };
117
135
  async function presentSKOverlay(params) {
118
- if (import_react_native2.Platform.OS !== "ios") {
136
+ if (import_react_native3.Platform.OS !== "ios") {
119
137
  return false;
120
138
  }
121
139
  let { appStoreId, position, dismissible } = params;
@@ -131,16 +149,16 @@ async function presentSKOverlay(params) {
131
149
  return NativeRNKontext_default.presentSKOverlay(appStoreId, position, dismissible);
132
150
  }
133
151
  async function dismissSKOverlay() {
134
- if (import_react_native2.Platform.OS !== "ios") {
152
+ if (import_react_native3.Platform.OS !== "ios") {
135
153
  return false;
136
154
  }
137
155
  return NativeRNKontext_default.dismissSKOverlay();
138
156
  }
139
157
 
140
158
  // src/services/SkStoreProduct.ts
141
- var import_react_native3 = require("react-native");
159
+ var import_react_native4 = require("react-native");
142
160
  async function presentSKStoreProduct(appStoreId) {
143
- if (import_react_native3.Platform.OS !== "ios") {
161
+ if (import_react_native4.Platform.OS !== "ios") {
144
162
  return false;
145
163
  }
146
164
  if (!isValidAppStoreId(appStoreId)) {
@@ -149,7 +167,7 @@ async function presentSKStoreProduct(appStoreId) {
149
167
  return NativeRNKontext_default.presentSKStoreProduct(appStoreId);
150
168
  }
151
169
  async function dismissSKStoreProduct() {
152
- if (import_react_native3.Platform.OS !== "ios") {
170
+ if (import_react_native4.Platform.OS !== "ios") {
153
171
  return false;
154
172
  }
155
173
  return NativeRNKontext_default.dismissSKStoreProduct();
@@ -194,7 +212,7 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
194
212
  const messageStatusRef = (0, import_react2.useRef)("none" /* None */);
195
213
  const modalInitTimeoutRef = (0, import_react2.useRef)(null);
196
214
  const isModalInitRef = (0, import_react2.useRef)(false);
197
- const { height: windowHeight, width: windowWidth } = (0, import_react_native4.useWindowDimensions)();
215
+ const { height: windowHeight, width: windowWidth } = (0, import_react_native5.useWindowDimensions)();
198
216
  const keyboardHeightRef = (0, import_react2.useRef)(0);
199
217
  const isAdViewVisible = showIframe && iframeLoaded;
200
218
  const reset = () => {
@@ -205,7 +223,6 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
205
223
  setIframeLoaded(false);
206
224
  resetModal();
207
225
  context?.resetAll();
208
- context?.captureError(new Error("Processing iframe error"));
209
226
  };
210
227
  const resetModal = () => {
211
228
  if (modalInitTimeoutRef.current) {
@@ -316,7 +333,7 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
316
333
  return;
317
334
  }
318
335
  try {
319
- await import_react_native4.Linking.openURL(`${context?.adServerUrl}${message.data.url}`);
336
+ await import_react_native5.Linking.openURL(`${context?.adServerUrl}${message.data.url}`);
320
337
  } catch (e) {
321
338
  console.error("error opening url", e);
322
339
  }
@@ -451,7 +468,6 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
451
468
  case "error-component-iframe":
452
469
  case "error-iframe":
453
470
  resetModal();
454
- context?.captureError(new Error("Processing modal iframe error"));
455
471
  break;
456
472
  case "open-skoverlay-iframe":
457
473
  openSkOverlay(
@@ -550,10 +566,10 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
550
566
  return () => clearInterval(interval);
551
567
  }, [isAdViewVisible]);
552
568
  (0, import_react2.useEffect)(() => {
553
- const showSubscription = import_react_native4.Keyboard.addListener("keyboardDidShow", (e) => {
569
+ const showSubscription = import_react_native5.Keyboard.addListener("keyboardDidShow", (e) => {
554
570
  keyboardHeightRef.current = e?.endCoordinates?.height ?? 0;
555
571
  });
556
- const hideSubscription = import_react_native4.Keyboard.addListener("keyboardDidHide", () => {
572
+ const hideSubscription = import_react_native5.Keyboard.addListener("keyboardDidHide", () => {
557
573
  keyboardHeightRef.current = 0;
558
574
  });
559
575
  return () => {
@@ -578,9 +594,14 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
578
594
  borderWidth: 0,
579
595
  ...iframeStyles
580
596
  },
581
- onError: () => {
582
- debug("iframe-error");
583
- reset();
597
+ onError: (error, shouldReset) => {
598
+ debug("iframe-error", {
599
+ error: error.toString()
600
+ });
601
+ context?.captureError(error);
602
+ if (shouldReset) {
603
+ reset();
604
+ }
584
605
  },
585
606
  onLoad: () => {
586
607
  debug("iframe-load");
@@ -589,7 +610,7 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
589
610
  }
590
611
  );
591
612
  const interstitialContent = /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
592
- import_react_native4.Modal,
613
+ import_react_native5.Modal,
593
614
  {
594
615
  visible: modalOpen,
595
616
  transparent: true,
@@ -597,7 +618,7 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
597
618
  animationType: "slide",
598
619
  statusBarTranslucent: true,
599
620
  children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
600
- import_react_native4.View,
621
+ import_react_native5.View,
601
622
  {
602
623
  style: {
603
624
  flex: 1,
@@ -616,9 +637,14 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
616
637
  width: "100%",
617
638
  borderWidth: 0
618
639
  },
619
- onError: () => {
620
- debug("modal-error");
621
- resetModal();
640
+ onError: (error, shouldReset) => {
641
+ debug("modal-error", {
642
+ error: error.toString()
643
+ });
644
+ context?.captureError(error);
645
+ if (shouldReset) {
646
+ resetModal();
647
+ }
622
648
  },
623
649
  onLoad: () => {
624
650
  debug("modal-load");
@@ -632,7 +658,7 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
632
658
  );
633
659
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
634
660
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
635
- import_react_native4.View,
661
+ import_react_native5.View,
636
662
  {
637
663
  style: isAdViewVisible ? containerStyles : {
638
664
  height: 0,
@@ -658,13 +684,92 @@ var InlineAd_default = InlineAd;
658
684
  // src/context/AdsProvider.tsx
659
685
  var import_sdk_react2 = require("@kontextso/sdk-react");
660
686
  var import_netinfo = require("@react-native-community/netinfo");
661
- var import_react_native5 = require("react-native");
687
+ var import_react_native7 = require("react-native");
662
688
  var import_react_native_device_info = __toESM(require("react-native-device-info"));
663
689
 
664
690
  // package.json
665
- var version = "3.3.0";
691
+ var version = "3.4.0-rc.0";
692
+
693
+ // src/services/Att.ts
694
+ var import_react_native6 = require("react-native");
695
+ var ZERO_UUID = "00000000-0000-0000-0000-000000000000";
696
+ var didRunStartupFlow = false;
697
+ var startupFlowPromise = null;
698
+ function requestTrackingAuthorization() {
699
+ if (didRunStartupFlow) {
700
+ return startupFlowPromise ?? Promise.resolve();
701
+ }
702
+ didRunStartupFlow = true;
703
+ startupFlowPromise = _runStartupFlow();
704
+ return startupFlowPromise;
705
+ }
706
+ async function getTrackingAuthorizationStatus() {
707
+ if (import_react_native6.Platform.OS !== "ios") {
708
+ return 4 /* NotSupported */;
709
+ }
710
+ try {
711
+ const raw = await NativeRNKontext_default.getTrackingAuthorizationStatus();
712
+ return _mapRawStatus(raw);
713
+ } catch (error) {
714
+ console.error(error);
715
+ return 4 /* NotSupported */;
716
+ }
717
+ }
718
+ async function getAdvertisingId() {
719
+ try {
720
+ const id = await NativeRNKontext_default.getAdvertisingId();
721
+ return _normalizeId(id);
722
+ } catch (error) {
723
+ console.error(error);
724
+ return null;
725
+ }
726
+ }
727
+ async function getVendorId() {
728
+ if (import_react_native6.Platform.OS !== "ios") {
729
+ return null;
730
+ }
731
+ try {
732
+ const id = await NativeRNKontext_default.getVendorId();
733
+ return _normalizeId(id);
734
+ } catch (error) {
735
+ console.error(error);
736
+ return null;
737
+ }
738
+ }
739
+ async function resolveIds(fallbacks) {
740
+ const [vendorId, advertisingId] = await Promise.all([getVendorId(), getAdvertisingId()]);
741
+ return {
742
+ vendorId: vendorId ?? _normalizeId(fallbacks?.vendorId),
743
+ advertisingId: advertisingId ?? _normalizeId(fallbacks?.advertisingId)
744
+ };
745
+ }
746
+ async function _runStartupFlow() {
747
+ if (import_react_native6.Platform.OS !== "ios") return;
748
+ try {
749
+ if (!_isVersionAtLeast145(import_react_native6.Platform.Version)) return;
750
+ const status = await getTrackingAuthorizationStatus();
751
+ if (status !== 0 /* NotDetermined */) return;
752
+ await NativeRNKontext_default.requestTrackingAuthorization();
753
+ } catch (error) {
754
+ console.error(error);
755
+ }
756
+ }
757
+ function _isVersionAtLeast145(version2) {
758
+ const [major = 0, minor = 0] = version2.split(".").map(Number);
759
+ return major > 14 || major === 14 && minor >= 5;
760
+ }
761
+ function _mapRawStatus(raw) {
762
+ if (raw >= 0 && raw <= 4) return raw;
763
+ return 4 /* NotSupported */;
764
+ }
765
+ function _normalizeId(id) {
766
+ if (!id || id.trim() === "") return null;
767
+ if (id.toLowerCase() === ZERO_UUID) return null;
768
+ return id;
769
+ }
666
770
 
667
771
  // src/context/AdsProvider.tsx
772
+ var import_react3 = require("react");
668
773
  var import_jsx_runtime4 = require("react/jsx-runtime");
669
774
  ErrorUtils.setGlobalHandler((error, isFatal) => {
670
775
  if (!isFatal) {
@@ -683,7 +788,7 @@ var getDevice = async () => {
683
788
  const powerState = await import_react_native_device_info.default.getPowerState();
684
789
  const deviceType = import_react_native_device_info.default.getDeviceType();
685
790
  const soundOn = await NativeRNKontext_default.isSoundOn();
686
- const screen = import_react_native5.Dimensions.get("screen");
791
+ const screen = import_react_native7.Dimensions.get("screen");
687
792
  const networkInfo = await (0, import_netinfo.fetch)();
688
793
  const mapDeviceTypeToHardwareType = () => {
689
794
  switch (deviceType) {
@@ -720,14 +825,14 @@ var getDevice = async () => {
720
825
  detail: networkInfo.type === import_netinfo.NetInfoStateType.cellular && networkInfo.details.cellularGeneration || void 0
721
826
  },
722
827
  os: {
723
- name: import_react_native5.Platform.OS,
828
+ name: import_react_native7.Platform.OS,
724
829
  version: import_react_native_device_info.default.getSystemVersion(),
725
830
  locale: Intl.DateTimeFormat().resolvedOptions().locale,
726
831
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
727
832
  },
728
833
  screen: {
729
- darkMode: import_react_native5.Appearance.getColorScheme() === "dark",
730
- dpr: import_react_native5.PixelRatio.get(),
834
+ darkMode: import_react_native7.Appearance.getColorScheme() === "dark",
835
+ dpr: import_react_native7.PixelRatio.get(),
731
836
  height: screen.height,
732
837
  width: screen.width,
733
838
  orientation: screen.width > screen.height ? "landscape" : "portrait"
@@ -760,7 +865,7 @@ var getApp = async () => {
760
865
  };
761
866
  var getSdk = async () => ({
762
867
  name: "sdk-react-native",
763
- platform: import_react_native5.Platform.OS === "ios" ? "ios" : "android",
868
+ platform: import_react_native7.Platform.OS === "ios" ? "ios" : "android",
764
869
  version
765
870
  });
766
871
  var getTcf = async () => {
@@ -778,7 +883,38 @@ var getTcf = async () => {
778
883
  }
779
884
  };
780
885
  var AdsProvider = (props) => {
781
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sdk_react2.AdsProviderInternal, { ...props, getDevice, getSdk, getApp, getTcf });
886
+ const [advertisingId, setAdvertisingId] = (0, import_react3.useState)();
887
+ const [vendorId, setVendorId] = (0, import_react3.useState)();
888
+ const initializeIfa = async () => {
889
+ try {
890
+ if (import_react_native7.Platform.OS === "ios") {
891
+ await requestTrackingAuthorization();
892
+ }
893
+ const ids = await resolveIds({
894
+ advertisingId: props.advertisingId ?? void 0,
895
+ vendorId: props.vendorId ?? void 0
896
+ });
897
+ setAdvertisingId(ids.advertisingId);
898
+ setVendorId(ids.vendorId);
899
+ } catch (error) {
900
+ console.error(error);
901
+ }
902
+ };
903
+ (0, import_react3.useEffect)(() => {
904
+ initializeIfa();
905
+ }, []);
906
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
907
+ import_sdk_react2.AdsProviderInternal,
908
+ {
909
+ ...props,
910
+ advertisingId,
911
+ vendorId,
912
+ getDevice,
913
+ getSdk,
914
+ getApp,
915
+ getTcf
916
+ }
917
+ );
782
918
  };
783
919
  // Annotate the CommonJS export names for ESM import in node:
784
920
  0 && (module.exports = {
package/dist/index.mjs CHANGED
@@ -25,15 +25,17 @@ import {
25
25
  useBid,
26
26
  useIframeUrl
27
27
  } from "@kontextso/sdk-react";
28
- import { useContext, useEffect, useRef, useState } from "react";
29
- import { Keyboard, Linking, Modal, useWindowDimensions, View } from "react-native";
28
+ import { useContext, useEffect, useRef as useRef2, useState } from "react";
29
+ import { Keyboard, Linking as Linking2, Modal, useWindowDimensions, View } from "react-native";
30
30
 
31
31
  // src/frame-webview.tsx
32
- import { forwardRef } from "react";
32
+ import { forwardRef, useRef } from "react";
33
+ import { Linking } from "react-native";
33
34
  import { WebView } from "react-native-webview";
34
35
  import { jsx } from "react/jsx-runtime";
35
36
  var FrameWebView = forwardRef(
36
37
  ({ iframeUrl, onMessage, style, onError, onLoad }, forwardedRef) => {
38
+ const isLoadedRef = useRef(false);
37
39
  return /* @__PURE__ */ jsx(
38
40
  WebView,
39
41
  {
@@ -58,9 +60,25 @@ var FrameWebView = forwardRef(
58
60
  window.ReactNativeWebView.postMessage(JSON.stringify(event.data));
59
61
  }
60
62
  }, false);
63
+
61
64
  `,
62
- onError,
63
- onLoad
65
+ onError: (event) => onError(
66
+ new Error(`${event.nativeEvent.title}: ${event.nativeEvent.description} [${event.nativeEvent.url}]`),
67
+ true
68
+ ),
69
+ onLoad: () => {
70
+ isLoadedRef.current = true;
71
+ onLoad();
72
+ },
73
+ onShouldStartLoadWithRequest: (request) => {
74
+ if (!isLoadedRef.current || request.url.match(/^about:/) || request.url.startsWith(iframeUrl)) {
75
+ return true;
76
+ }
77
+ Linking.openURL(request.url).catch((err) => {
78
+ onError(new Error(`Failed to open URL: ${request.url} [${err.message}]`));
79
+ });
80
+ return false;
81
+ }
64
82
  }
65
83
  );
66
84
  }
@@ -157,14 +175,14 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
157
175
  const [modalLoaded, setModalLoaded] = useState(false);
158
176
  const [containerStyles, setContainerStyles] = useState({});
159
177
  const [iframeStyles, setIframeStyles] = useState({});
160
- const containerRef = useRef(null);
161
- const webViewRef = useRef(null);
162
- const modalWebViewRef = useRef(null);
163
- const messageStatusRef = useRef("none" /* None */);
164
- const modalInitTimeoutRef = useRef(null);
165
- const isModalInitRef = useRef(false);
178
+ const containerRef = useRef2(null);
179
+ const webViewRef = useRef2(null);
180
+ const modalWebViewRef = useRef2(null);
181
+ const messageStatusRef = useRef2("none" /* None */);
182
+ const modalInitTimeoutRef = useRef2(null);
183
+ const isModalInitRef = useRef2(false);
166
184
  const { height: windowHeight, width: windowWidth } = useWindowDimensions();
167
- const keyboardHeightRef = useRef(0);
185
+ const keyboardHeightRef = useRef2(0);
168
186
  const isAdViewVisible = showIframe && iframeLoaded;
169
187
  const reset = () => {
170
188
  setHeight(0);
@@ -174,7 +192,6 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
174
192
  setIframeLoaded(false);
175
193
  resetModal();
176
194
  context?.resetAll();
177
- context?.captureError(new Error("Processing iframe error"));
178
195
  };
179
196
  const resetModal = () => {
180
197
  if (modalInitTimeoutRef.current) {
@@ -285,7 +302,7 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
285
302
  return;
286
303
  }
287
304
  try {
288
- await Linking.openURL(`${context?.adServerUrl}${message.data.url}`);
305
+ await Linking2.openURL(`${context?.adServerUrl}${message.data.url}`);
289
306
  } catch (e) {
290
307
  console.error("error opening url", e);
291
308
  }
@@ -420,7 +437,6 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
420
437
  case "error-component-iframe":
421
438
  case "error-iframe":
422
439
  resetModal();
423
- context?.captureError(new Error("Processing modal iframe error"));
424
440
  break;
425
441
  case "open-skoverlay-iframe":
426
442
  openSkOverlay(
@@ -547,9 +563,14 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
547
563
  borderWidth: 0,
548
564
  ...iframeStyles
549
565
  },
550
- onError: () => {
551
- debug("iframe-error");
552
- reset();
566
+ onError: (error, shouldReset) => {
567
+ debug("iframe-error", {
568
+ error: error.toString()
569
+ });
570
+ context?.captureError(error);
571
+ if (shouldReset) {
572
+ reset();
573
+ }
553
574
  },
554
575
  onLoad: () => {
555
576
  debug("iframe-load");
@@ -585,9 +606,14 @@ var Format = ({ code, messageId, wrapper, onEvent, ...otherParams }) => {
585
606
  width: "100%",
586
607
  borderWidth: 0
587
608
  },
588
- onError: () => {
589
- debug("modal-error");
590
- resetModal();
609
+ onError: (error, shouldReset) => {
610
+ debug("modal-error", {
611
+ error: error.toString()
612
+ });
613
+ context?.captureError(error);
614
+ if (shouldReset) {
615
+ resetModal();
616
+ }
591
617
  },
592
618
  onLoad: () => {
593
619
  debug("modal-load");
@@ -630,13 +656,92 @@ import {
630
656
  log
631
657
  } from "@kontextso/sdk-react";
632
658
  import { fetch as fetchNetworkInfo, NetInfoStateType } from "@react-native-community/netinfo";
633
- import { Appearance, Dimensions, PixelRatio, Platform as Platform3 } from "react-native";
659
+ import { Appearance, Dimensions, PixelRatio, Platform as Platform4 } from "react-native";
634
660
  import DeviceInfo from "react-native-device-info";
635
661
 
636
662
  // package.json
637
- var version = "3.3.0";
663
+ var version = "3.4.0-rc.0";
664
+
665
+ // src/services/Att.ts
666
+ import { Platform as Platform3 } from "react-native";
667
+ var ZERO_UUID = "00000000-0000-0000-0000-000000000000";
668
+ var didRunStartupFlow = false;
669
+ var startupFlowPromise = null;
670
+ function requestTrackingAuthorization() {
671
+ if (didRunStartupFlow) {
672
+ return startupFlowPromise ?? Promise.resolve();
673
+ }
674
+ didRunStartupFlow = true;
675
+ startupFlowPromise = _runStartupFlow();
676
+ return startupFlowPromise;
677
+ }
678
+ async function getTrackingAuthorizationStatus() {
679
+ if (Platform3.OS !== "ios") {
680
+ return 4 /* NotSupported */;
681
+ }
682
+ try {
683
+ const raw = await NativeRNKontext_default.getTrackingAuthorizationStatus();
684
+ return _mapRawStatus(raw);
685
+ } catch (error) {
686
+ console.error(error);
687
+ return 4 /* NotSupported */;
688
+ }
689
+ }
690
+ async function getAdvertisingId() {
691
+ try {
692
+ const id = await NativeRNKontext_default.getAdvertisingId();
693
+ return _normalizeId(id);
694
+ } catch (error) {
695
+ console.error(error);
696
+ return null;
697
+ }
698
+ }
699
+ async function getVendorId() {
700
+ if (Platform3.OS !== "ios") {
701
+ return null;
702
+ }
703
+ try {
704
+ const id = await NativeRNKontext_default.getVendorId();
705
+ return _normalizeId(id);
706
+ } catch (error) {
707
+ console.error(error);
708
+ return null;
709
+ }
710
+ }
711
+ async function resolveIds(fallbacks) {
712
+ const [vendorId, advertisingId] = await Promise.all([getVendorId(), getAdvertisingId()]);
713
+ return {
714
+ vendorId: vendorId ?? _normalizeId(fallbacks?.vendorId),
715
+ advertisingId: advertisingId ?? _normalizeId(fallbacks?.advertisingId)
716
+ };
717
+ }
718
+ async function _runStartupFlow() {
719
+ if (Platform3.OS !== "ios") return;
720
+ try {
721
+ if (!_isVersionAtLeast145(Platform3.Version)) return;
722
+ const status = await getTrackingAuthorizationStatus();
723
+ if (status !== 0 /* NotDetermined */) return;
724
+ await NativeRNKontext_default.requestTrackingAuthorization();
725
+ } catch (error) {
726
+ console.error(error);
727
+ }
728
+ }
729
+ function _isVersionAtLeast145(version2) {
730
+ const [major = 0, minor = 0] = version2.split(".").map(Number);
731
+ return major > 14 || major === 14 && minor >= 5;
732
+ }
733
+ function _mapRawStatus(raw) {
734
+ if (raw >= 0 && raw <= 4) return raw;
735
+ return 4 /* NotSupported */;
736
+ }
737
+ function _normalizeId(id) {
738
+ if (!id || id.trim() === "") return null;
739
+ if (id.toLowerCase() === ZERO_UUID) return null;
740
+ return id;
741
+ }
638
742
 
639
743
  // src/context/AdsProvider.tsx
744
+ import { useEffect as useEffect2, useState as useState2 } from "react";
640
745
  import { jsx as jsx4 } from "react/jsx-runtime";
641
746
  ErrorUtils.setGlobalHandler((error, isFatal) => {
642
747
  if (!isFatal) {
@@ -692,7 +797,7 @@ var getDevice = async () => {
692
797
  detail: networkInfo.type === NetInfoStateType.cellular && networkInfo.details.cellularGeneration || void 0
693
798
  },
694
799
  os: {
695
- name: Platform3.OS,
800
+ name: Platform4.OS,
696
801
  version: DeviceInfo.getSystemVersion(),
697
802
  locale: Intl.DateTimeFormat().resolvedOptions().locale,
698
803
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -732,7 +837,7 @@ var getApp = async () => {
732
837
  };
733
838
  var getSdk = async () => ({
734
839
  name: "sdk-react-native",
735
- platform: Platform3.OS === "ios" ? "ios" : "android",
840
+ platform: Platform4.OS === "ios" ? "ios" : "android",
736
841
  version
737
842
  });
738
843
  var getTcf = async () => {
@@ -750,7 +855,38 @@ var getTcf = async () => {
750
855
  }
751
856
  };
752
857
  var AdsProvider = (props) => {
753
- return /* @__PURE__ */ jsx4(AdsProviderInternal, { ...props, getDevice, getSdk, getApp, getTcf });
858
+ const [advertisingId, setAdvertisingId] = useState2();
859
+ const [vendorId, setVendorId] = useState2();
860
+ const initializeIfa = async () => {
861
+ try {
862
+ if (Platform4.OS === "ios") {
863
+ await requestTrackingAuthorization();
864
+ }
865
+ const ids = await resolveIds({
866
+ advertisingId: props.advertisingId ?? void 0,
867
+ vendorId: props.vendorId ?? void 0
868
+ });
869
+ setAdvertisingId(ids.advertisingId);
870
+ setVendorId(ids.vendorId);
871
+ } catch (error) {
872
+ console.error(error);
873
+ }
874
+ };
875
+ useEffect2(() => {
876
+ initializeIfa();
877
+ }, []);
878
+ return /* @__PURE__ */ jsx4(
879
+ AdsProviderInternal,
880
+ {
881
+ ...props,
882
+ advertisingId,
883
+ vendorId,
884
+ getDevice,
885
+ getSdk,
886
+ getApp,
887
+ getTcf
888
+ }
889
+ );
754
890
  };
755
891
  export {
756
892
  AdsProvider,
@@ -3,6 +3,8 @@ import AVFoundation
3
3
  import StoreKit
4
4
  import UIKit
5
5
  import React
6
+ import AdSupport
7
+ import AppTrackingTransparency
6
8
 
7
9
  @objc(KontextSDK)
8
10
  public class KontextSDK: NSObject {
@@ -134,4 +136,49 @@ public class KontextSDK: NSObject {
134
136
  return out
135
137
  }
136
138
 
139
+ // MARK: - ATT / IFA
140
+
141
+ private static let notSupportedStatus = 4
142
+
143
+ @objc
144
+ public static func getTrackingAuthorizationStatus() -> NSNumber {
145
+ if #available(iOS 14, *) {
146
+ return NSNumber(value: ATTrackingManager.trackingAuthorizationStatus.rawValue)
147
+ }
148
+ return NSNumber(value: notSupportedStatus)
149
+ }
150
+
151
+ @objc
152
+ public static func requestTrackingAuthorization(
153
+ _ resolve: @escaping RCTPromiseResolveBlock,
154
+ rejecter reject: @escaping RCTPromiseRejectBlock
155
+ ) {
156
+ if #available(iOS 14, *) {
157
+ TrackingAuthorizationManager.shared.requestTrackingAuthorization { statusRaw in
158
+ resolve(NSNumber(value: statusRaw))
159
+ }
160
+ } else {
161
+ resolve(NSNumber(value: notSupportedStatus))
162
+ }
163
+ }
164
+
165
+ @objc
166
+ public static func getAdvertisingId() -> String? {
167
+ let manager = ASIdentifierManager.shared()
168
+ if #available(iOS 14.0, *) {
169
+ guard ATTrackingManager.trackingAuthorizationStatus == .authorized else {
170
+ return nil
171
+ }
172
+ return manager.advertisingIdentifier.uuidString
173
+ }
174
+ return manager.isAdvertisingTrackingEnabled
175
+ ? manager.advertisingIdentifier.uuidString
176
+ : nil
177
+ }
178
+
179
+ @objc
180
+ public static func getVendorId() -> String? {
181
+ return UIDevice.current.identifierForVendor?.uuidString
182
+ }
183
+
137
184
  }
@@ -0,0 +1,64 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+
6
+ <key>NSPrivacyTracking</key><true/>
7
+
8
+ <key>NSPrivacyTrackingDomains</key>
9
+ <array>
10
+ <string>megabrain.co</string>
11
+ </array>
12
+
13
+ <key>NSPrivacyCollectedDataTypes</key>
14
+ <array>
15
+
16
+ <dict>
17
+ <key>NSPrivacyCollectedDataType</key>
18
+ <string>NSPrivacyCollectedDataTypeDeviceID</string>
19
+ <key>NSPrivacyCollectedDataTypeLinked</key><true/>
20
+ <key>NSPrivacyCollectedDataTypeTracking</key><true/>
21
+ <key>NSPrivacyCollectedDataTypePurposes</key>
22
+ <array>
23
+ <string>NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising</string>
24
+ </array>
25
+ </dict>
26
+
27
+ <dict>
28
+ <key>NSPrivacyCollectedDataType</key>
29
+ <string>NSPrivacyCollectedDataTypeOtherAppInfo</string>
30
+ <key>NSPrivacyCollectedDataTypeLinked</key><false/>
31
+ <key>NSPrivacyCollectedDataTypeTracking</key><false/>
32
+ <key>NSPrivacyCollectedDataTypePurposes</key>
33
+ <array>
34
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
35
+ </array>
36
+ </dict>
37
+
38
+ <dict>
39
+ <key>NSPrivacyCollectedDataType</key>
40
+ <string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
41
+ <key>NSPrivacyCollectedDataTypeLinked</key><false/>
42
+ <key>NSPrivacyCollectedDataTypeTracking</key><false/>
43
+ <key>NSPrivacyCollectedDataTypePurposes</key>
44
+ <array>
45
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
46
+ </array>
47
+ </dict>
48
+
49
+ </array>
50
+
51
+ <key>NSPrivacyAccessedAPITypes</key>
52
+ <array>
53
+ <dict>
54
+ <key>NSPrivacyAccessedAPIType</key>
55
+ <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
56
+ <key>NSPrivacyAccessedAPITypeReasons</key>
57
+ <array>
58
+ <string>CA92.1</string>
59
+ </array>
60
+ </dict>
61
+ </array>
62
+
63
+ </dict>
64
+ </plist>
package/ios/RNKontext.mm CHANGED
@@ -56,6 +56,26 @@ RCT_EXPORT_MODULE()
56
56
  resolve([KontextSDK getTcfData]);
57
57
  }
58
58
 
59
+ - (void)getTrackingAuthorizationStatus:(RCTPromiseResolveBlock)resolve
60
+ reject:(RCTPromiseRejectBlock)reject {
61
+ resolve([KontextSDK getTrackingAuthorizationStatus]);
62
+ }
63
+
64
+ - (void)requestTrackingAuthorization:(RCTPromiseResolveBlock)resolve
65
+ reject:(RCTPromiseRejectBlock)reject {
66
+ [KontextSDK requestTrackingAuthorization:resolve rejecter:reject];
67
+ }
68
+
69
+ - (void)getAdvertisingId:(RCTPromiseResolveBlock)resolve
70
+ reject:(RCTPromiseRejectBlock)reject {
71
+ resolve([KontextSDK getAdvertisingId]);
72
+ }
73
+
74
+ - (void)getVendorId:(RCTPromiseResolveBlock)resolve
75
+ reject:(RCTPromiseRejectBlock)reject {
76
+ resolve([KontextSDK getVendorId]);
77
+ }
78
+
59
79
  #else
60
80
 
61
81
  RCT_EXPORT_METHOD(isSoundOn : (RCTPromiseResolveBlock)resolve
@@ -103,6 +123,26 @@ RCT_EXPORT_METHOD(getTcfData:(RCTPromiseResolveBlock)resolve
103
123
  resolve([KontextSDK getTcfData]);
104
124
  }
105
125
 
126
+ RCT_EXPORT_METHOD(getTrackingAuthorizationStatus:(RCTPromiseResolveBlock)resolve
127
+ rejecter:(RCTPromiseRejectBlock)reject) {
128
+ resolve([KontextSDK getTrackingAuthorizationStatus]);
129
+ }
130
+
131
+ RCT_EXPORT_METHOD(requestTrackingAuthorization:(RCTPromiseResolveBlock)resolve
132
+ rejecter:(RCTPromiseRejectBlock)reject) {
133
+ [KontextSDK requestTrackingAuthorization:resolve rejecter:reject];
134
+ }
135
+
136
+ RCT_EXPORT_METHOD(getAdvertisingId:(RCTPromiseResolveBlock)resolve
137
+ rejecter:(RCTPromiseRejectBlock)reject) {
138
+ resolve([KontextSDK getAdvertisingId]);
139
+ }
140
+
141
+ RCT_EXPORT_METHOD(getVendorId:(RCTPromiseResolveBlock)resolve
142
+ rejecter:(RCTPromiseRejectBlock)reject) {
143
+ resolve([KontextSDK getVendorId]);
144
+ }
145
+
106
146
  #endif
107
147
 
108
148
  @end
@@ -0,0 +1,46 @@
1
+ import Foundation
2
+ import AppTrackingTransparency
3
+ import UIKit
4
+
5
+ @available(iOS 14, *)
6
+ final class TrackingAuthorizationManager {
7
+ static let shared = TrackingAuthorizationManager()
8
+ private var observer: NSObjectProtocol?
9
+
10
+ deinit {
11
+ removeObserver()
12
+ }
13
+
14
+ func requestTrackingAuthorization(completion: @escaping (Int) -> Void) {
15
+ removeObserver()
16
+ ATTrackingManager.requestTrackingAuthorization { [weak self] status in
17
+ // Race condition: OS returned .denied but system status is still
18
+ // .notDetermined — the dialog couldn't show (app wasn't fully active).
19
+ // Wait for the next foreground activation and retry.
20
+ if status == .denied
21
+ && ATTrackingManager.trackingAuthorizationStatus == .notDetermined {
22
+ self?.addObserver(completion: completion)
23
+ return
24
+ }
25
+ completion(Int(status.rawValue))
26
+ }
27
+ }
28
+
29
+ private func addObserver(completion: @escaping (Int) -> Void) {
30
+ removeObserver()
31
+ observer = NotificationCenter.default.addObserver(
32
+ forName: UIApplication.didBecomeActiveNotification,
33
+ object: nil,
34
+ queue: .main
35
+ ) { [weak self] _ in
36
+ self?.requestTrackingAuthorization(completion: completion)
37
+ }
38
+ }
39
+
40
+ private func removeObserver() {
41
+ if let observer = observer {
42
+ NotificationCenter.default.removeObserver(observer)
43
+ self.observer = nil
44
+ }
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextso/sdk-react-native",
3
- "version": "3.3.0",
3
+ "version": "3.4.0-rc.0",
4
4
  "description": "Kontext SDK for React Native",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -54,7 +54,7 @@
54
54
  "react-native-webview": "^13.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@kontextso/sdk-react": "^3.0.7"
57
+ "@kontextso/sdk-react": "^3.0.8-rc.0"
58
58
  },
59
59
  "files": [
60
60
  "dist/*",
@@ -3,11 +3,25 @@ import { TurboModuleRegistry } from 'react-native'
3
3
 
4
4
  export interface Spec extends TurboModule {
5
5
  isSoundOn(): Promise<boolean>
6
+
7
+ // SKOverlay
6
8
  presentSKOverlay(appStoreId: string, position: string, dismissible: boolean): Promise<boolean>
7
9
  dismissSKOverlay(): Promise<boolean>
10
+
11
+ // SKStoreProduct
8
12
  presentSKStoreProduct(appStoreId: string): Promise<boolean>
9
13
  dismissSKStoreProduct(): Promise<boolean>
14
+
15
+ // TCF
10
16
  getTcfData(): Promise<{ gdprApplies: 0 | 1 | null; tcString: string | null }>
17
+
18
+ // ATT
19
+ getTrackingAuthorizationStatus(): Promise<number>
20
+ requestTrackingAuthorization(): Promise<number>
21
+
22
+ // IFA
23
+ getAdvertisingId(): Promise<string | null>
24
+ getVendorId(): Promise<string | null>
11
25
  }
12
26
 
13
27
  export default TurboModuleRegistry.getEnforcing<Spec>('RNKontext')
@@ -12,6 +12,8 @@ import { Appearance, Dimensions, PixelRatio, Platform } from 'react-native'
12
12
  import DeviceInfo, { type DeviceType } from 'react-native-device-info'
13
13
  import { version } from '../../package.json'
14
14
  import NativeRNKontext from '../NativeRNKontext'
15
+ import { requestTrackingAuthorization, resolveIds } from '../services/Att'
16
+ import { useEffect, useState } from 'react'
15
17
 
16
18
  ErrorUtils.setGlobalHandler((error, isFatal) => {
17
19
  if (!isFatal) {
@@ -142,5 +144,35 @@ const getTcf = async (): Promise<Pick<RegulatoryConfig, 'gdpr' | 'gdprConsent'>>
142
144
  }
143
145
 
144
146
  export const AdsProvider = (props: AdsProviderProps) => {
145
- return <AdsProviderInternal {...props} getDevice={getDevice} getSdk={getSdk} getApp={getApp} getTcf={getTcf} />
147
+ const [advertisingId, setAdvertisingId] = useState<string | null | undefined>()
148
+ const [vendorId, setVendorId] = useState<string | null | undefined>()
149
+
150
+ const initializeIfa = async () => {
151
+ try {
152
+ if (Platform.OS === 'ios') {
153
+ await requestTrackingAuthorization()
154
+ }
155
+ const ids = await resolveIds({
156
+ advertisingId: props.advertisingId ?? undefined,
157
+ vendorId: props.vendorId ?? undefined,
158
+ })
159
+ setAdvertisingId(ids.advertisingId)
160
+ setVendorId(ids.vendorId)
161
+ } catch (error) {
162
+ console.error(error)
163
+ }
164
+ }
165
+
166
+ useEffect(() => {
167
+ initializeIfa()
168
+ }, [])
169
+ return <AdsProviderInternal
170
+ {...props}
171
+ advertisingId={advertisingId}
172
+ vendorId={vendorId}
173
+ getDevice={getDevice}
174
+ getSdk={getSdk}
175
+ getApp={getApp}
176
+ getTcf={getTcf}
177
+ />
146
178
  }
@@ -18,8 +18,8 @@ import { useContext, useEffect, useRef, useState } from 'react'
18
18
  import { Keyboard, Linking, Modal, useWindowDimensions, View } from 'react-native'
19
19
  import type { WebView, WebViewMessageEvent } from 'react-native-webview'
20
20
  import FrameWebView from '../frame-webview'
21
- import { presentSKOverlay, dismissSKOverlay, type SKOverlayPosition } from '../services/SkOverlay'
22
- import { presentSKStoreProduct, dismissSKStoreProduct } from '../services/SkStoreProduct'
21
+ import { dismissSKOverlay, presentSKOverlay, type SKOverlayPosition } from '../services/SkOverlay'
22
+ import { dismissSKStoreProduct, presentSKStoreProduct } from '../services/SkStoreProduct'
23
23
 
24
24
  const sendMessage = (
25
25
  webViewRef: React.RefObject<WebView>,
@@ -97,7 +97,6 @@ const Format = ({ code, messageId, wrapper, onEvent, ...otherParams }: FormatPro
97
97
  setIframeLoaded(false)
98
98
  resetModal()
99
99
  context?.resetAll()
100
- context?.captureError(new Error('Processing iframe error'))
101
100
  }
102
101
 
103
102
  const resetModal = () => {
@@ -380,7 +379,6 @@ const Format = ({ code, messageId, wrapper, onEvent, ...otherParams }: FormatPro
380
379
  case 'error-component-iframe':
381
380
  case 'error-iframe':
382
381
  resetModal()
383
- context?.captureError(new Error('Processing modal iframe error'))
384
382
  break
385
383
 
386
384
  case 'open-skoverlay-iframe':
@@ -532,9 +530,14 @@ const Format = ({ code, messageId, wrapper, onEvent, ...otherParams }: FormatPro
532
530
  borderWidth: 0,
533
531
  ...iframeStyles,
534
532
  }}
535
- onError={() => {
536
- debug('iframe-error')
537
- reset()
533
+ onError={(error, shouldReset) => {
534
+ debug('iframe-error', {
535
+ error: error.toString(),
536
+ })
537
+ context?.captureError(error)
538
+ if (shouldReset) {
539
+ reset()
540
+ }
538
541
  }}
539
542
  onLoad={() => {
540
543
  debug('iframe-load')
@@ -568,9 +571,14 @@ const Format = ({ code, messageId, wrapper, onEvent, ...otherParams }: FormatPro
568
571
  width: '100%',
569
572
  borderWidth: 0,
570
573
  }}
571
- onError={() => {
572
- debug('modal-error')
573
- resetModal()
574
+ onError={(error, shouldReset) => {
575
+ debug('modal-error', {
576
+ error: error.toString(),
577
+ })
578
+ context?.captureError(error)
579
+ if (shouldReset) {
580
+ resetModal()
581
+ }
574
582
  }}
575
583
  onLoad={() => {
576
584
  debug('modal-load')
@@ -1,17 +1,19 @@
1
- import { forwardRef } from 'react'
2
- import type { StyleProp, ViewStyle } from 'react-native'
1
+ import { forwardRef, useRef } from 'react'
2
+ import { Linking, type StyleProp, type ViewStyle } from 'react-native'
3
3
  import { WebView, type WebViewMessageEvent } from 'react-native-webview'
4
4
 
5
5
  interface FrameWebViewProps {
6
6
  iframeUrl: string
7
7
  onMessage: (event: WebViewMessageEvent) => void
8
8
  style: StyleProp<ViewStyle>
9
- onError: () => void
9
+ onError: (error: Error, shouldReset?: boolean) => void
10
10
  onLoad: () => void
11
11
  }
12
12
 
13
13
  const FrameWebView = forwardRef<WebView, FrameWebViewProps>(
14
14
  ({ iframeUrl, onMessage, style, onError, onLoad }, forwardedRef) => {
15
+ const isLoadedRef = useRef(false)
16
+
15
17
  return (
16
18
  <WebView
17
19
  ref={forwardedRef}
@@ -35,9 +37,28 @@ const FrameWebView = forwardRef<WebView, FrameWebViewProps>(
35
37
  window.ReactNativeWebView.postMessage(JSON.stringify(event.data));
36
38
  }
37
39
  }, false);
40
+
38
41
  `}
39
- onError={onError}
40
- onLoad={onLoad}
42
+ onError={(event) =>
43
+ onError(
44
+ new Error(`${event.nativeEvent.title}: ${event.nativeEvent.description} [${event.nativeEvent.url}]`),
45
+ true
46
+ )
47
+ }
48
+ onLoad={() => {
49
+ isLoadedRef.current = true
50
+ onLoad()
51
+ }}
52
+ onShouldStartLoadWithRequest={(request) => {
53
+ if (!isLoadedRef.current || request.url.match(/^about:/) || request.url.startsWith(iframeUrl)) {
54
+ return true
55
+ }
56
+
57
+ Linking.openURL(request.url).catch((err) => {
58
+ onError(new Error(`Failed to open URL: ${request.url} [${err.message}]`))
59
+ })
60
+ return false
61
+ }}
41
62
  />
42
63
  )
43
64
  }
@@ -0,0 +1,117 @@
1
+ import { Platform } from 'react-native'
2
+ import NativeRNKontext from '../NativeRNKontext'
3
+
4
+ const ZERO_UUID = '00000000-0000-0000-0000-000000000000'
5
+
6
+ // Mirrors TrackingStatus enum from Flutter / ATTrackingManager raw values
7
+ export enum TrackingStatus {
8
+ NotDetermined = 0,
9
+ Restricted = 1,
10
+ Denied = 2,
11
+ Authorized = 3,
12
+ NotSupported = 4, // iOS < 14 or non-iOS
13
+ }
14
+
15
+ let didRunStartupFlow = false
16
+ let startupFlowPromise: Promise<void> | null = null
17
+
18
+ /**
19
+ * Requests ATT authorization on iOS >= 14.5.
20
+ * On iOS 14.0–14.4 we intentionally skip the request: the dialog is not
21
+ * required to read IDFA on those versions, and asking only to get denied
22
+ * would permanently revoke access.
23
+ * Guaranteed to run only once per app session regardless of call count.
24
+ */
25
+ export function requestTrackingAuthorization(): Promise<void> {
26
+ if (didRunStartupFlow) {
27
+ return startupFlowPromise ?? Promise.resolve()
28
+ }
29
+ didRunStartupFlow = true
30
+ startupFlowPromise = _runStartupFlow()
31
+ return startupFlowPromise
32
+ }
33
+
34
+ export async function getTrackingAuthorizationStatus(): Promise<TrackingStatus> {
35
+ if (Platform.OS !== 'ios') {
36
+ return TrackingStatus.NotSupported
37
+ }
38
+ try {
39
+ const raw = await NativeRNKontext.getTrackingAuthorizationStatus()
40
+ return _mapRawStatus(raw)
41
+ } catch (error) {
42
+ console.error(error)
43
+ return TrackingStatus.NotSupported
44
+ }
45
+ }
46
+
47
+ export async function getAdvertisingId(): Promise<string | null> {
48
+ try {
49
+ const id = await NativeRNKontext.getAdvertisingId()
50
+ return _normalizeId(id)
51
+ } catch (error) {
52
+ console.error(error)
53
+ return null
54
+ }
55
+ }
56
+
57
+ export async function getVendorId(): Promise<string | null> {
58
+ if (Platform.OS !== 'ios') {
59
+ return null
60
+ }
61
+ try {
62
+ const id = await NativeRNKontext.getVendorId()
63
+ return _normalizeId(id)
64
+ } catch (error) {
65
+ console.error(error)
66
+ return null
67
+ }
68
+ }
69
+
70
+ export async function resolveIds(fallbacks?: {
71
+ vendorId?: string | undefined
72
+ advertisingId?: string | undefined
73
+ }): Promise<{ vendorId: string | null; advertisingId: string | null }> {
74
+ const [vendorId, advertisingId] = await Promise.all([getVendorId(), getAdvertisingId()])
75
+
76
+ return {
77
+ vendorId: vendorId ?? _normalizeId(fallbacks?.vendorId),
78
+ advertisingId: advertisingId ?? _normalizeId(fallbacks?.advertisingId),
79
+ }
80
+ }
81
+
82
+ // ─── internal ────────────────────────────────────────────────────────────────
83
+
84
+ async function _runStartupFlow(): Promise<void> {
85
+ if (Platform.OS !== 'ios') return
86
+
87
+ try {
88
+ if (!_isVersionAtLeast145(Platform.Version)) return
89
+
90
+ const status = await getTrackingAuthorizationStatus()
91
+ if (status !== TrackingStatus.NotDetermined) return
92
+
93
+ await NativeRNKontext.requestTrackingAuthorization()
94
+ } catch (error) {
95
+ console.error(error)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * iOS 14.0–14.4: ATT dialog not required for IDFA access.
101
+ * Requesting it risks permanent denial, so we only request on >= 14.5.
102
+ */
103
+ function _isVersionAtLeast145(version: string): boolean {
104
+ const [major = 0, minor = 0] = version.split('.').map(Number)
105
+ return major > 14 || (major === 14 && minor >= 5)
106
+ }
107
+
108
+ function _mapRawStatus(raw: number): TrackingStatus {
109
+ if (raw >= 0 && raw <= 4) return raw as TrackingStatus
110
+ return TrackingStatus.NotSupported
111
+ }
112
+
113
+ function _normalizeId(id: string | null | undefined): string | null {
114
+ if (!id || id.trim() === '') return null
115
+ if (id.toLowerCase() === ZERO_UUID) return null
116
+ return id
117
+ }