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
+663
-78
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user