@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.
- package/ApexCapacitorPlugin.podspec +17 -0
- package/LICENSE +17 -0
- package/README.md +136 -0
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +325 -0
- package/android/src/main/java/inc/apex/capacitor/DeepLinkManager.kt +47 -0
- package/android/src/main/java/inc/apex/capacitor/InstallReferrerParser.kt +123 -0
- package/android/src/main/java/inc/apex/capacitor/OfflineQueue.kt +150 -0
- package/android/src/main/java/inc/apex/capacitor/SessionManager.kt +108 -0
- package/dist/batch-sender.d.ts +60 -0
- package/dist/batch-sender.d.ts.map +1 -0
- package/dist/batch-sender.js +115 -0
- package/dist/batch-sender.js.map +1 -0
- package/dist/definitions.d.ts +224 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +14 -0
- package/dist/definitions.js.map +1 -0
- package/dist/esm/batch-sender.d.ts +60 -0
- package/dist/esm/batch-sender.d.ts.map +1 -0
- package/dist/esm/batch-sender.js +111 -0
- package/dist/esm/batch-sender.js.map +1 -0
- package/dist/esm/definitions.d.ts +224 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +13 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/event-id.d.ts +17 -0
- package/dist/esm/event-id.d.ts.map +1 -0
- package/dist/esm/event-id.js +57 -0
- package/dist/esm/event-id.js.map +1 -0
- package/dist/esm/index.d.ts +29 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +30 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/offline-queue.d.ts +111 -0
- package/dist/esm/offline-queue.d.ts.map +1 -0
- package/dist/esm/offline-queue.js +240 -0
- package/dist/esm/offline-queue.js.map +1 -0
- package/dist/esm/session-manager.d.ts +63 -0
- package/dist/esm/session-manager.d.ts.map +1 -0
- package/dist/esm/session-manager.js +100 -0
- package/dist/esm/session-manager.js.map +1 -0
- package/dist/esm/web.d.ts +65 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +203 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/event-id.d.ts +17 -0
- package/dist/event-id.d.ts.map +1 -0
- package/dist/event-id.js +61 -0
- package/dist/event-id.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/offline-queue.d.ts +111 -0
- package/dist/offline-queue.d.ts.map +1 -0
- package/dist/offline-queue.js +246 -0
- package/dist/offline-queue.js.map +1 -0
- package/dist/session-manager.d.ts +63 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +104 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/web.d.ts +65 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +207 -0
- package/dist/web.js.map +1 -0
- package/ios/Package.swift +34 -0
- package/ios/Sources/ApexCapacitorPlugin/AdvertisingIdProvider.swift +66 -0
- package/ios/Sources/ApexCapacitorPlugin/AttManager.swift +82 -0
- package/ios/Sources/ApexCapacitorPlugin/DeepLinkManager.swift +64 -0
- package/ios/Sources/ApexCapacitorPlugin/DeviceInfo.swift +107 -0
- package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +191 -0
- package/ios/Sources/ApexCapacitorPlugin/SessionManager.swift +113 -0
- package/ios/Sources/ApexCapacitorPlugin/SkanManager.swift +95 -0
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +269 -0
- package/ios/Tests/ApexCapacitorPluginTests/AdvertisingIdProviderTests.swift +74 -0
- package/ios/Tests/ApexCapacitorPluginTests/AttManagerTests.swift +82 -0
- package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +69 -0
- package/ios/Tests/ApexCapacitorPluginTests/DeviceInfoTests.swift +52 -0
- package/ios/Tests/ApexCapacitorPluginTests/OfflineQueueTests.swift +134 -0
- package/ios/Tests/ApexCapacitorPluginTests/SessionManagerTests.swift +98 -0
- package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +91 -0
- 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
|
+
}
|