Add cxyz Android project structure and initial configuration

- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration.
- Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files.
- Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown.
- Configured analysis options and dependencies in pubspec.yaml to support Flutter development.
- Established a pnpm workspace and lockfile for dependency management.
- Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
This commit is contained in:
2026-08-29 17:50:31 +07:00
parent 6e7f1ced35
commit a7c6058540
105 changed files with 16721 additions and 275 deletions
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/comicland/urls.dart';
void main() {
test('parseComicLandUrl extracts slug and chapter', () {
final parts = parseComicLandUrl(
'https://comicland.org/comic/the-delinquent-girl/chapter/12',
);
expect(parts.slug, 'the-delinquent-girl');
expect(parts.chapterIndex, '12');
});
test('transferLinkBasename pads chapter numbers', () {
expect(transferLinkBasename('12', 'Chapter 12', null), 'chapter 012.cbz');
expect(transferLinkBasename('1.5', null, null), 'chapter 001.5.cbz');
});
test('sanitizeComicDirName strips illegal chars', () {
expect(sanitizeComicDirName('A/B:C*'), 'ABC');
});
}
@@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangaread/mangaread_source.dart';
import 'package:cxyz_android/sources/source.dart';
void main() {
test('latest pagination maps offset/limit to distinct site pages', () async {
final source = MangaReadSource();
final page1 = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 0,
limit: 30,
);
final page2 = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 30,
limit: 30,
);
expect(page1, isNotEmpty);
expect(page2, isNotEmpty);
expect(page1.first.slug, isNot(page2.first.slug));
final overlap = page1.map((c) => c.slug).toSet().intersection(
page2.map((c) => c.slug).toSet(),
);
expect(overlap, isEmpty);
}, timeout: const Timeout(Duration(seconds: 60)));
}
@@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangatown/mangatown_source.dart';
import 'package:cxyz_android/sources/source.dart';
@Tags(['network'])
void main() {
test(
'latest pagination: offset 0 and 30 yield different first slugs',
() async {
final source = MangaTownSource();
final first = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 0,
limit: 30,
);
final second = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 30,
limit: 30,
);
expect(first, isNotEmpty);
expect(second, isNotEmpty);
expect(first.first.slug, isNot(second.first.slug));
final firstSlugs = first.map((c) => c.slug).toSet();
expect(
second.any((c) => firstSlugs.contains(c.slug)),
isFalse,
reason: 'page 2 should not overlap page 1',
);
},
timeout: const Timeout(Duration(seconds: 90)),
);
}
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangatown/urls.dart';
import 'package:cxyz_android/sources/source.dart';
void main() {
group('mangatown catalog URLs', () {
test('latest page 1 and 2', () {
expect(catalogLatestUrl(), 'https://m.mangatown.com/latest');
expect(catalogLatestUrl(page: 2), 'https://m.mangatown.com/latest/2.html');
});
test('hot total and period queries with pagination', () {
expect(catalogHotUrl(), 'https://m.mangatown.com/hot');
expect(
catalogHotUrl(page: 2),
'https://m.mangatown.com/hot/2.html',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.today),
'https://m.mangatown.com/hot?tviews.za',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.week, page: 2),
'https://m.mangatown.com/hot/2.html?wviews.za',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.month, page: 2),
'https://m.mangatown.com/hot/2.html?mviews.za',
);
});
test('directory default and filters', () {
expect(
directoryUrl(const CatalogBrowseParams()),
'https://m.mangatown.com/directory/0-0-0-0-0-0',
);
expect(
directoryUrl(
const CatalogBrowseParams(
demographic: 'seinen',
genre: 'action',
status: 'ongoing',
initial: 'a',
),
page: 2,
),
'https://m.mangatown.com/directory/seinen-action-0-ongoing-a-0/2.html',
);
});
test('search URL', () {
expect(
catalogSearchUrl(query: 'naruto'),
'https://m.mangatown.com/search?name=naruto',
);
expect(
catalogSearchUrl(query: 'naruto', page: 2),
'https://m.mangatown.com/search/2.html?name=naruto',
);
});
});
group('mangatown series/chapter helpers', () {
test('mangaSlugFromUrl and listingUrlForSlug', () {
expect(
mangaSlugFromUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/'),
'ao_no_souyoku',
);
expect(
listingUrlForSlug('ao_no_souyoku'),
'https://m.mangatown.com/manga/ao_no_souyoku',
);
});
test('chapterBaseUrl and chapterPageUrl', () {
expect(
chapterBaseUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/3.html'),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/',
);
expect(
chapterPageUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/', 1),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/',
);
expect(
chapterPageUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/', 5),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/5.html',
);
});
test('chapterKeyFromUrl and chapterNumberFromTitle', () {
expect(
chapterKeyFromUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/'),
'c005',
);
expect(chapterNumberFromTitle('C.5'), '5');
expect(chapterNumberFromTitle('C.031'), '031');
});
test('absoluteUrl upgrades protocol-relative', () {
expect(
absoluteUrl('//zjcdn.mangahere.org/a.jpg', 'https://m.mangatown.com/'),
'https://zjcdn.mangahere.org/a.jpg',
);
});
test('hotPeriodForMenu mapping', () {
expect(hotPeriodForMenu(CatalogMenuKind.popular), MangaTownHotPeriod.total);
expect(hotPeriodForMenu(CatalogMenuKind.hotToday), MangaTownHotPeriod.today);
expect(hotPeriodForMenu(CatalogMenuKind.hotWeek), MangaTownHotPeriod.week);
expect(hotPeriodForMenu(CatalogMenuKind.hotMonth), MangaTownHotPeriod.month);
expect(hotPeriodForMenu(CatalogMenuKind.latest), isNull);
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
/// Mirrors DownloadQueue session drain gating used in `_pump`.
bool shouldNotifyQueueDrained({
required bool sessionHadWork,
required int queuedRemaining,
}) {
return sessionHadWork && queuedRemaining == 0;
}
void main() {
group('queue drained gating', () {
test('does not fire when no work was processed', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: false, queuedRemaining: 0),
isFalse,
);
});
test('fires when work ran and queue is empty', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: true, queuedRemaining: 0),
isTrue,
);
});
test('does not fire while items remain', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: true, queuedRemaining: 2),
isFalse,
);
});
});
}
+115
View File
@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:cxyz_android/services/ntfy_notifier.dart';
import 'package:cxyz_android/services/ntfy_settings.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
void main() {
group('NtfyNotifier.formatBytes', () {
test('formats B KB MB', () {
expect(NtfyNotifier.formatBytes(500), '500 B');
expect(NtfyNotifier.formatBytes(1536), '1.5 KB');
expect(NtfyNotifier.formatBytes(5 * 1024 * 1024), '5.0 MB');
});
});
group('payload titles and body', () {
test('completed title is scannable', () {
expect(
NtfyNotifier.completedTitle('One Piece', 'Chapter 1100'),
'Downloaded · One Piece · Chapter 1100',
);
});
test('failed title includes series and chapter', () {
expect(
NtfyNotifier.failedTitle('One Piece', 'Chapter 1100'),
'Download failed · One Piece · Chapter 1100',
);
});
test('drained title includes counts', () {
expect(
NtfyNotifier.drainedTitle(ok: 5, failed: 1),
'Queue finished · 5 downloaded · 1 failed',
);
});
test('completed body includes sizes and savings', () {
final body = NtfyNotifier.completedBody(
imageCount: 36,
rawBytes: 10 * 1024 * 1024,
cbzBytes: 8 * 1024 * 1024,
);
expect(body, contains('Pages: 36'));
expect(body, contains('Images:'));
expect(body, contains('CBZ:'));
expect(body, contains('saved 20%'));
});
test('completed body appends note', () {
final body = NtfyNotifier.completedBody(
imageCount: 1,
rawBytes: 100,
cbzBytes: 90,
note: 'Attachment skipped (15 MB limit)',
);
expect(body, contains('Attachment skipped (15 MB limit)'));
});
});
group('NtfyAttachmentMode', () {
test('fromStorage defaults to none', () {
expect(NtfyAttachmentMode.fromStorage(null), NtfyAttachmentMode.none);
expect(NtfyAttachmentMode.fromStorage('nope'), NtfyAttachmentMode.none);
expect(
NtfyAttachmentMode.fromStorage('firstImage'),
NtfyAttachmentMode.firstImage,
);
});
});
group('preview resize', () {
test('resizePreviewIsolate shrinks large image under 250 KB', () {
final big = img.Image(width: 2000, height: 3000);
img.fill(big, color: img.ColorRgb8(40, 80, 120));
// Add noise so JPEG does not compress to near-zero.
for (var y = 0; y < big.height; y += 17) {
for (var x = 0; x < big.width; x += 13) {
big.setPixelRgb(x, y, (x * 3) % 255, (y * 5) % 255, 90);
}
}
final raw = Uint8List.fromList(img.encodeJpg(big, quality: 95));
expect(raw.length, greaterThan(250 * 1024));
final out = resizePreviewForTest(raw);
expect(out, isNotNull);
expect(out!.length, lessThanOrEqualTo(250 * 1024));
final decoded = img.decodeImage(out)!;
final longest =
decoded.width > decoded.height ? decoded.width : decoded.height;
expect(longest, lessThanOrEqualTo(720));
});
});
group('encodeNtfyHeader', () {
test('leaves plain ASCII unchanged', () {
expect(encodeNtfyHeader('Downloaded Chapter 1'), 'Downloaded Chapter 1');
});
test('RFC 2047-encodes middle-dot titles', () {
final encoded = encodeNtfyHeader('Downloaded · Series · Ch 1');
expect(encoded.startsWith('=?UTF-8?B?'), isTrue);
expect(encoded.endsWith('?='), isTrue);
expect(encoded.codeUnits.every((c) => c >= 0x20 && c <= 0x7e), isTrue);
});
test('RFC 2047-encodes multiline message', () {
final encoded = encodeNtfyHeader('Pages: 3\nImages: 1 MB\nCBZ: 0.9 MB');
expect(encoded.startsWith('=?UTF-8?B?'), isTrue);
expect(encoded.contains('\n'), isFalse);
});
});
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/services/reader_zoom.dart';
void main() {
test('clamp stays in range and snaps to step', () {
expect(ReaderZoom.clamp(0.1), ReaderZoom.min);
expect(ReaderZoom.clamp(3.0), ReaderZoom.max);
expect(ReaderZoom.clamp(0.66), closeTo(0.7, 0.001));
expect(ReaderZoom.clamp(1.0), 1.0);
});
test('percentLabel formats', () {
expect(ReaderZoom.percentLabel(0.7), '70%');
expect(ReaderZoom.percentLabel(1.0), '100%');
});
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/source.dart';
import 'package:cxyz_android/sources/source_flags.dart';
import 'package:cxyz_android/sources/comicland/comicland_source.dart';
import 'package:cxyz_android/sources/mangaread/mangaread_source.dart';
import 'package:cxyz_android/sources/mangaread/urls.dart';
import 'package:cxyz_android/sources/mangatown/mangatown_source.dart';
import 'package:cxyz_android/sources/registry.dart';
void main() {
group('parseEnabledSourceIds', () {
test('default comicland token', () {
expect(parseEnabledSourceIds('comicland'), {SourceId.comicland});
});
test('comma-separated both', () {
expect(
parseEnabledSourceIds('comicland,mangaread'),
{SourceId.comicland, SourceId.mangaread},
);
});
test('mangaread only', () {
expect(parseEnabledSourceIds('mangaread'), {SourceId.mangaread});
});
test('all enables every SourceId', () {
expect(parseEnabledSourceIds('all'), SourceId.values.toSet());
expect(parseEnabledSourceIds('ALL'), SourceId.values.toSet());
expect(parseEnabledSourceIds('all,mangaread'), SourceId.values.toSet());
});
test('mangatown token', () {
expect(parseEnabledSourceIds('mangatown'), {SourceId.mangatown});
});
test('ignores unknown and falls back when empty', () {
expect(parseEnabledSourceIds(''), {SourceId.comicland});
expect(parseEnabledSourceIds('nope,also-bad'), {SourceId.comicland});
});
test('trims and is case-insensitive', () {
expect(
parseEnabledSourceIds(' MangaRead , ComicLand '),
{SourceId.mangaread, SourceId.comicland},
);
});
});
group('SourceRegistry gating', () {
test('registers only enabled ids', () {
final reg = SourceRegistry(
enabled: {SourceId.mangaread},
mangaRead: MangaReadSource(),
);
expect(reg.all.map((s) => s.id), [SourceId.mangaread]);
expect(reg.hasMultiple, isFalse);
});
test('dual sources preserve declaration order', () {
final reg = SourceRegistry(
enabled: {SourceId.mangaread, SourceId.comicland},
comicLand: ComicLandSource(),
mangaRead: MangaReadSource(),
);
expect(reg.all.map((s) => s.id).toList(), [
SourceId.comicland,
SourceId.mangaread,
]);
expect(reg.hasMultiple, isTrue);
});
test('all three sources preserve declaration order', () {
final reg = SourceRegistry(
enabled: {
SourceId.mangatown,
SourceId.mangaread,
SourceId.comicland,
},
comicLand: ComicLandSource(),
mangaRead: MangaReadSource(),
mangaTown: MangaTownSource(),
);
expect(reg.all.map((s) => s.id).toList(), [
SourceId.comicland,
SourceId.mangaread,
SourceId.mangatown,
]);
});
});
group('catalogMenus capabilities', () {
test('ComicLand exposes newest, popular, official, uncensored, recommended, search', () {
final kinds =
ComicLandSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.popular,
CatalogMenuKind.official,
CatalogMenuKind.uncensored,
CatalogMenuKind.recommended,
CatalogMenuKind.search,
]);
expect(
ComicLandSource().catalogMenus.first.title,
'Newest',
);
});
test('MangaRead exposes latest and search only', () {
final kinds =
MangaReadSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.search,
]);
expect(kinds.contains(CatalogMenuKind.popular), isFalse);
});
test('MangaTown exposes latest, hot variants, directory, search', () {
final kinds =
MangaTownSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.popular,
CatalogMenuKind.hotToday,
CatalogMenuKind.hotWeek,
CatalogMenuKind.hotMonth,
CatalogMenuKind.directory,
CatalogMenuKind.search,
]);
});
});
group('mangaread urls', () {
test('mangaSlugFromUrl', () {
expect(
mangaSlugFromUrl(
'https://www.mangaread.org/manga/the-chaebeols-youngest-son/chapter-560/',
),
'the-chaebeols-youngest-son',
);
});
test('mangaBaseUrlFromListingUrl', () {
expect(
mangaBaseUrlFromListingUrl(
'https://www.mangaread.org/manga/foo-bar/chapter-1/',
),
'https://www.mangaread.org/manga/foo-bar/',
);
});
test('listingUrlForSlug', () {
expect(
listingUrlForSlug('foo-bar'),
'https://www.mangaread.org/manga/foo-bar/',
);
});
test('chapterNumberFromTitle', () {
expect(chapterNumberFromTitle('Chapter 560'), '560');
expect(chapterNumberFromTitle('Ch. 12.5'), '12.5');
});
});
}
@@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/services/reader_direction.dart';
import 'package:cxyz_android/services/source_reader_defaults.dart';
void main() {
group('SourceReaderDefaults.modeFor', () {
test('comicland is webtoon', () {
expect(SourceReaderDefaults.modeFor('comicland'), ReaderMode.webtoon);
});
test('mangaread and mangatown are paged', () {
expect(SourceReaderDefaults.modeFor('mangaread'), ReaderMode.paged);
expect(SourceReaderDefaults.modeFor('mangatown'), ReaderMode.paged);
});
});
group('SourceReaderDefaults.directionFor', () {
test('mangaread and mangatown are rtl', () {
expect(
SourceReaderDefaults.directionFor('mangaread'),
ReadingDirection.rtl,
);
expect(
SourceReaderDefaults.directionFor('mangatown'),
ReadingDirection.rtl,
);
});
test('comicland has no built-in direction', () {
expect(SourceReaderDefaults.directionFor('comicland'), isNull);
});
});
group('SourceReaderDefaults.resolveMode', () {
test('stored override wins', () {
expect(
SourceReaderDefaults.resolveMode('mangatown', 'webtoon'),
ReaderMode.webtoon,
);
expect(
SourceReaderDefaults.resolveMode('comicland', 'paged'),
ReaderMode.paged,
);
});
test('empty / unknown falls back to source default', () {
expect(
SourceReaderDefaults.resolveMode('mangatown', ''),
ReaderMode.paged,
);
expect(
SourceReaderDefaults.resolveMode('mangatown', null),
ReaderMode.paged,
);
expect(
SourceReaderDefaults.resolveMode('comicland', ''),
ReaderMode.webtoon,
);
});
});
group('SourceReaderDefaults.resolveDirection', () {
test('stored override wins', () {
expect(
SourceReaderDefaults.resolveDirection(
'mangatown',
'ltr',
globalFallback: ReadingDirection.rtl,
),
ReadingDirection.ltr,
);
});
test('source default when unset', () {
expect(
SourceReaderDefaults.resolveDirection(
'mangaread',
null,
globalFallback: ReadingDirection.ltr,
),
ReadingDirection.rtl,
);
});
test('global fallback for comicland when unset', () {
expect(
SourceReaderDefaults.resolveDirection(
'comicland',
null,
globalFallback: ReadingDirection.rtl,
),
ReadingDirection.rtl,
);
expect(
SourceReaderDefaults.resolveDirection(
'comicland',
'',
globalFallback: ReadingDirection.ltr,
),
ReadingDirection.ltr,
);
});
});
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/main.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
testWidgets('App builds', (tester) async {
await tester.pumpWidget(const ProviderScope(child: CxyzApp()));
// Catalog app bar title from ComicLand source.
expect(find.text('ComicLand'), findsOneWidget);
});
}