feat: 自动上传

This commit is contained in:
jinyaqiu 2025-07-14 16:57:24 +08:00
parent 828e84710f
commit 3f32bb26bc
9 changed files with 865 additions and 9 deletions

View File

@ -13,7 +13,8 @@
"infoPlist": {
"NSPhotoLibraryUsageDescription": "Allow $(PRODUCT_NAME) to access your photos.",
"NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.",
"ITSAppUsesNonExemptEncryption": false
"ITSAppUsesNonExemptEncryption": false,
"UIBackgroundModes": ["fetch", "location", "audio"]
},
"bundleIdentifier": "com.memowake.app"
},
@ -30,7 +31,9 @@
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.ACCESS_MEDIA_LOCATION"
"android.permission.ACCESS_MEDIA_LOCATION",
"FOREGROUND_SERVICE",
"WAKE_LOCK"
],
"edgeToEdgeEnabled": true,
"package": "com.memowake.app"
@ -42,7 +45,18 @@
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-secure-store", [
"expo-background-fetch",
{
"minimumInterval": 1
}
],
[
"expo-task-manager",
{
"transparency": "opaque"
}
],
[
"expo-location",
{

View File

@ -0,0 +1,227 @@
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { registerBackgroundUploadTask, triggerManualUpload } from './backgroundUploader';
export default function AutoUploadScreen() {
const [timeRange, setTimeRange] = useState('day');
const [isLoading, setIsLoading] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
// 注册后台任务
useEffect(() => {
const registerTask = async () => {
const registered = await registerBackgroundUploadTask();
setIsRegistered(registered);
};
registerTask();
}, []);
// 处理手动上传
const handleManualUpload = async () => {
try {
setIsLoading(true);
await triggerManualUpload(getDateRange(timeRange)[0], getDateRange(timeRange)[1]);
} catch (error) {
console.error('Upload error:', error);
} finally {
setIsLoading(false);
}
};
// 获取时间范围文本
const getDateRangeText = (timeRange: string) => {
switch (timeRange) {
case 'day':
return '最近一天';
case 'week':
return '最近一周';
case 'month':
return '最近一个月';
case 'all':
return '全部';
default:
return '';
}
};
// 获取时间范围
const getDateRange = (timeRange: string) => {
const date = new Date();
switch (timeRange) {
case 'day':
date.setDate(date.getDate() - 1);
break;
case 'week':
date.setDate(date.getDate() - 7);
break;
case 'month':
date.setMonth(date.getMonth() - 1);
break;
case 'all':
date.setFullYear(date.getFullYear() - 1);
break;
default:
break;
}
return [date, new Date()];
};
return (
<View style={styles.container}>
<Text style={styles.title}></Text>
<View style={styles.buttonGroup}>
<Text style={styles.sectionTitle}>:</Text>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.timeButton, timeRange === 'day' && styles.activeButton]}
onPress={() => setTimeRange('day')}
>
<Text style={styles.buttonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.timeButton, timeRange === 'week' && styles.activeButton]}
onPress={() => setTimeRange('week')}
>
<Text style={styles.buttonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.timeButton, timeRange === 'month' && styles.activeButton]}
onPress={() => setTimeRange('month')}
>
<Text style={styles.buttonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.timeButton, timeRange === 'all' && styles.activeButton]}
onPress={() => setTimeRange('all')}
>
<Text style={styles.buttonText}></Text>
</TouchableOpacity>
</View>
<Text style={styles.dateRangeText}>
{getDateRangeText(timeRange)}
</Text>
</View>
<View style={styles.uploadButtonContainer}>
<TouchableOpacity
style={[styles.uploadButton, isLoading && styles.uploadButtonDisabled]}
onPress={handleManualUpload}
disabled={isLoading}
>
<Text style={styles.uploadButtonText}>
{isLoading ? '上传中...' : '开始上传'}
</Text>
</TouchableOpacity>
</View>
<View style={styles.statusContainer}>
<Text style={styles.statusText}>
: {isRegistered ? '已启用' : '未启用'}
</Text>
<Text style={styles.hintText}>
24
</Text>
</View>
{isLoading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={styles.loadingText}>...</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: '#fff',
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
buttonGroup: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 16,
marginBottom: 10,
color: '#333',
},
buttonRow: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 10,
gap: 10,
},
timeButton: {
paddingVertical: 8,
paddingHorizontal: 15,
borderRadius: 20,
backgroundColor: '#f0f0f0',
borderWidth: 1,
borderColor: '#ddd',
},
activeButton: {
backgroundColor: '#007AFF',
borderColor: '#007AFF',
},
buttonText: {
color: '#333',
textAlign: 'center',
},
dateRangeText: {
fontSize: 14,
color: '#666',
marginTop: 8,
},
uploadButtonContainer: {
marginTop: 20,
},
uploadButton: {
backgroundColor: '#007AFF',
padding: 15,
borderRadius: 8,
alignItems: 'center',
},
uploadButtonDisabled: {
backgroundColor: '#84c1ff',
},
uploadButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
statusContainer: {
marginTop: 30,
padding: 15,
backgroundColor: '#f8f8f8',
borderRadius: 8,
borderWidth: 1,
borderColor: '#eee',
},
statusText: {
fontSize: 15,
marginBottom: 5,
color: '#333',
},
hintText: {
fontSize: 13,
color: '#666',
},
loadingContainer: {
marginTop: 20,
alignItems: 'center',
},
loadingText: {
marginTop: 10,
color: '#666',
},
});

View File

@ -0,0 +1,383 @@
import { fetchApi } from '@/lib/server-api-util';
import * as BackgroundFetch from 'expo-background-fetch';
import * as ImageManipulator from 'expo-image-manipulator';
import * as MediaLibrary from 'expo-media-library';
import * as TaskManager from 'expo-task-manager';
import { Alert } from 'react-native';
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
// 获取指定时间范围内的媒体文件
export const getMediaByDateRange = async (startDate: Date, endDate: Date) => {
try {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
console.log('Media library permission not granted');
return [];
}
// 获取媒体资源
const media = await MediaLibrary.getAssetsAsync({
mediaType: ['photo', 'video'],
first: 100, // 每次最多获取100个
sortBy: [MediaLibrary.SortBy.creationTime],
createdAfter: startDate.getTime(),
createdBefore: endDate.getTime(),
});
return media.assets;
} catch (error) {
console.error('Error getting media by date range:', error);
return [];
}
};
// 压缩图片
const compressImage = async (uri: string): Promise<{ uri: string; file: File }> => {
try {
const manipResult = await ImageManipulator.manipulateAsync(
uri,
[
{
resize: {
width: 1200,
height: 1200,
},
},
],
{
compress: 0.7,
format: ImageManipulator.SaveFormat.JPEG,
base64: false,
}
);
const response = await fetch(manipResult.uri);
const blob = await response.blob();
const filename = uri.split('/').pop() || `image_${Date.now()}.jpg`;
const file = new File([blob], filename, { type: 'image/jpeg' });
return { uri: manipResult.uri, file };
} catch (error) {
console.error('Error compressing image:', error);
throw error;
}
};
// 获取上传URL
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<{ upload_url: string; file_id: string }> => {
const body = {
filename: file.name,
content_type: file.type,
file_size: file.size,
metadata: {
...metadata,
originalName: file.name,
fileType: file.type.startsWith('video/') ? 'video' : 'image',
isCompressed: 'true',
},
};
return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", {
method: 'POST',
body: JSON.stringify(body)
});
};
// 确认上传
const confirmUpload = async (file_id: string) => {
return await fetchApi('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({ file_id })
});
};
// 新增素材
const addMaterial = async (file: string, compressFile: string) => {
await fetchApi('/material', {
method: 'POST',
body: JSON.stringify([{
"file_id": file,
"preview_file_id": compressFile
}])
}).catch((error) => {
// console.log(error);
})
}
// 上传文件到URL
const uploadFile = async (file: File, uploadUrl: string): Promise<void> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => {
reject(new Error('Network error during upload'));
};
xhr.send(file);
});
};
// 检查并请求媒体库权限
const checkMediaLibraryPermission = async (): Promise<{ hasPermission: boolean, status?: string }> => {
try {
const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync();
// 如果已经授权,直接返回
if (status === 'granted' && accessPrivileges === 'all') {
return { hasPermission: true, status };
}
// 如果没有授权,请求权限
const { status: newStatus, accessPrivileges: newPrivileges } = await MediaLibrary.requestPermissionsAsync();
const isGranted = newStatus === 'granted' && newPrivileges === 'all';
if (!isGranted) {
console.log('Media library permission not granted or limited access');
}
return {
hasPermission: isGranted,
status: newStatus
};
} catch (error) {
console.error('Error checking media library permission:', error);
return { hasPermission: false };
}
};
// 处理单个媒体文件上传
const processMediaUpload = async (asset: MediaLibrary.Asset) => {
try {
// 检查权限
const { hasPermission } = await checkMediaLibraryPermission();
if (!hasPermission) {
throw new Error('No media library permission');
}
const isVideo = asset.mediaType === 'video';
// 上传原始文件
const uploadOriginalFile = async () => {
const response = await fetch(asset.uri);
const blob = await response.blob();
const filename = asset.filename ||
`${isVideo ? 'video' : 'image'}_${Date.now()}_original.${isVideo ? 'mp4' : 'jpg'}`;
const mimeType = isVideo ? 'video/mp4' : 'image/jpeg';
const file = new File([blob], filename, { type: mimeType });
console.log("Original file prepared for upload:", {
name: file.name,
size: file.size,
type: file.type
});
// 获取上传URL并上传原始文件
const { upload_url, file_id } = await getUploadUrl(file, {
originalUri: asset.uri,
creationTime: asset.creationTime,
mediaType: isVideo ? 'video' : 'image',
isCompressed: false
});
await uploadFile(file, upload_url);
await confirmUpload(file_id);
console.log(`Successfully uploaded original: ${filename}`);
return { success: true, file_id };
};
// 上传压缩文件(仅图片)
const uploadCompressedFile = async () => {
if (isVideo) return { success: true, file_id: null }; // 视频不压缩
try {
const { file: compressedFile } = await compressImage(asset.uri);
const filename = asset.filename ?
`compressed_${asset.filename}` :
`image_${Date.now()}_compressed.jpg`;
console.log("Compressed file prepared for upload:", {
name: filename,
size: compressedFile.size,
type: compressedFile.type
});
// 获取上传URL并上传压缩文件
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
originalUri: asset.uri,
creationTime: asset.creationTime,
mediaType: 'image',
isCompressed: true
});
await uploadFile(compressedFile, upload_url);
await confirmUpload(file_id);
console.log(`Successfully uploaded compressed: ${filename}`);
return { success: true, file_id };
} catch (error) {
console.error('Error uploading compressed file:', error);
return { success: false, error };
}
};
// 同时上传原始文件和压缩文件
console.log("Asset info:", asset);
const [originalResult, compressedResult] = await Promise.all([
uploadOriginalFile(),
uploadCompressedFile()
]);
console.log("originalResult", originalResult);
console.log("compressedResult", compressedResult);
addMaterial(originalResult.file_id, compressedResult.file_id)
return {
originalSuccess: originalResult.success,
compressedSuccess: compressedResult.success,
fileIds: {
original: originalResult.file_id,
compressed: compressedResult.file_id
}
};
} catch (error) {
console.error('Error in processMediaUpload:', error);
if (error.message === 'No media library permission') {
throw error;
}
return {
originalSuccess: false,
compressedSuccess: false,
error
};
}
};
// 注册后台任务
export const registerBackgroundUploadTask = async () => {
try {
// 检查是否已注册
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK);
if (isRegistered) {
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_UPLOAD_TASK);
}
// 注册后台任务
await BackgroundFetch.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
minimumInterval: 15 * 60, // 15分钟
stopOnTerminate: false, // 应用关闭后继续运行
startOnBoot: true, // 系统启动后自动运行
});
console.log('Background upload task registered');
return true;
} catch (error) {
console.error('Error registering background task:', error);
return false;
}
};
// 定义后台任务
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
try {
console.log('Background upload task started');
// 检查并请求媒体库权限
const { hasPermission } = await checkMediaLibraryPermission();
if (!hasPermission) {
console.log('Media library permission not granted');
// 返回 NoData 而不是 Failed这样系统可能会在稍后重试
return BackgroundFetch.BackgroundFetchResult.NoData;
}
// 获取过去24小时内的媒体
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const media = await getMediaByDateRange(yesterday, now);
console.log("media", media);
if (media.length === 0) {
console.log('No media found in the specified time range');
return BackgroundFetch.BackgroundFetchResult.NoData;
}
console.log(`Found ${media.length} media items to process`);
// 处理每个媒体文件
for (const item of media) {
try {
await processMediaUpload(item);
} catch (error) {
console.error(`Error processing media ${item.id}:`, error);
// 如果是权限错误,直接返回失败
if (error.message === 'No media library permission') {
return BackgroundFetch.BackgroundFetchResult.Failed;
}
// 其他错误继续处理下一个文件
}
}
console.log('Background upload task completed');
return BackgroundFetch.BackgroundFetchResult.NewData;
} catch (error) {
console.error('Error in background upload task:', error);
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
// 手动触发上传
export const triggerManualUpload = async (startDate: Date, endDate: Date) => {
try {
console.log('Starting manual upload...');
console.log("startDate", startDate);
console.log("endDate", endDate);
const media = await getMediaByDateRange(startDate, endDate);
console.log("media", media);
if (media.length === 0) {
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
return [];
}
const results = [];
for (const item of media) {
try {
const result = await processMediaUpload(item);
results.push({
id: item.id,
originalSuccess: result.originalSuccess,
compressedSuccess: result.compressedSuccess,
fileIds: result.fileIds,
});
} catch (error) {
console.error(`Error uploading ${item.filename}:`, error);
results.push({
id: item.id,
originalSuccess: false,
compressedSuccess: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
const originalSuccessCount = results.filter(r => r.originalSuccess).length;
const compressedSuccessCount = results.filter(r => r.compressedSuccess).length;
Alert.alert('上传完成', `成功上传 ${originalSuccessCount}/${media.length} 个原始文件,${compressedSuccessCount}/${media.length} 个压缩文件`);
return results;
} catch (error) {
console.error('Error in manual upload:', error);
Alert.alert('错误', '上传过程中出现错误');
throw error;
}
};

View File

@ -0,0 +1,62 @@
import * as MediaLibrary from 'expo-media-library';
export type MediaItem = {
id: string;
uri: string;
creationTime: number;
filename: string;
};
// 获取指定时间范围内的图片
export const getFilteredMedia = async (range: 'today' | 'week' | 'month' | 'all'): Promise<MediaItem[]> => {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') throw new Error('Permission not granted');
console.log("statusq111111111111111111", status);
let cutoffDate: Date;
switch (range) {
case 'today':
cutoffDate = new Date();
cutoffDate.setHours(0, 0, 0, 0);
break;
case 'week':
cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 7);
break;
case 'month':
cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - 1);
break;
default:
cutoffDate = new Date(0); // 所有图片
}
console.log("cutoffDateq111111111111111111", cutoffDate);
const albums = await MediaLibrary.getAlbumsAsync({ includeSmartAlbums: true });
console.log("albumsq111111111111111111", albums);
let allAssets: MediaItem[] = [];
for (const album of albums) {
const result = await MediaLibrary.getAssetsAsync({
album: album.id,
mediaType: ['photo'],
first: 1000,
});
console.log("result111111111111", result);
const filtered = result.assets
.filter(asset => asset.creationTime > cutoffDate.getTime())
.map(asset => ({
id: asset.id,
uri: asset.uri,
creationTime: asset.creationTime,
filename: asset.filename,
}));
console.log("filtered111111111111", filtered);
}
console.log("allAssetsq111111111111111111", allAssets);
return allAssets;
};

View File

@ -0,0 +1,93 @@
import * as SecureStore from 'expo-secure-store';
const QUEUE_KEY = 'uploadQueue';
export type UploadItem = {
uri: string;
filename: string;
};
// 存储队列
export const saveUploadQueue = async (queue: UploadItem[]) => {
await SecureStore.setItemAsync(QUEUE_KEY, JSON.stringify(queue));
};
// 获取队列
export const getUploadQueue = async (): Promise<UploadItem[]> => {
const data = await SecureStore.getItemAsync(QUEUE_KEY);
return data ? JSON.parse(data) : [];
};
// 移除已上传项
export const removeFromQueue = async (uri: string) => {
const queue = await getUploadQueue();
const newQueue = queue.filter(item => item.uri !== uri);
await saveUploadQueue(newQueue);
};
export const uploadMediaFile = async (asset: any) => {
const uri = asset.uri;
const filename = uri.split('/').pop() || 'file.jpg';
const type =
asset.mediaType === 'photo'
? `image/${filename.split('.').pop()}`
: `video/${filename.split('.').pop()}`;
const formData = new FormData();
formData.append('file', { uri, name: filename, type } as any);
await getUploadUrl({
...formData,
name: filename,
type,
size: asset.fileSize
}, {}).then((res) => {
confirmUpload(res.file_id).then((confirmRes) => {
addMaterial(res.file_id, confirmRes.file_id)
}).catch((error) => {
console.log(error);
})
}).catch((error) => {
console.log(error);
})
};
// 获取上传URL
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
const body = {
filename: file.name,
content_type: file.type,
file_size: file.size,
metadata: {
...metadata,
originalName: file.name,
fileType: 'image',
isCompressed: metadata.isCompressed || 'false',
},
};
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
method: 'POST',
body: JSON.stringify(body)
});
};
// 向服务端confirm上传
const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => await fetchApi<ConfirmUpload>('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({
file_id
})
});
// 新增素材
const addMaterial = async (file: string, compressFile: string) => {
await fetchApi('/material', {
method: 'POST',
body: JSON.stringify([{
"file_id": file,
"preview_file_id": compressFile
}])
}).catch((error) => {
// console.log(error);
})
}

View File

@ -5,6 +5,7 @@ import { ThemedText } from '@/components/ThemedText';
import { FileUploadItem } from '@/types/upload';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native';
import AutoUploadScreen from '../file-upload/autoUploadScreen';
import FilesUploader from '../file-upload/files-uploader';
interface Props {
@ -60,7 +61,9 @@ export default function Look(props: Props) {
{t('auth.userMessage.choosePhoto', { ns: 'login' })}
</ThemedText>
</View>
} />
}
/>
<AutoUploadScreen />
</View>
<View className="w-full">

View File

@ -92,7 +92,13 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const logout = () => {
// 清除 Redux store 中的认证信息
dispatch(clearCredentials());
if (Platform.OS === 'web') {
localStorage.setItem('user', "");
localStorage.setItem('token', "");
} else {
SecureStore.setItemAsync('user', "");
SecureStore.setItemAsync('token', "");
}
// 触发事件通知
eventEmitter.emit(EVENT_TYPES.USER_INFO_UPDATED, null);

70
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@types/react-redux": "^7.1.34",
"expo": "~53.0.12",
"expo-audio": "~0.4.7",
"expo-background-fetch": "^13.1.6",
"expo-blur": "~14.1.5",
"expo-constants": "~17.1.6",
"expo-dev-client": "~5.2.1",
@ -37,6 +38,7 @@
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.9",
"expo-task-manager": "^13.1.6",
"expo-video": "~2.2.2",
"expo-video-thumbnails": "~9.1.3",
"expo-web-browser": "~14.2.0",
@ -48,7 +50,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.5.3",
"react-native": "0.79.4",
"react-native": "^0.79.4",
"react-native-gesture-handler": "~2.24.0",
"react-native-modal": "^14.0.0-rc.1",
"react-native-picker-select": "^9.3.1",
@ -3358,6 +3360,18 @@
}
}
},
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",
@ -7798,6 +7812,18 @@
"react-native": "*"
}
},
"node_modules/expo-background-fetch": {
"version": "13.1.6",
"resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-13.1.6.tgz",
"integrity": "sha512-hl4kR32DaxoHFYqNsILLZG2mWssCkUb4wnEAHtDGmpxUP4SCnJILcAn99J6AGDFUw5lF6FXNZZCXNfcrFioO4Q==",
"license": "MIT",
"dependencies": {
"expo-task-manager": "~13.1.6"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-blur": {
"version": "14.1.5",
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.1.5.tgz",
@ -8261,6 +8287,19 @@
}
}
},
"node_modules/expo-task-manager": {
"version": "13.1.6",
"resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-13.1.6.tgz",
"integrity": "sha512-sYNAftpIeZ+j6ur17Jo0OpSTk9ks/MDvTbrNCimXMyjIt69XXYL/kAPYf76bWuxOuN8bcJ8Ef8YvihkwFG9hDA==",
"license": "MIT",
"dependencies": {
"unimodules-app-loader": "~5.1.3"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-updates-interface": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-1.1.0.tgz",
@ -9704,6 +9743,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -10771,6 +10819,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -15139,6 +15199,12 @@
"node": ">=4"
}
},
"node_modules/unimodules-app-loader": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-5.1.3.tgz",
"integrity": "sha512-nPUkwfkpJWvdOQrVvyQSUol93/UdmsCVd9Hkx9RgAevmKSVYdZI+S87W73NGKl6QbwK9L1BDSY5OrQuo8Oq15g==",
"license": "MIT"
},
"node_modules/unique-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
@ -15830,4 +15896,4 @@
}
}
}
}
}

View File

@ -21,6 +21,7 @@
"@types/react-redux": "^7.1.34",
"expo": "~53.0.12",
"expo-audio": "~0.4.7",
"expo-background-fetch": "^13.1.6",
"expo-blur": "~14.1.5",
"expo-constants": "~17.1.6",
"expo-dev-client": "~5.2.1",
@ -42,6 +43,7 @@
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.9",
"expo-task-manager": "^13.1.6",
"expo-video": "~2.2.2",
"expo-video-thumbnails": "~9.1.3",
"expo-web-browser": "~14.2.0",
@ -53,7 +55,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.5.3",
"react-native": "0.79.4",
"react-native": "^0.79.4",
"react-native-gesture-handler": "~2.24.0",
"react-native-modal": "^14.0.0-rc.1",
"react-native-picker-select": "^9.3.1",
@ -90,4 +92,4 @@
}
}
}
}
}