diff options
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/android/app/src/main/AndroidManifest.xml | 3 | ||||
| -rw-r--r-- | player-android/lib/api/dio_client.dart | 39 | ||||
| -rw-r--r-- | player-android/lib/providers/api_client_provider.dart | 21 | ||||
| -rw-r--r-- | player-android/lib/screens/audio_player_screen.dart | 26 | ||||
| -rw-r--r-- | player-android/lib/screens/video_player_screen.dart | 11 |
5 files changed, 72 insertions, 28 deletions
diff --git a/player-android/android/app/src/main/AndroidManifest.xml b/player-android/android/app/src/main/AndroidManifest.xml index 81db32c..00d1b91 100644 --- a/player-android/android/app/src/main/AndroidManifest.xml +++ b/player-android/android/app/src/main/AndroidManifest.xml @@ -12,7 +12,8 @@ <application android:label="Player" android:name="${applicationName}" - android:icon="@drawable/ic_launcher"> + android:icon="@drawable/ic_launcher" + android:usesCleartextTraffic="true"> <activity android:name=".MainActivity" android:exported="true" diff --git a/player-android/lib/api/dio_client.dart b/player-android/lib/api/dio_client.dart index c988cf0..7a46885 100644 --- a/player-android/lib/api/dio_client.dart +++ b/player-android/lib/api/dio_client.dart @@ -113,32 +113,48 @@ class _UnauthorizedInterceptor extends Interceptor { /// Callers own the returned [Dio] and may add further interceptors on top. /// Separating construction from usage (SRP) keeps this class testable. class DioClient { - DioClient({ + factory DioClient({ required Uri baseUrl, required TokenStorage storage, required GlobalKey<NavigatorState> navigatorKey, String loginRoute = '/login', BaseOptions? baseOptions, - }) : _dio = _buildDio( - baseUrl: baseUrl, - storage: storage, - navigatorKey: navigatorKey, - loginRoute: loginRoute, - baseOptions: baseOptions, - ); + }) { + final jar = CookieJar(); + final dio = _buildDio( + baseUrl: baseUrl, + storage: storage, + navigatorKey: navigatorKey, + loginRoute: loginRoute, + baseOptions: baseOptions, + cookieJar: jar, + ); + return DioClient._(dio: dio, cookieJar: jar); + } + + DioClient._({required Dio dio, required CookieJar cookieJar}) + : _dio = dio, + _cookieJar = cookieJar; final Dio _dio; + final CookieJar _cookieJar; /// Exposes the underlying [Dio] so that [PlayerApiClient] can issue typed /// requests without re-implementing the interceptor plumbing. Dio get dio => _dio; + /// Exposes the cookie jar so consumers that bypass Dio (e.g. ExoPlayer via + /// just_audio, video_player, CachedNetworkImage) can still authenticate + /// against the session-cookie-protected media endpoints. + CookieJar get cookieJar => _cookieJar; + static Dio _buildDio({ required Uri baseUrl, required TokenStorage storage, required GlobalKey<NavigatorState> navigatorKey, required String loginRoute, BaseOptions? baseOptions, + required CookieJar cookieJar, }) { final options = (baseOptions ?? BaseOptions()).copyWith( baseUrl: baseUrl.toString(), @@ -147,13 +163,6 @@ class DioClient { responseType: ResponseType.json, ); - // The server's /api/v1/auth/login sets an HttpOnly Set-Cookie (session=...). - // Browsers persist this automatically; on mobile we attach a CookieJar so - // Dio replays the cookie on subsequent requests. Without this, every call - // after login returns 401 because Dio discards cookies by default. - // In-memory is sufficient: logout clears it, and we persist the bearer - // token (for API-token auth) separately via flutter_secure_storage. - final cookieJar = CookieJar(); return Dio(options) ..interceptors.addAll([ // Cookie manager runs first so the session cookie is replayed before diff --git a/player-android/lib/providers/api_client_provider.dart b/player-android/lib/providers/api_client_provider.dart index 9a3ca8d..27bd62f 100644 --- a/player-android/lib/providers/api_client_provider.dart +++ b/player-android/lib/providers/api_client_provider.dart @@ -1,3 +1,4 @@ +import 'package:cookie_jar/cookie_jar.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/dio_client.dart'; @@ -36,10 +37,13 @@ final tokenStorageProvider = Provider<TokenStorage>((ref) { /// Depends on [tokenStorageProvider] and [navigatorKey] (both singletons) so /// the same [Dio] instance is reused for every call site — avoiding redundant /// interceptor stacks. -final apiClientProvider = Provider<PlayerApiClient>((ref) { +// Single shared DioClient instance: cached as a Riverpod Provider so both the +// API client and the cookie-jar provider observe the same cookie store. The +// API client uses Dio; ExoPlayer/video_player/CachedNetworkImage bypass Dio +// and need the cookie jar to attach the session cookie manually. +final _dioClientProvider = Provider<DioClient>((ref) { final storage = ref.watch(tokenStorageProvider); - - final dioClient = DioClient( + return DioClient( baseUrl: Uri.parse(kPlayerBaseUrl), storage: storage, // Share the navigator key with go_router so 401 redirects go through the @@ -47,10 +51,19 @@ final apiClientProvider = Provider<PlayerApiClient>((ref) { navigatorKey: navigatorKey, loginRoute: '/login', ); +}); +final apiClientProvider = Provider<PlayerApiClient>((ref) { // Use DioPlayerApiClient — the concrete implementation that maps every // PlayerApiClient method to a real HTTP call via Dio. The base class now // acts as the public interface (dependency inversion); callers depend on // PlayerApiClient, not on this concrete class. - return DioPlayerApiClient(dio: dioClient.dio); + return DioPlayerApiClient(dio: ref.watch(_dioClientProvider).dio); +}); + +/// Provides the same [CookieJar] backing [apiClientProvider]'s Dio stack so +/// non-Dio HTTP clients (ExoPlayer, video_player, CachedNetworkImage) can +/// authenticate streaming/thumbnail requests using the same session cookie. +final cookieJarProvider = Provider<CookieJar>((ref) { + return ref.watch(_dioClientProvider).cookieJar; }); diff --git a/player-android/lib/screens/audio_player_screen.dart b/player-android/lib/screens/audio_player_screen.dart index 9e28062..4f05abf 100644 --- a/player-android/lib/screens/audio_player_screen.dart +++ b/player-android/lib/screens/audio_player_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cookie_jar/cookie_jar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; @@ -131,11 +132,12 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { final player = handler.player; final client = ref.read(apiClientProvider); final storage = ref.read(tokenStorageProvider); + final cookieJar = ref.read(cookieJarProvider); final mediaIdInt = int.tryParse(widget.mediaId) ?? 0; final url = widget.mediaUrl ?? client.streamUrl(mediaIdInt); - // Step 1–2: build auth headers. - final headers = await _buildAuthHeaders(storage); + // Step 1–2: build auth headers (Bearer + session cookie). + final headers = await _buildAuthHeaders(storage, cookieJar, Uri.parse(url)); if (!mounted) return; // Step 3: load the authenticated source; show error UI on failure. @@ -162,14 +164,26 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { _startProgressTicker(mediaIdInt, client, player, queue); } - /// Reads the bearer token and returns the `Authorization` header map. + /// Builds the headers map for an authenticated stream request. /// - /// Returns an empty map when no token is stored so the source can still be - /// loaded (e.g., public streams or during tests). - Future<Map<String, String>> _buildAuthHeaders(TokenStorage storage) async { + /// just_audio runs a localhost proxy that forwards these headers to + /// ExoPlayer's underlying HTTP request, which is how we authenticate against + /// the session-cookie-protected `/api/v1/media/{id}/stream` endpoint without + /// sharing Dio's HTTP stack. Both Bearer (for API-token auth) and Cookie + /// (for session auth) are attached so either auth scheme works. + Future<Map<String, String>> _buildAuthHeaders( + TokenStorage storage, + CookieJar jar, + Uri url, + ) async { final token = await storage.readToken(); + final cookies = await jar.loadForRequest(url); + final cookieHeader = cookies + .map((c) => '${c.name}=${c.value}') + .join('; '); return <String, String>{ if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader, }; } diff --git a/player-android/lib/screens/video_player_screen.dart b/player-android/lib/screens/video_player_screen.dart index 763168d..2172a12 100644 --- a/player-android/lib/screens/video_player_screen.dart +++ b/player-android/lib/screens/video_player_screen.dart @@ -116,19 +116,26 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> { final client = ref.read(apiClientProvider); final storage = ref.read(tokenStorageProvider); + final cookieJar = ref.read(cookieJarProvider); final mediaIdInt = int.tryParse(widget.mediaId) ?? 0; // Step 1: resolve the stream URL — prefer the route-extra URL so the // calling screen can forward a pre-computed URL; fall back to streamUrl. final url = widget.mediaUrl ?? client.streamUrl(mediaIdInt); - // Step 2: read the bearer token so the native player can authenticate - // without routing bytes through Dart (performance and correctness). + // Step 2: read the auth artefacts so the native player can authenticate + // without routing bytes through Dart. Both Bearer (API-token auth) and + // Cookie (session auth) headers are attached because ExoPlayer has its + // own HTTP stack and does not share Dio's cookie jar. final token = await storage.readToken(); + final cookies = await cookieJar.loadForRequest(Uri.parse(url)); if (!mounted) return; + final cookieHeader = + cookies.map((c) => '${c.name}=${c.value}').join('; '); final headers = <String, String>{ if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader, }; // Step 3: create and initialise the VideoPlayerController. |
