@apex-inc/capacitor-plugin 2.5.0 → 2.6.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/ApexIncCapacitorPlugin.podspec +1 -0
- package/README.md +77 -11
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +255 -20
- package/android/src/main/java/inc/apex/capacitor/FirstLaunchInstall.kt +28 -0
- package/android/src/main/java/inc/apex/capacitor/MetaInstallReferrer.kt +75 -0
- package/android/src/main/java/inc/apex/capacitor/MobileContext.kt +43 -0
- package/android/src/main/java/inc/apex/capacitor/TrackStamps.kt +26 -0
- package/dist/definitions.d.ts +45 -19
- package/dist/definitions.d.ts.map +1 -1
- package/dist/esm/definitions.d.ts +45 -19
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +1 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +1 -0
- package/dist/web.js.map +1 -1
- package/ios/Sources/ApexCapacitorPlugin/FirstLaunchInstall.swift +50 -0
- package/ios/Sources/ApexCapacitorPlugin/MobileContext.swift +49 -0
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +80 -1
- package/ios/Sources/PrivacyInfo.xcprivacy +72 -0
- package/ios/Tests/ApexCapacitorPluginTests/FirstLaunchInstallTests.swift +66 -0
- package/ios/Tests/ApexCapacitorPluginTests/MobileContextTests.swift +56 -0
- package/package.json +5 -2
- package/ios/Package.swift +0 -34
|
@@ -11,6 +11,7 @@ Pod::Spec.new do |s|
|
|
|
11
11
|
s.author = package['author']
|
|
12
12
|
s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
|
|
13
13
|
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
|
|
14
|
+
s.resource_bundles = { 'ApexIncCapacitorPluginPrivacy' => ['ios/Sources/PrivacyInfo.xcprivacy'] }
|
|
14
15
|
s.ios.deployment_target = '13.0'
|
|
15
16
|
s.dependency 'Capacitor'
|
|
16
17
|
s.swift_version = '5.9'
|
package/README.md
CHANGED
|
@@ -25,10 +25,12 @@ import { Apex } from "@apex-inc/capacitor-plugin";
|
|
|
25
25
|
await Apex.initialize({
|
|
26
26
|
workspaceKey: "prj_your_key",
|
|
27
27
|
// Optional:
|
|
28
|
-
// apiKey: "
|
|
28
|
+
// apiKey: "apex_pk_…", // app key only; see "API keys" below
|
|
29
29
|
// apiUrl: "https://app.apex.inc",
|
|
30
30
|
// autoTrackScreenViews: true, // default — screen_view auto-fires from SPA navigation
|
|
31
31
|
// autoConversionValues: true, // default — Apex drives SKAdNetwork values from Set up Apex (iOS)
|
|
32
|
+
// autoTrackInstall: true, // default — app_install once, on the first launch of a fresh install
|
|
33
|
+
// metaAppId: "1234567890", // Android: credits installs from Meta ads (see "Meta install referrer")
|
|
32
34
|
// sessionTimeoutMinutes: 30,
|
|
33
35
|
// offlineQueueMaxSize: 1000,
|
|
34
36
|
// testMode: false,
|
|
@@ -36,7 +38,17 @@ await Apex.initialize({
|
|
|
36
38
|
});
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
On iOS and Android the plugin sends `app_install` itself, once, on the
|
|
42
|
+
first launch of a fresh install, carrying what Apex needs to credit the
|
|
43
|
+
ad behind it. Someone who had your app before you added Apex is not sent
|
|
44
|
+
as an install. If your app already tracks `app_install`, keep it or
|
|
45
|
+
remove it: only the first one per install is sent.
|
|
46
|
+
|
|
47
|
+
Track `app_open` yourself on launch and when the app returns to the
|
|
48
|
+
foreground; on iOS and Android the plugin does not send it for you. (In a
|
|
49
|
+
browser preview, the web version sends one `app_open` per page load.)
|
|
50
|
+
|
|
51
|
+
`screen_view` auto-fires from
|
|
40
52
|
the webview's History API (pushState / replaceState / back-forward /
|
|
41
53
|
hashchange — covers Ionic, React Router, Vue Router, and plain SPA
|
|
42
54
|
routing, on device and in browser preview). Screen names default to the
|
|
@@ -46,15 +58,30 @@ into your router instead.
|
|
|
46
58
|
|
|
47
59
|
### API keys
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
Use an app key (`apex_pk_…`). Make one under Settings, Organization, API
|
|
62
|
+
keys with the **App key** preset. It is safe to ship: it gives your app its
|
|
63
|
+
own rate limit and lets a sign-up add a new person, but it never changes a
|
|
64
|
+
person already in Apex, never counts revenue, and never sends anything to
|
|
65
|
+
your ad platforms.
|
|
66
|
+
|
|
67
|
+
Do not put a server key (`apex_sk_…`) or a personal key (`apex_uk_…`) in
|
|
68
|
+
your app. Anyone can unpack an app and read the key, and either one lets
|
|
69
|
+
them send purchases Apex counts as revenue and passes to your ad
|
|
70
|
+
platforms. Send purchases from your server with a server key and the
|
|
71
|
+
[Server Events API](https://app.apex.inc/docs/api/server-events), or
|
|
72
|
+
connect the App Store or Google Play.
|
|
73
|
+
|
|
74
|
+
### Signed sign-ins
|
|
75
|
+
|
|
76
|
+
To attach a sign-in to someone already in Apex, have your server sign the
|
|
77
|
+
email with your workspace's sign-in secret and pass the result as
|
|
78
|
+
`userHash`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
await Apex.identify({ email, userHash }); // userHash comes from your backend
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
See [Signed sign-ins](https://app.apex.inc/docs/identity/signed-sign-ins).
|
|
58
85
|
|
|
59
86
|
## Track events
|
|
60
87
|
|
|
@@ -154,6 +181,27 @@ const { status: current } = await Apex.getTrackingStatus();
|
|
|
154
181
|
|
|
155
182
|
## Advertising identifiers
|
|
156
183
|
|
|
184
|
+
Every event carries the phone's details under `mobile` (app id, app
|
|
185
|
+
version, OS version, model, language, time zone, and on iOS the tracking
|
|
186
|
+
answer). Apex passes them to the ad networks you connect, so they can
|
|
187
|
+
credit installs and purchases to their ads. The advertising id rides
|
|
188
|
+
along only with permission:
|
|
189
|
+
|
|
190
|
+
- **iOS**: the IDFA, only after the person allowed tracking in the ATT
|
|
191
|
+
prompt. Read on every event, so a change in Settings applies at once.
|
|
192
|
+
- **Android**: the GAID, only when the person has not limited ad tracking
|
|
193
|
+
or deleted the id. Looked up at `initialize()` and again when the app
|
|
194
|
+
opens.
|
|
195
|
+
|
|
196
|
+
The plugin ships an Apple privacy manifest that declares the advertising
|
|
197
|
+
id, app activity and purchases as used for tracking, since Apex sends
|
|
198
|
+
installs and purchases to the ad networks you connect. Xcode merges it into your app's privacy report;
|
|
199
|
+
your App Store privacy label should say the same.
|
|
200
|
+
|
|
201
|
+
The IDFV and Android ID fallbacks below are never put on events. If your
|
|
202
|
+
app asks for consent with `requireConsent`, nothing is sent at all until
|
|
203
|
+
`setConsent({ granted: true })`.
|
|
204
|
+
|
|
157
205
|
```ts
|
|
158
206
|
// Returns IDFA (iOS, ATT authorized), GAID (Android), or fallback.
|
|
159
207
|
const { id, fallback } = await Apex.getAdvertisingId();
|
|
@@ -162,6 +210,24 @@ if (!id && fallback === "idfv") {
|
|
|
162
210
|
}
|
|
163
211
|
```
|
|
164
212
|
|
|
213
|
+
## Meta install referrer (Android)
|
|
214
|
+
|
|
215
|
+
The Facebook, Instagram and Facebook Lite apps remember the Meta ad a
|
|
216
|
+
person tapped or saw before installing yours. Pass your Meta app id and
|
|
217
|
+
the install carries that ad to Apex:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
await Apex.initialize({ workspaceKey: "prj_your_key", metaAppId: "1234567890" });
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Skip `metaAppId` if your app already has the Facebook SDK's
|
|
224
|
+
`com.facebook.sdk.ApplicationId` manifest entry; the plugin reads it.
|
|
225
|
+
The ad stays encrypted on the phone. Apex opens it with your install
|
|
226
|
+
referrer key: in Meta for Developers, open your app's Basic settings,
|
|
227
|
+
find "Install Referrer Decryption Key" under Android, and paste it in
|
|
228
|
+
Apex under the Meta connection. Needs Facebook 428, Instagram 296 or
|
|
229
|
+
Facebook Lite 411 or newer on the phone.
|
|
230
|
+
|
|
165
231
|
## SKAdNetwork conversion values (iOS 4.0+)
|
|
166
232
|
|
|
167
233
|
You do not have to write this code. In the Apex dashboard, **Set up Apex →
|
|
@@ -36,5 +36,10 @@
|
|
|
36
36
|
<package android:name="com.google.android.feedback" />
|
|
37
37
|
<package android:name="com.amazon.venezia" />
|
|
38
38
|
<package android:name="com.sec.android.app.samsungapps" />
|
|
39
|
+
<!-- Meta Install Referrer: Android 11+ hides these apps' content
|
|
40
|
+
providers unless the app declares it looks for them. -->
|
|
41
|
+
<package android:name="com.facebook.katana" />
|
|
42
|
+
<package android:name="com.instagram.android" />
|
|
43
|
+
<package android:name="com.facebook.lite" />
|
|
39
44
|
</queries>
|
|
40
45
|
</manifest>
|
|
@@ -52,6 +52,9 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
52
52
|
|
|
53
53
|
private var offlineQueue: NativeOfflineQueue? = null
|
|
54
54
|
private var batchSender: NativeBatchSender? = null
|
|
55
|
+
private val flushHandler = Handler(Looper.getMainLooper())
|
|
56
|
+
private var flushTimer: Runnable? = null
|
|
57
|
+
private var pendingTrackFlush: Runnable? = null
|
|
55
58
|
private var prefs: SharedPreferences? = null
|
|
56
59
|
private var visitorId: String = ""
|
|
57
60
|
private var testMode: Boolean = false
|
|
@@ -64,9 +67,19 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
64
67
|
// rather than returning null because the Play service hasn't
|
|
65
68
|
// connected yet. Calls that arrive before the fetch completes are
|
|
66
69
|
// parked and flushed by the listener.
|
|
67
|
-
private var installReferrerResolved: Boolean = false
|
|
70
|
+
@Volatile private var installReferrerResolved: Boolean = false
|
|
68
71
|
private var installReferrerClickSeconds: Long = 0
|
|
69
72
|
private val pendingReferrerCalls = mutableListOf<PluginCall>()
|
|
73
|
+
private val referrerLatch = java.util.concurrent.CountDownLatch(1)
|
|
74
|
+
|
|
75
|
+
// The install is sent once per install, by the plugin or the app, never both.
|
|
76
|
+
private var autoTrackInstall: Boolean = true
|
|
77
|
+
private val installLock = Any()
|
|
78
|
+
|
|
79
|
+
// Meta Install Referrer. Read once per launch, on the install event.
|
|
80
|
+
private var metaAppId: String? = null
|
|
81
|
+
@Volatile private var metaReferrerRead: Boolean = false
|
|
82
|
+
@Volatile private var metaReferrer: MetaInstallReferrer.Found? = null
|
|
70
83
|
// Phase 2-SDK — workspace-bound SDK key forwarded as
|
|
71
84
|
// `x-apex-api-key` on every event POST + on `identify()`.
|
|
72
85
|
private var apiKey: String? = null
|
|
@@ -82,6 +95,27 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
82
95
|
// Phase 4a — consent gate. Defaults granted; set false by
|
|
83
96
|
// initialize({ requireConsent: true }) until setConsent({ granted:true }).
|
|
84
97
|
private var consentGranted: Boolean = true
|
|
98
|
+
|
|
99
|
+
// The GAID, looked up off the main thread (Play services blocks) and
|
|
100
|
+
// refreshed on `app_open`, since the person can limit ad tracking or
|
|
101
|
+
// delete the id in Settings at any time.
|
|
102
|
+
@Volatile private var gaid: String? = null
|
|
103
|
+
@Volatile private var gaidResolved: Boolean = false
|
|
104
|
+
private val device by lazy {
|
|
105
|
+
val locale = Locale.getDefault()
|
|
106
|
+
MobileContext.Device(
|
|
107
|
+
bundleId = context.packageName,
|
|
108
|
+
appVersion = try {
|
|
109
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0.0.0"
|
|
110
|
+
} catch (_: Exception) {
|
|
111
|
+
"0.0.0"
|
|
112
|
+
},
|
|
113
|
+
osVersion = Build.VERSION.RELEASE,
|
|
114
|
+
deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}".trim(),
|
|
115
|
+
timezone = TimeZone.getDefault().id,
|
|
116
|
+
locale = "${locale.language}-${locale.country}",
|
|
117
|
+
)
|
|
118
|
+
}
|
|
85
119
|
// Phase 3 — per-launch dedup so each (experiment, variant) exposure fires
|
|
86
120
|
// once. Concurrent set: getVariant resolves on background threads.
|
|
87
121
|
private val firedExposures = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
|
|
@@ -153,6 +187,8 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
153
187
|
apiKey = if (rawKey.isNullOrEmpty()) null else rawKey
|
|
154
188
|
// Phase 4a — consent gate.
|
|
155
189
|
consentGranted = !(call.getBoolean("requireConsent", false) ?: false)
|
|
190
|
+
metaAppId = MetaInstallReferrer.normalizeAppId(call.getString("metaAppId")) ?: manifestMetaAppId()
|
|
191
|
+
autoTrackInstall = call.getBoolean("autoTrackInstall", true) ?: true
|
|
156
192
|
val maxSize = call.getInt("offlineQueueMaxSize") ?: 1000
|
|
157
193
|
|
|
158
194
|
val queueFile = File(context.filesDir, "apex-capacitor/events.json")
|
|
@@ -170,6 +206,8 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
170
206
|
apiKey = apiKey,
|
|
171
207
|
debug = debug,
|
|
172
208
|
)
|
|
209
|
+
scheduleFlushTimer()
|
|
210
|
+
kickFlush("initialize")
|
|
173
211
|
|
|
174
212
|
// MMP-061 — release channel detection. Unlike iOS, Play doesn't
|
|
175
213
|
// surface internal/closed/open tracks at the device level
|
|
@@ -205,10 +243,80 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
205
243
|
|
|
206
244
|
// Fire-and-forget install referrer retrieval on init.
|
|
207
245
|
fetchInstallReferrer()
|
|
246
|
+
Thread { resolveGaid() }.start()
|
|
247
|
+
// Off this thread: the install waits up to REFERRER_WAIT_MS for Play.
|
|
248
|
+
Thread { sendInstallIfFirstLaunch() }.start()
|
|
208
249
|
|
|
209
250
|
call.resolve()
|
|
210
251
|
}
|
|
211
252
|
|
|
253
|
+
/** First install time and this version's install time, from the package manager. */
|
|
254
|
+
private fun installTimes(): Pair<Long?, Long?> = try {
|
|
255
|
+
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
256
|
+
Pair(info.firstInstallTime, info.lastUpdateTime)
|
|
257
|
+
} catch (_: Exception) {
|
|
258
|
+
Pair(null, null)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Send `app_install` on the first launch of a fresh install. Marks the
|
|
263
|
+
* install as handled for someone who had the app before Apex, so the
|
|
264
|
+
* update that adds Apex reports no one as new.
|
|
265
|
+
*/
|
|
266
|
+
private fun sendInstallIfFirstLaunch() {
|
|
267
|
+
if (!autoTrackInstall || !consentGranted || offlineQueue == null) return
|
|
268
|
+
val (installed, versionInstalled) = installTimes()
|
|
269
|
+
val decision = synchronized(installLock) {
|
|
270
|
+
val sent = prefs?.getBoolean(FirstLaunchInstall.PREF_KEY, false) == true
|
|
271
|
+
val d = FirstLaunchInstall.decide(sent, installed, versionInstalled, System.currentTimeMillis())
|
|
272
|
+
if (d != FirstLaunchInstall.Decision.ALREADY_SENT) {
|
|
273
|
+
prefs?.edit()?.putBoolean(FirstLaunchInstall.PREF_KEY, true)?.apply()
|
|
274
|
+
}
|
|
275
|
+
d
|
|
276
|
+
}
|
|
277
|
+
if (debug) println("[apex-capacitor] first-launch install: $decision")
|
|
278
|
+
if (decision != FirstLaunchInstall.Decision.SEND) return
|
|
279
|
+
try {
|
|
280
|
+
enqueuePayload(
|
|
281
|
+
UUID.randomUUID().toString().lowercase(),
|
|
282
|
+
mutableMapOf<String, Any?>("type" to "app_install", "data" to JSONObject().put("source", "apex_first_launch")),
|
|
283
|
+
)
|
|
284
|
+
} catch (e: Exception) {
|
|
285
|
+
if (debug) println("[apex-capacitor] first-launch install not queued: ${e.message}")
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** True the first time an install is claimed; the app's own `app_install` after that is dropped. */
|
|
290
|
+
private fun claimInstall(): Boolean = synchronized(installLock) {
|
|
291
|
+
if (prefs?.getBoolean(FirstLaunchInstall.PREF_KEY, false) == true) {
|
|
292
|
+
false
|
|
293
|
+
} else {
|
|
294
|
+
prefs?.edit()?.putBoolean(FirstLaunchInstall.PREF_KEY, true)?.apply()
|
|
295
|
+
true
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private fun resolveGaid() {
|
|
300
|
+
if (Looper.myLooper() == Looper.getMainLooper()) return
|
|
301
|
+
gaid = try {
|
|
302
|
+
val info = AdvertisingIdClient.getAdvertisingIdInfo(context)
|
|
303
|
+
MobileContext.advertisingId(info.id, info.isLimitAdTrackingEnabled)
|
|
304
|
+
} catch (_: Exception) {
|
|
305
|
+
null
|
|
306
|
+
}
|
|
307
|
+
gaidResolved = true
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The first event of a launch (often the install) waits for the lookup rather than going without. */
|
|
311
|
+
private fun currentGaid(): String? {
|
|
312
|
+
if (!gaidResolved) resolveGaid()
|
|
313
|
+
return gaid
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private fun stampDevice(slot: MutableMap<String, Any?>) {
|
|
317
|
+
MobileContext.fill(slot, device, currentGaid())
|
|
318
|
+
}
|
|
319
|
+
|
|
212
320
|
// ── Push notifications (FCM) ──────────────────────────────────────
|
|
213
321
|
@PluginMethod
|
|
214
322
|
fun registerForPushNotifications(call: PluginCall) {
|
|
@@ -468,6 +576,10 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
468
576
|
if (traits != null) {
|
|
469
577
|
payload.put("metadata", traits)
|
|
470
578
|
}
|
|
579
|
+
val userHash = call.getString("userHash")
|
|
580
|
+
if (!userHash.isNullOrEmpty()) {
|
|
581
|
+
payload.put("userHash", userHash)
|
|
582
|
+
}
|
|
471
583
|
|
|
472
584
|
Thread {
|
|
473
585
|
try {
|
|
@@ -798,15 +910,12 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
798
910
|
"surface" to "mobile",
|
|
799
911
|
"app_version" to appVersion,
|
|
800
912
|
)
|
|
801
|
-
|
|
802
|
-
payload["platform"] = "android"
|
|
803
|
-
payload["timestamp"] = java.time.Instant.now().toString()
|
|
804
|
-
payload["visitorId"] = visitorId
|
|
913
|
+
TrackStamps.apply(payload, visitorId, testMode, java.time.Instant.now().toString())
|
|
805
914
|
payload["clientType"] = "native-android"
|
|
806
915
|
payload["mobile"] = mutableMapOf<String, Any?>(
|
|
807
916
|
"releaseChannel" to releaseChannel,
|
|
808
917
|
"releaseChannelSource" to releaseChannelSource,
|
|
809
|
-
)
|
|
918
|
+
).also { stampDevice(it) }
|
|
810
919
|
val event = NativeQueuedEvent(
|
|
811
920
|
id = UUID.randomUUID().toString().lowercase(),
|
|
812
921
|
payload = payload,
|
|
@@ -815,7 +924,7 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
815
924
|
)
|
|
816
925
|
try {
|
|
817
926
|
queue.enqueue(event)
|
|
818
|
-
|
|
927
|
+
kickFlush("track")
|
|
819
928
|
} catch (e: Exception) {
|
|
820
929
|
if (debug) println("[apex-capacitor] exposure enqueue failed: ${e.message}")
|
|
821
930
|
}
|
|
@@ -896,8 +1005,7 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
896
1005
|
// ── Events / Queue ────────────────────────────────────────────────
|
|
897
1006
|
@PluginMethod
|
|
898
1007
|
fun track(call: PluginCall) {
|
|
899
|
-
|
|
900
|
-
if (queue == null) {
|
|
1008
|
+
if (offlineQueue == null) {
|
|
901
1009
|
call.reject("Plugin not initialized — call initialize() first")
|
|
902
1010
|
return
|
|
903
1011
|
}
|
|
@@ -910,6 +1018,23 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
910
1018
|
val id = call.getString("id") ?: UUID.randomUUID().toString().lowercase()
|
|
911
1019
|
val payload = mutableMapOf<String, Any?>()
|
|
912
1020
|
call.data.keys().forEach { key -> payload[key] = call.data.opt(key) }
|
|
1021
|
+
if (payload["type"] == "app_install" && !claimInstall()) {
|
|
1022
|
+
if (debug) println("[apex-capacitor] app_install already sent for this install; dropping")
|
|
1023
|
+
call.resolve()
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
try {
|
|
1027
|
+
enqueuePayload(id, payload)
|
|
1028
|
+
call.resolve()
|
|
1029
|
+
} catch (e: Exception) {
|
|
1030
|
+
call.reject("Failed to enqueue event: ${e.message}")
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Stamp and queue an event. Shared by `track()` and the plugin's own install. */
|
|
1035
|
+
private fun enqueuePayload(id: String, payload: MutableMap<String, Any?>) {
|
|
1036
|
+
val queue = offlineQueue ?: throw IllegalStateException("Plugin not initialized")
|
|
1037
|
+
TrackStamps.apply(payload, visitorId, testMode, java.time.Instant.now().toString())
|
|
913
1038
|
|
|
914
1039
|
// MMP-061 — stamp release channel under event.mobile so the
|
|
915
1040
|
// server's TrackingEvent shape lands the field in the right
|
|
@@ -927,7 +1052,10 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
927
1052
|
}
|
|
928
1053
|
mobileSlot["releaseChannel"] = releaseChannel
|
|
929
1054
|
mobileSlot["releaseChannelSource"] = releaseChannelSource
|
|
1055
|
+
stampDevice(mobileSlot)
|
|
1056
|
+
if (payload["type"] == "app_install") stampInstallReferrers(mobileSlot)
|
|
930
1057
|
payload["mobile"] = mobileSlot
|
|
1058
|
+
if (payload["type"] == "app_open") Thread { resolveGaid() }.start()
|
|
931
1059
|
|
|
932
1060
|
// MMP-081 — runtime context. Native Android plugin always
|
|
933
1061
|
// stamps `native-android`; we don't overwrite a value the
|
|
@@ -944,12 +1072,8 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
944
1072
|
attempts = 0,
|
|
945
1073
|
enqueuedAtMs = System.currentTimeMillis(),
|
|
946
1074
|
)
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
call.resolve()
|
|
950
|
-
} catch (e: Exception) {
|
|
951
|
-
call.reject("Failed to enqueue event: ${e.message}")
|
|
952
|
-
}
|
|
1075
|
+
queue.enqueue(event)
|
|
1076
|
+
kickFlush("track")
|
|
953
1077
|
}
|
|
954
1078
|
|
|
955
1079
|
@PluginMethod
|
|
@@ -1001,11 +1125,8 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
1001
1125
|
consentGranted = granted
|
|
1002
1126
|
if (granted) {
|
|
1003
1127
|
// Resume: flush anything captured before this point.
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
if (queue != null && sender != null) {
|
|
1007
|
-
sender.flush(queue) { _, _ -> }
|
|
1008
|
-
}
|
|
1128
|
+
kickFlush("consent")
|
|
1129
|
+
Thread { sendInstallIfFirstLaunch() }.start()
|
|
1009
1130
|
} else {
|
|
1010
1131
|
// Revoke: drop anything queued but not yet sent.
|
|
1011
1132
|
try {
|
|
@@ -1023,6 +1144,54 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
1023
1144
|
|
|
1024
1145
|
// ── Internal ──────────────────────────────────────────────────────
|
|
1025
1146
|
|
|
1147
|
+
override fun handleOnResume() {
|
|
1148
|
+
super.handleOnResume()
|
|
1149
|
+
kickFlush("foreground")
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
override fun handleOnDestroy() {
|
|
1153
|
+
flushTimer?.let { flushHandler.removeCallbacks(it) }
|
|
1154
|
+
pendingTrackFlush?.let { flushHandler.removeCallbacks(it) }
|
|
1155
|
+
super.handleOnDestroy()
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Sends the queue every 30s so bursts and background captures leave the device. */
|
|
1159
|
+
private fun scheduleFlushTimer() {
|
|
1160
|
+
flushTimer?.let { flushHandler.removeCallbacks(it) }
|
|
1161
|
+
val tick = object : Runnable {
|
|
1162
|
+
override fun run() {
|
|
1163
|
+
kickFlush("timer")
|
|
1164
|
+
flushHandler.postDelayed(this, FLUSH_INTERVAL_MS)
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
flushTimer = tick
|
|
1168
|
+
flushHandler.postDelayed(tick, FLUSH_INTERVAL_MS)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Fire-and-forget send. Track-triggered sends wait 250ms so a burst of
|
|
1173
|
+
* taps goes as one batch. The sender only runs one drain at a time.
|
|
1174
|
+
*/
|
|
1175
|
+
private fun kickFlush(reason: String) {
|
|
1176
|
+
val queue = offlineQueue ?: return
|
|
1177
|
+
val sender = batchSender ?: return
|
|
1178
|
+
val run = Runnable {
|
|
1179
|
+
if (debug) println("[apex-capacitor] flush kicked ($reason)")
|
|
1180
|
+
sender.flush(queue) { flushed, remaining ->
|
|
1181
|
+
if (debug && (flushed > 0 || remaining > 0)) {
|
|
1182
|
+
println("[apex-capacitor] flush done — sent $flushed, remaining $remaining")
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (reason == "track") {
|
|
1187
|
+
pendingTrackFlush?.let { flushHandler.removeCallbacks(it) }
|
|
1188
|
+
pendingTrackFlush = run
|
|
1189
|
+
flushHandler.postDelayed(run, TRACK_FLUSH_DEBOUNCE_MS)
|
|
1190
|
+
} else {
|
|
1191
|
+
run.run()
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1026
1195
|
private fun fetchInstallReferrer() {
|
|
1027
1196
|
val client = InstallReferrerClient.newBuilder(context).build()
|
|
1028
1197
|
client.startConnection(object : InstallReferrerStateListener {
|
|
@@ -1056,8 +1225,71 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
1056
1225
|
})
|
|
1057
1226
|
}
|
|
1058
1227
|
|
|
1228
|
+
/**
|
|
1229
|
+
* The install event carries Play's referrer and Meta's, so Apex can
|
|
1230
|
+
* credit the ad. Waits briefly for Play when it hasn't answered yet,
|
|
1231
|
+
* off the main thread only.
|
|
1232
|
+
*/
|
|
1233
|
+
private fun stampInstallReferrers(slot: MutableMap<String, Any?>) {
|
|
1234
|
+
if (!installReferrerResolved && Looper.myLooper() != Looper.getMainLooper()) {
|
|
1235
|
+
try {
|
|
1236
|
+
referrerLatch.await(REFERRER_WAIT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
1237
|
+
} catch (_: InterruptedException) { }
|
|
1238
|
+
}
|
|
1239
|
+
MetaInstallReferrer.stampInstall(slot, lastInstallReferrer, installReferrerClickSeconds, readMetaReferrer())
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** The Meta app id from the Facebook SDK's manifest entry, when the app has one. */
|
|
1243
|
+
@Suppress("DEPRECATION")
|
|
1244
|
+
private fun manifestMetaAppId(): String? = try {
|
|
1245
|
+
val info = context.packageManager.getApplicationInfo(
|
|
1246
|
+
context.packageName,
|
|
1247
|
+
android.content.pm.PackageManager.GET_META_DATA,
|
|
1248
|
+
)
|
|
1249
|
+
MetaInstallReferrer.normalizeAppId(info.metaData?.get("com.facebook.sdk.ApplicationId")?.toString())
|
|
1250
|
+
} catch (_: Exception) {
|
|
1251
|
+
null
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** Ask the Facebook, Instagram and Facebook Lite apps for the Meta ad behind this install. */
|
|
1255
|
+
private fun readMetaReferrer(): MetaInstallReferrer.Found? {
|
|
1256
|
+
if (metaReferrerRead) return metaReferrer
|
|
1257
|
+
val appId = metaAppId
|
|
1258
|
+
if (appId == null) {
|
|
1259
|
+
metaReferrerRead = true
|
|
1260
|
+
return null
|
|
1261
|
+
}
|
|
1262
|
+
val found = mutableListOf<MetaInstallReferrer.Found>()
|
|
1263
|
+
for ((authority, app) in MetaInstallReferrer.PROVIDERS) {
|
|
1264
|
+
try {
|
|
1265
|
+
if (context.packageManager.resolveContentProvider(authority, 0) == null) continue
|
|
1266
|
+
val uri = Uri.parse("content://$authority/$appId")
|
|
1267
|
+
context.contentResolver.query(uri, MetaInstallReferrer.COLUMNS, null, null, null)?.use { c ->
|
|
1268
|
+
if (!c.moveToFirst()) return@use
|
|
1269
|
+
val refIdx = c.getColumnIndex("install_referrer")
|
|
1270
|
+
if (refIdx < 0 || c.isNull(refIdx)) return@use
|
|
1271
|
+
val ctIdx = c.getColumnIndex("is_ct")
|
|
1272
|
+
val tsIdx = c.getColumnIndex("actual_timestamp")
|
|
1273
|
+
found += MetaInstallReferrer.Found(
|
|
1274
|
+
referrer = c.getString(refIdx),
|
|
1275
|
+
isClickThrough = if (ctIdx >= 0 && !c.isNull(ctIdx)) c.getInt(ctIdx) == 1 else null,
|
|
1276
|
+
timestampSeconds = if (tsIdx >= 0 && !c.isNull(tsIdx)) c.getLong(tsIdx) else null,
|
|
1277
|
+
app = app,
|
|
1278
|
+
)
|
|
1279
|
+
}
|
|
1280
|
+
} catch (e: Exception) {
|
|
1281
|
+
if (debug) println("[apex-capacitor] Meta referrer from $app failed: ${e.message}")
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
metaReferrer = MetaInstallReferrer.latest(found)
|
|
1285
|
+
metaReferrerRead = true
|
|
1286
|
+
if (debug) println("[apex-capacitor] Meta install referrer ${if (metaReferrer != null) "found (${metaReferrer?.app})" else "not found"}")
|
|
1287
|
+
return metaReferrer
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1059
1290
|
private fun flushPendingReferrerCalls() {
|
|
1060
1291
|
installReferrerResolved = true
|
|
1292
|
+
referrerLatch.countDown()
|
|
1061
1293
|
val waiting = synchronized(pendingReferrerCalls) {
|
|
1062
1294
|
val copy = pendingReferrerCalls.toList()
|
|
1063
1295
|
pendingReferrerCalls.clear()
|
|
@@ -1090,6 +1322,9 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
1090
1322
|
*/
|
|
1091
1323
|
private const val REFERRER_WAIT_MS = 2500L
|
|
1092
1324
|
|
|
1325
|
+
private const val FLUSH_INTERVAL_MS = 30_000L
|
|
1326
|
+
private const val TRACK_FLUSH_DEBOUNCE_MS = 250L
|
|
1327
|
+
|
|
1093
1328
|
/**
|
|
1094
1329
|
* Register a (possibly refreshed) FCM token with Apex from the
|
|
1095
1330
|
* messaging service. Reads the persisted workspaceKey / apiBaseUrl
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package inc.apex.capacitor
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether this launch should send `app_install`. The plugin sends it once
|
|
5
|
+
* per install so every app counts installs the same way; the app's own
|
|
6
|
+
* `app_install` is dropped once one has gone.
|
|
7
|
+
*
|
|
8
|
+
* Someone who had the app before Apex was added is not a new install: the
|
|
9
|
+
* update that brings Apex must not report every existing user to the ad
|
|
10
|
+
* networks as one. A fresh install is one made in the last 7 days, or one
|
|
11
|
+
* never updated since (the version installed is the one running).
|
|
12
|
+
*/
|
|
13
|
+
internal object FirstLaunchInstall {
|
|
14
|
+
const val PREF_KEY = "installSent"
|
|
15
|
+
const val FRESH_WINDOW_MS: Long = 7L * 24 * 3600 * 1000
|
|
16
|
+
private const val SAME_VERSION_TOLERANCE_MS: Long = 5L * 60 * 1000
|
|
17
|
+
|
|
18
|
+
enum class Decision { SEND, ALREADY_SENT, EXISTING_USER }
|
|
19
|
+
|
|
20
|
+
fun decide(alreadySent: Boolean, installedMs: Long?, versionInstalledMs: Long?, nowMs: Long): Decision {
|
|
21
|
+
if (alreadySent) return Decision.ALREADY_SENT
|
|
22
|
+
if (installedMs == null || installedMs <= 0) return Decision.SEND
|
|
23
|
+
if (versionInstalledMs != null && kotlin.math.abs(versionInstalledMs - installedMs) <= SAME_VERSION_TOLERANCE_MS) {
|
|
24
|
+
return Decision.SEND
|
|
25
|
+
}
|
|
26
|
+
return if (nowMs - installedMs <= FRESH_WINDOW_MS) Decision.SEND else Decision.EXISTING_USER
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package inc.apex.capacitor
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Meta Install Referrer: the Facebook, Instagram and Facebook Lite apps
|
|
5
|
+
* keep the Meta ad a person tapped or saw before installing, and hand it to
|
|
6
|
+
* the app that was installed. Play's referrer only carries a tap that led
|
|
7
|
+
* straight to the install; this also carries views and earlier taps.
|
|
8
|
+
*
|
|
9
|
+
* The campaign inside is encrypted with the app owner's key. The plugin
|
|
10
|
+
* never decrypts it; it goes to Apex on the `app_install` event and Apex
|
|
11
|
+
* opens it with the key the shop saved in the Meta connection.
|
|
12
|
+
*
|
|
13
|
+
* Pure logic here (no Android imports) so it runs under plain JUnit. The
|
|
14
|
+
* ContentResolver query lives in the plugin.
|
|
15
|
+
*/
|
|
16
|
+
internal object MetaInstallReferrer {
|
|
17
|
+
|
|
18
|
+
/** Content provider authority → the app name Apex records. */
|
|
19
|
+
val PROVIDERS: List<Pair<String, String>> = listOf(
|
|
20
|
+
"com.facebook.katana.provider.InstallReferrerProvider" to "facebook",
|
|
21
|
+
"com.instagram.contentprovider.InstallReferrerProvider" to "instagram",
|
|
22
|
+
"com.facebook.lite.provider.InstallReferrerProvider" to "facebook_lite",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
val COLUMNS: Array<String> = arrayOf("install_referrer", "is_ct", "actual_timestamp")
|
|
26
|
+
|
|
27
|
+
data class Found(
|
|
28
|
+
val referrer: String,
|
|
29
|
+
/** Null when the Meta app didn't say tap or view. */
|
|
30
|
+
val isClickThrough: Boolean?,
|
|
31
|
+
/** When the tap or view happened, in seconds. */
|
|
32
|
+
val timestampSeconds: Long?,
|
|
33
|
+
val app: String,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The Meta app id as the provider path wants it: digits only. Accepts
|
|
38
|
+
* the Facebook SDK's `fb1234…` form as well as the plain id.
|
|
39
|
+
*/
|
|
40
|
+
fun normalizeAppId(raw: String?): String? {
|
|
41
|
+
val trimmed = raw?.trim()?.removePrefix("fb") ?: return null
|
|
42
|
+
return if (trimmed.isNotEmpty() && trimmed.all { it.isDigit() }) trimmed else null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The most recent touch wins when more than one Meta app answered. */
|
|
46
|
+
fun latest(found: List<Found>): Found? =
|
|
47
|
+
found.filter { it.referrer.isNotBlank() }.maxByOrNull { it.timestampSeconds ?: 0L }
|
|
48
|
+
|
|
49
|
+
/** What goes under `event.mobile.metaInstallReferrer`. */
|
|
50
|
+
fun toSlot(found: Found): Map<String, Any?> {
|
|
51
|
+
val slot = mutableMapOf<String, Any?>("referrer" to found.referrer, "app" to found.app)
|
|
52
|
+
found.isClickThrough?.let { slot["isClickThrough"] = it }
|
|
53
|
+
found.timestampSeconds?.let { if (it > 0) slot["timestamp"] = it }
|
|
54
|
+
return slot
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stamp the install's referrers on an `app_install` event, keeping
|
|
59
|
+
* anything the app already set.
|
|
60
|
+
*/
|
|
61
|
+
fun stampInstall(
|
|
62
|
+
slot: MutableMap<String, Any?>,
|
|
63
|
+
playReferrer: String?,
|
|
64
|
+
playClickSeconds: Long,
|
|
65
|
+
meta: Found?,
|
|
66
|
+
) {
|
|
67
|
+
if (slot["installReferrer"] == null && !playReferrer.isNullOrBlank()) {
|
|
68
|
+
slot["installReferrer"] = playReferrer
|
|
69
|
+
if (playClickSeconds > 0) slot["installReferrerClickTimestamp"] = playClickSeconds
|
|
70
|
+
}
|
|
71
|
+
if (slot["metaInstallReferrer"] == null && meta != null) {
|
|
72
|
+
slot["metaInstallReferrer"] = toSlot(meta)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|