@rk911/tracker-sdk 1.7.1 → 1.7.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/core/tracker.js CHANGED
@@ -96,6 +96,17 @@ export class Tracker {
96
96
  this._referrer = this._currentPage
97
97
  }
98
98
  this._currentPage = declaredPage
99
+
100
+ // 跨端信息携带:当在 Android/iOS 原生容器内运行时,将 H5 页面路由与标题同步给端上
101
+ if (typeof window !== 'undefined' && window.NativeBridge && window.NativeBridge.postMessage) {
102
+ try {
103
+ window.NativeBridge.postMessage('tracker.syncPageState', JSON.stringify({
104
+ pageRoute: declaredPage,
105
+ pageTitle: eventData.pageTitle || '',
106
+ timestamp: Date.now()
107
+ }), '')
108
+ } catch (e) {}
109
+ }
99
110
  }
100
111
 
101
112
  if (!this._currentPage) {
@@ -202,6 +213,14 @@ export class Tracker {
202
213
 
203
214
  setUser(userId, isNative = false) {
204
215
  this.session.setUser(userId, isNative)
216
+ // 跨端信息携带:当在原生 WebView 登录或切换用户时,将身份变更同步到 Native 端
217
+ if (!isNative && typeof window !== 'undefined' && window.NativeBridge && window.NativeBridge.postMessage) {
218
+ try {
219
+ window.NativeBridge.postMessage('tracker.syncUserIdentity', JSON.stringify({
220
+ userId: userId
221
+ }), '')
222
+ } catch (e) {}
223
+ }
205
224
  }
206
225
 
207
226
  setDeviceId(deviceId, isNative = false) {
@@ -2,48 +2,156 @@ package com.renrui.tracker;
2
2
 
3
3
  import android.app.Activity;
4
4
  import android.app.Application;
5
+ import android.content.ContentValues;
6
+ import android.content.Context;
7
+ import android.database.Cursor;
8
+ import android.database.sqlite.SQLiteDatabase;
9
+ import android.database.sqlite.SQLiteOpenHelper;
5
10
  import android.os.Bundle;
6
11
  import android.os.Handler;
7
12
  import android.os.HandlerThread;
8
13
  import android.text.TextUtils;
9
14
  import android.util.Log;
15
+ import android.view.ActionMode;
16
+ import android.view.KeyEvent;
17
+ import android.view.Menu;
18
+ import android.view.MenuItem;
19
+ import android.view.MotionEvent;
20
+ import android.view.SearchEvent;
21
+ import android.view.View;
22
+ import android.view.ViewConfiguration;
23
+ import android.view.ViewGroup;
24
+ import android.view.Window;
25
+ import android.view.WindowManager;
26
+ import android.widget.Button;
27
+ import android.widget.EditText;
28
+ import android.widget.TextView;
29
+
30
+ import androidx.annotation.NonNull;
31
+ import androidx.annotation.Nullable;
32
+ import androidx.fragment.app.Fragment;
33
+ import androidx.fragment.app.FragmentActivity;
34
+ import androidx.fragment.app.FragmentManager;
10
35
 
11
36
  import org.json.JSONArray;
12
37
  import org.json.JSONObject;
13
38
 
39
+ import java.io.BufferedReader;
40
+ import java.io.InputStream;
41
+ import java.io.InputStreamReader;
14
42
  import java.io.OutputStream;
15
43
  import java.net.HttpURLConnection;
16
44
  import java.net.URL;
17
45
  import java.util.ArrayList;
46
+ import java.util.HashMap;
18
47
  import java.util.List;
19
- import java.util.UUID;
48
+ import java.util.Map;
20
49
 
21
50
  /**
22
51
  * 行业级 Native Android Tracker SDK
23
- * 独立后台 HandlerThread 处理打点,主线程耗时 0ms
52
+ * 1. 独立后台 HandlerThread 处理打点,主线程耗时 0ms
53
+ * 2. 轻量自包含 SQLite 本地持久化队列,应用退后台/被杀/Crash 100% 防丢 (S1)
54
+ * 3. 弱网/断网失败重试与补偿机制 (S2)
55
+ * 4. TouchSlop 滑动误触过滤,彻底排除列表滚动误触发点击 (S3)
56
+ * 5. 页面来源 Referrer 状态机 (S4)
57
+ * 6. 对外暴露标准显式操控 API (S5)
58
+ * 7. 兼容历史自定义点位,支撑后端双写 (A6)
24
59
  */
25
60
  public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
26
61
 
27
62
  private static final String TAG = "AndroidTracker";
28
63
  private static final int BATCH_SIZE = 20;
29
- private static final long FLUSH_INTERVAL_MS = 5000;
64
+ private static final long FLUSH_INTERVAL_MS = 3000;
65
+ private static final int MAX_DB_RECORDS = 1000;
30
66
 
31
67
  private static volatile AndroidTracker instance;
32
68
  private Application application;
33
- private String serverUrl;
69
+ private volatile String serverUrl;
34
70
  private String appId = "12"; // 12: Android App
35
- private String sessionId;
36
- private String deviceId;
37
- private String userId;
38
- private String phone;
39
- private String storeName;
71
+ private volatile String sessionId;
72
+ private volatile String deviceId;
73
+ private volatile String userId;
74
+ private volatile String phone;
75
+ private volatile String storeName;
40
76
  private int sequence = 0;
41
77
 
42
78
  private HandlerThread bgThread;
43
79
  private Handler bgHandler;
44
- private final List<JSONObject> eventQueue = new ArrayList<>();
80
+ private TrackerDbHelper dbHelper;
81
+ private final Object dbLock = new Object();
82
+
45
83
  private long lastPageStartTime = 0;
46
84
  private String currentPageRoute = "";
85
+ private String referrerPageRoute = "";
86
+ private String lastClickKey = "";
87
+ private long lastClickTime = 0;
88
+
89
+ private static final Map<String, String> NATIVE_TITLE_MAP = new HashMap<>();
90
+ static {
91
+ NATIVE_TITLE_MAP.put("com.renrui.main.activity.MainActivity", "APP主页");
92
+ NATIVE_TITLE_MAP.put("com.renrui.main.fragment.MainTabFragment", "首页Tab");
93
+ NATIVE_TITLE_MAP.put("com.renrui.main.fragment.MainRecommentFragment", "推荐精选/视频");
94
+ NATIVE_TITLE_MAP.put("com.renrui.main.fragment.MainWebFragment", "首页Web容器");
95
+ NATIVE_TITLE_MAP.put("com.renrui.mine.fragment.MineFragment", "个人中心/我的");
96
+ NATIVE_TITLE_MAP.put("com.renrui.mine.activity.MineWebActivity", "我的Web容器");
97
+ NATIVE_TITLE_MAP.put("com.renrui.mine.fragment.MineWebFragment", "我的Web页面");
98
+ NATIVE_TITLE_MAP.put("com.renrui.wz.activity.SplashActivity", "启动闪屏页");
99
+ NATIVE_TITLE_MAP.put("com.renrui.web.CommonWebActivity", "通用H5容器");
100
+ NATIVE_TITLE_MAP.put("com.renrui.web.CommonWebFragment", "通用H5页面");
101
+ }
102
+
103
+ public static String getFriendlyPageTitle(String className) {
104
+ if (TextUtils.isEmpty(className)) return "";
105
+ if (NATIVE_TITLE_MAP.containsKey(className)) {
106
+ return NATIVE_TITLE_MAP.get(className);
107
+ }
108
+ int lastDot = className.lastIndexOf('.');
109
+ if (lastDot >= 0 && lastDot < className.length() - 1) {
110
+ String simple = className.substring(lastDot + 1);
111
+ if (simple.contains("MainTab")) return "首页/主Tab";
112
+ if (simple.contains("Recomment")) return "推荐精选";
113
+ if (simple.contains("Mine")) return "个人中心";
114
+ if (simple.contains("Splash")) return "启动闪屏页";
115
+ if (simple.contains("Login")) return "登录页";
116
+ if (simple.contains("Web")) return "Web视图";
117
+ return simple;
118
+ }
119
+ return className;
120
+ }
121
+
122
+ private static class EventRecord {
123
+ final long id;
124
+ final JSONObject json;
125
+
126
+ EventRecord(long id, JSONObject json) {
127
+ this.id = id;
128
+ this.json = json;
129
+ }
130
+ }
131
+
132
+ private static class TrackerDbHelper extends SQLiteOpenHelper {
133
+ private static final String DB_NAME = "rk_tracker.db";
134
+ private static final int DB_VERSION = 1;
135
+ private static final String TABLE_EVENTS = "events";
136
+
137
+ TrackerDbHelper(Context context) {
138
+ super(context, DB_NAME, null, DB_VERSION);
139
+ }
140
+
141
+ @Override
142
+ public void onCreate(SQLiteDatabase db) {
143
+ db.execSQL("CREATE TABLE IF NOT EXISTS " + TABLE_EVENTS + " ("
144
+ + "id INTEGER PRIMARY KEY AUTOINCREMENT, "
145
+ + "data TEXT NOT NULL, "
146
+ + "created_at INTEGER NOT NULL)");
147
+ }
148
+
149
+ @Override
150
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
151
+ db.execSQL("DROP TABLE IF EXISTS " + TABLE_EVENTS);
152
+ onCreate(db);
153
+ }
154
+ }
47
155
 
48
156
  private AndroidTracker() {}
49
157
 
@@ -72,11 +180,18 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
72
180
  } else {
73
181
  this.serverUrl = serverUrl;
74
182
  }
75
- this.deviceId = deviceId;
76
- this.userId = userId;
77
- this.phone = phone;
78
- this.storeName = storeName;
79
- this.sessionId = "android_" + System.currentTimeMillis() + "_" + (int)(Math.random() * 10000);
183
+ this.deviceId = !TextUtils.isEmpty(deviceId) ? deviceId : "dev_android_" + System.currentTimeMillis();
184
+ this.userId = userId != null ? userId : "";
185
+ this.phone = phone != null ? phone : "";
186
+ this.storeName = storeName != null ? storeName : "";
187
+ this.sessionId = "android_" + System.currentTimeMillis() + "_" + (int) (Math.random() * 10000);
188
+
189
+ // 初始化 SQLite 本地持久化数据库 (S1 防丢保障)
190
+ try {
191
+ dbHelper = new TrackerDbHelper(app.getApplicationContext());
192
+ } catch (Exception e) {
193
+ Log.e(TAG, "===> [Native Tracker DB Init Error]", e);
194
+ }
80
195
 
81
196
  // 创建独立后台线程处理队列与网络请求,保障 UI 0 卡顿
82
197
  bgThread = new HandlerThread("TrackerBgThread");
@@ -88,19 +203,91 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
88
203
 
89
204
  // 启动定时 Flush 任务
90
205
  scheduleTimerFlush();
91
- Log.d(TAG, "AndroidTracker initialized with sessionId: " + sessionId + ", serverUrl: " + this.serverUrl);
206
+ Log.i(TAG, "====================================================================");
207
+ Log.i(TAG, "===> [Native Tracker Init Success] SessionId: " + sessionId);
208
+ Log.i(TAG, "===> ServerUrl: " + this.serverUrl);
209
+ Log.i(TAG, "===> DeviceId: " + this.deviceId + ", UserId: " + this.userId + ", Phone: " + this.phone + ", Store: " + this.storeName);
210
+ Log.i(TAG, "====================================================================");
211
+
212
+ // 启动时检查是否有上一轮未发出的积压事件,立即尝试补发 (S2 补偿机制)
213
+ flushAsync();
92
214
  }
93
215
 
94
216
  public void updateUser(String userId, String phone, String storeName) {
95
- if (!TextUtils.isEmpty(userId)) this.userId = userId;
96
- if (!TextUtils.isEmpty(phone)) this.phone = phone;
97
- if (!TextUtils.isEmpty(storeName)) this.storeName = storeName;
217
+ if (userId != null) this.userId = userId;
218
+ if (phone != null) this.phone = phone;
219
+ if (storeName != null) this.storeName = storeName;
220
+ Log.i(TAG, "===> [Native Tracker User Updated] UserId: " + this.userId + ", Phone: " + this.phone + ", Store: " + this.storeName);
98
221
  }
99
222
 
100
223
  public String getSessionId() {
101
224
  return sessionId;
102
225
  }
103
226
 
227
+ public String getDeviceId() {
228
+ return deviceId;
229
+ }
230
+
231
+ public void setUserId(String userId) {
232
+ updateUser(userId, null, null);
233
+ }
234
+
235
+ /**
236
+ * H5 路由状态同步 (由 CommonWebPresenter 通过 NativeBridge 触发)
237
+ */
238
+ public void updateH5PageState(final String h5Route, final String h5Title) {
239
+ postBgTask(new Runnable() {
240
+ @Override
241
+ public void run() {
242
+ try {
243
+ if (TextUtils.isEmpty(h5Route)) return;
244
+ String referrer = currentPageRoute;
245
+ currentPageRoute = h5Route;
246
+ referrerPageRoute = referrer;
247
+ lastPageStartTime = System.currentTimeMillis();
248
+ Log.i(TAG, "===> [H5 Page State Synced to Native] Route: " + currentPageRoute + ", Title: " + h5Title + ", Referrer: " + referrerPageRoute);
249
+ } catch (Exception e) {
250
+ Log.e(TAG, "===> [updateH5PageState Error]", e);
251
+ }
252
+ }
253
+ });
254
+ }
255
+
256
+ public void resetSession() {
257
+ this.sessionId = "android_" + System.currentTimeMillis() + "_" + (int) (Math.random() * 10000);
258
+ Log.i(TAG, "===> [Native Tracker Session Reset] New SessionId: " + this.sessionId);
259
+ }
260
+
261
+ /**
262
+ * 手动页面访问打点 (S5 & A4 用于 ViewPager2 Tab 切换补报)
263
+ */
264
+ public void trackPageView(final String pageRoute, final String pageTitle) {
265
+ postBgTask(new Runnable() {
266
+ @Override
267
+ public void run() {
268
+ try {
269
+ // S34 类似防御:500ms 内相同路由防抖,避免生命周期和手动调用重复触发
270
+ if (pageRoute.equals(currentPageRoute) && (System.currentTimeMillis() - lastPageStartTime < 500)) {
271
+ Log.d(TAG, "===> [Native Page View Skipped - Duplicate within 500ms]: " + pageRoute);
272
+ return;
273
+ }
274
+ String referrer = currentPageRoute;
275
+ currentPageRoute = pageRoute;
276
+ referrerPageRoute = referrer;
277
+ lastPageStartTime = System.currentTimeMillis();
278
+
279
+ Log.i(TAG, "===> [Native Page View] Route: " + currentPageRoute + ", Title: " + pageTitle + ", Referrer: " + referrerPageRoute);
280
+ JSONObject event = createBaseEvent("page_view", "page_view");
281
+ event.put("pageTitle", !TextUtils.isEmpty(pageTitle) ? pageTitle : pageRoute);
282
+ enqueueEvent(event);
283
+ flushAsync();
284
+ } catch (Exception e) {
285
+ Log.e(TAG, "===> [Native trackPageView Error]", e);
286
+ }
287
+ }
288
+ });
289
+ }
290
+
104
291
  /**
105
292
  * 手动自定义埋点 (Custom Tracking)
106
293
  */
@@ -114,8 +301,9 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
114
301
  event.put("extraData", customProperties.toString());
115
302
  }
116
303
  enqueueEvent(event);
304
+ flushAsync(); // 自定义点位立即上报
117
305
  } catch (Exception e) {
118
- Log.e(TAG, "track custom event failed", e);
306
+ Log.e(TAG, "===> [Native Tracker Track Failed]", e);
119
307
  }
120
308
  }
121
309
  });
@@ -134,23 +322,104 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
134
322
  event.put("elementId", elementId);
135
323
  if (postParam != null) event.put("postParam", postParam);
136
324
  enqueueEvent(event);
325
+ flushAsync();
137
326
  } catch (Exception e) {
138
- Log.e(TAG, "trackFrontUrl failed", e);
327
+ Log.e(TAG, "===> [Native Tracker trackFrontUrl Failed]", e);
139
328
  }
140
329
  }
141
330
  });
142
331
  }
143
332
 
333
+ /**
334
+ * 接口请求与异常自动化上报 (S6 API Tracking - 0 阻塞、异步持久化)
335
+ */
336
+ public void trackApi(final String url, final String method, final int statusCode,
337
+ final long duration, final String reqParam, final String respData, final String error) {
338
+ if (TextUtils.isEmpty(url)) return;
339
+ // 严格防递归:SDK 自身上报地址直接跳过
340
+ if (url.contains("/api/point/task/trackBatch") || (serverUrl != null && url.contains(serverUrl))) {
341
+ return;
342
+ }
343
+
344
+ postBgTask(new Runnable() {
345
+ @Override
346
+ public void run() {
347
+ try {
348
+ String eventType = (statusCode >= 400 || !TextUtils.isEmpty(error)) ? "api_error" : "api_request";
349
+ // 聚合优化:提取纯净的 API 路由路径作为 eventName(例如 /app/user/getUserInfo),方便在报表按接口聚合
350
+ String apiPath = extractApiPath(url);
351
+ JSONObject event = createBaseEvent(eventType, apiPath);
352
+
353
+ JSONObject extra = new JSONObject();
354
+ extra.put("url", url);
355
+ extra.put("apiPath", apiPath);
356
+ extra.put("method", method != null ? method.toUpperCase() : "GET");
357
+ extra.put("statusCode", statusCode);
358
+ extra.put("duration", duration);
359
+
360
+ // 字段容量防膨胀安全截断:防止过大 JSON 导致 SQLite CursorWindow 溢出或 HTTP 413
361
+ if (!TextUtils.isEmpty(reqParam)) {
362
+ extra.put("requestParams", reqParam.length() > 2048 ? reqParam.substring(0, 2048) : reqParam);
363
+ }
364
+ if (!TextUtils.isEmpty(respData)) {
365
+ extra.put("responseData", respData.length() > 2048 ? respData.substring(0, 2048) : respData);
366
+ }
367
+ if (!TextUtils.isEmpty(error)) {
368
+ extra.put("error", error.length() > 512 ? error.substring(0, 512) : error);
369
+ }
370
+ if (!TextUtils.isEmpty(phone)) extra.put("phone", phone);
371
+ if (!TextUtils.isEmpty(storeName)) extra.put("storeName", storeName);
372
+ if (!TextUtils.isEmpty(userId)) extra.put("userId", userId);
373
+ extra.put("platform", "android");
374
+
375
+ event.put("extraData", extra.toString());
376
+ enqueueEvent(event);
377
+
378
+ // 接口异常立即上报排查;正常接口由 3s 定时器批量聚合上报
379
+ if ("api_error".equals(eventType)) {
380
+ flushAsync();
381
+ }
382
+ } catch (Exception e) {
383
+ Log.e(TAG, "===> [Native Tracker trackApi Failed]", e);
384
+ }
385
+ }
386
+ });
387
+ }
388
+
389
+ private static String extractApiPath(String url) {
390
+ if (TextUtils.isEmpty(url)) return "";
391
+ try {
392
+ int qIdx = url.indexOf('?');
393
+ String clean = qIdx >= 0 ? url.substring(0, qIdx) : url;
394
+ int schemeIdx = clean.indexOf("://");
395
+ if (schemeIdx >= 0) {
396
+ int pathIdx = clean.indexOf('/', schemeIdx + 3);
397
+ if (pathIdx >= 0) {
398
+ return clean.substring(pathIdx);
399
+ }
400
+ }
401
+ return clean;
402
+ } catch (Exception e) {
403
+ return url;
404
+ }
405
+ }
406
+
144
407
  private JSONObject createBaseEvent(String eventType, String eventName) throws Exception {
145
408
  sequence++;
146
409
  JSONObject event = new JSONObject();
147
- event.put("eventId", "e_" + System.currentTimeMillis() + "_" + (int)(Math.random() * 1000));
410
+ event.put("eventId", "e_" + System.currentTimeMillis() + "_" + (int) (Math.random() * 1000));
148
411
  event.put("eventType", eventType);
149
412
  event.put("eventName", eventName);
150
413
  event.put("timestamp", System.currentTimeMillis());
151
414
  event.put("sequence", sequence);
152
415
  event.put("pageRoute", currentPageRoute);
416
+ if (!TextUtils.isEmpty(referrerPageRoute)) {
417
+ event.put("referrer", referrerPageRoute);
418
+ }
153
419
  event.put("os", "Android");
420
+ event.put("deviceBrand", android.os.Build.BRAND != null ? android.os.Build.BRAND : "");
421
+ event.put("deviceModel", android.os.Build.MODEL != null ? android.os.Build.MODEL : "");
422
+ event.put("osVersion", android.os.Build.VERSION.RELEASE != null ? android.os.Build.VERSION.RELEASE : "");
154
423
 
155
424
  JSONObject extra = new JSONObject();
156
425
  if (!TextUtils.isEmpty(phone)) extra.put("phone", phone);
@@ -161,11 +430,28 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
161
430
  return event;
162
431
  }
163
432
 
433
+ /**
434
+ * S1 本地持久化入库:崩溃/被杀 100% 防丢
435
+ */
164
436
  private void enqueueEvent(JSONObject event) {
165
- synchronized (eventQueue) {
166
- eventQueue.add(event);
167
- if (eventQueue.size() >= BATCH_SIZE) {
168
- flushAsync();
437
+ synchronized (dbLock) {
438
+ if (dbHelper != null) {
439
+ try {
440
+ SQLiteDatabase db = dbHelper.getWritableDatabase();
441
+ ContentValues cv = new ContentValues();
442
+ cv.put("data", event.toString());
443
+ cv.put("created_at", System.currentTimeMillis());
444
+ db.insert(TrackerDbHelper.TABLE_EVENTS, null, cv);
445
+
446
+ // 限制最大记录数防膨胀
447
+ db.execSQL("DELETE FROM " + TrackerDbHelper.TABLE_EVENTS + " WHERE id NOT IN ("
448
+ + "SELECT id FROM " + TrackerDbHelper.TABLE_EVENTS + " ORDER BY id DESC LIMIT " + MAX_DB_RECORDS + ")");
449
+
450
+ Log.i(TAG, "===> [Native Tracker Event Persisted to SQLite] Type: " + event.optString("eventType")
451
+ + ", Name: " + event.optString("eventName") + ", Route: " + event.optString("pageRoute"));
452
+ } catch (Exception e) {
453
+ Log.e(TAG, "===> [Native Tracker DB Insert Failed]", e);
454
+ }
169
455
  }
170
456
  }
171
457
  }
@@ -185,20 +471,73 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
185
471
  postBgTask(new Runnable() {
186
472
  @Override
187
473
  public void run() {
188
- List<JSONObject> toSend;
189
- synchronized (eventQueue) {
190
- if (eventQueue.isEmpty()) return;
191
- toSend = new ArrayList<>(eventQueue);
192
- eventQueue.clear();
474
+ flushInternal();
475
+ }
476
+ });
477
+ }
478
+
479
+ public void flushSync() {
480
+ if (bgThread != null && Thread.currentThread() == bgThread) {
481
+ flushInternal();
482
+ return;
483
+ }
484
+ final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
485
+ postBgTask(new Runnable() {
486
+ @Override
487
+ public void run() {
488
+ try {
489
+ flushInternal();
490
+ } finally {
491
+ latch.countDown();
193
492
  }
194
- sendBatch(toSend);
195
493
  }
196
494
  });
495
+ try {
496
+ latch.await(1500, java.util.concurrent.TimeUnit.MILLISECONDS);
497
+ } catch (InterruptedException ignored) {}
498
+ }
499
+
500
+ private void flushInternal() {
501
+ int maxBatches = 5; // 单次最多连续发送 5 批(共 100 条),防止网络恢复时有大量积压滞留
502
+ while (maxBatches-- > 0) {
503
+ List<EventRecord> records = new ArrayList<>();
504
+ synchronized (dbLock) {
505
+ if (dbHelper != null) {
506
+ Cursor cursor = null;
507
+ try {
508
+ SQLiteDatabase db = dbHelper.getReadableDatabase();
509
+ cursor = db.rawQuery("SELECT id, data FROM " + TrackerDbHelper.TABLE_EVENTS + " ORDER BY id ASC LIMIT " + BATCH_SIZE, null);
510
+ while (cursor != null && cursor.moveToNext()) {
511
+ long id = cursor.getLong(0);
512
+ String dataStr = cursor.getString(1);
513
+ records.add(new EventRecord(id, new JSONObject(dataStr)));
514
+ }
515
+ } catch (Exception e) {
516
+ Log.e(TAG, "===> [Native Tracker DB Query Failed]", e);
517
+ } finally {
518
+ if (cursor != null) cursor.close();
519
+ }
520
+ }
521
+ }
522
+
523
+ if (records.isEmpty()) {
524
+ break;
525
+ }
526
+
527
+ boolean sent = sendBatch(records);
528
+ if (!sent || records.size() < BATCH_SIZE) {
529
+ break;
530
+ }
531
+ }
197
532
  }
198
533
 
199
- private void sendBatch(List<JSONObject> events) {
200
- if (TextUtils.isEmpty(serverUrl) || events.isEmpty()) return;
534
+ /**
535
+ * S2 网络发送与失败重试补偿机制
536
+ */
537
+ private boolean sendBatch(List<EventRecord> records) {
538
+ if (TextUtils.isEmpty(serverUrl) || records.isEmpty()) return false;
201
539
  HttpURLConnection conn = null;
540
+ boolean success = false;
202
541
  try {
203
542
  JSONObject body = new JSONObject();
204
543
  body.put("appId", Integer.parseInt(appId));
@@ -210,11 +549,13 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
210
549
  body.put("platform", "android");
211
550
 
212
551
  JSONArray eventsArr = new JSONArray();
213
- for (JSONObject e : events) {
214
- eventsArr.put(e);
552
+ for (EventRecord record : records) {
553
+ eventsArr.put(record.json);
215
554
  }
216
555
  body.put("events", eventsArr);
217
556
 
557
+ Log.i(TAG, "===> [Native Tracker Flushing] Sending " + records.size() + " native events to: " + serverUrl);
558
+
218
559
  URL url = new URL(serverUrl);
219
560
  conn = (HttpURLConnection) url.openConnection();
220
561
  conn.setRequestMethod("POST");
@@ -232,12 +573,51 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
232
573
  os.close();
233
574
 
234
575
  int code = conn.getResponseCode();
235
- Log.d(TAG, "sendBatch response code: " + code + ", count: " + events.size());
576
+ InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
577
+ String responseStr = "";
578
+ if (is != null) {
579
+ BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8"));
580
+ StringBuilder sb = new StringBuilder();
581
+ String line;
582
+ while ((line = reader.readLine()) != null) {
583
+ sb.append(line);
584
+ }
585
+ reader.close();
586
+ responseStr = sb.toString();
587
+ }
588
+
589
+ if (code >= 200 && code < 300) {
590
+ success = true;
591
+ Log.i(TAG, "===> [Native Tracker Send SUCCESS] Code: " + code + ", Events: " + records.size() + ", Response: " + responseStr);
592
+ } else {
593
+ Log.e(TAG, "===> [Native Tracker Send FAILED] HTTP Code: " + code + ", Response: " + responseStr);
594
+ }
236
595
  } catch (Exception e) {
237
- Log.e(TAG, "sendBatch failed", e);
596
+ Log.e(TAG, "===> [Native Tracker Network Error] Send batch failed (will retry in next flush)", e);
238
597
  } finally {
239
598
  if (conn != null) conn.disconnect();
240
599
  }
600
+
601
+ // 成功上报才从本地 SQLite 清理已上报事件;失败则保留在本地库中,等待下次补偿重试 (S2)
602
+ if (success) {
603
+ synchronized (dbLock) {
604
+ if (dbHelper != null) {
605
+ try {
606
+ SQLiteDatabase db = dbHelper.getWritableDatabase();
607
+ StringBuilder sb = new StringBuilder("id IN (");
608
+ for (int i = 0; i < records.size(); i++) {
609
+ sb.append(records.get(i).id);
610
+ if (i < records.size() - 1) sb.append(",");
611
+ }
612
+ sb.append(")");
613
+ db.delete(TrackerDbHelper.TABLE_EVENTS, sb.toString(), null);
614
+ } catch (Exception e) {
615
+ Log.e(TAG, "===> [Native Tracker DB Delete Clean Failed]", e);
616
+ }
617
+ }
618
+ }
619
+ }
620
+ return success;
241
621
  }
242
622
 
243
623
  private void postBgTask(Runnable runnable) {
@@ -246,7 +626,176 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
246
626
  }
247
627
  }
248
628
 
249
- // --- Activity Lifecycle Auto-Tracking ---
629
+ // --- Activity & Fragment Lifecycle & Auto Native Click Tracking ---
630
+
631
+ @Override
632
+ public void onActivityCreated(final Activity activity, Bundle savedInstanceState) {
633
+ // 1. 自动注册 Support Fragment 生命周期回调
634
+ if (activity instanceof FragmentActivity) {
635
+ ((FragmentActivity) activity).getSupportFragmentManager()
636
+ .registerFragmentLifecycleCallbacks(new FragmentManager.FragmentLifecycleCallbacks() {
637
+ @Override
638
+ public void onFragmentResumed(FragmentManager fm, Fragment f) {
639
+ super.onFragmentResumed(fm, f);
640
+ final String fragmentRoute = f.getClass().getName();
641
+ postBgTask(new Runnable() {
642
+ @Override
643
+ public void run() {
644
+ try {
645
+ if (fragmentRoute.equals(currentPageRoute) && (System.currentTimeMillis() - lastPageStartTime < 500)) {
646
+ return;
647
+ }
648
+ String referrer = currentPageRoute;
649
+ currentPageRoute = fragmentRoute;
650
+ referrerPageRoute = referrer;
651
+ lastPageStartTime = System.currentTimeMillis();
652
+ Log.i(TAG, "===> [Native Fragment Resumed] Page: " + currentPageRoute + ", Referrer: " + referrerPageRoute);
653
+ JSONObject event = createBaseEvent("page_view", "page_view");
654
+ event.put("pageTitle", getFriendlyPageTitle(fragmentRoute));
655
+ enqueueEvent(event);
656
+ flushAsync();
657
+ } catch (Exception e) {
658
+ Log.e(TAG, "===> [Native Fragment Resumed Error]", e);
659
+ }
660
+ }
661
+ });
662
+ }
663
+ }, true);
664
+ }
665
+
666
+ // 2. 自动拦截 Window.Callback,捕获 Native 控件点击 (带 S3 滑动防误触过滤)
667
+ try {
668
+ Window window = activity.getWindow();
669
+ if (window != null) {
670
+ final Window.Callback origCb = window.getCallback();
671
+ if (origCb != null && !(origCb instanceof WindowCallbackWrapper)) {
672
+ window.setCallback(new WindowCallbackWrapper(origCb, activity));
673
+ }
674
+ }
675
+ } catch (Exception e) {
676
+ Log.w(TAG, "===> [Native Window Callback Install Failed]", e);
677
+ }
678
+ }
679
+
680
+ private boolean isClickableTarget(View view) {
681
+ if (view == null) return false;
682
+ if (view.isClickable() || view.isLongClickable()) return true;
683
+ if (view instanceof Button || view instanceof EditText) return true;
684
+ return (view instanceof TextView && !TextUtils.isEmpty(((TextView) view).getText()));
685
+ }
686
+
687
+ private View findTouchedLeafView(View root, int rawX, int rawY) {
688
+ if (root == null || root.getVisibility() != View.VISIBLE) return null;
689
+
690
+ int[] location = new int[2];
691
+ root.getLocationOnScreen(location);
692
+ int x = location[0];
693
+ int y = location[1];
694
+ int w = root.getWidth();
695
+ int h = root.getHeight();
696
+
697
+ if (rawX < x || rawX > x + w || rawY < y || rawY > y + h) {
698
+ return null;
699
+ }
700
+
701
+ if (root instanceof ViewGroup) {
702
+ ViewGroup group = (ViewGroup) root;
703
+ for (int i = group.getChildCount() - 1; i >= 0; i--) {
704
+ View child = group.getChildAt(i);
705
+ View target = findTouchedLeafView(child, rawX, rawY);
706
+ if (target != null) {
707
+ return target;
708
+ }
709
+ }
710
+ }
711
+
712
+ return root;
713
+ }
714
+
715
+ private String extractViewText(View view) {
716
+ if (view == null) return "";
717
+ try {
718
+ // 1. 优先读取 contentDescription
719
+ CharSequence desc = view.getContentDescription();
720
+ if (!TextUtils.isEmpty(desc)) {
721
+ return desc.toString().trim();
722
+ }
723
+ // 2. 本身是 TextView / Button
724
+ if (view instanceof TextView) {
725
+ CharSequence text = ((TextView) view).getText();
726
+ if (!TextUtils.isEmpty(text)) {
727
+ return text.toString().trim();
728
+ }
729
+ }
730
+ // 3. ViewGroup 穿透提取子 View 文本
731
+ if (view instanceof ViewGroup) {
732
+ ViewGroup group = (ViewGroup) view;
733
+ int count = group.getChildCount();
734
+ for (int i = 0; i < count; i++) {
735
+ View child = group.getChildAt(i);
736
+ if (child != null && child.getVisibility() == View.VISIBLE) {
737
+ String childText = extractViewText(child);
738
+ if (!TextUtils.isEmpty(childText)) {
739
+ return childText;
740
+ }
741
+ }
742
+ }
743
+ }
744
+ } catch (Exception e) {}
745
+ return "";
746
+ }
747
+
748
+ private void handleNativeViewClick(final Activity activity, final View target, final float rawX, final float rawY) {
749
+ String extracted = extractViewText(target);
750
+ if (TextUtils.isEmpty(extracted) && target.getParent() instanceof View) {
751
+ extracted = extractViewText((View) target.getParent());
752
+ }
753
+ final String elementText = extracted;
754
+
755
+ final String elementId;
756
+ if (target.getId() != View.NO_ID) {
757
+ String resName = "";
758
+ try {
759
+ resName = target.getResources().getResourceEntryName(target.getId());
760
+ } catch (Exception e) {}
761
+ elementId = !TextUtils.isEmpty(resName) ? resName : "id_" + target.getId();
762
+ } else {
763
+ elementId = target.getClass().getSimpleName();
764
+ }
765
+
766
+ final String key = elementId + "|" + elementText;
767
+ final long now = System.currentTimeMillis();
768
+ if (key.equals(lastClickKey) && (now - lastClickTime < 800)) {
769
+ return;
770
+ }
771
+ lastClickKey = key;
772
+ lastClickTime = now;
773
+
774
+ postBgTask(new Runnable() {
775
+ @Override
776
+ public void run() {
777
+ try {
778
+ JSONObject event = createBaseEvent("click", "native_click");
779
+ event.put("elementId", elementId);
780
+ if (!TextUtils.isEmpty(elementText)) {
781
+ event.put("elementText", elementText.length() > 30 ? elementText.substring(0, 30) : elementText);
782
+ }
783
+ JSONObject extra = new JSONObject();
784
+ extra.put("viewType", target.getClass().getName());
785
+ extra.put("pageX", rawX);
786
+ extra.put("pageY", rawY);
787
+ extra.put("isNative", true);
788
+ event.put("extraData", extra.toString());
789
+
790
+ Log.i(TAG, "===> [Native Auto Click Captured] Id: " + elementId + ", Text: " + elementText);
791
+ enqueueEvent(event);
792
+ flushAsync();
793
+ } catch (Exception e) {
794
+ Log.e(TAG, "===> [Native Click Handle Error]", e);
795
+ }
796
+ }
797
+ });
798
+ }
250
799
 
251
800
  @Override
252
801
  public void onActivityResumed(final Activity activity) {
@@ -254,13 +803,22 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
254
803
  @Override
255
804
  public void run() {
256
805
  try {
257
- currentPageRoute = activity.getClass().getName();
806
+ final String route = activity.getClass().getName();
807
+ if (route.equals(currentPageRoute) && (System.currentTimeMillis() - lastPageStartTime < 500)) {
808
+ return;
809
+ }
810
+ String referrer = currentPageRoute;
811
+ currentPageRoute = route;
812
+ referrerPageRoute = referrer;
258
813
  lastPageStartTime = System.currentTimeMillis();
814
+ Log.i(TAG, "===> [Native Activity Resumed] Page: " + currentPageRoute + ", Referrer: " + referrerPageRoute);
259
815
  JSONObject event = createBaseEvent("page_view", "page_view");
260
- event.put("pageTitle", activity.getTitle() != null ? activity.getTitle().toString() : currentPageRoute);
816
+ String activityTitle = activity.getTitle() != null ? activity.getTitle().toString() : "";
817
+ event.put("pageTitle", !TextUtils.isEmpty(activityTitle) ? activityTitle : getFriendlyPageTitle(route));
261
818
  enqueueEvent(event);
819
+ flushAsync();
262
820
  } catch (Exception e) {
263
- Log.e(TAG, "onActivityResumed failed", e);
821
+ Log.e(TAG, "===> [Native Tracker onActivityResumed Error]", e);
264
822
  }
265
823
  }
266
824
  });
@@ -274,21 +832,107 @@ public class AndroidTracker implements Application.ActivityLifecycleCallbacks {
274
832
  try {
275
833
  if (lastPageStartTime > 0) {
276
834
  long duration = System.currentTimeMillis() - lastPageStartTime;
277
- JSONObject event = createBaseEvent("page_stay", "page_stay");
278
- event.put("duration", duration);
279
- enqueueEvent(event);
835
+ if (duration >= 500) {
836
+ JSONObject event = createBaseEvent("page_stay", "page_stay");
837
+ event.put("duration", duration);
838
+ enqueueEvent(event);
839
+ flushAsync();
840
+ }
280
841
  lastPageStartTime = 0;
281
842
  }
282
843
  } catch (Exception e) {
283
- Log.e(TAG, "onActivityPaused failed", e);
844
+ Log.e(TAG, "===> [Native Tracker onActivityPaused Error]", e);
284
845
  }
285
846
  }
286
847
  });
287
848
  }
288
849
 
289
- @Override public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
290
850
  @Override public void onActivityStarted(Activity activity) {}
291
851
  @Override public void onActivityStopped(Activity activity) { flushAsync(); }
292
852
  @Override public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
293
853
  @Override public void onActivityDestroyed(Activity activity) {}
854
+
855
+ // --- Window.Callback 代理包装器 (带 S3 滑动防误触过滤) ---
856
+ private class WindowCallbackWrapper implements Window.Callback {
857
+ private final Window.Callback mWrapped;
858
+ private final java.lang.ref.WeakReference<Activity> mActivityRef;
859
+ private float downX, downY;
860
+ private long downTime;
861
+ private int touchSlop = -1;
862
+
863
+ public WindowCallbackWrapper(Window.Callback wrapped, Activity activity) {
864
+ this.mWrapped = wrapped;
865
+ this.mActivityRef = new java.lang.ref.WeakReference<>(activity);
866
+ if (activity != null) {
867
+ try {
868
+ this.touchSlop = ViewConfiguration.get(activity).getScaledTouchSlop();
869
+ } catch (Exception ignored) {}
870
+ }
871
+ }
872
+
873
+ @Override
874
+ public boolean dispatchTouchEvent(MotionEvent event) {
875
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
876
+ downX = event.getRawX();
877
+ downY = event.getRawY();
878
+ downTime = System.currentTimeMillis();
879
+ } else if (event.getAction() == MotionEvent.ACTION_UP) {
880
+ float dx = Math.abs(event.getRawX() - downX);
881
+ float dy = Math.abs(event.getRawY() - downY);
882
+ double distance = Math.hypot(dx, dy);
883
+ long duration = System.currentTimeMillis() - downTime;
884
+
885
+ // S3 过滤:位移小于 touchSlop 且按下时长正常时才判定为点击
886
+ int slopThreshold = touchSlop > 0 ? touchSlop : 24;
887
+ if (distance <= slopThreshold && duration < 1500) {
888
+ try {
889
+ Activity act = mActivityRef != null ? mActivityRef.get() : null;
890
+ if (act != null && act.getWindow() != null) {
891
+ View decorView = act.getWindow().getDecorView();
892
+ View leaf = findTouchedLeafView(decorView, (int) event.getRawX(), (int) event.getRawY());
893
+ View clickableTarget = null;
894
+ View curr = leaf;
895
+ while (curr != null) {
896
+ if (isClickableTarget(curr)) {
897
+ clickableTarget = curr;
898
+ break;
899
+ }
900
+ if (curr == decorView || !(curr.getParent() instanceof View)) break;
901
+ curr = (View) curr.getParent();
902
+ }
903
+ if (clickableTarget != null) {
904
+ handleNativeViewClick(act, clickableTarget, event.getRawX(), event.getRawY());
905
+ }
906
+ }
907
+ } catch (Exception e) {
908
+ Log.w(TAG, "===> [Native Click Watcher Error]", e);
909
+ }
910
+ }
911
+ }
912
+ return mWrapped != null ? mWrapped.dispatchTouchEvent(event) : false;
913
+ }
914
+
915
+ @Override public boolean dispatchKeyEvent(KeyEvent event) { return mWrapped != null && mWrapped.dispatchKeyEvent(event); }
916
+ @Override public boolean dispatchKeyShortcutEvent(KeyEvent event) { return mWrapped != null && mWrapped.dispatchKeyShortcutEvent(event); }
917
+ @Override public boolean dispatchTrackballEvent(MotionEvent event) { return mWrapped != null && mWrapped.dispatchTrackballEvent(event); }
918
+ @Override public boolean dispatchGenericMotionEvent(MotionEvent event) { return mWrapped != null && mWrapped.dispatchGenericMotionEvent(event); }
919
+ @Override public boolean dispatchPopulateAccessibilityEvent(android.view.accessibility.AccessibilityEvent event) { return mWrapped != null && mWrapped.dispatchPopulateAccessibilityEvent(event); }
920
+ @Nullable @Override public View onCreatePanelView(int featureId) { return mWrapped != null ? mWrapped.onCreatePanelView(featureId) : null; }
921
+ @Override public boolean onCreatePanelMenu(int featureId, @NonNull Menu menu) { return mWrapped != null && mWrapped.onCreatePanelMenu(featureId, menu); }
922
+ @Override public boolean onPreparePanel(int featureId, @Nullable View view, @NonNull Menu menu) { return mWrapped != null && mWrapped.onPreparePanel(featureId, view, menu); }
923
+ @Override public boolean onMenuOpened(int featureId, @NonNull Menu menu) { return mWrapped != null && mWrapped.onMenuOpened(featureId, menu); }
924
+ @Override public boolean onMenuItemSelected(int featureId, @NonNull MenuItem item) { return mWrapped != null && mWrapped.onMenuItemSelected(featureId, item); }
925
+ @Override public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) { if (mWrapped != null) mWrapped.onWindowAttributesChanged(attrs); }
926
+ @Override public void onContentChanged() { if (mWrapped != null) mWrapped.onContentChanged(); }
927
+ @Override public void onWindowFocusChanged(boolean hasFocus) { if (mWrapped != null) mWrapped.onWindowFocusChanged(hasFocus); }
928
+ @Override public void onAttachedToWindow() { if (mWrapped != null) mWrapped.onAttachedToWindow(); }
929
+ @Override public void onDetachedFromWindow() { if (mWrapped != null) mWrapped.onDetachedFromWindow(); }
930
+ @Override public void onPanelClosed(int featureId, @NonNull Menu menu) { if (mWrapped != null) mWrapped.onPanelClosed(featureId, menu); }
931
+ @Override public boolean onSearchRequested() { return mWrapped != null && mWrapped.onSearchRequested(); }
932
+ @Override public boolean onSearchRequested(SearchEvent searchEvent) { return mWrapped != null && mWrapped.onSearchRequested(searchEvent); }
933
+ @Nullable @Override public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { return mWrapped != null ? mWrapped.onWindowStartingActionMode(callback) : null; }
934
+ @Nullable @Override public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, int type) { return mWrapped != null ? mWrapped.onWindowStartingActionMode(callback, type) : null; }
935
+ @Override public void onActionModeStarted(ActionMode mode) { if (mWrapped != null) mWrapped.onActionModeStarted(mode); }
936
+ @Override public void onActionModeFinished(ActionMode mode) { if (mWrapped != null) mWrapped.onActionModeFinished(mode); }
937
+ }
294
938
  }
@@ -0,0 +1,134 @@
1
+ package com.renrui.tracker;
2
+
3
+ import android.text.TextUtils;
4
+ import android.util.Log;
5
+
6
+ import java.io.IOException;
7
+ import java.nio.charset.Charset;
8
+ import java.nio.charset.StandardCharsets;
9
+
10
+ import okhttp3.Interceptor;
11
+ import okhttp3.MediaType;
12
+ import okhttp3.Request;
13
+ import okhttp3.RequestBody;
14
+ import okhttp3.Response;
15
+ import okhttp3.ResponseBody;
16
+ import okio.Buffer;
17
+
18
+ /**
19
+ * 行业级 Native OkHttp 埋点无侵入网络拦截器
20
+ * 1. 0 侵入:仅安全只读提取(peekBody),绝对不消耗或关闭原始 Stream
21
+ * 2. 0 阻塞:主线程 0ms 开销,数据采集后直接抛给 HandlerThread 异步队列
22
+ * 3. 严格防递归:过滤 SDK 自身上报 URL,杜绝死循环
23
+ * 4. 异常原样抛出:记录超时/断网 api_error 后原样 throw e,不破坏原有业务逻辑
24
+ */
25
+ public class TrackerInterceptor implements Interceptor {
26
+
27
+ private static final String TAG = "TrackerInterceptor";
28
+ private static final int MAX_PARAM_LENGTH = 4096;
29
+ private static final int MAX_RESP_PREVIEW_LENGTH = 2048;
30
+
31
+ @Override
32
+ public Response intercept(Chain chain) throws IOException {
33
+ Request request = chain.request();
34
+ String url = request.url() != null ? request.url().toString() : "";
35
+
36
+ // 1. 过滤自身打点接口,防止递归死循环
37
+ if (isTrackerInternalUrl(url)) {
38
+ return chain.proceed(request);
39
+ }
40
+
41
+ String reqParam = extractRequestParams(request);
42
+ long startTime = System.currentTimeMillis();
43
+ Response response;
44
+
45
+ try {
46
+ response = chain.proceed(request);
47
+ } catch (IOException e) {
48
+ // 网络异常(如超时、无法连接),采集并原样抛出;过滤客户端主动 cancel 引起的异常
49
+ long duration = System.currentTimeMillis() - startTime;
50
+ boolean isCanceled = "Canceled".equalsIgnoreCase(e.getMessage());
51
+ if (!isCanceled) {
52
+ try {
53
+ AndroidTracker.getInstance().trackApi(url, request.method(), 0, duration, reqParam, null, e.getMessage());
54
+ } catch (Exception ex) {
55
+ Log.e(TAG, "trackApi error", ex);
56
+ }
57
+ }
58
+ throw e;
59
+ }
60
+
61
+ long duration = System.currentTimeMillis() - startTime;
62
+ String respData = null;
63
+
64
+ // 2. 使用 OkHttp 官方推荐的 peekBody 做安全无损预览(不破坏业务层 response.body() 流)
65
+ try {
66
+ if (response.body() != null) {
67
+ MediaType mediaType = response.body().contentType();
68
+ if (mediaType != null) {
69
+ String sub = mediaType.subtype();
70
+ if (sub != null && (sub.contains("json") || sub.contains("text") || sub.contains("xml") || sub.contains("html"))) {
71
+ ResponseBody peek = response.peekBody(MAX_RESP_PREVIEW_LENGTH);
72
+ respData = peek.string();
73
+ }
74
+ }
75
+ }
76
+ } catch (Throwable ignored) {}
77
+
78
+ // 3. 异步送入 AndroidTracker,后台 HandlerThread 处理,0 阻塞当前网络线程
79
+ try {
80
+ AndroidTracker.getInstance().trackApi(url, request.method(), response.code(), duration, reqParam, respData, null);
81
+ } catch (Exception e) {
82
+ Log.e(TAG, "trackApi error", e);
83
+ }
84
+
85
+ return response;
86
+ }
87
+
88
+ private boolean isTrackerInternalUrl(String url) {
89
+ if (TextUtils.isEmpty(url)) return true;
90
+ String lower = url.toLowerCase();
91
+ return lower.contains("/api/point/task/trackBatch")
92
+ || lower.contains("/point/task/trackBatch")
93
+ || lower.contains("/point/task/activeReport")
94
+ || lower.contains("/api/point/task/activeReport")
95
+ || lower.contains("/api/track/collect")
96
+ || lower.contains(".png")
97
+ || lower.contains(".jpg")
98
+ || lower.contains(".jpeg")
99
+ || lower.contains(".webp")
100
+ || lower.contains(".gif")
101
+ || lower.contains(".mp4")
102
+ || lower.contains(".svg");
103
+ }
104
+
105
+ private String extractRequestParams(Request request) {
106
+ try {
107
+ if ("GET".equalsIgnoreCase(request.method())) {
108
+ String query = request.url().query();
109
+ return !TextUtils.isEmpty(query) ? query : "";
110
+ }
111
+ RequestBody body = request.body();
112
+ if (body != null && body.contentLength() > 0 && body.contentLength() <= MAX_PARAM_LENGTH) {
113
+ MediaType contentType = body.contentType();
114
+ if (contentType != null) {
115
+ String type = contentType.type();
116
+ String sub = contentType.subtype();
117
+ if ("multipart".equalsIgnoreCase(type) || "image".equalsIgnoreCase(type)
118
+ || "video".equalsIgnoreCase(type) || "audio".equalsIgnoreCase(type)
119
+ || (sub != null && sub.contains("octet-stream"))) {
120
+ return "[binary/multipart: " + body.contentLength() + " bytes]";
121
+ }
122
+ }
123
+ Buffer buffer = new Buffer();
124
+ body.writeTo(buffer);
125
+ Charset charset = StandardCharsets.UTF_8;
126
+ if (contentType != null) {
127
+ charset = contentType.charset(StandardCharsets.UTF_8);
128
+ }
129
+ return buffer.readString(charset != null ? charset : StandardCharsets.UTF_8);
130
+ }
131
+ } catch (Throwable ignored) {}
132
+ return "";
133
+ }
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rk911/tracker-sdk",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "人康多端埋点 SDK — 跨端自动化采集 + 手动埋点双写兼容",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
@@ -50,44 +50,149 @@ export class ApiWatchPlugin {
50
50
  var self = this
51
51
  var config = this.tracker.getConfig()
52
52
 
53
- try {
54
- uni.addInterceptor('request', {
55
- invoke: function (args) {
56
- // 给请求打上开始时间标记
57
- args._trackStartTime = Date.now()
58
- args._trackBgCount = self._bgCount
59
- args._trackHiddenTimeStart = self._totalHiddenTime + (self._lastHideTime ? Date.now() - self._lastHideTime : 0)
60
- return args
61
- },
62
- success: function (res, args) {
63
- self._reportApi(args, res, null)
64
- },
65
- fail: function (err, args) {
66
- self._reportApi(args, null, err)
67
- },
68
- })
69
- this._interceptorAdded = true
70
- } catch (e) {
71
- if (config.debug) {
72
- console.warn('[TrackerSDK] api-watch: failed to add interceptor', e)
53
+ // 1. uni-app 运行时环境:优先使用官方 uni.addInterceptor
54
+ if (typeof uni !== 'undefined' && typeof uni.addInterceptor === 'function') {
55
+ try {
56
+ uni.addInterceptor('request', {
57
+ invoke: function (args) {
58
+ args._trackStartTime = Date.now()
59
+ args._trackBgCount = self._bgCount
60
+ args._trackHiddenTimeStart = self._totalHiddenTime + (self._lastHideTime ? Date.now() - self._lastHideTime : 0)
61
+ return args
62
+ },
63
+ success: function (res, args) {
64
+ self._reportApi(args, res, null)
65
+ },
66
+ fail: function (err, args) {
67
+ self._reportApi(args, null, err)
68
+ },
69
+ })
70
+ this._interceptorAdded = true
71
+ return
72
+ } catch (e) {
73
+ if (config.debug) {
74
+ console.warn('[TrackerSDK] api-watch: failed to add uni interceptor', e)
75
+ }
73
76
  }
74
77
  }
78
+
79
+ // 2. 标准浏览器/WebView 环境:拦截 window.fetch 与 XMLHttpRequest
80
+ if (typeof window !== 'undefined') {
81
+ this._setupWebInterceptors()
82
+ }
75
83
  }
76
84
 
77
- _reportApi(args, res, err) {
78
- if (!args || !args.url) return
85
+ _isInternalUrl(url) {
86
+ if (!url || typeof url !== 'string') return true
87
+ var config = this.tracker ? this.tracker.getConfig() : {}
88
+ return (
89
+ url.indexOf('trackBatch') >= 0 ||
90
+ url.indexOf('activeReport') >= 0 ||
91
+ url.indexOf('/api/track/collect') >= 0 ||
92
+ url.indexOf('/api/point/device/add') >= 0 ||
93
+ (config && config.batchReportUrl && url.indexOf(config.batchReportUrl) >= 0)
94
+ )
95
+ }
79
96
 
80
- var config = this.tracker.getConfig()
97
+ _setupWebInterceptors() {
98
+ var self = this
99
+
100
+ // 2.1 拦截 window.fetch
101
+ if (typeof window.fetch === 'function') {
102
+ var origFetch = window.fetch
103
+ this._origFetch = origFetch
104
+ window.fetch = function (input, init) {
105
+ var url = typeof input === 'string' ? input : (input && input.url ? input.url : '')
106
+ if (self._isInternalUrl(url)) {
107
+ return origFetch.apply(this, arguments)
108
+ }
109
+
110
+ var startTime = Date.now()
111
+ var method = (init && init.method) || (input && input.method) || 'GET'
112
+ var requestParams = (init && init.body) || null
81
113
 
82
- // [PROJECT-ADAPT] 自身请求过滤列表
83
- if (
84
- args.url.indexOf('/api/point/task/activeReport') >= 0 ||
85
- args.url.indexOf(config.batchReportUrl) >= 0 ||
86
- args.url.indexOf('/api/track/collect') >= 0
87
- ) {
88
- return
114
+ var args = {
115
+ url: url,
116
+ method: method,
117
+ data: requestParams,
118
+ _trackStartTime: startTime
119
+ }
120
+
121
+ return origFetch.apply(this, arguments).then(function (res) {
122
+ try {
123
+ var clone = res.clone()
124
+ clone.text().then(function (text) {
125
+ self._reportApi(args, {
126
+ statusCode: res.status,
127
+ data: text,
128
+ header: {}
129
+ }, null)
130
+ }).catch(function () {
131
+ self._reportApi(args, {
132
+ statusCode: res.status,
133
+ data: null,
134
+ header: {}
135
+ }, null)
136
+ })
137
+ } catch (e) {
138
+ self._reportApi(args, { statusCode: res.status }, null)
139
+ }
140
+ return res
141
+ }, function (err) {
142
+ self._reportApi(args, null, err)
143
+ throw err
144
+ })
145
+ }
89
146
  }
90
147
 
148
+ // 2.2 拦截 XMLHttpRequest
149
+ if (typeof window.XMLHttpRequest === 'function') {
150
+ var origOpen = window.XMLHttpRequest.prototype.open
151
+ var origSend = window.XMLHttpRequest.prototype.send
152
+ this._origXhrOpen = origOpen
153
+ this._origXhrSend = origSend
154
+
155
+ window.XMLHttpRequest.prototype.open = function (method, url) {
156
+ this._trackMethod = method
157
+ this._trackUrl = url
158
+ return origOpen.apply(this, arguments)
159
+ }
160
+
161
+ window.XMLHttpRequest.prototype.send = function (data) {
162
+ var xhr = this
163
+ var url = xhr._trackUrl || ''
164
+ if (self._isInternalUrl(url)) {
165
+ return origSend.apply(this, arguments)
166
+ }
167
+
168
+ var startTime = Date.now()
169
+ var method = xhr._trackMethod || 'GET'
170
+
171
+ var args = {
172
+ url: url,
173
+ method: method,
174
+ data: data,
175
+ _trackStartTime: startTime
176
+ }
177
+
178
+ xhr.addEventListener('loadend', function () {
179
+ try {
180
+ self._reportApi(args, {
181
+ statusCode: xhr.status,
182
+ data: xhr.responseType === '' || xhr.responseType === 'text' ? xhr.responseText : null
183
+ }, xhr.status === 0 ? 'Network Error' : null)
184
+ } catch (e) {}
185
+ })
186
+
187
+ return origSend.apply(this, arguments)
188
+ }
189
+ }
190
+ this._interceptorAdded = true
191
+ }
192
+
193
+ _reportApi(args, res, err) {
194
+ if (!args || !args.url || this._isInternalUrl(args.url)) return
195
+
91
196
  var duration = args._trackStartTime
92
197
  ? Date.now() - args._trackStartTime
93
198
  : 0
@@ -210,8 +315,17 @@ export class ApiWatchPlugin {
210
315
  }
211
316
  if (this._interceptorAdded) {
212
317
  try {
213
- uni.removeInterceptor('request')
318
+ if (typeof uni !== 'undefined' && uni.removeInterceptor) {
319
+ uni.removeInterceptor('request')
320
+ }
214
321
  } catch (e) {}
322
+ if (this._origFetch && typeof window !== 'undefined') {
323
+ window.fetch = this._origFetch
324
+ }
325
+ if (this._origXhrOpen && typeof window !== 'undefined') {
326
+ window.XMLHttpRequest.prototype.open = this._origXhrOpen
327
+ window.XMLHttpRequest.prototype.send = this._origXhrSend
328
+ }
215
329
  }
216
330
  if (this._visibilityHandler && typeof document !== 'undefined') {
217
331
  document.removeEventListener('visibilitychange', this._visibilityHandler)