summaryrefslogtreecommitdiff
path: root/player-android/lib
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-20 23:34:37 +0300
committerPaul Buetow <paul@buetow.org>2026-05-20 23:34:37 +0300
commit1a68b1aead492b50d824bde99fb1f30e1360ed26 (patch)
tree651c86fa2d81758e5ee6cf124aa72cc0587aff36 /player-android/lib
parent30e95cd41fc26cc2cbef658c47c690eb5e388b04 (diff)
Add DioClient with auth interceptors and refactor PlayerApiClient (ma)
Introduces dio_client.dart with _AuthInterceptor (guards Bearer token injection with containsKey so callers can override Authorization) and _UnauthorizedInterceptor (private fields, 401 → login redirect). PlayerApiClient is refactored to accept a pre-configured Dio instance instead of raw credentials; adds dio + flutter_secure_storage deps. Fixes review issues: Auth header comment/behavior corrected, private fields on _UnauthorizedInterceptor, unused import removed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android/lib')
-rw-r--r--player-android/lib/api/dio_client.dart152
-rw-r--r--player-android/lib/api/player_api_client.dart269
2 files changed, 389 insertions, 32 deletions
diff --git a/player-android/lib/api/dio_client.dart b/player-android/lib/api/dio_client.dart
new file mode 100644
index 0000000..35db589
--- /dev/null
+++ b/player-android/lib/api/dio_client.dart
@@ -0,0 +1,152 @@
+import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+
+// Storage key under which the bearer token is persisted across app restarts.
+const _kTokenKey = 'bearer_token';
+
+/// Abstraction over the secure token store so the interceptor can be tested
+/// without platform code (Liskov substitution / dependency inversion).
+abstract interface class TokenStorage {
+ Future<String?> readToken();
+ Future<void> writeToken(String token);
+ Future<void> deleteToken();
+}
+
+/// Production implementation backed by [FlutterSecureStorage].
+///
+/// Uses AES encryption on Android and the iOS Keychain on Apple platforms.
+class SecureTokenStorage implements TokenStorage {
+ SecureTokenStorage({FlutterSecureStorage? storage})
+ : _storage = storage ?? const FlutterSecureStorage();
+
+ final FlutterSecureStorage _storage;
+
+ @override
+ Future<String?> readToken() => _storage.read(key: _kTokenKey);
+
+ @override
+ Future<void> writeToken(String token) =>
+ _storage.write(key: _kTokenKey, value: token);
+
+ @override
+ Future<void> deleteToken() => _storage.delete(key: _kTokenKey);
+}
+
+/// Interceptor that attaches a Bearer token to every outgoing request.
+///
+/// The token is read lazily from [TokenStorage] so that changes (login /
+/// logout) are picked up without restarting the Dio instance.
+class _AuthInterceptor extends Interceptor {
+ _AuthInterceptor(this._storage);
+
+ final TokenStorage _storage;
+
+ @override
+ Future<void> onRequest(
+ RequestOptions options,
+ RequestInterceptorHandler handler,
+ ) async {
+ final token = await _storage.readToken();
+ if (token != null && token.isNotEmpty) {
+ // Only attach the bearer token when no Authorization header has been set
+ // explicitly by the caller (e.g. public endpoints may supply their own
+ // credentials and must not be overwritten).
+ if (!options.headers.containsKey('Authorization')) {
+ options.headers['Authorization'] = 'Bearer $token';
+ }
+ }
+ handler.next(options);
+ }
+}
+
+/// Interceptor that intercepts 401 Unauthorized responses and redirects the
+/// user to the login route via the supplied [NavigatorKey].
+///
+/// On 401, the stored token is removed (it is no longer valid) and the
+/// navigator pushes a named replacement so that the back-stack cannot return
+/// the user to an authenticated screen.
+class _UnauthorizedInterceptor extends Interceptor {
+ _UnauthorizedInterceptor({
+ required TokenStorage storage,
+ required GlobalKey<NavigatorState> navigatorKey,
+ String loginRoute = '/login',
+ }) : _storage = storage,
+ _navigatorKey = navigatorKey,
+ _loginRoute = loginRoute;
+
+ // Private fields consistent with _AuthInterceptor naming conventions.
+ final TokenStorage _storage;
+ final GlobalKey<NavigatorState> _navigatorKey;
+ final String _loginRoute;
+
+ @override
+ Future<void> onError(
+ DioException err,
+ ErrorInterceptorHandler handler,
+ ) async {
+ if (err.response?.statusCode == 401) {
+ // Purge the stale token so subsequent requests start unauthenticated.
+ await _storage.deleteToken();
+
+ // Use the navigator key to redirect without needing a BuildContext.
+ _navigatorKey.currentState
+ ?.pushNamedAndRemoveUntil(_loginRoute, (_) => false);
+ }
+ handler.next(err);
+ }
+}
+
+/// Factory that assembles a fully configured [Dio] instance wired with:
+/// - bearer-token injection on every request, and
+/// - global 401 → login redirect.
+///
+/// Callers own the returned [Dio] and may add further interceptors on top.
+/// Separating construction from usage (SRP) keeps this class testable.
+class DioClient {
+ 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 Dio _dio;
+
+ /// Exposes the underlying [Dio] so that [PlayerApiClient] can issue typed
+ /// requests without re-implementing the interceptor plumbing.
+ Dio get dio => _dio;
+
+ static Dio _buildDio({
+ required Uri baseUrl,
+ required TokenStorage storage,
+ required GlobalKey<NavigatorState> navigatorKey,
+ required String loginRoute,
+ BaseOptions? baseOptions,
+ }) {
+ final options = (baseOptions ?? BaseOptions()).copyWith(
+ baseUrl: baseUrl.toString(),
+ // JSON is the wire format for all API endpoints.
+ contentType: 'application/json',
+ responseType: ResponseType.json,
+ );
+
+ return Dio(options)
+ ..interceptors.addAll([
+ // Auth must run before the 401 handler so the token is attached first.
+ _AuthInterceptor(storage),
+ _UnauthorizedInterceptor(
+ storage: storage,
+ navigatorKey: navigatorKey,
+ loginRoute: loginRoute,
+ ),
+ ]);
+ }
+}
diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart
index eb438f4..9e987c2 100644
--- a/player-android/lib/api/player_api_client.dart
+++ b/player-android/lib/api/player_api_client.dart
@@ -1,64 +1,269 @@
import 'dart:typed_data';
+import 'package:dio/dio.dart';
+
import '../models/models.dart';
+/// High-level API surface that maps 1-to-1 with the player-server REST API
+/// (see player-server/docs/api.md for the authoritative contract).
+///
+/// Each method corresponds to a single HTTP endpoint. All HTTP plumbing
+/// (bearer-token injection, 401 → login redirect, base-URL configuration) is
+/// handled by the [Dio] instance provided at construction time.
+///
+/// In production, create the [Dio] via [DioClient] which wires up the auth
+/// and 401-redirect interceptors. In tests, pass a plain or mocked [Dio].
+///
+/// Concrete implementations of the stub methods will be added incrementally as
+/// features are built.
class PlayerApiClient {
- final Uri baseUrl;
- final String bearerToken;
+ /// Creates a client backed by [dio].
+ ///
+ /// Prefer creating [dio] via [DioClient] in production to get bearer-token
+ /// injection and 401 → login redirect out of the box.
+ PlayerApiClient({required Dio dio}) : _dio = dio;
+
+ // The configured Dio instance with auth/401 interceptors already applied.
+ final Dio _dio;
+
+ // ---------------------------------------------------------------------------
+ // Auth
+ // ---------------------------------------------------------------------------
- // Normal (non-const) constructor: Uri is not const-constructable, so the
- // constructor must not be declared const.
- PlayerApiClient({required this.baseUrl, required this.bearerToken});
+ Future<User> bootstrap({
+ required String username,
+ required String password,
+ }) =>
+ throw UnimplementedError();
+
+ Future<User> login({
+ required String username,
+ required String password,
+ }) =>
+ throw UnimplementedError();
- Future<User> bootstrap({required String username, required String password}) => throw UnimplementedError();
- Future<User> login({required String username, required String password}) => throw UnimplementedError();
Future<void> logout() => throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Health
+ // ---------------------------------------------------------------------------
+
Future<void> healthz() => throw UnimplementedError();
Future<void> readyz() => throw UnimplementedError();
- Future<String> getSharedMediaPage(String token) => throw UnimplementedError();
- Future<Uint8List> streamSharedMedia(String token, {String? range}) => throw UnimplementedError();
- Future<Uint8List> getSharedThumbnail(String token) => throw UnimplementedError();
- Future<Uint8List> downloadSharedMedia(String token) => throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Shared / public endpoints (no auth required)
+ // ---------------------------------------------------------------------------
+
+ Future<String> getSharedMediaPage(String token) =>
+ throw UnimplementedError();
+
+ Future<Uint8List> streamSharedMedia(String token, {String? range}) =>
+ throw UnimplementedError();
+
+ Future<Uint8List> getSharedThumbnail(String token) =>
+ throw UnimplementedError();
+
+ Future<Uint8List> downloadSharedMedia(String token) =>
+ throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Config
+ // ---------------------------------------------------------------------------
+
Future<Map<String, dynamic>> getConfig() => throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Sets
+ // ---------------------------------------------------------------------------
+
Future<List<MediaSet>> listSets() => throw UnimplementedError();
- Future<Map<String, dynamic>> browseSet(int setId, {String? parent}) => throw UnimplementedError();
- Future<Uint8List> getSetCover(int setId, {String? folder}) => throw UnimplementedError();
- Future<void> updateSetCover(int setId, {String? folder}) => throw UnimplementedError();
- Future<Media> uploadToSet(int setId, {required String fileName, required List<int> bytes}) => throw UnimplementedError();
- Future<List<Media>> listMedia({String? search, int? setId, List<int>? setIds, String? type, bool? favorites, List<String>? tags, double? minDuration, double? maxDuration, int? fileSizeMin, int? fileSizeMax, String? sort, int? limit, int? offset, String? folder, String? parent}) => throw UnimplementedError();
+
+ Future<Map<String, dynamic>> browseSet(int setId, {String? parent}) =>
+ throw UnimplementedError();
+
+ Future<Uint8List> getSetCover(int setId, {String? folder}) =>
+ throw UnimplementedError();
+
+ Future<void> updateSetCover(int setId, {String? folder}) =>
+ throw UnimplementedError();
+
+ Future<Media> uploadToSet(
+ int setId, {
+ required String fileName,
+ required List<int> bytes,
+ }) =>
+ throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Media
+ // ---------------------------------------------------------------------------
+
+ Future<List<Media>> listMedia({
+ String? search,
+ int? setId,
+ List<int>? setIds,
+ String? type,
+ bool? favorites,
+ List<String>? tags,
+ double? minDuration,
+ double? maxDuration,
+ int? fileSizeMin,
+ int? fileSizeMax,
+ String? sort,
+ int? limit,
+ int? offset,
+ String? folder,
+ String? parent,
+ }) =>
+ throw UnimplementedError();
+
Future<Media> getMedia(int mediaId) => throw UnimplementedError();
- Future<Uint8List> streamMedia(int mediaId, {String? range}) => throw UnimplementedError();
+
+ Future<Uint8List> streamMedia(int mediaId, {String? range}) =>
+ throw UnimplementedError();
+
Future<Uint8List> downloadMedia(int mediaId) => throw UnimplementedError();
+
Future<Uint8List> getThumbnail(int mediaId) => throw UnimplementedError();
+
Future<void> regenerateThumbnail(int mediaId) => throw UnimplementedError();
+
Future<bool> toggleFavorite(int mediaId) => throw UnimplementedError();
+
+ Future<void> deleteMedia(int mediaId) => throw UnimplementedError();
+
+ Future<void> restoreMedia(int mediaId) => throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Tags
+ // ---------------------------------------------------------------------------
+
Future<List<Tag>> listTags() => throw UnimplementedError();
+
Future<void> addTag(int mediaId, String tag) => throw UnimplementedError();
- Future<void> removeTag(int mediaId, String tag) => throw UnimplementedError();
- Future<Share> createShare(int mediaId, {DateTime? expiresAt, int? maxUses}) => throw UnimplementedError();
- Future<List<Share>> listSharesForMedia(int mediaId) => throw UnimplementedError();
+
+ Future<void> removeTag(int mediaId, String tag) =>
+ throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Shares
+ // ---------------------------------------------------------------------------
+
+ Future<Share> createShare(
+ int mediaId, {
+ DateTime? expiresAt,
+ int? maxUses,
+ }) =>
+ throw UnimplementedError();
+
+ Future<List<Share>> listSharesForMedia(int mediaId) =>
+ throw UnimplementedError();
+
Future<List<Share>> listMyShares() => throw UnimplementedError();
+
Future<void> revokeShare(String token) => throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Notes
+ // ---------------------------------------------------------------------------
+
Future<Note?> getNote(int mediaId) => throw UnimplementedError();
- Future<Note> upsertNote(int mediaId, String content) => throw UnimplementedError();
+
+ Future<Note> upsertNote(int mediaId, String content) =>
+ throw UnimplementedError();
+
Future<void> deleteNote(int mediaId) => throw UnimplementedError();
- Future<void> updateProgress({required int mediaId, required double positionSeconds}) => throw UnimplementedError();
- Future<void> updateProgressStatus({required int mediaId, required String status}) => throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Progress
+ // ---------------------------------------------------------------------------
+
+ Future<void> updateProgress({
+ required int mediaId,
+ required double positionSeconds,
+ }) =>
+ throw UnimplementedError();
+
+ Future<void> updateProgressStatus({
+ required int mediaId,
+ required String status,
+ }) =>
+ throw UnimplementedError();
+
Future<List<Media>> listInProgress() => throw UnimplementedError();
- Future<void> deleteMedia(int mediaId) => throw UnimplementedError();
- Future<void> restoreMedia(int mediaId) => throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Podcasts
+ // ---------------------------------------------------------------------------
+
Future<List<PodcastFeed>> listPodcasts() => throw UnimplementedError();
- Future<List<PodcastEpisode>> listEpisodes(int podcastSetId, {int? limit, int? offset}) => throw UnimplementedError();
+
+ Future<List<PodcastEpisode>> listEpisodes(
+ int podcastSetId, {
+ int? limit,
+ int? offset,
+ }) =>
+ throw UnimplementedError();
+
Future<Media> downloadEpisode(int episodeId) => throw UnimplementedError();
- Future<void> toggleEpisodeComplete(int episodeId) => throw UnimplementedError();
- Future<PodcastFeed> subscribePodcast({required String feedUrl, String? setName}) => throw UnimplementedError();
+
+ Future<void> toggleEpisodeComplete(int episodeId) =>
+ throw UnimplementedError();
+
+ Future<PodcastFeed> subscribePodcast({
+ required String feedUrl,
+ String? setName,
+ }) =>
+ throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Admin – Users
+ // ---------------------------------------------------------------------------
+
Future<List<User>> listUsers() => throw UnimplementedError();
- Future<User> createUser({required String username, required String password, required bool isAdmin}) => throw UnimplementedError();
+
+ Future<User> createUser({
+ required String username,
+ required String password,
+ required bool isAdmin,
+ }) =>
+ throw UnimplementedError();
+
Future<void> deleteUser(int userId) => throw UnimplementedError();
- Future<List<Map<String, dynamic>>> listPermissions() => throw UnimplementedError();
- Future<void> grantPermission({required int setId, required int userId, required String role}) => throw UnimplementedError();
- Future<void> revokePermission({required int setId, required int userId}) => throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Admin – Permissions
+ // ---------------------------------------------------------------------------
+
+ Future<List<Map<String, dynamic>>> listPermissions() =>
+ throw UnimplementedError();
+
+ Future<void> grantPermission({
+ required int setId,
+ required int userId,
+ required String role,
+ }) =>
+ throw UnimplementedError();
+
+ Future<void> revokePermission({
+ required int setId,
+ required int userId,
+ }) =>
+ throw UnimplementedError();
+
+ // ---------------------------------------------------------------------------
+ // Admin – Scanner
+ // ---------------------------------------------------------------------------
+
Future<void> triggerRescan() => throw UnimplementedError();
+
Future<Map<String, dynamic>> getScanProgress() => throw UnimplementedError();
+
Future<List<Media>> listTrash() => throw UnimplementedError();
+
+ // Expose the underlying Dio for advanced callers (e.g. binary streaming).
+ // This should not be used for ordinary JSON requests; prefer the typed
+ // methods above.
+ Dio get rawDio => _dio;
}