Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +14 to +18

permissions:
contents: write
Expand Down Expand Up @@ -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')
Expand Down
4 changes: 3 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="ComicRow"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
Comment on lines 3 to +7
<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
3 changes: 3 additions & 0 deletions integration_test/streaming_smoke_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:comicrow/app.dart';
import 'package:comicrow/core/storage/database.dart';
import 'package:comicrow/features/library/providers/library_catalog_provider.dart';
import 'package:comicrow/features/servers/data/server_repository.dart';
import 'package:comicrow/features/downloads/data/download_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
Expand Down Expand Up @@ -32,6 +33,8 @@ void main() {
libraryBrowseControllerProvider.overrideWith(
FakeLibraryBrowseController.new,
),
activeDownloadsProvider.overrideWith((ref) => Stream.value(<DownloadRecord>[])),
completedDownloadsProvider.overrideWith((ref) => Stream.value(<DownloadRecord>[])),
],
child: const ComicRowApp(),
),
Expand Down
20 changes: 20 additions & 0 deletions lib/core/opds/opds_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ class OpdsClient {
return _detectVersionFromResponse(response);
}

Future<HttpTextResponse> 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<OpdsFeed> fetchFeed(
Uri feedUri, {
String? username,
Expand Down
89 changes: 70 additions & 19 deletions lib/features/library/providers/library_catalog_provider.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xml/xml.dart';

import '../../../core/network/auth.dart';

Expand Down Expand Up @@ -231,13 +232,24 @@ class LibraryBrowseController extends Notifier<AsyncValue<LibraryBrowseState>> {
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);
Comment on lines +237 to +241
if (fetchedTemplate != null) {
template = fetchedTemplate;
}
}

final searchUri = _resolveSearchUri(
currentState.currentUri,
template,
trimmedQuery,
);

final searchFeed = await _client.fetchFeed(
searchUri,
username: _server?.username,
Expand All @@ -255,6 +267,45 @@ class LibraryBrowseController extends Notifier<AsyncValue<LibraryBrowseState>> {
}
}

Future<String?> _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<void> clearSearch() async {
final rootUri = _rootUri;
final currentState = state.value;
Expand All @@ -281,26 +332,26 @@ class LibraryBrowseController extends Notifier<AsyncValue<LibraryBrowseState>> {

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<String, String>.from(resolved.queryParameters);
parameters['q'] = query;
return resolved.replace(queryParameters: parameters);
}

final parameters = Map<String, String>.from(resolved.queryParameters);
parameters['q'] = query;
return resolved.replace(queryParameters: parameters);
return resolved;
}

Uri resolvePublicationUri(OpdsEntry entry) {
Expand Down
100 changes: 37 additions & 63 deletions lib/features/library/ui/comic_detail_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Comment on lines +64 to +69
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) ...[
Expand Down
33 changes: 24 additions & 9 deletions lib/features/reader/widgets/streaming_page_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,32 @@ class _StreamingPageImageState extends ConsumerState<StreamingPageImage> {
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));
}
}
Comment thread
Copilot marked this conversation as resolved.

final pageBytes = Uint8List.fromList(bytes);
cache.put(widget.pageUrl, pageBytes);
return pageBytes;
return null;
}

void _markLoaded() {
Expand Down
4 changes: 2 additions & 2 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down