feat: 更新 APP 版本号,优化文件传输逻辑,添加 CDN key 提取功能

This commit is contained in:
康猛 2026-01-07 11:54:36 +08:00
parent 0e647b033e
commit 50b401b342
5 changed files with 142 additions and 37 deletions

View File

@ -8,6 +8,6 @@ export const ANDROID_ID = 'com.duomi.duooomi'
export const IOS_ID = ANDROID_ID export const IOS_ID = ANDROID_ID
export const IOS_UNIVERSAL_LINK = 'duooomi.bowong.cn' export const IOS_UNIVERSAL_LINK = 'duooomi.bowong.cn'
export const OWNER_ID = 'x3xbTCWf7dbtWu4gGU2TeI054L77xtkt' export const OWNER_ID = 'x3xbTCWf7dbtWu4gGU2TeI054L77xtkt'
export const APP_VERSION = 'dev202512311544' export const APP_VERSION = 'dev202601071100'
export const ALIPAY_SCHEMA = 'alipay2021006119657394' export const ALIPAY_SCHEMA = 'alipay2021006119657394'
export const ALIPAY_SCHEMA_SANDBOX = '9021000158673972' export const ALIPAY_SCHEMA_SANDBOX = '9021000158673972'

View File

@ -4,6 +4,7 @@ import * as FileSystem from 'expo-file-system/legacy'
import { Image } from 'expo-image' import { Image } from 'expo-image'
import * as ImagePicker from 'expo-image-picker' import * as ImagePicker from 'expo-image-picker'
import * as MediaLibrary from 'expo-media-library' import * as MediaLibrary from 'expo-media-library'
import { observer } from 'mobx-react-lite'
import React, { useState } from 'react' import React, { useState } from 'react'
import { Button, StyleSheet, TextInput } from 'react-native' import { Button, StyleSheet, TextInput } from 'react-native'
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'
@ -14,9 +15,12 @@ import { APP_VERSION } from '@/app.constants'
import { BLE_UUIDS, PROTOCOL_VERSION, useBleExplorer } from '@/ble' import { BLE_UUIDS, PROTOCOL_VERSION, useBleExplorer } from '@/ble'
import { ThemedText } from '@/components/themed-text' import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view' import { ThemedView } from '@/components/themed-view'
import { userStore } from '@/stores'
import { uploadFile } from '@/utils' import { uploadFile } from '@/utils'
import { buildCdnUrl } from '@/utils/getCDNKey'
export default function TabTwoScreen() { export default observer(function TabTwoScreen() {
const { user } = userStore
const { const {
isScanning, isScanning,
isConnected, isConnected,
@ -45,7 +49,7 @@ export default function TabTwoScreen() {
const [imageUri, setImageUri] = useState( const [imageUri, setImageUri] = useState(
'file:///data/user/0/com.bowong.duooomi/cache/ImageManipulator/153903d9-f5e9-4e65-9c7d-f7bedd574f3c.jpg', 'file:///data/user/0/com.bowong.duooomi/cache/ImageManipulator/153903d9-f5e9-4e65-9c7d-f7bedd574f3c.jpg',
) )
const [userId, setUserId] = useState('01duHavK1CMW7pawcgOtB5aUqQeHPeni') const [userId, setUserId] = useState(user?.id ?? '01duHavK1CMW7pawcgOtB5aUqQeHPeni')
// 查询设备版本 // 查询设备版本
const queryDeviceVersion = async () => { const queryDeviceVersion = async () => {
@ -137,7 +141,11 @@ export default function TabTwoScreen() {
try { try {
const assetList = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' }) const assetList = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' })
const asset = assetList[0] const asset = assetList[0] as ImagePicker.ImagePickerAsset
if (!asset) {
console.warn('No asset selected')
return
}
// return // return
if (asset?.uri) { if (asset?.uri) {
@ -147,7 +155,7 @@ export default function TabTwoScreen() {
const url = await uploadFile({ const url = await uploadFile({
uri: asset.uri, uri: asset.uri,
mimeType: asset.mimeType, mimeType: asset.mimeType,
fileName: asset.fileName, fileName: asset.fileName ?? undefined,
}) })
transferMediaSingle(url) transferMediaSingle(url)
@ -155,15 +163,18 @@ export default function TabTwoScreen() {
Toast?.show({ title: '文件传输完成' }) Toast?.show({ title: '文件传输完成' })
}) })
.catch((error) => { .catch((error) => {
if (error === 'no_space') { const { status } = error || {}
if (!status) {
Toast?.show({ title: `文件传输失败` })
return
}
if (status === 'no_space') {
Toast?.show({ title: '文件传输失败, 设备存储空间不足' }) Toast?.show({ title: '文件传输失败, 设备存储空间不足' })
return
} }
if (error === 'duplicated') { if (status === 'duplicated') {
Toast?.show({ title: '文件传输失败, 设备已存在相同文件' }) Toast?.show({ title: '文件传输失败, 设备已存在相同文件' })
return
} }
Toast?.show({ title: `文件传输失败,${error}` })
}) })
.finally(() => { .finally(() => {
Toast.hideLoading() Toast.hideLoading()
@ -370,14 +381,15 @@ export default function TabTwoScreen() {
// .fill('https://cdn.roasmax.cn/material/8836b879f2d44af48eef8da82d13a755.mp4') // .fill('https://cdn.roasmax.cn/material/8836b879f2d44af48eef8da82d13a755.mp4')
contents.map((item, index) => { contents.map((item, index) => {
console.log('item-----------', item) console.log('item-----------', item)
const url = buildCdnUrl(item)
if (!url) return null
const Width = 100 const Width = 100
return ( return (
<Block key={`${item}-${index}`} className="gap-2"> <Block key={`${item}-${index}`} className="gap-2">
<ThemedText style={styles.contentText} numberOfLines={1}> <ThemedText style={styles.contentText} numberOfLines={1}>
{item} {item}
</ThemedText> </ThemedText>
<VideoBox style={{ width: Width, height: Width }} url={item} /> <VideoBox style={{ width: Width, height: Width }} url={url} />
<Button <Button
title="删除" title="删除"
color="red" color="red"
@ -410,7 +422,7 @@ export default function TabTwoScreen() {
</ThemedView> </ThemedView>
</KeyboardAwareScrollView> </KeyboardAwareScrollView>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
headerImage: { headerImage: {

View File

@ -7,6 +7,7 @@ import { Alert, Linking, PermissionsAndroid, Platform } from 'react-native'
import { ScanMode } from 'react-native-ble-plx' import { ScanMode } from 'react-native-ble-plx'
import { aniStorage } from '@/utils/aniStorage' import { aniStorage } from '@/utils/aniStorage'
import { extractCdnKey } from '@/utils/getCDNKey'
import { DeviceInfoService } from '../services/DeviceInfoService' import { DeviceInfoService } from '../services/DeviceInfoService'
import { FileTransferService } from '../services/FileTransferService' import { FileTransferService } from '../services/FileTransferService'
@ -52,6 +53,16 @@ export const useBleExplorer = () => {
// 用于暂存扫描到的设备,避免频繁更新状态 // 用于暂存扫描到的设备,避免频繁更新状态
const pendingDevicesRef = useRef<BleDevice[]>([]) const pendingDevicesRef = useRef<BleDevice[]>([])
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 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>({ const [state, setState2] = useState<BleState>({
isScanning: false, isScanning: false,
@ -312,7 +323,17 @@ export const useBleExplorer = () => {
const onPrepareTransfer = (response: PrepareTransferResponse) => { const onPrepareTransfer = (response: PrepareTransferResponse) => {
console.log('Prepare transfer response:', response) 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') { if (response.status === 'ready') {
console.log('Device is ready to receive file:', response.key) console.log('Device is ready to receive file:', response.key)
} else if (response.status === 'no_space') { } else if (response.status === 'no_space') {
@ -581,29 +602,19 @@ export const useBleExplorer = () => {
} }
try { try {
// 创建一个 Promise 来等待响应 // 创建 Promise 并存储到 Map
const responsePromise = new Promise<PrepareTransferResponse>((resolve, reject) => { const responsePromise = new Promise<PrepareTransferResponse>((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> const timeoutId = setTimeout(() => {
pendingPrepareTransfersRef.current.delete(key)
const handleResponse = (response: PrepareTransferResponse) => {
if (response.key === key) {
clearTimeout(timeoutId)
deviceInfoService.removeListener(EVENT_TYPES.PREPARE_TRANSFER.name, handleResponse)
resolve(response)
}
}
// 添加临时监听器
deviceInfoService.addListener(EVENT_TYPES.PREPARE_TRANSFER.name, handleResponse)
// 设置超时
timeoutId = setTimeout(() => {
deviceInfoService.removeListener(EVENT_TYPES.PREPARE_TRANSFER.name, handleResponse)
reject(new Error('Prepare transfer timeout')) reject(new Error('Prepare transfer timeout'))
}, 10000) // 10秒超时 }, 10000) // 10秒超时
// 先注册 Promise再发送请求
pendingPrepareTransfersRef.current.set(key, { resolve, reject, timeoutId })
}) })
// 发送预先发送请求 // ✅ 关键:先注册 Promise再发送请求
// 这样即使设备立即返回Promise 也已经准备好了
await deviceInfoService.prepareTransfer(state.connectedDevice.id, key, size) await deviceInfoService.prepareTransfer(state.connectedDevice.id, key, size)
// 等待响应 // 等待响应
@ -754,7 +765,7 @@ export const useBleExplorer = () => {
let imageUri = media.uri let imageUri = media.uri
// Check if conversion is needed (if not jpeg) // Check if conversion is needed (if not jpeg)
// Note: mimeType might not always be reliable, so we can also check filename extension if needed, // Note: mimeType might not always be reliable, so we can also check filename extension if needed
// or just always run manipulator to ensure consistency. // or just always run manipulator to ensure consistency.
// Here we check if it is NOT jpeg/jpg // Here we check if it is NOT jpeg/jpg
const isJpeg = const isJpeg =
@ -927,11 +938,16 @@ export const useBleExplorer = () => {
const fileSizeByte = tempBuffer.byteLength const fileSizeByte = tempBuffer.byteLength
const prepareResp = await prepareTransfer(uriOrUrl, fileSizeByte) 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') { if (prepareResp.status !== 'ready') {
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } })) setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } }))
console.log('prepareResp not ready-----', prepareResp) console.log('prepareResp not ready-----', prepareResp)
return Promise.reject(`${prepareResp.status}`) return Promise.reject({ status: `${prepareResp.status}` })
} }
console.log('prepareResp-----', prepareResp) console.log('prepareResp-----', prepareResp)
@ -955,7 +971,7 @@ export const useBleExplorer = () => {
return Promise.reject(error.message) return Promise.reject(error.message)
} }
}, },
[state.connectedDevice, fileTransferService, convertToANIAsBuffer, setError], [state.connectedDevice, fileTransferService, convertImgToANIAsBuffer, setError],
) )
const clearLogs = useCallback(() => setState((prev) => ({ ...prev, logs: [] })), []) const clearLogs = useCallback(() => setState((prev) => ({ ...prev, logs: [] })), [])

View File

@ -10,7 +10,7 @@ interface UseUpdateCheckerOptions {
} }
export const useUpdateChecker = ({ export const useUpdateChecker = ({
interval = 1 * 30 * 1000, // 30秒 interval = 5 * 60 * 1000, // 5分钟
enablePeriodicCheck = true, enablePeriodicCheck = true,
}: UseUpdateCheckerOptions = {}) => { }: UseUpdateCheckerOptions = {}) => {
const [hasUpdate, setHasUpdate] = useState(false) const [hasUpdate, setHasUpdate] = useState(false)

77
utils/getCDNKey.ts Normal file
View File

@ -0,0 +1,77 @@
import { CNDHOST } from './../app.config'
const ensureTrailingSlash = (s: string) => (s.endsWith('/') ? s : s + '/')
/**
* CDN key material/xxx.mp4
* @param input URL https://cdn.roasmax.cn/material/xxx.mp4?x=y
* @param cdnHost 使 https://cdn.roasmax.cn/
*/
export const extractCdnKey = (input: string, cdnHost = CNDHOST): string | null => {
if (!input) return null
const base = ensureTrailingSlash(cdnHost)
// 简单前缀匹配(最快)
if (input.startsWith(base)) {
const rest = input.slice(base.length)
return rest.split(/[?#]/)[0].replace(/^\/+/, '')
}
// URL 解析兜底
try {
const u = new URL(input)
const b = new URL(base)
if (u.origin === b.origin) {
return u.pathname.replace(/^\/+/, '')
}
return null
} catch {
// 不是URL直接返回 null
return null
}
}
/** 判断是否为指定 CDN 的地址 */
export const isFromCdn = (input: string, cdnHost = CNDHOST): boolean => {
if (!input) return false
try {
const u = new URL(input)
const b = new URL(ensureTrailingSlash(cdnHost))
return u.origin === b.origin
} catch {
return input.startsWith(ensureTrailingSlash(cdnHost))
}
}
/**
* key CDN URLURL则直接返回规范化结果
* @param keyOrUrl key URL
* @param cdnHost CDN
*/
export const buildCdnUrl = (keyOrUrl: string, cdnHost = CNDHOST): string | null => {
if (!keyOrUrl) return null
const base = ensureTrailingSlash(cdnHost)
// 已是该 CDN 的完整 URL返回去除查询/哈希后的规范化结果
if (isFromCdn(keyOrUrl, cdnHost)) {
try {
const u = new URL(keyOrUrl)
return `${u.origin}${u.pathname}`
} catch {
return keyOrUrl
}
}
// 传入为其他域的URL或纯key提取路径作为key再拼接
const pathOrKey = (() => {
try {
const u = new URL(keyOrUrl)
return u.pathname
} catch {
return keyOrUrl
}
})()
const cleaned = pathOrKey.split(/[?#]/)[0].replace(/^\/+/, '')
return base + cleaned
}