From e4fb9966e762852bf17f21c8406501d42fae0b61 Mon Sep 17 00:00:00 2001 From: hc Date: Fri, 10 Apr 2026 17:39:12 +0800 Subject: Initial commit: iHealth BLE scanner app with patched SDK v1.5.0 --- App.tsx | 351 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 App.tsx (limited to 'App.tsx') diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..09e446f --- /dev/null +++ b/App.tsx @@ -0,0 +1,351 @@ +import React, {useState, useEffect, useRef} from 'react'; +import { + StyleSheet, + View, + Text, + TouchableOpacity, + FlatList, + Platform, + PermissionsAndroid, + DeviceEventEmitter, + NativeModules, +} from 'react-native'; +import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; + +// Device type string names accepted by startDiscovery on both platforms +const DEVICE_TYPE_KEYS = [ + 'AM3S', 'AM4', 'PO3', 'BP5', 'BP5S', 'BP3L', 'BP7', 'BP7S', + 'KN550', 'HS2', 'HS2S', 'HS4S', 'BG1', 'BG1S', 'BG5', 'BG5S', + 'ECG3', 'BTM', 'TS28B', 'NT13B', +] as const; + +type DeviceTypeName = (typeof DEVICE_TYPE_KEYS)[number]; + +// The native module exports string constants on iOS, number constants on Android. +type DiscoveryConstant = string | number; + +interface IHealthDeviceManager { + // Device type constants (string on iOS, number on Android) + AM3S: DiscoveryConstant; + AM4: DiscoveryConstant; + PO3: DiscoveryConstant; + BP5: DiscoveryConstant; + BP5S: DiscoveryConstant; + BP3L: DiscoveryConstant; + BP7: DiscoveryConstant; + BP7S: DiscoveryConstant; + KN550: DiscoveryConstant; + HS2: DiscoveryConstant; + HS2S: DiscoveryConstant; + HS4S: DiscoveryConstant; + BG1: DiscoveryConstant; + BG1S: DiscoveryConstant; + BG5: DiscoveryConstant; + BG5S: DiscoveryConstant; + ECG3: DiscoveryConstant; + BTM: DiscoveryConstant; + TS28B: DiscoveryConstant; + NT13B: DiscoveryConstant; + // Event name constants + Event_Scan_Device: string; + Event_Scan_Finish: string; + Event_Device_Connected: string; + Event_Device_Connect_Failed: string; + Event_Device_Disconnect: string; + Event_Authenticate_Result: string; + // Methods — startDiscovery takes a device type name string per official docs + startDiscovery(type: DeviceTypeName | string): void; + stopDiscovery(): void; + connectDevice(mac: string, type: string): void; + disconnectDevice(mac: string, type: string): void; + sdkAuthWithLicense(license: string): void; + authenConfigureInfo(userName: string, clientID: string, clientSecret: string): void; + getDevicesIDPS(mac: string, callback: (idps: Record) => void): void; +} + +const iHealthDeviceManagerModule = + NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager; + +type Device = { + mac: string; + type: string; + rssi?: number; + timestamp: number; +}; + +async function requestAndroidPermissions(): Promise { + if (Platform.OS !== 'android') return true; + + const apiLevel = Platform.Version; + const permissions: string[] = []; + + if (apiLevel >= 31) { + permissions.push( + PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, + PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, + ); + } + permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); + + const results = await PermissionsAndroid.requestMultiple(permissions as any); + return Object.values(results).every( + r => r === PermissionsAndroid.RESULTS.GRANTED, + ); +} + +function DeviceIcon({type}: {type: string}) { + const icons: Record = { + BP: 'BP', + AM: 'AM', + PO: 'PO', + BG: 'BG', + HS: 'HS', + ECG: 'ECG', + BTM: 'BTM', + }; + const prefix = Object.keys(icons).find(k => type.startsWith(k)) || '?'; + return ( + + {icons[prefix] || '?'} + + ); +} + +function App() { + const [devices, setDevices] = useState([]); + const [scanning, setScanning] = useState(false); + const [error, setError] = useState(null); + const devicesRef = useRef([]); + + useEffect(() => { + if (!iHealthDeviceManagerModule) { + setError('iHealth native module not found. Check linking.'); + return; + } + + // The iHealth SDK uses the global DeviceEventEmitter (not NativeEventEmitter) + // because the iOS native module doesn't subclass RCTEventEmitter. + const scanSub = DeviceEventEmitter.addListener( + iHealthDeviceManagerModule.Event_Scan_Device ?? 'event_scan_device', + (event: {mac: string; type: string; rssi?: number}) => { + const {mac = '', type = 'Unknown', rssi} = event; + + // Filter out AM3 — likely false positives from non-iHealth heart rate devices + if (type === 'AM3') return; + + const existing = devicesRef.current.findIndex(d => d.mac === mac); + let updated: Device[]; + if (existing >= 0) { + updated = [...devicesRef.current]; + updated[existing] = {mac, type, rssi, timestamp: Date.now()}; + } else { + updated = [ + ...devicesRef.current, + {mac, type, rssi, timestamp: Date.now()}, + ]; + } + devicesRef.current = updated; + setDevices(updated); + }, + ); + + const finishSub = DeviceEventEmitter.addListener( + iHealthDeviceManagerModule.Event_Scan_Finish ?? 'event_scan_finish', + () => { + setScanning(false); + }, + ); + + return () => { + scanSub.remove(); + finishSub.remove(); + }; + }, []); + + const startScan = async () => { + setError(null); + + const granted = await requestAndroidPermissions(); + if (!granted) { + setError('Bluetooth permissions denied'); + return; + } + + devicesRef.current = []; + setDevices([]); + setScanning(true); + + // Discover all device types. 'ALL' hits the default case in getDiscoveryType() + // which maps to DiscoveryTypeEnum.All (MIX = BLE + BT Classic + WiFi). + // Requires ACCESS_NETWORK_STATE permission for WiFi scan. + try { + iHealthDeviceManagerModule.startDiscovery('ALL'); + } catch (e) { + console.log('Failed to start discovery:', e); + } + }; + + const stopScan = () => { + try { + iHealthDeviceManagerModule.stopDiscovery(); + } catch (e) { + console.log('Failed to stop discovery:', e); + } + setScanning(false); + }; + + const renderDevice = ({item}: {item: Device}) => ( + + + + {item.type} + {item.mac} + + {item.rssi != null && ( + {item.rssi} dBm + )} + + ); + + return ( + + + iHealth Scanner + + {scanning + ? `Scanning... (${devices.length} found)` + : `${devices.length} device(s) found`} + + + {error && {error}} + + + + {scanning ? 'Stop Scan' : 'Start Scan'} + + + + item.mac} + renderItem={renderDevice} + style={styles.list} + contentContainerStyle={devices.length === 0 && styles.emptyList} + ListEmptyComponent={ + + {scanning + ? 'Looking for iHealth devices...' + : 'Tap "Start Scan" to find nearby iHealth devices'} + + } + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + paddingHorizontal: 16, + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#1a1a1a', + marginTop: 16, + }, + subtitle: { + fontSize: 14, + color: '#666', + marginTop: 4, + marginBottom: 16, + }, + error: { + color: '#d32f2f', + fontSize: 13, + marginBottom: 8, + backgroundColor: '#ffebee', + padding: 8, + borderRadius: 6, + }, + button: { + backgroundColor: '#2196F3', + paddingVertical: 14, + borderRadius: 10, + alignItems: 'center', + marginBottom: 16, + }, + buttonStop: { + backgroundColor: '#f44336', + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + list: { + flex: 1, + }, + emptyList: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + color: '#999', + fontSize: 15, + textAlign: 'center', + }, + deviceRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + padding: 14, + borderRadius: 10, + marginBottom: 8, + shadowColor: '#000', + shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + deviceIcon: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#e3f2fd', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + deviceIconText: { + fontSize: 13, + fontWeight: '700', + color: '#1565c0', + }, + deviceInfo: { + flex: 1, + }, + deviceType: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + }, + deviceMac: { + fontSize: 12, + color: '#888', + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + marginTop: 2, + }, + deviceRssi: { + fontSize: 12, + color: '#999', + marginLeft: 8, + }, +}); + +export default App; -- cgit