更改安卓端的文件访问方式,优化导入逻辑 (#64)

* Refactor import function & Allow import local comics without copying them to local path.

* android: use file_picker instead, support directory access for android 10

* Improve import logic

* Fix sql query.

* Add ability to remove invalid favorite items.

* Perform sort before choosing cover

* Revert changes of "use file_picker instead".

* Try catch on "check update"

* Added module 'flutter_saf'

* gitignore

* remove unsupported arch in build.gradle

* Use flutter_saf to handle android's directory and files, improve import logic.

* revert changes of 'requestLegacyExternalStorage'

* fix cbz import

* openDirectoryPlatform

* Remove double check on source folder

* use openFilePlatform

* remove unused import

* improve local comic's path handling

* bump version

* fix pubspec format

* return null when comic folder is empty
This commit is contained in:
pkuislm
2024-11-23 11:05:00 +08:00
committed by GitHub
parent c3474b1dff
commit a1474ca9c3
16 changed files with 369 additions and 253 deletions

1
android/.gitignore vendored
View File

@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
/app/.cxx/

View File

@@ -34,6 +34,8 @@ android {
splits{ splits{
abi { abi {
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
enable true enable true
universalApk true universalApk true
} }

View File

@@ -239,6 +239,9 @@
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 页", "Page @page": "第 @page 页",
"Also remove files on disk": "同时删除磁盘上的文件", "Also remove files on disk": "同时删除磁盘上的文件",
"Copy to app local path": "将漫画复制到本地存储目录中",
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
"Deleted @a favorite items.": "已删除 @a 条无效收藏",
"New version available": "有新版本可用", "New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
"No new version available": "没有新版本可用" "No new version available": "没有新版本可用"
@@ -483,6 +486,9 @@
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁", "Page @page": "第 @page 頁",
"Also remove files on disk": "同時刪除磁盤上的文件", "Also remove files on disk": "同時刪除磁盤上的文件",
"Copy to app local path": "將漫畫複製到本地儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本地收藏",
"Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用", "New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用" "No new version available": "沒有新版本可用"

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/image_provider/local_favorite_image.dart'; import 'package:venera/foundation/image_provider/local_favorite_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'dart:io'; import 'dart:io';
@@ -496,6 +497,22 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<int> removeInvalid() async {
int count = 0;
await Future.microtask(() {
var all = allComics();
for(var c in all) {
var comicSource = c.type.comicSource;
if ((c.type == ComicType.local && LocalManager().find(c.id, c.type) == null)
|| (c.type != ComicType.local && comicSource == null)) {
deleteComicWithId(c.folder, c.id, c.type);
count++;
}
}
});
return count;
}
Future<void> clearAll() async { Future<void> clearAll() async {
_db.dispose(); _db.dispose();
File("${App.dataPath}/local_favorite.db").deleteSync(); File("${App.dataPath}/local_favorite.db").deleteSync();

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart'; import 'base_image_provider.dart';
import 'cached_image.dart' as image_provider; import 'cached_image.dart' as image_provider;
@@ -24,7 +25,7 @@ class CachedImageProvider
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
if(url.startsWith("file://")) { if(url.startsWith("file://")) {
var file = File(url.substring(7)); var file = openFilePlatform(url.substring(7));
return file.readAsBytes(); return file.readAsBytes();
} }
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {

View File

@@ -71,12 +71,13 @@ class LocalComic with HistoryMixin implements Comic {
downloadedChapters = List.from(jsonDecode(row[8] as String)), downloadedChapters = List.from(jsonDecode(row[8] as String)),
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int); createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
File get coverFile => File(FilePath.join( File get coverFile => openFilePlatform(FilePath.join(
LocalManager().path, baseDir,
directory,
cover, cover,
)); ));
String get baseDir => directory.contains("/") ? directory : FilePath.join(LocalManager().path, directory);
@override @override
String get description => ""; String get description => "";
@@ -341,12 +342,12 @@ class LocalManager with ChangeNotifier {
throw "Invalid ep"; throw "Invalid ep";
} }
var comic = find(id, type) ?? (throw "Comic Not Found"); var comic = find(id, type) ?? (throw "Comic Not Found");
var directory = Directory(FilePath.join(path, comic.directory)); var directory = openDirectoryPlatform(comic.baseDir);
if (comic.chapters != null) { if (comic.chapters != null) {
var cid = ep is int var cid = ep is int
? comic.chapters!.keys.elementAt(ep - 1) ? comic.chapters!.keys.elementAt(ep - 1)
: (ep as String); : (ep as String);
directory = Directory(FilePath.join(directory.path, cid)); directory = openDirectoryPlatform(FilePath.join(directory.path, cid));
} }
var files = <File>[]; var files = <File>[];
await for (var entity in directory.list()) { await for (var entity in directory.list()) {
@@ -392,10 +393,10 @@ class LocalManager with ChangeNotifier {
String id, ComicType type, String name) async { String id, ComicType type, String name) async {
var comic = find(id, type); var comic = find(id, type);
if (comic != null) { if (comic != null) {
return Directory(FilePath.join(path, comic.directory)); return openDirectoryPlatform(FilePath.join(path, comic.directory));
} }
var dir = findValidDirectoryName(path, name); var dir = findValidDirectoryName(path, name);
return Directory(FilePath.join(path, dir)).create().then((value) => value); return openDirectoryPlatform(FilePath.join(path, dir)).create().then((value) => value);
} }
void completeTask(DownloadTask task) { void completeTask(DownloadTask task) {
@@ -454,7 +455,7 @@ class LocalManager with ChangeNotifier {
void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) { void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
if(removeFileOnDisk) { if(removeFileOnDisk) {
var dir = Directory(FilePath.join(path, c.directory)); var dir = openDirectoryPlatform(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true); dir.deleteIgnoreError(recursive: true);
} }
//Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted. //Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.

View File

@@ -1,3 +1,4 @@
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -12,6 +13,7 @@ import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart'; import 'foundation/appdata.dart';
Future<void> init() async { Future<void> init() async {
await SAFTaskWorker().init();
await AppTranslation.init(); await AppTranslation.init();
await appdata.init(); await appdata.init();
await App.init(); await App.init();

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
@@ -497,6 +496,10 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
String? selectedFolder; String? selectedFolder;
bool copyToLocalFolder = true;
bool cancelled = false;
@override @override
void dispose() { void dispose() {
loading = false; loading = false;
@@ -546,6 +549,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
}, },
); );
}), }),
if(type != 3)
ListTile( ListTile(
title: Text("Add to favorites".tl), title: Text("Add to favorites".tl),
trailing: Select( trailing: Select(
@@ -559,6 +563,15 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
}, },
), ),
).paddingHorizontal(8), ).paddingHorizontal(8),
CheckboxListTile(
enabled: true,
title: Text("Copy to app local path".tl),
value: copyToLocalFolder,
onChanged:(v) {
setState(() {
copyToLocalFolder = !copyToLocalFolder;
});
}).paddingHorizontal(8),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(info).paddingHorizontal(24), Text(info).paddingHorizontal(24),
], ],
@@ -620,18 +633,20 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
void selectAndImport() async { void selectAndImport() async {
height = key.currentContext!.size!.height; height = key.currentContext!.size!.height;
setState(() { setState(() {
loading = true; loading = true;
}); });
var importer = ImportComic(selectedFolder: selectedFolder); var importer = ImportComic(
var result = false; selectedFolder: selectedFolder,
if (type == 2) { copyToLocal: copyToLocalFolder);
result = await importer.cbz(); var result = switch(type) {
} else if (type == 3) { 0 => await importer.directory(true),
result = await importer.ehViewer(); 1 => await importer.directory(false),
} else { 2 => await importer.cbz(),
result = await importer.directory(type == 0); 3 => await importer.ehViewer(),
} int() => true,
};
if(result) { if(result) {
context.pop(); context.pop();
} else { } else {

View File

@@ -604,7 +604,7 @@ ImageProvider _createImageProvider(int page, BuildContext context) {
var reader = context.reader; var reader = context.reader;
var imageKey = reader.images![page - 1]; var imageKey = reader.images![page - 1];
if (imageKey.startsWith('file://')) { if (imageKey.startsWith('file://')) {
return FileImage(File(imageKey.replaceFirst("file://", ''))); return FileImage(openFilePlatform(imageKey.replaceFirst("file://", '')));
} else { } else {
return ReaderImageProvider( return ReaderImageProvider(
imageKey, imageKey,

View File

@@ -469,7 +469,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
ImageProvider image; ImageProvider image;
var imageKey = images[index]; var imageKey = images[index];
if (imageKey.startsWith('file://')) { if (imageKey.startsWith('file://')) {
image = FileImage(File(imageKey.replaceFirst("file://", ''))); image = FileImage(openFilePlatform(imageKey.replaceFirst("file://", '')));
} else { } else {
image = ReaderImageProvider( image = ReaderImageProvider(
imageKey, imageKey,
@@ -515,7 +515,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
} }
if (imageKey.startsWith("file://")) { if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes(); return await openFilePlatform(imageKey.substring(7)).readAsBytes();
} else { } else {
return (await CacheManager().findCache( return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!

View File

@@ -86,6 +86,7 @@ Future<bool> checkUpdate() async {
} }
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async { Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
try {
var value = await checkUpdate(); var value = await checkUpdate();
if (value) { if (value) {
showDialog( showDialog(
@@ -110,6 +111,9 @@ Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
} else if (showMessageIfNoUpdate) { } else if (showMessageIfNoUpdate) {
App.rootContext.showMessage(message: "No new version available".tl); App.rootContext.showMessage(message: "No new version available".tl);
} }
} catch (e, s) {
Log.error("Check Update", e.toString(), s);
}
} }
/// return true if version1 > version2 /// return true if version1 > version2

View File

@@ -38,6 +38,16 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
for (var e in LocalFavoritesManager().folderNames) e: e for (var e in LocalFavoritesManager().folderNames) e: e
}, },
).toSliver(), ).toSliver(),
_CallbackSetting(
title: "Delete all unavailable local favorite items".tl,
callback: () async {
var controller = showLoadingDialog(context);
var count = await LocalFavoritesManager().removeInvalid();
controller.close();
context.showMessage(message: "Deleted @a favorite items".tlParams({'a': count}));
},
actionTitle: 'Delete'.tl,
).toSliver(),
], ],
); );
} }

View File

@@ -8,50 +8,40 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:sqlite3/sqlite3.dart' as sql; import 'package:sqlite3/sqlite3.dart' as sql;
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'cbz.dart'; import 'cbz.dart';
import 'io.dart'; import 'io.dart';
class ImportComic { class ImportComic {
final String? selectedFolder; final String? selectedFolder;
final bool copyToLocal;
const ImportComic({this.selectedFolder}); const ImportComic({this.selectedFolder, this.copyToLocal = true});
Future<bool> cbz() async { Future<bool> cbz() async {
var file = await selectFile(ext: ['cbz']); var file = await selectFile(ext: ['cbz']);
Map<String?, List<LocalComic>> imported = {};
if(file == null) { if(file == null) {
return false; return false;
} }
var controller = showLoadingDialog(App.rootContext, allowCancel: false); var controller = showLoadingDialog(App.rootContext, allowCancel: false);
var isSuccessful = false;
try { try {
var comic = await CBZ.import(File(file.path)); var comic = await CBZ.import(File(file.path));
if (selectedFolder != null) { imported[selectedFolder] = [comic];
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
),
);
}
isSuccessful = true;
} catch (e, s) { } catch (e, s) {
Log.error("Import Comic", e.toString(), s); Log.error("Import Comic", e.toString(), s);
App.rootContext.showMessage(message: e.toString()); App.rootContext.showMessage(message: e.toString());
} }
controller.close(); controller.close();
return isSuccessful; return registerComics(imported, true);
} }
Future<bool> ehViewer() async { Future<bool> ehViewer() async {
var dbFile = await selectFile(ext: ['db']); var dbFile = await selectFile(ext: ['db']);
final picker = DirectoryPicker(); final picker = DirectoryPicker();
final comicSrc = await picker.pickDirectory(); final comicSrc = await picker.pickDirectory();
Map<String?, List<LocalComic>> imported = {};
if (dbFile == null || comicSrc == null) { if (dbFile == null || comicSrc == null) {
return false; return false;
} }
@@ -60,43 +50,27 @@ class ImportComic {
var controller = showLoadingDialog(App.rootContext, onCancel: () { var controller = showLoadingDialog(App.rootContext, onCancel: () {
cancelled = true; cancelled = true;
}); });
bool isSuccessful = false;
try { try {
var cache = FilePath.join(App.cachePath, dbFile.name); var db = sql.sqlite3.open(dbFile.path);
await dbFile.saveTo(cache);
var db = sql.sqlite3.open(cache);
Future<void> addTagComics(String destFolder, List<sql.Row> comics) async { Future<List<LocalComic>> validateComics(List<sql.Row> comics) async {
List<LocalComic> imported = [];
for (var comic in comics) { for (var comic in comics) {
if (cancelled) { if (cancelled) {
return; return imported;
} }
var comicDir = Directory( var comicDir = openDirectoryPlatform(
FilePath.join(comicSrc.path, comic['DIRNAME'] as String)); FilePath.join(comicSrc.path, comic['DIRNAME'] as String));
if (!(await comicDir.exists())) {
continue;
}
String titleJP = String titleJP =
comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String; comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String;
String title = titleJP == "" ? comic['TITLE'] as String : titleJP; String title = titleJP == "" ? comic['TITLE'] as String : titleJP;
if (LocalManager().findByName(title) != null) { int timeStamp = comic['TIME'] as int;
Log.info("Import Comic", "Comic already exists: $title"); DateTime downloadTime = timeStamp != 0
continue; ? DateTime.fromMillisecondsSinceEpoch(timeStamp)
}
String coverURL = await comicDir.joinFile(".thumb").exists()
? comicDir.joinFile(".thumb").path
: (comic['THUMB'] as String)
.replaceAll('s.exhentai.org', 'ehgt.org');
int downloadedTimeStamp = comic['TIME'] as int;
DateTime downloadedTime = downloadedTimeStamp != 0
? DateTime.fromMillisecondsSinceEpoch(downloadedTimeStamp)
: DateTime.now(); : DateTime.now();
var comicObj = LocalComic( var comicObj = await _checkSingleComic(comicDir,
id: LocalManager().findValidId(ComicType.local),
title: title, title: title,
subtitle: '',
tags: [ tags: [
//1 >> x //1 >> x
[ [
@@ -112,77 +86,55 @@ class ImportComic {
"WESTERN", "WESTERN",
][(log(comic['CATEGORY'] as int) / ln2).floor()] ][(log(comic['CATEGORY'] as int) / ln2).floor()]
], ],
directory: comicDir.path, createTime: downloadTime);
chapters: null, if (comicObj == null) {
cover: coverURL, continue;
comicType: ComicType.local,
downloadedChapters: [],
createdAt: downloadedTime,
);
LocalManager().add(comicObj, comicObj.id);
LocalFavoritesManager().addComic(
destFolder,
FavoriteItem(
id: comicObj.id,
name: comicObj.title,
coverPath: comicObj.cover,
author: comicObj.subtitle,
type: comicObj.comicType,
tags: comicObj.tags,
favoriteTime: downloadedTime,
),
);
} }
imported.add(comicObj);
}
return imported;
} }
{ var tags = <String>[""];
var defaultFolderName = '(EhViewer)Default'; tags.addAll(db.select("""
if (!LocalFavoritesManager().existsFolder(defaultFolderName)) { SELECT * FROM DOWNLOAD_LABELS LB
LocalFavoritesManager().createFolder(defaultFolderName); ORDER BY LB.TIME DESC;
} """).map((r) => r['LABEL'] as String).toList());
var comicList = db.select("""
SELECT *
FROM DOWNLOAD_DIRNAME DN
LEFT JOIN DOWNLOADS DL
ON DL.GID = DN.GID
WHERE DL.LABEL IS NULL AND DL.STATE = 3
ORDER BY DL.TIME DESC
""").toList();
await addTagComics(defaultFolderName, comicList);
}
var folders = db.select(""" for (var tag in tags) {
SELECT * FROM DOWNLOAD_LABELS;
""");
for (var folder in folders) {
if (cancelled) { if (cancelled) {
break; break;
} }
var label = folder["LABEL"] as String; var folderName =
var folderName = '(EhViewer)$label'; tag == '' ? '(EhViewer)Default'.tl : '(EhViewer)$tag';
if (!LocalFavoritesManager().existsFolder(folderName)) {
LocalFavoritesManager().createFolder(folderName);
}
var comicList = db.select(""" var comicList = db.select("""
SELECT * SELECT *
FROM DOWNLOAD_DIRNAME DN FROM DOWNLOAD_DIRNAME DN
LEFT JOIN DOWNLOADS DL LEFT JOIN DOWNLOADS DL
ON DL.GID = DN.GID ON DL.GID = DN.GID
WHERE DL.LABEL = ? AND DL.STATE = 3 WHERE DL.LABEL ${tag == '' ? 'IS NULL' : '= \'$tag\''} AND DL.STATE = 3
ORDER BY DL.TIME DESC ORDER BY DL.TIME DESC
""", [label]).toList(); """).toList();
await addTagComics(folderName, comicList);
var validComics = await validateComics(comicList);
imported[folderName] = validComics;
if (validComics.isNotEmpty &&
!LocalFavoritesManager().existsFolder(folderName)) {
LocalFavoritesManager().createFolder(folderName);
}
} }
db.dispose(); db.dispose();
//Android specific
var cache = FilePath.join(App.cachePath, dbFile.name);
await File(cache).deleteIgnoreError(); await File(cache).deleteIgnoreError();
isSuccessful = true;
} catch (e, s) { } catch (e, s) {
Log.error("Import Comic", e.toString(), s); Log.error("Import Comic", e.toString(), s);
App.rootContext.showMessage(message: e.toString()); App.rootContext.showMessage(message: e.toString());
} }
controller.close(); controller.close();
return isSuccessful; if(cancelled) return false;
return registerComics(imported, copyToLocal);
} }
Future<bool> directory(bool single) async { Future<bool> directory(bool single) async {
@@ -191,11 +143,12 @@ class ImportComic {
if (path == null) { if (path == null) {
return false; return false;
} }
Map<Directory, LocalComic> comics = {}; Map<String?, List<LocalComic>> imported = {selectedFolder: []};
try {
if (single) { if (single) {
var result = await _checkSingleComic(path); var result = await _checkSingleComic(path);
if (result != null) { if (result != null) {
comics[path] = result; imported[selectedFolder]!.add(result);
} else { } else {
App.rootContext.showMessage(message: "Invalid Comic".tl); App.rootContext.showMessage(message: "Invalid Comic".tl);
return false; return false;
@@ -205,57 +158,28 @@ class ImportComic {
if (entry is Directory) { if (entry is Directory) {
var result = await _checkSingleComic(entry); var result = await _checkSingleComic(entry);
if (result != null) { if (result != null) {
comics[entry] = result; imported[selectedFolder]!.add(result);
} }
} }
} }
} }
bool shouldCopy = true; } catch (e, s) {
for (var comic in comics.keys) { Log.error("Import Comic", e.toString(), s);
if (comic.parent.path == LocalManager().path) { App.rootContext.showMessage(message: e.toString());
shouldCopy = false;
break;
} }
} return registerComics(imported, copyToLocal);
if (shouldCopy && comics.isNotEmpty) {
try {
// copy the comics to the local directory
await compute<Map<String, dynamic>, void>(_copyDirectories, {
'toBeCopied': comics.keys.map((e) => e.path).toList(),
'destination': LocalManager().path,
});
} catch (e) {
App.rootContext.showMessage(message: "Failed to import comics".tl);
Log.error("Import Comic", e.toString());
return false;
}
}
for (var comic in comics.values) {
LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
),
);
}
}
App.rootContext.showMessage(
message: "Imported @a comics".tlParams({
'a': comics.length,
}));
return true;
} }
Future<LocalComic?> _checkSingleComic(Directory directory) async { //Automatically search for cover image and chapters
Future<LocalComic?> _checkSingleComic(Directory directory,
{String? id,
String? title,
String? subtitle,
List<String>? tags,
DateTime? createTime})
async {
if (!(await directory.exists())) return null; if (!(await directory.exists())) return null;
var name = directory.name; var name = title ?? directory.name;
if (LocalManager().findByName(name) != null) { if (LocalManager().findByName(name) != null) {
Log.info("Import Comic", "Comic already exists: $name"); Log.info("Import Comic", "Comic already exists: $name");
return null; return null;
@@ -263,7 +187,8 @@ class ImportComic {
bool hasChapters = false; bool hasChapters = false;
var chapters = <String>[]; var chapters = <String>[];
var coverPath = ''; // relative path to the cover image var coverPath = ''; // relative path to the cover image
for (var entry in directory.listSync()) { var fileList = <String>[];
await for (var entry in directory.list()) {
if (entry is Directory) { if (entry is Directory) {
hasChapters = true; hasChapters = true;
chapters.add(entry.name); chapters.add(entry.name);
@@ -275,20 +200,24 @@ class ImportComic {
} }
} }
} else if (entry is File) { } else if (entry is File) {
if (entry.name.startsWith('cover')) {
coverPath = entry.name;
}
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe']; const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'];
if (!coverPath.startsWith('cover') && if (imageExtensions.contains(entry.extension)) {
imageExtensions.contains(entry.extension)) { fileList.add(entry.name);
coverPath = entry.name;
} }
} }
} }
if(fileList.isEmpty) {
return null;
}
fileList.sort();
coverPath = fileList.firstWhereOrNull((l) => l.startsWith('cover')) ?? fileList.first;
chapters.sort(); chapters.sort();
if (hasChapters && coverPath == '') { if (hasChapters && coverPath == '') {
// use the first image in the first chapter as the cover // use the first image in the first chapter as the cover
var firstChapter = Directory('${directory.path}/${chapters.first}'); var firstChapter = openDirectoryPlatform('${directory.path}/${chapters.first}');
await for (var entry in firstChapter.list()) { await for (var entry in firstChapter.list()) {
if (entry is File) { if (entry is File) {
coverPath = entry.name; coverPath = entry.name;
@@ -301,25 +230,26 @@ class ImportComic {
return null; return null;
} }
return LocalComic( return LocalComic(
id: '0', id: id ?? '0',
title: name, title: name,
subtitle: '', subtitle: subtitle ?? '',
tags: [], tags: tags ?? [],
directory: directory.name, directory: directory.path,
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null, chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
cover: coverPath, cover: coverPath,
comicType: ComicType.local, comicType: ComicType.local,
downloadedChapters: chapters, downloadedChapters: chapters,
createdAt: DateTime.now(), createdAt: createTime ?? DateTime.now(),
); );
} }
static Future<void> _copyDirectories(Map<String, dynamic> data) async { static Future<Map<String, String>> _copyDirectories(Map<String, dynamic> data) async {
var toBeCopied = data['toBeCopied'] as List<String>; var toBeCopied = data['toBeCopied'] as List<String>;
var destination = data['destination'] as String; var destination = data['destination'] as String;
Map<String, String> result = {};
for (var dir in toBeCopied) { for (var dir in toBeCopied) {
var source = Directory(dir); var source = openDirectoryPlatform(dir);
var dest = Directory("$destination/${source.name}"); var dest = openDirectoryPlatform("$destination/${source.name}");
if (dest.existsSync()) { if (dest.existsSync()) {
// The destination directory already exists, and it is not managed by the app. // The destination directory already exists, and it is not managed by the app.
// Rename the old directory to avoid conflicts. // Rename the old directory to avoid conflicts.
@@ -330,6 +260,95 @@ class ImportComic {
} }
dest.createSync(); dest.createSync();
await copyDirectory(source, dest); await copyDirectory(source, dest);
result[source.path] = dest.path;
}
return result;
}
Future<Map<String?, List<LocalComic>>> _copyComicsToLocalDir(
Map<String?, List<LocalComic>> comics) async {
var destPath = LocalManager().path;
Map<String?, List<LocalComic>> result = {};
for (var favoriteFolder in comics.keys) {
result[favoriteFolder] = comics[favoriteFolder]!
.where((c) => c.directory.startsWith(destPath))
.toList();
comics[favoriteFolder]!
.removeWhere((c) => c.directory.startsWith(destPath));
if (comics[favoriteFolder]!.isEmpty) {
continue;
}
try {
// copy the comics to the local directory
var pathMap = await compute<Map<String, dynamic>, Map<String, String>>(
_copyDirectories, {
'toBeCopied': comics[favoriteFolder]!.map((e) => e.directory).toList(),
'destination': destPath,
});
//Construct a new object since LocalComic.directory is a final String
for (var c in comics[favoriteFolder]!) {
result[favoriteFolder]!.add(
LocalComic(
id: c.id,
title: c.title,
subtitle: c.subtitle,
tags: c.tags,
directory: pathMap[c.directory]!,
chapters: c.chapters,
cover: c.cover,
comicType: c.comicType,
downloadedChapters: c.downloadedChapters,
createdAt: c.createdAt
)
);
}
} catch (e) {
App.rootContext.showMessage(message: "Failed to copy comics".tl);
Log.error("Import Comic", e.toString());
return result;
}
}
return result;
}
Future<bool> registerComics(Map<String?, List<LocalComic>> importedComics, bool copy) async {
try {
if (copy) {
importedComics = await _copyComicsToLocalDir(importedComics);
}
int importedCount = 0;
for (var folder in importedComics.keys) {
for (var comic in importedComics[folder]!) {
var id = LocalManager().findValidId(ComicType.local);
LocalManager().add(comic, id);
importedCount++;
if (folder != null) {
LocalFavoritesManager().addComic(
folder,
FavoriteItem(
id: id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
favoriteTime: comic.createdAt
)
);
} }
} }
} }
App.rootContext.showMessage(
message: "Imported @a comics".tlParams({
'a': importedCount,
}));
} catch(e) {
App.rootContext.showMessage(message: "Failed to register comics".tl);
Log.error("Import Comic", e.toString());
return false;
}
return true;
}
}

View File

@@ -4,6 +4,7 @@ import 'dart:isolate';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_file_dialog/flutter_file_dialog.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart';
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -80,7 +81,7 @@ extension DirectoryExtension on Directory {
int total = 0; int total = 0;
for (var f in listSync(recursive: true)) { for (var f in listSync(recursive: true)) {
if (FileSystemEntity.typeSync(f.path) == FileSystemEntityType.file) { if (FileSystemEntity.typeSync(f.path) == FileSystemEntityType.file) {
total += await File(f.path).length(); total += await openFilePlatform(f.path).length();
} }
} }
return total; return total;
@@ -92,7 +93,7 @@ extension DirectoryExtension on Directory {
} }
File joinFile(String name) { File joinFile(String name) {
return File(FilePath.join(path, name)); return openFilePlatform(FilePath.join(path, name));
} }
} }
@@ -130,7 +131,7 @@ Future<void> copyDirectory(Directory source, Directory destination) async {
if (content is File) { if (content is File) {
content.copySync(newPath); content.copySync(newPath);
} else if (content is Directory) { } else if (content is Directory) {
Directory newDirectory = Directory(newPath); Directory newDirectory = openDirectoryPlatform(newPath);
newDirectory.createSync(); newDirectory.createSync();
copyDirectory(content.absolute, newDirectory.absolute); copyDirectory(content.absolute, newDirectory.absolute);
} }
@@ -146,11 +147,11 @@ Future<void> copyDirectoryIsolate(
String findValidDirectoryName(String path, String directory) { String findValidDirectoryName(String path, String directory) {
var name = sanitizeFileName(directory); var name = sanitizeFileName(directory);
var dir = Directory("$path/$name"); var dir = openDirectoryPlatform("$path/$name");
var i = 1; var i = 1;
while (dir.existsSync() && dir.listSync().isNotEmpty) { while (dir.existsSync() && dir.listSync().isNotEmpty) {
name = sanitizeFileName("$directory($i)"); name = sanitizeFileName("$directory($i)");
dir = Directory("$path/$name"); dir = openDirectoryPlatform("$path/$name");
i++; i++;
} }
return name; return name;
@@ -180,14 +181,14 @@ class DirectoryPicker {
if (App.isWindows || App.isLinux) { if (App.isWindows || App.isLinux) {
directory = await file_selector.getDirectoryPath(); directory = await file_selector.getDirectoryPath();
} else if (App.isAndroid) { } else if (App.isAndroid) {
directory = await _methodChannel.invokeMethod<String?>("getDirectoryPath"); directory = (await AndroidDirectory.pickDirectory())?.path;
} else { } else {
// ios, macos // ios, macos
directory = await _methodChannel.invokeMethod<String?>("getDirectoryPath"); directory = await _methodChannel.invokeMethod<String?>("getDirectoryPath");
} }
if (directory == null) return null; if (directory == null) return null;
_finalizer.attach(this, directory); _finalizer.attach(this, directory);
return Directory(directory); return openDirectoryPlatform(directory);
} finally { } finally {
Future.delayed(const Duration(milliseconds: 100), () { Future.delayed(const Duration(milliseconds: 100), () {
IO._isSelectingFiles = false; IO._isSelectingFiles = false;
@@ -310,6 +311,30 @@ Future<void> saveFile(
} }
} }
Directory openDirectoryPlatform(String path) {
if(App.isAndroid) {
var dir = AndroidDirectory.fromPathSync(path);
if(dir == null) {
return Directory(path);
}
return dir;
} else {
return Directory(path);
}
}
File openFilePlatform(String path) {
if(App.isAndroid) {
var f = AndroidFile.fromPathSync(path);
if(f == null) {
return File(path);
}
return f;
} else {
return File(path);
}
}
class Share { class Share {
static void shareFile({ static void shareFile({
required Uint8List data, required Uint8List data,

View File

@@ -389,6 +389,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.1" version: "2.5.1"
flutter_saf:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "51a27e2ca0e05becfb8ee3a506294dc4460721a8"
url: "https://github.com/pkuislm/flutter_saf.git"
source: git
version: "0.0.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter

View File

@@ -65,6 +65,10 @@ dependencies:
ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1 ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1
battery_plus: ^6.2.0 battery_plus: ^6.2.0
local_auth: ^2.3.0 local_auth: ^2.3.0
flutter_saf:
git:
url: https://github.com/pkuislm/flutter_saf.git
ref: 829a566b738a26ea98e523807f49838e21308543
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: