@pickleball/expo-sdk 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +163 -40
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +47 -0
  6. package/dist/advanced.d.cts +3 -0
  7. package/dist/advanced.d.ts +3 -0
  8. package/dist/advanced.js +47 -0
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-2JHULJZ7.js +265 -0
  12. package/dist/chunk-62YV3PCD.cjs +139 -0
  13. package/dist/chunk-6F3HN6US.js +50 -0
  14. package/dist/chunk-6YE5BL3A.cjs +50 -0
  15. package/dist/chunk-7OX2DSKI.cjs +33 -0
  16. package/dist/chunk-7Z7TN5N3.js +318 -0
  17. package/dist/chunk-DHDX3ZOO.js +800 -0
  18. package/dist/chunk-GXQYMPHE.cjs +9 -0
  19. package/dist/chunk-IUIRVKJI.cjs +2290 -0
  20. package/dist/chunk-KFQGP6VL.js +33 -0
  21. package/dist/chunk-LCWGNGEN.cjs +265 -0
  22. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  23. package/dist/chunk-XGP2MN5R.js +2290 -0
  24. package/dist/chunk-XL4CISVQ.js +9 -0
  25. package/dist/chunk-YGSQ25UA.cjs +800 -0
  26. package/dist/chunk-ZSKSXOCZ.js +139 -0
  27. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  28. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  29. package/dist/device-agent/index.cjs +1427 -0
  30. package/dist/device-agent/index.d.cts +493 -0
  31. package/dist/device-agent/index.d.ts +493 -0
  32. package/dist/device-agent/index.js +1427 -0
  33. package/dist/engine-BSvSdCSE.d.cts +741 -0
  34. package/dist/engine-BSvSdCSE.d.ts +741 -0
  35. package/dist/index.cjs +426 -6174
  36. package/dist/index.d.cts +299 -1400
  37. package/dist/index.d.ts +299 -1400
  38. package/dist/index.js +413 -6063
  39. package/dist/match/index.cjs +2456 -0
  40. package/dist/match/index.d.cts +253 -0
  41. package/dist/match/index.d.ts +253 -0
  42. package/dist/match/index.js +2456 -0
  43. package/dist/match/remote/index.cjs +21 -0
  44. package/dist/match/remote/index.d.cts +67 -0
  45. package/dist/match/remote/index.d.ts +67 -0
  46. package/dist/match/remote/index.js +21 -0
  47. package/dist/match/voice/index.cjs +23 -0
  48. package/dist/match/voice/index.d.cts +114 -0
  49. package/dist/match/voice/index.d.ts +114 -0
  50. package/dist/match/voice/index.js +23 -0
  51. package/dist/plugin.cjs +52 -71
  52. package/dist/plugin.d.cts +28 -1
  53. package/dist/plugin.d.ts +28 -1
  54. package/dist/plugin.js +26 -23
  55. package/dist/realtime/index.cjs +31 -0
  56. package/dist/realtime/index.d.cts +15 -0
  57. package/dist/realtime/index.d.ts +15 -0
  58. package/dist/realtime/index.js +31 -0
  59. package/dist/types-CrmcX408.d.cts +176 -0
  60. package/dist/types-CrmcX408.d.ts +176 -0
  61. package/dist/types-DiKWjwvN.d.cts +96 -0
  62. package/dist/types-DiKWjwvN.d.ts +96 -0
  63. package/dist/ui/index.cjs +1297 -0
  64. package/dist/ui/index.d.cts +493 -0
  65. package/dist/ui/index.d.ts +493 -0
  66. package/dist/ui/index.js +1297 -0
  67. package/dist/version.cjs +4 -32
  68. package/dist/version.js +2 -1
  69. package/package.json +95 -15
  70. package/src/advanced.ts +49 -0
  71. package/src/auto-resume.tsx +259 -0
  72. package/src/camera-controls.ts +99 -0
  73. package/src/camera-grant-provider.ts +392 -0
  74. package/src/camera-settings.ts +133 -0
  75. package/src/contracts.ts +73 -0
  76. package/src/device-agent/agent.ts +696 -0
  77. package/src/device-agent/beat-policy.ts +46 -0
  78. package/src/device-agent/component.tsx +60 -0
  79. package/src/device-agent/convex-beat-transport.ts +140 -0
  80. package/src/device-agent/convex-command-subscription.ts +101 -0
  81. package/src/device-agent/convex-control-channel.ts +88 -0
  82. package/src/device-agent/http-transport.ts +164 -0
  83. package/src/device-agent/index.ts +26 -0
  84. package/src/device-agent/mqtt-channel.ts +379 -0
  85. package/src/device-agent/mqtt-codec.ts +347 -0
  86. package/src/device-agent/tournament.ts +352 -0
  87. package/src/engine.ts +601 -20
  88. package/src/errors.ts +16 -0
  89. package/src/http-session-provider.ts +21 -183
  90. package/src/index.ts +117 -14
  91. package/src/local-video-quality.ts +43 -0
  92. package/src/match/index.ts +29 -0
  93. package/src/match/provider.tsx +4 -2
  94. package/src/match/remote/hid-gesture.ts +10 -2
  95. package/src/match/remote/types.ts +6 -4
  96. package/src/match/voice/scheduler.ts +6 -1
  97. package/src/match/voice/service.ts +16 -4
  98. package/src/native-runtime.ts +283 -77
  99. package/src/plugin.ts +54 -20
  100. package/src/provider.tsx +129 -22
  101. package/src/realtime/index.ts +56 -0
  102. package/src/resume-policy.ts +85 -0
  103. package/src/resume.ts +132 -0
  104. package/src/rtmp-room-adapter.ts +348 -0
  105. package/src/session-grant.ts +256 -0
  106. package/src/ui/camera-screen.tsx +484 -0
  107. package/src/ui/controls-sheet.tsx +445 -0
  108. package/src/ui/copy.ts +326 -0
  109. package/src/ui/helpers.ts +105 -0
  110. package/src/ui/hooks.ts +59 -0
  111. package/src/ui/index.ts +42 -0
  112. package/src/ui/preview-view.tsx +39 -0
  113. package/src/ui/primitives.tsx +304 -0
  114. package/src/uplink-check.ts +173 -0
  115. package/dist/chunk-VHLUJQG5.js +0 -17
  116. package/src/livekit-room-adapter.ts +0 -353
  117. package/src/livestream-screen.tsx +0 -980
@@ -1,99 +1,110 @@
1
- import type { EngineDependencies, SdkNetwork, SdkRuntime, SdkStorage, TelemetrySink } from "./engine";
2
- import { LiveKitRoomAdapter, type NativeLiveKitBindings } from "./livekit-room-adapter";
1
+ import type {
2
+ EngineDependencies,
3
+ SdkNetwork,
4
+ SdkRuntime,
5
+ SdkStorage,
6
+ SessionCommandChannel,
7
+ TelemetrySink,
8
+ } from "./engine";
9
+ import type {
10
+ SessionCommandPollResult,
11
+ SessionCommandRecord,
12
+ } from "./contracts";
13
+ import { RtmpRoomAdapter, type NativeRtmpBindings } from "./rtmp-room-adapter";
3
14
  import { UnsupportedRuntimeError } from "./errors";
4
15
 
5
16
  declare const require: (name: string) => any;
6
17
 
7
- let globalsRegistered = false;
8
-
9
- export function createNativeLiveKitBindings(): NativeLiveKitBindings {
10
- const reactNativeLiveKit = require("@livekit/react-native");
11
- const liveKit = require("livekit-client");
12
- return {
13
- registerGlobals() {
14
- if (!globalsRegistered) {
15
- reactNativeLiveKit.registerGlobals();
16
- globalsRegistered = true;
17
- }
18
- },
19
- createRoom: () => new liveKit.Room({ adaptiveStream: false, dynacast: false }),
20
- createLocalTracks: liveKit.createLocalTracks,
21
- startAudioSession: () => reactNativeLiveKit.AudioSession.startAudioSession(),
22
- stopAudioSession: () => reactNativeLiveKit.AudioSession.stopAudioSession(),
23
- isLandscape() {
24
- const { width, height } = require("react-native").Dimensions.get("window");
25
- return width > height;
26
- },
27
- async listCameras() {
28
- const { mediaDevices } = require("@livekit/react-native-webrtc");
29
- const devices: Array<{
30
- kind?: string;
31
- deviceId?: string;
32
- label?: string;
33
- facing?: string;
34
- }> = await mediaDevices.enumerateDevices();
35
- return devices
36
- .filter((device) => device.kind === "videoinput" && device.deviceId)
37
- .map((device) => ({
38
- deviceId: String(device.deviceId),
39
- label: String(device.label ?? ""),
40
- facing:
41
- device.facing === "front" || device.facing === "environment"
42
- ? device.facing
43
- : ("unknown" as const),
44
- }));
45
- },
46
- cameraSource: liveKit.Track.Source.Camera,
47
- events: {
48
- reconnecting: liveKit.RoomEvent.Reconnecting,
49
- reconnected: liveKit.RoomEvent.Reconnected,
50
- disconnected: liveKit.RoomEvent.Disconnected,
51
- mediaDevicesError: liveKit.RoomEvent.MediaDevicesError,
52
- dataReceived: liveKit.RoomEvent.DataReceived,
53
- },
54
- };
55
- }
56
-
57
- export function createDefaultRuntime(): SdkRuntime {
58
- const { NativeModules, Platform } = require("react-native");
18
+ /**
19
+ * Runtime cho app RTMP-only (KHÔNG có LiveKit/WebRTC): assert bằng module
20
+ * @pickleball/rtmp-native, xin quyền bằng API quyền thuần (Android) hoặc hàm
21
+ * native `requestPermissions` (iOS, rtmp-native ≥ 0.2; thiếu thì AVFoundation
22
+ * hỏi lúc mở camera). Chuyển từ apps/device-streamer (createRtmpOnlyRuntime).
23
+ * Không dùng getUserMedia: máy MediaTek chỉ cho một camera mở cùng lúc.
24
+ */
25
+ export function createRtmpRuntime(): SdkRuntime {
26
+ const { Platform, PermissionsAndroid } = require("react-native");
59
27
  return {
60
28
  assertSupported() {
61
- if (
62
- (Platform.OS !== "ios" && Platform.OS !== "android") ||
63
- !NativeModules.LivekitReactNativeModule
64
- ) {
29
+ if (Platform.OS !== "ios" && Platform.OS !== "android") {
65
30
  throw new UnsupportedRuntimeError();
66
31
  }
32
+ try {
33
+ require("@pickleball/rtmp-native");
34
+ } catch {
35
+ throw new UnsupportedRuntimeError(
36
+ "@pickleball/rtmp-native is required: install it and rebuild the Development Build",
37
+ );
38
+ }
67
39
  },
68
40
  async requestPermissions() {
41
+ if (Platform.OS === "android") {
42
+ try {
43
+ const granted = await PermissionsAndroid.requestMultiple([
44
+ PermissionsAndroid.PERMISSIONS.CAMERA,
45
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
46
+ ]);
47
+ return (
48
+ granted[PermissionsAndroid.PERMISSIONS.CAMERA] ===
49
+ PermissionsAndroid.RESULTS.GRANTED &&
50
+ granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] ===
51
+ PermissionsAndroid.RESULTS.GRANTED
52
+ );
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
69
57
  try {
70
- const { mediaDevices } = require("@livekit/react-native-webrtc");
71
- const stream = await mediaDevices.getUserMedia({ audio: true, video: true });
72
- for (const track of stream.getTracks()) track.stop();
73
- return true;
58
+ const native = require("@pickleball/rtmp-native").default;
59
+ if (typeof native?.requestPermissions === "function") {
60
+ return (await native.requestPermissions()) !== false;
61
+ }
74
62
  } catch {
75
- return false;
63
+ // Binary cũ chưa có hàm → AVFoundation tự hỏi lúc prepare.
76
64
  }
65
+ return true;
77
66
  },
78
- activateKeepAwake() {
79
- return require("expo-keep-awake").activateKeepAwakeAsync(
80
- "@pickleball/expo-sdk",
81
- );
82
- },
83
- deactivateKeepAwake() {
84
- return require("expo-keep-awake").deactivateKeepAwake(
85
- "@pickleball/expo-sdk",
86
- );
67
+ activateKeepAwake: () =>
68
+ Promise.resolve(
69
+ require("expo-keep-awake").activateKeepAwakeAsync("@pickleball/expo-sdk"),
70
+ ).catch(() => undefined),
71
+ // deactivate trả promise và REJECT khi activity đã chết (sau crash/reload) —
72
+ // không catch là thêm một "Uncaught (in promise)" gây nhiễu log.
73
+ deactivateKeepAwake: () => {
74
+ void Promise.resolve(
75
+ require("expo-keep-awake").deactivateKeepAwake("@pickleball/expo-sdk"),
76
+ ).catch(() => undefined);
87
77
  },
88
78
  };
89
79
  }
90
80
 
81
+ /** Runtime mặc định của SDK 1.0 = RTMP (LiveKit đã gỡ khỏi SDK — ADR-0002). */
82
+ export const createDefaultRuntime = createRtmpRuntime;
83
+
91
84
  export function createDefaultStorage(): SdkStorage {
92
- require("expo-sqlite/localStorage/install");
85
+ // Cài localStorage của expo-sqlite ở LẦN DÙNG ĐẦU, không phải lúc gọi factory:
86
+ // app thường tạo storage ở module scope (root layout), và static-render web
87
+ // của expo-router nạp module đó trong Node nơi expo-sqlite không resolve được.
88
+ // Không có lời gọi get/set/remove nào khi render tĩnh nên require không chạy.
89
+ let installed = false;
90
+ const install = () => {
91
+ if (installed) return;
92
+ require("expo-sqlite/localStorage/install");
93
+ installed = true;
94
+ };
93
95
  return {
94
- getItem: async (key) => globalThis.localStorage.getItem(key),
95
- setItem: async (key, value) => globalThis.localStorage.setItem(key, value),
96
- removeItem: async (key) => globalThis.localStorage.removeItem(key),
96
+ getItem: async (key) => {
97
+ install();
98
+ return globalThis.localStorage.getItem(key);
99
+ },
100
+ setItem: async (key, value) => {
101
+ install();
102
+ globalThis.localStorage.setItem(key, value);
103
+ },
104
+ removeItem: async (key) => {
105
+ install();
106
+ globalThis.localStorage.removeItem(key);
107
+ },
97
108
  };
98
109
  }
99
110
 
@@ -155,8 +166,152 @@ export function createDefaultTelemetry(
155
166
  };
156
167
  }
157
168
 
169
+ /** roomFactory mặc định của SDK 1.0 = RTMP (fps/camera mặc định của native). */
158
170
  export function createDefaultRoomFactory(): EngineDependencies["roomFactory"] {
159
- return () => new LiveKitRoomAdapter(createNativeLiveKitBindings());
171
+ return createRtmpRoomFactory();
172
+ }
173
+
174
+ /**
175
+ * Kênh poll lệnh cho phiên device-rtmp. URL suy từ telemetry endpoint
176
+ * (.../telemetry → .../commands) — cùng origin, cùng Bearer token, nên không
177
+ * cần thêm bất kỳ trường mới nào đi qua proxy của đối tác.
178
+ */
179
+ export function createDefaultCommandChannel(
180
+ fetchImplementation = globalThis.fetch,
181
+ timeoutMs = 10_000,
182
+ ): SessionCommandChannel {
183
+ const controllers = new Set<AbortController>();
184
+ return {
185
+ async poll(credentials, after): Promise<SessionCommandPollResult> {
186
+ const endpoint = credentials.endpoint.replace(/\/telemetry$/, "/commands");
187
+ const controller = new AbortController();
188
+ controllers.add(controller);
189
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
190
+ try {
191
+ const response = await fetchImplementation(endpoint, {
192
+ method: "POST",
193
+ headers: {
194
+ "Content-Type": "application/json",
195
+ Authorization: `Bearer ${credentials.token}`,
196
+ },
197
+ body: JSON.stringify({ after }),
198
+ signal: controller.signal,
199
+ redirect: "error",
200
+ });
201
+ if (!response.ok) {
202
+ if (typeof response.text === "function") await response.text();
203
+ throw Object.assign(new Error("Command poll rejected"), {
204
+ status: response.status,
205
+ });
206
+ }
207
+ const envelope = (await response.json()) as { data?: unknown };
208
+ return parseCommandPollResult(envelope?.data);
209
+ } finally {
210
+ clearTimeout(timeout);
211
+ controllers.delete(controller);
212
+ }
213
+ },
214
+ dispose() {
215
+ for (const controller of controllers) controller.abort();
216
+ controllers.clear();
217
+ },
218
+ };
219
+ }
220
+
221
+ function parseCommandPollResult(value: unknown): SessionCommandPollResult {
222
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
223
+ throw new Error("Command poll returned an invalid payload");
224
+ }
225
+ const record = value as Record<string, unknown>;
226
+ const status = record.status;
227
+ if (status !== "scheduled" && status !== "live" && status !== "ended") {
228
+ throw new Error("Command poll returned an invalid status");
229
+ }
230
+ const commands: SessionCommandRecord[] = [];
231
+ if (Array.isArray(record.commands)) {
232
+ for (const item of record.commands) {
233
+ if (item === null || typeof item !== "object") continue;
234
+ const command = item as Record<string, unknown>;
235
+ if (
236
+ typeof command.id !== "string" ||
237
+ (command.action !== "start" &&
238
+ command.action !== "stop" &&
239
+ command.action !== "end") ||
240
+ typeof command.issuedAt !== "number" ||
241
+ !Number.isFinite(command.issuedAt)
242
+ ) {
243
+ continue;
244
+ }
245
+ commands.push({
246
+ id: command.id,
247
+ action: command.action,
248
+ issuedAt: command.issuedAt,
249
+ });
250
+ }
251
+ }
252
+ return { commands, status };
253
+ }
254
+
255
+ /**
256
+ * Bindings native RTMP. `require` động: app chưa cài @pickleball/rtmp-native
257
+ * chỉ lỗi khi thật sự mở camera (UnsupportedRuntimeError rõ ràng), không gãy
258
+ * lúc import SDK — static-render web của expo-router vẫn nạp được module.
259
+ */
260
+ export function createNativeRtmpBindings(): NativeRtmpBindings {
261
+ const native = require("@pickleball/rtmp-native").default;
262
+ const { Platform, PermissionsAndroid } = require("react-native");
263
+ return {
264
+ prepare: (options) => native.prepare(options),
265
+ // Android: xin quyền THUẦN, không mở camera như getUserMedia của WebRTC.
266
+ // Máy MediaTek (Xiaomi tanzanite) chỉ cho một camera mở cùng lúc — camera
267
+ // WebRTC chưa nhả (~400ms) là HaishinKit dính "Too many users (-87)" và
268
+ // preview đen. iOS không có giới hạn đó nên để engine fallback getUserMedia.
269
+ ...(Platform.OS === "android"
270
+ ? {
271
+ async requestPermissions() {
272
+ const granted = await PermissionsAndroid.requestMultiple([
273
+ PermissionsAndroid.PERMISSIONS.CAMERA,
274
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
275
+ ]);
276
+ return (
277
+ granted[PermissionsAndroid.PERMISSIONS.CAMERA] ===
278
+ PermissionsAndroid.RESULTS.GRANTED &&
279
+ granted[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] ===
280
+ PermissionsAndroid.RESULTS.GRANTED
281
+ );
282
+ },
283
+ }
284
+ : {}),
285
+ isLandscape() {
286
+ const { width, height } = require("react-native").Dimensions.get("window");
287
+ return width > height;
288
+ },
289
+ connect: (options) => native.connect(options),
290
+ setPublishing: (enabled) => native.setPublishing(enabled),
291
+ disconnect: () => native.disconnect(),
292
+ switchCamera: () => native.switchCamera(),
293
+ listCameras: () => native.listCameras(),
294
+ selectCamera: (deviceId, facing) => native.selectCamera(deviceId, facing),
295
+ setMicrophoneEnabled: (enabled) => native.setMicrophoneEnabled(enabled),
296
+ setVideoBitrate: (bps) => native.setVideoBitrate(bps),
297
+ dispose: () => native.dispose(),
298
+ addStatusListener: (listener) => native.addListener("onStatusChanged", listener),
299
+ addBandwidthListener: (listener) => native.addListener("onBandwidth", listener),
300
+ };
301
+ }
302
+
303
+ /** roomFactory cho mode device-rtmp (thiết bị đẩy RTMP, không dùng LiveKit). */
304
+ export function createRtmpRoomFactory(options?: {
305
+ /**
306
+ * fps capture + encode (24 tiết kiệm băng thông, 30 mặc định của native).
307
+ * 60 chỉ nên truyền khi `getCameraCapabilities().supportedFrameRates` có nó —
308
+ * native bỏ qua trong im lặng nếu ống kính đang gắn không chạy được.
309
+ */
310
+ frameRate?: 24 | 30 | 60;
311
+ /** Camera mở sẵn lúc prepare — thiếu = camera sau mặc định của native. */
312
+ cameraId?: string;
313
+ }): EngineDependencies["roomFactory"] {
314
+ return () => new RtmpRoomAdapter(createNativeRtmpBindings(), options);
160
315
  }
161
316
 
162
317
  /**
@@ -214,6 +369,21 @@ export function createDefaultDeviceMetrics(): NonNullable<
214
369
  /* expo-device chưa được cài */
215
370
  }
216
371
 
372
+ // Nhiệt máy + nhiệt pin: chỉ có đường native (không package Expo nào đọc
373
+ // được). Guard require như các nguồn khác — app không cài rtmp-native thì
374
+ // bỏ qua hai trường này thay vì gãy cả collector.
375
+ try {
376
+ const native = require("@pickleball/rtmp-native").default;
377
+ const health = await native.getDeviceHealth();
378
+ if (typeof health?.thermal === "number") metrics.thermal = health.thermal;
379
+ if (typeof health?.batteryTempC === "number") {
380
+ // Làm tròn 1 số lẻ: pin báo theo 1/10 °C, giữ nguyên là đủ chính xác.
381
+ metrics.batteryTempC = Math.round(health.batteryTempC * 10) / 10;
382
+ }
383
+ } catch (error) {
384
+ notes.push(`device health: ${String(error)}`);
385
+ }
386
+
217
387
  try {
218
388
  const NetInfo = require("@react-native-community/netinfo").default;
219
389
  const status = await NetInfo.fetch();
@@ -227,6 +397,42 @@ export function createDefaultDeviceMetrics(): NonNullable<
227
397
  ).toUpperCase()
228
398
  : status.type;
229
399
  }
400
+ // Chi tiết mạng cho việc truy vết sự cố tại sân: máy còn bám router của
401
+ // giải hay đã rớt sang 4G, sóng yếu tới mức nào.
402
+ //
403
+ // CỐ Ý không đọc `details.ssid`/`bssid`: hai field đó bị khoá sau quyền
404
+ // VỊ TRÍ (Android cần ACCESS_FINE_LOCATION + bật GPS; iOS cần entitlement
405
+ // Access WiFi Information). Xin quyền vị trí cho mỗi máy đặt ở sân chỉ để
406
+ // hiện tên WiFi là cái giá quá đắt — IP nội bộ dưới đây phân biệt được
407
+ // router mà không cần quyền nào.
408
+ const details = status?.details as
409
+ | {
410
+ ipAddress?: unknown;
411
+ strength?: unknown;
412
+ linkSpeed?: unknown;
413
+ frequency?: unknown;
414
+ }
415
+ | undefined;
416
+ if (typeof details?.ipAddress === "string" && details.ipAddress) {
417
+ metrics.localIp = details.ipAddress;
418
+ }
419
+ // strength: 0-100 (%). CHỈ Android — iOS không mở cường độ sóng cho app.
420
+ if (typeof details?.strength === "number" && Number.isFinite(details.strength)) {
421
+ metrics.signalPct = Math.round(details.strength);
422
+ }
423
+ if (typeof details?.linkSpeed === "number" && Number.isFinite(details.linkSpeed)) {
424
+ metrics.linkMbps = Math.round(details.linkSpeed);
425
+ }
426
+ // Tần số kênh (Android) — phân biệt 2.4/5 GHz không cần quyền vị trí.
427
+ // Chỉ đi vào telemetry PHIÊN (dataJson tự do); heartbeat thiết bị lọc
428
+ // theo METRIC_FIELDS nên không đụng validator server.
429
+ if (
430
+ typeof details?.frequency === "number" &&
431
+ Number.isFinite(details.frequency) &&
432
+ details.frequency > 0
433
+ ) {
434
+ metrics.freqMhz = Math.round(details.frequency);
435
+ }
230
436
  } catch {
231
437
  /* netinfo không khả dụng */
232
438
  }
package/src/plugin.ts CHANGED
@@ -4,38 +4,71 @@ import {
4
4
  withAndroidManifest,
5
5
  withDangerousMod,
6
6
  withInfoPlist,
7
- withPlugins,
8
7
  type ConfigPlugin,
9
8
  } from "expo/config-plugins";
10
9
  import { readdir, readFile, writeFile } from "node:fs/promises";
11
10
  import { join } from "node:path";
12
- import { PICKLEBALL_EXPO_SDK_VERSION } from "./version";
11
+
12
+ /**
13
+ * Version của SCHEMA plugin — tách khỏi version package có chủ ý. Giá trị này
14
+ * được inline vào dist/plugin.cjs, mà @expo/fingerprint băm file đó làm
15
+ * runtimeVersion của app fleet: gắn theo version package là MỖI lần bump SDK
16
+ * (kể cả patch JS-only) đổi fingerprint và toàn bộ máy ngoài sân mất OTA. Chỉ
17
+ * nâng khi plugin đổi hành vi native (permission, mod).
18
+ */
19
+ export const PLUGIN_SCHEMA_VERSION = "1";
13
20
 
14
21
  export interface PickleballLivePluginProps {
15
22
  cameraPermission?: string;
16
23
  microphonePermission?: string;
17
24
  bluetoothPermission?: string;
18
25
  consumeHidVolumeKeys?: boolean;
26
+ /**
27
+ * Tắt phần cấu hình native của module không dùng để app gọn hơn:
28
+ * - remote: false → bỏ permission Bluetooth (Android + iOS usage string) và
29
+ * không patch MainActivity nhận phím HID. Dùng khi app không import
30
+ * "@pickleball/expo-sdk/match/remote".
31
+ * - voice: false → hiện chưa gate gì trong plugin (mic thuộc livestream);
32
+ * nhận trước để tương thích về sau. Muốn loại hẳn native khỏi binary, thêm
33
+ * expo.autolinking.exclude trong package.json của app (xem README).
34
+ * - livekit: KHÔNG còn tác dụng từ 1.0 (SDK RTMP-only, không compose plugin
35
+ * LiveKit nữa). Vẫn nhận để app.config cũ của fleet không phải sửa — sửa
36
+ * là đổi fingerprint OTA.
37
+ */
38
+ modules?: {
39
+ remote?: boolean;
40
+ voice?: boolean;
41
+ /** @deprecated bị bỏ qua từ 1.0 */
42
+ livekit?: boolean;
43
+ };
44
+ /** @deprecated bị bỏ qua từ 1.0 (SDK không còn LiveKit). */
19
45
  liveKit?: {
20
46
  android?: { audioType?: string; enableScreenShareService?: boolean };
21
47
  ios?: { enableMultitaskingCameraAccess?: boolean };
22
48
  };
23
49
  }
24
50
 
25
- const ANDROID_PERMISSIONS = [
51
+ const CORE_ANDROID_PERMISSIONS = [
26
52
  "android.permission.ACCESS_NETWORK_STATE",
27
- "android.permission.BLUETOOTH",
28
- "android.permission.BLUETOOTH_ADMIN",
29
- "android.permission.BLUETOOTH_SCAN",
30
- "android.permission.BLUETOOTH_CONNECT",
31
- "android.permission.ACCESS_FINE_LOCATION",
32
53
  "android.permission.CAMERA",
54
+ "android.permission.FOREGROUND_SERVICE",
55
+ "android.permission.FOREGROUND_SERVICE_CAMERA",
56
+ "android.permission.FOREGROUND_SERVICE_MICROPHONE",
33
57
  "android.permission.INTERNET",
34
58
  "android.permission.MODIFY_AUDIO_SETTINGS",
35
59
  "android.permission.RECORD_AUDIO",
36
60
  "android.permission.WAKE_LOCK",
37
61
  ] as const;
38
62
 
63
+ // Chỉ cần cho remote BLE/HID (@pickleball/expo-sdk/match/remote)
64
+ const REMOTE_ANDROID_PERMISSIONS = [
65
+ "android.permission.BLUETOOTH",
66
+ "android.permission.BLUETOOTH_ADMIN",
67
+ "android.permission.BLUETOOTH_SCAN",
68
+ "android.permission.BLUETOOTH_CONNECT",
69
+ "android.permission.ACCESS_FINE_LOCATION",
70
+ ] as const;
71
+
39
72
  export function injectHidVolumeMainActivity(source: string): string {
40
73
  if (source.includes("PickleballRemoteHidDispatcher.dispatch(event)")) return source;
41
74
  if (!/class\s+MainActivity\s*:\s*ReactActivity\(\)\s*\{/.test(source)) {
@@ -76,20 +109,21 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
76
109
  const bluetoothPermission =
77
110
  props?.bluetoothPermission ??
78
111
  "Cho phép $(PRODUCT_NAME) dùng Bluetooth để nhận thao tác từ nút điều khiển trên sân.";
112
+ const remoteEnabled = props?.modules?.remote !== false;
113
+ const androidPermissions = remoteEnabled
114
+ ? [...CORE_ANDROID_PERMISSIONS, ...REMOTE_ANDROID_PERMISSIONS]
115
+ : [...CORE_ANDROID_PERMISSIONS];
79
116
 
80
- config = withPlugins(config, [
81
- ["@livekit/react-native-expo-plugin", props?.liveKit],
82
- ]);
83
117
  config.ios = { ...config.ios, bitcode: false };
84
118
  config = withInfoPlist(config, (mod) => {
85
119
  mod.modResults.NSCameraUsageDescription = cameraPermission;
86
120
  mod.modResults.NSMicrophoneUsageDescription = microphonePermission;
87
- mod.modResults.NSBluetoothAlwaysUsageDescription = bluetoothPermission;
121
+ if (remoteEnabled) {
122
+ mod.modResults.NSBluetoothAlwaysUsageDescription = bluetoothPermission;
123
+ }
88
124
  return mod;
89
125
  });
90
- config = AndroidConfig.Permissions.withPermissions(config, [
91
- ...ANDROID_PERMISSIONS,
92
- ]);
126
+ config = AndroidConfig.Permissions.withPermissions(config, androidPermissions);
93
127
  config = withAndroidManifest(config, (mod) => {
94
128
  const manifest = mod.modResults.manifest;
95
129
  const existing = manifest["uses-permission"] ?? [];
@@ -106,7 +140,7 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
106
140
  const name = entry.$?.["android:name"];
107
141
  if (name && !byName.has(name)) byName.set(name, entry);
108
142
  }
109
- for (const permission of ANDROID_PERMISSIONS) {
143
+ for (const permission of androidPermissions) {
110
144
  const entry = byName.get(permission) ?? { $: { "android:name": permission } };
111
145
  entry.$ = {
112
146
  "android:name": permission,
@@ -114,17 +148,17 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
114
148
  };
115
149
  byName.set(permission, entry);
116
150
  }
117
- const requiredNames = new Set<string>(ANDROID_PERMISSIONS);
151
+ const requiredNames = new Set<string>(androidPermissions);
118
152
  manifest["uses-permission"] = [
119
153
  ...existing.filter((entry) => {
120
154
  const name = entry.$?.["android:name"];
121
155
  return !name || !requiredNames.has(name);
122
156
  }),
123
- ...ANDROID_PERMISSIONS.map((name) => byName.get(name)!),
157
+ ...androidPermissions.map((name) => byName.get(name)!),
124
158
  ];
125
159
  return mod;
126
160
  });
127
- if (props?.consumeHidVolumeKeys !== false) {
161
+ if (remoteEnabled && props?.consumeHidVolumeKeys !== false) {
128
162
  config = withDangerousMod(config, ["android", async (mod) => {
129
163
  const root = join(mod.modRequest.platformProjectRoot, "app", "src", "main", "java");
130
164
  const path = await findMainActivity(root);
@@ -141,5 +175,5 @@ const withPickleballLive: ConfigPlugin<PickleballLivePluginProps | undefined> =
141
175
  export default createRunOncePlugin(
142
176
  withPickleballLive,
143
177
  "@pickleball/expo-sdk",
144
- PICKLEBALL_EXPO_SDK_VERSION,
178
+ PLUGIN_SCHEMA_VERSION,
145
179
  );