@apex-inc/capacitor-plugin 0.3.9 → 2.1.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/android/build.gradle +4 -2
- package/android/src/main/AndroidManifest.xml +35 -3
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +403 -6
- package/android/src/main/java/inc/apex/capacitor/ApexEvents.kt +122 -0
- package/android/src/main/java/inc/apex/capacitor/ApexFirebaseMessagingService.kt +99 -0
- package/android/src/main/java/inc/apex/capacitor/NativeBatchSender.kt +260 -0
- package/dist/batch-sender.d.ts +12 -0
- package/dist/batch-sender.d.ts.map +1 -1
- package/dist/batch-sender.js +25 -0
- package/dist/batch-sender.js.map +1 -1
- package/dist/cart-helpers.d.ts +63 -0
- package/dist/cart-helpers.d.ts.map +1 -0
- package/dist/cart-helpers.js +50 -0
- package/dist/cart-helpers.js.map +1 -0
- package/dist/definitions.d.ts +142 -2
- package/dist/definitions.d.ts.map +1 -1
- package/dist/esm/batch-sender.d.ts +12 -0
- package/dist/esm/batch-sender.d.ts.map +1 -1
- package/dist/esm/batch-sender.js +25 -0
- package/dist/esm/batch-sender.js.map +1 -1
- package/dist/esm/cart-helpers.d.ts +63 -0
- package/dist/esm/cart-helpers.d.ts.map +1 -0
- package/dist/esm/cart-helpers.js +44 -0
- package/dist/esm/cart-helpers.js.map +1 -0
- package/dist/esm/definitions.d.ts +142 -2
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/events.d.ts +85 -0
- package/dist/esm/events.d.ts.map +1 -0
- package/dist/esm/events.js +96 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +4 -5
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +19 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/offline-queue.d.ts +15 -0
- package/dist/esm/offline-queue.d.ts.map +1 -1
- package/dist/esm/offline-queue.js +35 -0
- package/dist/esm/offline-queue.js.map +1 -1
- package/dist/esm/screen-view.d.ts +18 -0
- package/dist/esm/screen-view.d.ts.map +1 -0
- package/dist/esm/screen-view.js +28 -0
- package/dist/esm/screen-view.js.map +1 -0
- package/dist/esm/web.d.ts +29 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +161 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/events.d.ts +85 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +99 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -2
- package/dist/index.js.map +1 -1
- package/dist/offline-queue.d.ts +15 -0
- package/dist/offline-queue.d.ts.map +1 -1
- package/dist/offline-queue.js +35 -0
- package/dist/offline-queue.js.map +1 -1
- package/dist/screen-view.d.ts +18 -0
- package/dist/screen-view.d.ts.map +1 -0
- package/dist/screen-view.js +31 -0
- package/dist/screen-view.js.map +1 -0
- package/dist/web.d.ts +29 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +161 -0
- package/dist/web.js.map +1 -1
- package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +124 -0
- package/ios/Sources/ApexCapacitorPlugin/BatchSender.swift +18 -0
- package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +19 -0
- package/ios/Sources/ApexCapacitorPlugin/PushNotificationManager.swift +47 -0
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +280 -20
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -57,8 +57,10 @@ dependencies {
|
|
|
57
57
|
implementation "com.android.installreferrer:installreferrer:$installreferrerVersion"
|
|
58
58
|
// Play Services Ads for GAID
|
|
59
59
|
implementation "com.google.android.gms:play-services-ads-identifier:18.0.1"
|
|
60
|
-
// Firebase Messaging for FCM push tokens
|
|
61
|
-
compileOnly
|
|
60
|
+
// Firebase Messaging for FCM push tokens + alert notifications.
|
|
61
|
+
// `implementation` (not compileOnly) so the messaging service +
|
|
62
|
+
// token APIs are available at runtime for Android push.
|
|
63
|
+
implementation "com.google.firebase:firebase-messaging:23.4.0"
|
|
62
64
|
|
|
63
65
|
testImplementation "junit:junit:$junitVersion"
|
|
64
66
|
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
|
|
@@ -1,8 +1,40 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="utf-8"?>
|
|
2
1
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
3
|
-
<!-- Access Google Advertising ID (GAID) on API 24+. -->
|
|
4
2
|
<uses-permission android:name="com.google.android.gms.permission.AD_ID" />
|
|
5
|
-
<!-- Network required for batch-sender event delivery. -->
|
|
6
3
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
7
4
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
5
|
+
<!-- Android 13+ runtime permission for posting notifications (FCM alerts). -->
|
|
6
|
+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
7
|
+
|
|
8
|
+
<!-- FCM messaging service: receives the device token (onNewToken) and
|
|
9
|
+
alert messages (onMessageReceived). Merged into the host app's
|
|
10
|
+
manifest by the Android build. -->
|
|
11
|
+
<application>
|
|
12
|
+
<service
|
|
13
|
+
android:name="inc.apex.capacitor.ApexFirebaseMessagingService"
|
|
14
|
+
android:exported="false">
|
|
15
|
+
<intent-filter>
|
|
16
|
+
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
|
17
|
+
</intent-filter>
|
|
18
|
+
</service>
|
|
19
|
+
</application>
|
|
20
|
+
|
|
21
|
+
<!--
|
|
22
|
+
MMP-061 — release channel detection. On Android 11+ (API 30),
|
|
23
|
+
`PackageManager.getInstallerPackageName(pkg)` returns null unless
|
|
24
|
+
the calling package declares a <queries> element listing the
|
|
25
|
+
installers it cares about. Without this, half of the Play
|
|
26
|
+
Store + sideloaded device base would read as "unknown" / "sideloaded".
|
|
27
|
+
|
|
28
|
+
We use `PackageManager.getInstallSourceInfo()` on API 30+ (no
|
|
29
|
+
<queries> needed for the caller's OWN package). The query block
|
|
30
|
+
below explicitly lists Play + AOSP installer packages so the
|
|
31
|
+
legacy `getInstallerPackageName` fallback on API 29 and below
|
|
32
|
+
also resolves correctly.
|
|
33
|
+
-->
|
|
34
|
+
<queries>
|
|
35
|
+
<package android:name="com.android.vending" />
|
|
36
|
+
<package android:name="com.google.android.feedback" />
|
|
37
|
+
<package android:name="com.amazon.venezia" />
|
|
38
|
+
<package android:name="com.sec.android.app.samsungapps" />
|
|
39
|
+
</queries>
|
|
8
40
|
</manifest>
|
|
@@ -13,6 +13,7 @@ import com.getcapacitor.PluginCall
|
|
|
13
13
|
import com.getcapacitor.PluginMethod
|
|
14
14
|
import com.getcapacitor.annotation.CapacitorPlugin
|
|
15
15
|
import com.google.android.gms.ads.identifier.AdvertisingIdClient
|
|
16
|
+
import com.google.firebase.messaging.FirebaseMessaging
|
|
16
17
|
import java.io.File
|
|
17
18
|
import java.util.Locale
|
|
18
19
|
import java.util.TimeZone
|
|
@@ -47,11 +48,23 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
private var offlineQueue: NativeOfflineQueue? = null
|
|
51
|
+
private var batchSender: NativeBatchSender? = null
|
|
50
52
|
private var prefs: SharedPreferences? = null
|
|
51
53
|
private var visitorId: String = ""
|
|
52
54
|
private var testMode: Boolean = false
|
|
53
55
|
private var debug: Boolean = false
|
|
54
56
|
private var lastInstallReferrer: String? = null
|
|
57
|
+
// Phase 2-SDK — workspace-bound SDK key forwarded as
|
|
58
|
+
// `x-apex-api-key` on every event POST + on `identify()`.
|
|
59
|
+
private var apiKey: String? = null
|
|
60
|
+
private var apiBaseUrl: String = "https://api.apex.inc"
|
|
61
|
+
private var projectKey: String = ""
|
|
62
|
+
|
|
63
|
+
// MMP-061 — cached release channel + source. Detected once per
|
|
64
|
+
// launch in `initialize()` from installer-package source +
|
|
65
|
+
// `BuildConfig.DEBUG` + optional JS override.
|
|
66
|
+
private var releaseChannel: String = "unknown"
|
|
67
|
+
private var releaseChannelSource: String = "auto"
|
|
55
68
|
|
|
56
69
|
override fun load() {
|
|
57
70
|
val ctx = context
|
|
@@ -72,17 +85,50 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
72
85
|
deepLinks.setInitialUrl(url)
|
|
73
86
|
}
|
|
74
87
|
}
|
|
88
|
+
|
|
89
|
+
// Register as the active plugin so the FCM messaging service can
|
|
90
|
+
// forward token refreshes + taps to JS listeners.
|
|
91
|
+
activePlugin = this
|
|
92
|
+
|
|
93
|
+
// If the app was launched by tapping an FCM notification, the
|
|
94
|
+
// launcher intent carries the apex push data as extras — forward
|
|
95
|
+
// it as `pushClicked` so the app can report engagement + route.
|
|
96
|
+
activity.intent?.extras?.let { extras ->
|
|
97
|
+
if (extras.getString("apex_messageId") != null ||
|
|
98
|
+
extras.getString("apex_apexToken") != null ||
|
|
99
|
+
extras.getString("apex_url") != null
|
|
100
|
+
) {
|
|
101
|
+
emitPushClicked(
|
|
102
|
+
url = extras.getString("apex_url"),
|
|
103
|
+
messageId = extras.getString("apex_messageId"),
|
|
104
|
+
apexToken = extras.getString("apex_apexToken"),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Forward a push tap to JS listeners (`pushClicked`). */
|
|
111
|
+
private fun emitPushClicked(url: String?, messageId: String?, apexToken: String?) {
|
|
112
|
+
val data = JSObject()
|
|
113
|
+
if (url != null) data.put("url", url)
|
|
114
|
+
if (messageId != null) data.put("messageId", messageId)
|
|
115
|
+
if (apexToken != null) data.put("apexToken", apexToken)
|
|
116
|
+
notifyListeners("pushClicked", JSObject().put("data", data))
|
|
75
117
|
}
|
|
76
118
|
|
|
77
119
|
@PluginMethod
|
|
78
120
|
fun initialize(call: PluginCall) {
|
|
79
|
-
val
|
|
80
|
-
if (
|
|
121
|
+
val pk = call.getString("projectKey")
|
|
122
|
+
if (pk.isNullOrEmpty()) {
|
|
81
123
|
call.reject("projectKey is required")
|
|
82
124
|
return
|
|
83
125
|
}
|
|
126
|
+
projectKey = pk
|
|
84
127
|
testMode = call.getBoolean("testMode", false) ?: false
|
|
85
128
|
debug = call.getBoolean("debug", false) ?: false
|
|
129
|
+
apiBaseUrl = call.getString("apiUrl") ?: "https://api.apex.inc"
|
|
130
|
+
val rawKey = call.getString("apiKey")
|
|
131
|
+
apiKey = if (rawKey.isNullOrEmpty()) null else rawKey
|
|
86
132
|
val maxSize = call.getInt("offlineQueueMaxSize") ?: 1000
|
|
87
133
|
|
|
88
134
|
val queueFile = File(context.filesDir, "apex-capacitor/events.json")
|
|
@@ -90,12 +136,200 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
90
136
|
val storage = FileQueueStorage(queueFile)
|
|
91
137
|
offlineQueue = NativeOfflineQueue(storage, maxSize = maxSize)
|
|
92
138
|
|
|
139
|
+
// MMP-215 — drain the queue. Without this, every event captured
|
|
140
|
+
// while the webview was unmounted is stranded in apex-capacitor/
|
|
141
|
+
// events.json forever.
|
|
142
|
+
batchSender = NativeBatchSender(
|
|
143
|
+
apiBaseUrl = apiBaseUrl,
|
|
144
|
+
projectKey = projectKey,
|
|
145
|
+
platformHeader = "android",
|
|
146
|
+
apiKey = apiKey,
|
|
147
|
+
debug = debug,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// MMP-061 — release channel detection. Unlike iOS, Play doesn't
|
|
151
|
+
// surface internal/closed/open tracks at the device level
|
|
152
|
+
// (`com.android.vending` is the installer for every Play install).
|
|
153
|
+
// So an explicit override here is the canonical Android path
|
|
154
|
+
// for tagging closed-test builds until Play Console API
|
|
155
|
+
// reconciliation lands (MMP-069). Wire `Apex.initialize({
|
|
156
|
+
// releaseChannel: BuildConfig.RELEASE_CHANNEL })` from your
|
|
157
|
+
// Gradle build flavors and the channel reflects the flavor.
|
|
158
|
+
val override = call.getString("releaseChannel")
|
|
159
|
+
if (!override.isNullOrEmpty() && isValidReleaseChannel(override)) {
|
|
160
|
+
releaseChannel = override
|
|
161
|
+
releaseChannelSource = "override"
|
|
162
|
+
if (debug) {
|
|
163
|
+
println("[apex-capacitor] releaseChannel override = $override")
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
releaseChannel = detectReleaseChannel()
|
|
167
|
+
releaseChannelSource = "auto"
|
|
168
|
+
if (debug) {
|
|
169
|
+
println("[apex-capacitor] releaseChannel detected = $releaseChannel")
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Persist the bits the FCM messaging service needs to register a
|
|
174
|
+
// refreshed token on its own (it runs in a separate process
|
|
175
|
+
// context without access to the plugin's in-memory fields).
|
|
176
|
+
prefs?.edit()
|
|
177
|
+
?.putString("projectKey", projectKey)
|
|
178
|
+
?.putString("apiBaseUrl", apiBaseUrl)
|
|
179
|
+
?.putString("apiKey", apiKey)
|
|
180
|
+
?.apply()
|
|
181
|
+
|
|
93
182
|
// Fire-and-forget install referrer retrieval on init.
|
|
94
183
|
fetchInstallReferrer()
|
|
95
184
|
|
|
96
185
|
call.resolve()
|
|
97
186
|
}
|
|
98
187
|
|
|
188
|
+
// ── Push notifications (FCM) ──────────────────────────────────────
|
|
189
|
+
@PluginMethod
|
|
190
|
+
fun registerForPushNotifications(call: PluginCall) {
|
|
191
|
+
try {
|
|
192
|
+
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
|
193
|
+
if (!task.isSuccessful) {
|
|
194
|
+
call.resolve(
|
|
195
|
+
JSObject().put("permission", "denied").put("token", null as String?),
|
|
196
|
+
)
|
|
197
|
+
return@addOnCompleteListener
|
|
198
|
+
}
|
|
199
|
+
val token = task.result
|
|
200
|
+
if (token.isNullOrEmpty()) {
|
|
201
|
+
call.resolve(
|
|
202
|
+
JSObject().put("permission", "denied").put("token", null as String?),
|
|
203
|
+
)
|
|
204
|
+
return@addOnCompleteListener
|
|
205
|
+
}
|
|
206
|
+
// Auto-register with Apex (same contract as iOS) so the
|
|
207
|
+
// dashboard sees the device immediately.
|
|
208
|
+
registerFcmToken(token)
|
|
209
|
+
notifyListeners(
|
|
210
|
+
"pushTokenReceived",
|
|
211
|
+
JSObject().put("token", token).put("platform", "android"),
|
|
212
|
+
)
|
|
213
|
+
call.resolve(
|
|
214
|
+
JSObject().put("permission", "granted").put("token", token),
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
} catch (e: Exception) {
|
|
218
|
+
call.reject("FCM registration failed: ${e.message ?: "unknown"}")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* POST the FCM token to Apex's `/api/mobile/push-token` (same route
|
|
224
|
+
* the iOS plugin auto-registers against). Best-effort + background.
|
|
225
|
+
*/
|
|
226
|
+
private fun registerFcmToken(token: String) {
|
|
227
|
+
val pk = projectKey
|
|
228
|
+
if (pk.isEmpty()) return
|
|
229
|
+
val appVersion = try {
|
|
230
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
|
231
|
+
} catch (_: Exception) {
|
|
232
|
+
null
|
|
233
|
+
}
|
|
234
|
+
val payload = JSObject()
|
|
235
|
+
.put("projectKey", pk)
|
|
236
|
+
.put("visitorId", visitorId)
|
|
237
|
+
.put("platform", "android")
|
|
238
|
+
.put("token", token)
|
|
239
|
+
.put("bundleId", context.packageName)
|
|
240
|
+
.put("appVersion", appVersion ?: "")
|
|
241
|
+
Thread {
|
|
242
|
+
try {
|
|
243
|
+
val url = java.net.URL("${apiBaseUrl.trimEnd('/')}/api/mobile/push-token")
|
|
244
|
+
val conn = url.openConnection() as java.net.HttpURLConnection
|
|
245
|
+
conn.requestMethod = "POST"
|
|
246
|
+
conn.doOutput = true
|
|
247
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
248
|
+
apiKey?.let { conn.setRequestProperty("x-apex-api-key", it) }
|
|
249
|
+
conn.connectTimeout = 10000
|
|
250
|
+
conn.readTimeout = 15000
|
|
251
|
+
conn.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
|
|
252
|
+
conn.responseCode
|
|
253
|
+
conn.disconnect()
|
|
254
|
+
} catch (e: Exception) {
|
|
255
|
+
if (debug) println("[apex-capacitor] FCM token register failed: ${e.message}")
|
|
256
|
+
}
|
|
257
|
+
}.start()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* MMP-061 — detect the build's `releaseChannel` from the installer
|
|
262
|
+
* package source + `BuildConfig.DEBUG`.
|
|
263
|
+
*
|
|
264
|
+
* Detection waterfall:
|
|
265
|
+
* 1. `BuildConfig.DEBUG=true` → `xcode-debug` (Gradle debug).
|
|
266
|
+
* Returned before installer-source so debug builds installed
|
|
267
|
+
* via Play Internal still report as `xcode-debug` for the
|
|
268
|
+
* developer's own machine.
|
|
269
|
+
* 2. **API 30+** uses `PackageManager.getInstallSourceInfo()` —
|
|
270
|
+
* doesn't require a `<queries>` declaration for the caller's
|
|
271
|
+
* own package, so it works regardless of manifest.
|
|
272
|
+
* 3. **API 29 and below** uses the deprecated
|
|
273
|
+
* `getInstallerPackageName()`. Works on every API level but
|
|
274
|
+
* requires the `<queries>` block we added in the manifest so
|
|
275
|
+
* the result isn't silently null on API 30+.
|
|
276
|
+
*
|
|
277
|
+
* Mapping (post-API-version split):
|
|
278
|
+
* - `com.android.vending` (Play Store) → `play-production`
|
|
279
|
+
* - `com.google.android.feedback` (legacy Play) → `play-production`
|
|
280
|
+
* - any other non-null installer (Amazon, Samsung) → `sideloaded`
|
|
281
|
+
* - `null` (sideloaded APK, adb install, unknown) → `sideloaded`
|
|
282
|
+
*/
|
|
283
|
+
private fun detectReleaseChannel(): String {
|
|
284
|
+
// Step 1 — Gradle DEBUG build.
|
|
285
|
+
try {
|
|
286
|
+
val buildConfigClass = Class.forName("${context.packageName}.BuildConfig")
|
|
287
|
+
val isDebug = buildConfigClass.getField("DEBUG").getBoolean(null)
|
|
288
|
+
if (isDebug) return "xcode-debug"
|
|
289
|
+
} catch (_: Throwable) {
|
|
290
|
+
// BuildConfig might not be reachable in some test environments.
|
|
291
|
+
// Fall through to installer-source detection.
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Step 2 / 3 — installer source.
|
|
295
|
+
val installer: String? = try {
|
|
296
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
297
|
+
val info = context.packageManager.getInstallSourceInfo(context.packageName)
|
|
298
|
+
info.installingPackageName
|
|
299
|
+
} else {
|
|
300
|
+
@Suppress("DEPRECATION")
|
|
301
|
+
context.packageManager.getInstallerPackageName(context.packageName)
|
|
302
|
+
}
|
|
303
|
+
} catch (_: Exception) {
|
|
304
|
+
null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return when (installer) {
|
|
308
|
+
"com.android.vending" -> "play-production"
|
|
309
|
+
"com.google.android.feedback" -> "play-production"
|
|
310
|
+
null -> "sideloaded"
|
|
311
|
+
else -> "sideloaded"
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Validate a JS-supplied `releaseChannel` override. Anything we don't
|
|
317
|
+
* recognise gets ignored (we fall back to auto-detection). Mirrors
|
|
318
|
+
* the type union in `packages/capacitor/src/definitions.ts`.
|
|
319
|
+
*/
|
|
320
|
+
private fun isValidReleaseChannel(channel: String): Boolean {
|
|
321
|
+
return when (channel) {
|
|
322
|
+
"xcode-debug",
|
|
323
|
+
"testflight",
|
|
324
|
+
"app-store",
|
|
325
|
+
"play-internal",
|
|
326
|
+
"play-production",
|
|
327
|
+
"sideloaded",
|
|
328
|
+
"unknown" -> true
|
|
329
|
+
else -> false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
99
333
|
// ── ATT ───────────────────────────────────────────────────────────
|
|
100
334
|
@PluginMethod
|
|
101
335
|
fun requestTrackingAuthorization(call: PluginCall) {
|
|
@@ -160,6 +394,67 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
160
394
|
call.resolve()
|
|
161
395
|
}
|
|
162
396
|
|
|
397
|
+
// ── Identity (Phase 2-SDK) ────────────────────────────────────────
|
|
398
|
+
//
|
|
399
|
+
// POSTs to `${apiBaseUrl}/api/identity/stitch` with the current
|
|
400
|
+
// visitor id + supplied email + optional userId/traits. The server
|
|
401
|
+
// resolves the email to an Apex Contact, stamps verifiedAt = now,
|
|
402
|
+
// and runs the verified-mode downstream pipeline (affiliate
|
|
403
|
+
// stamping, scoring, journey dispatch).
|
|
404
|
+
//
|
|
405
|
+
// We use a background thread (`Thread {}`) rather than coroutines
|
|
406
|
+
// to keep the plugin's dependency footprint minimal — adding
|
|
407
|
+
// kotlinx.coroutines here would bloat every consumer's APK by ~1MB.
|
|
408
|
+
@PluginMethod
|
|
409
|
+
fun identify(call: PluginCall) {
|
|
410
|
+
val email = call.getString("email")?.trim()
|
|
411
|
+
if (email.isNullOrEmpty()) {
|
|
412
|
+
call.reject("email is required")
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
val userId = call.getString("userId")
|
|
416
|
+
val traits = call.getObject("traits")
|
|
417
|
+
|
|
418
|
+
val payload = JSObject()
|
|
419
|
+
.put("visitorId", visitorId)
|
|
420
|
+
.put("email", email)
|
|
421
|
+
.put("projectKey", projectKey)
|
|
422
|
+
.put("timestamp", java.time.Instant.now().toString())
|
|
423
|
+
if (!userId.isNullOrEmpty()) {
|
|
424
|
+
payload.put("userId", userId)
|
|
425
|
+
}
|
|
426
|
+
if (traits != null) {
|
|
427
|
+
payload.put("metadata", traits)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
Thread {
|
|
431
|
+
try {
|
|
432
|
+
val url = java.net.URL("${apiBaseUrl.trimEnd('/')}/api/identity/stitch")
|
|
433
|
+
val conn = url.openConnection() as java.net.HttpURLConnection
|
|
434
|
+
conn.requestMethod = "POST"
|
|
435
|
+
conn.doOutput = true
|
|
436
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
437
|
+
conn.setRequestProperty("x-apex-source", "sdk")
|
|
438
|
+
conn.setRequestProperty("x-apex-project", projectKey)
|
|
439
|
+
apiKey?.let { conn.setRequestProperty("x-apex-api-key", it) }
|
|
440
|
+
conn.connectTimeout = 10000
|
|
441
|
+
conn.readTimeout = 15000
|
|
442
|
+
conn.outputStream.use { os ->
|
|
443
|
+
os.write(payload.toString().toByteArray(Charsets.UTF_8))
|
|
444
|
+
}
|
|
445
|
+
val code = conn.responseCode
|
|
446
|
+
conn.disconnect()
|
|
447
|
+
if (code in 200..299) {
|
|
448
|
+
call.resolve()
|
|
449
|
+
} else {
|
|
450
|
+
call.reject("identify: server rejected (HTTP $code)")
|
|
451
|
+
}
|
|
452
|
+
} catch (e: Exception) {
|
|
453
|
+
call.reject("identify network error: ${e.message ?: "unknown"}")
|
|
454
|
+
}
|
|
455
|
+
}.start()
|
|
456
|
+
}
|
|
457
|
+
|
|
163
458
|
// ── SKAN (iOS-only) ───────────────────────────────────────────────
|
|
164
459
|
@PluginMethod
|
|
165
460
|
fun updateConversionValue(call: PluginCall) {
|
|
@@ -196,6 +491,10 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
196
491
|
.put("bundleId", context.packageName)
|
|
197
492
|
.put("timezone", TimeZone.getDefault().id)
|
|
198
493
|
.put("locale", localeTag)
|
|
494
|
+
// MMP-061 — surface release channel for sample apps and
|
|
495
|
+
// developer-facing UIs.
|
|
496
|
+
.put("releaseChannel", releaseChannel)
|
|
497
|
+
.put("releaseChannelSource", releaseChannelSource)
|
|
199
498
|
call.resolve(data)
|
|
200
499
|
}
|
|
201
500
|
|
|
@@ -237,6 +536,33 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
237
536
|
val payload = mutableMapOf<String, Any?>()
|
|
238
537
|
call.data.keys().forEach { key -> payload[key] = call.data.opt(key) }
|
|
239
538
|
|
|
539
|
+
// MMP-061 — stamp release channel under event.mobile so the
|
|
540
|
+
// server's TrackingEvent shape lands the field in the right
|
|
541
|
+
// slot. On Android, installer source doesn't change post-install
|
|
542
|
+
// (a sideload-then-Play-upgrade still reports `sideloaded`), so
|
|
543
|
+
// unlike iOS we don't re-detect on `app_open`.
|
|
544
|
+
val mobileSlot: MutableMap<String, Any?> = when (val existing = payload["mobile"]) {
|
|
545
|
+
is JSObject -> {
|
|
546
|
+
val map = mutableMapOf<String, Any?>()
|
|
547
|
+
existing.keys().forEach { k -> map[k] = existing.opt(k) }
|
|
548
|
+
map
|
|
549
|
+
}
|
|
550
|
+
is Map<*, *> -> existing.entries.associate { (k, v) -> (k as String) to v }.toMutableMap()
|
|
551
|
+
else -> mutableMapOf()
|
|
552
|
+
}
|
|
553
|
+
mobileSlot["releaseChannel"] = releaseChannel
|
|
554
|
+
mobileSlot["releaseChannelSource"] = releaseChannelSource
|
|
555
|
+
payload["mobile"] = mobileSlot
|
|
556
|
+
|
|
557
|
+
// MMP-081 — runtime context. Native Android plugin always
|
|
558
|
+
// stamps `native-android`; we don't overwrite a value the
|
|
559
|
+
// caller already set (preserves test fixtures and unusual
|
|
560
|
+
// workflows). Stamped at enqueue time so it travels with the
|
|
561
|
+
// event whichever sender drains the queue.
|
|
562
|
+
if (payload["clientType"] == null) {
|
|
563
|
+
payload["clientType"] = "native-android"
|
|
564
|
+
}
|
|
565
|
+
|
|
240
566
|
val event = NativeQueuedEvent(
|
|
241
567
|
id = id,
|
|
242
568
|
payload = payload,
|
|
@@ -269,10 +595,22 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
269
595
|
@PluginMethod
|
|
270
596
|
fun flushQueue(call: PluginCall) {
|
|
271
597
|
val queue = offlineQueue
|
|
272
|
-
val
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
598
|
+
val sender = batchSender
|
|
599
|
+
if (queue == null || sender == null) {
|
|
600
|
+
// Plugin not initialized — preserve the prior no-op shape so
|
|
601
|
+
// callers don't have to special-case this branch.
|
|
602
|
+
val data = JSObject()
|
|
603
|
+
.put("flushed", 0)
|
|
604
|
+
.put("remaining", 0)
|
|
605
|
+
call.resolve(data)
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
sender.flush(queue) { flushed, remaining ->
|
|
609
|
+
val data = JSObject()
|
|
610
|
+
.put("flushed", flushed)
|
|
611
|
+
.put("remaining", remaining)
|
|
612
|
+
call.resolve(data)
|
|
613
|
+
}
|
|
276
614
|
}
|
|
277
615
|
|
|
278
616
|
@PluginMethod
|
|
@@ -304,6 +642,65 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
304
642
|
override fun onInstallReferrerServiceDisconnected() { }
|
|
305
643
|
})
|
|
306
644
|
}
|
|
645
|
+
|
|
646
|
+
companion object {
|
|
647
|
+
/**
|
|
648
|
+
* Weak-ish reference to the live plugin so the FCM messaging
|
|
649
|
+
* service (a separate Android component) can forward a refreshed
|
|
650
|
+
* token / a tap to JS listeners when the webview is alive.
|
|
651
|
+
*/
|
|
652
|
+
@Volatile
|
|
653
|
+
var activePlugin: ApexCapacitorPlugin? = null
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Register a (possibly refreshed) FCM token with Apex from the
|
|
657
|
+
* messaging service. Reads the persisted projectKey / apiBaseUrl
|
|
658
|
+
* / apiKey / visitorId from SharedPreferences so it works even
|
|
659
|
+
* when no plugin instance is alive (background token refresh).
|
|
660
|
+
*/
|
|
661
|
+
fun registerTokenFromService(appContext: Context, token: String) {
|
|
662
|
+
val prefs = appContext.getSharedPreferences(
|
|
663
|
+
"apex-capacitor",
|
|
664
|
+
Context.MODE_PRIVATE,
|
|
665
|
+
)
|
|
666
|
+
val projectKey = prefs.getString("projectKey", null) ?: return
|
|
667
|
+
val apiBaseUrl = prefs.getString("apiBaseUrl", null) ?: "https://api.apex.inc"
|
|
668
|
+
val apiKey = prefs.getString("apiKey", null)
|
|
669
|
+
val visitorId = prefs.getString("visitorId", null) ?: return
|
|
670
|
+
|
|
671
|
+
val payload = JSObject()
|
|
672
|
+
.put("projectKey", projectKey)
|
|
673
|
+
.put("visitorId", visitorId)
|
|
674
|
+
.put("platform", "android")
|
|
675
|
+
.put("token", token)
|
|
676
|
+
.put("bundleId", appContext.packageName)
|
|
677
|
+
Thread {
|
|
678
|
+
try {
|
|
679
|
+
val url = java.net.URL("${apiBaseUrl.trimEnd('/')}/api/mobile/push-token")
|
|
680
|
+
val conn = url.openConnection() as java.net.HttpURLConnection
|
|
681
|
+
conn.requestMethod = "POST"
|
|
682
|
+
conn.doOutput = true
|
|
683
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
684
|
+
apiKey?.let { conn.setRequestProperty("x-apex-api-key", it) }
|
|
685
|
+
conn.connectTimeout = 10000
|
|
686
|
+
conn.readTimeout = 15000
|
|
687
|
+
conn.outputStream.use {
|
|
688
|
+
it.write(payload.toString().toByteArray(Charsets.UTF_8))
|
|
689
|
+
}
|
|
690
|
+
conn.responseCode
|
|
691
|
+
conn.disconnect()
|
|
692
|
+
} catch (_: Exception) {
|
|
693
|
+
// best-effort
|
|
694
|
+
}
|
|
695
|
+
}.start()
|
|
696
|
+
|
|
697
|
+
// If the plugin is alive, surface the refreshed token to JS.
|
|
698
|
+
activePlugin?.notifyListeners(
|
|
699
|
+
"pushTokenReceived",
|
|
700
|
+
JSObject().put("token", token).put("platform", "android"),
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
307
704
|
}
|
|
308
705
|
|
|
309
706
|
/**
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apex Spec — canonical event name constants for the Android SDK.
|
|
3
|
+
*
|
|
4
|
+
* Hand-authored from `app/src/lib/core/apex-spec/events.ts`. Kept in sync
|
|
5
|
+
* via the parity test at
|
|
6
|
+
* `app/src/lib/core/apex-spec/__tests__/sdk-parity.test.ts` — CI fails if a
|
|
7
|
+
* canonical name is added/renamed/removed in the registry without a matching
|
|
8
|
+
* update here.
|
|
9
|
+
*
|
|
10
|
+
* Usage from Kotlin:
|
|
11
|
+
*
|
|
12
|
+
* plugin.track(type = ApexEvents.ADD_TO_CART, data = mapOf("product_id" to "sku_123"))
|
|
13
|
+
*
|
|
14
|
+
* Each constant is the unversioned canonical name as the developer writes it
|
|
15
|
+
* in `track()` calls. Versions are an internal storage primitive; the SDK
|
|
16
|
+
* never carries a version field.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
package inc.apex.capacitor
|
|
20
|
+
|
|
21
|
+
object ApexEvents {
|
|
22
|
+
// ─── Commerce ───────────────────────────────────────────────────────
|
|
23
|
+
const val ADD_TO_CART = "add_to_cart"
|
|
24
|
+
const val REMOVE_FROM_CART = "remove_from_cart"
|
|
25
|
+
const val PRODUCT_VIEW = "product_view"
|
|
26
|
+
const val ADD_TO_WISHLIST = "add_to_wishlist"
|
|
27
|
+
const val CHECKOUT_STARTED = "checkout_started"
|
|
28
|
+
const val IN_APP_PURCHASE = "in_app_purchase"
|
|
29
|
+
const val PURCHASE_REFUNDED = "purchase_refunded"
|
|
30
|
+
const val SUBSCRIPTION_EVENT = "subscription_event"
|
|
31
|
+
const val ORDER_PLACED = "order_placed"
|
|
32
|
+
const val FIRST_SALE_COMPLETED = "first_sale_completed"
|
|
33
|
+
|
|
34
|
+
// ─── Marketing (merchant-fired triggers) ────────────────────────────
|
|
35
|
+
const val PRICE_DROPPED = "price_dropped"
|
|
36
|
+
const val RESTOCK_DUE = "restock_due"
|
|
37
|
+
const val REVIEW_REQUEST_DUE = "review_request_due"
|
|
38
|
+
|
|
39
|
+
// ─── Identity ───────────────────────────────────────────────────────
|
|
40
|
+
const val USER_SIGNED_UP = "user_signed_up"
|
|
41
|
+
const val USER_SIGNED_IN = "user_signed_in"
|
|
42
|
+
const val USER_SIGNED_OUT = "user_signed_out"
|
|
43
|
+
const val USER_IDENTIFIED = "user_identified"
|
|
44
|
+
|
|
45
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────
|
|
46
|
+
const val APP_INSTALL = "app_install"
|
|
47
|
+
const val APP_OPEN = "app_open"
|
|
48
|
+
const val APP_BACKGROUND = "app_background"
|
|
49
|
+
const val APP_UNINSTALL = "app_uninstall"
|
|
50
|
+
const val APP_REINSTALL = "app_reinstall"
|
|
51
|
+
|
|
52
|
+
// ─── Session ────────────────────────────────────────────────────────
|
|
53
|
+
const val SESSION_START = "session_start"
|
|
54
|
+
const val SESSION_END = "session_end"
|
|
55
|
+
|
|
56
|
+
// ─── Engagement ─────────────────────────────────────────────────────
|
|
57
|
+
const val PAGE_VIEW = "page_view"
|
|
58
|
+
const val PAGEVIEW = "pageview"
|
|
59
|
+
const val SCREEN_VIEW = "screen_view"
|
|
60
|
+
const val SEARCH = "search"
|
|
61
|
+
const val SHARE = "share"
|
|
62
|
+
const val CONTENT_VIEW = "content_view"
|
|
63
|
+
const val FORM_SUBMIT = "form_submit"
|
|
64
|
+
const val CLICK = "click"
|
|
65
|
+
const val ENGAGEMENT = "engagement"
|
|
66
|
+
const val HEARTBEAT = "heartbeat"
|
|
67
|
+
const val RAGE_CLICK = "rage_click"
|
|
68
|
+
const val DEAD_CLICK = "dead_click"
|
|
69
|
+
const val GOAL_CONVERSION = "goal_conversion"
|
|
70
|
+
const val ONBOARDING_STEP_COMPLETED = "onboarding_step_completed"
|
|
71
|
+
|
|
72
|
+
// ─── Product (PLG lifecycle outcomes) ───────────────────────────────
|
|
73
|
+
const val ONBOARDING_COMPLETED = "onboarding_completed"
|
|
74
|
+
const val ACTIVATED = "activated"
|
|
75
|
+
|
|
76
|
+
// ─── Communication ──────────────────────────────────────────────────
|
|
77
|
+
const val PUSH_RECEIVED = "push_received"
|
|
78
|
+
const val PUSH_OPENED = "push_opened"
|
|
79
|
+
const val EMAIL_OPENED = "email_opened"
|
|
80
|
+
const val EMAIL_CLICKED = "email_clicked"
|
|
81
|
+
const val IN_APP_MESSAGE_SEEN = "in_app_message_seen"
|
|
82
|
+
const val IN_APP_MESSAGE_CLICKED = "in_app_message_clicked"
|
|
83
|
+
|
|
84
|
+
// ─── Deep link ──────────────────────────────────────────────────────
|
|
85
|
+
const val DEEP_LINK_OPEN = "deep_link_open"
|
|
86
|
+
|
|
87
|
+
// ─── Attribution ────────────────────────────────────────────────────
|
|
88
|
+
const val REATTRIBUTION = "reattribution"
|
|
89
|
+
const val REENGAGEMENT = "reengagement"
|
|
90
|
+
|
|
91
|
+
// ─── Smart banner ───────────────────────────────────────────────────
|
|
92
|
+
const val SMART_BANNER_IMPRESSION = "smart_banner_impression"
|
|
93
|
+
const val SMART_BANNER_CLICK = "smart_banner_click"
|
|
94
|
+
const val SMART_BANNER_DISMISS = "smart_banner_dismiss"
|
|
95
|
+
|
|
96
|
+
// ─── System ─────────────────────────────────────────────────────────
|
|
97
|
+
const val CUSTOM = "custom"
|
|
98
|
+
const val LIFECYCLE_TRANSITION = "lifecycle_transition"
|
|
99
|
+
const val JS_ERROR = "js_error"
|
|
100
|
+
|
|
101
|
+
/** Apex Spec semver this SDK implements. */
|
|
102
|
+
const val SPEC_VERSION = "1.1.0"
|
|
103
|
+
|
|
104
|
+
/** Complete list of every canonical event name. */
|
|
105
|
+
val ALL: List<String> = listOf(
|
|
106
|
+
ADD_TO_CART, REMOVE_FROM_CART, PRODUCT_VIEW, ADD_TO_WISHLIST, CHECKOUT_STARTED,
|
|
107
|
+
IN_APP_PURCHASE, PURCHASE_REFUNDED, SUBSCRIPTION_EVENT,
|
|
108
|
+
USER_SIGNED_UP, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
|
|
109
|
+
APP_INSTALL, APP_OPEN, APP_BACKGROUND, APP_UNINSTALL, APP_REINSTALL,
|
|
110
|
+
SESSION_START, SESSION_END,
|
|
111
|
+
PAGE_VIEW, PAGEVIEW, SCREEN_VIEW, SEARCH, SHARE, CONTENT_VIEW, FORM_SUBMIT,
|
|
112
|
+
CLICK, ENGAGEMENT, HEARTBEAT, RAGE_CLICK, DEAD_CLICK, GOAL_CONVERSION,
|
|
113
|
+
ONBOARDING_STEP_COMPLETED,
|
|
114
|
+
ONBOARDING_COMPLETED, ACTIVATED,
|
|
115
|
+
PUSH_RECEIVED, PUSH_OPENED, EMAIL_OPENED, EMAIL_CLICKED,
|
|
116
|
+
IN_APP_MESSAGE_SEEN, IN_APP_MESSAGE_CLICKED,
|
|
117
|
+
DEEP_LINK_OPEN,
|
|
118
|
+
REATTRIBUTION, REENGAGEMENT,
|
|
119
|
+
SMART_BANNER_IMPRESSION, SMART_BANNER_CLICK, SMART_BANNER_DISMISS,
|
|
120
|
+
CUSTOM, LIFECYCLE_TRANSITION, JS_ERROR,
|
|
121
|
+
)
|
|
122
|
+
}
|