feat(mobile): offline downloads, persistent event queue and offline start (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
- Downloads queue (2 parallel, Wi-Fi only by default) stores files under the app's document directory and track metadata in SQLite; Collection gets a Downloads tab, album and player screens get download buttons. - Player plays downloaded files and local covers; My Wave falls back to a shuffle of downloaded tracks when the server is unreachable. - Playback events that fail on the network are kept in SQLite and sent when the connection returns. - AuthSession can open offline with the cached user and fetches an access token before the first request once online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
765345e0f4
commit
be34ee5361
22 files changed
+881
-387
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type { RefreshStore } from '@pipu/core';
|
||||
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
// Всё храним в SecureStore (Android Keystore): список серверов маленький, а refresh-токен — секрет.
|
||||
@@ -37,7 +37,7 @@ export async function rememberServer(server: SavedServer): Promise<void> {
|
||||
export async function forgetServer(url: string): Promise<void> {
|
||||
const list = (await loadServers()).filter((s) => s.url !== url);
|
||||
await SecureStore.setItemAsync(SERVERS_KEY, JSON.stringify(list));
|
||||
await SecureStore.deleteItemAsync(keyFor(url));
|
||||
await refreshStoreFor(url).clear();
|
||||
if ((await currentServer()) === url) await SecureStore.deleteItemAsync(CURRENT_KEY);
|
||||
}
|
||||
|
||||
@@ -50,12 +50,32 @@ export async function setCurrentServer(url: string | null): Promise<void> {
|
||||
else await SecureStore.deleteItemAsync(CURRENT_KEY);
|
||||
}
|
||||
|
||||
/** Refresh-токен сервера: у каждого сервера — свой. */
|
||||
/** Refresh-токен сервера (у каждого сервера — свой) и последний пользователь — для запуска без сети. */
|
||||
export function refreshStoreFor(url: string): RefreshStore {
|
||||
const key = keyFor(url);
|
||||
const userKey = key.replace('pipu.rt.', 'pipu.user.');
|
||||
return {
|
||||
get: () => SecureStore.getItemAsync(key),
|
||||
set: (token) => SecureStore.setItemAsync(key, token),
|
||||
clear: () => SecureStore.deleteItemAsync(key),
|
||||
clear: async () => {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
await SecureStore.deleteItemAsync(userKey);
|
||||
},
|
||||
getUser: async () => {
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(userKey);
|
||||
return raw ? (JSON.parse(raw) as AuthUser) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setUser: (user) => SecureStore.setItemAsync(userKey, JSON.stringify(user)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Короткий стабильный хэш строки — для имён файлов и ключей. */
|
||||
export function shortHash(s: string): string {
|
||||
let h = 0;
|
||||
for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return h.toString(36);
|
||||
}
|
||||
@@ -1,54 +1,51 @@
|
||||
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";
|
||||
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"];
|
||||
export type AndroidRelease = components['schemas']['AndroidRelease'];
|
||||
|
||||
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
||||
export type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "latest" }
|
||||
| { status: 'idle' }
|
||||
| { status: 'checking' }
|
||||
| { status: 'latest' }
|
||||
| {
|
||||
status: "available";
|
||||
status: 'available';
|
||||
release: AndroidRelease;
|
||||
required: boolean;
|
||||
dismissed: boolean;
|
||||
}
|
||||
| {
|
||||
status: "downloading";
|
||||
status: 'downloading';
|
||||
release: AndroidRelease;
|
||||
required: boolean;
|
||||
progress: number;
|
||||
}
|
||||
| { status: "verifying"; release: AndroidRelease; required: boolean }
|
||||
| { status: 'verifying'; release: AndroidRelease; required: boolean }
|
||||
| {
|
||||
status: "ready";
|
||||
status: 'ready';
|
||||
release: AndroidRelease;
|
||||
required: boolean;
|
||||
file: string;
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
status: 'error';
|
||||
release: AndroidRelease;
|
||||
required: boolean;
|
||||
error: "bad_checksum" | "failed";
|
||||
error: 'bad_checksum' | 'failed';
|
||||
};
|
||||
|
||||
const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
|
||||
const CHECKED_KEY = "pipu.update.checked";
|
||||
const CHECKED_KEY = 'pipu.update.checked';
|
||||
const HASH_CHUNK = 1 << 20;
|
||||
const APK_MIME = "application/vnd.android.package-archive";
|
||||
const APK_MIME = 'application/vnd.android.package-archive';
|
||||
|
||||
export function installedVersionCode(): number {
|
||||
return Number(
|
||||
(Constants.expoConfig?.extra as { versionCode?: number } | undefined)
|
||||
?.versionCode ?? 0,
|
||||
);
|
||||
return Number((Constants.expoConfig?.extra as { versionCode?: number } | undefined)?.versionCode ?? 0);
|
||||
}
|
||||
|
||||
/** SHA-256 файла кусками по 1 МБ: целиком APK в память JS не читаем. */
|
||||
@@ -69,7 +66,7 @@ async function fileSha256(uri: string, size: number): Promise<string> {
|
||||
}
|
||||
|
||||
class Updater {
|
||||
private current: UpdateState = { status: "idle" };
|
||||
private current: UpdateState = { status: 'idle' };
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private download: FileSystem.DownloadResumable | null = null;
|
||||
|
||||
@@ -89,76 +86,59 @@ class Updater {
|
||||
|
||||
/** Проверка: автоматически — не чаще раза в 6 часов, вручную (профиль) — всегда. */
|
||||
async check(session: AuthSession, manual = false): Promise<void> {
|
||||
if (["checking", "downloading", "verifying"].includes(this.current.status))
|
||||
return;
|
||||
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" });
|
||||
if (manual) this.set({ status: 'checking' });
|
||||
try {
|
||||
const release = unwrap(
|
||||
await session.client.GET("/api/v1/app/android/latest"),
|
||||
);
|
||||
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",
|
||||
status: 'available',
|
||||
release,
|
||||
required: installed < release.min_supported,
|
||||
dismissed: false,
|
||||
});
|
||||
} else {
|
||||
this.set(manual ? { status: "latest" } : { status: "idle" });
|
||||
this.set(manual ? { status: 'latest' } : { status: 'idle' });
|
||||
}
|
||||
} catch {
|
||||
// Нет релиза или сервер без Gitea — молча; при ручной проверке показываем «последняя версия».
|
||||
this.set(manual ? { status: "latest" } : { status: "idle" });
|
||||
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" });
|
||||
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> {
|
||||
async downloadAndInstall(session: AuthSession, server: string): Promise<void> {
|
||||
const s = this.current;
|
||||
if (
|
||||
s.status !== "available" &&
|
||||
s.status !== "error" &&
|
||||
s.status !== "ready"
|
||||
)
|
||||
return;
|
||||
if (s.status !== 'available' && s.status !== 'error' && s.status !== 'ready') return;
|
||||
const { release, required } = s;
|
||||
if (s.status === "ready") return this.install(s.file);
|
||||
if (s.status === 'ready') return this.install(s.file);
|
||||
|
||||
// Любой запрос через client обновит access-токен, если он истёк.
|
||||
await session.client
|
||||
.GET("/api/v1/app/android/latest")
|
||||
.catch(() => undefined);
|
||||
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 });
|
||||
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 ?? ""}` } },
|
||||
{ headers: { Authorization: `Bearer ${session.accessToken ?? ''}` } },
|
||||
(p) => {
|
||||
const total =
|
||||
p.totalBytesExpectedToWrite > 0
|
||||
? p.totalBytesExpectedToWrite
|
||||
: release.size;
|
||||
const total = p.totalBytesExpectedToWrite > 0 ? p.totalBytesExpectedToWrite : release.size;
|
||||
this.set({
|
||||
status: "downloading",
|
||||
status: 'downloading',
|
||||
release,
|
||||
required,
|
||||
progress: total ? p.totalBytesWritten / total : 0,
|
||||
@@ -169,25 +149,25 @@ class Updater {
|
||||
this.download = null;
|
||||
if (!res || res.status !== 200) throw new Error(`status ${res?.status}`);
|
||||
|
||||
this.set({ status: "verifying", release, required });
|
||||
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" });
|
||||
this.set({ status: 'error', release, required, error: 'bad_checksum' });
|
||||
return;
|
||||
}
|
||||
this.set({ status: "ready", release, required, file });
|
||||
this.set({ status: 'ready', release, required, file });
|
||||
await this.install(file);
|
||||
} catch {
|
||||
this.download = null;
|
||||
this.set({ status: "error", release, required, error: "failed" });
|
||||
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", {
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
data: uri,
|
||||
type: APK_MIME,
|
||||
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
||||
@@ -196,11 +176,8 @@ class Updater {
|
||||
|
||||
/** Экран «Установка неизвестных приложений» для нашего пакета. */
|
||||
async openInstallSettings() {
|
||||
const pkg = Constants.expoConfig?.android?.package ?? "app.pipu.music";
|
||||
await IntentLauncher.startActivityAsync(
|
||||
"android.settings.MANAGE_UNKNOWN_APP_SOURCES",
|
||||
{ data: `package:${pkg}` },
|
||||
);
|
||||
const pkg = Constants.expoConfig?.android?.package ?? 'app.pipu.music';
|
||||
await IntentLauncher.startActivityAsync('android.settings.MANAGE_UNKNOWN_APP_SOURCES', { data: `package:${pkg}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user