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_UNIVERSAL_LINK = 'duooomi.bowong.cn'
export const OWNER_ID = 'x3xbTCWf7dbtWu4gGU2TeI054L77xtkt'
export const APP_VERSION = 'dev202512311544'
export const APP_VERSION = 'dev202601071100'
export const ALIPAY_SCHEMA = 'alipay2021006119657394'
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 * as ImagePicker from 'expo-image-picker'
import * as MediaLibrary from 'expo-media-library'
import { observer } from 'mobx-react-lite'
import React, { useState } from 'react'
import { Button, StyleSheet, TextInput } from 'react-native'
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 { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { userStore } from '@/stores'
import { uploadFile } from '@/utils'
import { buildCdnUrl } from '@/utils/getCDNKey'
export default function TabTwoScreen() {
export default observer(function TabTwoScreen() {
const { user } = userStore
const {
isScanning,
isConnected,
@ -45,7 +49,7 @@ export default function TabTwoScreen() {
const [imageUri, setImageUri] = useState(
'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 () => {
@ -137,7 +141,11 @@ export default function TabTwoScreen() {
try {
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
if (asset?.uri) {
@ -147,7 +155,7 @@ export default function TabTwoScreen() {
const url = await uploadFile({
uri: asset.uri,
mimeType: asset.mimeType,
fileName: asset.fileName,
fileName: asset.fileName ?? undefined,
})
transferMediaSingle(url)
@ -155,15 +163,18 @@ export default function TabTwoScreen() {
Toast?.show({ title: '文件传输完成' })
})
.catch((error) => {
if (error === 'no_space') {
const { status } = error || {}
if (!status) {
Toast?.show({ title: `文件传输失败` })
return
}
if (status === 'no_space') {
Toast?.show({ title: '文件传输失败, 设备存储空间不足' })
return
}
if (error === 'duplicated') {
if (status === 'duplicated') {
Toast?.show({ title: '文件传输失败, 设备已存在相同文件' })
return
}
Toast?.show({ title: `文件传输失败,${error}` })
})
.finally(() => {
Toast.hideLoading()
@ -370,14 +381,15 @@ export default function TabTwoScreen() {
// .fill('https://cdn.roasmax.cn/material/8836b879f2d44af48eef8da82d13a755.mp4')
contents.map((item, index) => {
console.log('item-----------', item)
const url = buildCdnUrl(item)
if (!url) return null
const Width = 100
return (
<Block key={`${item}-${index}`} className="gap-2">
<ThemedText style={styles.contentText} numberOfLines={1}>
{item}
</ThemedText>
<VideoBox style={{ width: Width, height: Width }} url={item} />
<VideoBox style={{ width: Width, height: Width }} url={url} />
<Button
title="删除"
color="red"
@ -410,7 +422,7 @@ export default function TabTwoScreen() {
</ThemedView>
</KeyboardAwareScrollView>
)
}
})
const styles = StyleSheet.create({
headerImage: {

View File

@ -7,6 +7,7 @@ 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'
@ -52,6 +53,16 @@ export const useBleExplorer = () => {
// 用于暂存扫描到的设备,避免频繁更新状态
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,
@ -312,7 +323,17 @@ export const useBleExplorer = () => {
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') {
@ -581,29 +602,19 @@ export const useBleExplorer = () => {
}
try {
// 创建一个 Promise 来等待响应
// 创建 Promise 并存储到 Map
const responsePromise = new Promise<PrepareTransferResponse>((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout>
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)
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)
// 等待响应
@ -754,7 +765,7 @@ export const useBleExplorer = () => {
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,
// 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 =
@ -927,11 +938,16 @@ export const useBleExplorer = () => {
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') {
setState((prev) => ({ ...prev, loading: { ...prev.loading, converting: false } }))
console.log('prepareResp not ready-----', prepareResp)
return Promise.reject(`${prepareResp.status}`)
return Promise.reject({ status: `${prepareResp.status}` })
}
console.log('prepareResp-----', prepareResp)
@ -955,7 +971,7 @@ export const useBleExplorer = () => {
return Promise.reject(error.message)
}
},
[state.connectedDevice, fileTransferService, convertToANIAsBuffer, setError],
[state.connectedDevice, fileTransferService, convertImgToANIAsBuffer, setError],
)
const clearLogs = useCallback(() => setState((prev) => ({ ...prev, logs: [] })), [])

View File

@ -10,7 +10,7 @@ interface UseUpdateCheckerOptions {
}
export const useUpdateChecker = ({
interval = 1 * 30 * 1000, // 30秒
interval = 5 * 60 * 1000, // 5分钟
enablePeriodicCheck = true,
}: UseUpdateCheckerOptions = {}) => {
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
}