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;