import {useCallback, useEffect, useState} from 'react'; import {Alert, PermissionsAndroid, Platform} from 'react-native'; import { ActivationStatus, BLE_UUIDS, BleClient, BleDevice, BleError, BleProtocolService, COMMAND_TYPES, ConnectionState, DeviceInfo, DeviceInfoService, FileTransferService } from '../ble/index'; import {ScanMode} from "react-native-ble-plx"; import * as ImagePicker from 'expo-image-picker'; import {File, Directory, Paths} from 'expo-file-system'; import * as ImageManipulator from 'expo-image-manipulator'; interface BleState { isScanning: boolean; isConnected: boolean; connectedDevice: BleDevice | null; deviceInfo: DeviceInfo | null; version: string; isActivated: boolean; transferProgress: number; isTransferring: boolean; logs: string[]; discoveredDevices: BleDevice[]; loading: { connecting: boolean; querying: boolean; transferring: boolean; }; error: string | null; } const MAX_LOGS = 100; export const useBleExplorer = () => { const bleClient = BleClient.getInstance(); const deviceInfoService = DeviceInfoService.getInstance(); const fileTransferService = FileTransferService.getInstance(); const protocolService = BleProtocolService.getInstance(); const [state, setState] = useState({ isScanning: false, isConnected: false, connectedDevice: null, deviceInfo: null, version: '', isActivated: false, transferProgress: 0, isTransferring: false, logs: [], discoveredDevices: [], loading: { connecting: false, querying: false, transferring: false, }, error: null, }); const addLog = useCallback((message: string) => { const timestamp = new Date().toLocaleTimeString(); console.log(message); setState(prev => { const newLogs = [...prev.logs, `[${timestamp}] ${message}`]; const trimmedLogs = newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs; return {...prev, logs: trimmedLogs}; }); }, []); const setError = useCallback( (error: string | null) => { setState(prev => ({...prev, error})); if (error) { addLog(`ERROR: ${error}`); } }, [addLog] ); const requestBluetoothPermissions = useCallback(async (): Promise => { if (Platform.OS !== 'android') return true; try { const permissions = [ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, ]; const results = await PermissionsAndroid.requestMultiple(permissions); const allGranted = Object.values(results).every(result => result === PermissionsAndroid.RESULTS.GRANTED); if (!allGranted) { const denied = Object.entries(results) .filter(([_, result]) => result !== PermissionsAndroid.RESULTS.GRANTED) .map(([permission]) => permission.split('.').pop()); setError(`Bluetooth permissions required: ${denied.join(', ')}`); return false; } return true; } catch (error) { setError(`Permission request failed: ${error}`); return false; } }, [setError]); useEffect(() => { if (Platform.OS === 'web') { addLog('BLE not supported on web platform'); return; } const onConnectionStateChange = ({deviceId, state: connState}: { deviceId: string, state: ConnectionState }) => { const isConnected = connState === ConnectionState.CONNECTED; setState(prev => { const updatedDevices = prev.discoveredDevices.map(d => { if (d.id === deviceId) { const newDevice = Object.assign(Object.create(Object.getPrototypeOf(d)), d) as BleDevice; newDevice.connected = isConnected; return newDevice; } return d; }); return { ...prev, isConnected, connectedDevice: connState === ConnectionState.DISCONNECTED ? null : prev.connectedDevice, discoveredDevices: updatedDevices, }; }); addLog(`Connection state ${deviceId}: ${connState}`); }; const onScanError = (error: BleError) => { setError(`Scan error: ${error.message}`); setState(prev => ({...prev, isScanning: false})); }; bleClient.addListener('connectionStateChange', onConnectionStateChange); bleClient.addListener('scanError', onScanError); const onDeviceInfo = (info: DeviceInfo) => { setState(prev => ({...prev, deviceInfo: info})); addLog(`Device info received: ${info.devname}`); }; const onActivationStatus = (status: ActivationStatus) => { const activated = status.state === 1; setState(prev => ({...prev, isActivated: activated})); addLog(`Activation status: ${activated ? 'Activated' : 'Not activated'}`); }; deviceInfoService.addListener('deviceInfo', onDeviceInfo); deviceInfoService.addListener('activationStatus', onActivationStatus); return () => { bleClient.removeListener('connectionStateChange', onConnectionStateChange); bleClient.removeListener('scanError', onScanError); deviceInfoService.removeListener('deviceInfo', onDeviceInfo); deviceInfoService.removeListener('activationStatus', onActivationStatus); }; }, [bleClient, deviceInfoService, addLog, setError]); const startScan = useCallback(async () => { if (Platform.OS === 'web') return; try { setError(null); addLog('Starting scan...'); const hasPerms = await requestBluetoothPermissions(); if (!hasPerms) return; setState(prev => ({...prev, isScanning: true, discoveredDevices: []})); // Check for already connected devices try { const relevantDevices = await bleClient.getConnectedDevices([BLE_UUIDS.SERVICE]); if (relevantDevices.length > 0) { addLog(`Found ${relevantDevices.length} system-connected devices`); setState(prev => { // Avoid duplicates if any const newDevices = relevantDevices.filter( nd => !prev.discoveredDevices.some(ed => ed.id === nd.id) ); newDevices.forEach(d => { d.connected = false; }); // Treat as disconnected until explicit connect return { ...prev, discoveredDevices: [...prev.discoveredDevices, ...newDevices] }; }); } } catch (e) { console.warn("Failed to check connected devices", e); } await bleClient.startScan( // [BLE_UUIDS.SERVICE], null, {scanMode: ScanMode.LowLatency, allowDuplicates: false}, (result) => { setState(prev => { const device = result.device as BleDevice; // Filter only for service UUID if (device.name?.startsWith("707")) { // continue } else if (!device.serviceUUIDs) { return prev; } if (prev.discoveredDevices.find(d => d.id === device.id)) return prev; device.connected = false; addLog(`Device found: ${device.name} (${device.id}), serviceUUIDs: ${device.serviceUUIDs}`); return {...prev, discoveredDevices: [...prev.discoveredDevices, device]}; }); }); } catch (e: any) { setError(`Start scan failed: ${e.message}`); } }, [bleClient, requestBluetoothPermissions, addLog, setError]); const stopScan = useCallback(() => { bleClient.stopScan(); setState(prev => ({...prev, isScanning: false})); addLog('Scan stopped'); }, [bleClient, addLog]); const connectToDevice = useCallback(async (device: BleDevice) => { try { stopScan(); setState(prev => ({...prev, loading: {...prev.loading, connecting: true}})); addLog(`Connecting to ${device.name}...`); const connectedDevice = await bleClient.connect(device.id) as BleDevice; connectedDevice.connected = true; // Scan and log all services and characteristics try { const services = await connectedDevice.services(); for (const service of services) { addLog(`[Service] ${service.uuid}`); const characteristics = await service.characteristics(); for (const char of characteristics) { const props = [ char.isReadable ? 'Read' : '', char.isWritableWithResponse ? 'Write' : '', char.isWritableWithoutResponse ? 'WriteNoResp' : '', char.isNotifiable ? 'Notify' : '', char.isIndicatable ? 'Indicate' : '' ].filter(Boolean).join(','); addLog(` -> [Char] ${char.uuid} (${props})`); } } } catch (scanError) { addLog(`Error scanning services: ${scanError}`); } await protocolService.initialize(device.id); setState(prev => ({ ...prev, connectedDevice, isConnected: true, loading: {...prev.loading, connecting: false} })); addLog('Connected and Protocol initialized'); } catch (e: any) { setError(`Connection failed: ${e.message}`); setState(prev => ({...prev, loading: {...prev.loading, connecting: false}})); } }, [bleClient, protocolService, stopScan, addLog, setError]); const disconnectDevice = useCallback(async () => { try { addLog('Disconnecting...'); await bleClient.disconnect(); protocolService.disconnect(); } catch (e: any) { addLog(`Disconnect failed: ${e.message}`); } }, [bleClient, protocolService, addLog]); const requestDeviceInfo = useCallback(async () => { if (!state.connectedDevice) return; try { addLog(`[${state.connectedDevice.id}] Requesting Device Info...`); await deviceInfoService.queryDeviceInfo(state.connectedDevice.id); addLog(`[${state.connectedDevice.id}] Device Info query request sent.`); } catch (e: any) { setError(`Request failed: ${e.message}`); } }, [deviceInfoService, state.connectedDevice, addLog, setError]); const queryActivationStatus = useCallback(async () => { if (!state.connectedDevice) return; try { addLog('Querying Activation Status...'); await deviceInfoService.queryActivationStatus(state.connectedDevice.id); } catch (e: any) { setError(`Query failed: ${e.message}`); } }, [deviceInfoService, state.connectedDevice, addLog, setError]); const queryDeviceVersion = useCallback(async () => { if (!state.connectedDevice) return; try { addLog(`[${state.connectedDevice.id}] Requesting Device Version...`); await deviceInfoService.queryDeviceVersion(state.connectedDevice.id); addLog(`[${state.connectedDevice.id}] Device Version query request sent`); } catch (e: any) { setError(`Request failed: ${e.message}`); } }, [state.connectedDevice, deviceInfoService, addLog, setError]) const pickImage = async () => { // No permissions request is necessary for launching the image library. // Manually request permissions for videos on iOS when `allowsEditing` is set to `false` // and `videoExportPreset` is `'Passthrough'` (the default), ideally before launching the picker // so the app users aren't surprised by a system dialog after picking a video. // See "Invoke permissions for videos" sub section for more details. const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permissionResult.granted) { Alert.alert('Permission required', 'Permission to access the media library is required.'); return []; } let result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images', 'videos'], allowsEditing: false, aspect: [1, 1], quality: 1.0, }); if (!result.canceled) { return result.assets; } else { return []; } }; const convertToANI = useCallback(async (tempDir: Directory, media: ImagePicker.ImagePickerAsset): Promise => { const tempFile = new File(tempDir, `${media.fileName}.ani`) const formData = new FormData(); formData.append('file', { uri: media.uri, name: media.fileName || 'video.mp4', type: media.mimeType || 'video/mp4', } as any); const response = await fetch("https://bowongai-test--ani-video-converter-fastapi-app.modal.run/api/convert/ani", { method: "POST", body: formData, headers: {'Accept': 'multipart/form-data',} }); if (!response.ok) { throw new Error(`Conversion failed with status ${response.status}`); } tempFile.write(await response.bytes()); const tempFileInfo = tempFile.info() addLog(`Converted video saved to ${tempFile.uri}, size : ${tempFileInfo.size} bytes`); return tempFile; }, [addLog]); const transferSampleFile = useCallback(async () => { if (!state.connectedDevice) { setError('No device connected'); return; } const tempDir = new Directory(Paths.cache, 'anis') if (!tempDir.exists) tempDir.create(); const medias = await pickImage(); if (medias.length === 0) return; addLog(`[${state.connectedDevice.id}] processing ${medias.length} files...`); for (const media of medias) { if (media.type === 'video') { let tempFile: File; addLog(`Converting video: ${media.fileName || 'video'}...`); tempFile = await convertToANI(tempDir, media) addLog(`Transferring converted file to device...`); await fileTransferService.transferFile( state.connectedDevice.id, tempFile.uri, COMMAND_TYPES.TRANSFER_ANI_VIDEO, (progress) => { setState(prev => ({...prev, transferProgress: progress * 100})); // Optional: throttle logs to avoid spam if (Math.round(progress * 100) % 10 === 0) { // addLog(`Transfer progress: ${Math.round(progress * 100)}%`); } } ); tempFile.delete(); addLog(`Transfer successful`); } else if (media.type === 'image') { try { addLog(`Processing image: ${media.fileName || 'image'}...`); // Check if it is a GIF const isGif = media.mimeType === 'image/gif' || media.uri.toLowerCase().endsWith('.gif'); if (isGif) { addLog(`Converting GIF to ANI: ${media.fileName || 'gif'}...`); const tempFile = await convertToANI(tempDir, media) addLog(`Transferring converted file to device...`); await fileTransferService.transferFile( state.connectedDevice.id, tempFile.uri, COMMAND_TYPES.TRANSFER_ANI_VIDEO, (progress) => { setState(prev => ({...prev, transferProgress: progress * 100})); } ); addLog(`Transfer successful`); tempFile.delete(); addLog(`Cleaned up temp file`); return; } let imageUri = media.uri; // Check if conversion is needed (if not jpeg) // Note: mimeType might not always be reliable, so we can also check filename extension if needed, // or just always run manipulator to ensure consistency. // Here we check if it is NOT jpeg/jpg const isJpeg = media.mimeType === 'image/jpeg' || media.mimeType === 'image/jpg' || media.uri.toLowerCase().endsWith('.jpg') || media.uri.toLowerCase().endsWith('.jpeg'); if (!isJpeg) { addLog(`Converting image to JPEG...`); const context = ImageManipulator.ImageManipulator.manipulate(media.uri); const imageRef = await context.renderAsync(); const result = await imageRef.saveAsync({ compress: 1, format: ImageManipulator.SaveFormat.JPEG, }); imageUri = result.uri; addLog(`Conversion successful: ${imageUri}`); } addLog(`Transferring image to device...`); await fileTransferService.transferFile( state.connectedDevice.id, imageUri, COMMAND_TYPES.TRANSFER_JPEG_IMAGE, // Assuming PHOTO_ALBUM is for images, adjust if needed (progress) => { setState(prev => ({...prev, transferProgress: progress * 100})); } ); // If we created a temporary file via manipulation, we might want to clean it up? // ImageManipulator results are usually in cache, which OS cleans up, but we can't easily delete if we don't own it. // For this flow, we just leave it. addLog(`Transfer successful`); } catch (e: any) { addLog(`Error: ${e.message}`); setError(e.message); } } else { console.log(`Unsupported media type: ${media.type}`); } } }, [state.connectedDevice, fileTransferService, convertToANI, setError, addLog]); const clearLogs = useCallback(() => setState(prev => ({...prev, logs: []})), []); return { ...state, startScan, stopScan, connectToDevice, disconnectDevice, requestDeviceInfo, queryDeviceVersion, queryActivationStatus, transferSampleFile, clearLogs, updateActivationTime: () => { }, sendIdentityCheck: () => { }, }; };