From 0df4b4f0506b66ca02cb123e0fc4d8588fa1b22c Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:36:31 +0530 Subject: [PATCH] feat(ui): support authenticated image loading with cache manager Add Riverpod-aware image header and cache manager support for network images used in avatar and markdown widgets. Convert AvatarImage to a ConsumerWidget and read a self-signed cache manager and HTTP headers from Riverpod so CachedNetworkImage can send auth/custom headers and use the provided cache manager. In Markdown image builder, obtain headers and cache manager from a ProviderContainer (via ProviderScope.containerOf) to enable the same authenticated loading in non-consumer contexts. Introduce image_header_utils.dart to centralize building Authorization and custom headers from auth/api providers, with helpers for Ref, WidgetRef, and ProviderContainer. Add dependency adjustments in pubspec.lock for flutter_cache_manager and http marked as direct main. These changes ensure protected images (self-signed or auth-required) load correctly across the app and reuse the configured cache manager. --- lib/core/network/image_header_utils.dart | 47 +++++++++++++ .../self_signed_image_cache_manager.dart | 66 +++++++++++++++++++ .../widgets/enhanced_image_attachment.dart | 46 +++---------- .../widgets/markdown/markdown_config.dart | 10 +++ lib/shared/widgets/user_avatar.dart | 14 +++- pubspec.lock | 4 +- pubspec.yaml | 2 + 7 files changed, 149 insertions(+), 40 deletions(-) create mode 100644 lib/core/network/image_header_utils.dart create mode 100644 lib/core/network/self_signed_image_cache_manager.dart diff --git a/lib/core/network/image_header_utils.dart b/lib/core/network/image_header_utils.dart new file mode 100644 index 0000000..b77d1e3 --- /dev/null +++ b/lib/core/network/image_header_utils.dart @@ -0,0 +1,47 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:conduit/core/providers/app_providers.dart'; +import 'package:conduit/features/auth/providers/unified_auth_providers.dart'; + +/// Builds HTTP headers for protected image requests. +/// +/// Includes Authorization (Bearer token or API key) and any server-configured +/// custom headers. Returns `null` if no headers are needed. +Map? buildImageHeadersFromRef(Ref ref) { + final api = ref.read(apiServiceProvider); + final token = ref.read(authTokenProvider3); + return _build(api, token); +} + +Map? buildImageHeadersFromWidgetRef(WidgetRef ref) { + final api = ref.read(apiServiceProvider); + final token = ref.read(authTokenProvider3); + return _build(api, token); +} + +/// Same as [buildImageHeadersFromRef] but using a [ProviderContainer], useful +/// when you don't have a `Ref` (e.g., in non-Consumer widgets/utilities). +Map? buildImageHeadersFromContainer( + ProviderContainer container, +) { + final api = container.read(apiServiceProvider); + final token = container.read(authTokenProvider3); + return _build(api, token); +} + +Map? _build(dynamic api, String? token) { + final headers = {}; + + if (token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } else if (api?.serverConfig.apiKey != null && + api!.serverConfig.apiKey!.isNotEmpty) { + headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}'; + } + + if (api != null && api.serverConfig.customHeaders.isNotEmpty) { + headers.addAll(api.serverConfig.customHeaders); + } + + return headers.isEmpty ? null : headers; +} diff --git a/lib/core/network/self_signed_image_cache_manager.dart b/lib/core/network/self_signed_image_cache_manager.dart new file mode 100644 index 0000000..f2ac8c4 --- /dev/null +++ b/lib/core/network/self_signed_image_cache_manager.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/io_client.dart'; + +import '../models/server_config.dart'; +import '../providers/app_providers.dart'; + +/// Returns a CacheManager that accepts a self-signed certificate for the +/// currently active server's host/port. Returns null when not needed. +/// +/// Notes +/// - Scoped to the configured host and (optionally) port only. +/// - Not available on web (browsers enforce TLS validation). +final selfSignedImageCacheManagerProvider = Provider((ref) { + final active = ref.watch(activeServerProvider); + + return active.maybeWhen( + data: (server) { + if (server == null) return null; + return _buildForServer(server); + }, + orElse: () => null, + ); +}); + +CacheManager? _buildForServer(ServerConfig server) { + if (kIsWeb) return null; + if (!server.allowSelfSignedCertificates) return null; + + final uri = _parseUri(server.url); + if (uri == null) return null; + + // Configure a HttpClient that accepts only this host (+ optional port). + final client = HttpClient(); + final host = uri.host.toLowerCase(); + final port = uri.hasPort ? uri.port : null; + + client.badCertificateCallback = + (X509Certificate cert, String requestHost, int requestPort) { + if (requestHost.toLowerCase() != host) return false; + if (port == null) return true; // Any port on this host + return requestPort == port; // Exact host+port only + }; + + final ioClient = IOClient(client); + final fileService = HttpFileService(httpClient: ioClient); + + // Use a stable key per host/port to share cache across widgets. + final key = 'conduit-selfsigned-$host:${port ?? 0}'; + return CacheManager(Config(key, fileService: fileService)); +} + +Uri? _parseUri(String url) { + final trimmed = url.trim(); + if (trimmed.isEmpty) return null; + Uri? parsed = Uri.tryParse(trimmed); + if (parsed == null) return null; + if (!parsed.hasScheme) { + parsed = + Uri.tryParse('https://$trimmed') ?? Uri.tryParse('http://$trimmed'); + } + return parsed; +} diff --git a/lib/features/chat/widgets/enhanced_image_attachment.dart b/lib/features/chat/widgets/enhanced_image_attachment.dart index c5d59c9..c60cb72 100644 --- a/lib/features/chat/widgets/enhanced_image_attachment.dart +++ b/lib/features/chat/widgets/enhanced_image_attachment.dart @@ -13,6 +13,8 @@ import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/providers/app_providers.dart'; import '../../auth/providers/unified_auth_providers.dart'; import '../../../core/utils/debug_logger.dart'; +import '../../../core/network/self_signed_image_cache_manager.dart'; +import '../../../core/network/image_header_utils.dart'; // Simple global cache to prevent reloading final _globalImageCache = {}; @@ -414,29 +416,15 @@ class _EnhancedImageAttachmentState Widget _buildNetworkImage() { // Get authentication headers if available - final api = ref.read(apiServiceProvider); - final authToken = ref.read(authTokenProvider3); - final headers = {}; - - // Add auth token from unified auth provider - if (authToken != null && authToken.isNotEmpty) { - headers['Authorization'] = 'Bearer $authToken'; - } else if (api?.serverConfig.apiKey != null && - api!.serverConfig.apiKey!.isNotEmpty) { - // Fallback to API key from server config - headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}'; - } - - // Add any custom headers from server config - if (api != null && api.serverConfig.customHeaders.isNotEmpty) { - headers.addAll(api.serverConfig.customHeaders); - } + final headers = buildImageHeadersFromWidgetRef(ref); + final cacheManager = ref.read(selfSignedImageCacheManagerProvider); final imageWidget = CachedNetworkImage( key: ValueKey('image_${widget.attachmentId}'), imageUrl: _cachedImageData!, fit: BoxFit.cover, - httpHeaders: headers.isNotEmpty ? headers : null, + cacheManager: cacheManager, + httpHeaders: headers, fadeInDuration: widget.disableAnimation ? Duration.zero : const Duration(milliseconds: 200), @@ -559,28 +547,14 @@ class FullScreenImageViewer extends ConsumerWidget { if (imageData.startsWith('http')) { // Get authentication headers if available - final api = ref.read(apiServiceProvider); - final authToken = ref.read(authTokenProvider3); - final headers = {}; - - // Add auth token from unified auth provider - if (authToken != null && authToken.isNotEmpty) { - headers['Authorization'] = 'Bearer $authToken'; - } else if (api?.serverConfig.apiKey != null && - api!.serverConfig.apiKey!.isNotEmpty) { - // Fallback to API key from server config - headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}'; - } - - // Add any custom headers from server config - if (api != null && api.serverConfig.customHeaders.isNotEmpty) { - headers.addAll(api.serverConfig.customHeaders); - } + final headers = buildImageHeadersFromWidgetRef(ref); + final cacheManager = ref.read(selfSignedImageCacheManagerProvider); imageWidget = CachedNetworkImage( imageUrl: imageData, fit: BoxFit.contain, - httpHeaders: headers.isNotEmpty ? headers : null, + cacheManager: cacheManager, + httpHeaders: headers, placeholder: (context, url) => Center( child: CircularProgressIndicator( color: context.conduitTheme.buttonPrimary, diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 9ed41ad..8e49c06 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:markdown/markdown.dart' as md; @@ -16,6 +17,8 @@ import 'package:conduit/l10n/app_localizations.dart'; import '../../theme/color_tokens.dart'; import '../../theme/theme_extensions.dart'; import 'code_block_header.dart'; +import 'package:conduit/core/network/self_signed_image_cache_manager.dart'; +import 'package:conduit/core/network/image_header_utils.dart'; typedef MarkdownLinkTapCallback = void Function(String url, String title); @@ -413,8 +416,15 @@ class _ImageBuilder extends MarkdownElementBuilder { BuildContext context, ConduitThemeExtension theme, ) { + // Read headers and optional self-signed cache manager from Riverpod + final container = ProviderScope.containerOf(context, listen: false); + final headers = buildImageHeadersFromContainer(container); + final cacheManager = container.read(selfSignedImageCacheManagerProvider); + return CachedNetworkImage( imageUrl: url, + cacheManager: cacheManager, + httpHeaders: headers, placeholder: (context, _) => Container( height: 200, decoration: BoxDecoration( diff --git a/lib/shared/widgets/user_avatar.dart b/lib/shared/widgets/user_avatar.dart index b145a1f..0bd8b7c 100644 --- a/lib/shared/widgets/user_avatar.dart +++ b/lib/shared/widgets/user_avatar.dart @@ -3,14 +3,17 @@ import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../services/brand_service.dart'; import '../theme/theme_extensions.dart'; +import 'package:conduit/core/network/self_signed_image_cache_manager.dart'; +import 'package:conduit/core/network/image_header_utils.dart'; typedef AvatarWidgetBuilder = Widget Function(BuildContext context, double size); -class AvatarImage extends StatelessWidget { +class AvatarImage extends ConsumerWidget { final double size; final String? imageUrl; final BorderRadius? borderRadius; @@ -29,7 +32,7 @@ class AvatarImage extends StatelessWidget { BorderRadius get _radius => borderRadius ?? BorderRadius.circular(size / 2); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final url = imageUrl?.trim(); if (url == null || url.isEmpty) { return fallbackBuilder(context, size); @@ -53,6 +56,11 @@ class AvatarImage extends StatelessWidget { return fallbackBuilder(context, size); } + // Build auth/custom headers when loading from network + final headers = buildImageHeadersFromWidgetRef(ref); + + final cacheManager = ref.read(selfSignedImageCacheManagerProvider); + return ClipRRect( borderRadius: _radius, child: CachedNetworkImage( @@ -60,6 +68,8 @@ class AvatarImage extends StatelessWidget { width: size, height: size, fit: BoxFit.cover, + cacheManager: cacheManager, + httpHeaders: headers, placeholder: (context, _) => (placeholderBuilder ?? _defaultPlaceholder)(context, size), errorWidget: (context, url, error) => fallbackBuilder(context, size), diff --git a/pubspec.lock b/pubspec.lock index a57444b..a51fad1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -495,7 +495,7 @@ packages: source: hosted version: "4.5.2" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -750,7 +750,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 diff --git a/pubspec.yaml b/pubspec.yaml index 8e2d10a..3203813 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,8 @@ dependencies: riverpod_annotation: ^3.0.0 flutter_local_notifications: ^19.4.2 connectivity_plus: ^7.0.0 + flutter_cache_manager: ^3.4.1 + http: ^1.5.0 # Clipboard functionality is available through flutter/services (part of Flutter SDK)