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

+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();
}