diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08c81f8..65d7984 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,11 @@ on: required: false default: internal type: string + release_status: + description: Google Play release status (completed, draft, halted, inProgress) + required: false + default: completed + type: string permissions: contents: write @@ -77,7 +82,7 @@ jobs: packageName: com.nadiar.comicrow releaseFiles: build/app/outputs/bundle/release/app-release.aab track: ${{ inputs.play_track || 'internal' }} - status: completed + status: ${{ inputs.release_status || 'completed' }} - name: Create GitHub Release if: startsWith(github.ref, 'refs/tags/v') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index efb247e..ea47ce2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,10 @@ + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> Stream.value([])), + completedDownloadsProvider.overrideWith((ref) => Stream.value([])), ], child: const ComicRowApp(), ), diff --git a/lib/core/opds/opds_client.dart b/lib/core/opds/opds_client.dart index 29fe9fe..3446b4e 100644 --- a/lib/core/opds/opds_client.dart +++ b/lib/core/opds/opds_client.dart @@ -46,6 +46,26 @@ class OpdsClient { return _detectVersionFromResponse(response); } + Future fetchRaw( + Uri uri, { + String? username, + String? password, + }) async { + final response = await _transport.get( + uri, + username: username, + password: password, + headers: _acceptHeader, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw OpdsConnectionException( + 'Connection failed with status ${response.statusCode}.', + ); + } + return response; + } + Future fetchFeed( Uri feedUri, { String? username, diff --git a/lib/features/library/providers/library_catalog_provider.dart b/lib/features/library/providers/library_catalog_provider.dart index 26856b8..b59f8d8 100644 --- a/lib/features/library/providers/library_catalog_provider.dart +++ b/lib/features/library/providers/library_catalog_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xml/xml.dart'; import '../../../core/network/auth.dart'; @@ -231,13 +232,24 @@ class LibraryBrowseController extends Notifier> { throw const LibraryCatalogException('Search is not available for this catalog.'); } - final searchUri = _resolveSearchUri( - currentState.currentUri, - searchUrl, - trimmedQuery, - ); state = const AsyncValue.loading(); try { + var template = searchUrl; + final rawUri = currentState.currentUri.resolve(searchUrl); + + if (!searchUrl.contains('{searchTerms}') && !searchUrl.contains('{searchTerm}')) { + final fetchedTemplate = await _getSearchTemplate(rawUri); + if (fetchedTemplate != null) { + template = fetchedTemplate; + } + } + + final searchUri = _resolveSearchUri( + currentState.currentUri, + template, + trimmedQuery, + ); + final searchFeed = await _client.fetchFeed( searchUri, username: _server?.username, @@ -255,6 +267,45 @@ class LibraryBrowseController extends Notifier> { } } + Future _getSearchTemplate(Uri searchUri) async { + try { + final response = await _client.fetchRaw( + searchUri, + username: _server?.username, + password: _password, + ); + + final document = XmlDocument.parse(response.body); + final searchDesc = document.findElements('OpenSearchDescription').firstOrNull ?? + document.findAllElements('OpenSearchDescription').firstOrNull; + if (searchDesc != null) { + final urls = searchDesc.findElements('Url'); + for (final url in urls) { + final type = url.getAttribute('type') ?? ''; + if (type.contains('application/atom+xml') || + type.contains('application/opds+json') || + type.contains('application/xml') || + type.contains('text/xml')) { + final template = url.getAttribute('template'); + if (template != null && template.isNotEmpty) { + return template; + } + } + } + final anyUrl = urls.firstOrNull; + if (anyUrl != null) { + final template = anyUrl.getAttribute('template'); + if (template != null && template.isNotEmpty) { + return template; + } + } + } + } catch (e) { + // Log template parsing error silently or fallback + } + return null; + } + Future clearSearch() async { final rootUri = _rootUri; final currentState = state.value; @@ -281,26 +332,26 @@ class LibraryBrowseController extends Notifier> { Uri _resolveSearchUri(Uri baseUri, String searchHref, String query) { final encodedQuery = Uri.encodeQueryComponent(query); - final href = searchHref + + var href = searchHref + .replaceAll('{searchTerms}', encodedQuery) + .replaceAll('{searchTerm}', encodedQuery) .replaceAll('{?searchTerms}', '?q=$encodedQuery') - .replaceAll('{searchTerms}', encodedQuery); + .replaceAll('{?searchTerm}', '?q=$encodedQuery'); - if (href.contains('{') || href.contains('}')) { - throw const LibraryCatalogException('Unsupported search URI template.'); - } + href = href.replaceAll(RegExp(r'\{[^\}]+\}'), ''); final resolved = baseUri.resolve(href); - if (resolved.queryParameters.containsKey('q')) { - return resolved; - } - - if (href.contains('{?searchTerms}')) { - return resolved; + if (!searchHref.contains('{searchTerms}') && + !searchHref.contains('{searchTerm}') && + !resolved.queryParameters.containsKey('q') && + !resolved.queryParameters.containsKey('query')) { + final parameters = Map.from(resolved.queryParameters); + parameters['q'] = query; + return resolved.replace(queryParameters: parameters); } - final parameters = Map.from(resolved.queryParameters); - parameters['q'] = query; - return resolved.replace(queryParameters: parameters); + return resolved; } Uri resolvePublicationUri(OpdsEntry entry) { diff --git a/lib/features/library/ui/comic_detail_sheet.dart b/lib/features/library/ui/comic_detail_sheet.dart index 9f3945c..fe5fbc0 100644 --- a/lib/features/library/ui/comic_detail_sheet.dart +++ b/lib/features/library/ui/comic_detail_sheet.dart @@ -61,74 +61,48 @@ class ComicDetailSheet extends ConsumerWidget { ), const SizedBox(height: 12), - // Thumbnail + Publication Info - if (entry.thumbnailHref != null) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - baseUri.resolve(entry.thumbnailHref!).toString(), - width: 100, - height: 150, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - width: 100, - height: 150, - color: Colors.grey[300], - child: const Icon(Icons.broken_image), - ); - }, + // Publication Info + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (meta.series != null) ...[ + Text( + meta.series!, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (meta.series != null) ...[ - Text( - meta.series!, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - ], - if (meta.number != null) ...[ - Text( - 'Issue #${meta.number}', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 4), - ], - if (meta.year != null) ...[ - Text( - 'Published: ${meta.year}${meta.month != null ? '/${meta.month}' : ''}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 4), - ], - if (meta.publisher != null) ...[ - Text( - 'Publisher: ${meta.publisher}', - style: Theme.of(context).textTheme.bodySmall, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), + const SizedBox(height: 4), + ], + if (meta.number != null) ...[ + Text( + 'Issue #${meta.number}', + style: Theme.of(context).textTheme.bodyMedium, ), + const SizedBox(height: 4), ], - ), + if (meta.year != null) ...[ + Text( + 'Published: ${meta.year}${meta.month != null ? '/${meta.month}' : ''}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + ], + if (meta.publisher != null) ...[ + Text( + 'Publisher: ${meta.publisher}', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], ), + ), // Summary if (entry.summary?.isNotEmpty == true) ...[ diff --git a/lib/features/reader/widgets/streaming_page_image.dart b/lib/features/reader/widgets/streaming_page_image.dart index d889bd8..2f34161 100644 --- a/lib/features/reader/widgets/streaming_page_image.dart +++ b/lib/features/reader/widgets/streaming_page_image.dart @@ -98,17 +98,32 @@ class _StreamingPageImageState extends ConsumerState { return cached; } - final bytes = await ref.read(comicDownloaderProvider).downloadBytesWithHeaders( - Uri.parse(widget.pageUrl), - headers: widget.headers, - ); - if (bytes.isEmpty) { - return null; + const maxAttempts = 3; + final pageUri = Uri.parse(widget.pageUrl); + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + if (!mounted) return null; + + try { + final bytes = await ref.read(comicDownloaderProvider).downloadBytesWithHeaders( + pageUri, + headers: widget.headers, + ); + if (bytes.isNotEmpty) { + final pageBytes = Uint8List.fromList(bytes); + cache.put(widget.pageUrl, pageBytes); + return pageBytes; + } + } catch (e) { + debugPrint('Page download attempt $attempt failed for ${widget.pageUrl}: $e'); + } + + if (attempt < maxAttempts) { + await Future.delayed(Duration(seconds: attempt)); + } } - final pageBytes = Uint8List.fromList(bytes); - cache.put(widget.pageUrl, pageBytes); - return pageBytes; + return null; } void _markLoaded() { diff --git a/pubspec.lock b/pubspec.lock index dfd1fe5..e586ee0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1016,10 +1016,10 @@ packages: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad url: "https://pub.dev" source: hosted - version: "0.6.0+eol" + version: "0.5.42" sqlparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3bfc4c2..a432cf2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: go_router: ^17.1.0 dio: ^5.9.2 drift: ^2.31.0 - sqlite3_flutter_libs: ^0.6.0+eol + sqlite3_flutter_libs: ^0.5.42 cached_network_image: ^3.4.1 xml: ^6.6.1 archive: ^4.0.9