import { useCallback, useEffect, useRef, useState } from 'react' import { Alert, PermissionsAndroid, Platform, Linking } from 'react-native' import { BindingResponse, BLE_UUIDS, BleClient, BleDevice, BleError, BleProtocolService, ConnectionState, DeviceInfo, COMMAND_TYPES, EVENT_TYPES, UnBindResponse, } 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' import * as Sentry from '@sentry/react-native' 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 [bleSeesionId, setBleSessionId] = useState(`ble-session-${Date.now().toString()}`) const [state, setState2] = 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 setState = useCallback( (updater: (prev: BleState) => BleState) => { try { setState2(updater) Sentry.captureException(new Error('ble'), { tags: { sessionId: bleSeesionId, component: 'useBleExplorer', operation: 'setState', has_device: !!state.connectedDevice, is_scanning: state.isScanning, }, contexts: { ble_state: { connection: { connected: state.isConnected, device_id: state.connectedDevice?.id, device_name: state.connectedDevice?.name, }, device: { activated: state.isActivated, version: state.version, free_space: state.deviceInfo?.freespace, brand: state.deviceInfo?.brand, }, transfer: { progress: state.transferProgress, converting: state.loading.converting, transferring: state.loading.transferring, }, discovery: { scanning: state.isScanning, devices_found: state.discoveredDevices.length, }, }, }, extra: { error_message: state.error, loading_states: state.loading, }, }) } catch (error) { console.error('State update failed:', error) } }, [ state.connectedDevice, state.isConnected, state.isActivated, state.version, state.deviceInfo, state.transferProgress, state.loading, state.isScanning, state.discoveredDevices.length, state.error, ], ) 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 sdk = Number(Platform.Version) || 0 const perms: Array = [] if (sdk >= 31) { perms.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN) perms.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT) } else { perms.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION) } // 先预检,有些 ROM(如 MIUI)会在 check 阶段就返回 false const preChecks = await Promise.all(perms.map((p) => PermissionsAndroid.check(p as any))) const needRequest = perms.filter((_, i) => !preChecks[i]) if (needRequest.length === 0) return true const results = await PermissionsAndroid.requestMultiple(needRequest as any) console.log('results-----------', results) const allGranted = Object.values(results).every((r) => r === PermissionsAndroid.RESULTS.GRANTED) if (allGranted) return true const neverAsk = Object.values(results).some((r) => r === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN) if (neverAsk) { Alert.alert( '需要权限', '检测到蓝牙权限已被禁止且选择了不再询问。请在系统设置中为应用手动开启蓝牙和位置权限。', [ { text: '取消', style: 'cancel' }, { text: '打开设置', onPress: () => Linking.openSettings().catch(() => { setError('无法打开系统设置,请手动前往应用权限设置') }), }, ], { cancelable: true }, ) } const denied = Object.entries(results) .filter(([_, r]) => r !== PermissionsAndroid.RESULTS.GRANTED) .map(([p]) => p.split('.').pop()) setError(`Bluetooth permissions required: ${denied.join(', ')}`) return false } catch (err) { setError(`Permission request failed: ${err}`) 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 })) } const onVersionInfo = (version: string) => { setState((prev) => ({ ...prev, version: version })) } const onBindStatus = (status: BindingResponse) => { const activated = status.success === 1 setState((prev) => ({ ...prev, isActivated: activated })) } const onUnBindStatus = (status: UnBindResponse) => { const activated = status.success === 1 setState((prev) => ({ ...prev, isActivated: activated })) } deviceInfoService.addListener(EVENT_TYPES.DEVICE_INFO.name, onDeviceInfo) deviceInfoService.addListener(EVENT_TYPES.VERSION_INFO.name, onVersionInfo) deviceInfoService.addListener(EVENT_TYPES.BIND_DEVICE.name, onBindStatus) deviceInfoService.addListener(EVENT_TYPES.UNBIND_DEVICE.name, onUnBindStatus) return () => { bleClient.removeListener('connectionStateChange', onConnectionStateChange) bleClient.removeListener('scanError', onScanError) deviceInfoService.removeListener(EVENT_TYPES.DEVICE_INFO.name, onDeviceInfo) deviceInfoService.removeListener(EVENT_TYPES.VERSION_INFO.name, onVersionInfo) deviceInfoService.removeListener(EVENT_TYPES.BIND_DEVICE.name, onBindStatus) deviceInfoService.removeListener(EVENT_TYPES.UNBIND_DEVICE.name, onUnBindStatus) } }, [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}`) console.log('scan error --------------', e) // 解决安卓31以下爱无法检测到蓝牙是否开启的问题 setState((prev) => ({ ...prev, isScanning: false, discoveredDevices: [] })) Alert.alert('手机蓝牙未开启', '检测到手机蓝牙未打开,请打开手机蓝牙再试。', [{ text: '确定', style: 'default' }], { cancelable: true }) } }, [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}...${device.id}`) if (!device?.id) return 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 getDeviceInfo = useCallback(async () => { if (!state.connectedDevice) return try { console.log(`[${state.connectedDevice.id}] Requesting Device Info...`) await deviceInfoService.getDeviceInfo(state.connectedDevice.id) console.log(`[${state.connectedDevice.id}] Device Info query request sent.`) return Promise.resolve() } catch (e: any) { setError(`Request failed: ${e.message}`) return Promise.reject(e.message) } }, [deviceInfoService, state.connectedDevice, setError]) const getDeviceVersion = useCallback(async () => { if (!state.connectedDevice) return try { console.log(`[${state.connectedDevice.id}] Requesting Device Version...`) await deviceInfoService.getDeviceVersion(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 bindDevice = useCallback( async (userId: string) => { if (!state.connectedDevice) return try { await deviceInfoService.bindDevice(state.connectedDevice.id, userId) return Promise.resolve() } catch (e: any) { setError(`Request failed: ${e.message}`) return Promise.reject(e.message) } }, [state.connectedDevice, deviceInfoService, setError], ) const unBindDevice = useCallback( async (userId: string) => { if (!state.connectedDevice) return try { await deviceInfoService.unbindDevice(state.connectedDevice.id, userId) return Promise.resolve() } catch (e: any) { setError(`Request failed: ${e.message}`) return Promise.reject(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).resize({ width: BLE_UUIDS.SCREEN_SIZE, height: BLE_UUIDS.SCREEN_SIZE, }) 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 transferMediaSingle = useCallback( async (uriOrUrl: string) => { try { if (!state.connectedDevice) { setError('No device connected') return Promise.reject('No device connected') } const tempDir = new Directory(Paths.cache, 'anis') if (!tempDir.exists) tempDir.create() if (!uriOrUrl) return Promise.reject('No uriOrUrl provided') console.debug(`[${state.connectedDevice.id}] processing ${uriOrUrl}...`) // let tempFile: File; let tempBuffer: ArrayBuffer console.debug(`Converting video: ${uriOrUrl || 'video'}...`) setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: true } })) // tempFile = await convertToANIAsFile(tempDir, media) tempBuffer = await convertImgToANIAsBuffer(uriOrUrl) setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false, transferring: true } })) 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 })) }) setState((prev) => ({ ...prev, loading: { ...prev.loading, transferring: false } })) console.log(`Transfer successful`) return Promise.resolve() } catch (error: any) { setState((prev) => ({ ...prev, loading: { ...prev.loading, transferring: false } })) console.log(`Transfer failed: ${error.message}`) return Promise.reject(error.message) } }, [state.connectedDevice, fileTransferService, convertToANIAsBuffer, setError], ) function guessMimeType(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase() switch (ext) { case 'mp4': return 'video/mp4' case 'mov': return 'video/quicktime' case 'webm': return 'video/webm' case 'jpg': case 'jpeg': return 'image/jpeg' case 'png': return 'image/png' default: return 'application/octet-stream' } } const convertImgToANIAsBuffer = useCallback(async (uriOrUrl: string): Promise => { // 1️⃣ 用 fetch 统一拿 Blob(本地 / 网络都行) const res = await fetch(uriOrUrl) if (!res.ok) { // throw new Error(`Failed to fetch media: ${res.status}`) return Promise.reject(`Failed to fetch media: ${res.status}`) } const blob = await res.blob() // 2️⃣ 从 url / uri 推断文件名 const name = uriOrUrl.split('/').pop()?.split('?')[0] || 'media.bin' // 3️⃣ MIME 类型兜底 const type = blob.type || guessMimeType(name) // 4️⃣ 构造 FormData const formData = new FormData() formData.append('file', { uri: uriOrUrl, name, type, } as any) // 5️⃣ 上传到 ANI 服务 const aniProd = 'https://bowongai-prod--ani-video-converter-fastapi-app.modal.run/api/convert/ani' const response = await fetch(aniProd, { method: 'POST', body: formData, // headers: { // Accept: 'application/json', // }, }) if (!response.ok) { throw new Error(`Conversion failed: ${response.status}`) } // 6️⃣ 返回 ArrayBuffer const content = await response.arrayBuffer() console.debug(`Converted video size: ${content.byteLength} bytes`) return content }, []) const clearLogs = useCallback(() => setState((prev) => ({ ...prev, logs: [] })), []) return { ...state, startScan, stopScan, connectToDevice, disconnectDevice, getDeviceInfo, getDeviceVersion, bindDevice, unBindDevice, transferMedia, transferMediaSingle, clearLogs, bleSeesionId, } }