feat(APKUpdate): implement APK update check and enhance build workflow
Some checks failed
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled

- 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:
lafay
2026-03-26 03:41:43 +08:00
parent 405cd271db
commit c903990aaf
15 changed files with 449 additions and 62 deletions

View File

@@ -272,6 +272,42 @@ jobs:
name: carrot-bbs-android-release-apk name: carrot-bbs-android-release-apk
path: android/app/build/outputs/apk/release/app-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: build-and-push-web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useCallback } from 'react';
import { Platform, useWindowDimensions } from 'react-native'; import { Platform, useWindowDimensions } from 'react-native';
import { Tabs, usePathname } from 'expo-router'; import { Tabs, usePathname } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; 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_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12; const TAB_BAR_FLOATING_MARGIN = 12;
@@ -19,10 +19,17 @@ export default function TabsLayout() {
const pathname = usePathname(); const pathname = usePathname();
const unreadCount = useTotalUnreadCount(); const unreadCount = useTotalUnreadCount();
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop; const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/'); const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
const handleHomeTabPress = useCallback(() => {
if (pathname === '/home') {
triggerHomeTabPress();
}
}, [pathname, triggerHomeTabPress]);
const tabBarStyle = useMemo(() => { const tabBarStyle = useMemo(() => {
if (hideTabBar) { if (hideTabBar) {
return { display: 'none' as const, height: 0, overflow: 'hidden' as const }; return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
@@ -81,6 +88,9 @@ export default function TabsLayout() {
/> />
), ),
}} }}
listeners={{
tabPress: handleHomeTabPress,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="apps" name="apps"

View File

@@ -21,6 +21,7 @@ import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost'; import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride'; import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores'; import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
registerNotificationPresentationHandler(); registerNotificationPresentationHandler();
@@ -137,6 +138,26 @@ function NotificationBootstrap() {
return null; 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() { function ThemedStack() {
const router = useRouter(); const router = useRouter();
const colors = useAppColors(); const colors = useAppColors();
@@ -148,6 +169,7 @@ function ThemedStack() {
<SystemChrome /> <SystemChrome />
<SessionGate> <SessionGate>
<NotificationBootstrap /> <NotificationBootstrap />
<APKUpdateBootstrap />
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} /> <Stack.Screen name="(auth)" options={{ headerShown: false }} />

10
package-lock.json generated
View File

@@ -29,6 +29,7 @@
"expo-haptics": "~55.0.8", "expo-haptics": "~55.0.8",
"expo-image": "^55.0.5", "expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10", "expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8", "expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9", "expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10", "expo-notifications": "^55.0.10",
@@ -5747,6 +5748,15 @@
"expo": "*" "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": { "node_modules/expo-json-utils": {
"version": "55.0.0", "version": "55.0.0",
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz", "resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",

View File

@@ -35,6 +35,7 @@
"expo-haptics": "~55.0.8", "expo-haptics": "~55.0.8",
"expo-image": "^55.0.5", "expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10", "expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8", "expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9", "expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10", "expo-notifications": "^55.0.10",

View File

@@ -1,14 +1,10 @@
import { const { withMainActivity } = require('@expo/config-plugins');
ConfigPlugin,
withMainActivity,
AndroidConfig,
} from '@expo/config-plugins';
/** /**
* MainActivity 中添加 onConfigurationChanged 方法 * MainActivity 中添加 onConfigurationChanged 方法
* 用于监听系统深色模式变化并通知 React Native * 用于监听系统深色模式变化并通知 React Native
*/ */
const withMainActivityConfigChange: ConfigPlugin = (config) => { const withMainActivityConfigChange = (config) => {
return withMainActivity(config, async (config) => { return withMainActivity(config, async (config) => {
const { contents } = config.modResults; const { contents } = config.modResults;
@@ -74,4 +70,4 @@ import android.content.res.Configuration`;
}); });
}; };
export default withMainActivityConfigChange; module.exports = withMainActivityConfigChange;

View File

@@ -26,7 +26,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme'; import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores'; import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore'; import { useCurrentUser } from '../../stores/authStore';
import { channelService, postService } from '../../services'; import { channelService, postService } from '../../services';
import { PostCard, TabBar, SearchBar } from '../../components/business'; import { PostCard, TabBar, SearchBar } from '../../components/business';
@@ -212,9 +212,14 @@ export const HomeScreen: React.FC = () => {
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll); const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
const homeListScrollYRef = useRef(0); const homeListScrollYRef = useRef(0);
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 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(() => { const clearTabBarIdleRestoreTimer = useCallback(() => {
if (tabBarIdleRestoreTimerRef.current != null) { if (tabBarIdleRestoreTimerRef.current != null) {
clearTimeout(tabBarIdleRestoreTimerRef.current); clearTimeout(tabBarIdleRestoreTimerRef.current);
@@ -269,6 +274,19 @@ export const HomeScreen: React.FC = () => {
} }
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]); }, [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 capsuleHScrollRef = useRef<ScrollView | null>(null);
const capsuleScrollXRef = useRef(0); const capsuleScrollXRef = useRef(0);
@@ -819,6 +837,7 @@ export const HomeScreen: React.FC = () => {
// 移动端使用瀑布流布局2列 // 移动端使用瀑布流布局2列
return ( return (
<ScrollView <ScrollView
ref={scrollViewRef}
style={styles.waterfallScroll} style={styles.waterfallScroll}
contentContainerStyle={[ contentContainerStyle={[
styles.waterfallContainer, styles.waterfallContainer,
@@ -899,6 +918,7 @@ export const HomeScreen: React.FC = () => {
// 移动端和宽屏都使用单列 FlatList宽屏下居中显示 // 移动端和宽屏都使用单列 FlatList宽屏下居中显示
return ( return (
<FlatList <FlatList
ref={flatListRef}
data={displayPosts} data={displayPosts}
renderItem={renderPostList} renderItem={renderPostList}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}

View File

@@ -404,7 +404,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
> >
<Avatar <Avatar
source={senderInfo.avatar} source={senderInfo.avatar}
size={36} size={40}
name={senderInfo.nickname} name={senderInfo.nickname}
/> />
</TouchableOpacity> </TouchableOpacity>
@@ -418,7 +418,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
> >
<Avatar <Avatar
source={otherUser?.avatar || null} source={otherUser?.avatar || null}
size={36} size={40}
name={otherUser?.nickname || ''} name={otherUser?.nickname || ''}
/> />
</TouchableOpacity> </TouchableOpacity>
@@ -452,7 +452,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
<View style={styles.avatarWrapper}> <View style={styles.avatarWrapper}>
<Avatar <Avatar
source={currentUser?.avatar || null} source={currentUser?.avatar || null}
size={36} size={40}
name={currentUser?.nickname || ''} name={currentUser?.nickname || ''}
/> />
</View> </View>

View File

@@ -847,41 +847,36 @@ function createSegmentStyles(colors: AppColors) {
marginTop: 2, marginTop: 2,
}, },
// 文本 - Telegram风格统一深色字体 // 文本 - 柔和风格:深灰色文字 + 正常字重
textContent: { textContent: {
fontSize: 16, fontSize: 17.5,
lineHeight: 23, lineHeight: 25,
letterSpacing: 0.2, letterSpacing: 0.1,
fontWeight: '400', fontWeight: '400',
}, },
textMe: { textMe: {
color: colors.chat.textPrimary, color: '#2A2A2A',
}, },
textOther: { textOther: {
color: colors.chat.textPrimary, color: '#2A2A2A',
}, },
// @提及 - Telegram风格蓝色高亮 // @提及 - 微信/QQ 风格:更精致的高亮
atText: { atText: {
fontSize: 16, fontSize: 16,
lineHeight: 23, lineHeight: 22,
fontWeight: '600', fontWeight: '500',
paddingHorizontal: 2,
}, },
atTextMe: { atTextMe: {
color: '#4A88C7', color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰
backgroundColor: 'rgba(74, 136, 199, 0.12)',
borderRadius: 4,
}, },
atTextOther: { atTextOther: {
color: colors.chat.link, color: colors.chat.link,
backgroundColor: 'rgba(74, 136, 199, 0.12)',
borderRadius: 4,
}, },
atHighlight: { atHighlight: {
backgroundColor: 'rgba(74, 136, 199, 0.2)', backgroundColor: 'rgba(74, 136, 199, 0.15)',
borderRadius: 4, borderRadius: 4,
paddingHorizontal: 4, paddingHorizontal: 3,
}, },
// 图片 - QQ风格保持原始宽高比 // 图片 - QQ风格保持原始宽高比

View File

@@ -201,7 +201,7 @@ export function createChatScreenStyles(colors: AppColors) {
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
// 头像 // 头像 - 更大一点
avatarWrapper: { avatarWrapper: {
marginHorizontal: 2, marginHorizontal: 2,
shadowColor: colors.chat.shadow, shadowColor: colors.chat.shadow,
@@ -226,43 +226,43 @@ export function createChatScreenStyles(colors: AppColors) {
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
senderName: { senderName: {
fontSize: 13, fontSize: 14,
color: colors.chat.link, color: '#555555', // 柔和的深灰色
marginBottom: 4, marginBottom: 4,
marginLeft: 4, marginLeft: 4,
fontWeight: '600', fontWeight: '600',
}, },
// 消息气泡 - 外层极轻阴影(纯文字气泡更接近扁平 IM // 消息气泡 - 微信风格:更小的圆角 + 更紧凑的内边距
messageBubbleOuter: { messageBubbleOuter: {
borderRadius: 16, borderRadius: 12,
minWidth: 60, minWidth: 44,
shadowColor: colors.chat.shadow, shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 0.5 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04, shadowOpacity: 0.06,
shadowRadius: 1, shadowRadius: 2,
elevation: 1, elevation: 1,
}, },
myBubbleOuter: { myBubbleOuter: {
borderBottomRightRadius: 4, borderBottomRightRadius: 4, // 微信的小尾巴更尖
}, },
theirBubbleOuter: { theirBubbleOuter: {
borderTopLeftRadius: 4, borderTopLeftRadius: 4,
}, },
// 内层:背景 + 裁剪,防止回复条/@ 高亮在圆角外露出颜色 // 内层:更紧凑的微信风格内边距
messageBubbleInner: { messageBubbleInner: {
borderRadius: 16, borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
paddingHorizontal: spacing.md, paddingHorizontal: 10, // 微信:更窄的左右边距
paddingVertical: spacing.sm + 4, paddingVertical: 7, // 微信:更紧的上下边距
minWidth: 60, minWidth: 44,
}, },
myBubbleInner: { myBubbleInner: {
backgroundColor: colors.chat.replyTint, backgroundColor: colors.chat.bubbleOutgoing,
borderBottomRightRadius: 4, borderBottomRightRadius: 4,
}, },
theirBubbleInner: { theirBubbleInner: {
backgroundColor: colors.chat.card, backgroundColor: colors.chat.bubbleIncoming,
borderTopLeftRadius: 4, borderTopLeftRadius: 4,
}, },
recalledBubble: { recalledBubble: {
@@ -272,17 +272,17 @@ export function createChatScreenStyles(colors: AppColors) {
borderStyle: 'dashed', borderStyle: 'dashed',
}, },
myBubbleText: { myBubbleText: {
color: colors.chat.textPrimary, // 主文案 color: '#2A2A2A',
fontSize: 16, fontSize: 18, // 更大字体
lineHeight: 23, lineHeight: 25, // 相应调大行高
letterSpacing: 0.2, letterSpacing: 0.1,
fontWeight: '400', fontWeight: '400',
}, },
theirBubbleText: { theirBubbleText: {
color: colors.chat.textPrimary, // 主文案 color: '#2A2A2A',
fontSize: 16, fontSize: 18,
lineHeight: 23, lineHeight: 25,
letterSpacing: 0.2, letterSpacing: 0.1,
fontWeight: '400', fontWeight: '400',
}, },
// 选中状态 // 选中状态
@@ -312,21 +312,21 @@ export function createChatScreenStyles(colors: AppColors) {
fontStyle: 'italic', fontStyle: 'italic',
}, },
// 图片消息 - QQ风格更大的图片,更明显的圆角 // 图片消息 - QQ/微信风格:更大的圆角,更柔和的阴影
imageBubble: { imageBubble: {
borderRadius: 18, borderRadius: 18,
overflow: 'hidden', overflow: 'hidden',
shadowColor: colors.chat.shadow, shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 3 }, shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.15, shadowOpacity: 0.12,
shadowRadius: 6, shadowRadius: 6,
elevation: 5, elevation: 4,
}, },
myImageBubble: { myImageBubble: {
borderBottomRightRadius: 6, // 右下角尖,箭头在右下 borderBottomRightRadius: 8,
}, },
theirImageBubble: { theirImageBubble: {
borderTopLeftRadius: 6, // 左上角尖,箭头在左上 borderTopLeftRadius: 8,
}, },
messageImage: { messageImage: {
width: 220, width: 220,

View 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,
};

View File

@@ -110,3 +110,7 @@ export {
// 统一提示/弹窗服务 // 统一提示/弹窗服务
export { showPrompt } from './promptService'; export { showPrompt } from './promptService';
export { showConfirm } from './dialogService'; export { showConfirm } from './dialogService';
// APK 更新检查服务
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';

View 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 })),
}));

View File

@@ -44,6 +44,9 @@ export { userManager } from './userManager';
export { export {
useHomeTabBarVisibilityStore, useHomeTabBarVisibilityStore,
} from './homeTabBarVisibilityStore'; } from './homeTabBarVisibilityStore';
export {
useHomeTabPressStore,
} from './homeTabPressStore';
export { export {
useAppColors, useAppColors,
useThemePreference, useThemePreference,

View File

@@ -76,8 +76,8 @@ export const lightColors = {
link: '#4A88C7', link: '#4A88C7',
success: '#34C759', success: '#34C759',
danger: '#FF3B30', danger: '#FF3B30',
bubbleOutgoing: '#E8E8E8', bubbleOutgoing: '#7CB9FF', // 柔和的淡蓝色
bubbleIncoming: '#FFFFFF', bubbleIncoming: '#FFFFFF', // 对方发的消息 - 纯白
replyTint: '#DFF2FF', replyTint: '#DFF2FF',
replyTintActive: '#CFEAFF', replyTintActive: '#CFEAFF',
replyBorder: '#7FB6E6', replyBorder: '#7FB6E6',