feat(APKUpdate): implement APK update check and enhance build workflow
- Added `expo-intent-launcher` dependency to support APK launching functionality. - Introduced `APKUpdateBootstrap` component to check for APK updates during app initialization. - Enhanced build workflow in `build.yml` to resolve runtime version and upload APK to the updates server. - Updated `HomeScreen` and `TabsLayout` to manage tab visibility and scroll behavior based on user interactions. - Refactored message bubble styles for improved UI consistency and user experience.
This commit is contained in:
@@ -272,6 +272,42 @@ jobs:
|
||||
name: carrot-bbs-android-release-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
- name: Upload APK to Updates Server
|
||||
env:
|
||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||
UPDATES_SERVER_URL: https://updates.littlelan.cn
|
||||
run: |
|
||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||
|
||||
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
|
||||
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
|
||||
|
||||
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
|
||||
|
||||
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
|
||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||
-H "Content-Type: application/vnd.android.package-archive" \
|
||||
--data-binary @"${APK_PATH}")
|
||||
|
||||
echo "HTTP Response Code: ${HTTP_CODE}"
|
||||
cat response.json
|
||||
|
||||
if [ "${HTTP_CODE}" -ne 200 ]; then
|
||||
echo "Failed to upload APK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "APK uploaded successfully!"
|
||||
|
||||
build-and-push-web:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Platform, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useAppColors, shadows } from '../../../src/theme';
|
||||
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
||||
|
||||
const TAB_BAR_HEIGHT = 56;
|
||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
@@ -19,10 +19,17 @@ export default function TabsLayout() {
|
||||
const pathname = usePathname();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
||||
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
||||
|
||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||
|
||||
const handleHomeTabPress = useCallback(() => {
|
||||
if (pathname === '/home') {
|
||||
triggerHomeTabPress();
|
||||
}
|
||||
}, [pathname, triggerHomeTabPress]);
|
||||
|
||||
const tabBarStyle = useMemo(() => {
|
||||
if (hideTabBar) {
|
||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||
@@ -81,6 +88,9 @@ export default function TabsLayout() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleHomeTabPress,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="apps"
|
||||
|
||||
@@ -21,6 +21,7 @@ import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from '../src/services/alertOverride';
|
||||
import { useAuthStore } from '../src/stores';
|
||||
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
|
||||
|
||||
registerNotificationPresentationHandler();
|
||||
|
||||
@@ -137,6 +138,26 @@ function NotificationBootstrap() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function APKUpdateBootstrap() {
|
||||
const hasChecked = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasChecked.current) return;
|
||||
hasChecked.current = true;
|
||||
|
||||
// 延迟执行,避免与启动流程冲突
|
||||
const timer = setTimeout(() => {
|
||||
checkForAPKUpdate().catch((error) => {
|
||||
console.error('APK update check failed:', error);
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ThemedStack() {
|
||||
const router = useRouter();
|
||||
const colors = useAppColors();
|
||||
@@ -148,6 +169,7 @@ function ThemedStack() {
|
||||
<SystemChrome />
|
||||
<SessionGate>
|
||||
<NotificationBootstrap />
|
||||
<APKUpdateBootstrap />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -29,6 +29,7 @@
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "^55.0.10",
|
||||
@@ -5747,6 +5748,15 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-intent-launcher": {
|
||||
"version": "55.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz",
|
||||
"integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-json-utils": {
|
||||
"version": "55.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "^55.0.10",
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import {
|
||||
ConfigPlugin,
|
||||
withMainActivity,
|
||||
AndroidConfig,
|
||||
} from '@expo/config-plugins';
|
||||
const { withMainActivity } = require('@expo/config-plugins');
|
||||
|
||||
/**
|
||||
* 在 MainActivity 中添加 onConfigurationChanged 方法
|
||||
* 用于监听系统深色模式变化并通知 React Native
|
||||
*/
|
||||
const withMainActivityConfigChange: ConfigPlugin = (config) => {
|
||||
const withMainActivityConfigChange = (config) => {
|
||||
return withMainActivity(config, async (config) => {
|
||||
const { contents } = config.modResults;
|
||||
|
||||
@@ -74,4 +70,4 @@ import android.content.res.Configuration`;
|
||||
});
|
||||
};
|
||||
|
||||
export default withMainActivityConfigChange;
|
||||
module.exports = withMainActivityConfigChange;
|
||||
@@ -26,7 +26,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores';
|
||||
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||
import { useCurrentUser } from '../../stores/authStore';
|
||||
import { channelService, postService } from '../../services';
|
||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||
@@ -212,9 +212,14 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
|
||||
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
|
||||
const homeListScrollYRef = useRef(0);
|
||||
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// FlatList 和 ScrollView 的 ref,用于滚动到顶部
|
||||
const flatListRef = useRef<FlatList<Post> | null>(null);
|
||||
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||
|
||||
const clearTabBarIdleRestoreTimer = useCallback(() => {
|
||||
if (tabBarIdleRestoreTimerRef.current != null) {
|
||||
clearTimeout(tabBarIdleRestoreTimerRef.current);
|
||||
@@ -269,6 +274,19 @@ export const HomeScreen: React.FC = () => {
|
||||
}
|
||||
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
|
||||
|
||||
// 监听首页 Tab 点击事件,滚动到顶部
|
||||
useEffect(() => {
|
||||
if (homeTabPressCount > 0) {
|
||||
// 滚动 FlatList 到顶部
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
// 滚动 ScrollView 到顶部(网格模式)
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
// 重置底部 Tab 栏状态
|
||||
setBottomTabBarHiddenByScroll(false);
|
||||
homeListScrollYRef.current = 0;
|
||||
}
|
||||
}, [homeTabPressCount, setBottomTabBarHiddenByScroll]);
|
||||
|
||||
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
|
||||
const capsuleHScrollRef = useRef<ScrollView | null>(null);
|
||||
const capsuleScrollXRef = useRef(0);
|
||||
@@ -819,6 +837,7 @@ export const HomeScreen: React.FC = () => {
|
||||
// 移动端使用瀑布流布局(2列)
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.waterfallScroll}
|
||||
contentContainerStyle={[
|
||||
styles.waterfallContainer,
|
||||
@@ -899,6 +918,7 @@ export const HomeScreen: React.FC = () => {
|
||||
// 移动端和宽屏都使用单列 FlatList,宽屏下居中显示
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={displayPosts}
|
||||
renderItem={renderPostList}
|
||||
keyExtractor={keyExtractor}
|
||||
|
||||
@@ -404,7 +404,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
>
|
||||
<Avatar
|
||||
source={senderInfo.avatar}
|
||||
size={36}
|
||||
size={40}
|
||||
name={senderInfo.nickname}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
@@ -418,7 +418,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
>
|
||||
<Avatar
|
||||
source={otherUser?.avatar || null}
|
||||
size={36}
|
||||
size={40}
|
||||
name={otherUser?.nickname || ''}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
@@ -452,7 +452,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
<View style={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
source={currentUser?.avatar || null}
|
||||
size={36}
|
||||
size={40}
|
||||
name={currentUser?.nickname || ''}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -847,41 +847,36 @@ function createSegmentStyles(colors: AppColors) {
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// 文本 - Telegram风格:统一深色字体
|
||||
// 文本 - 柔和风格:深灰色文字 + 正常字重
|
||||
textContent: {
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
letterSpacing: 0.2,
|
||||
fontSize: 17.5,
|
||||
lineHeight: 25,
|
||||
letterSpacing: 0.1,
|
||||
fontWeight: '400',
|
||||
},
|
||||
textMe: {
|
||||
color: colors.chat.textPrimary,
|
||||
color: '#2A2A2A',
|
||||
},
|
||||
textOther: {
|
||||
color: colors.chat.textPrimary,
|
||||
color: '#2A2A2A',
|
||||
},
|
||||
|
||||
// @提及 - Telegram风格:蓝色高亮
|
||||
// @提及 - 微信/QQ 风格:更精致的高亮
|
||||
atText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: 2,
|
||||
lineHeight: 22,
|
||||
fontWeight: '500',
|
||||
},
|
||||
atTextMe: {
|
||||
color: '#4A88C7',
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
||||
borderRadius: 4,
|
||||
color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰
|
||||
},
|
||||
atTextOther: {
|
||||
color: colors.chat.link,
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
atHighlight: {
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.2)',
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.15)',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
paddingHorizontal: 3,
|
||||
},
|
||||
|
||||
// 图片 - QQ风格:保持原始宽高比
|
||||
|
||||
@@ -201,7 +201,7 @@ export function createChatScreenStyles(colors: AppColors) {
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
|
||||
// 头像
|
||||
// 头像 - 更大一点
|
||||
avatarWrapper: {
|
||||
marginHorizontal: 2,
|
||||
shadowColor: colors.chat.shadow,
|
||||
@@ -226,43 +226,43 @@ export function createChatScreenStyles(colors: AppColors) {
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
senderName: {
|
||||
fontSize: 13,
|
||||
color: colors.chat.link,
|
||||
fontSize: 14,
|
||||
color: '#555555', // 柔和的深灰色
|
||||
marginBottom: 4,
|
||||
marginLeft: 4,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// 消息气泡 - 外层极轻阴影(纯文字气泡更接近扁平 IM)
|
||||
// 消息气泡 - 微信风格:更小的圆角 + 更紧凑的内边距
|
||||
messageBubbleOuter: {
|
||||
borderRadius: 16,
|
||||
minWidth: 60,
|
||||
borderRadius: 12,
|
||||
minWidth: 44,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 0.5 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 1,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
myBubbleOuter: {
|
||||
borderBottomRightRadius: 4,
|
||||
borderBottomRightRadius: 4, // 微信的小尾巴更尖
|
||||
},
|
||||
theirBubbleOuter: {
|
||||
borderTopLeftRadius: 4,
|
||||
},
|
||||
// 内层:背景 + 裁剪,防止回复条/@ 高亮在圆角外露出颜色
|
||||
// 内层:更紧凑的微信风格内边距
|
||||
messageBubbleInner: {
|
||||
borderRadius: 16,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 4,
|
||||
minWidth: 60,
|
||||
paddingHorizontal: 10, // 微信:更窄的左右边距
|
||||
paddingVertical: 7, // 微信:更紧的上下边距
|
||||
minWidth: 44,
|
||||
},
|
||||
myBubbleInner: {
|
||||
backgroundColor: colors.chat.replyTint,
|
||||
backgroundColor: colors.chat.bubbleOutgoing,
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
theirBubbleInner: {
|
||||
backgroundColor: colors.chat.card,
|
||||
backgroundColor: colors.chat.bubbleIncoming,
|
||||
borderTopLeftRadius: 4,
|
||||
},
|
||||
recalledBubble: {
|
||||
@@ -272,17 +272,17 @@ export function createChatScreenStyles(colors: AppColors) {
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
myBubbleText: {
|
||||
color: colors.chat.textPrimary, // 主文案
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
letterSpacing: 0.2,
|
||||
color: '#2A2A2A',
|
||||
fontSize: 18, // 更大字体
|
||||
lineHeight: 25, // 相应调大行高
|
||||
letterSpacing: 0.1,
|
||||
fontWeight: '400',
|
||||
},
|
||||
theirBubbleText: {
|
||||
color: colors.chat.textPrimary, // 主文案
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
letterSpacing: 0.2,
|
||||
color: '#2A2A2A',
|
||||
fontSize: 18,
|
||||
lineHeight: 25,
|
||||
letterSpacing: 0.1,
|
||||
fontWeight: '400',
|
||||
},
|
||||
// 选中状态
|
||||
@@ -312,21 +312,21 @@ export function createChatScreenStyles(colors: AppColors) {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// 图片消息 - QQ风格:更大的图片,更明显的圆角
|
||||
// 图片消息 - QQ/微信风格:更大的圆角,更柔和的阴影
|
||||
imageBubble: {
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 6,
|
||||
elevation: 5,
|
||||
elevation: 4,
|
||||
},
|
||||
myImageBubble: {
|
||||
borderBottomRightRadius: 6, // 右下角尖,箭头在右下
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
theirImageBubble: {
|
||||
borderTopLeftRadius: 6, // 左上角尖,箭头在左上
|
||||
borderTopLeftRadius: 8,
|
||||
},
|
||||
messageImage: {
|
||||
width: 220,
|
||||
|
||||
277
src/services/apkUpdateService.ts
Normal file
277
src/services/apkUpdateService.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* APK 更新检查服务
|
||||
* 用于检查最新版本并提示用户下载更新
|
||||
*/
|
||||
|
||||
import { Platform, Linking, Alert } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { showConfirm } from './dialogService';
|
||||
|
||||
// 更新服务器地址
|
||||
const UPDATES_SERVER_URL = Constants.expoConfig?.extra?.updatesUrl
|
||||
? Constants.expoConfig.extra.updatesUrl.replace('/api/manifest', '')
|
||||
: 'https://updates.littlelan.cn';
|
||||
|
||||
// 检查间隔(毫秒):24小时
|
||||
const CHECK_INTERVAL = 24 * 60 * 60 * 1000;
|
||||
|
||||
// 最后检查时间存储键
|
||||
const LAST_CHECK_KEY = 'apk_update_last_check';
|
||||
|
||||
// APK 版本信息
|
||||
export interface APKVersionInfo {
|
||||
runtimeVersion: string;
|
||||
versionName: string;
|
||||
size: number;
|
||||
downloadUrl: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 版本比较结果
|
||||
export interface VersionCheckResult {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较版本号
|
||||
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
|
||||
*/
|
||||
function 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 p1 = parts1[i] || 0;
|
||||
const p2 = parts2[i] || 0;
|
||||
if (p1 > p2) return 1;
|
||||
if (p1 < p2) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新 APK 版本信息
|
||||
*/
|
||||
async function fetchLatestAPKVersion(): Promise<APKVersionInfo | null> {
|
||||
try {
|
||||
const response = await fetch(`${UPDATES_SERVER_URL}/api/apk/latest`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to fetch latest APK version:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: APKVersionInfo = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching latest APK version:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前应用版本
|
||||
*/
|
||||
function getCurrentVersion(): string {
|
||||
return Constants.expoConfig?.version || '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该执行检查(避免频繁检查)
|
||||
*/
|
||||
async function shouldCheck(): Promise<boolean> {
|
||||
try {
|
||||
const lastCheck = await AsyncStorage.getItem(LAST_CHECK_KEY);
|
||||
if (!lastCheck) return true;
|
||||
|
||||
const lastCheckTime = parseInt(lastCheck, 10);
|
||||
return Date.now() - lastCheckTime > CHECK_INTERVAL;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录检查时间
|
||||
*/
|
||||
async function recordCheckTime(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(LAST_CHECK_KEY, Date.now().toString());
|
||||
} catch (error) {
|
||||
console.error('Failed to record check time:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并安装 APK
|
||||
*/
|
||||
async function downloadAndInstallAPK(downloadUrl: string, version: string): Promise<void> {
|
||||
if (Platform.OS !== 'android') {
|
||||
Alert.alert('提示', '此功能仅支持 Android 设备');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadPath = `${FileSystem.documentDirectory}carrot-bbs-${version}.apk`;
|
||||
|
||||
// 显示下载进度
|
||||
showConfirm({
|
||||
title: '正在下载',
|
||||
message: `正在下载新版本 ${version},请稍候...`,
|
||||
confirmText: '后台下载',
|
||||
cancelText: '取消',
|
||||
onConfirm: () => {
|
||||
// 用户选择后台下载,继续
|
||||
},
|
||||
onCancel: () => {
|
||||
// 用户取消
|
||||
},
|
||||
});
|
||||
|
||||
// 下载 APK
|
||||
const downloadResult = await FileSystem.downloadAsync(downloadUrl, downloadPath);
|
||||
|
||||
if (!downloadResult.uri) {
|
||||
Alert.alert('下载失败', '无法下载安装包,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 安装 APK
|
||||
await installAPK(downloadResult.uri);
|
||||
} catch (error) {
|
||||
console.error('Download/Install error:', error);
|
||||
Alert.alert('更新失败', '下载或安装过程中出错,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 APK
|
||||
*/
|
||||
async function installAPK(apkUri: string): Promise<void> {
|
||||
try {
|
||||
// 获取文件的 content URI
|
||||
const contentUri = await FileSystem.getContentUriAsync(apkUri);
|
||||
|
||||
// 使用 Intent 安装 APK
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.INSTALL_PACKAGE', {
|
||||
data: contentUri,
|
||||
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Install APK error:', error);
|
||||
// 尝试使用浏览器下载
|
||||
const webUrl = apkUri.replace('file://', '');
|
||||
Linking.openURL(webUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示更新提示对话框
|
||||
*/
|
||||
function showUpdateDialog(result: VersionCheckResult): void {
|
||||
const sizeText = formatFileSize(result.size);
|
||||
|
||||
showConfirm({
|
||||
title: '发现新版本',
|
||||
message: `当前版本: ${result.currentVersion}\n最新版本: ${result.latestVersion}\n安装包大小: ${sizeText}\n\n是否立即下载更新?`,
|
||||
confirmText: '立即更新',
|
||||
cancelText: '稍后提醒',
|
||||
onConfirm: () => {
|
||||
downloadAndInstallAPK(result.downloadUrl, result.latestVersion);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 APK 更新(主入口)
|
||||
* @param force 是否强制检查(忽略时间间隔)
|
||||
*/
|
||||
export async function checkForAPKUpdate(force: boolean = false): Promise<VersionCheckResult | null> {
|
||||
// 仅在 Android 平台检查
|
||||
if (Platform.OS !== 'android') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否应该执行检查
|
||||
if (!force) {
|
||||
const should = await shouldCheck();
|
||||
if (!should) {
|
||||
console.log('APK update check skipped (too frequent)');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录检查时间
|
||||
await recordCheckTime();
|
||||
|
||||
// 获取最新版本信息
|
||||
const latestAPK = await fetchLatestAPKVersion();
|
||||
if (!latestAPK) {
|
||||
console.log('No APK version info available');
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentVersion = getCurrentVersion();
|
||||
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
|
||||
|
||||
// 比较版本
|
||||
const comparison = compareVersions(currentVersion, latestVersion);
|
||||
|
||||
const result: VersionCheckResult = {
|
||||
hasUpdate: comparison < 0,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
downloadUrl: latestAPK.downloadUrl,
|
||||
size: latestAPK.size,
|
||||
};
|
||||
|
||||
// 如果有更新,显示对话框
|
||||
if (result.hasUpdate) {
|
||||
showUpdateDialog(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动检查更新(用户主动触发)
|
||||
*/
|
||||
export async function manualCheckForUpdate(): Promise<void> {
|
||||
const result = await checkForAPKUpdate(true);
|
||||
|
||||
if (!result) {
|
||||
Alert.alert('检查失败', '无法获取版本信息,请检查网络连接');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.hasUpdate) {
|
||||
Alert.alert('已是最新', `当前版本 ${result.currentVersion} 已是最新版本`);
|
||||
}
|
||||
}
|
||||
|
||||
export const apkUpdateService = {
|
||||
checkForAPKUpdate,
|
||||
manualCheckForUpdate,
|
||||
};
|
||||
@@ -110,3 +110,7 @@ export {
|
||||
// 统一提示/弹窗服务
|
||||
export { showPrompt } from './promptService';
|
||||
export { showConfirm } from './dialogService';
|
||||
|
||||
// APK 更新检查服务
|
||||
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
|
||||
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';
|
||||
|
||||
13
src/stores/homeTabPressStore.ts
Normal file
13
src/stores/homeTabPressStore.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface HomeTabPressState {
|
||||
/** 用于触发首页回到顶部的事件计数器 */
|
||||
pressCount: number;
|
||||
/** 触发首页 Tab 点击事件 */
|
||||
triggerPress: () => void;
|
||||
}
|
||||
|
||||
export const useHomeTabPressStore = create<HomeTabPressState>((set) => ({
|
||||
pressCount: 0,
|
||||
triggerPress: () => set((state) => ({ pressCount: state.pressCount + 1 })),
|
||||
}));
|
||||
@@ -44,6 +44,9 @@ export { userManager } from './userManager';
|
||||
export {
|
||||
useHomeTabBarVisibilityStore,
|
||||
} from './homeTabBarVisibilityStore';
|
||||
export {
|
||||
useHomeTabPressStore,
|
||||
} from './homeTabPressStore';
|
||||
export {
|
||||
useAppColors,
|
||||
useThemePreference,
|
||||
|
||||
@@ -76,8 +76,8 @@ export const lightColors = {
|
||||
link: '#4A88C7',
|
||||
success: '#34C759',
|
||||
danger: '#FF3B30',
|
||||
bubbleOutgoing: '#E8E8E8',
|
||||
bubbleIncoming: '#FFFFFF',
|
||||
bubbleOutgoing: '#7CB9FF', // 柔和的淡蓝色
|
||||
bubbleIncoming: '#FFFFFF', // 对方发的消息 - 纯白
|
||||
replyTint: '#DFF2FF',
|
||||
replyTintActive: '#CFEAFF',
|
||||
replyBorder: '#7FB6E6',
|
||||
|
||||
Reference in New Issue
Block a user