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

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