@vailix/mask 0.1.2
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/ANALYSIS_REPORT.md +211 -0
- package/BLE_IMPLEMENTATION_PLAN.md +703 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +365 -0
- package/dist/index.d.mts +347 -0
- package/dist/index.d.ts +347 -0
- package/dist/index.js +1095 -0
- package/dist/index.mjs +1058 -0
- package/package.json +62 -0
- package/src/ble.ts +504 -0
- package/src/db.ts +57 -0
- package/src/identity.ts +91 -0
- package/src/index.ts +375 -0
- package/src/matcher.ts +224 -0
- package/src/storage.ts +110 -0
- package/src/transport.ts +20 -0
- package/src/types.ts +100 -0
- package/src/utils.ts +20 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
# BLE Implementation Plan: Replace NFC with Bluetooth Low Energy
|
|
2
|
+
|
|
3
|
+
**Date:** January 7, 2026
|
|
4
|
+
**Status:** Draft for Review
|
|
5
|
+
**Scope:** Replace NFC pairing in `@vailix/mask` with BLE-based proximity exchange
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Overview
|
|
10
|
+
|
|
11
|
+
### Current State (NFC)
|
|
12
|
+
- Phone-to-phone NFC exchange doesn't work reliably on modern devices
|
|
13
|
+
- iOS cannot emulate NFC tags (Apple restriction)
|
|
14
|
+
- Requires 2-3 taps with physical NFC tags
|
|
15
|
+
|
|
16
|
+
### Proposed State (BLE)
|
|
17
|
+
- Foreground-only BLE advertising and scanning
|
|
18
|
+
- One-tap pairing with explicit user consent
|
|
19
|
+
- Cross-platform (iOS + Android)
|
|
20
|
+
- No physical hardware required
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 2. User Flow
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
28
|
+
│ USER A USER B │
|
|
29
|
+
├─────────────────────────────────────────────────────────────┤
|
|
30
|
+
│ 1. Opens app 1. Opens app │
|
|
31
|
+
│ ↓ ↓ │
|
|
32
|
+
│ 2. App starts advertising 2. App starts advertising │
|
|
33
|
+
│ + scanning + scanning │
|
|
34
|
+
│ ↓ ↓ │
|
|
35
|
+
│ 3. Sees "🐼 42" in 3. Sees "🦊 17" in │
|
|
36
|
+
│ nearby list nearby list │
|
|
37
|
+
│ ↓ │
|
|
38
|
+
│ 4. Taps "🐼 42" to pair │
|
|
39
|
+
│ ↓ ↓ │
|
|
40
|
+
│ 5. BLE connection established ←→ Connection accepted │
|
|
41
|
+
│ ↓ ↓ │
|
|
42
|
+
│ 6. Exchanges RPI + metadataKey ←→ Exchanges RPI + metaKey │
|
|
43
|
+
│ ↓ ↓ │
|
|
44
|
+
│ 7. Both phones now have each other's data ✓ │
|
|
45
|
+
└─────────────────────────────────────────────────────────────┘
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Total user actions:** 1 tap (by either user)
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
# PART A: Package Implementation (`@vailix/mask`)
|
|
53
|
+
|
|
54
|
+
This section covers changes to the `@vailix/mask` package source code.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 3. Package Dependencies
|
|
59
|
+
|
|
60
|
+
### Add to `packages/mask/package.json`
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"react-native-ble-plx": "^3.2.1"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Remove from `packages/mask/package.json`
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"react-native-nfc-manager": "^3.14.0" // REMOVE
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 4. File Changes
|
|
81
|
+
|
|
82
|
+
### Files to Delete
|
|
83
|
+
```
|
|
84
|
+
src/nfc.ts # Remove entirely
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Files to Create
|
|
88
|
+
```
|
|
89
|
+
src/ble.ts # New BLE service
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Files to Modify
|
|
93
|
+
```
|
|
94
|
+
src/index.ts # Replace NFC methods with BLE methods
|
|
95
|
+
src/types.ts # Add BLE-related types
|
|
96
|
+
package.json # Update dependencies
|
|
97
|
+
README.md # Update documentation
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 5. BLE Service Design (`src/ble.ts`)
|
|
103
|
+
|
|
104
|
+
### 5.1. Constants
|
|
105
|
+
```typescript
|
|
106
|
+
// Service UUID (unique to Vailix - generate once, use forever)
|
|
107
|
+
const VAILIX_SERVICE_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
108
|
+
|
|
109
|
+
// Characteristic UUIDs
|
|
110
|
+
const RPI_OUT_CHAR_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567891'; // Others read my RPI from here
|
|
111
|
+
const RPI_IN_CHAR_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567892'; // Others write their RPI to here
|
|
112
|
+
|
|
113
|
+
const META_OUT_CHAR_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567893'; // Others read my Key from here
|
|
114
|
+
const META_IN_CHAR_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567894'; // Others write their Key to here
|
|
115
|
+
|
|
116
|
+
// Default timeout before removing user from nearby list
|
|
117
|
+
const DEFAULT_DISCOVERY_TIMEOUT_MS = 15000; // 15 seconds
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 5.2. Types (add to `src/types.ts`)
|
|
121
|
+
```typescript
|
|
122
|
+
/**
|
|
123
|
+
* Represents a nearby user discovered via BLE.
|
|
124
|
+
*
|
|
125
|
+
* Design: Internal details (RPI, metadataKey) are hidden from the public API.
|
|
126
|
+
* The app only needs: something to display, something to pair with, and status flags.
|
|
127
|
+
* RPIs and keys are managed internally by the package for contact tracing.
|
|
128
|
+
*/
|
|
129
|
+
export interface NearbyUser {
|
|
130
|
+
id: string; // Opaque identifier for pairing (pass to pairWithUser)
|
|
131
|
+
displayName: string; // Generated emoji + number for UI display
|
|
132
|
+
rssi: number; // Signal strength (for proximity filtering)
|
|
133
|
+
discoveredAt: number; // Timestamp of last advertisement received
|
|
134
|
+
paired: boolean; // true if we have securely stored their data
|
|
135
|
+
hasIncomingRequest: boolean; // true if they sent us data but we haven't accepted yet (explicit mode)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Internal type (not exported) - used by BleService
|
|
139
|
+
interface InternalNearbyUser extends NearbyUser {
|
|
140
|
+
rpiPrefix: string; // Truncated RPI from advertisement (8 bytes hex)
|
|
141
|
+
fullRpi?: string; // Complete RPI (populated after GATT read)
|
|
142
|
+
metadataKey?: string; // Metadata key (populated after GATT read)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Design Decision: Why hide RPI/metadataKey from public API?
|
|
147
|
+
*
|
|
148
|
+
* 1. Cleaner API - App developers don't need to understand contact tracing internals
|
|
149
|
+
* 2. Prevents misuse - Can't accidentally expose RPIs in logs or analytics
|
|
150
|
+
* 3. Two-phase data - rpiPrefix available on discovery, fullRpi only after pairing
|
|
151
|
+
* 4. Future flexibility - Internal structure can change without breaking apps
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
export interface PairResult {
|
|
155
|
+
success: boolean;
|
|
156
|
+
partnerRpi?: string;
|
|
157
|
+
partnerMetadataKey?: string;
|
|
158
|
+
error?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Unified Configuration Interface
|
|
162
|
+
export interface VailixConfig {
|
|
163
|
+
// --- Backend & Auth ---
|
|
164
|
+
reportUrl: string;
|
|
165
|
+
downloadUrl: string;
|
|
166
|
+
appSecret: string;
|
|
167
|
+
|
|
168
|
+
// --- Storage ---
|
|
169
|
+
keyStorage?: KeyStorage;
|
|
170
|
+
reportDays?: number;
|
|
171
|
+
|
|
172
|
+
// --- Contact Tracing Protocol ---
|
|
173
|
+
rpiDurationMs?: number;
|
|
174
|
+
rescanIntervalMs?: number;
|
|
175
|
+
|
|
176
|
+
// --- BLE Proximity & Pairing ---
|
|
177
|
+
// Timeout before removing a user from nearby list (default: 15000ms)
|
|
178
|
+
bleDiscoveryTimeoutMs?: number;
|
|
179
|
+
|
|
180
|
+
// Minimum RSSI to consider a user "nearby" (default: -70)
|
|
181
|
+
proximityThreshold?: number;
|
|
182
|
+
|
|
183
|
+
// If true, automatically pair when receiving a write (default: true)
|
|
184
|
+
// If false, set hasIncomingRequest=true and wait for explicit pairWithUser()
|
|
185
|
+
autoAcceptIncomingPairs?: boolean;
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### 5.3. BleService Class
|
|
190
|
+
```typescript
|
|
191
|
+
// src/ble.ts
|
|
192
|
+
|
|
193
|
+
import { BleManager, Device, State } from 'react-native-ble-plx';
|
|
194
|
+
import { VailixConfig } from './types';
|
|
195
|
+
|
|
196
|
+
// Internal service configuration derived from main config
|
|
197
|
+
export interface BleServiceConfig {
|
|
198
|
+
discoveryTimeoutMs?: number; // Default: 15000
|
|
199
|
+
proximityThreshold?: number; // Default: -70
|
|
200
|
+
autoAccept?: boolean; // Default: true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export class BleService {
|
|
204
|
+
private manager: BleManager;
|
|
205
|
+
private isAdvertising: boolean = false;
|
|
206
|
+
private isScanning: boolean = false;
|
|
207
|
+
private nearbyUsers: Map<string, InternalNearbyUser> = new Map(); // Internal type with RPI details
|
|
208
|
+
private discoveryTimeoutMs: number;
|
|
209
|
+
private proximityThreshold: number;
|
|
210
|
+
private autoAccept: boolean;
|
|
211
|
+
private cleanupInterval?: NodeJS.Timeout;
|
|
212
|
+
private onNearbyUpdated?: (users: NearbyUser[]) => void; // Public type (no RPI details)
|
|
213
|
+
|
|
214
|
+
constructor(config: BleServiceConfig = {}) {
|
|
215
|
+
this.manager = new BleManager();
|
|
216
|
+
this.discoveryTimeoutMs = config.discoveryTimeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
|
|
217
|
+
this.proximityThreshold = config.proximityThreshold ?? -70;
|
|
218
|
+
this.autoAccept = config.autoAccept ?? true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check if BLE is available and enabled
|
|
222
|
+
async initialize(): Promise<boolean>;
|
|
223
|
+
|
|
224
|
+
// Start advertising our RPI + scanning for others
|
|
225
|
+
async startDiscovery(myRpi: string, myMetadataKey: string): Promise<void>;
|
|
226
|
+
|
|
227
|
+
// Stop advertising and scanning
|
|
228
|
+
async stopDiscovery(): Promise<void>;
|
|
229
|
+
|
|
230
|
+
// Get current list of nearby users
|
|
231
|
+
getNearbyUsers(): NearbyUser[];
|
|
232
|
+
|
|
233
|
+
// Subscribe to nearby user updates
|
|
234
|
+
onNearbyUsersChanged(callback: (users: NearbyUser[]) => void): () => void;
|
|
235
|
+
|
|
236
|
+
// Initiate pairing with a specific user (triggers bidirectional exchange)
|
|
237
|
+
async pairWithUser(userId: string, myRpi: string, myMetadataKey: string): Promise<PairResult>;
|
|
238
|
+
|
|
239
|
+
// Cleanup resources
|
|
240
|
+
destroy(): void;
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 5.4. Display Name Generation
|
|
245
|
+
```typescript
|
|
246
|
+
// Generate random emoji + number for anonymous identification
|
|
247
|
+
const EMOJIS = ['🐼', '🦊', '🐨', '🦁', '🐯', '🐸', '🦋', '🐙', '🦄', '🐳'];
|
|
248
|
+
|
|
249
|
+
function generateDisplayName(rpiPrefix: string): string {
|
|
250
|
+
// Deterministic based on truncated RPI (from advertisement)
|
|
251
|
+
// Ensures all scanners see the same name for the same user
|
|
252
|
+
// 8 bytes (64 bits) provides plenty of entropy for 1000 combinations
|
|
253
|
+
const hash = rpiPrefix.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
|
254
|
+
const emoji = EMOJIS[hash % EMOJIS.length];
|
|
255
|
+
const number = hash % 100;
|
|
256
|
+
return `${emoji} ${number}`;
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### 5.5. Stale User Cleanup
|
|
261
|
+
```typescript
|
|
262
|
+
// Remove users not seen within timeout period
|
|
263
|
+
private startCleanupInterval(): void {
|
|
264
|
+
this.cleanupInterval = setInterval(() => {
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
let changed = false;
|
|
267
|
+
|
|
268
|
+
for (const [id, user] of this.nearbyUsers) {
|
|
269
|
+
if (now - user.discoveredAt > this.discoveryTimeoutMs) {
|
|
270
|
+
this.nearbyUsers.delete(id);
|
|
271
|
+
changed = true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (changed && this.onNearbyUpdated) {
|
|
276
|
+
// Strip internal fields before emitting
|
|
277
|
+
this.onNearbyUpdated(Array.from(this.nearbyUsers.values()).map(this.toPublicUser));
|
|
278
|
+
}
|
|
279
|
+
}, 1000); // Check every second
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// CRITICAL: When a device is discovered via scanning:
|
|
283
|
+
// 1. If it's a NEW device: Add to map with current timestamp
|
|
284
|
+
// 2. If it's an EXISTING device: Update `rssi` AND `discoveredAt` = Date.now()
|
|
285
|
+
// This ensures active users are not removed by the cleanup interval.
|
|
286
|
+
|
|
287
|
+
// When emitting to onNearbyUpdated callback, strip internal fields:
|
|
288
|
+
// InternalNearbyUser -> NearbyUser (remove rpiPrefix, fullRpi, metadataKey)
|
|
289
|
+
private toPublicUser(internal: InternalNearbyUser): NearbyUser {
|
|
290
|
+
const { rpiPrefix, fullRpi, metadataKey, ...publicFields } = internal;
|
|
291
|
+
return publicFields;
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### 5.6. Advertisement Data Structure
|
|
296
|
+
|
|
297
|
+
BLE advertisement payload (max ~31 bytes for legacy):
|
|
298
|
+
|
|
299
|
+
```
|
|
300
|
+
┌─────────────────────────────────────────────────────────┐
|
|
301
|
+
│ Field │ Size │ Description │
|
|
302
|
+
├─────────────────┼─────────┼─────────────────────────────┤
|
|
303
|
+
│ Service UUID │ 16 bytes│ VAILIX_SERVICE_UUID │
|
|
304
|
+
│ RPI (truncated) │ 8 bytes │ First 8 bytes of RPI │
|
|
305
|
+
│ Flags │ 1 byte │ Protocol version, etc. │
|
|
306
|
+
└─────────────────────────────────────────────────────────┘
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Full RPI + metadataKey exchanged via GATT characteristics after connection.
|
|
310
|
+
|
|
311
|
+
### 5.7. GATT Service Structure
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
Vailix Service (VAILIX_SERVICE_UUID)
|
|
315
|
+
├── RPI_OUT (RPI_OUT_CHAR_UUID)
|
|
316
|
+
│ ├── Properties: Read
|
|
317
|
+
│ └── Value: 32 bytes (My RPI)
|
|
318
|
+
│
|
|
319
|
+
├── RPI_IN (RPI_IN_CHAR_UUID)
|
|
320
|
+
│ ├── Properties: Write
|
|
321
|
+
│ └── Value: - (Incoming RPIs)
|
|
322
|
+
│
|
|
323
|
+
├── META_OUT (META_OUT_CHAR_UUID)
|
|
324
|
+
│ ├── Properties: Read
|
|
325
|
+
│ └── Value: 64 bytes (My Metadata Key)
|
|
326
|
+
│
|
|
327
|
+
└── META_IN (META_IN_CHAR_UUID)
|
|
328
|
+
├── Properties: Write
|
|
329
|
+
└── Value: - (Incoming Metadata Keys)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## 6. SDK API Changes (`src/index.ts`)
|
|
335
|
+
|
|
336
|
+
### 6.1. New Config Option
|
|
337
|
+
|
|
338
|
+
Add `bleDiscoveryTimeoutMs` to `VailixSDK.create()` config:
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// Consolidated create method using the new unified config interface
|
|
342
|
+
static async create(config: VailixConfig): Promise<VailixSDK>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### 6.2. Remove NFC Methods
|
|
346
|
+
```typescript
|
|
347
|
+
// REMOVE these from VailixSDK:
|
|
348
|
+
static async isNfcSupported(): Promise<boolean>;
|
|
349
|
+
async pairViaNfc(): Promise<{ success: boolean; partnerRpi?: string }>;
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### 6.3. Add BLE Methods
|
|
353
|
+
```typescript
|
|
354
|
+
// ADD these to VailixSDK:
|
|
355
|
+
|
|
356
|
+
// Start BLE discovery (call when pairing screen opens)
|
|
357
|
+
async startDiscovery(): Promise<void>;
|
|
358
|
+
|
|
359
|
+
// Stop BLE discovery (call when leaving pairing screen)
|
|
360
|
+
async stopDiscovery(): Promise<void>;
|
|
361
|
+
|
|
362
|
+
// Get list of nearby users
|
|
363
|
+
getNearbyUsers(): NearbyUser[];
|
|
364
|
+
|
|
365
|
+
// Subscribe to nearby user updates (returns cleanup function)
|
|
366
|
+
onNearbyUsersChanged(callback: (users: NearbyUser[]) => void): () => void;
|
|
367
|
+
|
|
368
|
+
// Pair with a specific user (one-tap action, or accept incoming request)
|
|
369
|
+
async pairWithUser(userId: string): Promise<{ success: boolean; partnerRpi?: string }>;
|
|
370
|
+
|
|
371
|
+
// Unpair with a user (removes from storage and resets status)
|
|
372
|
+
async unpairUser(userId: string): Promise<void>;
|
|
373
|
+
|
|
374
|
+
// Check if BLE is available
|
|
375
|
+
static async isBleSupported(): Promise<boolean>;
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## 7. Pairing Handshake Protocol
|
|
381
|
+
|
|
382
|
+
When User A taps to pair with User B:
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
386
|
+
│ Step │ Action │
|
|
387
|
+
├──────┼──────────────────────────────────────────────────────┤
|
|
388
|
+
│ 1 │ A connects to B's GATT server │
|
|
389
|
+
│ 2 │ A reads B's RPI_OUT → gets B's RPI │
|
|
390
|
+
│ 3 │ A reads B's META_OUT → gets B's key │
|
|
391
|
+
│ 4 │ A writes A's RPI to B's RPI_IN │
|
|
392
|
+
│ 5 │ A writes A's MetadataKey to B's META_IN │
|
|
393
|
+
│ 6 │ B receives write on IN chars → processing logic │
|
|
394
|
+
│ 7 │ A stores B's RPI + key locally │
|
|
395
|
+
│ 8 │ A disconnects │
|
|
396
|
+
│ 9 │ Both phones now have each other's data ✓ │
|
|
397
|
+
└─────────────────────────────────────────────────────────────┘
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**Result:** Single tap by A triggers mutual exchange.
|
|
401
|
+
|
|
402
|
+
### 7.1. Paired Status Update & Explicit Consent
|
|
403
|
+
|
|
404
|
+
**Configuration:** `autoAcceptIncomingPairs` (boolean)
|
|
405
|
+
|
|
406
|
+
**Scenario A: User initiates pairing (Always Explicit for Initiator)**
|
|
407
|
+
1. User A taps pair on User B
|
|
408
|
+
2. Exchange completes
|
|
409
|
+
3. A stores B's data -> `paired = true`
|
|
410
|
+
|
|
411
|
+
**Scenario B: User receives pairing (Passive Role)**
|
|
412
|
+
|
|
413
|
+
*If `autoAcceptIncomingPairs = true` (Default):*
|
|
414
|
+
1. Package receives write -> Validates data
|
|
415
|
+
2. Package stores A's data securely
|
|
416
|
+
3. Sets `nearbyUsers[A].paired = true`
|
|
417
|
+
4. App shows "Paired"
|
|
418
|
+
|
|
419
|
+
*If `autoAcceptIncomingPairs = false` (Explicit Mode):*
|
|
420
|
+
1. Package receives write -> Validates data
|
|
421
|
+
2. Package *holds* data in memory (does NOT persist to secure storage yet)
|
|
422
|
+
3. Sets `nearbyUsers[A].hasIncomingRequest = true` (and `paired = false`)
|
|
423
|
+
4. App shows "Accept?" button or indication for User A
|
|
424
|
+
5. User B taps "Accept" (calls `pairWithUser(A)`)
|
|
425
|
+
6. `pairWithUser` finds pending data -> Persists to secure storage
|
|
426
|
+
7. Sets `paired = true`, `hasIncomingRequest = false`
|
|
427
|
+
8. App shows "Paired"
|
|
428
|
+
|
|
429
|
+
**Unpairing (`unpairUser`)**
|
|
430
|
+
- Deletes RPI + MetadataKey from secure storage
|
|
431
|
+
- Updates `nearbyUsers` list: sets `paired = false`, `hasIncomingRequest = false`
|
|
432
|
+
- Allows user to "undo" a pairing or reject a request
|
|
433
|
+
|
|
434
|
+
### 7.2. Race Condition Handling
|
|
435
|
+
|
|
436
|
+
If both users tap "pair" simultaneously:
|
|
437
|
+
- Both phones attempt to connect to each other
|
|
438
|
+
- Both exchanges may happen in parallel (redundant but safe)
|
|
439
|
+
- One connection might fail while the other succeeds
|
|
440
|
+
|
|
441
|
+
**Mitigation:** After a failed `pairWithUser()`, check if we already have that user's RPI stored (from the reverse connection). If yes, return success anyway.
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
async pairWithUser(userId: string): Promise<PairResult> {
|
|
445
|
+
try {
|
|
446
|
+
// Attempt connection and exchange
|
|
447
|
+
const result = await this._doExchange(userId);
|
|
448
|
+
return result;
|
|
449
|
+
} catch (error) {
|
|
450
|
+
// Check if we already have this user's RPI (paired via reverse connection)
|
|
451
|
+
const internalUser = this.nearbyUsers.get(userId); // InternalNearbyUser
|
|
452
|
+
if (internalUser?.fullRpi) {
|
|
453
|
+
const alreadyPaired = await this.storage.hasRpi(internalUser.fullRpi);
|
|
454
|
+
if (alreadyPaired) {
|
|
455
|
+
return { success: true, partnerRpi: internalUser.fullRpi };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { success: false, error: error.message };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## 8. Package-Level Security
|
|
466
|
+
|
|
467
|
+
### 8.1. Proximity Filtering (optional, configurable)
|
|
468
|
+
```typescript
|
|
469
|
+
// Only show users within reasonable proximity
|
|
470
|
+
// Configurable via `proximityThreshold` (default: -70)
|
|
471
|
+
const MIN_RSSI = -70; // Approximately 1-2 meters
|
|
472
|
+
|
|
473
|
+
function filterByProximity(users: NearbyUser[]): NearbyUser[] {
|
|
474
|
+
return users.filter(u => u.rssi >= MIN_RSSI);
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### 8.2. Replay Protection
|
|
479
|
+
- RPI changes periodically (configured via `rpiDurationMs`)
|
|
480
|
+
- Timestamp included in stored scan data
|
|
481
|
+
- Old RPIs rejected during matching
|
|
482
|
+
|
|
483
|
+
### 8.3. Connection Security
|
|
484
|
+
- BLE connections are not encrypted by default at the application layer
|
|
485
|
+
- Data exchanged (RPI + metadataKey) is already designed to be shared
|
|
486
|
+
- No sensitive user data is transmitted
|
|
487
|
+
|
|
488
|
+
### 8.4. Rate Limiting
|
|
489
|
+
- Rate limit connection attempts to prevent abuse
|
|
490
|
+
- Timeout stale nearby users based on `discoveryTimeoutMs`
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## 9. Implementation Order
|
|
495
|
+
|
|
496
|
+
1. **Phase 1:** Add `react-native-ble-plx` dependency, create `src/ble.ts` skeleton
|
|
497
|
+
2. **Phase 2:** Implement advertising + scanning
|
|
498
|
+
3. **Phase 3:** Implement GATT server (peripheral role)
|
|
499
|
+
4. **Phase 4:** Implement connection + data exchange (central role)
|
|
500
|
+
5. **Phase 5:** Integrate into VailixSDK, update `src/index.ts`
|
|
501
|
+
6. **Phase 6:** Remove NFC code and `react-native-nfc-manager` dependency
|
|
502
|
+
7. **Phase 7:** Update types, exports, and README
|
|
503
|
+
8. **Phase 8:** Testing
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
## 10. Breaking Changes & Versioning
|
|
508
|
+
|
|
509
|
+
### 10.1. Breaking Changes
|
|
510
|
+
- `pairViaNfc()` removed
|
|
511
|
+
- `isNfcSupported()` removed
|
|
512
|
+
- New methods: `startDiscovery()`, `stopDiscovery()`, `pairWithUser()`, etc.
|
|
513
|
+
|
|
514
|
+
### 10.2. Version Bump
|
|
515
|
+
- This is a **breaking change** → bump to `v0.2.0`
|
|
516
|
+
|
|
517
|
+
### 10.3. Changelog Entry
|
|
518
|
+
```markdown
|
|
519
|
+
## [0.2.0] - 2026-01-XX
|
|
520
|
+
|
|
521
|
+
### Changed
|
|
522
|
+
- **BREAKING:** Replaced NFC pairing with BLE-based proximity discovery
|
|
523
|
+
- **BREAKING:** New unified `VailixConfig` interface for SDK configuration
|
|
524
|
+
- New API: `startDiscovery()`, `stopDiscovery()`, `pairWithUser()`, `onNearbyUsersChanged()`
|
|
525
|
+
- Removed: `pairViaNfc()`, `isNfcSupported()`
|
|
526
|
+
|
|
527
|
+
### Added
|
|
528
|
+
- `NearbyUser` type for discovered users (with `paired` and `hasIncomingRequest` status flags)
|
|
529
|
+
- `isBleSupported()` static method
|
|
530
|
+
- `unpairUser()` method to remove a pairing
|
|
531
|
+
- Config options:
|
|
532
|
+
- `bleDiscoveryTimeoutMs` - timeout for stale user removal (default: 15000)
|
|
533
|
+
- `proximityThreshold` - minimum RSSI for nearby filtering (default: -70)
|
|
534
|
+
- `autoAcceptIncomingPairs` - auto-accept or explicit consent mode (default: true)
|
|
535
|
+
|
|
536
|
+
### Removed
|
|
537
|
+
- `react-native-nfc-manager` dependency
|
|
538
|
+
- NFC-related code (`src/nfc.ts`)
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
# PART B: App Developer Integration (README Documentation)
|
|
544
|
+
|
|
545
|
+
This section covers what app developers need to do when using `@vailix/mask`.
|
|
546
|
+
These items should be documented in the package README, not implemented in the package.
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## 11. App Configuration (Developer Responsibility)
|
|
551
|
+
|
|
552
|
+
### 11.1. iOS Configuration
|
|
553
|
+
|
|
554
|
+
App developer must add to `ios/[AppName]/Info.plist`:
|
|
555
|
+
|
|
556
|
+
```xml
|
|
557
|
+
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
558
|
+
<string>Used to discover and pair with nearby users</string>
|
|
559
|
+
<key>NSBluetoothPeripheralUsageDescription</key>
|
|
560
|
+
<string>Used to allow other users to discover you</string>
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### 11.2. Android Configuration
|
|
564
|
+
|
|
565
|
+
App developer must add to `android/app/src/main/AndroidManifest.xml`:
|
|
566
|
+
|
|
567
|
+
```xml
|
|
568
|
+
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
|
569
|
+
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
|
570
|
+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
571
|
+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## 12. Usage Example (For README)
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
// PairingScreen.tsx — Example app implementation
|
|
580
|
+
|
|
581
|
+
import { useState, useEffect } from 'react';
|
|
582
|
+
import { View, Text, FlatList, TouchableOpacity, Alert } from 'react-native';
|
|
583
|
+
import { NearbyUser } from '@vailix/mask';
|
|
584
|
+
|
|
585
|
+
function PairingScreen({ sdk }) {
|
|
586
|
+
const [nearbyUsers, setNearbyUsers] = useState<NearbyUser[]>([]);
|
|
587
|
+
const [pairingId, setPairingId] = useState<string | null>(null);
|
|
588
|
+
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
// Start discovery when screen opens
|
|
591
|
+
sdk.startDiscovery();
|
|
592
|
+
|
|
593
|
+
// Subscribe to nearby user updates (includes paired status changes)
|
|
594
|
+
const cleanup = sdk.onNearbyUsersChanged(setNearbyUsers);
|
|
595
|
+
|
|
596
|
+
return () => {
|
|
597
|
+
cleanup();
|
|
598
|
+
sdk.stopDiscovery();
|
|
599
|
+
};
|
|
600
|
+
}, []);
|
|
601
|
+
|
|
602
|
+
const handlePair = async (user: NearbyUser) => {
|
|
603
|
+
setPairingId(user.id);
|
|
604
|
+
const result = await sdk.pairWithUser(user.id);
|
|
605
|
+
if (result.success) {
|
|
606
|
+
Alert.alert('Paired!', `You are now connected with ${user.displayName}`);
|
|
607
|
+
} else {
|
|
608
|
+
Alert.alert('Failed', 'Could not pair. Please try again.');
|
|
609
|
+
}
|
|
610
|
+
setPairingId(null);
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
return (
|
|
614
|
+
<View>
|
|
615
|
+
<Text>Nearby Users ({nearbyUsers.length})</Text>
|
|
616
|
+
<FlatList
|
|
617
|
+
data={nearbyUsers}
|
|
618
|
+
keyExtractor={(item) => item.id}
|
|
619
|
+
renderItem={({ item }) => (
|
|
620
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
621
|
+
<Text>{item.displayName}</Text>
|
|
622
|
+
|
|
623
|
+
{/* APP RESPONSIBILITY: Show "Paired" label or "Pair" button based on status */}
|
|
624
|
+
{item.paired ? (
|
|
625
|
+
<Text style={{ color: 'green' }}>✓ Paired</Text>
|
|
626
|
+
) : (
|
|
627
|
+
<TouchableOpacity
|
|
628
|
+
onPress={() => handlePair(item)}
|
|
629
|
+
disabled={pairingId !== null}
|
|
630
|
+
>
|
|
631
|
+
<Text>{pairingId === item.id ? 'Pairing...' : 'Pair'}</Text>
|
|
632
|
+
</TouchableOpacity>
|
|
633
|
+
)}
|
|
634
|
+
</View>
|
|
635
|
+
)}
|
|
636
|
+
/>
|
|
637
|
+
</View>
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**Key Point:** The `paired` status is managed by the package. The app simply checks `item.paired` to decide whether to show a button or a label.
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## 13. Edge Cases (App Developer Responsibility)
|
|
647
|
+
|
|
648
|
+
These scenarios should be handled by the app, not the package:
|
|
649
|
+
|
|
650
|
+
| Scenario | App Should... |
|
|
651
|
+
|----------|---------------|
|
|
652
|
+
| Bluetooth disabled | Show prompt asking user to enable Bluetooth |
|
|
653
|
+
| Permission denied | Show message with link to Settings |
|
|
654
|
+
| User leaves range mid-pair | Show error toast, allow retry |
|
|
655
|
+
| Multiple users nearby | Display list, let user choose |
|
|
656
|
+
| No users found | Show helpful message ("Ask your partner to open the app") |
|
|
657
|
+
| App backgrounded | Discovery stops automatically; resume when foregrounded |
|
|
658
|
+
|
|
659
|
+
---
|
|
660
|
+
|
|
661
|
+
## 14. Testing Plan
|
|
662
|
+
|
|
663
|
+
### 14.1. Package Unit Tests
|
|
664
|
+
- [ ] BleService initialization
|
|
665
|
+
- [ ] Advertisement data encoding/decoding
|
|
666
|
+
- [ ] Nearby user deduplication
|
|
667
|
+
- [ ] Display name generation
|
|
668
|
+
- [ ] Stale user cleanup logic
|
|
669
|
+
|
|
670
|
+
### 14.2. Package Integration Tests
|
|
671
|
+
- [ ] Start/stop discovery lifecycle
|
|
672
|
+
- [ ] Pair with mock device
|
|
673
|
+
- [ ] Handle BLE unavailable
|
|
674
|
+
|
|
675
|
+
### 14.3. Device Testing (App Level)
|
|
676
|
+
- [ ] iOS to iOS pairing
|
|
677
|
+
- [ ] Android to Android pairing
|
|
678
|
+
- [ ] iOS to Android pairing (cross-platform)
|
|
679
|
+
- [ ] Multiple users nearby
|
|
680
|
+
- [ ] Background/foreground transitions
|
|
681
|
+
|
|
682
|
+
---
|
|
683
|
+
|
|
684
|
+
## 15. Resolved Questions
|
|
685
|
+
|
|
686
|
+
1. **User identification:** ✅ Generate random emoji + number from RPI (consistent across all scanners)
|
|
687
|
+
|
|
688
|
+
2. **Auto-timeout:** ✅ 15 seconds default, **configurable** via `bleDiscoveryTimeoutMs`
|
|
689
|
+
|
|
690
|
+
3. **Confirmation dialog:** ✅ No confirmation — instant mutual exchange on tap
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## 16. Approval Checklist
|
|
695
|
+
|
|
696
|
+
- [ ] Implementation approach approved
|
|
697
|
+
- [ ] API design approved
|
|
698
|
+
- [ ] Breaking change accepted
|
|
699
|
+
- [ ] Package/App separation clear
|
|
700
|
+
|
|
701
|
+
---
|
|
702
|
+
|
|
703
|
+
*Ready for review.*
|