expo-duooomi-app/ble/managers/bleManager.ts

700 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
type VersionInfo,
} 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<typeof setTimeout> | null = null
private pendingPrepareTransfersRef = new Map<
string,
{
resolve: (value: PrepareTransferResponse) => void
reject: (reason?: any) => void
timeoutId: ReturnType<typeof setTimeout>
}
>()
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 = (versionInfo: VersionInfo) => {
bleStore.setState((prev) => ({ ...prev, version: versionInfo?.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<boolean> {
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()
console.log('Bluetooth permissions:', hasPerms)
if (!hasPerms) {
console.log('Permissions denied, stopping scan')
bleStore.setState((prev) => ({ ...prev, isScanning: false }))
return
}
console.log('Setting isScanning to true')
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 currentConnectedId = prev.connectedDevice?.id
const newDevices = relevantDevices.filter((nd) => !prev.discoveredDevices.some((ed) => ed.id === nd.id))
// 正确设置设备连接状态
newDevices.forEach((d) => {
d.connected = d.id === currentConnectedId
})
// 同时更新现有设备列表中的连接状态
const updatedExistingDevices = prev.discoveredDevices.map((existingDevice) => {
const systemConnectedDevice = relevantDevices.find((rd) => rd.id === existingDevice.id)
if (systemConnectedDevice) {
const newDevice = Object.assign(
Object.create(Object.getPrototypeOf(existingDevice)),
existingDevice,
) as BleDevice
newDevice.connected = existingDevice.id === currentConnectedId
return newDevice
}
return existingDevice
})
return { ...prev, discoveredDevices: [...updatedExistingDevices, ...newDevices] }
})
}
} catch (e) {
console.warn('Failed to check connected devices', e)
}
console.log('Starting BLE scan with service UUID:', BLE_UUIDS.SERVICE)
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)
},
)
console.log('BLE scan started successfully')
} catch (e: any) {
console.error('Start scan error:', e)
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<BleDevice> {
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) => {
// 更新设备列表中对应设备的连接状态
const updatedDevices = prev.discoveredDevices.map((d) => {
if (d.id === device.id) {
const newDevice = Object.assign(Object.create(Object.getPrototypeOf(d)), d) as BleDevice
newDevice.connected = true
return newDevice
}
return d
})
return {
...prev,
connectedDevice,
isConnected: true,
discoveredDevices: updatedDevices,
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...')
// ✅ 修复:先断开 BLE 连接,让系统监听器处理状态更新
await this.bleClient.disconnect()
console.log('Disconnected successfully')
} catch (error: any) {
// ✅ 修复:忽略断开连接时的常见错误
const errorMsg = error?.message || String(error)
if (
errorMsg.includes('Disconnected') ||
errorMsg.includes('GATT_SUCCESS') ||
errorMsg.includes('Unknown error')
) {
console.debug('Disconnect completed with expected error:', errorMsg)
} else {
console.error('Disconnect failed:', errorMsg)
}
} finally {
// ✅ 修复:延迟清理协议服务,确保状态监听器先执行
setTimeout(() => {
this.protocolService.disconnect()
// 只清理业务相关状态,连接状态由监听器处理
bleStore.setState((prev) => ({
...prev,
// discoveredDevices: [], // 清空设备发现列表,断开后需要重新扫描
deviceInfo: null,
version: '',
isActivated: false,
transferProgress: 0,
contents: [],
loading: {
...prev.loading,
connecting: false,
querying: false,
converting: false,
transferring: false,
},
error: null,
}))
}, 200) // 给监听器足够时间处理连接状态变化
}
}
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')
}
// 检查是否已经有文件在上传
if (state.loading.transferring) {
const errorMsg = '已有文件正在上传中,请等待当前文件上传完成'
this.setError(errorMsg)
return Promise.reject(errorMsg)
}
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)
console.log('prepareResp:=-------------', prepareResp)
if (prepareResp.status !== 'ready') {
bleStore.setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } }))
const message = prepareResp.status === 'no_space' ? '设备存储空间不足' : `文件已存在于设备上`
return Promise.reject(message)
}
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<PrepareTransferResponse> {
const state = bleStore.state
if (!state.connectedDevice) {
const error = 'No device connected'
this.setError(error)
return Promise.reject(error)
}
try {
const responsePromise = new Promise<PrepareTransferResponse>((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<ArrayBuffer> {
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()