@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.
@@ -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.*