From e4fb9966e762852bf17f21c8406501d42fae0b61 Mon Sep 17 00:00:00 2001
From: hc <haocheng.xie@respiree.com>
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<string, string>) => void): void;
+}
+
+const iHealthDeviceManagerModule =
+  NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager;
+
+type Device = {
+  mac: string;
+  type: string;
+  rssi?: number;
+  timestamp: number;
+};
+
+async function requestAndroidPermissions(): Promise<boolean> {
+  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<string, string> = {
+    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 (
+    <View style={styles.deviceIcon}>
+      <Text style={styles.deviceIconText}>{icons[prefix] || '?'}</Text>
+    </View>
+  );
+}
+
+function App() {
+  const [devices, setDevices] = useState<Device[]>([]);
+  const [scanning, setScanning] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const devicesRef = useRef<Device[]>([]);
+
+  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}) => (
+    <View style={styles.deviceRow}>
+      <DeviceIcon type={item.type} />
+      <View style={styles.deviceInfo}>
+        <Text style={styles.deviceType}>{item.type}</Text>
+        <Text style={styles.deviceMac}>{item.mac}</Text>
+      </View>
+      {item.rssi != null && (
+        <Text style={styles.deviceRssi}>{item.rssi} dBm</Text>
+      )}
+    </View>
+  );
+
+  return (
+    <SafeAreaProvider>
+      <SafeAreaView style={styles.container}>
+        <Text style={styles.title}>iHealth Scanner</Text>
+        <Text style={styles.subtitle}>
+          {scanning
+            ? `Scanning... (${devices.length} found)`
+            : `${devices.length} device(s) found`}
+        </Text>
+
+        {error && <Text style={styles.error}>{error}</Text>}
+
+        <TouchableOpacity
+          style={[styles.button, scanning && styles.buttonStop]}
+          onPress={scanning ? stopScan : startScan}>
+          <Text style={styles.buttonText}>
+            {scanning ? 'Stop Scan' : 'Start Scan'}
+          </Text>
+        </TouchableOpacity>
+
+        <FlatList
+          data={devices}
+          keyExtractor={item => item.mac}
+          renderItem={renderDevice}
+          style={styles.list}
+          contentContainerStyle={devices.length === 0 && styles.emptyList}
+          ListEmptyComponent={
+            <Text style={styles.emptyText}>
+              {scanning
+                ? 'Looking for iHealth devices...'
+                : 'Tap "Start Scan" to find nearby iHealth devices'}
+            </Text>
+          }
+        />
+      </SafeAreaView>
+    </SafeAreaProvider>
+  );
+}
+
+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 

