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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 13:02:47 +03:00
1 parent 765345e0f4
commit be34ee5361
22 files changed
+881 -387

No files matched your search

+27 -1
View File
@@ -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/);
});