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

611 lines
21 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,
} 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 = (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<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()
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<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) => ({
...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<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()