diff --git a/android/.gitignore b/android/.gitignore index 6f56801..1028187 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks +/app/.cxx/ diff --git a/android/app/build.gradle b/android/app/build.gradle index 59f3a36..a544de7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -34,6 +34,8 @@ android { splits{ abi { + reset() + include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' enable true universalApk true } diff --git a/assets/translation.json b/assets/translation.json index 4e1e046..788b30b 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -239,6 +239,9 @@ "Chapter @ep": "第 @ep 章", "Page @page": "第 @page 页", "Also remove files on disk": "同时删除磁盘上的文件", + "Copy to app local path": "将漫画复制到本地存储目录中", + "Delete all unavailable local favorite items": "删除所有无效的本地收藏", + "Deleted @a favorite items.": "已删除 @a 条无效收藏", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", "No new version available": "没有新版本可用" @@ -483,6 +486,9 @@ "Chapter @ep": "第 @ep 章", "Page @page": "第 @page 頁", "Also remove files on disk": "同時刪除磁盤上的文件", + "Copy to app local path": "將漫畫複製到本地儲存目錄中", + "Delete all unavailable local favorite items": "刪除所有無效的本地收藏", + "Deleted @a favorite items.": "已刪除 @a 條無效收藏", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", "No new version available": "沒有新版本可用" diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index e1a60fc..29eab03 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/image_provider/local_favorite_image.dart'; +import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'dart:io'; @@ -496,6 +497,22 @@ class LocalFavoritesManager with ChangeNotifier { notifyListeners(); } + Future 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 clearAll() async { _db.dispose(); File("${App.dataPath}/local_favorite.db").deleteSync(); diff --git a/lib/foundation/image_provider/cached_image.dart b/lib/foundation/image_provider/cached_image.dart index daf1338..a962f3d 100644 --- a/lib/foundation/image_provider/cached_image.dart +++ b/lib/foundation/image_provider/cached_image.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/network/images.dart'; +import 'package:venera/utils/io.dart'; import 'base_image_provider.dart'; import 'cached_image.dart' as image_provider; @@ -24,7 +25,7 @@ class CachedImageProvider @override Future load(StreamController chunkEvents) async { if(url.startsWith("file://")) { - var file = File(url.substring(7)); + var file = openFilePlatform(url.substring(7)); return file.readAsBytes(); } await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 1461727..2ff2a21 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -71,12 +71,13 @@ class LocalComic with HistoryMixin implements Comic { downloadedChapters = List.from(jsonDecode(row[8] as String)), createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int); - File get coverFile => File(FilePath.join( - LocalManager().path, - directory, + File get coverFile => openFilePlatform(FilePath.join( + baseDir, cover, )); + String get baseDir => directory.contains("/") ? directory : FilePath.join(LocalManager().path, directory); + @override String get description => ""; @@ -341,12 +342,12 @@ class LocalManager with ChangeNotifier { throw "Invalid ep"; } 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) { var cid = ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String); - directory = Directory(FilePath.join(directory.path, cid)); + directory = openDirectoryPlatform(FilePath.join(directory.path, cid)); } var files = []; await for (var entity in directory.list()) { @@ -392,10 +393,10 @@ class LocalManager with ChangeNotifier { String id, ComicType type, String name) async { var comic = find(id, type); if (comic != null) { - return Directory(FilePath.join(path, comic.directory)); + return openDirectoryPlatform(FilePath.join(path, comic.directory)); } 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) { @@ -454,7 +455,7 @@ class LocalManager with ChangeNotifier { void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) { if(removeFileOnDisk) { - var dir = Directory(FilePath.join(path, c.directory)); + var dir = openDirectoryPlatform(FilePath.join(path, c.directory)); dir.deleteIgnoreError(recursive: true); } //Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted. diff --git a/lib/init.dart b/lib/init.dart index a4262d1..0a9ce3c 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -1,3 +1,4 @@ +import 'package:flutter_saf/flutter_saf.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; @@ -12,6 +13,7 @@ import 'package:venera/utils/translations.dart'; import 'foundation/appdata.dart'; Future init() async { + await SAFTaskWorker().init(); await AppTranslation.init(); await appdata.init(); await App.init(); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 75e637e..49a4d0d 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:venera/components/components.dart'; @@ -497,6 +496,10 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { String? selectedFolder; + bool copyToLocalFolder = true; + + bool cancelled = false; + @override void dispose() { loading = false; @@ -530,22 +533,23 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { ), ) : Column( - key: key, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 600), - ...List.generate(importMethods.length, (index) { - return RadioListTile( - title: Text(importMethods[index]), - value: index, - groupValue: type, - onChanged: (value) { - setState(() { - type = value as int; - }); - }, - ); - }), + key: key, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 600), + ...List.generate(importMethods.length, (index) { + return RadioListTile( + title: Text(importMethods[index]), + value: index, + groupValue: type, + onChanged: (value) { + setState(() { + type = value as int; + }); + }, + ); + }), + if(type != 3) ListTile( title: Text("Add to favorites".tl), trailing: Select( @@ -559,10 +563,19 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { }, ), ).paddingHorizontal(8), - const SizedBox(height: 8), - Text(info).paddingHorizontal(24), - ], - ), + CheckboxListTile( + enabled: true, + title: Text("Copy to app local path".tl), + value: copyToLocalFolder, + onChanged:(v) { + setState(() { + copyToLocalFolder = !copyToLocalFolder; + }); + }).paddingHorizontal(8), + const SizedBox(height: 8), + Text(info).paddingHorizontal(24), + ], + ), actions: [ Button.text( child: Row( @@ -620,18 +633,20 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { void selectAndImport() async { height = key.currentContext!.size!.height; + setState(() { loading = true; }); - var importer = ImportComic(selectedFolder: selectedFolder); - var result = false; - if (type == 2) { - result = await importer.cbz(); - } else if (type == 3) { - result = await importer.ehViewer(); - } else { - result = await importer.directory(type == 0); - } + var importer = ImportComic( + selectedFolder: selectedFolder, + copyToLocal: copyToLocalFolder); + var result = switch(type) { + 0 => await importer.directory(true), + 1 => await importer.directory(false), + 2 => await importer.cbz(), + 3 => await importer.ehViewer(), + int() => true, + }; if(result) { context.pop(); } else { diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index d273eca..a202591 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -604,7 +604,7 @@ ImageProvider _createImageProvider(int page, BuildContext context) { var reader = context.reader; var imageKey = reader.images![page - 1]; if (imageKey.startsWith('file://')) { - return FileImage(File(imageKey.replaceFirst("file://", ''))); + return FileImage(openFilePlatform(imageKey.replaceFirst("file://", ''))); } else { return ReaderImageProvider( imageKey, diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 95043c3..804f32c 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -469,7 +469,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ImageProvider image; var imageKey = images[index]; if (imageKey.startsWith('file://')) { - image = FileImage(File(imageKey.replaceFirst("file://", ''))); + image = FileImage(openFilePlatform(imageKey.replaceFirst("file://", ''))); } else { image = ReaderImageProvider( imageKey, @@ -515,7 +515,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { } } if (imageKey.startsWith("file://")) { - return await File(imageKey.substring(7)).readAsBytes(); + return await openFilePlatform(imageKey.substring(7)).readAsBytes(); } else { return (await CacheManager().findCache( "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 147ad36..6a0c70a 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -86,29 +86,33 @@ Future checkUpdate() async { } Future checkUpdateUi([bool showMessageIfNoUpdate = true]) async { - var value = await checkUpdate(); - if (value) { - showDialog( - context: App.rootContext, - builder: (context) { - return ContentDialog( - title: "New version available".tl, - content: Text( - "A new version is available. Do you want to update now?".tl), - actions: [ - Button.text( - onPressed: () { - Navigator.pop(context); - launchUrlString( - "https://github.com/venera-app/venera/releases"); - }, - child: Text("Update".tl), - ), - ], - ); - }); - } else if (showMessageIfNoUpdate) { - App.rootContext.showMessage(message: "No new version available".tl); + try { + var value = await checkUpdate(); + if (value) { + showDialog( + context: App.rootContext, + builder: (context) { + return ContentDialog( + title: "New version available".tl, + content: Text( + "A new version is available. Do you want to update now?".tl), + actions: [ + Button.text( + onPressed: () { + Navigator.pop(context); + launchUrlString( + "https://github.com/venera-app/venera/releases"); + }, + child: Text("Update".tl), + ), + ], + ); + }); + } else if (showMessageIfNoUpdate) { + App.rootContext.showMessage(message: "No new version available".tl); + } + } catch (e, s) { + Log.error("Check Update", e.toString(), s); } } diff --git a/lib/pages/settings/local_favorites.dart b/lib/pages/settings/local_favorites.dart index 9cbce52..c35f9a3 100644 --- a/lib/pages/settings/local_favorites.dart +++ b/lib/pages/settings/local_favorites.dart @@ -38,6 +38,16 @@ class _LocalFavoritesSettingsState extends State { for (var e in LocalFavoritesManager().folderNames) e: e }, ).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(), ], ); } diff --git a/lib/utils/import_comic.dart b/lib/utils/import_comic.dart index 51990d9..cbcecbc 100644 --- a/lib/utils/import_comic.dart +++ b/lib/utils/import_comic.dart @@ -8,50 +8,40 @@ import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:sqlite3/sqlite3.dart' as sql; +import 'package:venera/utils/ext.dart'; import 'package:venera/utils/translations.dart'; import 'cbz.dart'; import 'io.dart'; class ImportComic { final String? selectedFolder; + final bool copyToLocal; - const ImportComic({this.selectedFolder}); + const ImportComic({this.selectedFolder, this.copyToLocal = true}); Future cbz() async { var file = await selectFile(ext: ['cbz']); + Map> imported = {}; if(file == null) { return false; } var controller = showLoadingDialog(App.rootContext, allowCancel: false); - var isSuccessful = false; try { var comic = await CBZ.import(File(file.path)); - 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, - ), - ); - } - isSuccessful = true; + imported[selectedFolder] = [comic]; } catch (e, s) { Log.error("Import Comic", e.toString(), s); App.rootContext.showMessage(message: e.toString()); } controller.close(); - return isSuccessful; + return registerComics(imported, true); } Future ehViewer() async { var dbFile = await selectFile(ext: ['db']); final picker = DirectoryPicker(); final comicSrc = await picker.pickDirectory(); + Map> imported = {}; if (dbFile == null || comicSrc == null) { return false; } @@ -60,129 +50,91 @@ class ImportComic { var controller = showLoadingDialog(App.rootContext, onCancel: () { cancelled = true; }); - bool isSuccessful = false; try { - var cache = FilePath.join(App.cachePath, dbFile.name); - await dbFile.saveTo(cache); - var db = sql.sqlite3.open(cache); + var db = sql.sqlite3.open(dbFile.path); - Future addTagComics(String destFolder, List comics) async { + Future> validateComics(List comics) async { + List imported = []; for (var comic in comics) { if (cancelled) { - return; + return imported; } - var comicDir = Directory( + var comicDir = openDirectoryPlatform( FilePath.join(comicSrc.path, comic['DIRNAME'] as String)); - if (!(await comicDir.exists())) { - continue; - } 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; - if (LocalManager().findByName(title) != null) { - Log.info("Import Comic", "Comic already exists: $title"); + int timeStamp = comic['TIME'] as int; + DateTime downloadTime = timeStamp != 0 + ? DateTime.fromMillisecondsSinceEpoch(timeStamp) + : DateTime.now(); + var comicObj = await _checkSingleComic(comicDir, + title: title, + tags: [ + //1 >> x + [ + "MISC", + "DOUJINSHI", + "MANGA", + "ARTISTCG", + "GAMECG", + "IMAGE SET", + "COSPLAY", + "ASIAN PORN", + "NON-H", + "WESTERN", + ][(log(comic['CATEGORY'] as int) / ln2).floor()] + ], + createTime: downloadTime); + if (comicObj == null) { continue; } - - 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(); - var comicObj = LocalComic( - id: LocalManager().findValidId(ComicType.local), - title: title, - subtitle: '', - tags: [ - //1 >> x - [ - "MISC", - "DOUJINSHI", - "MANGA", - "ARTISTCG", - "GAMECG", - "IMAGE SET", - "COSPLAY", - "ASIAN PORN", - "NON-H", - "WESTERN", - ][(log(comic['CATEGORY'] as int) / ln2).floor()] - ], - directory: comicDir.path, - chapters: null, - cover: coverURL, - 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 defaultFolderName = '(EhViewer)Default'; - if (!LocalFavoritesManager().existsFolder(defaultFolderName)) { - LocalFavoritesManager().createFolder(defaultFolderName); - } - 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 tags = [""]; + tags.addAll(db.select(""" + SELECT * FROM DOWNLOAD_LABELS LB + ORDER BY LB.TIME DESC; + """).map((r) => r['LABEL'] as String).toList()); - var folders = db.select(""" - SELECT * FROM DOWNLOAD_LABELS; - """); - - for (var folder in folders) { + for (var tag in tags) { if (cancelled) { break; } - var label = folder["LABEL"] as String; - var folderName = '(EhViewer)$label'; - if (!LocalFavoritesManager().existsFolder(folderName)) { - LocalFavoritesManager().createFolder(folderName); - } + var folderName = + tag == '' ? '(EhViewer)Default'.tl : '(EhViewer)$tag'; var comicList = db.select(""" SELECT * FROM DOWNLOAD_DIRNAME DN LEFT JOIN DOWNLOADS DL 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 - """, [label]).toList(); - await addTagComics(folderName, comicList); + """).toList(); + + var validComics = await validateComics(comicList); + imported[folderName] = validComics; + if (validComics.isNotEmpty && + !LocalFavoritesManager().existsFolder(folderName)) { + LocalFavoritesManager().createFolder(folderName); + } } db.dispose(); + + //Android specific + var cache = FilePath.join(App.cachePath, dbFile.name); await File(cache).deleteIgnoreError(); - isSuccessful = true; } catch (e, s) { Log.error("Import Comic", e.toString(), s); App.rootContext.showMessage(message: e.toString()); } controller.close(); - return isSuccessful; + if(cancelled) return false; + return registerComics(imported, copyToLocal); } Future directory(bool single) async { @@ -191,71 +143,43 @@ class ImportComic { if (path == null) { return false; } - Map comics = {}; - if (single) { - var result = await _checkSingleComic(path); - if (result != null) { - comics[path] = result; + Map> imported = {selectedFolder: []}; + try { + if (single) { + var result = await _checkSingleComic(path); + if (result != null) { + imported[selectedFolder]!.add(result); + } else { + App.rootContext.showMessage(message: "Invalid Comic".tl); + return false; + } } else { - App.rootContext.showMessage(message: "Invalid Comic".tl); - return false; - } - } else { - await for (var entry in path.list()) { - if (entry is Directory) { - var result = await _checkSingleComic(entry); - if (result != null) { - comics[entry] = result; + await for (var entry in path.list()) { + if (entry is Directory) { + var result = await _checkSingleComic(entry); + if (result != null) { + imported[selectedFolder]!.add(result); + } } } } + } catch (e, s) { + Log.error("Import Comic", e.toString(), s); + App.rootContext.showMessage(message: e.toString()); } - bool shouldCopy = true; - for (var comic in comics.keys) { - if (comic.parent.path == LocalManager().path) { - shouldCopy = false; - break; - } - } - if (shouldCopy && comics.isNotEmpty) { - try { - // copy the comics to the local directory - await compute, 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; + return registerComics(imported, copyToLocal); } - Future _checkSingleComic(Directory directory) async { + //Automatically search for cover image and chapters + Future _checkSingleComic(Directory directory, + {String? id, + String? title, + String? subtitle, + List? tags, + DateTime? createTime}) + async { if (!(await directory.exists())) return null; - var name = directory.name; + var name = title ?? directory.name; if (LocalManager().findByName(name) != null) { Log.info("Import Comic", "Comic already exists: $name"); return null; @@ -263,7 +187,8 @@ class ImportComic { bool hasChapters = false; var chapters = []; var coverPath = ''; // relative path to the cover image - for (var entry in directory.listSync()) { + var fileList = []; + await for (var entry in directory.list()) { if (entry is Directory) { hasChapters = true; chapters.add(entry.name); @@ -275,20 +200,24 @@ class ImportComic { } } } else if (entry is File) { - if (entry.name.startsWith('cover')) { - coverPath = entry.name; - } const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe']; - if (!coverPath.startsWith('cover') && - imageExtensions.contains(entry.extension)) { - coverPath = entry.name; + if (imageExtensions.contains(entry.extension)) { + fileList.add(entry.name); } } } + + if(fileList.isEmpty) { + return null; + } + + fileList.sort(); + coverPath = fileList.firstWhereOrNull((l) => l.startsWith('cover')) ?? fileList.first; + chapters.sort(); if (hasChapters && coverPath == '') { // 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()) { if (entry is File) { coverPath = entry.name; @@ -301,25 +230,26 @@ class ImportComic { return null; } return LocalComic( - id: '0', + id: id ?? '0', title: name, - subtitle: '', - tags: [], - directory: directory.name, + subtitle: subtitle ?? '', + tags: tags ?? [], + directory: directory.path, chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null, cover: coverPath, comicType: ComicType.local, downloadedChapters: chapters, - createdAt: DateTime.now(), + createdAt: createTime ?? DateTime.now(), ); } - static Future _copyDirectories(Map data) async { + static Future> _copyDirectories(Map data) async { var toBeCopied = data['toBeCopied'] as List; var destination = data['destination'] as String; + Map result = {}; for (var dir in toBeCopied) { - var source = Directory(dir); - var dest = Directory("$destination/${source.name}"); + var source = openDirectoryPlatform(dir); + var dest = openDirectoryPlatform("$destination/${source.name}"); if (dest.existsSync()) { // The destination directory already exists, and it is not managed by the app. // Rename the old directory to avoid conflicts. @@ -330,6 +260,95 @@ class ImportComic { } dest.createSync(); await copyDirectory(source, dest); + result[source.path] = dest.path; } + return result; + } + + Future>> _copyComicsToLocalDir( + Map> comics) async { + var destPath = LocalManager().path; + Map> 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>( + _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 registerComics(Map> 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; } } diff --git a/lib/utils/io.dart b/lib/utils/io.dart index 6593f75..50ef88a 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; import 'package:flutter/services.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/utils/ext.dart'; import 'package:path/path.dart' as p; @@ -80,7 +81,7 @@ extension DirectoryExtension on Directory { int total = 0; for (var f in listSync(recursive: true)) { if (FileSystemEntity.typeSync(f.path) == FileSystemEntityType.file) { - total += await File(f.path).length(); + total += await openFilePlatform(f.path).length(); } } return total; @@ -92,7 +93,7 @@ extension DirectoryExtension on Directory { } File joinFile(String name) { - return File(FilePath.join(path, name)); + return openFilePlatform(FilePath.join(path, name)); } } @@ -130,7 +131,7 @@ Future copyDirectory(Directory source, Directory destination) async { if (content is File) { content.copySync(newPath); } else if (content is Directory) { - Directory newDirectory = Directory(newPath); + Directory newDirectory = openDirectoryPlatform(newPath); newDirectory.createSync(); copyDirectory(content.absolute, newDirectory.absolute); } @@ -146,11 +147,11 @@ Future copyDirectoryIsolate( String findValidDirectoryName(String path, String directory) { var name = sanitizeFileName(directory); - var dir = Directory("$path/$name"); + var dir = openDirectoryPlatform("$path/$name"); var i = 1; while (dir.existsSync() && dir.listSync().isNotEmpty) { name = sanitizeFileName("$directory($i)"); - dir = Directory("$path/$name"); + dir = openDirectoryPlatform("$path/$name"); i++; } return name; @@ -180,14 +181,14 @@ class DirectoryPicker { if (App.isWindows || App.isLinux) { directory = await file_selector.getDirectoryPath(); } else if (App.isAndroid) { - directory = await _methodChannel.invokeMethod("getDirectoryPath"); + directory = (await AndroidDirectory.pickDirectory())?.path; } else { // ios, macos directory = await _methodChannel.invokeMethod("getDirectoryPath"); } if (directory == null) return null; _finalizer.attach(this, directory); - return Directory(directory); + return openDirectoryPlatform(directory); } finally { Future.delayed(const Duration(milliseconds: 100), () { IO._isSelectingFiles = false; @@ -310,6 +311,30 @@ Future 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 { static void shareFile({ required Uint8List data, diff --git a/pubspec.lock b/pubspec.lock index 5e8daa2..4a58d34 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -389,6 +389,15 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 1b1a23c..463d582 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,10 @@ dependencies: ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1 battery_plus: ^6.2.0 local_auth: ^2.3.0 + flutter_saf: + git: + url: https://github.com/pkuislm/flutter_saf.git + ref: 829a566b738a26ea98e523807f49838e21308543 dev_dependencies: flutter_test: