summaryrefslogtreecommitdiff
path: root/App.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'App.tsx')
-rw-r--r--App.tsx351
1 files changed, 351 insertions, 0 deletions
diff --git a/App.tsx b/App.tsx
new file mode 100644
index 0000000..09e446f
--- /dev/null
+++ b/App.tsx
@@ -0,0 +1,351 @@
1import React, {useState, useEffect, useRef} from 'react';
2import {
3 StyleSheet,
4 View,
5 Text,
6 TouchableOpacity,
7 FlatList,
8 Platform,
9 PermissionsAndroid,
10 DeviceEventEmitter,
11 NativeModules,
12} from 'react-native';
13import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context';
14
15// Device type string names accepted by startDiscovery on both platforms
16const DEVICE_TYPE_KEYS = [
17 'AM3S', 'AM4', 'PO3', 'BP5', 'BP5S', 'BP3L', 'BP7', 'BP7S',
18 'KN550', 'HS2', 'HS2S', 'HS4S', 'BG1', 'BG1S', 'BG5', 'BG5S',
19 'ECG3', 'BTM', 'TS28B', 'NT13B',
20] as const;
21
22type DeviceTypeName = (typeof DEVICE_TYPE_KEYS)[number];
23
24// The native module exports string constants on iOS, number constants on Android.
25type DiscoveryConstant = string | number;
26
27interface IHealthDeviceManager {
28 // Device type constants (string on iOS, number on Android)
29 AM3S: DiscoveryConstant;
30 AM4: DiscoveryConstant;
31 PO3: DiscoveryConstant;
32 BP5: DiscoveryConstant;
33 BP5S: DiscoveryConstant;
34 BP3L: DiscoveryConstant;
35 BP7: DiscoveryConstant;
36 BP7S: DiscoveryConstant;
37 KN550: DiscoveryConstant;
38 HS2: DiscoveryConstant;
39 HS2S: DiscoveryConstant;
40 HS4S: DiscoveryConstant;
41 BG1: DiscoveryConstant;
42 BG1S: DiscoveryConstant;
43 BG5: DiscoveryConstant;
44 BG5S: DiscoveryConstant;
45 ECG3: DiscoveryConstant;
46 BTM: DiscoveryConstant;
47 TS28B: DiscoveryConstant;
48 NT13B: DiscoveryConstant;
49 // Event name constants
50 Event_Scan_Device: string;
51 Event_Scan_Finish: string;
52 Event_Device_Connected: string;
53 Event_Device_Connect_Failed: string;
54 Event_Device_Disconnect: string;
55 Event_Authenticate_Result: string;
56 // Methods — startDiscovery takes a device type name string per official docs
57 startDiscovery(type: DeviceTypeName | string): void;
58 stopDiscovery(): void;
59 connectDevice(mac: string, type: string): void;
60 disconnectDevice(mac: string, type: string): void;
61 sdkAuthWithLicense(license: string): void;
62 authenConfigureInfo(userName: string, clientID: string, clientSecret: string): void;
63 getDevicesIDPS(mac: string, callback: (idps: Record<string, string>) => void): void;
64}
65
66const iHealthDeviceManagerModule =
67 NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager;
68
69type Device = {
70 mac: string;
71 type: string;
72 rssi?: number;
73 timestamp: number;
74};
75
76async function requestAndroidPermissions(): Promise<boolean> {
77 if (Platform.OS !== 'android') return true;
78
79 const apiLevel = Platform.Version;
80 const permissions: string[] = [];
81
82 if (apiLevel >= 31) {
83 permissions.push(
84 PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
85 PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
86 );
87 }
88 permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION);
89
90 const results = await PermissionsAndroid.requestMultiple(permissions as any);
91 return Object.values(results).every(
92 r => r === PermissionsAndroid.RESULTS.GRANTED,
93 );
94}
95
96function DeviceIcon({type}: {type: string}) {
97 const icons: Record<string, string> = {
98 BP: 'BP',
99 AM: 'AM',
100 PO: 'PO',
101 BG: 'BG',
102 HS: 'HS',
103 ECG: 'ECG',
104 BTM: 'BTM',
105 };
106 const prefix = Object.keys(icons).find(k => type.startsWith(k)) || '?';
107 return (
108 <View style={styles.deviceIcon}>
109 <Text style={styles.deviceIconText}>{icons[prefix] || '?'}</Text>
110 </View>
111 );
112}
113
114function App() {
115 const [devices, setDevices] = useState<Device[]>([]);
116 const [scanning, setScanning] = useState(false);
117 const [error, setError] = useState<string | null>(null);
118 const devicesRef = useRef<Device[]>([]);
119
120 useEffect(() => {
121 if (!iHealthDeviceManagerModule) {
122 setError('iHealth native module not found. Check linking.');
123 return;
124 }
125
126 // The iHealth SDK uses the global DeviceEventEmitter (not NativeEventEmitter)
127 // because the iOS native module doesn't subclass RCTEventEmitter.
128 const scanSub = DeviceEventEmitter.addListener(
129 iHealthDeviceManagerModule.Event_Scan_Device ?? 'event_scan_device',
130 (event: {mac: string; type: string; rssi?: number}) => {
131 const {mac = '', type = 'Unknown', rssi} = event;
132
133 // Filter out AM3 — likely false positives from non-iHealth heart rate devices
134 if (type === 'AM3') return;
135
136 const existing = devicesRef.current.findIndex(d => d.mac === mac);
137 let updated: Device[];
138 if (existing >= 0) {
139 updated = [...devicesRef.current];
140 updated[existing] = {mac, type, rssi, timestamp: Date.now()};
141 } else {
142 updated = [
143 ...devicesRef.current,
144 {mac, type, rssi, timestamp: Date.now()},
145 ];
146 }
147 devicesRef.current = updated;
148 setDevices(updated);
149 },
150 );
151
152 const finishSub = DeviceEventEmitter.addListener(
153 iHealthDeviceManagerModule.Event_Scan_Finish ?? 'event_scan_finish',
154 () => {
155 setScanning(false);
156 },
157 );
158
159 return () => {
160 scanSub.remove();
161 finishSub.remove();
162 };
163 }, []);
164
165 const startScan = async () => {
166 setError(null);
167
168 const granted = await requestAndroidPermissions();
169 if (!granted) {
170 setError('Bluetooth permissions denied');
171 return;
172 }
173
174 devicesRef.current = [];
175 setDevices([]);
176 setScanning(true);
177
178 // Discover all device types. 'ALL' hits the default case in getDiscoveryType()
179 // which maps to DiscoveryTypeEnum.All (MIX = BLE + BT Classic + WiFi).
180 // Requires ACCESS_NETWORK_STATE permission for WiFi scan.
181 try {
182 iHealthDeviceManagerModule.startDiscovery('ALL');
183 } catch (e) {
184 console.log('Failed to start discovery:', e);
185 }
186 };
187
188 const stopScan = () => {
189 try {
190 iHealthDeviceManagerModule.stopDiscovery();
191 } catch (e) {
192 console.log('Failed to stop discovery:', e);
193 }
194 setScanning(false);
195 };
196
197 const renderDevice = ({item}: {item: Device}) => (
198 <View style={styles.deviceRow}>
199 <DeviceIcon type={item.type} />
200 <View style={styles.deviceInfo}>
201 <Text style={styles.deviceType}>{item.type}</Text>
202 <Text style={styles.deviceMac}>{item.mac}</Text>
203 </View>
204 {item.rssi != null && (
205 <Text style={styles.deviceRssi}>{item.rssi} dBm</Text>
206 )}
207 </View>
208 );
209
210 return (
211 <SafeAreaProvider>
212 <SafeAreaView style={styles.container}>
213 <Text style={styles.title}>iHealth Scanner</Text>
214 <Text style={styles.subtitle}>
215 {scanning
216 ? `Scanning... (${devices.length} found)`
217 : `${devices.length} device(s) found`}
218 </Text>
219
220 {error && <Text style={styles.error}>{error}</Text>}
221
222 <TouchableOpacity
223 style={[styles.button, scanning && styles.buttonStop]}
224 onPress={scanning ? stopScan : startScan}>
225 <Text style={styles.buttonText}>
226 {scanning ? 'Stop Scan' : 'Start Scan'}
227 </Text>
228 </TouchableOpacity>
229
230 <FlatList
231 data={devices}
232 keyExtractor={item => item.mac}
233 renderItem={renderDevice}
234 style={styles.list}
235 contentContainerStyle={devices.length === 0 && styles.emptyList}
236 ListEmptyComponent={
237 <Text style={styles.emptyText}>
238 {scanning
239 ? 'Looking for iHealth devices...'
240 : 'Tap "Start Scan" to find nearby iHealth devices'}
241 </Text>
242 }
243 />
244 </SafeAreaView>
245 </SafeAreaProvider>
246 );
247}
248
249const styles = StyleSheet.create({
250 container: {
251 flex: 1,
252 backgroundColor: '#f5f5f5',
253 paddingHorizontal: 16,
254 },
255 title: {
256 fontSize: 28,
257 fontWeight: '700',
258 color: '#1a1a1a',
259 marginTop: 16,
260 },
261 subtitle: {
262 fontSize: 14,
263 color: '#666',
264 marginTop: 4,
265 marginBottom: 16,
266 },
267 error: {
268 color: '#d32f2f',
269 fontSize: 13,
270 marginBottom: 8,
271 backgroundColor: '#ffebee',
272 padding: 8,
273 borderRadius: 6,
274 },
275 button: {
276 backgroundColor: '#2196F3',
277 paddingVertical: 14,
278 borderRadius: 10,
279 alignItems: 'center',
280 marginBottom: 16,
281 },
282 buttonStop: {
283 backgroundColor: '#f44336',
284 },
285 buttonText: {
286 color: '#fff',
287 fontSize: 16,
288 fontWeight: '600',
289 },
290 list: {
291 flex: 1,
292 },
293 emptyList: {
294 flex: 1,
295 justifyContent: 'center',
296 alignItems: 'center',
297 },
298 emptyText: {
299 color: '#999',
300 fontSize: 15,
301 textAlign: 'center',
302 },
303 deviceRow: {
304 flexDirection: 'row',
305 alignItems: 'center',
306 backgroundColor: '#fff',
307 padding: 14,
308 borderRadius: 10,
309 marginBottom: 8,
310 shadowColor: '#000',
311 shadowOffset: {width: 0, height: 1},
312 shadowOpacity: 0.05,
313 shadowRadius: 2,
314 elevation: 1,
315 },
316 deviceIcon: {
317 width: 44,
318 height: 44,
319 borderRadius: 22,
320 backgroundColor: '#e3f2fd',
321 justifyContent: 'center',
322 alignItems: 'center',
323 marginRight: 12,
324 },
325 deviceIconText: {
326 fontSize: 13,
327 fontWeight: '700',
328 color: '#1565c0',
329 },
330 deviceInfo: {
331 flex: 1,
332 },
333 deviceType: {
334 fontSize: 16,
335 fontWeight: '600',
336 color: '#1a1a1a',
337 },
338 deviceMac: {
339 fontSize: 12,
340 color: '#888',
341 fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
342 marginTop: 2,
343 },
344 deviceRssi: {
345 fontSize: 12,
346 color: '#999',
347 marginLeft: 8,
348 },
349});
350
351export default App;