diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 09:17:31 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 09:17:31 +0300 |
| commit | 1824175276fcd29d4b6d4c14277c62e73247b7e2 (patch) | |
| tree | b32c9867f24d4ca34bb5a902adb9fb508ddc00d1 | |
| parent | 1b66b5f573c8de883947d712057ff85f5513e8ae (diff) | |
Implement public ShareViewerScreen with Android deep-link intent-filter (9b)
- New ShareViewerScreen (/share/:token): unauthenticated share-viewer that
fetches share metadata via publicApiClientProvider, renders filename, type,
duration, thumbnail, and a Play button routing to the video/audio player.
- New publicApiClientProvider: bare Dio client (no auth interceptors) for the
public share endpoint; shares kPlayerBaseUrl with the authenticated client.
- AndroidManifest: http + https deep-link intent-filters for /share/.* so
Android routes share URLs directly into the app (App Links / autoVerify).
- router.dart: /share/:token bypasses the authentication redirect; guard uses
AppRoutes.shareViewerPrefix constant instead of a raw '/share/' string (DIP).
- app_routes.dart: shareViewer route constant, shareViewerPrefix, shareViewerPath helper.
- error_mappers.dart: shareViewerErrorMessage — 404 invalid/revoked, 410 expired.
- player_api_client.dart: baseUrl getter encapsulates rawDio.options.baseUrl so
screens never access transport internals directly (ISP, DIP).
- Review fixes: OCP icon map in _FallbackThumbnail, LSP explicit baseUrl
overrides in test fakes, DIP shareViewerPrefix constant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | player-android/android/app/src/main/AndroidManifest.xml | 30 | ||||
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 9 | ||||
| -rw-r--r-- | player-android/lib/app_routes.dart | 21 | ||||
| -rw-r--r-- | player-android/lib/providers/api_client_provider.dart | 18 | ||||
| -rw-r--r-- | player-android/lib/providers/public_api_client_provider.dart | 30 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 31 | ||||
| -rw-r--r-- | player-android/lib/screens/share_viewer_screen.dart | 429 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 26 | ||||
| -rw-r--r-- | player-android/test/screens/share_viewer_screen_test.dart | 443 |
9 files changed, 1027 insertions, 10 deletions
diff --git a/player-android/android/app/src/main/AndroidManifest.xml b/player-android/android/app/src/main/AndroidManifest.xml index 602051c..3276ad7 100644 --- a/player-android/android/app/src/main/AndroidManifest.xml +++ b/player-android/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,36 @@ <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> + <!-- Deep-link intent-filter for public share URLs. + When a user opens a link of the form + https://<PLAYER_HOST>/share/<token> + Android routes the intent to this activity and go_router + navigates to the ShareViewerScreen (/share/:token). + Replace PLAYER_HOST with the real server hostname (e.g. + player.example.com) for production deployments; the wildcard + host below accepts any host for development flexibility. + android:autoVerify="true" enables App Links (verified deep + links) once an assetlinks.json file is served at the host; + without verification the OS shows the disambiguation dialog. + Both http and https schemes are declared so links work with + self-signed or non-TLS development servers as well as + production HTTPS deployments. --> + <intent-filter android:autoVerify="true"> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + <data android:scheme="https" + android:host="PLAYER_HOST" + android:pathPattern="/share/.*" /> + </intent-filter> + <intent-filter android:autoVerify="true"> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + <data android:scheme="http" + android:host="PLAYER_HOST" + android:pathPattern="/share/.*" /> + </intent-filter> </activity> <meta-data android:name="flutterEmbedding" diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 0094a90..ffb3457 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -177,6 +177,15 @@ class PlayerApiClient implements ProgressSyncClient { /// hard-code URL segments (Dependency Inversion Principle). String shareUrl(String token) => '${rawDio.options.baseUrl}/s/$token'; + /// Returns the base URL of the underlying Dio instance without a trailing slash. + /// + /// Exposed so screens can construct absolute URLs from server-relative paths + /// (e.g., `/s/abc123/stream`) without reaching into [rawDio] directly. + /// Encapsulating this lookup here prevents the Dio transport detail from + /// leaking into the UI layer (Interface Segregation, Dependency Inversion). + String get baseUrl => + rawDio.options.baseUrl.replaceAll(RegExp(r'/$'), ''); + Future<void> regenerateThumbnail(int mediaId) => throw UnimplementedError(); Future<bool> toggleFavorite(int mediaId) => throw UnimplementedError(); diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index c9207f8..c1f350e 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -8,6 +8,14 @@ abstract final class AppRoutes { static const home = '/home'; static const mediaDetail = '/media/:id'; static const share = '/share'; + + /// Public share-viewer route — no authentication required. + /// + /// The ':token' segment is the opaque share token issued by the server. + /// This route is intentionally outside the authenticated route set so + /// anyone with a share link can view the shared media without logging in. + static const shareViewer = '/share/:token'; + static const settings = '/settings'; /// First-run setup route shown when no admin account exists yet. @@ -36,6 +44,19 @@ abstract final class AppRoutes { /// The ':mediaId' segment identifies the media item whose note is edited. static const notes = '/notes/:mediaId'; + /// URL prefix used in the router redirect guard to identify share-viewer URLs. + /// + /// The go_router pattern [shareViewer] is a template string with a `:token` + /// placeholder and cannot be used directly as a prefix check. This constant + /// encapsulates the literal prefix so [router.dart]'s redirect logic can + /// reference a named constant rather than embedding a raw string literal + /// (Dependency Inversion — high-level routing policy depends on this abstraction, + /// not on a hardcoded '/share/' string). + static const shareViewerPrefix = '/share/'; + + /// Returns the concrete path for the share-viewer page of a given [token]. + static String shareViewerPath(String token) => '/share/$token'; + /// Returns the concrete path for a media-detail page given a numeric [id]. static String mediaDetailPath(int id) => '/media/$id'; diff --git a/player-android/lib/providers/api_client_provider.dart b/player-android/lib/providers/api_client_provider.dart index 128b89d..9a3ca8d 100644 --- a/player-android/lib/providers/api_client_provider.dart +++ b/player-android/lib/providers/api_client_provider.dart @@ -5,12 +5,18 @@ import '../api/dio_player_api_client.dart'; import '../api/player_api_client.dart'; import '../navigation_key.dart'; -/// Base URL for the player-server API. +/// Base URL for the player-server API, resolved at compile time via +/// the PLAYER_BASE_URL environment variable (or the default below). /// -/// In production this is injected from the environment or a config file. -/// The default points to a local dev instance so the app is runnable -/// without extra configuration. -const _kBaseUrl = String.fromEnvironment( +/// Declared as a package-level identifier (no underscore) so it can be +/// shared by [publicApiClientProvider] in [public_api_client_provider.dart]. +/// The value is set once at compile time and never changes at runtime, +/// making it safe to share across providers. +/// +/// In production this is injected via `--dart-define=PLAYER_BASE_URL=...`. +/// The default points to the Android emulator host loopback address so the +/// app is runnable out-of-the-box without extra configuration. +const kPlayerBaseUrl = String.fromEnvironment( 'PLAYER_BASE_URL', defaultValue: 'http://10.0.2.2:8080', ); @@ -34,7 +40,7 @@ final apiClientProvider = Provider<PlayerApiClient>((ref) { final storage = ref.watch(tokenStorageProvider); final dioClient = DioClient( - baseUrl: Uri.parse(_kBaseUrl), + baseUrl: Uri.parse(kPlayerBaseUrl), storage: storage, // Share the navigator key with go_router so 401 redirects go through the // correct router instance rather than the raw Navigator. diff --git a/player-android/lib/providers/public_api_client_provider.dart b/player-android/lib/providers/public_api_client_provider.dart new file mode 100644 index 0000000..a27662b --- /dev/null +++ b/player-android/lib/providers/public_api_client_provider.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/dio_player_api_client.dart'; +import '../api/player_api_client.dart'; +import 'api_client_provider.dart' show kPlayerBaseUrl; + +/// Provides an unauthenticated [PlayerApiClient] for public endpoints. +/// +/// Unlike [apiClientProvider], this client has no bearer-token interceptor +/// and no 401 → login redirect interceptor. It is designed exclusively for +/// the share-viewer feature, where the share token is part of the URL path +/// (not an Authorization header) and no session is required. +/// +/// Using a dedicated provider keeps the authenticated and public clients +/// cleanly separated (Single Responsibility, Separation of Concerns) and +/// avoids accidentally attaching a session to public requests. +final publicApiClientProvider = Provider<PlayerApiClient>((ref) { + // Minimal Dio instance: JSON content-type, same base URL as the auth client, + // but without any auth or redirect interceptors. + final dio = Dio( + BaseOptions( + baseUrl: kPlayerBaseUrl, + contentType: 'application/json', + responseType: ResponseType.json, + ), + ); + + return DioPlayerApiClient(dio: dio); +}); diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 36cdd50..c838a8b 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -17,6 +17,7 @@ import 'screens/podcast_episodes_screen.dart'; import 'screens/podcast_list_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/share_screen.dart'; +import 'screens/share_viewer_screen.dart'; import 'screens/my_shares_screen.dart'; import 'screens/notes_editor_screen.dart'; import 'screens/folder_browser_screen.dart'; @@ -64,16 +65,26 @@ final routerProvider = Provider<GoRouter>((ref) { final isLoginRoute = location == AppRoutes.login; // Bootstrap is a public route (user is unauthenticated by definition). final isBootstrapRoute = location == AppRoutes.bootstrap; + // Share-viewer routes (/share/:token) are public — no session required. + // The token is embedded in the URL path; authentication is irrelevant. + // Uses [AppRoutes.shareViewerPrefix] rather than a raw string literal so + // a rename of the share-viewer path is reflected here automatically (DIP). + final isShareViewerRoute = + location.startsWith(AppRoutes.shareViewerPrefix); if (auth.isAuthenticated && (isLoginRoute || isBootstrapRoute)) { // Prevent already-authenticated users from viewing auth/setup screens. return AppRoutes.home; } - if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) { - // Unauthenticated: any route other than /login and /bootstrap is - // protected. This covers /home, /media/:id, /share, /settings, and - // any future authenticated routes added to the route table. + if (auth.isUnauthenticated && + !isLoginRoute && + !isBootstrapRoute && + !isShareViewerRoute) { + // Unauthenticated: any route other than /login, /bootstrap, and + // /share/:token is protected. This covers /home, /media/:id, + // /share (list), /settings, and any future authenticated routes added + // to the route table. // // Determine whether this is first-run (no users exist yet) or a normal // returning-user scenario. firstRunProvider returns true when the @@ -139,6 +150,18 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) => const ShareScreen(), ), GoRoute( + // Public share-viewer — no authentication required. + // The ':token' path parameter is the opaque share token from the URL. + // This route is intentionally separate from /share (the authenticated + // share-management screen) and must remain before the redirect logic + // guards it in any future refactor. + path: AppRoutes.shareViewer, + builder: (context, state) { + final token = state.pathParameters['token']!; + return ShareViewerScreen(token: token); + }, + ), + GoRoute( path: AppRoutes.settings, builder: (context, state) => const SettingsScreen(), ), diff --git a/player-android/lib/screens/share_viewer_screen.dart b/player-android/lib/screens/share_viewer_screen.dart new file mode 100644 index 0000000..6cb5070 --- /dev/null +++ b/player-android/lib/screens/share_viewer_screen.dart @@ -0,0 +1,429 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../app_routes.dart'; +import '../providers/public_api_client_provider.dart'; +import '../utils/duration_formatter.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Share page metadata model +// --------------------------------------------------------------------------- + +/// Parsed metadata from the GET /s/{token} JSON response. +/// +/// Keeps the screen layer free from raw map access: the data is extracted once +/// here and then consumed via typed fields (Separation of Concerns). +class SharePageMetadata { + const SharePageMetadata({ + required this.fileName, + required this.type, + required this.duration, + required this.hasThumb, + required this.streamUrl, + required this.thumbUrl, + required this.downloadUrl, + }); + + final String fileName; + final String type; + + /// Duration in seconds; null when the server omits the field. + final double? duration; + + final bool hasThumb; + + /// Relative path for the stream endpoint (e.g. "/s/abc123/stream"). + final String streamUrl; + + /// Relative path for the thumbnail (e.g. "/s/abc123/thumbnail"). + final String thumbUrl; + + /// Relative path for downloading the original file. + final String downloadUrl; + + /// Parses a [SharePageMetadata] from the raw JSON string returned by + /// [PlayerApiClient.getSharedMediaPage]. + /// + /// Missing fields are replaced with safe defaults so the screen always has + /// something to display rather than throwing on an unexpected server response. + factory SharePageMetadata.fromJson(String jsonBody) { + final map = jsonDecode(jsonBody) as Map<String, dynamic>; + final media = map['media'] as Map<String, dynamic>? ?? {}; + + return SharePageMetadata( + fileName: (media['file_name'] as String?) ?? 'Unknown file', + type: (media['type'] as String?) ?? 'video', + duration: (media['duration'] as num?)?.toDouble(), + hasThumb: (map['has_thumb'] as bool?) ?? false, + streamUrl: (map['stream_url'] as String?) ?? '', + thumbUrl: (map['thumb_url'] as String?) ?? '', + downloadUrl: (map['download_url'] as String?) ?? '', + ); + } +} + +// --------------------------------------------------------------------------- +// ShareViewerScreen +// --------------------------------------------------------------------------- + +/// Public share-viewer screen — no authentication required. +/// +/// Accepts a [token] from the go_router path parameter (/share/:token). +/// Calls the unauthenticated [publicApiClientProvider] to fetch share metadata +/// and renders the file name, type, duration, and thumbnail. A "Play" button +/// navigates to the appropriate video or audio player with the stream URL so +/// the viewer can watch or listen without a user account. +/// +/// Design notes: +/// - [ConsumerStatefulWidget] is used so local state (loading, error, data) +/// is managed without extra Riverpod providers for transient UI state, and +/// so [mounted] guards are available on all async continuations. +/// - No [Dio] import: all HTTP calls go through [publicApiClientProvider], +/// keeping the screen layer decoupled from the HTTP transport (DIP). +/// - The screen does not import any authenticated provider, ensuring it cannot +/// accidentally attach a session to a public request. +/// - Progress updates sent by the player screen will fail silently for +/// public shares (the progress endpoint requires auth) — this is acceptable +/// because progress tracking is a per-user authenticated feature. +class ShareViewerScreen extends ConsumerStatefulWidget { + const ShareViewerScreen({super.key, required this.token}); + + /// The opaque share token extracted from the URL path by go_router. + final String token; + + @override + ConsumerState<ShareViewerScreen> createState() => _ShareViewerScreenState(); +} + +class _ShareViewerScreenState extends ConsumerState<ShareViewerScreen> { + // Null during the initial load; non-null after a successful fetch. + SharePageMetadata? _page; + + // Non-null when the last fetch attempt failed. + String? _error; + + // True while the initial or retry load is in flight. + bool _isLoading = false; + + @override + void initState() { + super.initState(); + // Defer first load until after the first frame so any provider overrides + // applied in tests are in place before [ref] is accessed. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches share metadata from the server using the unauthenticated client. + /// + /// Uses [publicApiClientProvider] so no bearer token is attached. + /// Errors are mapped to human-readable strings by [shareViewerErrorMessage]. + Future<void> _load() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final client = ref.read(publicApiClientProvider); + final json = await client.getSharedMediaPage(widget.token); + final page = SharePageMetadata.fromJson(json); + if (!mounted) return; + setState(() { + _page = page; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = shareViewerErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Play action + // --------------------------------------------------------------------------- + + /// Navigates to the appropriate player screen with the absolute stream URL. + /// + /// Builds the absolute URL from [PlayerApiClient.baseUrl] and the relative + /// [SharePageMetadata.streamUrl] path so the player screen receives a fully + /// qualified URL it can pass directly to ExoPlayer / just_audio. + /// + /// The player screen's [mediaId] is set to '0' as a placeholder because + /// progress tracking (which requires auth) is not available for public shares; + /// failed progress-update calls in the player are already fire-and-forget and + /// do not affect playback. + void _play() { + if (_page == null) return; + + final client = ref.read(publicApiClientProvider); + final absoluteStreamUrl = '${client.baseUrl}${_page!.streamUrl}'; + + // Navigate to the audio or video player based on the media type. + // The stream URL is passed as a route extra so the player uses it directly + // without deriving it from a media ID (Dependency Inversion). + final playerPath = AppRoutes.playerPathForType(_page!.type, '0'); + context.go(playerPath, extra: absoluteStreamUrl); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Shared Media')), + body: _buildBody(context), + ); + } + + /// Returns the appropriate body widget for the current state: + /// - Full-screen spinner while the first load is in flight. + /// - Error view with retry button when the fetch failed. + /// - Metadata card with Play button when data is ready. + Widget _buildBody(BuildContext context) { + if (_isLoading && _page == null) { + return const Center( + key: Key('share_viewer_loading'), + child: CircularProgressIndicator(), + ); + } + + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + + if (_page == null) { + // Defensive guard: should not be reachable under normal flow. + return const SizedBox.shrink(); + } + + return _MetadataView(page: _page!, onPlay: _play); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Displays the share metadata and the Play button. +/// +/// Extracted into its own stateless widget so the parent state class stays +/// focused on data-loading concerns and the UI is independently testable +/// (Single Responsibility Principle). +class _MetadataView extends StatelessWidget { + const _MetadataView({required this.page, required this.onPlay}); + + final SharePageMetadata page; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Thumbnail or media-type icon placeholder. + _ThumbnailWidget(page: page), + const SizedBox(height: 16), + + // File name — primary text element of the card. + Text( + page.fileName, + key: const Key('share_viewer_filename'), + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + // Type and duration shown as a secondary metadata row. + _MetadataRow(page: page), + const SizedBox(height: 24), + + // Play button — navigates to the appropriate player screen. + FilledButton.icon( + key: const Key('share_viewer_play_button'), + onPressed: onPlay, + icon: const Icon(Icons.play_arrow), + label: const Text('Play'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ], + ), + ); + } +} + +/// Shows the thumbnail image when available, or a media-type icon placeholder. +/// +/// Delegates to [_ThumbnailImage] when [SharePageMetadata.hasThumb] is true, +/// or to [_FallbackThumbnail] when there is no thumbnail (Open-Closed: adding +/// a new media type only requires extending [_FallbackThumbnail]). +class _ThumbnailWidget extends ConsumerWidget { + const _ThumbnailWidget({required this.page}); + + final SharePageMetadata page; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!page.hasThumb || page.thumbUrl.isEmpty) { + return _FallbackThumbnail(type: page.type); + } + + final client = ref.read(publicApiClientProvider); + final absoluteThumbUrl = '${client.baseUrl}${page.thumbUrl}'; + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Image.network( + absoluteThumbUrl, + key: const Key('share_viewer_thumbnail'), + fit: BoxFit.cover, + // Fall back to the type icon if the image fails to load (e.g. network + // error) so the viewer always sees something meaningful. + errorBuilder: (_, __, ___) => _FallbackThumbnail(type: page.type), + ), + ), + ); + } +} + +/// Icon placeholder shown when no thumbnail is available or fails to load. +/// +/// The icon is chosen by [type] so audio shares show a music note while video +/// shares show a movie icon, giving the viewer a visual hint about the content. +/// +/// The icon lookup uses a map rather than a binary conditional so that new +/// media types can be added by extending [_typeIcons] alone (Open-Closed +/// Principle) — no if/else chain to update. +class _FallbackThumbnail extends StatelessWidget { + const _FallbackThumbnail({required this.type}); + + final String type; + + /// Maps media type strings to their representative Material icons. + /// + /// Unknown types fall back to [Icons.movie] via the null-coalescing lookup + /// in [build], so new server-side types degrade gracefully without crashes. + static const _typeIcons = { + 'audio': Icons.audio_file, + 'video': Icons.movie, + }; + + @override + Widget build(BuildContext context) { + final icon = _typeIcons[type] ?? Icons.movie; + return AspectRatio( + aspectRatio: 16 / 9, + child: Container( + key: const Key('share_viewer_thumbnail_placeholder'), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } +} + +/// One-line row showing the media type (capitalised) and formatted duration. +/// +/// Placed below the filename so the viewer can see at a glance what kind of +/// media the link points to and how long it is before tapping Play. +class _MetadataRow extends StatelessWidget { + const _MetadataRow({required this.page}); + + final SharePageMetadata page; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final typeLabel = _capitalise(page.type); + final durationLabel = + page.duration != null ? formatDuration(page.duration!) : null; + + final parts = [typeLabel, if (durationLabel != null) durationLabel]; + + return Text( + parts.join(' · '), + key: const Key('share_viewer_metadata'), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ); + } + + /// Returns [s] with its first character uppercased. + static String _capitalise(String s) => + s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}'; +} + +/// Full-screen error view with a retry button. +/// +/// Shown when [getSharedMediaPage] throws — e.g. 404 (invalid token), +/// 410 (expired link), or a network failure. The [message] comes from +/// [shareViewerErrorMessage], which maps exceptions to human-readable strings. +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.link_off, + size: 56, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + message, + key: const Key('share_viewer_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('share_viewer_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index b0d3511..8f5c78b 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -259,6 +259,32 @@ String folderErrorMessage(Object error) { return 'Unexpected error. Please try again.'; } +/// Maps any thrown object from [PlayerApiClient.getSharedMediaPage] to a UI string. +/// +/// Adds human-readable messages for the status codes the share-viewer endpoint +/// can return: +/// - 404: the share token does not exist (never created, or already deleted). +/// - 410: the share has expired (server-side expiry or max-uses exceeded). +/// +/// These two cases are shown with distinct messages so the viewer knows whether +/// the link was invalid from the start or whether it was valid but has since +/// expired. All other failures fall back to [dioConnectionErrorMessage]. +/// +/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of other error mappers. +String shareViewerErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'This share link is invalid or has been revoked.'; + } + if (error.response?.statusCode == 410) { + return 'This share link has expired.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} + /// Maps any thrown object from [PlayerApiClient.listEpisodes] to a UI string. /// /// Adds a 404-specific message (podcast set not found) on top of the generic diff --git a/player-android/test/screens/share_viewer_screen_test.dart b/player-android/test/screens/share_viewer_screen_test.dart new file mode 100644 index 0000000..2c048d9 --- /dev/null +++ b/player-android/test/screens/share_viewer_screen_test.dart @@ -0,0 +1,443 @@ +// Widget tests for ShareViewerScreen (share_viewer_screen.dart). +// +// Tests cover: +// 1. Shows a loading indicator while getSharedMediaPage is in flight. +// 2. Renders filename, type, and duration after a successful load. +// 3. Shows the thumbnail placeholder when hasThumb is false. +// 4. Shows the play button after a successful load. +// 5. Shows a 404 error message for an invalid/revoked token. +// 6. Shows a 410 error message for an expired token. +// 7. Shows a generic error message for network failures. +// 8. Shows the retry button on error and re-calls getSharedMediaPage on tap. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server. GoRouter is replaced with a plain MaterialApp to avoid test +// infrastructure complexity. +// +// Run with: flutter test test/screens/share_viewer_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/providers/public_api_client_provider.dart'; +import 'package:player_android/screens/share_viewer_screen.dart'; +import 'package:player_android/utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// Controllable [PlayerApiClient] stub for [ShareViewerScreen] tests. +/// +/// Overrides the two members that [ShareViewerScreen] actually calls — +/// [getSharedMediaPage] and [baseUrl] — making the contract explicit rather +/// than relying on the concrete base class throwing [UnimplementedError] for +/// the many methods the screen never touches (LSP / ISP: the fake honours +/// the subset of the contract the screen depends on). +class _FakePublicApiClient extends PlayerApiClient { + _FakePublicApiClient() + : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local'))); + + // When non-null, [getSharedMediaPage] returns this string. + String? pageJson; + + // When non-null, [getSharedMediaPage] throws this instead of returning. + Object? pageError; + + // Count of calls to [getSharedMediaPage] so retry tests can assert call count. + int callCount = 0; + + /// Returns the test base URL without a trailing slash, matching the + /// production [PlayerApiClient.baseUrl] contract used in [ShareViewerScreen]. + @override + String get baseUrl => 'http://test.local'; + + @override + Future<String> getSharedMediaPage(String token) async { + callCount++; + if (pageError != null) throw pageError!; + return pageJson!; + } +} + +/// Controllable [PlayerApiClient] stub that delays [getSharedMediaPage] until +/// [complete] is called — used to inspect the mid-flight loading state. +/// +/// Overrides [baseUrl] explicitly for the same reason as [_FakePublicApiClient]: +/// the screen calls [baseUrl] when constructing the thumbnail URL, so the stub +/// must provide a consistent value rather than delegating to [rawDio] internals. +class _DelayedFakePublicApiClient extends PlayerApiClient { + _DelayedFakePublicApiClient() + : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local'))); + + final _completer = Completer<String>(); + + void complete(String json) => _completer.complete(json); + + /// Returns the test base URL without a trailing slash. + @override + String get baseUrl => 'http://test.local'; + + @override + Future<String> getSharedMediaPage(String token) => _completer.future; +} + +// --------------------------------------------------------------------------- +// Sample JSON +// --------------------------------------------------------------------------- + +/// Valid share-page JSON for a video file with a thumbnail. +const _kVideoShareJson = ''' +{ + "media": { + "id": 42, + "file_name": "holiday.mp4", + "type": "video", + "duration": 3612.5 + }, + "has_thumb": true, + "stream_url": "/s/abc123/stream", + "download_url": "/s/abc123/download", + "thumb_url": "/s/abc123/thumbnail" +} +'''; + +/// Valid share-page JSON for an audio file without a thumbnail. +const _kAudioShareJson = ''' +{ + "media": { + "id": 7, + "file_name": "podcast.mp3", + "type": "audio", + "duration": 1800.0 + }, + "has_thumb": false, + "stream_url": "/s/tok7/stream", + "download_url": "/s/tok7/download", + "thumb_url": "" +} +'''; + +// --------------------------------------------------------------------------- +// Helper: pump ShareViewerScreen inside a ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [ShareViewerScreen] inside a [ProviderScope] that overrides +/// [publicApiClientProvider] with a fake, wrapped in a [MaterialApp] so +/// widgets like SnackBar and routes work correctly. +/// +/// [goRouterOverride] is passed as the [MaterialApp] router if supplied; +/// the default is a plain [MaterialApp] with no |
