import {useCallback, useEffect, useState} from 'react'; import {Alert, PermissionsAndroid, Platform} from 'react-native'; import { ActivationStatus, BLE_UUIDS, BleClient, BleDevice, BleError, BleProtocolService, ConnectionState, DeviceInfo, COMMAND_TYPES, } from '../index'; import {DeviceInfoService} from '../services/DeviceInfoService' import {FileTransferService} from "../services/FileTransferService"; 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; discoveredDevices: BleDevice[]; loading: { connecting: boolean; querying: boolean; converting: boolean; transferring: boolean; }; error: string | null; } 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, discoveredDevices: [], loading: { connecting: false, querying: false, converting: false, transferring: false, }, error: null, }); const setError = useCallback( (error: string | null) => { setState(prev => ({...prev, error})); if (error) { console.error(`ERROR: ${error}`); } }, []); 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') { console.log('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, }; }); console.log(`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})); console.log(`Device info received: ${info.devname}`); }; const onActivationStatus = (status: ActivationStatus) => { const activated = status.state === 1; setState(prev => ({...prev, isActivated: activated})); console.log(`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, setError]); const startScan = useCallback(async () => { if (Platform.OS === 'web') return; try { setError(null); console.log('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) { console.log(`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; console.debug(`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, setError]); const stopScan = useCallback(() => { bleClient.stopScan(); setState(prev => ({...prev, isScanning: false})); console.log('Scan stopped'); }, [bleClient]); const connectToDevice = useCallback(async (device: BleDevice) => { try { stopScan(); setState(prev => ({...prev, loading: {...prev.loading, connecting: true}})); console.log(`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) { console.log(`[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(','); console.log(` -> [Char] ${char.uuid} (${props})`); } } } catch (scanError) { console.error(`Error scanning services: ${scanError}`); } await protocolService.initialize(device.id); setState(prev => ({ ...prev, connectedDevice, isConnected: true, loading: {...prev.loading, connecting: false} })); console.log('Connected and Protocol initialized'); } catch (e: any) { setError(`Connection failed: ${e.message}`); setState(prev => ({...prev, loading: {...prev.loading, connecting: false}})); } }, [bleClient, protocolService, stopScan, setError]); const disconnectDevice = useCallback(async () => { try { console.log('Disconnecting...'); await bleClient.disconnect(); protocolService.disconnect(); } catch (e: any) { console.error(`Disconnect failed: ${e.message}`); } }, [bleClient, protocolService]); const requestDeviceInfo = useCallback(async () => { if (!state.connectedDevice) return; try { console.log(`[${state.connectedDevice.id}] Requesting Device Info...`); await deviceInfoService.queryDeviceInfo(state.connectedDevice.id); console.log(`[${state.connectedDevice.id}] Device Info query request sent.`); } catch (e: any) { setError(`Request failed: ${e.message}`); } }, [deviceInfoService, state.connectedDevice, setError]); const queryActivationStatus = useCallback(async () => { if (!state.connectedDevice) return; try { console.log('Querying Activation Status...'); await deviceInfoService.queryActivationStatus(state.connectedDevice.id); } catch (e: any) { setError(`Query failed: ${e.message}`); } }, [deviceInfoService, state.connectedDevice, setError]); const queryDeviceVersion = useCallback(async () => { if (!state.connectedDevice) return; try { console.log(`[${state.connectedDevice.id}] Requesting Device Version...`); await deviceInfoService.queryDeviceVersion(state.connectedDevice.id); console.log(`[${state.connectedDevice.id}] Device Version query request sent`); } catch (e: any) { setError(`Request failed: ${e.message}`); } }, [state.connectedDevice, deviceInfoService, 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 convertToANIAsFile = useCallback(async (tempDir: Directory, media: ImagePicker.ImagePickerAsset): Promise => { const tempFile = new File(tempDir, `${media.fileName?.split('.')[0]}.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}`); } const content = await response.arrayBuffer() tempFile.write(new Uint8Array(content)); const tempFileInfo = tempFile.info() console.debug(`Converted video saved to ${tempFile.uri}, size : ${tempFileInfo.size} bytes`); return tempFile; }, []); const convertToANIAsBuffer = useCallback(async (tempDir: Directory, media: ImagePicker.ImagePickerAsset): Promise => { 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}`); } const content = await response.arrayBuffer() console.debug(`Converted video size : ${content.byteLength} bytes`); return content; }, []); const transferMedia = 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; console.debug(`[${state.connectedDevice.id}] processing ${medias.length} files...`); for (const media of medias) { if (media.type === 'video') { // let tempFile: File; let tempBuffer: ArrayBuffer; console.debug(`Converting video: ${media.fileName || 'video'}...`); setState(prev => ({...prev, loading: {...prev.loading, converting: true}})); // tempFile = await convertToANIAsFile(tempDir, media) tempBuffer = await convertToANIAsBuffer(tempDir, media) setState(prev => ({...prev, loading: {...prev.loading, converting: false}})); console.log(`Transferring converted file to device...`); await fileTransferService.transferFile(state.connectedDevice.id, tempBuffer, 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(); console.log(`Transfer successful`); } else if (media.type === 'image') { try { console.debug(`Processing image: ${media.fileName || 'image'}...`); // Check if it is a GIF const isGif = media.mimeType === 'image/gif' || media.uri.toLowerCase().endsWith('.gif'); if (isGif) { console.debug(`Converting GIF to ANI: ${media.fileName || 'gif'}...`); // const tempFile = await convertToANIAsFile(tempDir, media) const tempBuffer = await convertToANIAsBuffer(tempDir, media) console.log(`Transferring converted file to device...`); await fileTransferService.transferFile(state.connectedDevice.id, tempBuffer, COMMAND_TYPES.TRANSFER_ANI_VIDEO, (progress) => { setState(prev => ({...prev, transferProgress: progress * 100})); } ); console.log(`Transfer successful`); // tempFile.delete(); console.log(`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) { console.debug(`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; console.debug(`Conversion successful: ${imageUri}`); } console.log(`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. console.log(`Transfer successful`); } catch (e: any) { console.error(`Error: ${e.message}`); setError(e.message); } } else { console.log(`Unsupported media type: ${media.type}`); } } }, [state.connectedDevice, fileTransferService, convertToANIAsBuffer, setError]); const clearLogs = useCallback(() => setState(prev => ({...prev, logs: []})), []); return { ...state, startScan, stopScan, connectToDevice, disconnectDevice, requestDeviceInfo, queryDeviceVersion, queryActivationStatus, transferMedia, clearLogs, updateActivationTime: () => { }, sendIdentityCheck: () => { }, }; };