11 Commits

Author SHA1 Message Date
litecn
619901ef69 Bump version from 1.3.12 to 1.3.13 2026-02-05 11:04:58 +08:00
litecn
8523e5f157 Merge pull request #266 from litecn/cokvr
fix: set login state and show login modal on authentication failure
2026-02-05 10:38:47 +08:00
James Chen
29ad5a5e75 fix: set login state and show login modal on authentication failure 2026-02-05 10:32:05 +08:00
litecn
cf854c3c9f Bump version from 1.3.11 to 1.3.12 2025-12-17 16:29:59 +08:00
litecn
a86eb8ca5c Merge pull request #257 from litecn/cokvr
fix: 修复显示“认证失败,请重新登录”,却找不到登录框 #247 #255
2025-12-17 16:27:30 +08:00
James Chen
487c15d8b6 fix: 修复显示“认证失败,请重新登录”,却找不到登录框 #247 #255 2025-12-13 22:26:00 +08:00
litecn
3526189e32 Merge pull request #240 from litecn/cokvr
fix: installApk error: exposed beyond app through Intent.getData()
2025-10-12 16:19:13 +08:00
James Chen
c473581c26 fix: installApk error: exposed beyond app through Intent.getData() 2025-10-12 15:33:30 +08:00
Xin
826380714d Update package.json 2025-10-09 11:24:29 +08:00
Xin
3caa9af11a Merge pull request #234 from litecn/cokvr
fix: ios-tv work ok
2025-10-09 11:23:41 +08:00
James Chen
e6194a50ab fix: ios-tv work ok 2025-09-22 15:16:13 +08:00
13 changed files with 410 additions and 234 deletions

View File

@@ -56,15 +56,15 @@
"screenOrientation": "unspecified", "screenOrientation": "unspecified",
"intentFilters": [ "intentFilters": [
{ {
"action": "VIEW", "action": "android.intent.action.VIEW",
"data": [ "data": [
{ {
"scheme": "oriontv" "scheme": "oriontv"
} }
], ],
"category": [ "category": [
"BROWSABLE", "android.intent.category.BROWSABLE",
"DEFAULT" "android.intent.category.DEFAULT"
] ]
} }
] ]

View File

@@ -55,7 +55,6 @@ export default function HomeScreen() {
// 双击返回退出逻辑(只限当前页面) // 双击返回退出逻辑(只限当前页面)
const backPressTimeRef = useRef<number | null>(null); const backPressTimeRef = useRef<number | null>(null);
const exitToastShownRef = useRef(false); // 防止重复显示提示
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -82,7 +81,6 @@ export default function HomeScreen() {
return () => { return () => {
backHandler.remove(); backHandler.remove();
backPressTimeRef.current = null; backPressTimeRef.current = null;
exitToastShownRef.current = false;
}; };
} }
}, []) }, [])

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react"; 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 { useTVEventHandler } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
@@ -22,6 +22,18 @@ import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
import { DeviceUtils } from "@/utils/DeviceUtils"; import { DeviceUtils } from "@/utils/DeviceUtils";
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; 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() { export default function SettingsScreen() {
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore(); const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
const { lastMessage, targetPage, clearMessage } = useRemoteControlStore(); const { lastMessage, targetPage, clearMessage } = useRemoteControlStore();
@@ -87,8 +99,66 @@ export default function SettingsScreen() {
setHasChanges(true); setHasChanges(true);
}; };
const sections = [ // const sections = [
// 远程输入配置 - 仅在非手机端显示 // // 远程输入配置 - 仅在非手机端显示
// deviceType !== "mobile" && {
// component: (
// <RemoteInputSection
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(0);
// setCurrentSection("remote");
// }}
// />
// ),
// key: "remote",
// },
// {
// component: (
// <APIConfigSection
// ref={apiSectionRef}
// onChanged={markAsChanged}
// hideDescription={deviceType === "mobile"}
// onFocus={() => {
// setCurrentFocusIndex(1);
// setCurrentSection("api");
// }}
// />
// ),
// key: "api",
// },
// // 直播源配置 - 仅在非手机端显示
// deviceType !== "mobile" && {
// component: (
// <LiveStreamSection
// ref={liveStreamSectionRef}
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(2);
// setCurrentSection("livestream");
// }}
// />
// ),
// key: "livestream",
// },
// // {
// // component: (
// // <VideoSourceSection
// // onChanged={markAsChanged}
// // onFocus={() => {
// // setCurrentFocusIndex(3);
// // setCurrentSection("videoSource");
// // }}
// // />
// // ),
// // key: "videoSource",
// // },
// Platform.OS === "android" && {
// component: <UpdateSection />,
// key: "update",
// },
// ].filter(Boolean);
const rawSections = [
deviceType !== "mobile" && { deviceType !== "mobile" && {
component: ( component: (
<RemoteInputSection <RemoteInputSection
@@ -115,7 +185,6 @@ export default function SettingsScreen() {
), ),
key: "api", key: "api",
}, },
// 直播源配置 - 仅在非手机端显示
deviceType !== "mobile" && { deviceType !== "mobile" && {
component: ( component: (
<LiveStreamSection <LiveStreamSection
@@ -129,23 +198,14 @@ export default function SettingsScreen() {
), ),
key: "livestream", key: "livestream",
}, },
// {
// component: (
// <VideoSourceSection
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(3);
// setCurrentSection("videoSource");
// }}
// />
// ),
// key: "videoSource",
// },
Platform.OS === "android" && { Platform.OS === "android" && {
component: <UpdateSection />, component: <UpdateSection />,
key: "update", key: "update",
}, },
].filter(Boolean); ] as const; // 把每个对象都当作字面量保留
/** 这里得到的 sections 已经是 SectionItem[](没有 false */
const sections: SectionItem[] = rawSections.filter(isSectionItem);
// TV遥控器事件处理 - 仅在TV设备上启用 // TV遥控器事件处理 - 仅在TV设备上启用
const handleTVEvent = React.useCallback( const handleTVEvent = React.useCallback(
@@ -189,7 +249,7 @@ export default function SettingsScreen() {
</View> </View>
)} )}
<View style={dynamicStyles.scrollView}> {/* <View style={dynamicStyles.scrollView}>
<FlatList <FlatList
data={sections} data={sections}
renderItem={({ item }) => { renderItem={({ item }) => {
@@ -202,6 +262,14 @@ export default function SettingsScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={dynamicStyles.listContent} contentContainerStyle={dynamicStyles.listContent}
/> />
</View> */}
<View style={dynamicStyles.scrollView}>
{sections.map(item => (
// 必须把 key 放在最外层的 View 上
<View key={item.key} style={dynamicStyles.itemWrapper}>
{item.component}
</View>
))}
</View> </View>
<View style={dynamicStyles.footer}> <View style={dynamicStyles.footer}>
@@ -273,5 +341,8 @@ const createResponsiveStyles = (deviceType: string, spacing: number, insets: any
disabledButton: { disabledButton: {
opacity: 0.5, opacity: 0.5,
}, },
itemWrapper: {
marginBottom: spacing, // 这里的 spacing 来自 useResponsiveLayout()
},
}); });
}; };

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react"; 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 { usePathname } from "expo-router";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import useAuthStore from "@/stores/authStore"; import useAuthStore from "@/stores/authStore";
@@ -23,9 +23,14 @@ const LoginModal = () => {
const pathname = usePathname(); const pathname = usePathname();
const isSettingsPage = pathname.includes("settings"); const isSettingsPage = pathname.includes("settings");
const [isModalReady, setIsModalReady] = useState(false);
// Load saved credentials when modal opens // Load saved credentials when modal opens
useEffect(() => { useEffect(() => {
if (isLoginModalVisible && !isSettingsPage) { if (isLoginModalVisible && !isSettingsPage) {
// 先确保键盘状态清理
Keyboard.dismiss();
const loadCredentials = async () => { const loadCredentials = async () => {
const savedCredentials = await LoginCredentialsManager.get(); const savedCredentials = await LoginCredentialsManager.get();
if (savedCredentials) { if (savedCredentials) {
@@ -34,12 +39,22 @@ const LoginModal = () => {
} }
}; };
loadCredentials(); loadCredentials();
// 延迟设置 Modal 就绪状态
const readyTimeout = setTimeout(() => {
setIsModalReady(true);
}, 300);
return () => {
clearTimeout(readyTimeout);
setIsModalReady(false);
};
} }
}, [isLoginModalVisible, isSettingsPage]); }, [isLoginModalVisible, isSettingsPage]);
// Focus management with better TV remote handling // Focus management with better TV remote handling
useEffect(() => { useEffect(() => {
if (isLoginModalVisible && !isSettingsPage) { if (isModalReady && isLoginModalVisible && !isSettingsPage) {
const isUsernameVisible = serverConfig?.StorageType !== "localstorage"; const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
// Use a small delay to ensure the modal is fully rendered // Use a small delay to ensure the modal is fully rendered
@@ -49,11 +64,19 @@ const LoginModal = () => {
} else { } else {
passwordInputRef.current?.focus(); passwordInputRef.current?.focus();
} }
}, 100); }, 300);
return () => clearTimeout(focusTimeout); return () => clearTimeout(focusTimeout);
} }
}, [isLoginModalVisible, serverConfig, isSettingsPage]); }, [isModalReady, isLoginModalVisible, serverConfig, isSettingsPage]);
// 清理 effect - 确保 Modal 关闭时清理所有状态
useEffect(() => {
return () => {
Keyboard.dismiss();
setIsModalReady(false);
};
}, []);
const handleLogin = async () => { const handleLogin = async () => {
const isLocalStorage = serverConfig?.StorageType === "localstorage"; const isLocalStorage = serverConfig?.StorageType === "localstorage";
@@ -71,14 +94,33 @@ const LoginModal = () => {
await LoginCredentialsManager.save({ username, password }); await LoginCredentialsManager.save({ username, password });
Toast.show({ type: "success", text1: "登录成功" }); Toast.show({ type: "success", text1: "登录成功" });
hideLoginModal(); // 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);
// Show disclaimer alert after successful login
Alert.alert(
"免责声明",
"本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
[{ text: "确定" }]
);
} catch (error) { } catch (error) {
Toast.show({ Toast.show({
type: "error", type: "error",

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react"; 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 { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection"; import { SettingsSection } from "./SettingsSection";
@@ -59,20 +59,31 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
return ( return (
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur} <SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
{...Platform.isTV||deviceType !=='tv'? undefined :{onPress:handlePress}} {...Platform.isTV || deviceType !== 'tv' ? undefined : { onPress: handlePress }}
> >
<Pressable style={styles.settingItem} onFocus={handleSectionFocus} onBlur={handleSectionBlur}> <Pressable style={styles.settingItem} onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<ThemedText style={styles.settingName}></ThemedText> <ThemedText style={styles.settingName}></ThemedText>
</View> </View>
<Animated.View style={animationStyle}> <Animated.View style={animationStyle}>
<Switch { Platform.OS === 'ios' && Platform.isTV ? (
value={remoteInputEnabled} <TouchableOpacity
onValueChange={() => {}} // 禁用Switch的直接交互 activeOpacity={0.8}
trackColor={{ false: "#767577", true: Colors.dark.primary }} onPress={() => handlePress()}
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"} style={styles.statusLabel}
pointerEvents="none" >
/> <ThemedText style={styles.statusValue}>{remoteInputEnabled ? '已启用' : '已禁用'}</ThemedText>
</TouchableOpacity>
) : (
<Switch
value={remoteInputEnabled}
onValueChange={() => { }} // 禁用Switch的直接交互
trackColor={{ false: "#767577", true: Colors.dark.primary }}
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
pointerEvents="none"
/>
)
}
</Animated.View> </Animated.View>
</Pressable> </Pressable>

View File

@@ -2,7 +2,7 @@
"name": "OrionTV", "name": "OrionTV",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.3.10", "version": "1.3.13",
"scripts": { "scripts": {
"start": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start", "start": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"android": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android", "android": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
@@ -36,6 +36,7 @@
"expo-build-properties": "~0.12.3", "expo-build-properties": "~0.12.3",
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-font": "~12.0.7", "expo-font": "~12.0.7",
"expo-intent-launcher": "~11.0.1",
"expo-linear-gradient": "~13.0.2", "expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-router": "~3.5.16", "expo-router": "~3.5.16",

View File

@@ -1,3 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
// region: --- Interface Definitions --- // region: --- Interface Definitions ---
export interface DoubanItem { export interface DoubanItem {
title: string; title: string;
@@ -104,17 +106,32 @@ export class API {
return response; return response;
} }
async getServerConfig(): Promise<ServerConfig> {
const response = await this._fetch("/api/server-config");
return response.json();
}
async login(username?: string | undefined, password?: string): Promise<{ ok: boolean }> { async login(username?: string | undefined, password?: string): Promise<{ ok: boolean }> {
const response = await this._fetch("/api/login", { const response = await this._fetch("/api/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), 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<ServerConfig> {
const response = await this._fetch("/api/server-config");
return response.json(); return response.json();
} }

View File

@@ -1,9 +1,12 @@
import ReactNativeBlobUtil from "react-native-blob-util"; // UpdateService.ts
import FileViewer from "react-native-file-viewer"; import * as FileSystem from 'expo-file-system';
import Toast from "react-native-toast-message"; import * as IntentLauncher from 'expo-intent-launcher';
import { version as currentVersion } from "../package.json"; // import * as Device from 'expo-device';
import { UPDATE_CONFIG } from "../constants/UpdateConfig"; 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 Logger from '@/utils/Logger';
import { Platform } from 'react-native';
const logger = Logger.withTag('UpdateService'); const logger = Logger.withTag('UpdateService');
@@ -12,9 +15,13 @@ interface VersionInfo {
downloadUrl: string; downloadUrl: string;
} }
/**
* 只在 Android 平台使用的常量iOS 不会走到下载/安装流程)
*/
const ANDROID_MIME_TYPE = 'application/vnd.android.package-archive';
class UpdateService { class UpdateService {
private static instance: UpdateService; private static instance: UpdateService;
static getInstance(): UpdateService { static getInstance(): UpdateService {
if (!UpdateService.instance) { if (!UpdateService.instance) {
UpdateService.instance = new UpdateService(); UpdateService.instance = new UpdateService();
@@ -22,203 +29,220 @@ class UpdateService {
return UpdateService.instance; return UpdateService.instance;
} }
/** --------------------------------------------------------------
* 1⃣ 远程版本检查(保持不变,只是把 fetch 包装成 async/await
* --------------------------------------------------------------- */
async checkVersion(): Promise<VersionInfo> { async checkVersion(): Promise<VersionInfo> {
let retries = 0;
const maxRetries = 3; const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
while (retries < maxRetries) {
try { try {
const controller = new AbortController(); 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, { const response = await fetch(UPDATE_CONFIG.GITHUB_RAW_URL, {
signal: controller.signal, signal: controller.signal,
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) { 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 remotePackage = await response.json();
const remoteVersion = remotePackage.version; const remoteVersion = remotePackage.version as string;
return { return {
version: remoteVersion, version: remoteVersion,
downloadUrl: UPDATE_CONFIG.getDownloadUrl(remoteVersion), downloadUrl: UPDATE_CONFIG.getDownloadUrl(remoteVersion),
}; };
} catch (error) { } catch (e) {
retries++; logger.warn(`checkVersion attempt ${attempt}/${maxRetries}`, e);
logger.info(`Error checking version (attempt ${retries}/${maxRetries}):`, error); if (attempt === maxRetries) {
Toast.show({
if (retries === maxRetries) { type: 'error',
Toast.show({ type: "error", text1: "检查更新失败", text2: "无法获取版本信息,请检查网络连接" }); text1: '检查更新失败',
throw error; text2: '无法获取版本信息,请检查网络',
});
throw e;
} }
// 指数退避
// 等待一段时间后重试 await new Promise(r => setTimeout(r, 2_000 * attempt));
await new Promise(resolve => setTimeout(resolve, 2000 * retries));
} }
} }
// 这句永远走不到,仅为 TypeScript 报错
throw new Error("Maximum retry attempts exceeded"); throw new Error('Unexpected');
} }
// 清理旧的APK文件 /** --------------------------------------------------------------
* 2⃣ 清理旧的 APK 文件(使用 expo-file-system 的 API
* --------------------------------------------------------------- */
private async cleanOldApkFiles(): Promise<void> { private async cleanOldApkFiles(): Promise<void> {
try { try {
const { dirs } = ReactNativeBlobUtil.fs; const dirUri = FileSystem.documentDirectory; // e.g. file:///data/user/0/.../files/
// 使用DocumentDir而不是DownloadDir if (!dirUri) {
const files = await ReactNativeBlobUtil.fs.ls(dirs.DocumentDir); 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'));
// 查找所有OrionTV APK文件 if (apkFiles.length <= 2) return;
const apkFiles = files.filter(file => file.startsWith('OrionTV_v') && file.endsWith('.apk'));
// 保留最新的2个文件删除其他的 const sorted = apkFiles.sort((a, b) => {
if (apkFiles.length > 2) { const numA = parseInt(a.replace(/[^0-9]/g, ''), 10);
const sortedFiles = apkFiles.sort((a, b) => { const numB = parseInt(b.replace(/[^0-9]/g, ''), 10);
// 从文件名中提取时间戳进行排序 return numB - numA; // 倒序(最新在前)
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 stale = sorted.slice(2); // 保留最新的两个
const filesToDelete = sortedFiles.slice(2); for (const file of stale) {
for (const file of filesToDelete) { const path = `${dirUri}${file}`;
try { try {
await ReactNativeBlobUtil.fs.unlink(`${dirs.DocumentDir}/${file}`); await FileSystem.deleteAsync(path, { idempotent: true });
logger.debug(`Cleaned old APK file: ${file}`); logger.debug(`Deleted old APK: ${file}`);
} catch (deleteError) { } catch (e) {
logger.warn(`Failed to delete old APK file ${file}:`, deleteError); logger.warn(`Failed to delete ${file}`, e);
}
} }
} }
} catch (error) { } catch (e) {
logger.warn('Failed to clean old APK files:', error); logger.warn('cleanOldApkFiles error', e);
} }
} }
async downloadApk(url: string, onProgress?: (progress: number) => void): Promise<string> { /** --------------------------------------------------------------
let retries = 0; * 3⃣ 下载 APK使用 expo-file-system 的下载 API
* --------------------------------------------------------------- */
async downloadApk(
url: string,
onProgress?: (percent: number) => void,
): Promise<string> {
const maxRetries = 3; const maxRetries = 3;
// 清理旧文件
await this.cleanOldApkFiles(); await this.cleanOldApkFiles();
while (retries < maxRetries) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
const { dirs } = ReactNativeBlobUtil.fs; const timestamp = Date.now();
const timestamp = new Date().getTime();
const fileName = `OrionTV_v${timestamp}.apk`; const fileName = `OrionTV_v${timestamp}.apk`;
// 使用应用的外部文件目录,而不是系统下载目录 const fileUri = `${FileSystem.documentDirectory}${fileName}`;
const filePath = `${dirs.DocumentDir}/${fileName}`;
const task = ReactNativeBlobUtil.config({ // expo-file-system 把下载进度回调参数统一为 `{totalBytesWritten, totalBytesExpectedToWrite}`
fileCache: true, const downloadResumable = FileSystem.createDownloadResumable(
path: filePath, url,
timeout: UPDATE_CONFIG.DOWNLOAD_TIMEOUT, fileUri,
// 移除 addAndroidDownloads 配置,避免使用系统下载管理器 {
// addAndroidDownloads: { // Android 需要在 AndroidManifest 中声明 INTERNET、WRITE_EXTERNAL_STORAGE (API 33+ 使用 MANAGE_EXTERNAL_STORAGE)
// useDownloadManager: true, // 这里不使用系统下载管理器,因为我们想自己控制进度回调。
// notification: true, },
// title: UPDATE_CONFIG.NOTIFICATION.TITLE, progress => {
// description: UPDATE_CONFIG.NOTIFICATION.DOWNLOADING_TEXT, if (onProgress && progress.totalBytesExpectedToWrite) {
// mime: "application/vnd.android.package-archive", const percent = Math.floor(
// mediaScannable: true, (progress.totalBytesWritten / progress.totalBytesExpectedToWrite) * 100,
// }, );
}).fetch("GET", url); onProgress(percent);
}
},
);
// 监听下载进度 const result = await downloadResumable.downloadAsync();
if (onProgress) { if (result && result.uri) {
task.progress((received: string, total: string) => { logger.debug(`APK downloaded to ${result.uri}`);
const receivedNum = parseInt(received, 10); return result.uri;
const totalNum = parseInt(total, 10); } else {
const progress = Math.floor((receivedNum / totalNum) * 100); throw new Error('Download failed: No URI available');
onProgress(progress); }
} 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<void> {
// ① 先确认文件存在
const exists = await FileSystem.getInfoAsync(fileUri);
if (!exists.exists) {
throw new Error(`APK not found at ${fileUri}`);
}
// ② 把 file:// 转成 content://ExpoFileSystem 已经实现了 FileProvider
const contentUri = await FileSystem.getContentUriAsync(fileUri);
// ③ 只在 Android 里执行
if (Platform.OS === 'android') {
try {
// FLAG_ACTIVITY_NEW_TASK = 0x10000000 (1)
// FLAG_GRANT_READ_URI_PERMISSION = 0x00000010
const flags = 1 | 0x00000010; // 1 | 16
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri, // 必须是 content://
type: ANDROID_MIME_TYPE, // application/vnd.android.package-archive
flags,
});
} 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: '未知错误,请稍后重试',
}); });
} }
throw e;
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));
} }
} } else {
// iOS 设备不支持直接安装 APK
throw new Error("Maximum retry attempts exceeded for download"); Toast.show({
} type: 'error',
text1: '安装失败',
async installApk(filePath: string): Promise<void> { text2: 'iOS 设备无法直接安装 APK',
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",
}); });
} catch (error) { throw new Error('APK install not supported on iOS');
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;
} }
} }
/** --------------------------------------------------------------
* 5⃣ 版本比对工具(保持原来的实现)
* --------------------------------------------------------------- */
compareVersions(v1: string, v2: string): number { compareVersions(v1: string, v2: string): number {
const parts1 = v1.split(".").map(Number); const p1 = v1.split('.').map(Number);
const parts2 = v2.split(".").map(Number); const p2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(p1.length, p2.length); i++) {
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { const n1 = p1[i] ?? 0;
const part1 = parts1[i] || 0; const n2 = p2[i] ?? 0;
const part2 = parts2[i] || 0; if (n1 > n2) return 1;
if (n1 < n2) return -1;
if (part1 > part2) return 1;
if (part1 < part2) return -1;
} }
return 0; return 0;
} }
getCurrentVersion(): string { getCurrentVersion(): string {
return currentVersion; return currentVersion;
} }
isUpdateAvailable(remoteVersion: string): boolean { isUpdateAvailable(remoteVersion: string): boolean {
return this.compareVersions(remoteVersion, currentVersion) > 0; return this.compareVersions(remoteVersion, currentVersion) > 0;
} }
} }
/* 单例导出 */
export default UpdateService.getInstance(); export default UpdateService.getInstance();

View File

@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 { api } from "@/services/api";
import { useSettingsStore } from "./settingsStore"; import { useSettingsStore } from "./settingsStore";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
@@ -56,20 +56,21 @@ const useAuthStore = create<AuthState>((set) => ({
} }
return; return;
} }
const cookies = await Cookies.get(api.baseURL);
if (serverConfig && serverConfig.StorageType === "localstorage" && !cookies.auth) { const authToken = await AsyncStorage.getItem('authCookies');
const loginResult = await api.login().catch(() => { 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 }); set({ isLoggedIn: false, isLoginModalVisible: true });
});
if (loginResult && loginResult.ok) {
set({ isLoggedIn: true });
} }
} else { } else {
const isLoggedIn = cookies && !!cookies.auth; set({ isLoggedIn: true, isLoginModalVisible: false });
set({ isLoggedIn });
if (!isLoggedIn) {
set({ isLoginModalVisible: true });
}
} }
} catch (error) { } catch (error) {
logger.error("Failed to check login status:", error); logger.error("Failed to check login status:", error);
@@ -82,7 +83,7 @@ const useAuthStore = create<AuthState>((set) => ({
}, },
logout: async () => { logout: async () => {
try { try {
await Cookies.clearAll(); await api.logout();
set({ isLoggedIn: false, isLoginModalVisible: true }); set({ isLoggedIn: false, isLoginModalVisible: true });
} catch (error) { } catch (error) {
logger.error("Failed to logout:", error); logger.error("Failed to logout:", error);

View File

@@ -254,6 +254,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
errorMessage = "请点击右上角设置按钮,配置您的服务器地址"; errorMessage = "请点击右上角设置按钮,配置您的服务器地址";
} else if (err.message === "UNAUTHORIZED") { } else if (err.message === "UNAUTHORIZED") {
errorMessage = "认证失败,请重新登录"; errorMessage = "认证失败,请重新登录";
useAuthStore.setState({ isLoggedIn: false, isLoginModalVisible: true });
} else if (err.message.includes("Network")) { } else if (err.message.includes("Network")) {
errorMessage = "网络连接失败,请检查网络连接"; errorMessage = "网络连接失败,请检查网络连接";
} else if (err.message.includes("timeout")) { } else if (err.message.includes("timeout")) {

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import { SettingsManager } from "@/services/storage"; import { SettingsManager } from "@/services/storage";
import { api, ServerConfig } from "@/services/api"; import { api, ServerConfig } from "@/services/api";
import { storageConfig } from "@/services/storageConfig"; import { storageConfig } from "@/services/storageConfig";
import AsyncStorage from "@react-native-async-storage/async-storage";
import Logger from "@/utils/Logger"; import Logger from "@/utils/Logger";
const logger = Logger.withTag('SettingsStore'); const logger = Logger.withTag('SettingsStore');
@@ -79,7 +80,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setVideoSource: (config) => set({ videoSource: config }), setVideoSource: (config) => set({ videoSource: config }),
saveSettings: async () => { saveSettings: async () => {
const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get(); const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get();
const currentSettings = await SettingsManager.get()
const currentApiBaseUrl = currentSettings.apiBaseUrl;
let processedApiBaseUrl = apiBaseUrl.trim(); let processedApiBaseUrl = apiBaseUrl.trim();
if (processedApiBaseUrl.endsWith("/")) { if (processedApiBaseUrl.endsWith("/")) {
processedApiBaseUrl = processedApiBaseUrl.slice(0, -1); processedApiBaseUrl = processedApiBaseUrl.slice(0, -1);
@@ -105,6 +107,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
remoteInputEnabled, remoteInputEnabled,
videoSource, videoSource,
}); });
if ( currentApiBaseUrl !== processedApiBaseUrl) {
await AsyncStorage.setItem('authCookies', '');
}
api.setBaseUrl(processedApiBaseUrl); api.setBaseUrl(processedApiBaseUrl);
// Also update the URL in the state so the input field shows the processed URL // Also update the URL in the state so the input field shows the processed URL
set({ isModalVisible: false, apiBaseUrl: processedApiBaseUrl }); set({ isModalVisible: false, apiBaseUrl: processedApiBaseUrl });

View File

@@ -4592,6 +4592,11 @@ expo-font@~12.0.10, expo-font@~12.0.7:
dependencies: dependencies:
fontfaceobserver "^2.1.0" 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: expo-keep-awake@~13.0.2:
version "13.0.2" version "13.0.2"
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz#5ef31311a339671eec9921b934fdd90ab9652b0e" resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz#5ef31311a339671eec9921b934fdd90ab9652b0e"