feat:热更新

This commit is contained in:
Developer
2026-03-20 14:06:52 +08:00
parent 4a69a8ed1d
commit 38fc41f184
8 changed files with 278 additions and 21 deletions

67
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Release APK
on:
push:
branches:
- master
jobs:
release:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Bump version
id: bump
run: |
NEW_VERSION=$(node scripts/bump-version.js)
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Commit version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add app.json package.json android/app/build.gradle
git commit -m "chore: bump version to v${{ steps.bump.outputs.version }} [skip ci]"
git push
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Grant Gradle execute permission
run: chmod +x android/gradlew
- name: Build Release APK
run: |
cd android
./gradlew assembleRelease --no-daemon
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.bump.outputs.version }}" \
--title "JKVideo v${{ steps.bump.outputs.version }}" \
--generate-notes \
android/app/build/outputs/apk/release/app-release.apk

View File

@@ -26,8 +26,7 @@
"permissions": [ "permissions": [
"android.permission.RECORD_AUDIO", "android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS", "android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.RECORD_AUDIO", "android.permission.REQUEST_INSTALL_PACKAGES"
"android.permission.MODIFY_AUDIO_SETTINGS"
], ],
"package": "com.anonymous.jkvideo" "package": "com.anonymous.jkvideo"
}, },

View File

@@ -1,15 +1,17 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { useCheckUpdate } from '../hooks/useCheckUpdate';
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { isLoggedIn, logout } = useAuthStore(); const { isLoggedIn, logout } = useAuthStore();
const { coverQuality, setCoverQuality } = useSettingsStore(); const { coverQuality, setCoverQuality } = useSettingsStore();
const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate();
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
@@ -26,6 +28,35 @@ export default function SettingsScreen() {
<View style={styles.spacer} /> <View style={styles.spacer} />
</View> </View>
<View style={styles.section}>
<Text style={styles.sectionLabel}></Text>
<View style={styles.versionRow}>
<Text style={styles.versionLabel}></Text>
<Text style={styles.versionValue}>v{currentVersion}</Text>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionLabel}></Text>
<TouchableOpacity
style={styles.updateBtn}
onPress={checkUpdate}
activeOpacity={0.7}
disabled={isChecking || downloadProgress !== null}
>
{isChecking ? (
<>
<ActivityIndicator size="small" color="#00AEEC" style={{ marginRight: 8 }} />
<Text style={styles.updateBtnText}>...</Text>
</>
) : downloadProgress !== null ? (
<Text style={styles.updateBtnText}> {downloadProgress}%</Text>
) : (
<Text style={styles.updateBtnText}></Text>
)}
</TouchableOpacity>
</View>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionLabel}></Text> <Text style={styles.sectionLabel}></Text>
<View style={styles.optionRow}> <View style={styles.optionRow}>
@@ -102,6 +133,19 @@ const styles = StyleSheet.create({
optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' }, optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' },
optionText: { fontSize: 14, color: '#666' }, optionText: { fontSize: 14, color: '#666' },
optionTextActive: { color: '#00AEEC', fontWeight: '600' }, optionTextActive: { color: '#00AEEC', fontWeight: '600' },
versionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
versionLabel: { fontSize: 14, color: '#212121' },
versionValue: { fontSize: 14, color: '#999' },
updateBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 6,
},
updateBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
logoutBtn: { logoutBtn: {
margin: 24, margin: 24,
paddingVertical: 12, paddingVertical: 12,

View File

@@ -460,7 +460,7 @@ const styles = StyleSheet.create({
paddingBottom: 0, paddingBottom: 0,
paddingTop: 12, paddingTop: 12,
}, },
avatar: { width: 48, height: 48, borderRadius: 19, marginRight: 10 }, avatar: { width: 48, height: 48, borderRadius:30, marginRight: 10 },
upName: { flex: 1, fontSize: 14, color: "#212121", fontWeight: "500" }, upName: { flex: 1, fontSize: 14, color: "#212121", fontWeight: "500" },
followBtn: { followBtn: {
backgroundColor: "#00AEEC", backgroundColor: "#00AEEC",

99
hooks/useCheckUpdate.ts Normal file
View File

@@ -0,0 +1,99 @@
import { useState } from 'react';
import { Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
import Constants from 'expo-constants';
const GITHUB_API = 'https://api.github.com/repos/tiajinsha/JKVideo/releases/latest';
function compareVersions(a: string, b: string): number {
const pa = a.replace(/^v/, '').split('.').map(Number);
const pb = b.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
}
return 0;
}
export function useCheckUpdate() {
const currentVersion = Constants.expoConfig?.version ?? '0.0.0';
const [isChecking, setIsChecking] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const checkUpdate = async () => {
setIsChecking(true);
try {
const res = await fetch(GITHUB_API, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
const data = await res.json();
const latestVersion: string = data.tag_name ?? '';
const apkAsset = (data.assets as any[]).find((a) =>
(a.name as string).endsWith('.apk')
);
const downloadUrl: string = apkAsset?.browser_download_url ?? '';
const releaseNotes: string = data.body ?? '';
if (compareVersions(latestVersion, currentVersion) <= 0) {
Alert.alert('已是最新版本', `当前版本 v${currentVersion} 已是最新`);
return;
}
Alert.alert(
`发现新版本 ${latestVersion}`,
releaseNotes || '有新版本可用,是否立即下载?',
[
{ text: '取消', style: 'cancel' },
{
text: '下载安装',
onPress: () => downloadAndInstall(downloadUrl, latestVersion),
},
]
);
} catch (e: any) {
Alert.alert('检查失败', e?.message ?? '网络错误,请稍后重试');
} finally {
setIsChecking(false);
}
};
const downloadAndInstall = async (url: string, version: string) => {
if (Platform.OS !== 'android') {
Alert.alert('提示', '自动安装仅支持 Android 设备');
return;
}
const localUri = FileSystem.cacheDirectory + `JKVideo-${version}.apk`;
try {
setDownloadProgress(0);
const downloadResumable = FileSystem.createDownloadResumable(
url,
localUri,
{},
({ totalBytesWritten, totalBytesExpectedToWrite }) => {
if (totalBytesExpectedToWrite > 0) {
setDownloadProgress(
Math.round((totalBytesWritten / totalBytesExpectedToWrite) * 100)
);
}
}
);
await downloadResumable.downloadAsync();
setDownloadProgress(null);
const contentUri = await FileSystem.getContentUriAsync(localUri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
flags: 1,
type: 'application/vnd.android.package-archive',
});
} catch (e: any) {
setDownloadProgress(null);
Alert.alert('下载失败', e?.message ?? '请稍后重试');
}
};
return { currentVersion, isChecking, downloadProgress, checkUpdate };
}

45
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "reactbilibiliapp", "name": "jkvideo",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "reactbilibiliapp", "name": "jkvideo",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dr.pogodin/react-native-static-server": "^0.26.0", "@dr.pogodin/react-native-static-server": "^0.26.0",
@@ -15,8 +15,10 @@
"expo": "~55.0.5", "expo": "~55.0.5",
"expo-av": "^16.0.8", "expo-av": "^16.0.8",
"expo-clipboard": "~55.0.9", "expo-clipboard": "~55.0.9",
"expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.11", "expo-dev-client": "~55.0.11",
"expo-file-system": "~55.0.10", "expo-file-system": "~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.10", "expo-media-library": "~55.0.10",
"expo-network": "~55.0.9", "expo-network": "~55.0.9",
@@ -1532,15 +1534,15 @@
} }
}, },
"node_modules/@expo/config": { "node_modules/@expo/config": {
"version": "55.0.8", "version": "55.0.10",
"resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.8.tgz", "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.10.tgz",
"integrity": "sha512-D7RYYHfErCgEllGxNwdYdkgzLna7zkzUECBV3snbUpf7RvIpB5l1LpCgzuVoc5KVew5h7N1Tn4LnT/tBSUZsQg==", "integrity": "sha512-qCHxo9H1ZoeW+y0QeMtVZ3JfGmumpGrgUFX60wLWMarraoQZSe47ZUm9kJSn3iyoPjUtUNanO3eXQg+K8k4rag==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/config-plugins": "~55.0.6", "@expo/config-plugins": "~55.0.7",
"@expo/config-types": "^55.0.5", "@expo/config-types": "^55.0.5",
"@expo/json-file": "^10.0.12", "@expo/json-file": "^10.0.12",
"@expo/require-utils": "^55.0.2", "@expo/require-utils": "^55.0.3",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"getenv": "^2.0.0", "getenv": "^2.0.0",
"glob": "^13.0.0", "glob": "^13.0.0",
@@ -1551,9 +1553,9 @@
} }
}, },
"node_modules/@expo/config-plugins": { "node_modules/@expo/config-plugins": {
"version": "55.0.6", "version": "55.0.7",
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.6.tgz", "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.7.tgz",
"integrity": "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A==", "integrity": "sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/config-types": "^55.0.5", "@expo/config-types": "^55.0.5",
@@ -1798,9 +1800,9 @@
} }
}, },
"node_modules/@expo/require-utils": { "node_modules/@expo/require-utils": {
"version": "55.0.2", "version": "55.0.3",
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.2.tgz", "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.3.tgz",
"integrity": "sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw==", "integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.20.0", "@babel/code-frame": "^7.20.0",
@@ -4451,12 +4453,12 @@
} }
}, },
"node_modules/expo-constants": { "node_modules/expo-constants": {
"version": "55.0.7", "version": "55.0.9",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.7.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.9.tgz",
"integrity": "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ==", "integrity": "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/config": "~55.0.8", "@expo/config": "~55.0.10",
"@expo/env": "~2.1.1" "@expo/env": "~2.1.1"
}, },
"peerDependencies": { "peerDependencies": {
@@ -4570,6 +4572,15 @@
} }
} }
}, },
"node_modules/expo-intent-launcher": {
"version": "55.0.9",
"resolved": "https://registry.npmjs.org/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.npmjs.org/expo-json-utils/-/expo-json-utils-55.0.0.tgz", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-55.0.0.tgz",

View File

@@ -17,8 +17,10 @@
"expo": "~55.0.5", "expo": "~55.0.5",
"expo-av": "^16.0.8", "expo-av": "^16.0.8",
"expo-clipboard": "~55.0.9", "expo-clipboard": "~55.0.9",
"expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.11", "expo-dev-client": "~55.0.11",
"expo-file-system": "~55.0.10", "expo-file-system": "~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.10", "expo-media-library": "~55.0.10",
"expo-network": "~55.0.9", "expo-network": "~55.0.9",

35
scripts/bump-version.js Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
// Read app.json
const appJsonPath = path.join(root, 'app.json');
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
const currentVersion = appJson.expo.version;
// Parse semver
const parts = currentVersion.split('.').map(Number);
parts[2] += 1;
const newVersion = parts.join('.');
const newVersionCode = parts[0] * 10000 + parts[1] * 100 + parts[2];
// Update app.json
appJson.expo.version = newVersion;
fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
// Update package.json
const pkgJsonPath = path.join(root, 'package.json');
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
pkgJson.version = newVersion;
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + '\n');
// Update android/app/build.gradle
const gradlePath = path.join(root, 'android', 'app', 'build.gradle');
let gradle = fs.readFileSync(gradlePath, 'utf8');
gradle = gradle.replace(/versionCode\s+\d+/, `versionCode ${newVersionCode}`);
gradle = gradle.replace(/versionName\s+"[^"]+"/, `versionName "${newVersion}"`);
fs.writeFileSync(gradlePath, gradle);
console.log(newVersion);