import * as Sentry from '@sentry/react-native' import { Directory, Paths } from 'expo-file-system' import { Alert, Linking, PermissionsAndroid, Platform } from 'react-native' import { ScanMode } from 'react-native-ble-plx' import { bleStore } from '@/stores' import { aniStorage } from '@/utils/aniStorage' import { extractCdnKey } from '@/utils/getCDNKey' import { BleClient } from '../core/BleClient' import { type BleDevice, type BleError, ConnectionState } from '../core/types' import { BLE_UUIDS, COMMAND_TYPES, EVENT_TYPES } from '../protocol/Constants' import { type BindingResponse, type DeleteFileResponse, type DeviceInfo, type PrepareTransferResponse, type UnBindResponse, } from '../protocol/types' import { BleProtocolService } from '../services/BleProtocolService' import { DeviceInfoService } from '../services/DeviceInfoService' import { FileTransferService } from '../services/FileTransferService' class BleManager { private static instance: BleManager private initialized = false // Services (单例) private bleClient = BleClient.getInstance() private deviceInfoService = DeviceInfoService.getInstance() private fileTransferService = FileTransferService.getInstance() private protocolService = BleProtocolService.getInstance() // 全局的 refs,避免重复创建 private pendingDevicesRef: BleDevice[] = [] private flushTimerRef: ReturnType | null = null private pendingPrepareTransfersRef = new Map< string, { resolve: (value: PrepareTransferResponse) => void reject: (reason?: any) => void timeoutId: ReturnType } >() static getInstance(): BleManager { if (!BleManager.instance) { BleManager.instance = new BleManager() } return BleManager.instance } initialize() { if (this.initialized || Platform.OS === 'web') { console.log('BLE Manager already initialized or on web platform') return } this.initialized = true console.log('Initializing BLE Manager...') this.setupEventListeners() } private setupEventListeners() { console.log('Setting up BLE event listeners...') const onConnectionStateChange = ({ deviceId, state: connState }: { deviceId: string; state: ConnectionState }) => { const isConnected = connState === ConnectionState.CONNECTED bleStore.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) => { this.setError(`Scan error: ${error.message}`) bleStore.setState((prev) => ({ ...prev, isScanning: false })) } const onDeviceInfo = (info: DeviceInfo) => { bleStore.setState((prev) => ({ ...prev, deviceInfo: info })) } const onVersionInfo = (version: string) => { bleStore.setState((prev) => ({ ...prev, version })) } const onBindStatus = (status: BindingResponse) => { const activated = status.success === 1 const contents = status.contents || [] console.log('onBindStatus-----', status) bleStore.setState((prev) => ({ ...prev, isActivated: activated, contents })) } const onUnBindStatus = (status: UnBindResponse) => { const activated = status.success === 1 bleStore.setState((prev) => ({ ...prev, isActivated: activated })) } const onDeleteFile = (response: DeleteFileResponse) => { if (response.success === 0) { console.log('File deleted successfully') } else { const errorMsg = response.success === 1 ? '删除失败' : response.success === 2 ? '文件不存在' : '未知错误' console.error('Delete file failed:', errorMsg) } } const onPrepareTransfer = (response: PrepareTransferResponse) => { console.log('Prepare transfer response:', response) const pending = this.pendingPrepareTransfersRef.get(response.key) if (pending) { clearTimeout(pending.timeoutId) this.pendingPrepareTransfersRef.delete(response.key) pending.resolve(response) return } if (response.status === 'ready') { console.log('Device is ready to receive file:', response.key) } else if (response.status === 'no_space') { console.warn('Device has no space for file:', response.key) } else if (response.status === 'duplicated') { console.warn('File already exists on device:', response.key) } } // 注册监听器 this.bleClient.addListener('connectionStateChange', onConnectionStateChange) this.bleClient.addListener('scanError', onScanError) this.deviceInfoService.addListener(EVENT_TYPES.DEVICE_INFO.name, onDeviceInfo) this.deviceInfoService.addListener(EVENT_TYPES.VERSION_INFO.name, onVersionInfo) this.deviceInfoService.addListener(EVENT_TYPES.BIND_DEVICE.name, onBindStatus) this.deviceInfoService.addListener(EVENT_TYPES.UNBIND_DEVICE.name, onUnBindStatus) this.deviceInfoService.addListener(EVENT_TYPES.DELETE_FILE.name, onDeleteFile) this.deviceInfoService.addListener(EVENT_TYPES.PREPARE_TRANSFER.name, onPrepareTransfer) } private setError(error: string | null) { bleStore.setState((prev) => ({ ...prev, error })) if (error) { console.error(`BLE ERROR: ${error}`) } } private logToSentry(operation: string) { try { const state = bleStore.state Sentry.captureMessage(`ble_${operation}`, { level: 'info', tags: { sessionId: bleStore.bleSessionId, component: 'BleManager', operation, 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('Sentry log failed:', error) } } private flushPendingDevices() { if (this.pendingDevicesRef.length === 0) return const devicesToAdd = [...this.pendingDevicesRef] this.pendingDevicesRef = [] this.logToSentry('device_discovered_batch') bleStore.setState((prev) => { const newDevices = devicesToAdd.filter((nd) => !prev.discoveredDevices?.some((ed) => ed?.id === nd?.id)) if (newDevices.length === 0) return prev console.debug(`Batch adding ${newDevices.length} devices`) return { ...prev, discoveredDevices: [...prev.discoveredDevices, ...newDevices] } }) } private queueDevice(device: BleDevice) { if (this.pendingDevicesRef?.some((d) => d?.id === device?.id)) return this.pendingDevicesRef.push(device) if (this.flushTimerRef) { clearTimeout(this.flushTimerRef) } this.flushTimerRef = setTimeout(() => this.flushPendingDevices(), 500) } private async requestBluetoothPermissions(): Promise { if (Platform.OS !== 'android') return true try { const sdk = Number(Platform.Version) || 0 const perms: string[] = [] if (sdk >= 31) { perms.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN) perms.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT) } else { perms.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION) } 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) 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(() => { this.setError('无法打开系统设置,请手动前往应用权限设置') }), }, ], { cancelable: true }, ) } return false } catch (err) { this.setError(`Permission request failed: ${err}`) return false } } // Public methods async startScan() { if (Platform.OS === 'web') return try { this.setError(null) console.log('Starting scan...') const hasPerms = await this.requestBluetoothPermissions() if (!hasPerms) return bleStore.setState((prev) => ({ ...prev, isScanning: true, discoveredDevices: [] })) try { const relevantDevices = await this.bleClient.getConnectedDevices([BLE_UUIDS.SERVICE]) if (relevantDevices.length > 0) { console.log(`Found ${relevantDevices.length} system-connected devices`) bleStore.setState((prev) => { const newDevices = relevantDevices.filter((nd) => !prev.discoveredDevices.some((ed) => ed.id === nd.id)) newDevices.forEach((d) => { d.connected = false }) return { ...prev, discoveredDevices: [...prev.discoveredDevices, ...newDevices] } }) } } catch (e) { console.warn('Failed to check connected devices', e) } await this.bleClient.startScan( [BLE_UUIDS.SERVICE], { scanMode: ScanMode.Balanced, allowDuplicates: false }, (result) => { const device = result.device as BleDevice const targetServiceUUID = BLE_UUIDS.SERVICE.toLowerCase() const hasTargetService = device.serviceUUIDs?.some((uuid) => uuid?.toLowerCase() === targetServiceUUID) if (!hasTargetService || !device?.id) return device.connected = false console.debug( `Device found: ${device.name} (${device.id}), serviceUUIDs: ${JSON.stringify(device.serviceUUIDs)}`, ) this.queueDevice(device) }, ) } catch (e: any) { this.setError(`Start scan failed: ${e.message}`) bleStore.setState((prev) => ({ ...prev, isScanning: false, discoveredDevices: [] })) Alert.alert( '手机蓝牙未开启', '检测到手机蓝牙未打开,请打开手机蓝牙再试。', [{ text: '确定', style: 'default' }], { cancelable: true }, ) } } stopScan() { if (this.flushTimerRef) { clearTimeout(this.flushTimerRef) this.flushTimerRef = null } this.pendingDevicesRef = [] this.bleClient.stopScan() bleStore.setState((prev) => ({ ...prev, isScanning: false })) console.log('Scan stopped') } async connectToDevice(device: BleDevice): Promise { this.stopScan() bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: true } })) console.log(`Connecting to ${device.name}...${device.id}`) if (!device?.id) { bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } })) return Promise.reject('Device ID is missing') } try { const connectedDevice = (await this.bleClient.connect(device.id)) as BleDevice if (!connectedDevice) { bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } })) return Promise.reject('Failed to connect to device') } connectedDevice.connected = true await this.protocolService.initialize(device.id) bleStore.setState((prev) => ({ ...prev, connectedDevice, isConnected: true, loading: { ...prev.loading, connecting: false }, })) this.logToSentry('device_connected') console.log('Connected and Protocol initialized') return connectedDevice } catch (e: any) { const errorMsg = e?.message || String(e) || 'Unknown connection error' console.error(`Connection failed: ${errorMsg}`, e) this.setError(`Connection failed: ${errorMsg}`) bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } })) throw new Error(errorMsg) } } async disconnectDevice() { try { console.log('Disconnecting...') await this.bleClient.disconnect() this.protocolService.disconnect() } catch (e: any) { console.error(`Disconnect failed: ${e.message}`) } } async getDeviceInfo() { const state = bleStore.state if (!state.connectedDevice) return try { console.log(`[${state.connectedDevice.id}] Requesting Device Info...`) await this.deviceInfoService.getDeviceInfo(state.connectedDevice.id) console.log(`[${state.connectedDevice.id}] Device Info query request sent.`) return Promise.resolve() } catch (e: any) { this.setError(`Request failed: ${e.message}`) return Promise.reject(e.message) } } async getDeviceVersion() { const state = bleStore.state if (!state.connectedDevice) return try { console.log(`[${state.connectedDevice.id}] Requesting Device Version...`) await this.deviceInfoService.getDeviceVersion(state.connectedDevice.id) console.log(`[${state.connectedDevice.id}] Device Version query request sent`) return Promise.resolve() } catch (e: any) { this.setError(`Request failed: ${e.message}`) return Promise.reject(e.message) } } async bindDevice(userId: string) { const state = bleStore.state if (!state.connectedDevice) return try { await this.deviceInfoService.bindDevice(state.connectedDevice.id, userId) return Promise.resolve() } catch (e: any) { this.setError(`Request failed: ${e.message}`) return Promise.reject(e.message) } } async unBindDevice(userId: string) { const state = bleStore.state if (!state.connectedDevice) return try { await this.deviceInfoService.unbindDevice(state.connectedDevice.id, userId) return Promise.resolve() } catch (e: any) { this.setError(`Request failed: ${e.message}`) return Promise.reject(e.message) } } async deleteFile(key: string) { const state = bleStore.state if (!state.connectedDevice) { const error = 'No device connected' this.setError(error) return Promise.reject(error) } try { await this.deviceInfoService.deleteFile(state.connectedDevice.id, key) return Promise.resolve() } catch (e: any) { this.setError(`Delete file failed: ${e.message}`) return Promise.reject(e.message) } } async transferMediaSingle(uriOrUrl: string) { try { const state = bleStore.state if (!state.connectedDevice) { this.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') let tempBuffer: ArrayBuffer const tempBufferExist = await aniStorage.has(uriOrUrl) if (!tempBufferExist) { console.debug(`Converting video: ${uriOrUrl || 'video'}...`) bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: true } })) tempBuffer = await this.convertImgToANIAsBuffer(uriOrUrl) await aniStorage.set(uriOrUrl, tempBuffer) } else { tempBuffer = await aniStorage.get(uriOrUrl) } const fileSizeByte = tempBuffer.byteLength const key = extractCdnKey(uriOrUrl) if (!key) { bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } })) return Promise.reject('Invalid uriOrUrl key') } const prepareResp = await this.prepareTransfer(key, fileSizeByte) if (prepareResp.status !== 'ready') { bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } })) return Promise.reject({ status: `${prepareResp.status}` }) } bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false, transferring: true } })) console.log(`Transferring converted file to device...`) await this.fileTransferService.transferFile( state.connectedDevice.id, tempBuffer, COMMAND_TYPES.TRANSFER_ANI_VIDEO, (progress) => { bleStore.setState((prev) => ({ ...prev, transferProgress: progress * 100 })) }, ) bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, transferring: false } })) console.log(`Transfer successful`) return Promise.resolve() } catch (error: any) { bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, transferring: false } })) console.log(`Transfer failed: ${error?.message}`) return Promise.reject(error?.message) } } private async prepareTransfer(key: string, size: number): Promise { const state = bleStore.state if (!state.connectedDevice) { const error = 'No device connected' this.setError(error) return Promise.reject(error) } try { const responsePromise = new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { this.pendingPrepareTransfersRef.delete(key) reject(new Error('Prepare transfer timeout')) }, 10e3) this.pendingPrepareTransfersRef.set(key, { resolve, reject, timeoutId }) }) await this.deviceInfoService.prepareTransfer(state.connectedDevice.id, key, size) return await responsePromise } catch (e: any) { this.setError(`Prepare transfer failed: ${e.message}`) return Promise.reject(e.message) } } private async convertImgToANIAsBuffer(uriOrUrl: string): Promise { const res = await fetch(uriOrUrl) if (!res.ok) { return Promise.reject(`Failed to fetch media: ${res.status}`) } const blob = await res.blob() const name = uriOrUrl.split('/').pop()?.split('?')[0] || 'media.bin' const type = blob.type || this.guessMimeType(name) const formData = new FormData() formData.append('file', { uri: uriOrUrl, name, type } as any) const aniProd = 'https://bowongai-prod--ani-video-converter-fastapi-app.modal.run/api/convert/ani' const response = await fetch(aniProd, { method: 'POST', body: formData }) if (!response.ok) { throw new Error(`Conversion failed: ${response.status}`) } const content = await response.arrayBuffer() console.debug(`Converted video size: ${content.byteLength} bytes`) return content } private 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' } } clearLogs() { this.setError(null) } destroy() { if (this.flushTimerRef) { clearTimeout(this.flushTimerRef) } this.pendingPrepareTransfersRef.forEach(({ timeoutId }) => { clearTimeout(timeoutId) }) this.pendingPrepareTransfersRef.clear() this.initialized = false } } // Global instance export const bleManager = BleManager.getInstance()