firstusers 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +267 -113
- package/android/build.gradle +32 -0
- package/android/src/main/java/com/firstusers/tracker/FirstUsers.kt +113 -0
- package/android/src/main/java/com/firstusers/tracker/FirstUsersModule.kt +29 -0
- package/build/index.d.ts +48 -0
- package/build/index.js +119 -0
- package/expo-module.config.json +6 -0
- package/package.json +33 -28
- package/plugin/src/withFirstUsers.js +60 -0
- package/LICENSE +0 -21
- package/dist/index.js +0 -304
- package/index.d.ts +0 -59
package/README.md
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# firstusers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Session tracking SDK for React Native/Expo apps. Tracks authenticated user sessions with automatic 30-second heartbeat updates.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
✅ **
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
📦 **Dynamic Package Name** — Passed via props for reuse
|
|
7
|
+
✅ **Post-Login Tracking** - Only tracks authenticated user sessions (excludes login screen time)
|
|
8
|
+
✅ **Automatic Heartbeat** - Updates session duration every 30 seconds
|
|
9
|
+
✅ **Survives App Kill** - Last heartbeat data already saved (max 30s loss)
|
|
10
|
+
✅ **Zero Configuration** - Expo Config Plugin auto-configures your project
|
|
11
|
+
✅ **Privacy-Focused** - No sensitive data collection
|
|
12
|
+
✅ **Lightweight** - Minimal battery and performance impact
|
|
14
13
|
|
|
15
14
|
## Installation
|
|
16
15
|
|
|
@@ -18,168 +17,323 @@
|
|
|
18
17
|
npm install firstusers
|
|
19
18
|
```
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
Or with yarn:
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
```bash
|
|
23
|
+
yarn add firstusers
|
|
24
|
+
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
## Configuration
|
|
27
|
+
|
|
28
|
+
### Step 1: Add Plugin to app.json
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"expo": {
|
|
33
|
+
"plugins": [
|
|
34
|
+
[
|
|
35
|
+
"firstusers",
|
|
36
|
+
{
|
|
37
|
+
"apiUrl": "https://your-api.workers.dev"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
27
43
|
```
|
|
28
44
|
|
|
29
|
-
|
|
45
|
+
**Plugin Options:**
|
|
30
46
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
| Option | Type | Required | Default |
|
|
48
|
+
|--------|------|----------|---------|
|
|
49
|
+
| `apiUrl` | string | No | `https://cotester-api.01hunterwl.workers.dev` |
|
|
50
|
+
|
|
51
|
+
### Step 2: Rebuild Your App
|
|
52
|
+
|
|
53
|
+
The Config Plugin needs a native rebuild to apply changes:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Development build
|
|
57
|
+
npx expo run:android
|
|
58
|
+
|
|
59
|
+
# Or prebuild for custom builds
|
|
60
|
+
npx expo prebuild --clean
|
|
44
61
|
```
|
|
45
62
|
|
|
46
|
-
|
|
63
|
+
⚠️ **Important:** You must rebuild the app after installing this package. Expo Go does not support custom native modules.
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
47
66
|
|
|
48
|
-
|
|
67
|
+
### Basic Setup
|
|
49
68
|
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
|
|
69
|
+
```typescript
|
|
70
|
+
import futracker from 'firstusers';
|
|
71
|
+
import { supabase } from './lib/supabase';
|
|
53
72
|
|
|
54
|
-
//
|
|
55
|
-
|
|
73
|
+
// Configure once at app startup
|
|
74
|
+
futracker.configure('https://your-api.workers.dev');
|
|
75
|
+
futracker.setPackageName('com.yourcompany.app');
|
|
56
76
|
|
|
57
|
-
//
|
|
58
|
-
|
|
77
|
+
// Start tracking after successful login
|
|
78
|
+
supabase.auth.onAuthStateChange((event, session) => {
|
|
79
|
+
if (event === 'SIGNED_IN' && session) {
|
|
80
|
+
futracker.startTracking();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
59
83
|
```
|
|
60
84
|
|
|
61
|
-
|
|
85
|
+
### Alternative: Convenience Function
|
|
62
86
|
|
|
63
|
-
|
|
87
|
+
```typescript
|
|
88
|
+
import { startTracking } from 'firstusers';
|
|
64
89
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
90
|
+
// One-line start with package name
|
|
91
|
+
if (loginSuccess) {
|
|
92
|
+
startTracking('com.yourcompany.app');
|
|
93
|
+
}
|
|
94
|
+
```
|
|
69
95
|
|
|
70
|
-
###
|
|
96
|
+
### Optional: Manual Stop (usually not needed)
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
98
|
+
```typescript
|
|
99
|
+
import { stopTracking } from 'firstusers';
|
|
100
|
+
|
|
101
|
+
// Optional: Call on logout
|
|
102
|
+
function handleLogout() {
|
|
103
|
+
stopTracking(); // Not required - heartbeat already saved data
|
|
104
|
+
// ... rest of logout logic
|
|
105
|
+
}
|
|
106
|
+
```
|
|
77
107
|
|
|
78
108
|
## API Reference
|
|
79
109
|
|
|
80
|
-
###
|
|
110
|
+
### `futracker.configure(apiUrl: string)`
|
|
111
|
+
|
|
112
|
+
Configure the API endpoint. Call once at app startup.
|
|
81
113
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
| `appPackageName` | string | Yes | - | Your app's package name (e.g., "com.yourcompany.app") |
|
|
85
|
-
| `apiEndpoint` | string | No | First Users API | Custom API endpoint if you're self-hosting |
|
|
114
|
+
**Parameters:**
|
|
115
|
+
- `apiUrl` (string) - Your First Users API endpoint
|
|
86
116
|
|
|
87
|
-
|
|
117
|
+
**Example:**
|
|
118
|
+
```typescript
|
|
119
|
+
futracker.configure('https://your-api.workers.dev');
|
|
120
|
+
```
|
|
88
121
|
|
|
89
|
-
|
|
122
|
+
### `futracker.setPackageName(packageName: string)`
|
|
90
123
|
|
|
91
|
-
|
|
124
|
+
Set your app's package name (same as `applicationId` in build.gradle).
|
|
92
125
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
126
|
+
**Parameters:**
|
|
127
|
+
- `packageName` (string) - Your app's package name
|
|
128
|
+
|
|
129
|
+
**Example:**
|
|
130
|
+
```typescript
|
|
131
|
+
futracker.setPackageName('com.yourcompany.app');
|
|
96
132
|
```
|
|
97
133
|
|
|
98
|
-
###
|
|
134
|
+
### `futracker.startTracking(packageName?: string)`
|
|
135
|
+
|
|
136
|
+
Start tracking user session. Call after successful login.
|
|
99
137
|
|
|
100
|
-
|
|
138
|
+
**Parameters:**
|
|
139
|
+
- `packageName` (string, optional) - Override package name for this call
|
|
101
140
|
|
|
102
|
-
|
|
141
|
+
**Example:**
|
|
142
|
+
```typescript
|
|
143
|
+
// Use pre-configured package name
|
|
144
|
+
futracker.startTracking();
|
|
103
145
|
|
|
104
|
-
|
|
105
|
-
|
|
146
|
+
// Or pass directly
|
|
147
|
+
futracker.startTracking('com.yourcompany.app');
|
|
148
|
+
```
|
|
106
149
|
|
|
107
|
-
|
|
108
|
-
FUHeartbeat.start('fu-12345', trackFunction);
|
|
150
|
+
### `futracker.stopTracking()`
|
|
109
151
|
|
|
110
|
-
|
|
111
|
-
const duration = FUHeartbeat.getDuration();
|
|
152
|
+
Stop tracking session. **Optional** - heartbeat auto-saves data every 30s.
|
|
112
153
|
|
|
113
|
-
|
|
114
|
-
|
|
154
|
+
**Example:**
|
|
155
|
+
```typescript
|
|
156
|
+
futracker.stopTracking();
|
|
115
157
|
```
|
|
116
158
|
|
|
117
|
-
|
|
159
|
+
### `futracker.setDebug(enabled: boolean)`
|
|
118
160
|
|
|
119
|
-
|
|
161
|
+
Enable or disable debug logging. Debug mode is enabled by default.
|
|
120
162
|
|
|
121
|
-
|
|
122
|
-
|
|
163
|
+
**Parameters:**
|
|
164
|
+
- `enabled` (boolean) - Whether to enable debug logs
|
|
123
165
|
|
|
124
|
-
|
|
166
|
+
**Example:**
|
|
167
|
+
```typescript
|
|
168
|
+
// Disable debug logs in production
|
|
169
|
+
futracker.setDebug(false);
|
|
125
170
|
```
|
|
126
171
|
|
|
127
|
-
|
|
172
|
+
### `futracker.isActive()`
|
|
128
173
|
|
|
129
|
-
|
|
174
|
+
Check if tracking is currently active.
|
|
175
|
+
|
|
176
|
+
**Returns:** boolean indicating if tracking is active
|
|
177
|
+
|
|
178
|
+
**Example:**
|
|
179
|
+
```typescript
|
|
180
|
+
if (futracker.isActive()) {
|
|
181
|
+
console.log('Tracking is running');
|
|
182
|
+
}
|
|
183
|
+
```
|
|
130
184
|
|
|
131
|
-
|
|
132
|
-
import { getOrCreateUserId } from 'firstusers';
|
|
185
|
+
## How It Works
|
|
133
186
|
|
|
134
|
-
|
|
187
|
+
1. **Initial Record**: When `startTracking()` is called, creates one record in `fu_tracking` table with `session_duration = 0`
|
|
188
|
+
2. **Heartbeat Updates**: Every 30 seconds, UPDATE the same record's `session_duration` to current elapsed time
|
|
189
|
+
3. **App Kill Safety**: If app is killed, last heartbeat data is already saved (accurate within ~30 seconds)
|
|
190
|
+
4. **No Data Loss**: No need to call `stopTracking()` - heartbeat already persisted the duration
|
|
191
|
+
|
|
192
|
+
## Database Schema
|
|
193
|
+
|
|
194
|
+
The tracker sends data to the `fu_tracking` table:
|
|
195
|
+
|
|
196
|
+
```sql
|
|
197
|
+
CREATE TABLE fu_tracking (
|
|
198
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
199
|
+
fu_id TEXT NOT NULL, -- Guest ID (gu-XXXXX) or User ID (fu-XXXXX)
|
|
200
|
+
app_package TEXT NOT NULL, -- Your app's package name
|
|
201
|
+
timestamp TEXT NOT NULL, -- ISO 8601 UTC timestamp
|
|
202
|
+
action TEXT NOT NULL, -- 'session_start'
|
|
203
|
+
device_brand TEXT, -- Device manufacturer
|
|
204
|
+
device_api_level TEXT, -- Android version
|
|
205
|
+
user_agent TEXT, -- Platform info
|
|
206
|
+
session_id TEXT NOT NULL, -- Unique session identifier
|
|
207
|
+
session_duration INTEGER NOT NULL -- Seconds since session start
|
|
208
|
+
);
|
|
135
209
|
```
|
|
136
210
|
|
|
137
|
-
|
|
211
|
+
## Data Collected
|
|
212
|
+
|
|
213
|
+
**Session Data:**
|
|
214
|
+
- Session start time (UTC timestamp)
|
|
215
|
+
- Session duration (seconds)
|
|
216
|
+
- Session ID (unique per app open)
|
|
217
|
+
|
|
218
|
+
**Device Info:**
|
|
219
|
+
- Device manufacturer (e.g., "Google", "Samsung")
|
|
220
|
+
- Android API level (e.g., "Android 33")
|
|
221
|
+
- Platform (e.g., "Android/13")
|
|
138
222
|
|
|
139
|
-
|
|
223
|
+
**No Personal Data:**
|
|
224
|
+
- ❌ No user location
|
|
225
|
+
- ❌ No contact information
|
|
226
|
+
- ❌ No sensitive permissions
|
|
140
227
|
|
|
141
|
-
|
|
142
|
-
import { getDeviceInfo } from 'firstusers';
|
|
228
|
+
## Troubleshooting
|
|
143
229
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
230
|
+
### Module not found error
|
|
231
|
+
|
|
232
|
+
**Error:** `The package 'firstusers' doesn't seem to be linked`
|
|
233
|
+
|
|
234
|
+
**Solution:** You must rebuild the native app:
|
|
235
|
+
```bash
|
|
236
|
+
npx expo run:android
|
|
150
237
|
```
|
|
151
238
|
|
|
152
|
-
|
|
239
|
+
### Tracking not starting
|
|
153
240
|
|
|
154
|
-
|
|
155
|
-
|
|
241
|
+
**Check:**
|
|
242
|
+
1. Did you call `setPackageName()` before `startTracking()`?
|
|
243
|
+
2. Is the package name correct? (Check `android/app/build.gradle` → `applicationId`)
|
|
244
|
+
3. Did you rebuild after installing the package?
|
|
156
245
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
-
|
|
161
|
-
|
|
162
|
-
|
|
246
|
+
**Debug:**
|
|
247
|
+
```typescript
|
|
248
|
+
// Enable console logs
|
|
249
|
+
futracker.configure('https://your-api.workers.dev');
|
|
250
|
+
futracker.setPackageName('com.yourcompany.app');
|
|
251
|
+
futracker.startTracking();
|
|
252
|
+
// Check console for "[FirstUsers]" logs
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Build errors
|
|
163
256
|
|
|
164
|
-
|
|
165
|
-
Green banner showing:
|
|
166
|
-
- FU ID is active
|
|
167
|
-
- Session duration
|
|
257
|
+
**Error:** `Plugin [id: 'expo-module-gradle-plugin'] was not found`
|
|
168
258
|
|
|
169
|
-
|
|
259
|
+
**Solution:** Clean and rebuild:
|
|
260
|
+
```bash
|
|
261
|
+
cd android
|
|
262
|
+
./gradlew clean
|
|
263
|
+
cd ..
|
|
264
|
+
npx expo prebuild --clean
|
|
265
|
+
npx expo run:android
|
|
266
|
+
```
|
|
170
267
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
-
|
|
174
|
-
-
|
|
268
|
+
## Requirements
|
|
269
|
+
|
|
270
|
+
- **React Native:** 0.70+
|
|
271
|
+
- **Expo:** 50+
|
|
272
|
+
- **Android:** API 24+ (Android 7.0)
|
|
273
|
+
- **Kotlin:** 1.8+
|
|
274
|
+
|
|
275
|
+
## Example Integration
|
|
276
|
+
|
|
277
|
+
Complete example with Supabase authentication:
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
// App.tsx
|
|
281
|
+
import React, { useEffect } from 'react';
|
|
282
|
+
import futracker from 'firstusers';
|
|
283
|
+
import { supabase } from './lib/supabase';
|
|
284
|
+
|
|
285
|
+
export default function App() {
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
// Configure tracker once
|
|
288
|
+
futracker.configure('https://cotester-api.01hunterwl.workers.dev');
|
|
289
|
+
futracker.setPackageName('net.woodlogos.app.firstusers');
|
|
290
|
+
|
|
291
|
+
// Listen for auth changes
|
|
292
|
+
const { data: authListener } = supabase.auth.onAuthStateChange(
|
|
293
|
+
async (event, session) => {
|
|
294
|
+
if (event === 'SIGNED_IN' && session) {
|
|
295
|
+
// Start tracking after successful login
|
|
296
|
+
futracker.startTracking();
|
|
297
|
+
console.log('✅ Tracking started for authenticated user');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
return () => {
|
|
303
|
+
authListener?.subscription.unsubscribe();
|
|
304
|
+
};
|
|
305
|
+
}, []);
|
|
175
306
|
|
|
176
|
-
|
|
307
|
+
return (
|
|
308
|
+
// Your app components
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Privacy & Compliance
|
|
177
314
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
315
|
+
**GDPR Compliant:** Only tracks usage duration and basic device info (no personal data)
|
|
316
|
+
|
|
317
|
+
**Disclose in Privacy Policy:**
|
|
318
|
+
```
|
|
319
|
+
This app collects anonymous usage data for beta testing:
|
|
320
|
+
- Session duration (start/end time)
|
|
321
|
+
- Device model and Android version
|
|
322
|
+
- App package name
|
|
323
|
+
|
|
324
|
+
No personal information is collected.
|
|
325
|
+
```
|
|
182
326
|
|
|
183
327
|
## License
|
|
184
328
|
|
|
185
329
|
MIT
|
|
330
|
+
|
|
331
|
+
## Support
|
|
332
|
+
|
|
333
|
+
- **Issues:** [GitHub Issues](https://github.com/firstusers/react-native-tracker/issues)
|
|
334
|
+
- **Documentation:** [GitHub Wiki](https://github.com/firstusers/react-native-tracker/wiki)
|
|
335
|
+
- **Email:** support@firstusers.io
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
**Made with ❤️ by First Users Team**
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
apply plugin: 'expo-module-gradle-plugin'
|
|
4
|
+
|
|
5
|
+
android {
|
|
6
|
+
namespace "com.firstusers.tracker"
|
|
7
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
|
8
|
+
|
|
9
|
+
defaultConfig {
|
|
10
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
11
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
compileOptions {
|
|
15
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
16
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
kotlinOptions {
|
|
20
|
+
jvmTarget = "17"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
dependencies {
|
|
25
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
|
26
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
|
27
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def safeExtGet(prop, fallback) {
|
|
31
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
32
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
package com.firstusers.tracker
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import kotlinx.coroutines.*
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import java.net.HttpURLConnection
|
|
8
|
+
import java.net.URL
|
|
9
|
+
import java.text.SimpleDateFormat
|
|
10
|
+
import java.util.*
|
|
11
|
+
|
|
12
|
+
object FirstUsers {
|
|
13
|
+
private const val P = "fu_prefs"
|
|
14
|
+
private const val K = "fu_device_id"
|
|
15
|
+
private var apiUrl = "https://cotester-api.01hunterwl.workers.dev"
|
|
16
|
+
private var sid = ""
|
|
17
|
+
private var t0 = 0L
|
|
18
|
+
private var pkg = ""
|
|
19
|
+
private var tmr: Timer? = null
|
|
20
|
+
private var isTracking = false
|
|
21
|
+
|
|
22
|
+
fun configure(url: String) {
|
|
23
|
+
apiUrl = url
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fun start(c: Context, ap: String) {
|
|
27
|
+
// Prevent duplicate tracking sessions
|
|
28
|
+
if (isTracking) {
|
|
29
|
+
android.util.Log.d("FirstUsers", "Already tracking, ignoring duplicate start call")
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
isTracking = true
|
|
33
|
+
|
|
34
|
+
// Cancel previous timer if exists (prevent duplicate timers)
|
|
35
|
+
tmr?.cancel()
|
|
36
|
+
tmr = null
|
|
37
|
+
|
|
38
|
+
pkg = ap
|
|
39
|
+
sid = "s_" + UUID.randomUUID().toString().take(8)
|
|
40
|
+
t0 = System.currentTimeMillis()
|
|
41
|
+
|
|
42
|
+
// Create initial record (session_duration = 0)
|
|
43
|
+
post("$apiUrl/api/fu/track",
|
|
44
|
+
JSONObject()
|
|
45
|
+
.put("fu_id", gid(c))
|
|
46
|
+
.put("app_package", pkg)
|
|
47
|
+
.put("timestamp", ts())
|
|
48
|
+
.put("action","session_start")
|
|
49
|
+
.put("device_brand", Build.MANUFACTURER)
|
|
50
|
+
.put("device_api_level", "Android "+Build.VERSION.SDK_INT)
|
|
51
|
+
.put("user_agent", "Android/"+Build.VERSION.RELEASE)
|
|
52
|
+
.put("session_id", sid)
|
|
53
|
+
.put("session_duration", 0))
|
|
54
|
+
|
|
55
|
+
// Heartbeat every 30s to update session_duration
|
|
56
|
+
tmr = Timer()
|
|
57
|
+
tmr?.schedule(object:TimerTask(){
|
|
58
|
+
override fun run() { hb() }
|
|
59
|
+
}, 30000, 30000)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fun stop(c: Context) {
|
|
63
|
+
if (!isTracking) return
|
|
64
|
+
isTracking = false
|
|
65
|
+
tmr?.cancel()
|
|
66
|
+
tmr = null
|
|
67
|
+
hb() // final update
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private fun dur() = ((System.currentTimeMillis()-t0)/1000).toInt()
|
|
71
|
+
|
|
72
|
+
private fun hb() {
|
|
73
|
+
post("$apiUrl/api/fu/track/heartbeat",
|
|
74
|
+
JSONObject()
|
|
75
|
+
.put("session_id", sid)
|
|
76
|
+
.put("session_duration",dur()))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun ts(): String {
|
|
80
|
+
val f = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
|
81
|
+
f.timeZone = TimeZone.getTimeZone("UTC")
|
|
82
|
+
return f.format(Date())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private fun gid(c: Context): String {
|
|
86
|
+
val sp = c.getSharedPreferences(P, Context.MODE_PRIVATE)
|
|
87
|
+
var id = sp.getString(K, null)
|
|
88
|
+
if (id == null) {
|
|
89
|
+
id = "gu-" + UUID.randomUUID().toString().take(5)
|
|
90
|
+
sp.edit().putString(K, id).apply()
|
|
91
|
+
}
|
|
92
|
+
return id
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private fun post(u: String, j: JSONObject) {
|
|
96
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
97
|
+
try {
|
|
98
|
+
val c = URL(u).openConnection() as HttpURLConnection
|
|
99
|
+
c.requestMethod = "POST"
|
|
100
|
+
c.setRequestProperty("Content-Type", "application/json")
|
|
101
|
+
c.doOutput = true
|
|
102
|
+
c.connectTimeout = 5000
|
|
103
|
+
c.outputStream.use {
|
|
104
|
+
it.write(j.toString().toByteArray())
|
|
105
|
+
}
|
|
106
|
+
c.responseCode
|
|
107
|
+
c.disconnect()
|
|
108
|
+
} catch (e: Exception) {
|
|
109
|
+
android.util.Log.w("FirstUsers", "Network request failed: ${e.message}")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package com.firstusers.tracker
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import expo.modules.kotlin.modules.Module
|
|
5
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
6
|
+
|
|
7
|
+
class FirstUsersModule : Module() {
|
|
8
|
+
override fun definition() = ModuleDefinition {
|
|
9
|
+
Name("FirstUsersTracker")
|
|
10
|
+
|
|
11
|
+
Function("configure") { apiUrl: String ->
|
|
12
|
+
FirstUsers.configure(apiUrl)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Function("start") { packageName: String ->
|
|
16
|
+
val context = appContext.reactContext as? Context
|
|
17
|
+
context?.let {
|
|
18
|
+
FirstUsers.start(it, packageName)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Function("stop") {
|
|
23
|
+
val context = appContext.reactContext as? Context
|
|
24
|
+
context?.let {
|
|
25
|
+
FirstUsers.stop(it)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare class futracker {
|
|
2
|
+
private static apiUrl;
|
|
3
|
+
private static packageName;
|
|
4
|
+
private static isTracking;
|
|
5
|
+
private static debugMode;
|
|
6
|
+
/**
|
|
7
|
+
* Configure the tracker with custom API URL
|
|
8
|
+
* @param apiUrl - Your First Users API endpoint
|
|
9
|
+
*/
|
|
10
|
+
static configure(apiUrl: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Enable or disable debug logging
|
|
13
|
+
* @param enabled - Whether to enable debug logs
|
|
14
|
+
*/
|
|
15
|
+
static setDebug(enabled: boolean): void;
|
|
16
|
+
/**
|
|
17
|
+
* Set the package name (usually from build.gradle applicationId)
|
|
18
|
+
* @param packageName - Your app's package name
|
|
19
|
+
*/
|
|
20
|
+
static setPackageName(packageName: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Start tracking user session
|
|
23
|
+
* Call this after successful user login
|
|
24
|
+
* @param packageName - Optional: override package name
|
|
25
|
+
*/
|
|
26
|
+
static startTracking(packageName?: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Stop tracking user session
|
|
29
|
+
* Optional: Can be called on logout, but heartbeat already auto-saves every 30s
|
|
30
|
+
*/
|
|
31
|
+
static stopTracking(): void;
|
|
32
|
+
/**
|
|
33
|
+
* Check if tracking is currently active
|
|
34
|
+
* @returns boolean indicating if tracking is active
|
|
35
|
+
*/
|
|
36
|
+
static isActive(): boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Convenience function for quick start
|
|
40
|
+
* @param packageName - Your app's package name
|
|
41
|
+
* @param apiUrl - Optional: custom API URL
|
|
42
|
+
*/
|
|
43
|
+
export declare function startTracking(packageName: string, apiUrl?: string): void;
|
|
44
|
+
/**
|
|
45
|
+
* Convenience function for stop
|
|
46
|
+
*/
|
|
47
|
+
export declare function stopTracking(): void;
|
|
48
|
+
export default futracker;
|
package/build/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.futracker = void 0;
|
|
4
|
+
exports.startTracking = startTracking;
|
|
5
|
+
exports.stopTracking = stopTracking;
|
|
6
|
+
const react_native_1 = require("react-native");
|
|
7
|
+
const LINKING_ERROR = `The package 'firstusers' doesn't seem to be linked. Make sure:\n\n` +
|
|
8
|
+
react_native_1.Platform.select({ ios: "- Run 'pod install'\n", default: '' }) +
|
|
9
|
+
'- You rebuilt the app after installing the package\n' +
|
|
10
|
+
'- You are not using Expo Go (this module requires custom native code)\n';
|
|
11
|
+
const FirstUsersTrackerModule = react_native_1.NativeModules.FirstUsersTracker
|
|
12
|
+
? react_native_1.NativeModules.FirstUsersTracker
|
|
13
|
+
: new Proxy({}, {
|
|
14
|
+
get() {
|
|
15
|
+
throw new Error(LINKING_ERROR);
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
class futracker {
|
|
19
|
+
/**
|
|
20
|
+
* Configure the tracker with custom API URL
|
|
21
|
+
* @param apiUrl - Your First Users API endpoint
|
|
22
|
+
*/
|
|
23
|
+
static configure(apiUrl) {
|
|
24
|
+
this.apiUrl = apiUrl;
|
|
25
|
+
FirstUsersTrackerModule.configure(apiUrl);
|
|
26
|
+
if (this.debugMode)
|
|
27
|
+
console.log('[FirstUsers] Configured with API:', apiUrl);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Enable or disable debug logging
|
|
31
|
+
* @param enabled - Whether to enable debug logs
|
|
32
|
+
*/
|
|
33
|
+
static setDebug(enabled) {
|
|
34
|
+
this.debugMode = enabled;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Set the package name (usually from build.gradle applicationId)
|
|
38
|
+
* @param packageName - Your app's package name
|
|
39
|
+
*/
|
|
40
|
+
static setPackageName(packageName) {
|
|
41
|
+
this.packageName = packageName;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Start tracking user session
|
|
45
|
+
* Call this after successful user login
|
|
46
|
+
* @param packageName - Optional: override package name
|
|
47
|
+
*/
|
|
48
|
+
static startTracking(packageName) {
|
|
49
|
+
const pkg = packageName || this.packageName;
|
|
50
|
+
if (!pkg) {
|
|
51
|
+
console.error('[FirstUsers] Package name not set. Call setPackageName() or pass it as argument.');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (this.isTracking) {
|
|
55
|
+
if (this.debugMode)
|
|
56
|
+
console.log('[FirstUsers] Already tracking, ignoring duplicate call');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
FirstUsersTrackerModule.start(pkg);
|
|
61
|
+
this.isTracking = true;
|
|
62
|
+
if (this.debugMode)
|
|
63
|
+
console.log('[FirstUsers] Tracking started for:', pkg);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.error('[FirstUsers] Failed to start tracking:', error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Stop tracking user session
|
|
71
|
+
* Optional: Can be called on logout, but heartbeat already auto-saves every 30s
|
|
72
|
+
*/
|
|
73
|
+
static stopTracking() {
|
|
74
|
+
if (!this.isTracking) {
|
|
75
|
+
if (this.debugMode)
|
|
76
|
+
console.log('[FirstUsers] Not currently tracking');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
FirstUsersTrackerModule.stop();
|
|
81
|
+
this.isTracking = false;
|
|
82
|
+
if (this.debugMode)
|
|
83
|
+
console.log('[FirstUsers] Tracking stopped');
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error('[FirstUsers] Failed to stop tracking:', error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Check if tracking is currently active
|
|
91
|
+
* @returns boolean indicating if tracking is active
|
|
92
|
+
*/
|
|
93
|
+
static isActive() {
|
|
94
|
+
return this.isTracking;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
exports.futracker = futracker;
|
|
98
|
+
futracker.apiUrl = 'https://cotester-api.01hunterwl.workers.dev';
|
|
99
|
+
futracker.packageName = '';
|
|
100
|
+
futracker.isTracking = false;
|
|
101
|
+
futracker.debugMode = true;
|
|
102
|
+
/**
|
|
103
|
+
* Convenience function for quick start
|
|
104
|
+
* @param packageName - Your app's package name
|
|
105
|
+
* @param apiUrl - Optional: custom API URL
|
|
106
|
+
*/
|
|
107
|
+
function startTracking(packageName, apiUrl) {
|
|
108
|
+
if (apiUrl) {
|
|
109
|
+
futracker.configure(apiUrl);
|
|
110
|
+
}
|
|
111
|
+
futracker.startTracking(packageName);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Convenience function for stop
|
|
115
|
+
*/
|
|
116
|
+
function stopTracking() {
|
|
117
|
+
futracker.stopTracking();
|
|
118
|
+
}
|
|
119
|
+
exports.default = futracker;
|
package/package.json
CHANGED
|
@@ -1,42 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firstusers",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "
|
|
6
|
-
"types": "index.d.ts",
|
|
7
|
-
"
|
|
8
|
-
"dist",
|
|
9
|
-
"index.d.ts",
|
|
10
|
-
"README.md",
|
|
11
|
-
"LICENSE"
|
|
12
|
-
],
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Usage tracking SDK for React Native/Expo apps. Tracks authenticated user sessions with 30s heartbeat to First Users platform.",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"app.plugin": "./plugin/src/withFirstUsers.js",
|
|
13
8
|
"scripts": {
|
|
14
|
-
"
|
|
15
|
-
"
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"prepare": "npm run build"
|
|
16
11
|
},
|
|
17
12
|
"keywords": [
|
|
18
|
-
"
|
|
19
|
-
"
|
|
13
|
+
"react-native",
|
|
14
|
+
"expo",
|
|
20
15
|
"analytics",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"guest-user"
|
|
16
|
+
"tracking",
|
|
17
|
+
"session",
|
|
18
|
+
"beta-testing",
|
|
19
|
+
"first-users"
|
|
26
20
|
],
|
|
27
21
|
"author": "First Users Team",
|
|
28
22
|
"license": "MIT",
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"expo": ">=50.0.0",
|
|
25
|
+
"react": "*",
|
|
26
|
+
"react-native": "*"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@expo/config-plugins": "^9.0.0",
|
|
30
|
+
"@types/react": "^19.0.0",
|
|
31
|
+
"typescript": "^5.3.0"
|
|
32
|
+
},
|
|
29
33
|
"repository": {
|
|
30
34
|
"type": "git",
|
|
31
|
-
"url": "https://github.com/
|
|
32
|
-
},
|
|
33
|
-
"peerDependencies": {
|
|
34
|
-
"react": ">=16.8.0"
|
|
35
|
+
"url": "https://github.com/firstusers/react-native-tracker.git"
|
|
35
36
|
},
|
|
36
|
-
"
|
|
37
|
-
"
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/firstusers/react-native-tracker/issues"
|
|
38
39
|
},
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
"homepage": "https://github.com/firstusers/react-native-tracker#readme",
|
|
41
|
+
"files": [
|
|
42
|
+
"build/",
|
|
43
|
+
"android/",
|
|
44
|
+
"plugin/",
|
|
45
|
+
"expo-module.config.json"
|
|
46
|
+
]
|
|
42
47
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const { withAndroidManifest, withAppBuildGradle } = require('@expo/config-plugins');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Expo Config Plugin for First Users Tracker
|
|
5
|
+
* Automatically configures Android project for session tracking
|
|
6
|
+
*/
|
|
7
|
+
function withFirstUsers(config, props = {}) {
|
|
8
|
+
const apiUrl = props.apiUrl || 'https://cotester-api.01hunterwl.workers.dev';
|
|
9
|
+
|
|
10
|
+
// Add INTERNET and DETECT_SCREEN_CAPTURE permissions to AndroidManifest.xml
|
|
11
|
+
config = withAndroidManifest(config, (config) => {
|
|
12
|
+
const androidManifest = config.modResults;
|
|
13
|
+
const mainApplication = androidManifest.manifest;
|
|
14
|
+
|
|
15
|
+
// Ensure permissions array exists
|
|
16
|
+
if (!mainApplication['uses-permission']) {
|
|
17
|
+
mainApplication['uses-permission'] = [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const permissions = mainApplication['uses-permission'];
|
|
21
|
+
|
|
22
|
+
// Add INTERNET permission if not exists
|
|
23
|
+
const hasInternet = permissions.some(
|
|
24
|
+
(perm) => perm.$?.['android:name'] === 'android.permission.INTERNET'
|
|
25
|
+
);
|
|
26
|
+
if (!hasInternet) {
|
|
27
|
+
permissions.push({
|
|
28
|
+
$: { 'android:name': 'android.permission.INTERNET' }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return config;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Add Kotlin Coroutines dependencies to app/build.gradle
|
|
36
|
+
config = withAppBuildGradle(config, (config) => {
|
|
37
|
+
const buildGradle = config.modResults.contents;
|
|
38
|
+
|
|
39
|
+
// Check if coroutines dependencies already exist
|
|
40
|
+
if (!buildGradle.includes('kotlinx-coroutines-android')) {
|
|
41
|
+
// Add dependencies after the dependencies block starts
|
|
42
|
+
const dependenciesRegex = /dependencies\s*{/;
|
|
43
|
+
if (dependenciesRegex.test(buildGradle)) {
|
|
44
|
+
config.modResults.contents = buildGradle.replace(
|
|
45
|
+
dependenciesRegex,
|
|
46
|
+
`dependencies {
|
|
47
|
+
// First Users Tracker - Kotlin Coroutines
|
|
48
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
|
49
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return config;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = withFirstUsers;
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 First Users Team
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
package/dist/index.js
DELETED
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
-
|
|
3
|
-
// ---- Global Heartbeat Manager (persists across route changes) ----
|
|
4
|
-
const FUHeartbeat = {
|
|
5
|
-
_interval: null,
|
|
6
|
-
_startTime: null,
|
|
7
|
-
_fuId: null,
|
|
8
|
-
_trackFn: null,
|
|
9
|
-
start(fuId, trackFn) {
|
|
10
|
-
if (this._interval) return; // already running
|
|
11
|
-
this._fuId = fuId;
|
|
12
|
-
this._trackFn = trackFn;
|
|
13
|
-
this._startTime = Date.now();
|
|
14
|
-
this._interval = setInterval(() => {
|
|
15
|
-
const dur = Math.floor((Date.now() - this._startTime) / 1000);
|
|
16
|
-
this._trackFn(this._fuId, 'heartbeat', { session_duration: dur });
|
|
17
|
-
}, 30000);
|
|
18
|
-
if (typeof window !== 'undefined') {
|
|
19
|
-
window.addEventListener('beforeunload', this._onUnload);
|
|
20
|
-
}
|
|
21
|
-
},
|
|
22
|
-
_onUnload() {
|
|
23
|
-
FUHeartbeat.stop(true);
|
|
24
|
-
},
|
|
25
|
-
stop(sendEnd = false) {
|
|
26
|
-
if (!this._interval) return;
|
|
27
|
-
clearInterval(this._interval);
|
|
28
|
-
if (sendEnd && this._trackFn && this._fuId) {
|
|
29
|
-
const dur = Math.floor((Date.now() - this._startTime) / 1000);
|
|
30
|
-
this._trackFn(this._fuId, 'session_end', { session_duration: dur });
|
|
31
|
-
}
|
|
32
|
-
if (typeof window !== 'undefined') {
|
|
33
|
-
window.removeEventListener('beforeunload', this._onUnload);
|
|
34
|
-
}
|
|
35
|
-
this._interval = null;
|
|
36
|
-
},
|
|
37
|
-
getDuration() {
|
|
38
|
-
if (!this._startTime) return 0;
|
|
39
|
-
return Math.floor((Date.now() - this._startTime) / 1000);
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
// ---- GU (Guest User) Auto-Generation ----
|
|
44
|
-
function generateGuestId() {
|
|
45
|
-
const randomStr = Math.random().toString(36).substr(2, 9);
|
|
46
|
-
return 'gu-' + randomStr;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function getOrCreateUserId() {
|
|
50
|
-
if (typeof localStorage === 'undefined') return null;
|
|
51
|
-
let userId = localStorage.getItem('fu_tester_id');
|
|
52
|
-
if (!userId) {
|
|
53
|
-
// Auto-generate GU ID for anonymous tracking
|
|
54
|
-
userId = generateGuestId();
|
|
55
|
-
localStorage.setItem('fu_tester_id', userId);
|
|
56
|
-
console.log('Auto-generated Guest User ID:', userId);
|
|
57
|
-
}
|
|
58
|
-
return userId;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ---- Device Info Helper ----
|
|
62
|
-
function getDeviceInfo() {
|
|
63
|
-
const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '') || '';
|
|
64
|
-
let device_brand = 'unknown';
|
|
65
|
-
let device_api_level = 'unknown';
|
|
66
|
-
// Detect brand from common UA patterns
|
|
67
|
-
const brands = ['Samsung','Xiaomi','Huawei','OnePlus','Oppo','Vivo','Realme',
|
|
68
|
-
'Motorola','LG','Sony','Google','Pixel','Nokia','Asus','ZTE','Lenovo'];
|
|
69
|
-
for (const b of brands) {
|
|
70
|
-
if (ua.toLowerCase().includes(b.toLowerCase())) { device_brand = b; break; }
|
|
71
|
-
}
|
|
72
|
-
// Detect Android API level
|
|
73
|
-
const androidMatch = ua.match(/Android\s([\d.]+)/);
|
|
74
|
-
if (androidMatch) device_api_level = 'Android ' + androidMatch[1];
|
|
75
|
-
const iosMatch = ua.match(/OS\s([\d_]+)\slike/);
|
|
76
|
-
if (iosMatch) device_api_level = 'iOS ' + iosMatch[1].replace(/_/g, '.');
|
|
77
|
-
return { device_brand, device_api_level, user_agent: ua };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ---- Offline Queue ----
|
|
81
|
-
function queueFailedRequest(data) {
|
|
82
|
-
if (typeof localStorage === 'undefined') return;
|
|
83
|
-
try {
|
|
84
|
-
const q = JSON.parse(localStorage.getItem('fu_failed_requests') || '[]');
|
|
85
|
-
q.push({ ...data, queued_at: new Date().toISOString() });
|
|
86
|
-
localStorage.setItem('fu_failed_requests', JSON.stringify(q));
|
|
87
|
-
} catch (e) { console.error('Queue error:', e); }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function processFailedRequests(trackFn) {
|
|
91
|
-
if (typeof localStorage === 'undefined') return;
|
|
92
|
-
try {
|
|
93
|
-
const q = JSON.parse(localStorage.getItem('fu_failed_requests') || '[]');
|
|
94
|
-
if (!q.length) return;
|
|
95
|
-
const remaining = [];
|
|
96
|
-
for (const req of q) {
|
|
97
|
-
const ok = await trackFn(req.fu_id, req.action, req, false);
|
|
98
|
-
if (!ok) remaining.push(req);
|
|
99
|
-
}
|
|
100
|
-
localStorage.setItem('fu_failed_requests', JSON.stringify(remaining));
|
|
101
|
-
} catch (e) { console.error('Retry error:', e); }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* First Users Integration Component
|
|
106
|
-
* @param {Object} props
|
|
107
|
-
* @param {string} props.appPackageName - Your app's package name (e.g., "com.yourcompany.app")
|
|
108
|
-
* @param {string} [props.apiEndpoint] - Custom API endpoint (default: First Users API)
|
|
109
|
-
*/
|
|
110
|
-
function FirstUsersIntegration({ appPackageName = 'your.package.name', apiEndpoint = 'https://cotester-api.01hunterwl.workers.dev' }) {
|
|
111
|
-
const [fuId, setFuId] = useState('');
|
|
112
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
113
|
-
const [isTracked, setIsTracked] = useState(false);
|
|
114
|
-
const [sessionDuration, setSessionDuration] = useState(0);
|
|
115
|
-
const timerRef = useRef(null);
|
|
116
|
-
const sessionId = React.useMemo(() =>
|
|
117
|
-
'session_' + Math.random().toString(36).substr(2, 9), []
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
const trackActivity = async (fuIdToTrack, action = 'app_usage', extraData = {}, shouldQueue = true) => {
|
|
121
|
-
try {
|
|
122
|
-
const device = getDeviceInfo();
|
|
123
|
-
const requestData = {
|
|
124
|
-
fu_id: fuIdToTrack,
|
|
125
|
-
app_package: appPackageName,
|
|
126
|
-
timestamp: new Date().toISOString(),
|
|
127
|
-
action,
|
|
128
|
-
page_url: typeof window !== 'undefined' ? (window.location?.pathname + window.location?.search) : '',
|
|
129
|
-
user_agent: device.user_agent,
|
|
130
|
-
device_brand: device.device_brand,
|
|
131
|
-
device_api_level: device.device_api_level,
|
|
132
|
-
session_id: sessionId,
|
|
133
|
-
session_duration: FUHeartbeat.getDuration(),
|
|
134
|
-
...extraData
|
|
135
|
-
};
|
|
136
|
-
const resp = await fetch(`${apiEndpoint}/api/fu/track`, {
|
|
137
|
-
method: 'POST',
|
|
138
|
-
headers: { 'Content-Type': 'application/json' },
|
|
139
|
-
body: JSON.stringify(requestData),
|
|
140
|
-
});
|
|
141
|
-
const result = await resp.json();
|
|
142
|
-
if (result.success) { console.log('FU tracked:', result); return true; }
|
|
143
|
-
if (shouldQueue) queueFailedRequest(requestData);
|
|
144
|
-
return false;
|
|
145
|
-
} catch (e) {
|
|
146
|
-
console.error('FU error:', e);
|
|
147
|
-
if (shouldQueue) queueFailedRequest({ fu_id: fuIdToTrack, app_package: appPackageName, action, ...extraData });
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
// On mount: restore tracked state, process offline queue, start heartbeat
|
|
153
|
-
useEffect(() => {
|
|
154
|
-
const userId = getOrCreateUserId(); // Auto-generate GU if needed
|
|
155
|
-
if (!userId) return; // Skip if localStorage unavailable
|
|
156
|
-
|
|
157
|
-
setFuId(userId);
|
|
158
|
-
|
|
159
|
-
// Determine if this is a real FU or auto-generated GU
|
|
160
|
-
const isRealFU = userId.startsWith('fu-');
|
|
161
|
-
setIsTracked(isRealFU);
|
|
162
|
-
|
|
163
|
-
// Start tracking immediately with either FU or GU
|
|
164
|
-
trackActivity(userId, 'page_visit');
|
|
165
|
-
FUHeartbeat.start(userId, trackActivity);
|
|
166
|
-
|
|
167
|
-
// Process any queued offline requests on startup
|
|
168
|
-
processFailedRequests(trackActivity);
|
|
169
|
-
// Update duration display every second
|
|
170
|
-
timerRef.current = setInterval(() => setSessionDuration(FUHeartbeat.getDuration()), 1000);
|
|
171
|
-
return () => {
|
|
172
|
-
if (timerRef.current) clearInterval(timerRef.current);
|
|
173
|
-
};
|
|
174
|
-
}, []);
|
|
175
|
-
|
|
176
|
-
// Bind GU to FU when user submits their FU ID
|
|
177
|
-
const bindGuestToFU = async (guId, fuId) => {
|
|
178
|
-
try {
|
|
179
|
-
const resp = await fetch(`${apiEndpoint}/api/fu/bind`, {
|
|
180
|
-
method: 'POST',
|
|
181
|
-
headers: { 'Content-Type': 'application/json' },
|
|
182
|
-
body: JSON.stringify({ gu_id: guId, fu_id: fuId }),
|
|
183
|
-
});
|
|
184
|
-
const result = await resp.json();
|
|
185
|
-
return result.success;
|
|
186
|
-
} catch (e) {
|
|
187
|
-
console.error('Bind error:', e);
|
|
188
|
-
return false;
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const handleSubmitFuId = async () => {
|
|
193
|
-
if (!fuId.startsWith('fu-') || fuId.length !== 8) {
|
|
194
|
-
alert('Please enter a valid FU ID (format: fu-XXXXX)');
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
setIsSubmitting(true);
|
|
198
|
-
|
|
199
|
-
// Get current ID (might be GU)
|
|
200
|
-
const currentId = typeof localStorage !== 'undefined' ? localStorage.getItem('fu_tester_id') : null;
|
|
201
|
-
const isGU = currentId && currentId.startsWith('gu-');
|
|
202
|
-
|
|
203
|
-
// If upgrading from GU to FU, bind them first
|
|
204
|
-
if (isGU) {
|
|
205
|
-
const bindSuccess = await bindGuestToFU(currentId, fuId);
|
|
206
|
-
if (!bindSuccess) {
|
|
207
|
-
alert('Failed to link your previous activity. Please try again.');
|
|
208
|
-
setIsSubmitting(false);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
console.log(`Successfully bound ${currentId} to ${fuId}`);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Register the FU ID
|
|
215
|
-
const ok = await trackActivity(fuId, 'tester_registration');
|
|
216
|
-
if (ok) {
|
|
217
|
-
alert('Your testing activity is now being tracked with your FU ID.');
|
|
218
|
-
if (typeof localStorage !== 'undefined') {
|
|
219
|
-
localStorage.setItem('fu_tester_id', fuId);
|
|
220
|
-
}
|
|
221
|
-
setIsTracked(true);
|
|
222
|
-
// Restart heartbeat with new FU ID
|
|
223
|
-
FUHeartbeat.stop();
|
|
224
|
-
FUHeartbeat.start(fuId, trackActivity);
|
|
225
|
-
} else {
|
|
226
|
-
alert('Failed to register. Will retry automatically.');
|
|
227
|
-
}
|
|
228
|
-
setIsSubmitting(false);
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
// Expose global feature tracker
|
|
232
|
-
useEffect(() => {
|
|
233
|
-
if (typeof window !== 'undefined') {
|
|
234
|
-
window.trackFUFeature = (name) => {
|
|
235
|
-
const id = localStorage.getItem('fu_tester_id');
|
|
236
|
-
if (id) trackActivity(id, `feature_${name}`);
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
}, []);
|
|
240
|
-
|
|
241
|
-
const currentId = typeof localStorage !== 'undefined' ? localStorage.getItem('fu_tester_id') : '';
|
|
242
|
-
const isGU = currentId && currentId.startsWith('gu-');
|
|
243
|
-
|
|
244
|
-
if (isTracked) {
|
|
245
|
-
return (
|
|
246
|
-
<div style={{ padding:'15px', backgroundColor:'#dcfce7', borderRadius:'8px',
|
|
247
|
-
margin:'20px 0', border:'1px solid #bbf7d0' }}>
|
|
248
|
-
<h4 style={{ color:'#16a34a', margin:'0 0 5px' }}>First Users Tester Active</h4>
|
|
249
|
-
<p style={{ margin:0, fontSize:'14px', color:'#15803d' }}>
|
|
250
|
-
Tracking active with FU ID. Session: {Math.floor(sessionDuration/60)}m {sessionDuration%60}s
|
|
251
|
-
</p>
|
|
252
|
-
</div>
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (isGU) {
|
|
257
|
-
return (
|
|
258
|
-
<div style={{ padding:'20px', backgroundColor:'#fff3cd', borderRadius:'8px',
|
|
259
|
-
margin:'20px 0', border:'1px solid #ffc107' }}>
|
|
260
|
-
<h3 style={{ color:'#856404', marginTop:0 }}>Anonymous Tracking Active</h3>
|
|
261
|
-
<p style={{ fontSize:'14px', color:'#856404', margin:'10px 0' }}>
|
|
262
|
-
Currently tracking with Guest ID: <code>{currentId}</code>
|
|
263
|
-
</p>
|
|
264
|
-
<p style={{ fontSize:'14px', color:'#856404', margin:'10px 0' }}>
|
|
265
|
-
Session: {Math.floor(sessionDuration/60)}m {sessionDuration%60}s
|
|
266
|
-
</p>
|
|
267
|
-
<p style={{ fontSize:'14px', color:'#856404', fontWeight:'bold', margin:'10px 0' }}>
|
|
268
|
-
Enter your FU ID to link your activity:
|
|
269
|
-
</p>
|
|
270
|
-
<input type="text" placeholder="fu-12345" value={fuId}
|
|
271
|
-
onChange={(e) => setFuId(e.target.value)}
|
|
272
|
-
style={{ padding:'10px', marginRight:'10px', border:'1px solid #ddd',
|
|
273
|
-
borderRadius:'4px', minWidth:'120px' }} />
|
|
274
|
-
<button onClick={handleSubmitFuId} disabled={isSubmitting}
|
|
275
|
-
style={{ padding:'10px 20px', backgroundColor: isSubmitting?'#9ca3af':'#3b82f6',
|
|
276
|
-
color:'white', border:'none', borderRadius:'4px',
|
|
277
|
-
cursor: isSubmitting?'not-allowed':'pointer' }}>
|
|
278
|
-
{isSubmitting ? 'Linking...' : 'Link FU ID'}
|
|
279
|
-
</button>
|
|
280
|
-
</div>
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return (
|
|
285
|
-
<div style={{ padding:'20px', backgroundColor:'#f5f5f5', borderRadius:'8px',
|
|
286
|
-
margin:'20px 0', border:'1px solid #d1d5db' }}>
|
|
287
|
-
<h3>First Users Tester</h3>
|
|
288
|
-
<p>Enter your FU ID to start tracking:</p>
|
|
289
|
-
<input type="text" placeholder="fu-12345" value={fuId}
|
|
290
|
-
onChange={(e) => setFuId(e.target.value)}
|
|
291
|
-
style={{ padding:'10px', marginRight:'10px', border:'1px solid #ddd',
|
|
292
|
-
borderRadius:'4px', minWidth:'120px' }} />
|
|
293
|
-
<button onClick={handleSubmitFuId} disabled={isSubmitting}
|
|
294
|
-
style={{ padding:'10px 20px', backgroundColor: isSubmitting?'#9ca3af':'#3b82f6',
|
|
295
|
-
color:'white', border:'none', borderRadius:'4px',
|
|
296
|
-
cursor: isSubmitting?'not-allowed':'pointer' }}>
|
|
297
|
-
{isSubmitting ? 'Registering...' : 'Start Tracking'}
|
|
298
|
-
</button>
|
|
299
|
-
</div>
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export default FirstUsersIntegration;
|
|
304
|
-
export { FUHeartbeat, generateGuestId, getOrCreateUserId, getDeviceInfo };
|
package/index.d.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
|
|
3
|
-
export interface FirstUsersIntegrationProps {
|
|
4
|
-
/**
|
|
5
|
-
* Your application's package name (e.g., "com.yourcompany.app")
|
|
6
|
-
*/
|
|
7
|
-
appPackageName: string;
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Custom API endpoint (optional, defaults to First Users API)
|
|
11
|
-
*/
|
|
12
|
-
apiEndpoint?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* First Users Integration Component
|
|
17
|
-
* Provides automatic tester tracking with heartbeat monitoring, offline retry,
|
|
18
|
-
* device fingerprinting, and guest user auto-tracking.
|
|
19
|
-
*/
|
|
20
|
-
declare const FirstUsersIntegration: React.FC<FirstUsersIntegrationProps>;
|
|
21
|
-
|
|
22
|
-
export default FirstUsersIntegration;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Global heartbeat manager singleton
|
|
26
|
-
*/
|
|
27
|
-
export const FUHeartbeat: {
|
|
28
|
-
start(fuId: string, trackFn: (id: string, action: string, data?: any) => Promise<boolean>): void;
|
|
29
|
-
stop(sendEnd?: boolean): void;
|
|
30
|
-
getDuration(): number;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Generate a unique Guest User ID
|
|
35
|
-
*/
|
|
36
|
-
export function generateGuestId(): string;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Get existing user ID or create new Guest User ID
|
|
40
|
-
*/
|
|
41
|
-
export function getOrCreateUserId(): string | null;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Extract device information from user agent
|
|
45
|
-
*/
|
|
46
|
-
export function getDeviceInfo(): {
|
|
47
|
-
device_brand: string;
|
|
48
|
-
device_api_level: string;
|
|
49
|
-
user_agent: string;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Global feature tracking function (available after component mount)
|
|
54
|
-
*/
|
|
55
|
-
declare global {
|
|
56
|
-
interface Window {
|
|
57
|
-
trackFUFeature?: (featureName: string) => void;
|
|
58
|
-
}
|
|
59
|
-
}
|