pulse-updates 1.2.4 → 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.
- package/README.md +55 -4
- package/android/build.gradle +1 -0
- package/android/consumer-rules.pro +21 -0
- package/android/src/main/java/app/pulse/updates/PulseUpdatesPackage.kt +5 -1
- package/android/src/main/java/app/pulse/updates/attribution/DeferredHandoffReferrerParser.kt +47 -0
- package/android/src/main/java/app/pulse/updates/attribution/InstallReferrerReflection.kt +155 -0
- package/android/src/main/java/app/pulse/updates/attribution/PulseAttributionModule.kt +229 -0
- package/ios/PulseAttribution/PulseAttribution.m +29 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteButtonComponentView.h +13 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteButtonComponentView.mm +81 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteButtonManager.m +28 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteView.h +21 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteView.m +231 -0
- package/lib/commonjs/DeferredLinkPasteButton.js +64 -0
- package/lib/commonjs/DeferredLinkPasteButton.js.map +1 -0
- package/lib/commonjs/NativePulseAttribution.js +13 -0
- package/lib/commonjs/NativePulseAttribution.js.map +1 -0
- package/lib/commonjs/PulseDeferredLinkPasteButtonNativeComponent.js +13 -0
- package/lib/commonjs/PulseDeferredLinkPasteButtonNativeComponent.js.map +1 -0
- package/lib/commonjs/decisions.js +5 -0
- package/lib/commonjs/decisions.js.map +1 -1
- package/lib/commonjs/index.js +33 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/links.js +944 -0
- package/lib/commonjs/links.js.map +1 -0
- package/lib/commonjs/nativeAttribution.js +29 -0
- package/lib/commonjs/nativeAttribution.js.map +1 -0
- package/lib/module/DeferredLinkPasteButton.js +56 -0
- package/lib/module/DeferredLinkPasteButton.js.map +1 -0
- package/lib/module/NativePulseAttribution.js +9 -0
- package/lib/module/NativePulseAttribution.js.map +1 -0
- package/lib/module/PulseDeferredLinkPasteButtonNativeComponent.js +7 -0
- package/lib/module/PulseDeferredLinkPasteButtonNativeComponent.js.map +1 -0
- package/lib/module/decisions.js +5 -0
- package/lib/module/decisions.js.map +1 -1
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/links.js +933 -0
- package/lib/module/links.js.map +1 -0
- package/lib/module/nativeAttribution.js +21 -0
- package/lib/module/nativeAttribution.js.map +1 -0
- package/lib/typescript/DeferredLinkPasteButton.d.ts +22 -0
- package/lib/typescript/DeferredLinkPasteButton.d.ts.map +1 -0
- package/lib/typescript/NativePulseAttribution.d.ts +11 -0
- package/lib/typescript/NativePulseAttribution.d.ts.map +1 -0
- package/lib/typescript/PulseDeferredLinkPasteButtonNativeComponent.d.ts +16 -0
- package/lib/typescript/PulseDeferredLinkPasteButtonNativeComponent.d.ts.map +1 -0
- package/lib/typescript/decisions.d.ts +6 -0
- package/lib/typescript/decisions.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +4 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +142 -0
- package/lib/typescript/links.d.ts.map +1 -0
- package/lib/typescript/nativeAttribution.d.ts +9 -0
- package/lib/typescript/nativeAttribution.d.ts.map +1 -0
- package/package.json +17 -2
- package/pulse-updates.podspec +8 -2
- package/scripts/publish.mjs +5 -0
- package/src/DeferredLinkPasteButton.tsx +98 -0
- package/src/NativePulseAttribution.ts +12 -0
- package/src/PulseDeferredLinkPasteButtonNativeComponent.ts +18 -0
- package/src/decisions.ts +12 -0
- package/src/index.ts +4 -0
- package/src/links.ts +1232 -0
- 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
|
|
2
|
+
<h1 align="center">Pulse — React Native app-experience SDK</h1>
|
|
3
3
|
<p align="center">
|
|
4
|
-
<b>
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
<b>Updates, config, experiments, events, decisions and first-party links in one SDK.</b><br/>
|
|
5
|
+
Build measurable app experiences and ship JavaScript & 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
|
package/android/build.gradle
CHANGED
|
@@ -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(
|
|
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,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
|