@apex-inc/capacitor-plugin 0.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.
Files changed (83) hide show
  1. package/ApexCapacitorPlugin.podspec +17 -0
  2. package/LICENSE +17 -0
  3. package/README.md +136 -0
  4. package/android/build.gradle +68 -0
  5. package/android/src/main/AndroidManifest.xml +8 -0
  6. package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +325 -0
  7. package/android/src/main/java/inc/apex/capacitor/DeepLinkManager.kt +47 -0
  8. package/android/src/main/java/inc/apex/capacitor/InstallReferrerParser.kt +123 -0
  9. package/android/src/main/java/inc/apex/capacitor/OfflineQueue.kt +150 -0
  10. package/android/src/main/java/inc/apex/capacitor/SessionManager.kt +108 -0
  11. package/dist/batch-sender.d.ts +60 -0
  12. package/dist/batch-sender.d.ts.map +1 -0
  13. package/dist/batch-sender.js +115 -0
  14. package/dist/batch-sender.js.map +1 -0
  15. package/dist/definitions.d.ts +224 -0
  16. package/dist/definitions.d.ts.map +1 -0
  17. package/dist/definitions.js +14 -0
  18. package/dist/definitions.js.map +1 -0
  19. package/dist/esm/batch-sender.d.ts +60 -0
  20. package/dist/esm/batch-sender.d.ts.map +1 -0
  21. package/dist/esm/batch-sender.js +111 -0
  22. package/dist/esm/batch-sender.js.map +1 -0
  23. package/dist/esm/definitions.d.ts +224 -0
  24. package/dist/esm/definitions.d.ts.map +1 -0
  25. package/dist/esm/definitions.js +13 -0
  26. package/dist/esm/definitions.js.map +1 -0
  27. package/dist/esm/event-id.d.ts +17 -0
  28. package/dist/esm/event-id.d.ts.map +1 -0
  29. package/dist/esm/event-id.js +57 -0
  30. package/dist/esm/event-id.js.map +1 -0
  31. package/dist/esm/index.d.ts +29 -0
  32. package/dist/esm/index.d.ts.map +1 -0
  33. package/dist/esm/index.js +30 -0
  34. package/dist/esm/index.js.map +1 -0
  35. package/dist/esm/offline-queue.d.ts +111 -0
  36. package/dist/esm/offline-queue.d.ts.map +1 -0
  37. package/dist/esm/offline-queue.js +240 -0
  38. package/dist/esm/offline-queue.js.map +1 -0
  39. package/dist/esm/session-manager.d.ts +63 -0
  40. package/dist/esm/session-manager.d.ts.map +1 -0
  41. package/dist/esm/session-manager.js +100 -0
  42. package/dist/esm/session-manager.js.map +1 -0
  43. package/dist/esm/web.d.ts +65 -0
  44. package/dist/esm/web.d.ts.map +1 -0
  45. package/dist/esm/web.js +203 -0
  46. package/dist/esm/web.js.map +1 -0
  47. package/dist/event-id.d.ts +17 -0
  48. package/dist/event-id.d.ts.map +1 -0
  49. package/dist/event-id.js +61 -0
  50. package/dist/event-id.js.map +1 -0
  51. package/dist/index.d.ts +29 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +76 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/offline-queue.d.ts +111 -0
  56. package/dist/offline-queue.d.ts.map +1 -0
  57. package/dist/offline-queue.js +246 -0
  58. package/dist/offline-queue.js.map +1 -0
  59. package/dist/session-manager.d.ts +63 -0
  60. package/dist/session-manager.d.ts.map +1 -0
  61. package/dist/session-manager.js +104 -0
  62. package/dist/session-manager.js.map +1 -0
  63. package/dist/web.d.ts +65 -0
  64. package/dist/web.d.ts.map +1 -0
  65. package/dist/web.js +207 -0
  66. package/dist/web.js.map +1 -0
  67. package/ios/Package.swift +34 -0
  68. package/ios/Sources/ApexCapacitorPlugin/AdvertisingIdProvider.swift +66 -0
  69. package/ios/Sources/ApexCapacitorPlugin/AttManager.swift +82 -0
  70. package/ios/Sources/ApexCapacitorPlugin/DeepLinkManager.swift +64 -0
  71. package/ios/Sources/ApexCapacitorPlugin/DeviceInfo.swift +107 -0
  72. package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +191 -0
  73. package/ios/Sources/ApexCapacitorPlugin/SessionManager.swift +113 -0
  74. package/ios/Sources/ApexCapacitorPlugin/SkanManager.swift +95 -0
  75. package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +269 -0
  76. package/ios/Tests/ApexCapacitorPluginTests/AdvertisingIdProviderTests.swift +74 -0
  77. package/ios/Tests/ApexCapacitorPluginTests/AttManagerTests.swift +82 -0
  78. package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +69 -0
  79. package/ios/Tests/ApexCapacitorPluginTests/DeviceInfoTests.swift +52 -0
  80. package/ios/Tests/ApexCapacitorPluginTests/OfflineQueueTests.swift +134 -0
  81. package/ios/Tests/ApexCapacitorPluginTests/SessionManagerTests.swift +98 -0
  82. package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +91 -0
  83. package/package.json +82 -0
@@ -0,0 +1,17 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ApexCapacitorPlugin'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license']
10
+ s.homepage = package['homepage']
11
+ s.author = package['author']
12
+ s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
13
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '14.0'
15
+ s.dependency 'Capacitor'
16
+ s.swift_version = '5.9'
17
+ end
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Apex Inc.
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # @apex-inc/capacitor-plugin
2
+
3
+ Apex Capacitor plugin — iOS and Android attribution, events, deep linking, SKAN, and offline-tolerant tracking for Capacitor apps.
4
+
5
+ Ships alongside `apex.js` (the web snippet) so a Capacitor app gets unified identity and events across WebView + native APIs with one dependency pattern.
6
+
7
+ > **Scope.** This plugin is for Capacitor apps. Native iOS (Swift), native Android (Kotlin), React Native, and Flutter apps need their own SDKs — see https://apex.inc/docs/mobile/which-sdk.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install @apex-inc/capacitor-plugin
13
+ npx cap sync
14
+ ```
15
+
16
+ ## Initialize
17
+
18
+ Call `initialize()` once at app startup, before any other plugin method.
19
+
20
+ ```ts
21
+ import { Apex } from "@apex-inc/capacitor-plugin";
22
+
23
+ await Apex.initialize({
24
+ projectKey: "prj_your_key",
25
+ // Optional:
26
+ // apiUrl: "https://api.apex.inc",
27
+ // sessionTimeoutMinutes: 30,
28
+ // offlineQueueMaxSize: 1000,
29
+ // testMode: false,
30
+ // debug: false,
31
+ });
32
+ ```
33
+
34
+ ## Track events
35
+
36
+ ```ts
37
+ await Apex.track({
38
+ type: "app_open",
39
+ data: { from: "push_notification" },
40
+ });
41
+
42
+ // In-app purchase — typed payload
43
+ await Apex.track({
44
+ type: "in_app_purchase",
45
+ purchase: {
46
+ productId: "com.example.app.pro_monthly",
47
+ amount: 9.99,
48
+ currency: "USD",
49
+ transactionId: "abc123",
50
+ },
51
+ });
52
+ ```
53
+
54
+ Every event gets a client-generated UUIDv4 `id` automatically. Server-side idempotency means replayed events never double-count.
55
+
56
+ ## iOS App Tracking Transparency
57
+
58
+ ```ts
59
+ // Present the ATT prompt once (no-op on Android).
60
+ const { status } = await Apex.requestTrackingAuthorization();
61
+
62
+ // Check status later without prompting.
63
+ const { status: current } = await Apex.getTrackingStatus();
64
+ ```
65
+
66
+ ## Advertising identifiers
67
+
68
+ ```ts
69
+ // Returns IDFA (iOS, ATT authorized), GAID (Android), or fallback.
70
+ const { id, fallback } = await Apex.getAdvertisingId();
71
+ if (!id && fallback === "idfv") {
72
+ console.log("Using IDFV fallback — user denied ATT");
73
+ }
74
+ ```
75
+
76
+ ## SKAdNetwork conversion values (iOS 4.0+)
77
+
78
+ ```ts
79
+ await Apex.updateConversionValue({
80
+ fineValue: 42,
81
+ coarseValue: "high",
82
+ });
83
+ ```
84
+
85
+ ## Deep links
86
+
87
+ ```ts
88
+ // Cold start: read the URL that opened the app (if any).
89
+ const { url } = await Apex.getInitialDeepLink();
90
+
91
+ // Warm-start / subsequent links while running:
92
+ await Apex.addListener("deepLink", ({ url }) => {
93
+ handleRoute(url);
94
+ });
95
+ ```
96
+
97
+ ## Sessions
98
+
99
+ ```ts
100
+ const { sessionId } = await Apex.startSession();
101
+
102
+ await Apex.addListener("sessionEnd", ({ sessionId, durationSeconds }) => {
103
+ console.log(`Session ${sessionId} ended after ${durationSeconds}s`);
104
+ });
105
+ ```
106
+
107
+ ## Offline durability
108
+
109
+ Events are persisted to IndexedDB (web), Core Data (iOS), or Room (Android). If the device is offline, they queue up to the configured `offlineQueueMaxSize` (default 1000). When the network returns, the plugin drains the queue in batches with exponential-backoff retry.
110
+
111
+ ```ts
112
+ const { count, oldestEventAt } = await Apex.getQueueSize();
113
+
114
+ // Manually trigger a flush (rarely needed — happens automatically):
115
+ const { flushed, remaining } = await Apex.flushQueue();
116
+ ```
117
+
118
+ ## Test mode
119
+
120
+ ```ts
121
+ // At init:
122
+ await Apex.initialize({ projectKey: "prj_test", testMode: true });
123
+
124
+ // Or toggle at runtime:
125
+ await Apex.setTestMode({ enabled: true });
126
+ ```
127
+
128
+ Test-mode events are stored separately on the server and never pollute production analytics.
129
+
130
+ ## Web fallback
131
+
132
+ The plugin runs in a browser or PWA too — identifiers return null, ATT is a no-op, events still flow to the server via the normal offline queue. This lets you run shared code paths between web + mobile without `Capacitor.isNativePlatform()` checks everywhere.
133
+
134
+ ## License
135
+
136
+ Apache-2.0 — see [LICENSE](./LICENSE). See also the project [plan document](../../plans/mobile-measurement-platform.md) for the full MMP roadmap.
@@ -0,0 +1,68 @@
1
+ // Gradle build script for @apex-inc/capacitor-plugin Android module.
2
+ // Compiled alongside a consuming Capacitor app via `npx cap sync`.
3
+
4
+ ext {
5
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
6
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
7
+ installreferrerVersion = '2.2'
8
+ }
9
+
10
+ buildscript {
11
+ repositories {
12
+ google()
13
+ mavenCentral()
14
+ }
15
+ dependencies {
16
+ classpath 'com.android.tools.build:gradle:8.2.0'
17
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21"
18
+ }
19
+ }
20
+
21
+ apply plugin: 'com.android.library'
22
+ apply plugin: 'kotlin-android'
23
+
24
+ android {
25
+ namespace "inc.apex.capacitor"
26
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
27
+ defaultConfig {
28
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
29
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
30
+ versionCode 1
31
+ versionName "0.1.0"
32
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
33
+ }
34
+ buildTypes {
35
+ release {
36
+ minifyEnabled false
37
+ }
38
+ }
39
+ compileOptions {
40
+ sourceCompatibility JavaVersion.VERSION_17
41
+ targetCompatibility JavaVersion.VERSION_17
42
+ }
43
+ kotlinOptions {
44
+ jvmTarget = "17"
45
+ }
46
+ }
47
+
48
+ repositories {
49
+ google()
50
+ mavenCentral()
51
+ }
52
+
53
+ dependencies {
54
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
55
+ implementation project(':capacitor-android')
56
+ // Play Install Referrer API — captures click IDs (gclid, fbclid) on install
57
+ implementation "com.android.installreferrer:installreferrer:$installreferrerVersion"
58
+ // Play Services Ads for GAID
59
+ implementation "com.google.android.gms:play-services-ads-identifier:18.0.1"
60
+ // Firebase Messaging for FCM push tokens (used in Phase 5f uninstall tracking)
61
+ compileOnly "com.google.firebase:firebase-messaging:23.4.0"
62
+
63
+ testImplementation "junit:junit:$junitVersion"
64
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
65
+ // org.json is included in Android runtime; for local JVM unit tests we
66
+ // need it as a test dependency.
67
+ testImplementation "org.json:json:20240303"
68
+ }
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <!-- Access Google Advertising ID (GAID) on API 24+. -->
4
+ <uses-permission android:name="com.google.android.gms.permission.AD_ID" />
5
+ <!-- Network required for batch-sender event delivery. -->
6
+ <uses-permission android:name="android.permission.INTERNET" />
7
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
8
+ </manifest>
@@ -0,0 +1,325 @@
1
+ package inc.apex.capacitor
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.net.Uri
6
+ import android.os.Build
7
+ import android.provider.Settings
8
+ import com.android.installreferrer.api.InstallReferrerClient
9
+ import com.android.installreferrer.api.InstallReferrerStateListener
10
+ import com.getcapacitor.JSObject
11
+ import com.getcapacitor.Plugin
12
+ import com.getcapacitor.PluginCall
13
+ import com.getcapacitor.PluginMethod
14
+ import com.getcapacitor.annotation.CapacitorPlugin
15
+ import com.google.android.gms.ads.identifier.AdvertisingIdClient
16
+ import java.io.File
17
+ import java.util.Locale
18
+ import java.util.TimeZone
19
+ import java.util.UUID
20
+
21
+ /**
22
+ * Capacitor plugin entry point for Android. Orchestrates the pure-Kotlin
23
+ * logic classes (SessionManager, OfflineQueue, DeepLinkManager, InstallReferrerParser)
24
+ * and exposes them to the JS side via Capacitor's PluginCall bridge.
25
+ *
26
+ * Tests that don't need the Capacitor runtime live in the test/ folder and
27
+ * target the pure-logic classes directly. This class is verified via Android
28
+ * instrumentation tests on a real device or emulator.
29
+ */
30
+ @CapacitorPlugin(name = "ApexCapacitorPlugin")
31
+ class ApexCapacitorPlugin : Plugin() {
32
+
33
+ private val deepLinks = DeepLinkManager()
34
+ private val sessionManager by lazy {
35
+ NativeSessionManager(
36
+ onStart = { snapshot ->
37
+ val data = JSObject().put("sessionId", snapshot.sessionId)
38
+ notifyListeners("sessionStart", data)
39
+ },
40
+ onEnd = { snapshot ->
41
+ val data = JSObject()
42
+ .put("sessionId", snapshot.sessionId)
43
+ .put("durationSeconds", snapshot.durationSeconds ?: 0)
44
+ notifyListeners("sessionEnd", data)
45
+ }
46
+ )
47
+ }
48
+
49
+ private var offlineQueue: NativeOfflineQueue? = null
50
+ private var prefs: SharedPreferences? = null
51
+ private var visitorId: String = ""
52
+ private var testMode: Boolean = false
53
+ private var debug: Boolean = false
54
+ private var lastInstallReferrer: String? = null
55
+
56
+ override fun load() {
57
+ val ctx = context
58
+ prefs = ctx.getSharedPreferences("apex-capacitor", Context.MODE_PRIVATE)
59
+ visitorId = prefs?.getString("visitorId", null)
60
+ ?: UUID.randomUUID().toString().lowercase().also {
61
+ prefs?.edit()?.putString("visitorId", it)?.apply()
62
+ }
63
+
64
+ deepLinks.setWarmLinkHandler { url ->
65
+ val data = JSObject().put("url", url)
66
+ notifyListeners("deepLink", data)
67
+ }
68
+
69
+ // Capture the intent that opened the activity, if any.
70
+ activity.intent?.data?.toString()?.let { url ->
71
+ if (DeepLinkManager.isValidDeepLink(url)) {
72
+ deepLinks.setInitialUrl(url)
73
+ }
74
+ }
75
+ }
76
+
77
+ @PluginMethod
78
+ fun initialize(call: PluginCall) {
79
+ val projectKey = call.getString("projectKey")
80
+ if (projectKey.isNullOrEmpty()) {
81
+ call.reject("projectKey is required")
82
+ return
83
+ }
84
+ testMode = call.getBoolean("testMode", false) ?: false
85
+ debug = call.getBoolean("debug", false) ?: false
86
+ val maxSize = call.getInt("offlineQueueMaxSize") ?: 1000
87
+
88
+ val queueFile = File(context.filesDir, "apex-capacitor/events.json")
89
+ queueFile.parentFile?.mkdirs()
90
+ val storage = FileQueueStorage(queueFile)
91
+ offlineQueue = NativeOfflineQueue(storage, maxSize = maxSize)
92
+
93
+ // Fire-and-forget install referrer retrieval on init.
94
+ fetchInstallReferrer()
95
+
96
+ call.resolve()
97
+ }
98
+
99
+ // ── ATT ───────────────────────────────────────────────────────────
100
+ @PluginMethod
101
+ fun requestTrackingAuthorization(call: PluginCall) {
102
+ // Android has no equivalent of ATT — tracking is governed by the
103
+ // system-level Ads Personalization toggle (surfaced via AdvertisingIdClient).
104
+ val data = JSObject().put("status", "authorized")
105
+ call.resolve(data)
106
+ }
107
+
108
+ @PluginMethod
109
+ fun getTrackingStatus(call: PluginCall) {
110
+ val data = JSObject().put("status", "authorized")
111
+ call.resolve(data)
112
+ }
113
+
114
+ // ── Identifiers ───────────────────────────────────────────────────
115
+ @PluginMethod
116
+ fun getAdvertisingId(call: PluginCall) {
117
+ Thread {
118
+ try {
119
+ val info = AdvertisingIdClient.getAdvertisingIdInfo(context)
120
+ val data = JSObject()
121
+ if (info.isLimitAdTrackingEnabled || info.id.isNullOrEmpty()) {
122
+ val androidId = Settings.Secure.getString(
123
+ context.contentResolver,
124
+ Settings.Secure.ANDROID_ID
125
+ )
126
+ data.put("id", androidId)
127
+ data.put("fallback", "android_id")
128
+ } else {
129
+ data.put("id", info.id)
130
+ data.put("fallback", null as String?)
131
+ }
132
+ call.resolve(data)
133
+ } catch (e: Exception) {
134
+ val data = JSObject().put("id", null as String?).put("fallback", null as String?)
135
+ call.resolve(data)
136
+ }
137
+ }.start()
138
+ }
139
+
140
+ @PluginMethod
141
+ fun getInstallReferrer(call: PluginCall) {
142
+ val data = JSObject().put("referrer", lastInstallReferrer ?: JSONull())
143
+ call.resolve(data)
144
+ }
145
+
146
+ @PluginMethod
147
+ fun getVisitorId(call: PluginCall) {
148
+ call.resolve(JSObject().put("visitorId", visitorId))
149
+ }
150
+
151
+ @PluginMethod
152
+ fun setVisitorId(call: PluginCall) {
153
+ val id = call.getString("visitorId")
154
+ if (id.isNullOrEmpty()) {
155
+ call.reject("visitorId is required")
156
+ return
157
+ }
158
+ visitorId = id
159
+ prefs?.edit()?.putString("visitorId", id)?.apply()
160
+ call.resolve()
161
+ }
162
+
163
+ // ── SKAN (iOS-only) ───────────────────────────────────────────────
164
+ @PluginMethod
165
+ fun updateConversionValue(call: PluginCall) {
166
+ // No-op on Android.
167
+ call.resolve()
168
+ }
169
+
170
+ // ── Deep Links ────────────────────────────────────────────────────
171
+ @PluginMethod
172
+ fun getInitialDeepLink(call: PluginCall) {
173
+ val url = deepLinks.consumeInitialUrl()
174
+ val data = JSObject().put("url", url ?: JSONull())
175
+ call.resolve(data)
176
+ }
177
+
178
+ // ── Device Info ───────────────────────────────────────────────────
179
+ @PluginMethod
180
+ fun getDeviceInfo(call: PluginCall) {
181
+ val appVersion = try {
182
+ val pm = context.packageManager
183
+ val pi = pm.getPackageInfo(context.packageName, 0)
184
+ pi.versionName ?: "0.0.0"
185
+ } catch (_: Exception) {
186
+ "0.0.0"
187
+ }
188
+
189
+ val locale = Locale.getDefault()
190
+ val localeTag = "${locale.language}-${locale.country}"
191
+ val data = JSObject()
192
+ .put("platform", "android")
193
+ .put("osVersion", Build.VERSION.RELEASE)
194
+ .put("model", "${Build.MANUFACTURER} ${Build.MODEL}".trim())
195
+ .put("appVersion", appVersion)
196
+ .put("bundleId", context.packageName)
197
+ .put("timezone", TimeZone.getDefault().id)
198
+ .put("locale", localeTag)
199
+ call.resolve(data)
200
+ }
201
+
202
+ // ── Sessions ──────────────────────────────────────────────────────
203
+ @PluginMethod
204
+ fun startSession(call: PluginCall) {
205
+ val snapshot = sessionManager.forceStart()
206
+ call.resolve(JSObject().put("sessionId", snapshot.sessionId))
207
+ }
208
+
209
+ @PluginMethod
210
+ fun endSession(call: PluginCall) {
211
+ sessionManager.endSession()
212
+ call.resolve()
213
+ }
214
+
215
+ @PluginMethod
216
+ fun getCurrentSession(call: PluginCall) {
217
+ val current = sessionManager.getCurrent()
218
+ val data = JSObject()
219
+ .put("sessionId", current?.sessionId ?: JSONull())
220
+ .put(
221
+ "startedAt",
222
+ current?.startedAtMs?.let { java.time.Instant.ofEpochMilli(it).toString() }
223
+ ?: JSONull()
224
+ )
225
+ call.resolve(data)
226
+ }
227
+
228
+ // ── Events / Queue ────────────────────────────────────────────────
229
+ @PluginMethod
230
+ fun track(call: PluginCall) {
231
+ val queue = offlineQueue
232
+ if (queue == null) {
233
+ call.reject("Plugin not initialized — call initialize() first")
234
+ return
235
+ }
236
+ val id = call.getString("id") ?: UUID.randomUUID().toString().lowercase()
237
+ val payload = mutableMapOf<String, Any?>()
238
+ call.data.keys().forEach { key -> payload[key] = call.data.opt(key) }
239
+
240
+ val event = NativeQueuedEvent(
241
+ id = id,
242
+ payload = payload,
243
+ attempts = 0,
244
+ enqueuedAtMs = System.currentTimeMillis(),
245
+ )
246
+ try {
247
+ queue.enqueue(event)
248
+ call.resolve()
249
+ } catch (e: Exception) {
250
+ call.reject("Failed to enqueue event: ${e.message}")
251
+ }
252
+ }
253
+
254
+ @PluginMethod
255
+ fun getQueueSize(call: PluginCall) {
256
+ val queue = offlineQueue
257
+ if (queue == null) {
258
+ call.resolve(JSObject().put("count", 0).put("oldestEventAt", JSONull()))
259
+ return
260
+ }
261
+ val oldest = queue.oldestEventAtMs()
262
+ ?.let { java.time.Instant.ofEpochMilli(it).toString() }
263
+ val data = JSObject()
264
+ .put("count", queue.size())
265
+ .put("oldestEventAt", oldest ?: JSONull())
266
+ call.resolve(data)
267
+ }
268
+
269
+ @PluginMethod
270
+ fun flushQueue(call: PluginCall) {
271
+ val queue = offlineQueue
272
+ val data = JSObject()
273
+ .put("flushed", 0)
274
+ .put("remaining", queue?.size() ?: 0)
275
+ call.resolve(data)
276
+ }
277
+
278
+ @PluginMethod
279
+ fun setTestMode(call: PluginCall) {
280
+ testMode = call.getBoolean("enabled", false) ?: false
281
+ call.resolve()
282
+ }
283
+
284
+ // ── Internal ──────────────────────────────────────────────────────
285
+
286
+ private fun fetchInstallReferrer() {
287
+ val client = InstallReferrerClient.newBuilder(context).build()
288
+ client.startConnection(object : InstallReferrerStateListener {
289
+ override fun onInstallReferrerSetupFinished(responseCode: Int) {
290
+ try {
291
+ if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
292
+ val details = client.installReferrer
293
+ lastInstallReferrer = details.installReferrer
294
+ }
295
+ } catch (_: Exception) {
296
+ // ignore
297
+ } finally {
298
+ try {
299
+ client.endConnection()
300
+ } catch (_: Exception) { }
301
+ }
302
+ }
303
+
304
+ override fun onInstallReferrerServiceDisconnected() { }
305
+ })
306
+ }
307
+ }
308
+
309
+ /**
310
+ * File-based QueueStorage used by the plugin in production. Tests swap
311
+ * InMemoryQueueStorage in its place.
312
+ */
313
+ private class FileQueueStorage(private val file: File) : QueueStorage {
314
+ override fun readAll(): String? =
315
+ if (file.exists()) file.readText() else null
316
+
317
+ override fun writeAll(contents: String) {
318
+ file.writeText(contents)
319
+ }
320
+ }
321
+
322
+ /** Sentinel for passing JSON null to Capacitor's JSObject helpers. */
323
+ private object JSONull {
324
+ override fun toString(): String = "null"
325
+ }
@@ -0,0 +1,47 @@
1
+ package inc.apex.capacitor
2
+
3
+ /**
4
+ * Retains the URL that opened the app (App Link or custom scheme) and
5
+ * forwards warm-start URLs to a registered handler. Mirrors the iOS
6
+ * DeepLinkManager — same shape, pure Kotlin for testability.
7
+ */
8
+ class DeepLinkManager {
9
+ private var initialUrl: String? = null
10
+ private var warmHandler: ((String) -> Unit)? = null
11
+ private val lock = Any()
12
+
13
+ fun setInitialUrl(url: String) {
14
+ synchronized(lock) {
15
+ if (initialUrl == null) initialUrl = url
16
+ }
17
+ }
18
+
19
+ /** One-shot: returns stored URL and clears it. */
20
+ fun consumeInitialUrl(): String? {
21
+ return synchronized(lock) {
22
+ val url = initialUrl
23
+ initialUrl = null
24
+ url
25
+ }
26
+ }
27
+
28
+ /** Register / replace the handler that receives warm-start URLs. */
29
+ fun setWarmLinkHandler(handler: (String) -> Unit) {
30
+ synchronized(lock) { warmHandler = handler }
31
+ }
32
+
33
+ fun deliverWarmLink(url: String) {
34
+ synchronized(lock) { warmHandler?.invoke(url) }
35
+ }
36
+
37
+ companion object {
38
+ /** Is this a sensible deep link? Reject `file://` and empty schemes. */
39
+ fun isValidDeepLink(url: String): Boolean {
40
+ if (url.isBlank()) return false
41
+ val schemeEnd = url.indexOf(":")
42
+ if (schemeEnd <= 0) return false
43
+ val scheme = url.substring(0, schemeEnd).lowercase()
44
+ return scheme.isNotEmpty() && scheme != "file"
45
+ }
46
+ }
47
+ }