forked from yudi_xiao/expo-ble-app-demo
497 lines
21 KiB
TypeScript
497 lines
21 KiB
TypeScript
import {useCallback, useEffect, useState} from 'react';
|
|
import {Alert, PermissionsAndroid, Platform} from 'react-native';
|
|
import {
|
|
ActivationStatus,
|
|
BLE_UUIDS,
|
|
BleClient,
|
|
BleDevice,
|
|
BleError,
|
|
BleProtocolService,
|
|
ConnectionState,
|
|
DeviceInfo, COMMAND_TYPES,
|
|
} 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';
|
|
|
|
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 [state, setState] = 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 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 permissions = [
|
|
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
|
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
|
];
|
|
const results = await PermissionsAndroid.requestMultiple(permissions);
|
|
const allGranted = Object.values(results).every(result => result === PermissionsAndroid.RESULTS.GRANTED);
|
|
if (!allGranted) {
|
|
const denied = Object.entries(results)
|
|
.filter(([_, result]) => result !== PermissionsAndroid.RESULTS.GRANTED)
|
|
.map(([permission]) => permission.split('.').pop());
|
|
setError(`Bluetooth permissions required: ${denied.join(', ')}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
setError(`Permission request failed: ${error}`);
|
|
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}));
|
|
console.log(`Device info received: ${info.devname}`);
|
|
};
|
|
|
|
const onActivationStatus = (status: ActivationStatus) => {
|
|
const activated = status.state === 1;
|
|
setState(prev => ({...prev, isActivated: activated}));
|
|
console.log(`Activation status: ${activated ? 'Activated' : 'Not activated'}`);
|
|
};
|
|
|
|
deviceInfoService.addListener('deviceInfo', onDeviceInfo);
|
|
deviceInfoService.addListener('activationStatus', onActivationStatus);
|
|
|
|
return () => {
|
|
bleClient.removeListener('connectionStateChange', onConnectionStateChange);
|
|
bleClient.removeListener('scanError', onScanError);
|
|
deviceInfoService.removeListener('deviceInfo', onDeviceInfo);
|
|
deviceInfoService.removeListener('activationStatus', onActivationStatus);
|
|
};
|
|
}, [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}`);
|
|
}
|
|
}, [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}...`);
|
|
|
|
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 requestDeviceInfo = useCallback(async () => {
|
|
if (!state.connectedDevice) return;
|
|
try {
|
|
console.log(`[${state.connectedDevice.id}] Requesting Device Info...`);
|
|
await deviceInfoService.queryDeviceInfo(state.connectedDevice.id);
|
|
console.log(`[${state.connectedDevice.id}] Device Info query request sent.`);
|
|
} catch (e: any) {
|
|
setError(`Request failed: ${e.message}`);
|
|
}
|
|
}, [deviceInfoService, state.connectedDevice, setError]);
|
|
|
|
const queryActivationStatus = useCallback(async () => {
|
|
if (!state.connectedDevice) return;
|
|
try {
|
|
console.log('Querying Activation Status...');
|
|
await deviceInfoService.queryActivationStatus(state.connectedDevice.id);
|
|
} catch (e: any) {
|
|
setError(`Query failed: ${e.message}`);
|
|
}
|
|
}, [deviceInfoService, state.connectedDevice, setError]);
|
|
|
|
const queryDeviceVersion = useCallback(async () => {
|
|
if (!state.connectedDevice) return;
|
|
try {
|
|
console.log(`[${state.connectedDevice.id}] Requesting Device Version...`);
|
|
await deviceInfoService.queryDeviceVersion(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 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 clearLogs = useCallback(() => setState(prev => ({...prev, logs: []})), []);
|
|
|
|
return {
|
|
...state,
|
|
startScan,
|
|
stopScan,
|
|
connectToDevice,
|
|
disconnectDevice,
|
|
requestDeviceInfo,
|
|
queryDeviceVersion,
|
|
queryActivationStatus,
|
|
transferMedia,
|
|
clearLogs,
|
|
updateActivationTime: () => {
|
|
},
|
|
sendIdentityCheck: () => {
|
|
},
|
|
};
|
|
};
|