diff --git a/app.json b/app.json index 4a3c783..4e7ad8f 100644 --- a/app.json +++ b/app.json @@ -56,15 +56,15 @@ "screenOrientation": "unspecified", "intentFilters": [ { - "action": "VIEW", + "action": "android.intent.action.VIEW", "data": [ { "scheme": "oriontv" } ], "category": [ - "BROWSABLE", - "DEFAULT" + "android.intent.category.BROWSABLE", + "android.intent.category.DEFAULT" ] } ] diff --git a/app/index.tsx b/app/index.tsx index 885bcb7..fcdfd42 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -55,7 +55,6 @@ export default function HomeScreen() { // 双击返回退出逻辑(只限当前页面) const backPressTimeRef = useRef(null); - const exitToastShownRef = useRef(false); // 防止重复显示提示 useFocusEffect( useCallback(() => { @@ -82,7 +81,6 @@ export default function HomeScreen() { return () => { backHandler.remove(); backPressTimeRef.current = null; - exitToastShownRef.current = false; }; } }, []) diff --git a/app/settings.tsx b/app/settings.tsx index f860834..a7de9a5 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { View, StyleSheet, FlatList, Alert, KeyboardAvoidingView, Platform, ScrollView } from "react-native"; +import { View, StyleSheet, Alert, Platform } from "react-native"; import { useTVEventHandler } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ThemedText } from "@/components/ThemedText"; @@ -22,6 +22,18 @@ import ResponsiveHeader from "@/components/navigation/ResponsiveHeader"; import { DeviceUtils } from "@/utils/DeviceUtils"; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; +type SectionItem = { + component: React.ReactElement; + key: string; +}; + +/** 过滤掉 false/undefined,帮 TypeScript 推断出真正的数组元素类型 */ +function isSectionItem( + item: false | undefined | SectionItem +): item is SectionItem { + return !!item; +} + export default function SettingsScreen() { const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore(); const { lastMessage, targetPage, clearMessage } = useRemoteControlStore(); @@ -87,8 +99,66 @@ export default function SettingsScreen() { setHasChanges(true); }; - const sections = [ - // 远程输入配置 - 仅在非手机端显示 + // const sections = [ + // // 远程输入配置 - 仅在非手机端显示 + // deviceType !== "mobile" && { + // component: ( + // { + // setCurrentFocusIndex(0); + // setCurrentSection("remote"); + // }} + // /> + // ), + // key: "remote", + // }, + // { + // component: ( + // { + // setCurrentFocusIndex(1); + // setCurrentSection("api"); + // }} + // /> + // ), + // key: "api", + // }, + // // 直播源配置 - 仅在非手机端显示 + // deviceType !== "mobile" && { + // component: ( + // { + // setCurrentFocusIndex(2); + // setCurrentSection("livestream"); + // }} + // /> + // ), + // key: "livestream", + // }, + // // { + // // component: ( + // // { + // // setCurrentFocusIndex(3); + // // setCurrentSection("videoSource"); + // // }} + // // /> + // // ), + // // key: "videoSource", + // // }, + // Platform.OS === "android" && { + // component: , + // key: "update", + // }, + // ].filter(Boolean); + const rawSections = [ deviceType !== "mobile" && { component: ( { - // setCurrentFocusIndex(3); - // setCurrentSection("videoSource"); - // }} - // /> - // ), - // key: "videoSource", - // }, Platform.OS === "android" && { component: , key: "update", }, - ].filter(Boolean); + ] as const; // 把每个对象都当作字面量保留 + /** 这里得到的 sections 已经是 SectionItem[](没有 false) */ + const sections: SectionItem[] = rawSections.filter(isSectionItem); + // TV遥控器事件处理 - 仅在TV设备上启用 const handleTVEvent = React.useCallback( @@ -189,7 +249,7 @@ export default function SettingsScreen() { )} - + {/* { @@ -202,6 +262,14 @@ export default function SettingsScreen() { showsVerticalScrollIndicator={false} contentContainerStyle={dynamicStyles.listContent} /> + */} + + {sections.map(item => ( + // 必须把 key 放在最外层的 View 上 + + {item.component} + + ))} @@ -273,5 +341,8 @@ const createResponsiveStyles = (deviceType: string, spacing: number, insets: any disabledButton: { opacity: 0.5, }, + itemWrapper: { + marginBottom: spacing, // 这里的 spacing 来自 useResponsiveLayout() + }, }); }; diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 4d2797e..0a4bcf8 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from "react"; -import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert } from "react-native"; +import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert, Keyboard, InteractionManager } from "react-native"; import { usePathname } from "expo-router"; import Toast from "react-native-toast-message"; import useAuthStore from "@/stores/authStore"; @@ -23,9 +23,14 @@ const LoginModal = () => { const pathname = usePathname(); const isSettingsPage = pathname.includes("settings"); + const [isModalReady, setIsModalReady] = useState(false); + // Load saved credentials when modal opens useEffect(() => { if (isLoginModalVisible && !isSettingsPage) { + // 先确保键盘状态清理 + Keyboard.dismiss(); + const loadCredentials = async () => { const savedCredentials = await LoginCredentialsManager.get(); if (savedCredentials) { @@ -34,12 +39,22 @@ const LoginModal = () => { } }; loadCredentials(); + + // 延迟设置 Modal 就绪状态 + const readyTimeout = setTimeout(() => { + setIsModalReady(true); + }, 300); + + return () => { + clearTimeout(readyTimeout); + setIsModalReady(false); + }; } }, [isLoginModalVisible, isSettingsPage]); // Focus management with better TV remote handling useEffect(() => { - if (isLoginModalVisible && !isSettingsPage) { + if (isModalReady && isLoginModalVisible && !isSettingsPage) { const isUsernameVisible = serverConfig?.StorageType !== "localstorage"; // Use a small delay to ensure the modal is fully rendered @@ -49,11 +64,19 @@ const LoginModal = () => { } else { passwordInputRef.current?.focus(); } - }, 100); + }, 300); return () => clearTimeout(focusTimeout); } - }, [isLoginModalVisible, serverConfig, isSettingsPage]); + }, [isModalReady, isLoginModalVisible, serverConfig, isSettingsPage]); + + // 清理 effect - 确保 Modal 关闭时清理所有状态 + useEffect(() => { + return () => { + Keyboard.dismiss(); + setIsModalReady(false); + }; + }, []); const handleLogin = async () => { const isLocalStorage = serverConfig?.StorageType === "localstorage"; @@ -66,19 +89,38 @@ const LoginModal = () => { await api.login(isLocalStorage ? undefined : username, password); await checkLoginStatus(apiBaseUrl); await refreshPlayRecords(); - + // Save credentials on successful login await LoginCredentialsManager.save({ username, password }); - - Toast.show({ type: "success", text1: "登录成功" }); - hideLoginModal(); - // Show disclaimer alert after successful login - Alert.alert( - "免责声明", - "本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。", - [{ text: "确定" }] - ); + Toast.show({ type: "success", text1: "登录成功" }); + // hideLoginModal(); + + // // Show disclaimer alert after successful login + // Alert.alert( + // "免责声明", + // "本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。", + // [{ text: "确定" }] + // ); + + // 在登录成功后清理状态,再显示 Alert + const hideAndAlert = () => { + hideLoginModal(); + setIsModalReady(false); + Keyboard.dismiss(); + + setTimeout(() => { + Alert.alert( + "免责声明", + "本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。", + [{ text: "确定" }] + ); + }, 100); + }; + + // 使用 InteractionManager 确保 UI 稳定后再执行 + InteractionManager.runAfterInteractions(hideAndAlert); + } catch (error) { Toast.show({ type: "error", diff --git a/components/settings/RemoteInputSection.tsx b/components/settings/RemoteInputSection.tsx index 77218d0..5af3de9 100644 --- a/components/settings/RemoteInputSection.tsx +++ b/components/settings/RemoteInputSection.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from "react"; -import { View, Switch, StyleSheet, Pressable, Animated, Platform } from "react-native"; +import { View, Switch, StyleSheet, Pressable, Animated, Platform, TouchableOpacity } from "react-native"; import { useTVEventHandler } from "react-native"; import { ThemedText } from "@/components/ThemedText"; import { SettingsSection } from "./SettingsSection"; @@ -59,20 +59,31 @@ export const RemoteInputSection: React.FC = ({ onChange return ( + {...Platform.isTV || deviceType !== 'tv' ? undefined : { onPress: handlePress }} + > 启用远程输入 - {}} // 禁用Switch的直接交互 - trackColor={{ false: "#767577", true: Colors.dark.primary }} - thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"} - pointerEvents="none" - /> + { Platform.OS === 'ios' && Platform.isTV ? ( + handlePress()} + style={styles.statusLabel} + > + {remoteInputEnabled ? '已启用' : '已禁用'} + + ) : ( + { }} // 禁用Switch的直接交互 + trackColor={{ false: "#767577", true: Colors.dark.primary }} + thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"} + pointerEvents="none" + /> + ) + } diff --git a/hooks/useApiConfig.ts b/hooks/useApiConfig.ts index 0f870ff..b9a2416 100644 --- a/hooks/useApiConfig.ts +++ b/hooks/useApiConfig.ts @@ -38,7 +38,7 @@ export const useApiConfig = () => { const validateConfig = async () => { setValidationState(prev => ({ ...prev, isValidating: true, error: null })); - + try { await api.getServerConfig(); setValidationState({ @@ -48,7 +48,7 @@ export const useApiConfig = () => { }); } catch (error) { let errorMessage = '服务器连接失败'; - + if (error instanceof Error) { switch (error.message) { case 'API_URL_NOT_SET': @@ -70,7 +70,7 @@ export const useApiConfig = () => { break; } } - + setValidationState({ isValidating: false, isValid: false, @@ -98,10 +98,10 @@ export const useApiConfig = () => { if (serverConfig) { setValidationState(prev => ({ ...prev, isValid: true, error: null })); } else { - setValidationState(prev => ({ - ...prev, - isValid: false, - error: prev.error || '无法获取服务器配置' + setValidationState(prev => ({ + ...prev, + isValid: false, + error: prev.error || '无法获取服务器配置' })); } } @@ -122,18 +122,18 @@ export const getApiConfigErrorMessage = (status: ApiConfigStatus): string => { if (status.needsConfiguration) { return '请点击右上角设置按钮,配置您的服务器地址'; } - + if (status.error) { return status.error; } - + if (status.isValidating) { return '正在验证服务器配置...'; } - + if (status.isValid === false) { return '服务器配置验证失败,请检查设置'; } - + return '加载失败,请重试'; }; \ No newline at end of file diff --git a/package.json b/package.json index 1b302e1..8615783 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "expo-build-properties": "~0.12.3", "expo-constants": "~16.0.2", "expo-font": "~12.0.7", + "expo-intent-launcher": "~11.0.1", "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", "expo-router": "~3.5.16", diff --git a/services/api.ts b/services/api.ts index ae20f5b..c14cb0a 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,3 +1,5 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; + // region: --- Interface Definitions --- export interface DoubanItem { title: string; @@ -104,17 +106,32 @@ export class API { return response; } - async getServerConfig(): Promise { - const response = await this._fetch("/api/server-config"); - return response.json(); - } - async login(username?: string | undefined, password?: string): Promise<{ ok: boolean }> { const response = await this._fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); + + // 存储cookie到AsyncStorage + const cookies = response.headers.get("Set-Cookie"); + if (cookies) { + await AsyncStorage.setItem("authCookies", cookies); + } + + return response.json(); + } + + async logout(): Promise<{ ok: boolean }> { + const response = await this._fetch("/api/logout", { + method: "POST", + }); + await AsyncStorage.setItem("authCookies", ''); + return response.json(); + } + + async getServerConfig(): Promise { + const response = await this._fetch("/api/server-config"); return response.json(); } diff --git a/services/updateService.ts b/services/updateService.ts index 3cd005f..305b146 100644 --- a/services/updateService.ts +++ b/services/updateService.ts @@ -1,9 +1,12 @@ -import ReactNativeBlobUtil from "react-native-blob-util"; -import FileViewer from "react-native-file-viewer"; -import Toast from "react-native-toast-message"; -import { version as currentVersion } from "../package.json"; -import { UPDATE_CONFIG } from "../constants/UpdateConfig"; +// UpdateService.ts +import * as FileSystem from 'expo-file-system'; +import * as IntentLauncher from 'expo-intent-launcher'; +// import * as Device from 'expo-device'; +import Toast from 'react-native-toast-message'; +import { version as currentVersion } from '../package.json'; +import { UPDATE_CONFIG } from '../constants/UpdateConfig'; import Logger from '@/utils/Logger'; +import { Platform } from 'react-native'; const logger = Logger.withTag('UpdateService'); @@ -12,9 +15,13 @@ interface VersionInfo { downloadUrl: string; } +/** + * 只在 Android 平台使用的常量(iOS 不会走到下载/安装流程) + */ +const ANDROID_MIME_TYPE = 'application/vnd.android.package-archive'; + class UpdateService { private static instance: UpdateService; - static getInstance(): UpdateService { if (!UpdateService.instance) { UpdateService.instance = new UpdateService(); @@ -22,203 +29,223 @@ class UpdateService { return UpdateService.instance; } + /** -------------------------------------------------------------- + * 1️⃣ 远程版本检查(保持不变,只是把 fetch 包装成 async/await) + * --------------------------------------------------------------- */ async checkVersion(): Promise { - let retries = 0; const maxRetries = 3; - - while (retries < maxRetries) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 - + const timeoutId = setTimeout(() => controller.abort(), 10_000); const response = await fetch(UPDATE_CONFIG.GITHUB_RAW_URL, { signal: controller.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: Failed to fetch version info`); + throw new Error(`HTTP ${response.status}`); } - const remotePackage = await response.json(); - const remoteVersion = remotePackage.version; - + const remoteVersion = remotePackage.version as string; return { version: remoteVersion, downloadUrl: UPDATE_CONFIG.getDownloadUrl(remoteVersion), }; - } catch (error) { - retries++; - logger.info(`Error checking version (attempt ${retries}/${maxRetries}):`, error); - - if (retries === maxRetries) { - Toast.show({ type: "error", text1: "检查更新失败", text2: "无法获取版本信息,请检查网络连接" }); - throw error; + } catch (e) { + logger.warn(`checkVersion attempt ${attempt}/${maxRetries}`, e); + if (attempt === maxRetries) { + Toast.show({ + type: 'error', + text1: '检查更新失败', + text2: '无法获取版本信息,请检查网络', + }); + throw e; } - - // 等待一段时间后重试 - await new Promise(resolve => setTimeout(resolve, 2000 * retries)); + // 指数退避 + await new Promise(r => setTimeout(r, 2_000 * attempt)); } } - - throw new Error("Maximum retry attempts exceeded"); + // 这句永远走不到,仅为 TypeScript 报错 + throw new Error('Unexpected'); } - // 清理旧的APK文件 + /** -------------------------------------------------------------- + * 2️⃣ 清理旧的 APK 文件(使用 expo-file-system 的 API) + * --------------------------------------------------------------- */ private async cleanOldApkFiles(): Promise { try { - const { dirs } = ReactNativeBlobUtil.fs; - // 使用DocumentDir而不是DownloadDir - const files = await ReactNativeBlobUtil.fs.ls(dirs.DocumentDir); - - // 查找所有OrionTV APK文件 - const apkFiles = files.filter(file => file.startsWith('OrionTV_v') && file.endsWith('.apk')); - - // 保留最新的2个文件,删除其他的 - if (apkFiles.length > 2) { - const sortedFiles = apkFiles.sort((a, b) => { - // 从文件名中提取时间戳进行排序 - const timeA = a.match(/OrionTV_v(\d+)\.apk/)?.[1] || '0'; - const timeB = b.match(/OrionTV_v(\d+)\.apk/)?.[1] || '0'; - return parseInt(timeB) - parseInt(timeA); - }); - - // 删除旧文件 - const filesToDelete = sortedFiles.slice(2); - for (const file of filesToDelete) { - try { - await ReactNativeBlobUtil.fs.unlink(`${dirs.DocumentDir}/${file}`); - logger.debug(`Cleaned old APK file: ${file}`); - } catch (deleteError) { - logger.warn(`Failed to delete old APK file ${file}:`, deleteError); - } + const dirUri = FileSystem.documentDirectory; // e.g. file:///data/user/0/.../files/ + if (!dirUri) { + throw new Error('Document directory is not available'); + } + const listing = await FileSystem.readDirectoryAsync(dirUri); + const apkFiles = listing.filter(name => name.startsWith('OrionTV_v') && name.endsWith('.apk')); + + if (apkFiles.length <= 2) return; + + const sorted = apkFiles.sort((a, b) => { + const numA = parseInt(a.replace(/[^0-9]/g, ''), 10); + const numB = parseInt(b.replace(/[^0-9]/g, ''), 10); + return numB - numA; // 倒序(最新在前) + }); + + const stale = sorted.slice(2); // 保留最新的两个 + for (const file of stale) { + const path = `${dirUri}${file}`; + try { + await FileSystem.deleteAsync(path, { idempotent: true }); + logger.debug(`Deleted old APK: ${file}`); + } catch (e) { + logger.warn(`Failed to delete ${file}`, e); } } - } catch (error) { - logger.warn('Failed to clean old APK files:', error); + } catch (e) { + logger.warn('cleanOldApkFiles error', e); } } - async downloadApk(url: string, onProgress?: (progress: number) => void): Promise { - let retries = 0; + /** -------------------------------------------------------------- + * 3️⃣ 下载 APK(使用 expo-file-system 的下载 API) + * --------------------------------------------------------------- */ + async downloadApk( + url: string, + onProgress?: (percent: number) => void, + ): Promise { const maxRetries = 3; - - // 清理旧文件 await this.cleanOldApkFiles(); - - while (retries < maxRetries) { + + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - const { dirs } = ReactNativeBlobUtil.fs; - const timestamp = new Date().getTime(); + const timestamp = Date.now(); const fileName = `OrionTV_v${timestamp}.apk`; - // 使用应用的外部文件目录,而不是系统下载目录 - const filePath = `${dirs.DocumentDir}/${fileName}`; + const fileUri = `${FileSystem.documentDirectory}${fileName}`; - const task = ReactNativeBlobUtil.config({ - fileCache: true, - path: filePath, - timeout: UPDATE_CONFIG.DOWNLOAD_TIMEOUT, - // 移除 addAndroidDownloads 配置,避免使用系统下载管理器 - // addAndroidDownloads: { - // useDownloadManager: true, - // notification: true, - // title: UPDATE_CONFIG.NOTIFICATION.TITLE, - // description: UPDATE_CONFIG.NOTIFICATION.DOWNLOADING_TEXT, - // mime: "application/vnd.android.package-archive", - // mediaScannable: true, - // }, - }).fetch("GET", url); + // expo-file-system 把下载进度回调参数统一为 `{totalBytesWritten, totalBytesExpectedToWrite}` + const downloadResumable = FileSystem.createDownloadResumable( + url, + fileUri, + { + // Android 需要在 AndroidManifest 中声明 INTERNET、WRITE_EXTERNAL_STORAGE (API 33+ 使用 MANAGE_EXTERNAL_STORAGE) + // 这里不使用系统下载管理器,因为我们想自己控制进度回调。 + }, + progress => { + if (onProgress && progress.totalBytesExpectedToWrite) { + const percent = Math.floor( + (progress.totalBytesWritten / progress.totalBytesExpectedToWrite) * 100, + ); + onProgress(percent); + } + }, + ); - // 监听下载进度 - if (onProgress) { - task.progress((received: string, total: string) => { - const receivedNum = parseInt(received, 10); - const totalNum = parseInt(total, 10); - const progress = Math.floor((receivedNum / totalNum) * 100); - onProgress(progress); + const result = await downloadResumable.downloadAsync(); + if (result && result.uri) { + logger.debug(`APK downloaded to ${result.uri}`); + return result.uri; + } else { + throw new Error('Download failed: No URI available'); + } + } catch (e) { + logger.warn(`downloadApk attempt ${attempt}/${maxRetries}`, e); + if (attempt === maxRetries) { + Toast.show({ + type: 'error', + text1: '下载失败', + text2: 'APK 下载出现错误,请检查网络', + }); + throw e; + } + // 指数退避 + await new Promise(r => setTimeout(r, 3_000 * attempt)); + } + } + // 同上,理论不会到这里 + throw new Error('Download failed'); + } + + /** -------------------------------------------------------------- + * 4️⃣ 安装 APK(只在 Android 可用,使用 expo-intent-launcher) + * --------------------------------------------------------------- */ + async installApk(fileUri: string): Promise { + // if (!Device.isDevice) { + // // 在模拟器里打开文件会报错,直接给用户提示 + // Toast.show({ + // type: 'error', + // text1: '安装失败', + // text2: '模拟器不支持直接安装 APK,请在真机上操作', + // }); + // throw new Error('Cannot install on simulator'); + // } + + const exists = await FileSystem.getInfoAsync(fileUri); + if (!exists.exists) { + throw new Error(`APK not found at ${fileUri}`); + } + + // Android 需要给 Intent 设置 mime 类型,并且使用 ACTION_VIEW + if (Platform.OS === 'android') { + try { + // Android 7+ 需要给出 URI 权限(FileProvider),Expo‑Intent‑Launcher 已经在内部使用了 + await IntentLauncher.startActivityAsync('android.intent.action.VIEW', { + data: fileUri, + type: ANDROID_MIME_TYPE, + flags: 1, // FLAG_ACTIVITY_NEW_TASK + }); + } catch (e: any) { + // 常见错误:没有“未知来源”权限、或没有安装包管理器 + if (e.message?.includes('Activity not found')) { + Toast.show({ + type: 'error', + text1: '安装失败', + text2: '系统没有找到可以打开 APK 的应用,请检查系统设置', + }); + } else if (e.message?.includes('permission')) { + Toast.show({ + type: 'error', + text1: '安装失败', + text2: '请在设置里允许“未知来源”安装', + }); + } else { + Toast.show({ + type: 'error', + text1: '安装失败', + text2: '未知错误,请稍后重试', }); } - - const res = await task; - logger.debug(`APK downloaded successfully: ${filePath}`); - return res.path(); - } catch (error) { - retries++; - logger.info(`Error downloading APK (attempt ${retries}/${maxRetries}):`, error); - - if (retries === maxRetries) { - Toast.show({ type: "error", text1: "下载失败", text2: "APK下载失败,请检查网络连接" }); - throw new Error(`Download failed after ${maxRetries} attempts: ${error}`); - } - - // 等待一段时间后重试 - await new Promise(resolve => setTimeout(resolve, 3000 * retries)); + throw e; } - } - - throw new Error("Maximum retry attempts exceeded for download"); - } - - async installApk(filePath: string): Promise { - try { - // 首先检查文件是否存在 - const exists = await ReactNativeBlobUtil.fs.exists(filePath); - if (!exists) { - throw new Error(`APK file not found: ${filePath}`); - } - - // 使用FileViewer打开APK文件进行安装 - // 这会触发Android的包安装器 - await FileViewer.open(filePath, { - showOpenWithDialog: true, // 显示选择应用对话框 - showAppsSuggestions: true, // 显示应用建议 - displayName: "OrionTV Update", + } else { + // iOS 是不支持的,直接提示用户 + Toast.show({ + type: 'error', + text1: '安装失败', + text2: 'iOS 设备无法直接安装 APK', }); - } catch (error) { - logger.info("Error installing APK:", error); - - // 提供更详细的错误信息 - if (error instanceof Error) { - if (error.message.includes('No app found')) { - Toast.show({ type: "error", text1: "安装失败", text2: "未找到可安装APK的应用,请确保允许安装未知来源的应用" }); - throw new Error('未找到可安装APK的应用,请确保允许安装未知来源的应用'); - } else if (error.message.includes('permission')) { - Toast.show({ type: "error", text1: "安装失败", text2: "没有安装权限,请在设置中允许此应用安装未知来源的应用" }); - throw new Error('没有安装权限,请在设置中允许此应用安装未知来源的应用'); - } else { - Toast.show({ type: "error", text1: "安装失败", text2: "APK安装过程中出现错误" }); - } - } else { - Toast.show({ type: "error", text1: "安装失败", text2: "APK安装过程中出现未知错误" }); - } - - throw error; + throw new Error('APK install not supported on iOS'); } } + /** -------------------------------------------------------------- + * 5️⃣ 版本比对工具(保持原来的实现) + * --------------------------------------------------------------- */ compareVersions(v1: string, v2: string): number { - const parts1 = v1.split(".").map(Number); - const parts2 = v2.split(".").map(Number); - - for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { - const part1 = parts1[i] || 0; - const part2 = parts2[i] || 0; - - if (part1 > part2) return 1; - if (part1 < part2) return -1; + const p1 = v1.split('.').map(Number); + const p2 = v2.split('.').map(Number); + for (let i = 0; i < Math.max(p1.length, p2.length); i++) { + const n1 = p1[i] ?? 0; + const n2 = p2[i] ?? 0; + if (n1 > n2) return 1; + if (n1 < n2) return -1; } - return 0; } - getCurrentVersion(): string { return currentVersion; } - isUpdateAvailable(remoteVersion: string): boolean { return this.compareVersions(remoteVersion, currentVersion) > 0; } } +/* 单例导出 */ export default UpdateService.getInstance(); diff --git a/stores/authStore.ts b/stores/authStore.ts index f9cba1d..0feb34a 100644 --- a/stores/authStore.ts +++ b/stores/authStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import Cookies from "@react-native-cookies/cookies"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { api } from "@/services/api"; import { useSettingsStore } from "./settingsStore"; import Toast from "react-native-toast-message"; @@ -30,14 +30,14 @@ const useAuthStore = create((set) => ({ // Wait for server config to be loaded if it's currently loading const settingsState = useSettingsStore.getState(); let serverConfig = settingsState.serverConfig; - + // If server config is loading, wait a bit for it to complete if (settingsState.isLoadingServerConfig) { // Wait up to 3 seconds for server config to load const maxWaitTime = 3000; const checkInterval = 100; let waitTime = 0; - + while (waitTime < maxWaitTime) { await new Promise(resolve => setTimeout(resolve, checkInterval)); waitTime += checkInterval; @@ -48,7 +48,7 @@ const useAuthStore = create((set) => ({ } } } - + if (!serverConfig?.StorageType) { // Only show error if we're not loading and have tried to fetch the config if (!settingsState.isLoadingServerConfig) { @@ -56,20 +56,21 @@ const useAuthStore = create((set) => ({ } return; } - const cookies = await Cookies.get(api.baseURL); - if (serverConfig && serverConfig.StorageType === "localstorage" && !cookies.auth) { - const loginResult = await api.login().catch(() => { + + const authToken = await AsyncStorage.getItem('authCookies'); + if (!authToken) { + if (serverConfig && serverConfig.StorageType === "localstorage") { + const loginResult = await api.login().catch(() => { + set({ isLoggedIn: false, isLoginModalVisible: true }); + }); + if (loginResult && loginResult.ok) { + set({ isLoggedIn: true }); + } + } else { set({ isLoggedIn: false, isLoginModalVisible: true }); - }); - if (loginResult && loginResult.ok) { - set({ isLoggedIn: true }); } } else { - const isLoggedIn = cookies && !!cookies.auth; - set({ isLoggedIn }); - if (!isLoggedIn) { - set({ isLoginModalVisible: true }); - } + set({ isLoggedIn: true, isLoginModalVisible: false }); } } catch (error) { logger.error("Failed to check login status:", error); @@ -82,7 +83,7 @@ const useAuthStore = create((set) => ({ }, logout: async () => { try { - await Cookies.clearAll(); + await api.logout(); set({ isLoggedIn: false, isLoginModalVisible: true }); } catch (error) { logger.error("Failed to logout:", error); diff --git a/yarn.lock b/yarn.lock index d4832c2..6ca2127 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4592,6 +4592,11 @@ expo-font@~12.0.10, expo-font@~12.0.7: dependencies: fontfaceobserver "^2.1.0" +expo-intent-launcher@~11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/expo-intent-launcher/-/expo-intent-launcher-11.0.1.tgz#297dc4d084b1e3e2fab431afc847800f87cd1dc2" + integrity sha512-nUmTTa/HG4jUyRc5YHngdpP5bMyGSRZPi2RX9kpILd3vbMWQeVnwzqAfC+uI34W8uKhEk+9b9Dytzmm7bBND1Q== + expo-keep-awake@~13.0.2: version "13.0.2" resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz#5ef31311a339671eec9921b934fdd90ab9652b0e"