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,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiError, AuthSession, type AuthTokens, type RefreshStore } from './auth';
|
||||
import { ApiError, AuthSession, type AuthTokens, type AuthUser, type RefreshStore } from './auth';
|
||||
|
||||
const user = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
@@ -182,6 +182,32 @@ describe('AuthSession', () => {
|
||||
expect(await auth.refresh()).toBe(false);
|
||||
});
|
||||
|
||||
it('opens offline with the cached user and refreshes on the first request', async () => {
|
||||
let online = false;
|
||||
const store = { ...memoryStore('R0'), cached: null as AuthUser | null };
|
||||
store.getUser = async () => store.cached;
|
||||
store.setUser = async (u) => {
|
||||
store.cached = u;
|
||||
};
|
||||
store.cached = user;
|
||||
const s = server({
|
||||
'POST /api/v1/auth/refresh': () => json(tokens('A1', 'R1')),
|
||||
'GET /api/v1/me': () => json(user),
|
||||
});
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
if (!online) throw new TypeError('Network request failed');
|
||||
return s.fetch(input);
|
||||
}) as typeof fetch;
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: fetchImpl, clientType: 'android', refreshStore: store });
|
||||
expect(await auth.restore()).toEqual({ status: 'signed_in', user, offline: true });
|
||||
|
||||
online = true;
|
||||
await auth.client.GET('/api/v1/me');
|
||||
expect(s.log.map((l) => l.route)).toEqual(['POST /api/v1/auth/refresh', 'GET /api/v1/me']);
|
||||
expect(s.log[1]?.auth).toBe('Bearer A1');
|
||||
expect(auth.state).toEqual({ status: 'signed_in', user });
|
||||
});
|
||||
|
||||
it('requires a refresh store for android', () => {
|
||||
expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/);
|
||||
});
|
||||
|
||||
@@ -48,12 +48,16 @@ export interface RefreshStore {
|
||||
get(): Promise<string | null>;
|
||||
set(token: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
/** Последний вошедший пользователь — чтобы открыть приложение без сети (Android, офлайн-режим). */
|
||||
getUser?(): Promise<AuthUser | null>;
|
||||
setUser?(user: AuthUser): Promise<void>;
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: 'unknown' }
|
||||
| { status: 'signed_out'; reason?: string }
|
||||
| { status: 'signed_in'; user: AuthUser };
|
||||
/** offline — сервер недоступен при запуске, пользователь взят из RefreshStore; токен обновится при первом запросе. */
|
||||
| { status: 'signed_in'; user: AuthUser; offline?: boolean };
|
||||
|
||||
export interface AuthSessionOptions {
|
||||
/** Адрес сервера; пусто — тот же origin (веб). */
|
||||
@@ -84,6 +88,7 @@ export class AuthSession {
|
||||
private readonly baseFetch: typeof globalThis.fetch;
|
||||
private access: string | null = null;
|
||||
private refreshing: Promise<boolean> | null = null;
|
||||
private networkDown = false;
|
||||
private current: AuthState = { status: 'unknown' };
|
||||
private readonly listeners = new Set<(s: AuthState) => void>();
|
||||
|
||||
@@ -120,7 +125,9 @@ export class AuthSession {
|
||||
/** Восстанавливает сессию после перезагрузки по refresh-токену. */
|
||||
async restore(): Promise<AuthState> {
|
||||
if (!(await this.refresh())) {
|
||||
if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
|
||||
const user = this.networkDown ? await this.opts.refreshStore?.getUser?.() : null;
|
||||
if (user) this.setState({ status: 'signed_in', user, offline: true });
|
||||
else if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
@@ -136,13 +143,17 @@ export class AuthSession {
|
||||
async apply(tokens: AuthTokens): Promise<AuthUser> {
|
||||
this.access = tokens.access_token;
|
||||
if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token);
|
||||
await this.opts.refreshStore?.setUser?.(tokens.user);
|
||||
this.setState({ status: 'signed_in', user: tokens.user });
|
||||
return tokens.user;
|
||||
}
|
||||
|
||||
/** Обновляет профиль в состоянии (после PATCH /me). */
|
||||
setUser(user: AuthUser) {
|
||||
if (this.current.status === 'signed_in') this.setState({ status: 'signed_in', user });
|
||||
if (this.current.status === 'signed_in') {
|
||||
this.setState({ ...this.current, user });
|
||||
void this.opts.refreshStore?.setUser?.(user);
|
||||
}
|
||||
}
|
||||
|
||||
async logout(all = false): Promise<void> {
|
||||
@@ -177,7 +188,9 @@ export class AuthSession {
|
||||
let res;
|
||||
try {
|
||||
res = await this.raw.POST('/api/v1/auth/refresh', { body });
|
||||
this.networkDown = false;
|
||||
} catch {
|
||||
this.networkDown = true;
|
||||
return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова
|
||||
}
|
||||
if (res.data) {
|
||||
@@ -195,6 +208,8 @@ export class AuthSession {
|
||||
}
|
||||
|
||||
private async authFetch(request: Request): Promise<Response> {
|
||||
// Запущены без сети (offline): access-токена ещё нет — получаем его перед первым запросом.
|
||||
if (!this.access && this.opts.refreshStore && this.current.status === 'signed_in') await this.refresh();
|
||||
const retry = request.clone();
|
||||
const sent = this.access;
|
||||
const res = await this.baseFetch(this.withAuth(request));
|
||||
|
||||
@@ -215,6 +215,22 @@
|
||||
"update_bad_checksum": "The file is corrupted — try again",
|
||||
"update_failed": "Could not download the update",
|
||||
"update_allow": "Allow installing apps from this app in Android settings",
|
||||
"update_open_settings": "Open settings"
|
||||
"update_open_settings": "Open settings",
|
||||
"dl_title": "Downloads",
|
||||
"dl_download": "Download",
|
||||
"dl_remove": "Remove from device",
|
||||
"dl_album": "Download album",
|
||||
"dl_all_likes": "Download all likes",
|
||||
"dl_wifi_only": "Download over Wi-Fi only",
|
||||
"dl_auto_likes": "Download new likes",
|
||||
"dl_usage": "{{used}} MB used · {{free}} GB free on the phone",
|
||||
"dl_pending_one": "{{count}} track queued",
|
||||
"dl_pending_other": "{{count}} tracks queued",
|
||||
"dl_waiting_wifi": "Downloads are waiting for Wi-Fi ({{count}})",
|
||||
"dl_waiting_network": "Downloads are waiting for a connection ({{count}})",
|
||||
"dl_retry": "Retry failed",
|
||||
"dl_empty": "Download tracks, albums and likes to listen without a connection",
|
||||
"dl_pending_few": "{{count}} tracks queued",
|
||||
"dl_pending_many": "{{count}} tracks queued"
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,22 @@
|
||||
"update_bad_checksum": "Файл повреждён — попробуйте ещё раз",
|
||||
"update_failed": "Не удалось скачать обновление",
|
||||
"update_allow": "Разрешите установку из этого приложения в настройках Android",
|
||||
"update_open_settings": "Открыть настройки"
|
||||
"update_open_settings": "Открыть настройки",
|
||||
"dl_title": "Скачанное",
|
||||
"dl_download": "Скачать",
|
||||
"dl_remove": "Удалить с устройства",
|
||||
"dl_album": "Скачать альбом",
|
||||
"dl_all_likes": "Скачать все лайки",
|
||||
"dl_wifi_only": "Скачивать только по Wi-Fi",
|
||||
"dl_auto_likes": "Скачивать новые лайки",
|
||||
"dl_usage": "Занято {{used}} МБ · свободно в телефоне {{free}} ГБ",
|
||||
"dl_pending_one": "В очереди {{count}} трек",
|
||||
"dl_pending_few": "В очереди {{count}} трека",
|
||||
"dl_pending_many": "В очереди {{count}} треков",
|
||||
"dl_pending_other": "В очереди {{count}} трека",
|
||||
"dl_waiting_wifi": "Загрузки ждут Wi-Fi ({{count}})",
|
||||
"dl_waiting_network": "Загрузки ждут сеть ({{count}})",
|
||||
"dl_retry": "Повторить неудачные",
|
||||
"dl_empty": "Скачивайте треки, альбомы и лайки — они будут доступны без сети"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user