997 lines
35 KiB
TypeScript
997 lines
35 KiB
TypeScript
import * as Sentry from '@sentry/react-native'
|
||
import { Directory, File, Paths } from 'expo-file-system'
|
||
import * as ImageManipulator from 'expo-image-manipulator'
|
||
import * as ImagePicker from 'expo-image-picker'
|
||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { Alert, Linking, PermissionsAndroid, Platform } from 'react-native'
|
||
import { ScanMode } from 'react-native-ble-plx'
|
||
|
||
import { aniStorage } from '@/utils/aniStorage'
|
||
import { extractCdnKey } from '@/utils/getCDNKey'
|
||
|
||
import { DeviceInfoService } from '../services/DeviceInfoService'
|
||
import { FileTransferService } from '../services/FileTransferService'
|
||
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'
|
||
|
||
interface BleState {
|
||
isScanning: boolean
|
||
isConnected: boolean
|
||
connectedDevice: BleDevice | null
|
||
deviceInfo: DeviceInfo | null
|
||
version: string
|
||
isActivated: boolean
|
||
transferProgress: number
|
||
discoveredDevices: BleDevice[]
|
||
contents: string[]
|
||
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 pendingDevicesRef = useRef<BleDevice[]>([])
|
||
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const pendingPrepareTransfersRef = useRef<
|
||
Map<
|
||
string,
|
||
{
|
||
resolve: (value: PrepareTransferResponse) => void
|
||
reject: (reason?: any) => void
|
||
timeoutId: ReturnType<typeof setTimeout>
|
||
}
|
||
>
|
||
>(new Map())
|
||
|
||
const [state, setState2] = useState<BleState>({
|
||
isScanning: false,
|
||
isConnected: false,
|
||
connectedDevice: null,
|
||
deviceInfo: null,
|
||
version: '',
|
||
isActivated: false,
|
||
transferProgress: 0,
|
||
discoveredDevices: [],
|
||
contents: [],
|
||
loading: {
|
||
connecting: false,
|
||
querying: false,
|
||
converting: false,
|
||
transferring: false,
|
||
},
|
||
error: null,
|
||
})
|
||
|
||
const setState = useCallback((updater: (prev: BleState) => BleState) => {
|
||
try {
|
||
setState2(updater)
|
||
} catch (error) {
|
||
console.error('State update failed:', error)
|
||
}
|
||
}, [])
|
||
|
||
// 仅在关键操作时记录 Sentry 日志
|
||
const logToSentry = useCallback(
|
||
(operation: string) => {
|
||
try {
|
||
Sentry.captureMessage(`ble_${operation}`, {
|
||
level: 'info',
|
||
tags: {
|
||
sessionId: bleSeesionId,
|
||
component: 'useBleExplorer',
|
||
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)
|
||
}
|
||
},
|
||
[
|
||
bleSeesionId,
|
||
state.connectedDevice,
|
||
state.isConnected,
|
||
state.isActivated,
|
||
state.version,
|
||
state.deviceInfo,
|
||
state.transferProgress,
|
||
state.loading,
|
||
state.isScanning,
|
||
state.discoveredDevices.length,
|
||
state.error,
|
||
],
|
||
)
|
||
|
||
// 批量刷新设备列表到 state(防抖,每 500ms 最多更新一次)
|
||
const flushPendingDevices = useCallback(() => {
|
||
if (pendingDevicesRef.current.length === 0) return
|
||
|
||
const devicesToAdd = [...pendingDevicesRef.current]
|
||
pendingDevicesRef.current = []
|
||
|
||
logToSentry('device_discovered_batch')
|
||
|
||
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] }
|
||
})
|
||
}, [setState])
|
||
|
||
// 添加设备到待处理队列(防抖更新)
|
||
const queueDevice = useCallback(
|
||
(device: BleDevice) => {
|
||
// 检查是否已在队列中
|
||
if (pendingDevicesRef.current?.some((d) => d?.id === device?.id)) return
|
||
|
||
pendingDevicesRef.current.push(device)
|
||
|
||
// 清除之前的定时器,设置新的
|
||
if (flushTimerRef.current) {
|
||
clearTimeout(flushTimerRef.current)
|
||
}
|
||
flushTimerRef.current = setTimeout(flushPendingDevices, 500)
|
||
},
|
||
[flushPendingDevices],
|
||
)
|
||
|
||
// 清理定时器
|
||
useEffect(() => {
|
||
return () => {
|
||
if (flushTimerRef.current) {
|
||
clearTimeout(flushTimerRef.current)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
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: 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
|
||
const contents = status.contents || []
|
||
setState((prev) => ({ ...prev, isActivated: activated, contents }))
|
||
}
|
||
|
||
const onUnBindStatus = (status: UnBindResponse) => {
|
||
const activated = status.success === 1
|
||
setState((prev) => ({ ...prev, isActivated: activated }))
|
||
}
|
||
|
||
const onDeleteFile = (response: DeleteFileResponse) => {
|
||
// 删除成功后,从 contents 中移除该文件
|
||
if (response.success === 0) {
|
||
// 暂时不更新 contents,等待下次绑定时获取最新列表
|
||
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)
|
||
|
||
// 检查是否有对应的待处理 Promise
|
||
const pending = pendingPrepareTransfersRef.current.get(response.key)
|
||
if (pending) {
|
||
clearTimeout(pending.timeoutId)
|
||
pendingPrepareTransfersRef.current.delete(response.key)
|
||
pending.resolve(response)
|
||
return // 早期返回,避免继续处理
|
||
}
|
||
|
||
// 如果没有待处理 Promise,才做其他处理
|
||
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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
deviceInfoService.addListener(EVENT_TYPES.DELETE_FILE.name, onDeleteFile)
|
||
deviceInfoService.addListener(EVENT_TYPES.PREPARE_TRANSFER.name, onPrepareTransfer)
|
||
|
||
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)
|
||
deviceInfoService.removeListener(EVENT_TYPES.DELETE_FILE.name, onDeleteFile)
|
||
deviceInfoService.removeListener(EVENT_TYPES.PREPARE_TRANSFER.name, onPrepareTransfer)
|
||
}
|
||
}, [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],
|
||
{ scanMode: ScanMode.Balanced, allowDuplicates: false },
|
||
(result) => {
|
||
const device = result.device as BleDevice
|
||
|
||
// 手动过滤:必须包含目标 serviceUUID(BLE 库在某些平台不严格过滤)
|
||
const targetServiceUUID = BLE_UUIDS.SERVICE.toLowerCase()
|
||
const hasTargetService = device.serviceUUIDs?.some((uuid) => uuid?.toLowerCase() === targetServiceUUID)
|
||
|
||
// console.log('startScan--------', device)
|
||
|
||
if (!hasTargetService || !device?.id) {
|
||
return
|
||
}
|
||
|
||
device.connected = false
|
||
console.debug(
|
||
`Device found: ${device.name} (${device.id}), serviceUUIDs: ${JSON.stringify(device.serviceUUIDs)}`,
|
||
)
|
||
|
||
// 使用队列批量更新,避免频繁触发 setState
|
||
queueDevice(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, queueDevice])
|
||
|
||
const stopScan = useCallback(() => {
|
||
// 清理待处理的设备队列
|
||
if (flushTimerRef.current) {
|
||
clearTimeout(flushTimerRef.current)
|
||
flushTimerRef.current = null
|
||
}
|
||
pendingDevicesRef.current = []
|
||
|
||
bleClient.stopScan()
|
||
setState((prev) => ({ ...prev, isScanning: false }))
|
||
console.log('Scan stopped')
|
||
}, [bleClient, setState])
|
||
|
||
const connectToDevice = useCallback(
|
||
async (device: BleDevice): Promise<BleDevice> => {
|
||
stopScan()
|
||
setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: true } }))
|
||
console.log(`Connecting to ${device.name}...${device.id}`)
|
||
|
||
if (!device?.id) {
|
||
setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } }))
|
||
return Promise.reject('Device ID is missing')
|
||
}
|
||
|
||
try {
|
||
const connectedDevice = (await bleClient.connect(device.id)) as BleDevice
|
||
if (!connectedDevice) {
|
||
setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } }))
|
||
return Promise.reject('Failed to connect to device')
|
||
}
|
||
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 },
|
||
}))
|
||
|
||
// 记录设备连接成功日志
|
||
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)
|
||
setError(`Connection failed: ${errorMsg}`)
|
||
setState((prev) => ({ ...prev, loading: { ...prev.loading, connecting: false } }))
|
||
throw new Error(errorMsg)
|
||
}
|
||
},
|
||
[bleClient, protocolService, stopScan, setError, setState, logToSentry],
|
||
)
|
||
|
||
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 deleteFile = useCallback(
|
||
async (key: string) => {
|
||
if (!state.connectedDevice) {
|
||
const error = 'No device connected'
|
||
setError(error)
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
try {
|
||
await deviceInfoService.deleteFile(state.connectedDevice.id, key)
|
||
return Promise.resolve()
|
||
} catch (e: any) {
|
||
setError(`Delete file failed: ${e.message}`)
|
||
return Promise.reject(e.message)
|
||
}
|
||
},
|
||
[state.connectedDevice, deviceInfoService, setError],
|
||
)
|
||
|
||
const prepareTransfer = useCallback(
|
||
async (key: string, size: number): Promise<PrepareTransferResponse> => {
|
||
if (!state.connectedDevice) {
|
||
const error = 'No device connected'
|
||
setError(error)
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
try {
|
||
// 创建 Promise 并存储到 Map
|
||
const responsePromise = new Promise<PrepareTransferResponse>((resolve, reject) => {
|
||
const timeoutId = setTimeout(() => {
|
||
pendingPrepareTransfersRef.current.delete(key)
|
||
reject(new Error('Prepare transfer timeout'))
|
||
}, 10000) // 10秒超时
|
||
|
||
// 先注册 Promise,再发送请求
|
||
pendingPrepareTransfersRef.current.set(key, { resolve, reject, timeoutId })
|
||
})
|
||
|
||
// ✅ 关键:先注册 Promise,再发送请求
|
||
// 这样即使设备立即返回,Promise 也已经准备好了
|
||
await deviceInfoService.prepareTransfer(state.connectedDevice.id, key, size)
|
||
|
||
// 等待响应
|
||
return await responsePromise
|
||
} catch (e: any) {
|
||
setError(`Prepare transfer 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 transferJPG = useCallback(
|
||
async (uriOrUrl: string) => {
|
||
try {
|
||
console.debug(`transferJPG processing image: ${uriOrUrl}...`)
|
||
|
||
let imageUri = uriOrUrl
|
||
|
||
const context = ImageManipulator.ImageManipulator.manipulate(imageUri)
|
||
// .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(`ImageManipulator Conversion successful: ${imageUri}`)
|
||
return imageUri
|
||
} catch (error: any) {
|
||
console.log(`Image processing failed: ${error.message}`)
|
||
return Promise.reject(error.message)
|
||
}
|
||
},
|
||
[state.connectedDevice, 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
|
||
}, [])
|
||
|
||
// 单个上传接口
|
||
// 视频直接转ani上传,图片转jpg上传
|
||
// 本地缓存ani文件,避免重复转换
|
||
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')
|
||
|
||
let tempBuffer: ArrayBuffer
|
||
const tempBufferExist = await aniStorage.has(uriOrUrl)
|
||
console.log('tempBufferExist-----', tempBufferExist)
|
||
|
||
if (!tempBufferExist) {
|
||
console.debug(`Converting video: ${uriOrUrl || 'video'}...`)
|
||
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: true } }))
|
||
tempBuffer = await convertImgToANIAsBuffer(uriOrUrl)
|
||
|
||
await aniStorage.set(uriOrUrl, tempBuffer)
|
||
} else {
|
||
tempBuffer = await aniStorage.get(uriOrUrl)
|
||
}
|
||
|
||
const fileSizeByte = tempBuffer.byteLength
|
||
|
||
const key = extractCdnKey(uriOrUrl)
|
||
if (!key) {
|
||
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } }))
|
||
return Promise.reject('Invalid uriOrUrl key')
|
||
}
|
||
const prepareResp = await prepareTransfer(key, fileSizeByte)
|
||
if (prepareResp.status !== 'ready') {
|
||
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } }))
|
||
console.log('prepareResp not ready-----', prepareResp)
|
||
return Promise.reject({ status: `${prepareResp.status}` })
|
||
}
|
||
console.log('prepareResp-----', prepareResp)
|
||
|
||
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, convertImgToANIAsBuffer, setError],
|
||
)
|
||
|
||
const clearLogs = useCallback(() => setState((prev) => ({ ...prev, logs: [] })), [])
|
||
|
||
return {
|
||
...state,
|
||
startScan,
|
||
stopScan,
|
||
connectToDevice,
|
||
disconnectDevice,
|
||
getDeviceInfo,
|
||
getDeviceVersion,
|
||
bindDevice,
|
||
unBindDevice,
|
||
deleteFile,
|
||
prepareTransfer,
|
||
transferMedia,
|
||
transferMediaSingle,
|
||
clearLogs,
|
||
bleSeesionId,
|
||
}
|
||
}
|