pulse-updates 1.2.5 → 1.3.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 (58) hide show
  1. package/README.md +55 -4
  2. package/android/build.gradle +1 -0
  3. package/android/consumer-rules.pro +21 -0
  4. package/android/src/main/java/app/pulse/updates/PulseUpdatesPackage.kt +5 -1
  5. package/android/src/main/java/app/pulse/updates/attribution/DeferredHandoffReferrerParser.kt +47 -0
  6. package/android/src/main/java/app/pulse/updates/attribution/InstallReferrerReflection.kt +155 -0
  7. package/android/src/main/java/app/pulse/updates/attribution/PulseAttributionModule.kt +229 -0
  8. package/ios/PulseAttribution/PulseAttribution.m +29 -0
  9. package/ios/PulseAttribution/PulseDeferredLinkPasteButtonComponentView.h +13 -0
  10. package/ios/PulseAttribution/PulseDeferredLinkPasteButtonComponentView.mm +81 -0
  11. package/ios/PulseAttribution/PulseDeferredLinkPasteButtonManager.m +28 -0
  12. package/ios/PulseAttribution/PulseDeferredLinkPasteView.h +21 -0
  13. package/ios/PulseAttribution/PulseDeferredLinkPasteView.m +231 -0
  14. package/lib/commonjs/DeferredLinkPasteButton.js +64 -0
  15. package/lib/commonjs/DeferredLinkPasteButton.js.map +1 -0
  16. package/lib/commonjs/NativePulseAttribution.js +13 -0
  17. package/lib/commonjs/NativePulseAttribution.js.map +1 -0
  18. package/lib/commonjs/PulseDeferredLinkPasteButtonNativeComponent.js +13 -0
  19. package/lib/commonjs/PulseDeferredLinkPasteButtonNativeComponent.js.map +1 -0
  20. package/lib/commonjs/index.js +33 -1
  21. package/lib/commonjs/index.js.map +1 -1
  22. package/lib/commonjs/links.js +944 -0
  23. package/lib/commonjs/links.js.map +1 -0
  24. package/lib/commonjs/nativeAttribution.js +29 -0
  25. package/lib/commonjs/nativeAttribution.js.map +1 -0
  26. package/lib/module/DeferredLinkPasteButton.js +56 -0
  27. package/lib/module/DeferredLinkPasteButton.js.map +1 -0
  28. package/lib/module/NativePulseAttribution.js +9 -0
  29. package/lib/module/NativePulseAttribution.js.map +1 -0
  30. package/lib/module/PulseDeferredLinkPasteButtonNativeComponent.js +7 -0
  31. package/lib/module/PulseDeferredLinkPasteButtonNativeComponent.js.map +1 -0
  32. package/lib/module/index.js +3 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/links.js +933 -0
  35. package/lib/module/links.js.map +1 -0
  36. package/lib/module/nativeAttribution.js +21 -0
  37. package/lib/module/nativeAttribution.js.map +1 -0
  38. package/lib/typescript/DeferredLinkPasteButton.d.ts +22 -0
  39. package/lib/typescript/DeferredLinkPasteButton.d.ts.map +1 -0
  40. package/lib/typescript/NativePulseAttribution.d.ts +11 -0
  41. package/lib/typescript/NativePulseAttribution.d.ts.map +1 -0
  42. package/lib/typescript/PulseDeferredLinkPasteButtonNativeComponent.d.ts +16 -0
  43. package/lib/typescript/PulseDeferredLinkPasteButtonNativeComponent.d.ts.map +1 -0
  44. package/lib/typescript/index.d.ts +4 -0
  45. package/lib/typescript/index.d.ts.map +1 -1
  46. package/lib/typescript/links.d.ts +142 -0
  47. package/lib/typescript/links.d.ts.map +1 -0
  48. package/lib/typescript/nativeAttribution.d.ts +9 -0
  49. package/lib/typescript/nativeAttribution.d.ts.map +1 -0
  50. package/package.json +17 -2
  51. package/pulse-updates.podspec +8 -2
  52. package/scripts/publish.mjs +5 -0
  53. package/src/DeferredLinkPasteButton.tsx +98 -0
  54. package/src/NativePulseAttribution.ts +12 -0
  55. package/src/PulseDeferredLinkPasteButtonNativeComponent.ts +18 -0
  56. package/src/index.ts +4 -0
  57. package/src/links.ts +1232 -0
  58. package/src/nativeAttribution.ts +25 -0
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <p align="center"><img src="logo.png" width="88" alt="Pulse Updates" /></p>
2
- <h1 align="center">Pulse Updates — React Native SDK</h1>
2
+ <h1 align="center">Pulse — React Native app-experience SDK</h1>
3
3
  <p align="center">
4
- <b>Lightweight over-the-air (OTA) updates for React Native.</b><br/>
5
- Ship JavaScript &amp; assets instantly a simpler, self-hostable alternative to expo-updates,
6
- with full New Architecture (Fabric/Bridgeless) support and Ed25519 signature verification.
4
+ <b>Updates, config, experiments, events, decisions and first-party links in one SDK.</b><br/>
5
+ Build measurable app experiences and ship JavaScript &amp; assets safely, with a self-hostable
6
+ control plane, New Architecture support and fail-closed Ed25519 verification.
7
7
  </p>
8
8
 
9
9
  ---
@@ -77,12 +77,63 @@ Unknown events remain visible and must be approved in the PULSE dashboard.
77
77
 
78
78
  OTA updates are the other half of the package and set up separately — see below.
79
79
 
80
+ ## Pulse Links
81
+
82
+ Pulse owns the reusable installed-app transport; Encore remains the phase-one Smart Link resolver
83
+ and campaign ledger. The client persists only the opaque handoff state, retries transient failures,
84
+ applies each resolved action once and accepts only app-owned deep-link prefixes:
85
+
86
+ ```ts
87
+ import {
88
+ createPulseLinkClient,
89
+ DeferredLinkPasteButton,
90
+ recoverAndroidInstallReferrer,
91
+ } from 'pulse-updates';
92
+
93
+ const links = createPulseLinkClient({
94
+ appSlug: 'my-app',
95
+ resolverBaseUrl: 'https://encore.example.com/t/d',
96
+ storage: mmkv,
97
+ allowedDeepLinkPrefixes: ['myapp://', 'https://myapp.example/app/'],
98
+ isAccountReady: () => auth.isSignedIn && !auth.isGuest,
99
+ subscribeAccountState: auth.subscribe,
100
+ accountBridge: {
101
+ claim: (token) => billing.claimDeferredLink(token),
102
+ pending: () => billing.getPendingDeferredLink(),
103
+ },
104
+ onDeepLink: (url) => navigation.open(url),
105
+ onAction: (link) => applyClosedAppAction(link.action),
106
+ // Safe marketing dimensions only. The opaque token/link id is deliberately absent.
107
+ onOutcome: ({ name, matchBasis, confidence, campaignId }) =>
108
+ pulse.track(name, { matchBasis, confidence, campaignId }),
109
+ });
110
+
111
+ // Universal/App Link while the app is installed.
112
+ links.captureUrl(incomingUrl);
113
+
114
+ // Android: reads the optional PulseAttribution bridge. The host keeps
115
+ // playStoreImplementation 'com.android.installreferrer:installreferrer:2.2'; non-Play builds
116
+ // contain no Google dependency and return FEATURE_NOT_SUPPORTED.
117
+ void recoverAndroidInstallReferrer(links);
118
+ ```
119
+
120
+ On a recent iOS first open, the host may call `links.matchFirstOpen` with its native install time,
121
+ bundle id and locale. The request intentionally contains no device id, account, clipboard value,
122
+ hardware attributes or client-observed IP. Ambiguous matches produce no action. Sensitive actions
123
+ are rejected for every probabilistic result even if a malformed server response claims otherwise.
124
+
125
+ For the explicit iOS recovery fallback, render `DeferredLinkPasteButton` and pass its validated
126
+ token to `links.capture(token, 'ios_user_paste')`. It uses the system `UIPasteControl` on iOS 16+
127
+ and never reads the clipboard passively. Adding Pulse Links native capabilities requires a new
128
+ App Store/Play binary; Pulse's OTA capability preflight must not be bypassed.
129
+
80
130
  ## Features
81
131
 
82
132
  - One-call setup for config, experiments and events (`initPulse`)
83
133
  - Remote config with typed getters and a default that is never `false` by accident
84
134
  - A/B experiments assigned server-side, with the arm carried in the payload
85
135
  - Your own events (`track`) — no warehouse required
136
+ - First-party deferred-link transport and privacy-safe install recovery (`createPulseLinkClient`)
86
137
  - Full support for React Native New Architecture (Bridgeless mode)
87
138
  - Automatic asset resolution with embedded fallback
88
139
  - Hermes bytecode compilation for faster startup
@@ -22,6 +22,7 @@ android {
22
22
  defaultConfig {
23
23
  minSdkVersion safeExtGet('minSdkVersion', 23)
24
24
  targetSdkVersion safeExtGet('targetSdkVersion', 34)
25
+ consumerProguardFiles 'consumer-rules.pro'
25
26
  }
26
27
 
27
28
  sourceSets {
@@ -0,0 +1,21 @@
1
+ # PulseAttribution loads this optional Play-flavor API by exact class and method name. Keep only
2
+ # that reflected public surface; non-Play variants still package no Install Referrer bytecode.
3
+ -keep,allowoptimization class com.android.installreferrer.api.InstallReferrerClient {
4
+ public static com.android.installreferrer.api.InstallReferrerClient$Builder newBuilder(android.content.Context);
5
+ public void startConnection(com.android.installreferrer.api.InstallReferrerStateListener);
6
+ public com.android.installreferrer.api.ReferrerDetails getInstallReferrer();
7
+ public void endConnection();
8
+ }
9
+
10
+ -keep,allowoptimization class com.android.installreferrer.api.InstallReferrerClient$Builder {
11
+ public com.android.installreferrer.api.InstallReferrerClient build();
12
+ }
13
+
14
+ -keep,allowoptimization interface com.android.installreferrer.api.InstallReferrerStateListener {
15
+ public void onInstallReferrerSetupFinished(int);
16
+ public void onInstallReferrerServiceDisconnected();
17
+ }
18
+
19
+ -keep,allowoptimization class com.android.installreferrer.api.ReferrerDetails {
20
+ public java.lang.String getInstallReferrer();
21
+ }
@@ -1,5 +1,6 @@
1
1
  package app.pulse.updates
2
2
 
3
+ import app.pulse.updates.attribution.PulseAttributionModule
3
4
  import com.facebook.react.ReactPackage
4
5
  import com.facebook.react.bridge.NativeModule
5
6
  import com.facebook.react.bridge.ReactApplicationContext
@@ -7,7 +8,10 @@ import com.facebook.react.uimanager.ViewManager
7
8
 
8
9
  class PulseUpdatesPackage : ReactPackage {
9
10
  override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10
- return listOf(PulseUpdatesModule(reactContext))
11
+ return listOf(
12
+ PulseUpdatesModule(reactContext),
13
+ PulseAttributionModule(reactContext),
14
+ )
11
15
  }
12
16
 
13
17
  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@@ -0,0 +1,47 @@
1
+ package app.pulse.updates.attribution
2
+
3
+ import java.net.URLDecoder
4
+ import java.nio.charset.StandardCharsets
5
+
6
+ /**
7
+ * Extracts the one attribution value the SDK is allowed to expose.
8
+ *
9
+ * Install Referrer returns an application/x-www-form-urlencoded query string. Keep this parser
10
+ * deliberately narrow: other campaign parameters, the original referrer, and its metadata never
11
+ * cross the native bridge.
12
+ */
13
+ internal object DeferredHandoffReferrerParser {
14
+ const val MAX_REFERRER_LENGTH = 4_096
15
+
16
+ private const val HANDOFF_PARAMETER = "encore_handoff"
17
+ private val tokenPattern = Regex("^[A-Za-z0-9_-]{16,512}$")
18
+
19
+ fun extract(referrer: String): String? {
20
+ if (referrer.isEmpty() || referrer.length > MAX_REFERRER_LENGTH) return null
21
+
22
+ var candidate: String? = null
23
+ var matches = 0
24
+
25
+ for (part in referrer.split('&')) {
26
+ val separator = part.indexOf('=')
27
+ val encodedKey = if (separator >= 0) part.substring(0, separator) else part
28
+ val key = decode(encodedKey) ?: continue
29
+ if (key != HANDOFF_PARAMETER) continue
30
+
31
+ matches += 1
32
+ // Ambiguous referrers are rejected even if just one of the duplicate values is valid.
33
+ if (matches > 1) return null
34
+
35
+ val encodedValue = if (separator >= 0) part.substring(separator + 1) else ""
36
+ candidate = decode(encodedValue) ?: return null
37
+ }
38
+
39
+ return candidate?.takeIf(tokenPattern::matches)
40
+ }
41
+
42
+ private fun decode(value: String): String? = try {
43
+ URLDecoder.decode(value, StandardCharsets.UTF_8.name())
44
+ } catch (_: IllegalArgumentException) {
45
+ null
46
+ }
47
+ }
@@ -0,0 +1,155 @@
1
+ package app.pulse.updates.attribution
2
+
3
+ import android.content.Context
4
+ import java.lang.reflect.InvocationHandler
5
+ import java.lang.reflect.Method
6
+ import java.lang.reflect.Proxy
7
+ import java.util.concurrent.atomic.AtomicBoolean
8
+
9
+ /** Reflection-only access to Play Install Referrer. Non-Play variants do not package these types. */
10
+ internal class InstallReferrerReflection private constructor(
11
+ private val listenerClass: Class<*>,
12
+ private val newBuilder: Method,
13
+ private val build: Method,
14
+ private val startConnection: Method,
15
+ private val getInstallReferrer: Method,
16
+ private val getInstallReferrerValue: Method,
17
+ private val endConnection: Method,
18
+ ) {
19
+ sealed class LoadResult {
20
+ class Available(val api: InstallReferrerReflection) : LoadResult()
21
+ object Missing : LoadResult()
22
+ object Broken : LoadResult()
23
+ }
24
+
25
+ interface Listener {
26
+ fun onSetupFinished(responseCode: Int)
27
+ fun onServiceDisconnected()
28
+ }
29
+
30
+ fun createConnection(context: Context): Connection {
31
+ val builder = newBuilder.invoke(null, context.applicationContext)
32
+ ?: throw ReflectiveOperationException("Install Referrer builder was null")
33
+ val client = build.invoke(builder)
34
+ ?: throw ReflectiveOperationException("Install Referrer client was null")
35
+ return Connection(
36
+ client = client,
37
+ listenerClass = listenerClass,
38
+ startConnection = startConnection,
39
+ getInstallReferrer = getInstallReferrer,
40
+ getInstallReferrerValue = getInstallReferrerValue,
41
+ endConnection = endConnection,
42
+ )
43
+ }
44
+
45
+ class Connection internal constructor(
46
+ private val client: Any,
47
+ private val listenerClass: Class<*>,
48
+ private val startConnection: Method,
49
+ private val getInstallReferrer: Method,
50
+ private val getInstallReferrerValue: Method,
51
+ private val endConnection: Method,
52
+ ) {
53
+ private val closed = AtomicBoolean(false)
54
+
55
+ @Volatile
56
+ private var listenerProxy: Any? = null
57
+
58
+ fun start(listener: Listener) {
59
+ check(!closed.get()) { "Install Referrer connection is closed" }
60
+ val handler = InvocationHandler { proxy, method, args ->
61
+ when (method.name) {
62
+ "onInstallReferrerSetupFinished" -> {
63
+ val code = (args?.firstOrNull() as? Number)?.toInt()
64
+ if (code == null) listener.onSetupFinished(DEVELOPER_ERROR)
65
+ else listener.onSetupFinished(code)
66
+ null
67
+ }
68
+ "onInstallReferrerServiceDisconnected" -> {
69
+ listener.onServiceDisconnected()
70
+ null
71
+ }
72
+ "equals" -> proxy === args?.firstOrNull()
73
+ "hashCode" -> System.identityHashCode(proxy)
74
+ "toString" -> "PulseInstallReferrerListener"
75
+ else -> null
76
+ }
77
+ }
78
+ val proxy = Proxy.newProxyInstance(
79
+ listenerClass.classLoader ?: client.javaClass.classLoader,
80
+ arrayOf(listenerClass),
81
+ handler,
82
+ )
83
+ listenerProxy = proxy
84
+ startConnection.invoke(client, proxy)
85
+ }
86
+
87
+ fun readReferrer(): String {
88
+ check(!closed.get()) { "Install Referrer connection is closed" }
89
+ val details = getInstallReferrer.invoke(client)
90
+ ?: throw ReflectiveOperationException("Install Referrer details were null")
91
+ return getInstallReferrerValue.invoke(details) as? String
92
+ ?: throw ReflectiveOperationException("Install Referrer value was null")
93
+ }
94
+
95
+ /** Safe from timeout, callback, retry, and module invalidation races. */
96
+ fun close() {
97
+ if (!closed.compareAndSet(false, true)) return
98
+ try {
99
+ endConnection.invoke(client)
100
+ } catch (_: Throwable) {
101
+ // Cleanup is best-effort and must never replace the already selected result.
102
+ } finally {
103
+ listenerProxy = null
104
+ }
105
+ }
106
+ }
107
+
108
+ companion object {
109
+ const val OK = 0
110
+ const val SERVICE_UNAVAILABLE = 1
111
+ const val FEATURE_NOT_SUPPORTED = 2
112
+ const val DEVELOPER_ERROR = 3
113
+ const val SERVICE_DISCONNECTED = -1
114
+
115
+ private const val CLIENT_CLASS =
116
+ "com.android.installreferrer.api.InstallReferrerClient"
117
+ private const val BUILDER_CLASS =
118
+ "com.android.installreferrer.api.InstallReferrerClient\$Builder"
119
+ private const val LISTENER_CLASS =
120
+ "com.android.installreferrer.api.InstallReferrerStateListener"
121
+ private const val DETAILS_CLASS =
122
+ "com.android.installreferrer.api.ReferrerDetails"
123
+
124
+ fun load(context: Context): LoadResult {
125
+ val loader = context.classLoader ?: InstallReferrerReflection::class.java.classLoader
126
+ return try {
127
+ val client = Class.forName(CLIENT_CLASS, false, loader)
128
+ val builder = Class.forName(BUILDER_CLASS, false, loader)
129
+ val listener = Class.forName(LISTENER_CLASS, false, loader)
130
+ val details = Class.forName(DETAILS_CLASS, false, loader)
131
+ LoadResult.Available(
132
+ InstallReferrerReflection(
133
+ listenerClass = listener,
134
+ newBuilder = client.getMethod("newBuilder", Context::class.java),
135
+ build = builder.getMethod("build"),
136
+ startConnection = client.getMethod("startConnection", listener),
137
+ getInstallReferrer = client.getMethod("getInstallReferrer"),
138
+ getInstallReferrerValue = details.getMethod("getInstallReferrer"),
139
+ endConnection = client.getMethod("endConnection"),
140
+ ),
141
+ )
142
+ } catch (_: ClassNotFoundException) {
143
+ LoadResult.Missing
144
+ } catch (_: NoClassDefFoundError) {
145
+ LoadResult.Missing
146
+ } catch (_: ReflectiveOperationException) {
147
+ LoadResult.Broken
148
+ } catch (_: SecurityException) {
149
+ LoadResult.Broken
150
+ } catch (_: LinkageError) {
151
+ LoadResult.Broken
152
+ }
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,229 @@
1
+ package app.pulse.updates.attribution
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
9
+ import com.facebook.react.bridge.ReactMethod
10
+ import java.util.Collections
11
+ import java.util.concurrent.atomic.AtomicBoolean
12
+
13
+ /**
14
+ * A separate native capability so an OTA that expects attribution fails closed on older binaries.
15
+ */
16
+ class PulseAttributionModule(
17
+ private val reactContext: ReactApplicationContext,
18
+ ) : ReactContextBaseJavaModule(reactContext) {
19
+ private val mainHandler = Handler(Looper.getMainLooper())
20
+ private val activeRequests = Collections.synchronizedSet(mutableSetOf<DeferredHandoffRequest>())
21
+
22
+ override fun getName(): String = NAME
23
+
24
+ @ReactMethod
25
+ fun getDeferredHandoff(promise: Promise) {
26
+ lateinit var request: DeferredHandoffRequest
27
+ request = DeferredHandoffRequest(
28
+ context = reactContext.applicationContext,
29
+ promise = promise,
30
+ handler = mainHandler,
31
+ onSettled = { activeRequests.remove(request) },
32
+ )
33
+ activeRequests.add(request)
34
+ request.start()
35
+ }
36
+
37
+ override fun invalidate() {
38
+ val pending = synchronized(activeRequests) { activeRequests.toList() }
39
+ pending.forEach(DeferredHandoffRequest::cancel)
40
+ activeRequests.clear()
41
+ super.invalidate()
42
+ }
43
+
44
+ companion object {
45
+ const val NAME = "PulseAttribution"
46
+ }
47
+ }
48
+
49
+ private enum class DeferredHandoffStatus(val retryable: Boolean) {
50
+ OK(false),
51
+ NO_TOKEN(false),
52
+ FEATURE_NOT_SUPPORTED(false),
53
+ SERVICE_UNAVAILABLE(true),
54
+ DEVELOPER_ERROR(false),
55
+ SERVICE_DISCONNECTED(true),
56
+ }
57
+
58
+ private class DeferredHandoffRequest(
59
+ private val context: android.content.Context,
60
+ private val promise: Promise,
61
+ private val handler: Handler,
62
+ private val onSettled: () -> Unit,
63
+ ) {
64
+ private val settled = AtomicBoolean(false)
65
+ private var attempts = 0
66
+ private var lastTransientStatus = DeferredHandoffStatus.SERVICE_UNAVAILABLE
67
+
68
+ @Volatile
69
+ private var activeConnection: InstallReferrerReflection.Connection? = null
70
+
71
+ private val timeout = Runnable {
72
+ settle(lastTransientStatus, null)
73
+ }
74
+
75
+ fun start() {
76
+ handler.post {
77
+ if (settled.get()) return@post
78
+ when (val loaded = InstallReferrerReflection.load(context)) {
79
+ is InstallReferrerReflection.LoadResult.Available -> {
80
+ handler.postDelayed(timeout, TIMEOUT_MS)
81
+ beginAttempt(loaded.api)
82
+ }
83
+ InstallReferrerReflection.LoadResult.Missing -> {
84
+ settle(DeferredHandoffStatus.FEATURE_NOT_SUPPORTED, null)
85
+ }
86
+ InstallReferrerReflection.LoadResult.Broken -> {
87
+ settle(DeferredHandoffStatus.DEVELOPER_ERROR, null)
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ fun cancel() {
94
+ settle(DeferredHandoffStatus.SERVICE_UNAVAILABLE, null)
95
+ }
96
+
97
+ private fun beginAttempt(api: InstallReferrerReflection) {
98
+ if (settled.get()) return
99
+ attempts += 1
100
+
101
+ val callbackConsumed = AtomicBoolean(false)
102
+ val connection = try {
103
+ api.createConnection(context)
104
+ } catch (_: Throwable) {
105
+ settle(DeferredHandoffStatus.DEVELOPER_ERROR, null)
106
+ return
107
+ }
108
+ if (settled.get()) {
109
+ connection.close()
110
+ return
111
+ }
112
+ activeConnection = connection
113
+
114
+ val listener = object : InstallReferrerReflection.Listener {
115
+ override fun onSetupFinished(responseCode: Int) {
116
+ if (!callbackConsumed.compareAndSet(false, true)) return
117
+ handler.post {
118
+ if (settled.get()) {
119
+ connection.close()
120
+ return@post
121
+ }
122
+ when (responseCode) {
123
+ InstallReferrerReflection.OK -> readAndSettle(connection, api)
124
+ InstallReferrerReflection.SERVICE_UNAVAILABLE -> retryTransient(
125
+ connection,
126
+ api,
127
+ DeferredHandoffStatus.SERVICE_UNAVAILABLE,
128
+ )
129
+ InstallReferrerReflection.SERVICE_DISCONNECTED -> retryTransient(
130
+ connection,
131
+ api,
132
+ DeferredHandoffStatus.SERVICE_DISCONNECTED,
133
+ )
134
+ InstallReferrerReflection.FEATURE_NOT_SUPPORTED -> {
135
+ connection.close()
136
+ settle(DeferredHandoffStatus.FEATURE_NOT_SUPPORTED, null)
137
+ }
138
+ InstallReferrerReflection.DEVELOPER_ERROR -> {
139
+ connection.close()
140
+ settle(DeferredHandoffStatus.DEVELOPER_ERROR, null)
141
+ }
142
+ // 2.2 also defines PERMISSION_ERROR. The public bridge intentionally has
143
+ // no Play-specific status for it, so it is a terminal developer error.
144
+ else -> {
145
+ connection.close()
146
+ settle(DeferredHandoffStatus.DEVELOPER_ERROR, null)
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ override fun onServiceDisconnected() {
153
+ if (!callbackConsumed.compareAndSet(false, true)) return
154
+ handler.post {
155
+ if (settled.get()) {
156
+ connection.close()
157
+ return@post
158
+ }
159
+ retryTransient(
160
+ connection,
161
+ api,
162
+ DeferredHandoffStatus.SERVICE_DISCONNECTED,
163
+ )
164
+ }
165
+ }
166
+ }
167
+
168
+ try {
169
+ connection.start(listener)
170
+ } catch (_: Throwable) {
171
+ if (callbackConsumed.compareAndSet(false, true)) {
172
+ connection.close()
173
+ settle(DeferredHandoffStatus.DEVELOPER_ERROR, null)
174
+ }
175
+ }
176
+ }
177
+
178
+ private fun readAndSettle(
179
+ connection: InstallReferrerReflection.Connection,
180
+ api: InstallReferrerReflection,
181
+ ) {
182
+ val referrer = try {
183
+ connection.readReferrer()
184
+ } catch (_: Throwable) {
185
+ retryTransient(connection, api, DeferredHandoffStatus.SERVICE_DISCONNECTED)
186
+ return
187
+ }
188
+ connection.close()
189
+
190
+ val token = DeferredHandoffReferrerParser.extract(referrer)
191
+ if (token == null) settle(DeferredHandoffStatus.NO_TOKEN, null)
192
+ else settle(DeferredHandoffStatus.OK, token)
193
+ }
194
+
195
+ private fun retryTransient(
196
+ connection: InstallReferrerReflection.Connection,
197
+ api: InstallReferrerReflection,
198
+ status: DeferredHandoffStatus,
199
+ ) {
200
+ connection.close()
201
+ lastTransientStatus = status
202
+ if (attempts < MAX_ATTEMPTS) beginAttempt(api)
203
+ else settle(status, null)
204
+ }
205
+
206
+ private fun settle(status: DeferredHandoffStatus, token: String?) {
207
+ if (!settled.compareAndSet(false, true)) return
208
+ handler.removeCallbacks(timeout)
209
+ activeConnection?.close()
210
+ activeConnection = null
211
+
212
+ val result = Arguments.createMap().apply {
213
+ putString("status", status.name)
214
+ putBoolean("retryable", status.retryable)
215
+ putInt("attempts", attempts)
216
+ if (token == null) putNull("token") else putString("token", token)
217
+ }
218
+ try {
219
+ promise.resolve(result)
220
+ } finally {
221
+ onSettled()
222
+ }
223
+ }
224
+
225
+ companion object {
226
+ private const val TIMEOUT_MS = 5_000L
227
+ private const val MAX_ATTEMPTS = 3
228
+ }
229
+ }
@@ -0,0 +1,29 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ /// Cross-platform native capability marker. Android supplies Play Install Referrer through an
4
+ /// optional reflected dependency; iOS deterministic recovery uses Universal Links/UIPasteControl.
5
+ @interface PulseAttribution : NSObject <RCTBridgeModule>
6
+ @end
7
+
8
+ @implementation PulseAttribution
9
+
10
+ RCT_EXPORT_MODULE(PulseAttribution)
11
+
12
+ + (BOOL)requiresMainQueueSetup
13
+ {
14
+ return NO;
15
+ }
16
+
17
+ RCT_REMAP_METHOD(getDeferredHandoff,
18
+ getDeferredHandoffWithResolver:(RCTPromiseResolveBlock)resolve
19
+ rejecter:(RCTPromiseRejectBlock)reject)
20
+ {
21
+ resolve(@{
22
+ @"status": @"FEATURE_NOT_SUPPORTED",
23
+ @"retryable": @NO,
24
+ @"attempts": @0,
25
+ @"token": [NSNull null],
26
+ });
27
+ }
28
+
29
+ @end
@@ -0,0 +1,13 @@
1
+ #ifdef RCT_NEW_ARCH_ENABLED
2
+
3
+ #import <React/RCTViewComponentView.h>
4
+ #import <UIKit/UIKit.h>
5
+
6
+ NS_ASSUME_NONNULL_BEGIN
7
+
8
+ @interface PulseDeferredLinkPasteButtonComponentView : RCTViewComponentView
9
+ @end
10
+
11
+ NS_ASSUME_NONNULL_END
12
+
13
+ #endif
@@ -0,0 +1,81 @@
1
+ #ifdef RCT_NEW_ARCH_ENABLED
2
+
3
+ #import "PulseDeferredLinkPasteButtonComponentView.h"
4
+ #import "PulseDeferredLinkPasteView.h"
5
+
6
+ #import <react/renderer/components/PulseAttributionSpec/ComponentDescriptors.h>
7
+ #import <react/renderer/components/PulseAttributionSpec/EventEmitters.h>
8
+ #import <react/renderer/components/PulseAttributionSpec/Props.h>
9
+ #import <react/renderer/components/PulseAttributionSpec/RCTComponentViewHelpers.h>
10
+
11
+ #import <React/RCTConversions.h>
12
+
13
+ using namespace facebook::react;
14
+
15
+ @interface PulseDeferredLinkPasteButtonComponentView ()
16
+ <RCTPulseDeferredLinkPasteButtonViewProtocol>
17
+ - (void)emitHandoff:(NSDictionary<NSString *, NSString *> *)body;
18
+ @end
19
+
20
+ @implementation PulseDeferredLinkPasteButtonComponentView {
21
+ PulseDeferredLinkPasteView *_pasteView;
22
+ }
23
+
24
+ + (ComponentDescriptorProvider)componentDescriptorProvider
25
+ {
26
+ return concreteComponentDescriptorProvider<PulseDeferredLinkPasteButtonComponentDescriptor>();
27
+ }
28
+
29
+ - (instancetype)initWithFrame:(CGRect)frame
30
+ {
31
+ if ((self = [super initWithFrame:frame])) {
32
+ static const auto defaultProps = std::make_shared<const PulseDeferredLinkPasteButtonProps>();
33
+ _props = defaultProps;
34
+ _pasteView = [[PulseDeferredLinkPasteView alloc] initWithFrame:self.bounds];
35
+ _pasteView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
36
+ __weak __typeof(self) weakSelf = self;
37
+ _pasteView.onHandoff = ^(NSDictionary<NSString *, NSString *> *body) {
38
+ [weakSelf emitHandoff:body];
39
+ };
40
+ self.contentView = _pasteView;
41
+ }
42
+ return self;
43
+ }
44
+
45
+ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps
46
+ {
47
+ const auto &oldViewProps =
48
+ *std::static_pointer_cast<const PulseDeferredLinkPasteButtonProps>(_props);
49
+ const auto &newViewProps =
50
+ *std::static_pointer_cast<const PulseDeferredLinkPasteButtonProps>(props);
51
+ if (oldViewProps.label != newViewProps.label) {
52
+ _pasteView.label = RCTNSStringFromStringNilIfEmpty(newViewProps.label);
53
+ }
54
+ if (oldViewProps.foregroundColor != newViewProps.foregroundColor) {
55
+ _pasteView.foregroundColor = RCTUIColorFromSharedColor(newViewProps.foregroundColor);
56
+ }
57
+ if (oldViewProps.handoffPrefix != newViewProps.handoffPrefix) {
58
+ _pasteView.handoffPrefix = RCTNSStringFromStringNilIfEmpty(newViewProps.handoffPrefix);
59
+ }
60
+ [super updateProps:props oldProps:oldProps];
61
+ }
62
+
63
+ - (void)emitHandoff:(NSDictionary<NSString *, NSString *> *)body
64
+ {
65
+ if (!_eventEmitter) return;
66
+ NSString *token = body[@"token"];
67
+ if (![token isKindOfClass:NSString.class]) return;
68
+ PulseDeferredLinkPasteButtonEventEmitter::OnHandoff event = {};
69
+ event.token = std::string(token.UTF8String);
70
+ std::static_pointer_cast<const PulseDeferredLinkPasteButtonEventEmitter>(_eventEmitter)
71
+ ->onHandoff(event);
72
+ }
73
+
74
+ @end
75
+
76
+ Class<RCTComponentViewProtocol> PulseDeferredLinkPasteButtonCls(void)
77
+ {
78
+ return PulseDeferredLinkPasteButtonComponentView.class;
79
+ }
80
+
81
+ #endif