expo-duooomi-app/ble/hooks/useBleExplorer.ts

732 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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<BleState>({
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<boolean> => {
if (Platform.OS !== 'android') return true
try {
const sdk = Number(Platform.Version) || 0
const perms: Array<string> = []
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<File> => {
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<ArrayBuffer> => {
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<ArrayBuffer> => {
// 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,
}
}