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

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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:55:34 +03:00
1 parent 6a67c27f50
commit 765345e0f4
10 files changed
+663 -78

No files matched your search

+3
View File
@@ -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",
+110 -47
View File
@@ -1,43 +1,55 @@
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 } }}
tabBar={({ state, navigation }) => (
<View>
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
onPress={(name) => navigation.navigate(name)}
/>
</View>
)}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="profile" />
</Tabs>
<>
<Tabs
screenOptions={{
headerShown: false,
sceneStyle: { backgroundColor: colors.bg },
}}
tabBar={({ state, navigation }) => (
<View>
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
onPress={(name) => navigation.navigate(name)}
/>
</View>
)}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<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>
+103 -26
View File
@@ -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>
+207
View File
@@ -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();
+175
View File
@@ -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();
}