feat(mobile): self-update dialog with verified APK download and install (M5)
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m33s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 35s
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m33s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 35s
Checks the server for a newer release at most every 6 hours and on demand in Profile, shows release notes, downloads the APK with progress, verifies SHA-256 in 1 MB chunks and hands it to the system installer. Updates below min_supported cannot be dismissed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a67c27f50
commit
765345e0f4
10 files changed
+645
-60
No files matched your search
@@ -20,12 +20,15 @@
|
||||
"expo": "~53.0.20",
|
||||
"expo-build-properties": "~0.14.8",
|
||||
"expo-constants": "~17.1.7",
|
||||
"expo-file-system": "~18.1.11",
|
||||
"expo-font": "~13.3.2",
|
||||
"expo-intent-launcher": "~12.1.5",
|
||||
"expo-linking": "~7.1.7",
|
||||
"expo-router": "~5.1.11",
|
||||
"expo-secure-store": "~14.2.4",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"i18next": "^25.2.1",
|
||||
"js-sha256": "^1.0.0",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "19.0.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import { Tabs, useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Tabs, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { colors } from '../../theme';
|
||||
import { Cover, Icon, type IconName } from '../../ui/kit';
|
||||
import { player } from "../../player/controller";
|
||||
import { usePlayerState } from "../../player/hooks";
|
||||
import { colors } from "../../theme";
|
||||
import { Cover, Icon, type IconName } from "../../ui/kit";
|
||||
import { UpdateDialog } from "../../ui/UpdateDialog";
|
||||
|
||||
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
|
||||
const ICONS: Record<string, IconName> = {
|
||||
index: "home",
|
||||
search: "search",
|
||||
collection: "library-music",
|
||||
profile: "person",
|
||||
};
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const labels: Record<string, string> = {
|
||||
index: t('nav.home'),
|
||||
search: t('mobile.search'),
|
||||
collection: t('nav.collection'),
|
||||
profile: t('mobile.profile'),
|
||||
index: t("nav.home"),
|
||||
search: t("mobile.search"),
|
||||
collection: t("nav.collection"),
|
||||
profile: t("mobile.profile"),
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
sceneStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
tabBar={({ state, navigation }) => (
|
||||
<View>
|
||||
<MiniPlayer />
|
||||
@@ -38,6 +48,8 @@ export default function TabsLayout() {
|
||||
<Tabs.Screen name="collection" />
|
||||
<Tabs.Screen name="profile" />
|
||||
</Tabs>
|
||||
<UpdateDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +69,7 @@ function TabBar({
|
||||
<View
|
||||
accessibilityRole="tablist"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexDirection: "row",
|
||||
paddingBottom: insets.bottom,
|
||||
backgroundColor: colors.bg2,
|
||||
borderTopWidth: 1,
|
||||
@@ -73,10 +85,24 @@ function TabBar({
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: on }}
|
||||
accessibilityLabel={labels[name]}
|
||||
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={ICONS[name] ?? "circle"}
|
||||
color={on ? colors.accent2 : colors.text3}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: on ? colors.text : colors.text3,
|
||||
fontWeight: on ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
|
||||
{labels[name]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -93,31 +119,68 @@ function MiniPlayer() {
|
||||
const st = usePlayerState();
|
||||
const track = st.track;
|
||||
if (!track) return null;
|
||||
const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0;
|
||||
const progress = st.durationMs
|
||||
? Math.min(st.positionMs / st.durationMs, 1)
|
||||
: 0;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => router.push('/player')}
|
||||
onPress={() => router.push("/player")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('mobile.open_player')}
|
||||
style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }}
|
||||
accessibilityLabel={t("mobile.open_player")}
|
||||
style={{
|
||||
backgroundColor: colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
}}
|
||||
>
|
||||
<View style={{ height: 2, backgroundColor: colors.surface3 }}>
|
||||
<View style={{ height: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
|
||||
<View
|
||||
style={{
|
||||
height: 2,
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: colors.accent2,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Cover album={track.album} size={42} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: colors.text, fontWeight: "600" }}
|
||||
>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 12 }}>
|
||||
{track.artists.map((a) => a.name).join(', ')}
|
||||
{track.artists.map((a) => a.name).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}>
|
||||
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={32} color={colors.text} />
|
||||
<Pressable
|
||||
onPress={() => player.toggle()}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t(st.playing ? "player.pause" : "player.play")}
|
||||
>
|
||||
<Icon
|
||||
name={st.playing ? "pause" : "play-arrow"}
|
||||
size={32}
|
||||
color={colors.text}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||
<Pressable
|
||||
onPress={() => player.next()}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("player.next")}
|
||||
>
|
||||
<Icon name="skip-next" size={28} color={colors.text2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||
import Constants from 'expo-constants';
|
||||
import * as Linking from 'expo-linking';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { unwrap } from "@pipu/core";
|
||||
import { SUPPORTED_LOCALES, type Locale } from "@pipu/i18n";
|
||||
import Constants from "expo-constants";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { player } from '../../player/controller';
|
||||
import { colors, radius } from '../../theme';
|
||||
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||
import { useSession, useSessionCtx, useUser } from "../../lib/session";
|
||||
import { updater } from "../../lib/update";
|
||||
import { player } from "../../player/controller";
|
||||
import { colors, radius } from "../../theme";
|
||||
import { Button, H1, Icon, Muted, Screen, Segmented } from "../../ui/kit";
|
||||
import { useUpdateState } from "../../ui/UpdateDialog";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const { server, disconnect } = useSessionCtx();
|
||||
const staff = user.role !== 'user';
|
||||
const staff = user.role !== "user";
|
||||
const update = useUpdateState();
|
||||
|
||||
const setLocale = async (l: Locale) => {
|
||||
await i18n.changeLanguage(l);
|
||||
try {
|
||||
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
|
||||
session.setUser(
|
||||
unwrap(
|
||||
await session.client.PATCH("/api/v1/me", { body: { locale: l } }),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// язык уже сменился локально
|
||||
}
|
||||
@@ -40,33 +47,79 @@ export default function ProfileScreen() {
|
||||
</Muted>
|
||||
|
||||
<Section>
|
||||
<Row label={t('language.label')}>
|
||||
<Row label={t("language.label")}>
|
||||
<Segmented<Locale>
|
||||
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
|
||||
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
|
||||
value={
|
||||
(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language)
|
||||
? (i18n.language as Locale)
|
||||
: "en"
|
||||
}
|
||||
options={SUPPORTED_LOCALES.map((l) => ({
|
||||
value: l,
|
||||
label: l.toUpperCase(),
|
||||
}))}
|
||||
onChange={(l) => void setLocale(l)}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Row label={t('mobile.server')}>
|
||||
<Row label={t("mobile.server")}>
|
||||
<Text style={{ color: colors.text2 }} numberOfLines={1}>
|
||||
{server?.replace(/^https?:\/\//, '')}
|
||||
{server?.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
</Row>
|
||||
{staff && server && (
|
||||
<Pressable onPress={() => void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(`${server}/admin/users`)}
|
||||
accessibilityRole="link"
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
>
|
||||
<Icon name="open-in-new" size={20} color={colors.accent2} />
|
||||
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
|
||||
<Text style={{ color: colors.accent2, fontWeight: "500" }}>
|
||||
{t("mobile.open_admin")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Button title={t('profile.logout')} variant="danger" icon="logout" onPress={() => void logout()} />
|
||||
<Button title={t('mobile.change_server')} variant="ghost" onPress={() => void logout().then(disconnect)} />
|
||||
<Text style={{ color: colors.text3, textAlign: 'center', fontSize: 12 }}>
|
||||
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
|
||||
<Button
|
||||
title={t(
|
||||
update.status === "checking"
|
||||
? "mobile.update_checking"
|
||||
: "mobile.update_check",
|
||||
)}
|
||||
variant="secondary"
|
||||
icon="system-update"
|
||||
busy={update.status === "checking"}
|
||||
onPress={() => void updater.check(session, true)}
|
||||
/>
|
||||
{update.status === "latest" && (
|
||||
<Muted>{t("mobile.update_latest")}</Muted>
|
||||
)}
|
||||
|
||||
<Button
|
||||
title={t("profile.logout")}
|
||||
variant="danger"
|
||||
icon="logout"
|
||||
onPress={() => void logout()}
|
||||
/>
|
||||
<Button
|
||||
title={t("mobile.change_server")}
|
||||
variant="ghost"
|
||||
onPress={() => void logout().then(disconnect)}
|
||||
/>
|
||||
<Text
|
||||
style={{ color: colors.text3, textAlign: "center", fontSize: 12 }}
|
||||
>
|
||||
{t("mobile.version", {
|
||||
version: Constants.expoConfig?.version ?? "0.0.0",
|
||||
})}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
@@ -74,12 +127,36 @@ export default function ProfileScreen() {
|
||||
}
|
||||
|
||||
function Section({ children }: { children: React.ReactNode }) {
|
||||
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.surface,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function Row({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.text }}>{label}</Text>
|
||||
<View style={{ flexShrink: 1 }}>{children}</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { unwrap, type AuthSession } from "@pipu/core";
|
||||
import type { components } from "@pipu/api-client";
|
||||
import Constants from "expo-constants";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as IntentLauncher from "expo-intent-launcher";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { sha256 } from "js-sha256";
|
||||
|
||||
export type AndroidRelease = components["schemas"]["AndroidRelease"];
|
||||
|
||||
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
||||
export type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "latest" }
|
||||
| {
|
||||
status: "available";
|
||||
release: AndroidRelease;
|
||||
required: boolean;
|
||||
dismissed: boolean;
|
||||
}
|
||||
| {
|
||||
status: "downloading";
|
||||
release: AndroidRelease;
|
||||
required: boolean;
|
||||
progress: number;
|
||||
}
|
||||
| { status: "verifying"; release: AndroidRelease; required: boolean }
|
||||
| {
|
||||
status: "ready";
|
||||
release: AndroidRelease;
|
||||
required: boolean;
|
||||
file: string;
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
release: AndroidRelease;
|
||||
required: boolean;
|
||||
error: "bad_checksum" | "failed";
|
||||
};
|
||||
|
||||
const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
|
||||
const CHECKED_KEY = "pipu.update.checked";
|
||||
const HASH_CHUNK = 1 << 20;
|
||||
const APK_MIME = "application/vnd.android.package-archive";
|
||||
|
||||
export function installedVersionCode(): number {
|
||||
return Number(
|
||||
(Constants.expoConfig?.extra as { versionCode?: number } | undefined)
|
||||
?.versionCode ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** SHA-256 файла кусками по 1 МБ: целиком APK в память JS не читаем. */
|
||||
async function fileSha256(uri: string, size: number): Promise<string> {
|
||||
const hash = sha256.create();
|
||||
for (let pos = 0; pos < size; pos += HASH_CHUNK) {
|
||||
const b64 = await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
position: pos,
|
||||
length: Math.min(HASH_CHUNK, size - pos),
|
||||
});
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
hash.update(bytes);
|
||||
}
|
||||
return hash.hex();
|
||||
}
|
||||
|
||||
class Updater {
|
||||
private current: UpdateState = { status: "idle" };
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private download: FileSystem.DownloadResumable | null = null;
|
||||
|
||||
get state(): UpdateState {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
};
|
||||
|
||||
private set(s: UpdateState) {
|
||||
this.current = s;
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
|
||||
/** Проверка: автоматически — не чаще раза в 6 часов, вручную (профиль) — всегда. */
|
||||
async check(session: AuthSession, manual = false): Promise<void> {
|
||||
if (["checking", "downloading", "verifying"].includes(this.current.status))
|
||||
return;
|
||||
if (!manual) {
|
||||
const last = Number((await SecureStore.getItemAsync(CHECKED_KEY)) ?? 0);
|
||||
if (Date.now() - last < CHECK_EVERY_MS) return;
|
||||
}
|
||||
if (manual) this.set({ status: "checking" });
|
||||
try {
|
||||
const release = unwrap(
|
||||
await session.client.GET("/api/v1/app/android/latest"),
|
||||
);
|
||||
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
|
||||
const installed = installedVersionCode();
|
||||
if (release.version_code > installed) {
|
||||
this.set({
|
||||
status: "available",
|
||||
release,
|
||||
required: installed < release.min_supported,
|
||||
dismissed: false,
|
||||
});
|
||||
} else {
|
||||
this.set(manual ? { status: "latest" } : { status: "idle" });
|
||||
}
|
||||
} catch {
|
||||
// Нет релиза или сервер без Gitea — молча; при ручной проверке показываем «последняя версия».
|
||||
this.set(manual ? { status: "latest" } : { status: "idle" });
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const s = this.current;
|
||||
if (s.status === "available" && !s.required)
|
||||
this.set({ ...s, dismissed: true });
|
||||
else if (s.status === "latest") this.set({ status: "idle" });
|
||||
}
|
||||
|
||||
/** Скачивает APK с сервера (с Bearer), сверяет SHA-256 и запускает установщик. */
|
||||
async downloadAndInstall(
|
||||
session: AuthSession,
|
||||
server: string,
|
||||
): Promise<void> {
|
||||
const s = this.current;
|
||||
if (
|
||||
s.status !== "available" &&
|
||||
s.status !== "error" &&
|
||||
s.status !== "ready"
|
||||
)
|
||||
return;
|
||||
const { release, required } = s;
|
||||
if (s.status === "ready") return this.install(s.file);
|
||||
|
||||
// Любой запрос через client обновит access-токен, если он истёк.
|
||||
await session.client
|
||||
.GET("/api/v1/app/android/latest")
|
||||
.catch(() => undefined);
|
||||
const file = `${FileSystem.cacheDirectory}pipu-music-${release.version}.apk`;
|
||||
this.set({ status: "downloading", release, required, progress: 0 });
|
||||
try {
|
||||
await FileSystem.deleteAsync(file, { idempotent: true });
|
||||
this.download = FileSystem.createDownloadResumable(
|
||||
server + release.download_url,
|
||||
file,
|
||||
{ headers: { Authorization: `Bearer ${session.accessToken ?? ""}` } },
|
||||
(p) => {
|
||||
const total =
|
||||
p.totalBytesExpectedToWrite > 0
|
||||
? p.totalBytesExpectedToWrite
|
||||
: release.size;
|
||||
this.set({
|
||||
status: "downloading",
|
||||
release,
|
||||
required,
|
||||
progress: total ? p.totalBytesWritten / total : 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
const res = await this.download.downloadAsync();
|
||||
this.download = null;
|
||||
if (!res || res.status !== 200) throw new Error(`status ${res?.status}`);
|
||||
|
||||
this.set({ status: "verifying", release, required });
|
||||
const info = await FileSystem.getInfoAsync(file);
|
||||
const size = info.exists ? info.size : 0;
|
||||
if ((await fileSha256(file, size)) !== release.sha256.toLowerCase()) {
|
||||
await FileSystem.deleteAsync(file, { idempotent: true });
|
||||
this.set({ status: "error", release, required, error: "bad_checksum" });
|
||||
return;
|
||||
}
|
||||
this.set({ status: "ready", release, required, file });
|
||||
await this.install(file);
|
||||
} catch {
|
||||
this.download = null;
|
||||
this.set({ status: "error", release, required, error: "failed" });
|
||||
}
|
||||
}
|
||||
|
||||
private async install(file: string) {
|
||||
const uri = await FileSystem.getContentUriAsync(file);
|
||||
await IntentLauncher.startActivityAsync("android.intent.action.VIEW", {
|
||||
data: uri,
|
||||
type: APK_MIME,
|
||||
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
||||
});
|
||||
}
|
||||
|
||||
/** Экран «Установка неизвестных приложений» для нашего пакета. */
|
||||
async openInstallSettings() {
|
||||
const pkg = Constants.expoConfig?.android?.package ?? "app.pipu.music";
|
||||
await IntentLauncher.startActivityAsync(
|
||||
"android.settings.MANAGE_UNKNOWN_APP_SOURCES",
|
||||
{ data: `package:${pkg}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const updater = new Updater();
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AppState,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { useSession, useSessionCtx } from "../lib/session";
|
||||
import { updater } from "../lib/update";
|
||||
import { colors, radius } from "../theme";
|
||||
import { Button } from "./kit";
|
||||
|
||||
export function useUpdateState() {
|
||||
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
||||
}
|
||||
|
||||
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
||||
export function UpdateDialog() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const st = useUpdateState();
|
||||
|
||||
useEffect(() => {
|
||||
void updater.check(session);
|
||||
const sub = AppState.addEventListener("change", (s) => {
|
||||
if (s === "active") void updater.check(session);
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [session]);
|
||||
|
||||
if (
|
||||
st.status === "idle" ||
|
||||
st.status === "checking" ||
|
||||
st.status === "latest"
|
||||
)
|
||||
return null;
|
||||
if (st.status === "available" && st.dismissed) return null;
|
||||
|
||||
const { release, required } = st;
|
||||
const busy = st.status === "downloading" || st.status === "verifying";
|
||||
const action = () => void updater.downloadAndInstall(session, server ?? "");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible
|
||||
onRequestClose={() => updater.dismiss()}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.lg,
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
maxHeight: "80%",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.text, fontSize: 18, fontWeight: "700" }}>
|
||||
{t("mobile.update_title", { version: release.version })}
|
||||
</Text>
|
||||
{required && (
|
||||
<Text style={{ color: colors.danger }}>
|
||||
{t("mobile.update_required")}
|
||||
</Text>
|
||||
)}
|
||||
{release.notes_md.trim() !== "" && (
|
||||
<ScrollView style={{ maxHeight: 240 }}>
|
||||
<Text style={{ color: colors.text2, lineHeight: 20 }}>
|
||||
{plainNotes(release.notes_md)}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
)}
|
||||
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
||||
{t("mobile.update_size", {
|
||||
size: (release.size / 1048576).toFixed(1),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{st.status === "downloading" && (
|
||||
<View style={{ gap: 6 }}>
|
||||
<View
|
||||
style={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.surface3,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
width: `${Math.round(st.progress * 100)}%`,
|
||||
backgroundColor: colors.accent2,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text style={{ color: colors.text2, fontSize: 12 }}>
|
||||
{t("mobile.update_downloading", {
|
||||
percent: Math.round(st.progress * 100),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{st.status === "verifying" && (
|
||||
<Text style={{ color: colors.text2, fontSize: 12 }}>
|
||||
{t("mobile.update_verifying")}
|
||||
</Text>
|
||||
)}
|
||||
{st.status === "error" && (
|
||||
<Text style={{ color: colors.danger }}>
|
||||
{t(
|
||||
st.error === "bad_checksum"
|
||||
? "mobile.update_bad_checksum"
|
||||
: "mobile.update_failed",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{st.status === "ready" && (
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text style={{ color: colors.text2, fontSize: 12 }}>
|
||||
{t("mobile.update_allow")}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void updater.openInstallSettings()}
|
||||
accessibilityRole="link"
|
||||
>
|
||||
<Text style={{ color: colors.accent2, fontWeight: "500" }}>
|
||||
{t("mobile.update_open_settings")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button
|
||||
title={t("mobile.update_install")}
|
||||
icon="system-update"
|
||||
busy={busy}
|
||||
onPress={action}
|
||||
/>
|
||||
{!required && !busy && (
|
||||
<Button
|
||||
title={t("mobile.update_later")}
|
||||
variant="ghost"
|
||||
onPress={() => updater.dismiss()}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
|
||||
function plainNotes(md: string): string {
|
||||
return md
|
||||
.replace(/^#{1,6}\s*/gm, "")
|
||||
.replace(/\*\*(.+?)\*\*/g, "$1")
|
||||
.replace(/^\s*[-*]\s+/gm, "• ")
|
||||
.replace(/\[(.+?)\]\(.+?\)/g, "$1")
|
||||
.trim();
|
||||
}
|
||||
+7
-3
@@ -75,13 +75,17 @@
|
||||
2. Если `version_code` новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже».
|
||||
Если текущая версия ниже `min_supported` — обновление обязательно.
|
||||
3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза).
|
||||
4. Установка: `Intent.ACTION_VIEW` с `content://` URI через `FileProvider` и флагом `FLAG_GRANT_READ_URI_PERMISSION`
|
||||
(небольшой локальный Expo-модуль `modules/apk-installer` на Kotlin; при необходимости — `PackageInstaller` session API).
|
||||
Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`,
|
||||
сервер отдаёт файл с поддержкой `Range` и сам сверяет SHA-256 при кэшировании из Gitea.
|
||||
4. Установка: `Intent.ACTION_VIEW` с `content://` URI (`FileSystem.getContentUriAsync` — FileProvider expo-file-system)
|
||||
и флагом `FLAG_GRANT_READ_URI_PERMISSION` через `expo-intent-launcher` (`src/lib/update.ts`).
|
||||
5. Если нет разрешения «установка из неизвестных источников» — открываем `Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES`
|
||||
для нашего пакета с пояснением.
|
||||
|
||||
**Требования:**
|
||||
- В манифесте: `REQUEST_INSTALL_PACKAGES`, `FileProvider`.
|
||||
- В манифесте: `REQUEST_INSTALL_PACKAGES` (FileProvider добавляет expo-file-system).
|
||||
- Сервер: `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN`, кэш APK — `UPDATES_DIR` (`/updates`). Без них — `404 app.update_unavailable`,
|
||||
приложение молча пропускает проверку.
|
||||
- Все релизы подписываются **одним и тем же ключом** (keystore в секретах CI) — иначе Android откажет в обновлении.
|
||||
- `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 | rc)` (см. [ci-cd.md](ci-cd.md#версионирование)).
|
||||
- Ассеты релиза: `pipu-music-<version>.apk`, `pipu-music-<version>.apk.sha256`.
|
||||
|
||||
@@ -101,6 +101,11 @@ export class AuthSession {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Текущий access-токен — для запросов мимо `client` (загрузка файлов на Android). */
|
||||
get accessToken(): string | null {
|
||||
return this.access;
|
||||
}
|
||||
|
||||
/** Подписка на смену состояния; возвращает функцию отписки. */
|
||||
subscribe(fn: (s: AuthState) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
|
||||
@@ -201,6 +201,20 @@
|
||||
"open_player": "Open the player",
|
||||
"close_player": "Collapse the player",
|
||||
"queue": "Queue",
|
||||
"forget_server": "Forget server"
|
||||
"forget_server": "Forget server",
|
||||
"update_check": "Check for updates",
|
||||
"update_checking": "Checking…",
|
||||
"update_latest": "You have the latest version",
|
||||
"update_title": "Version {{version}} is available",
|
||||
"update_required": "The server no longer supports this version — please update the app",
|
||||
"update_size": "Size {{size}} MB",
|
||||
"update_install": "Download and install",
|
||||
"update_later": "Later",
|
||||
"update_downloading": "Downloading {{percent}}%",
|
||||
"update_verifying": "Verifying the file…",
|
||||
"update_bad_checksum": "The file is corrupted — try again",
|
||||
"update_failed": "Could not download the update",
|
||||
"update_allow": "Allow installing apps from this app in Android settings",
|
||||
"update_open_settings": "Open settings"
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,20 @@
|
||||
"open_player": "Открыть плеер",
|
||||
"close_player": "Свернуть плеер",
|
||||
"queue": "Очередь",
|
||||
"forget_server": "Забыть сервер"
|
||||
"forget_server": "Забыть сервер",
|
||||
"update_check": "Проверить обновления",
|
||||
"update_checking": "Проверяем…",
|
||||
"update_latest": "Установлена последняя версия",
|
||||
"update_title": "Доступна версия {{version}}",
|
||||
"update_required": "Эта версия больше не поддерживается сервером — обновите приложение",
|
||||
"update_size": "Размер {{size}} МБ",
|
||||
"update_install": "Скачать и установить",
|
||||
"update_later": "Позже",
|
||||
"update_downloading": "Загрузка {{percent}}%",
|
||||
"update_verifying": "Проверяем файл…",
|
||||
"update_bad_checksum": "Файл повреждён — попробуйте ещё раз",
|
||||
"update_failed": "Не удалось скачать обновление",
|
||||
"update_allow": "Разрешите установку из этого приложения в настройках Android",
|
||||
"update_open_settings": "Открыть настройки"
|
||||
}
|
||||
}
|
||||
Generated
+23
@@ -47,9 +47,15 @@ importers:
|
||||
expo-constants:
|
||||
specifier: ~17.1.7
|
||||
version: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
|
||||
expo-file-system:
|
||||
specifier: ~18.1.11
|
||||
version: 18.1.11(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
|
||||
expo-font:
|
||||
specifier: ~13.3.2
|
||||
version: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||
expo-intent-launcher:
|
||||
specifier: ~12.1.5
|
||||
version: 12.1.5(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))
|
||||
expo-linking:
|
||||
specifier: ~7.1.7
|
||||
version: 7.1.7(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||
@@ -65,6 +71,9 @@ importers:
|
||||
i18next:
|
||||
specifier: ^25.2.1
|
||||
version: 25.10.10(typescript@5.8.3)
|
||||
js-sha256:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
query-string:
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
@@ -2095,6 +2104,11 @@ packages:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
|
||||
expo-intent-launcher@12.1.5:
|
||||
resolution: {integrity: sha512-KmCc/dJHTnVf2ZdrZhYSkvQ588K7qQW+nBGfJj5woCwhEXwYz1xOLQcShnPQgQWRf8conAvQDkI3pbjYNPcECw==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-keep-awake@14.1.4:
|
||||
resolution: {integrity: sha512-wU9qOnosy4+U4z/o4h8W9PjPvcFMfZXrlUoKTMBW7F4pLqhkkP/5G4EviPZixv4XWFMjn1ExQ5rV6BX8GwJsWA==}
|
||||
peerDependencies:
|
||||
@@ -2456,6 +2470,9 @@ packages:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
js-sha256@1.0.0:
|
||||
resolution: {integrity: sha512-Bqxf6ENUzYIMzuELCmRNrJOVbjKH1oMgbfYJBKVr/W1Xf9fazpahqCbb24v1pR7XV1isuqhM+w9KWpK7zCyUQw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -6070,6 +6087,10 @@ snapshots:
|
||||
fontfaceobserver: 2.3.0
|
||||
react: 19.0.0
|
||||
|
||||
expo-intent-launcher@12.1.5(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)):
|
||||
dependencies:
|
||||
expo: 53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||
|
||||
expo-keep-awake@14.1.4(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
||||
dependencies:
|
||||
expo: 53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||
@@ -6475,6 +6496,8 @@ snapshots:
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
js-sha256@1.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
Reference in new issue
Block a user