diff --git a/android/app/src/main/kotlin/com/github/wgh136/venera/MainActivity.kt b/android/app/src/main/kotlin/com/github/wgh136/venera/MainActivity.kt index db2781e..4a3f677 100644 --- a/android/app/src/main/kotlin/com/github/wgh136/venera/MainActivity.kt +++ b/android/app/src/main/kotlin/com/github/wgh136/venera/MainActivity.kt @@ -8,7 +8,6 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Environment -import android.provider.DocumentsContract import android.provider.Settings import android.view.KeyEvent import androidx.activity.result.ActivityResultCallback @@ -96,11 +95,7 @@ class MainActivity : FlutterFragmentActivity() { if (pickedDirectoryUri == null) res.success(null) else - try { - res.success(onPickedDirectory(pickedDirectoryUri)) - } catch (e: Exception) { - res.error("Failed to Copy Files", e.toString(), null) - } + onPickedDirectory(pickedDirectoryUri, res) } } @@ -134,8 +129,9 @@ class MainActivity : FlutterFragmentActivity() { } val selectFileChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/select_file") - selectFileChannel.setMethodCallHandler { _, res -> - openFile(res) + selectFileChannel.setMethodCallHandler { req, res -> + val mimeType = req.arguments() + openFile(res, mimeType!!) } } @@ -166,26 +162,40 @@ class MainActivity : FlutterFragmentActivity() { return super.onKeyDown(keyCode, event) } - /// copy the directory to tmp directory, return copied directory - private fun onPickedDirectory(uri: Uri): String { - if (!hasStoragePermission()) { - // dart:io cannot access the directory without permission. - // so we need to copy the directory to cache directory - val contentResolver = contentResolver - var tmp = cacheDir - tmp = File(tmp, "getDirectoryPathTemp") - tmp.mkdir() - Thread { - copyDirectory(contentResolver, uri, tmp) - }.start() - - return tmp.absolutePath - } else { - val docId = DocumentsContract.getTreeDocumentId(uri) - val split: Array = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - return if ((split.size >= 2) && (split[1] != null)) split[1]!! - else File.separator + /// Ensure that the directory is accessible by dart:io + private fun onPickedDirectory(uri: Uri, result: MethodChannel.Result) { + if (hasStoragePermission()) { + var plain = uri.toString() + if(plain.contains("%3A")) { + plain = Uri.decode(plain) + } + val externalStoragePrefix = "content://com.android.externalstorage.documents/tree/primary:"; + if(plain.startsWith(externalStoragePrefix)) { + val path = plain.substring(externalStoragePrefix.length) + result.success(Environment.getExternalStorageDirectory().absolutePath + "/" + path) + } + // The uri cannot be parsed to plain path, use copy method } + // dart:io cannot access the directory without permission. + // so we need to copy the directory to cache directory + val contentResolver = contentResolver + var tmp = cacheDir + var dirName = DocumentFile.fromTreeUri(this, uri)?.name + tmp = File(tmp, dirName!!) + if(tmp.exists()) { + tmp.deleteRecursively() + } + tmp.mkdir() + Thread { + try { + copyDirectory(contentResolver, uri, tmp) + result.success(tmp.absolutePath) + } + catch (e: Exception) { + result.error("copy error", e.message, null) + } + }.start() + } private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) { @@ -197,11 +207,12 @@ class MainActivity : FlutterFragmentActivity() { copyDirectory(resolver, file.uri, newDir) } else { val newFile = File(destDir, file.name!!) - val inputStream = resolver.openInputStream(file.uri) ?: return - val outputStream = FileOutputStream(newFile) - inputStream.copyTo(outputStream) - inputStream.close() - outputStream.close() + resolver.openInputStream(file.uri)?.use { input -> + FileOutputStream(newFile).use { output -> + input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE) + output.flush() + } + } } } } @@ -277,10 +288,10 @@ class MainActivity : FlutterFragmentActivity() { } } - private fun openFile(result: MethodChannel.Result) { + private fun openFile(result: MethodChannel.Result, mimeType: String) { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "*/*" + intent.type = mimeType startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ activityResult -> if (activityResult.resultCode != Activity.RESULT_OK) { result.success(null) @@ -312,20 +323,9 @@ class MainActivity : FlutterFragmentActivity() { // ignore } } - // copy file to cache directory - val cacheDir = cacheDir - val newFile = File(cacheDir, fileName) - val inputStream = contentResolver.openInputStream(uri) - if (inputStream == null) { - result.success(null) - return@startContractForResult - } - val outputStream = FileOutputStream(newFile) - inputStream.copyTo(outputStream) - inputStream.close() - outputStream.close() - // send file path to flutter - result.success(newFile.absolutePath) + // use copy method + val filePath = FileUtils.getPathFromCopyOfFileFromUri(this, uri) + result.success(filePath) } } } diff --git a/assets/translation.json b/assets/translation.json index f56d9f9..0825140 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -231,7 +231,8 @@ "Please check your settings": "请检查您的设置", "No Category Pages": "没有分类页面", "Chapter @ep": "第 @ep 章", - "Page @page": "第 @page 页" + "Page @page": "第 @page 页", + "Also remove files on disk": "同时删除磁盘上的文件" }, "zh_TW": { "Home": "首頁", @@ -465,6 +466,7 @@ "Please check your settings": "請檢查您的設定", "No Category Pages": "沒有分類頁面", "Chapter @ep": "第 @ep 章", - "Page @page": "第 @page 頁" + "Page @page": "第 @page 頁", + "Also remove files on disk": "同時刪除磁盤上的文件" } } \ No newline at end of file diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 16b8247..803d89a 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -453,7 +453,6 @@ class LocalManager with ChangeNotifier { if(HistoryManager().findSync(c.id, c.comicType) != null) { HistoryManager().remove(c.id, c.comicType); } - assert(c.comicType == ComicType.local); var folders = LocalFavoritesManager().find(c.id, c.comicType); for (var f in folders) { LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType); diff --git a/lib/main.dart b/lib/main.dart index 2000267..f4fb9c8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/main_page.dart'; import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/app_links.dart'; +import 'package:venera/utils/io.dart'; import 'package:window_manager/window_manager.dart'; import 'components/components.dart'; import 'components/window_frame.dart'; @@ -81,7 +82,7 @@ class _MyAppState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - if(!App.isMobile) { + if (!App.isMobile) { return; } if (state == AppLifecycleState.inactive && hideContentOverlay == null) { @@ -104,7 +105,8 @@ class _MyAppState extends State with WidgetsBindingObserver { } if (state == AppLifecycleState.hidden && appdata.settings['authorizationRequired'] && - !isAuthPageActive) { + !isAuthPageActive && + !IO.isSelectingFiles) { isAuthPageActive = true; App.rootContext.to( () => AuthPage( diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 097b9de..75e637e 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -4,26 +4,21 @@ import 'package:sliver_tools/sliver_tools.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; -import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/local.dart'; -import 'package:venera/foundation/log.dart'; import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/history_page.dart'; import 'package:venera/pages/search_page.dart'; -import 'package:venera/utils/cbz.dart'; import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/ext.dart'; -import 'package:venera/utils/io.dart'; +import 'package:venera/utils/import_comic.dart'; import 'package:venera/utils/translations.dart'; -import 'package:sqlite3/sqlite3.dart' as sql; -import 'dart:math'; import 'local_comics_page.dart'; @@ -624,323 +619,26 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { } void selectAndImport() async { - if (type == 2) { - var xFile = await selectFile(ext: ['cbz']); - var controller = showLoadingDialog(context, allowCancel: false); - try { - var cache = FilePath.join(App.cachePath, xFile?.name ?? 'temp.cbz'); - await xFile!.saveTo(cache); - var comic = await CBZ.import(File(cache)); - 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, - )); - } - await File(cache).deleteIgnoreError(); - } catch (e, s) { - Log.error("Import Comic", e.toString(), s); - context.showMessage(message: e.toString()); - } - controller.close(); - return; - } else if (type == 3) { - var dbFile = await selectFile(ext: ['db']); - final picker = DirectoryPicker(); - final comicSrc = await picker.pickDirectory(); - if (dbFile == null || comicSrc == null) { - return; - } - - bool cancelled = false; - var controller = showLoadingDialog(context, onCancel: () { cancelled = true; }); - - try { - var cache = FilePath.join(App.cachePath, dbFile.name); - await dbFile.saveTo(cache); - var db = sql.sqlite3.open(cache); - - Future addTagComics(String destFolder, List comics) async { - for(var comic in comics) { - if(cancelled) { - return; - } - var comicDir = Directory(FilePath.join(comicSrc.path, comic['DIRNAME'] as String)); - if(!(await comicDir.exists())) { - continue; - } - String titleJP = 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"); - 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 - ), - ); - } - } - - //default folder - { - var defaultFolderName = '(EhViewer)Default'.tl; - 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 folders = db.select(""" - SELECT * FROM DOWNLOAD_LABELS; - """); - - for (var folder in folders) { - if(cancelled) { - break; - } - var label = folder["LABEL"] as String; - var folderName = '(EhViewer)$label'; - if(!LocalFavoritesManager().existsFolder(folderName)) { - LocalFavoritesManager().createFolder(folderName); - } - 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 - ORDER BY DL.TIME DESC - """, [label]).toList(); - await addTagComics(folderName, comicList); - } - db.dispose(); - await File(cache).deleteIgnoreError(); - } catch (e, s) { - Log.error("Import Comic", e.toString(), s); - context.showMessage(message: e.toString()); - } - controller.close(); - return; - } height = key.currentContext!.size!.height; setState(() { loading = true; }); - final picker = DirectoryPicker(); - final path = await picker.pickDirectory(); - if (!loading) { - picker.dispose(); - return; + 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); } - if (path == null) { + if(result) { + context.pop(); + } else { setState(() { loading = false; }); - return; } - Map comics = {}; - if (type == 0) { - var result = await checkSingleComic(path); - if (result != null) { - comics[path] = result; - } else { - context.showMessage(message: "Invalid Comic".tl); - setState(() { - loading = false; - }); - return; - } - } else { - await for (var entry in path.list()) { - if (entry is Directory) { - var result = await checkSingleComic(entry); - if (result != null) { - comics[entry] = result; - } - } - } - } - 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) { - context.showMessage(message: "Failed to import comics".tl); - Log.error("Import Comic", e.toString()); - setState(() { - loading = false; - }); - return; - } - } - 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, - )); - } - } - context.pop(); - context.showMessage( - message: "Imported @a comics".tlParams({ - 'a': comics.length, - })); - } - - static _copyDirectories(Map data) { - var toBeCopied = data['toBeCopied'] as List; - var destination = data['destination'] as String; - for (var dir in toBeCopied) { - var source = Directory(dir); - var dest = Directory("$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. - Log.info("Import Comic", - "Directory already exists: ${source.name}\nRenaming the old directory."); - dest.rename( - findValidDirectoryName(dest.parent.path, "${dest.path}_old")); - } - dest.createSync(); - copyDirectory(source, dest); - } - } - - Future checkSingleComic(Directory directory) async { - if (!(await directory.exists())) return null; - var name = directory.name; - if (LocalManager().findByName(name) != null) { - Log.info("Import Comic", "Comic already exists: $name"); - return null; - } - bool hasChapters = false; - var chapters = []; - var coverPath = ''; // relative path to the cover image - await for (var entry in directory.list()) { - if (entry is Directory) { - hasChapters = true; - chapters.add(entry.name); - await for (var file in entry.list()) { - if (file is Directory) { - Log.info("Import Comic", - "Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory."); - return null; - } - } - } 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; - } - } - } - chapters.sort(); - if (hasChapters && coverPath == '') { - // use the first image in the first chapter as the cover - var firstChapter = Directory('${directory.path}/${chapters.first}'); - await for (var entry in firstChapter.list()) { - if (entry is File) { - coverPath = entry.name; - break; - } - } - } - if (coverPath == '') { - Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found."); - return null; - } - return LocalComic( - id: '0', - title: name, - subtitle: '', - tags: [], - directory: directory.name, - chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null, - cover: coverPath, - comicType: ComicType.local, - downloadedChapters: chapters, - createdAt: DateTime.now(), - ); } } diff --git a/lib/pages/local_comics_page.dart b/lib/pages/local_comics_page.dart index 244b8b7..5c5aa21 100644 --- a/lib/pages/local_comics_page.dart +++ b/lib/pages/local_comics_page.dart @@ -298,24 +298,16 @@ class _LocalComicsPageState extends State { return StatefulBuilder(builder: (context, state) { return ContentDialog( title: "Delete".tl, - content: Column( - children: [ - Text("Delete selected comics?".tl) - .paddingVertical(8), - Transform.scale( - scale: 0.9, - child: CheckboxListTile( - title: Text( - "Also remove files on disk".tl), - value: removeComicFile, - onChanged: (v) { - state(() { - removeComicFile = - !removeComicFile; - }); - })), - ], - ).paddingHorizontal(16).paddingVertical(8), + content: CheckboxListTile( + title: + Text("Also remove files on disk".tl), + value: removeComicFile, + onChanged: (v) { + state(() { + removeComicFile = !removeComicFile; + }); + }, + ), actions: [ FilledButton( onPressed: () { @@ -379,12 +371,12 @@ class _LocalComicsPageState extends State { return PopScope( canPop: !multiSelectMode && !searchMode, onPopInvokedWithResult: (didPop, result) { - if(multiSelectMode) { + if (multiSelectMode) { setState(() { multiSelectMode = false; selectedComics.clear(); }); - } else if(searchMode) { + } else if (searchMode) { setState(() { searchMode = false; keyword = ""; diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart index cef23ce..4458b45 100644 --- a/lib/utils/cbz.dart +++ b/lib/utils/cbz.dart @@ -86,6 +86,9 @@ abstract class CBZ { var ext = e.path.split('.').last; return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); }); + if(files.isEmpty) { + throw Exception('No images found in the archive'); + } files.sort((a, b) => a.path.compareTo(b.path)); var coverFile = files.firstWhereOrNull( (element) => diff --git a/lib/utils/file_type.dart b/lib/utils/file_type.dart index 72cdba4..42f8f77 100644 --- a/lib/utils/file_type.dart +++ b/lib/utils/file_type.dart @@ -10,6 +10,9 @@ class FileType { if(ext.startsWith('.')) { ext = ext.substring(1); } + if(ext == 'cbz') { + return const FileType('.cbz', 'application/octet-stream'); + } var mime = lookupMimeType('no-file.$ext'); return FileType(".$ext", mime ?? 'application/octet-stream'); } diff --git a/lib/utils/import_comic.dart b/lib/utils/import_comic.dart new file mode 100644 index 0000000..a5e40b6 --- /dev/null +++ b/lib/utils/import_comic.dart @@ -0,0 +1,338 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:venera/components/components.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/comic_type.dart'; +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/translations.dart'; +import 'cbz.dart'; +import 'io.dart'; + +class ImportComic { + final String? selectedFolder; + + const ImportComic({this.selectedFolder}); + + Future cbz() async { + var xFile = await selectFile(ext: ['cbz']); + if(xFile == null) { + return false; + } + var controller = showLoadingDialog(App.rootContext, allowCancel: false); + var isSuccessful = false; + try { + var cache = FilePath.join(App.cachePath, xFile.name); + await xFile.saveTo(cache); + var comic = await CBZ.import(File(cache)); + 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, + ), + ); + } + 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; + } + + Future ehViewer() async { + var dbFile = await selectFile(ext: ['db']); + final picker = DirectoryPicker(); + final comicSrc = await picker.pickDirectory(); + if (dbFile == null || comicSrc == null) { + return false; + } + + bool cancelled = false; + 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); + + Future addTagComics(String destFolder, List comics) async { + for (var comic in comics) { + if (cancelled) { + return; + } + var comicDir = Directory( + FilePath.join(comicSrc.path, comic['DIRNAME'] as String)); + if (!(await comicDir.exists())) { + continue; + } + String titleJP = + 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"); + 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, + ), + ); + } + } + + { + 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 folders = db.select(""" + SELECT * FROM DOWNLOAD_LABELS; + """); + + for (var folder in folders) { + if (cancelled) { + break; + } + var label = folder["LABEL"] as String; + var folderName = '(EhViewer)$label'; + if (!LocalFavoritesManager().existsFolder(folderName)) { + LocalFavoritesManager().createFolder(folderName); + } + 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 + ORDER BY DL.TIME DESC + """, [label]).toList(); + await addTagComics(folderName, comicList); + } + db.dispose(); + 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; + } + + Future directory(bool single) async { + final picker = DirectoryPicker(); + final path = await picker.pickDirectory(); + if (path == null) { + return false; + } + Map comics = {}; + if (single) { + var result = await _checkSingleComic(path); + if (result != null) { + comics[path] = result; + } 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; + } + } + } + } + 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; + } + + Future _checkSingleComic(Directory directory) async { + if (!(await directory.exists())) return null; + var name = directory.name; + if (LocalManager().findByName(name) != null) { + Log.info("Import Comic", "Comic already exists: $name"); + return null; + } + bool hasChapters = false; + var chapters = []; + var coverPath = ''; // relative path to the cover image + for (var entry in directory.listSync()) { + if (entry is Directory) { + hasChapters = true; + chapters.add(entry.name); + await for (var file in entry.list()) { + if (file is Directory) { + Log.info("Import Comic", + "Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory."); + return null; + } + } + } 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; + } + } + } + chapters.sort(); + if (hasChapters && coverPath == '') { + // use the first image in the first chapter as the cover + var firstChapter = Directory('${directory.path}/${chapters.first}'); + await for (var entry in firstChapter.list()) { + if (entry is File) { + coverPath = entry.name; + break; + } + } + } + if (coverPath == '') { + Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found."); + return null; + } + return LocalComic( + id: '0', + title: name, + subtitle: '', + tags: [], + directory: directory.name, + chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null, + cover: coverPath, + comicType: ComicType.local, + downloadedChapters: chapters, + createdAt: DateTime.now(), + ); + } + + static _copyDirectories(Map data) { + var toBeCopied = data['toBeCopied'] as List; + var destination = data['destination'] as String; + for (var dir in toBeCopied) { + var source = Directory(dir); + var dest = Directory("$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. + Log.info("Import Comic", + "Directory already exists: ${source.name}\nRenaming the old directory."); + dest.rename( + findValidDirectoryName(dest.parent.path, "${dest.path}_old")); + } + dest.createSync(); + copyDirectory(source, dest); + } + } +} diff --git a/lib/utils/io.dart b/lib/utils/io.dart index 5e41265..aafeb52 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -14,6 +14,21 @@ import 'package:venera/utils/file_type.dart'; export 'dart:io'; export 'dart:typed_data'; +class IO { + /// A global flag used to indicate whether the app is selecting files. + /// + /// Select file and other similar file operations will launch external programs, + /// causing the app to lose focus. AppLifecycleState will be set to paused. + static bool get isSelectingFiles => _isSelectingFiles; + + static bool _isSelectingFiles = false; +} + +/// A finalizer that can be used to dispose resources related to file operations. +final _finalizer = Finalizer((e) { + e(); +}); + class FilePath { const FilePath._(); @@ -147,24 +162,35 @@ String findValidDirectoryName(String path, String directory) { } class DirectoryPicker { + DirectoryPicker() { + _finalizer.attach(this, dispose); + } + String? _directory; final _methodChannel = const MethodChannel("venera/method_channel"); Future pickDirectory() async { - if (App.isWindows || App.isLinux) { - var d = await file_selector.getDirectoryPath(); - _directory = d; - return d == null ? null : Directory(d); - } else if (App.isAndroid) { - var d = await _methodChannel.invokeMethod("getDirectoryPath"); - _directory = d; - return d == null ? null : Directory(d); - } else { - // ios, macos - var d = await _methodChannel.invokeMethod("getDirectoryPath"); - _directory = d; - return d == null ? null : Directory(d); + IO._isSelectingFiles = true; + try { + if (App.isWindows || App.isLinux) { + var d = await file_selector.getDirectoryPath(); + _directory = d; + return d == null ? null : Directory(d); + } else if (App.isAndroid) { + var d = await _methodChannel.invokeMethod("getDirectoryPath"); + _directory = d; + return d == null ? null : Directory(d); + } else { + // ios, macos + var d = await _methodChannel.invokeMethod("getDirectoryPath"); + _directory = d; + return d == null ? null : Directory(d); + } + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); } } @@ -172,11 +198,15 @@ class DirectoryPicker { if (_directory == null) { return; } - if (App.isAndroid && _directory != null) { - return Directory(_directory!).deleteIgnoreError(recursive: true); + if (App.isAndroid && + _directory != null && + _directory!.startsWith(App.cachePath)) { + await Directory(_directory!).deleteIgnoreError(recursive: true); + _directory = null; } if (App.isIOS || App.isMacOS) { await _methodChannel.invokeMethod("stopAccessingSecurityScopedResource"); + _directory = null; } } } @@ -186,53 +216,73 @@ class IOSDirectoryPicker { // 调用 iOS 目录选择方法 static Future selectDirectory() async { + IO._isSelectingFiles = true; try { final String? path = await _channel.invokeMethod('selectDirectory'); return path; } catch (e) { // 返回报错信息 return e.toString(); + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); } } } Future selectFile({required List ext}) async { - var extensions = App.isMacOS || App.isIOS ? null : ext; - if (App.isAndroid) { - for (var e in ext) { - var fileType = FileType.fromExtension(e); - if (fileType.mime == "application/octet-stream") { - extensions = null; - break; - } - } - } - file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup( - label: 'files', - extensions: extensions, - ); - file_selector.XFile? file; - if (extensions == null && App.isAndroid) { - const selectFileChannel = MethodChannel("venera/select_file"); - var filePath = await selectFileChannel.invokeMethod("selectFile"); - if (filePath == null) return null; - file = file_selector.XFile(filePath); - } else { - file = await file_selector.openFile( - acceptedTypeGroups: [typeGroup], + IO._isSelectingFiles = true; + try { + var extensions = App.isMacOS || App.isIOS ? null : ext; + file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup( + label: 'files', + extensions: extensions, ); - if (file == null) return null; + file_selector.XFile? file; + if (App.isAndroid) { + const selectFileChannel = MethodChannel("venera/select_file"); + String mimeType = "*/*"; + if(ext.length == 1) { + mimeType = FileType.fromExtension(ext[0]).mime; + if(mimeType == "application/octet-stream") { + mimeType = "*/*"; + } + } + var filePath = await selectFileChannel.invokeMethod( + "selectFile", + mimeType, + ); + if (filePath == null) return null; + file = _AndroidFileSelectResult(filePath); + } else { + file = await file_selector.openFile( + acceptedTypeGroups: [typeGroup], + ); + if (file == null) return null; + } + if (!ext.contains(file.path.split(".").last)) { + App.rootContext.showMessage(message: "Invalid file type"); + return null; + } + return file; + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); } - if (!ext.contains(file.path.split(".").last)) { - App.rootContext.showMessage(message: "Invalid file type"); - return null; - } - return file; } Future selectDirectory() async { - var path = await file_selector.getDirectoryPath(); - return path; + IO._isSelectingFiles = true; + try { + var path = await file_selector.getDirectoryPath(); + return path; + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); + } } // selectDirectoryIOS @@ -245,25 +295,32 @@ Future saveFile( if (data == null && file == null) { throw Exception("data and file cannot be null at the same time"); } - if (data != null) { - var cache = FilePath.join(App.cachePath, filename); - if (File(cache).existsSync()) { - File(cache).deleteSync(); + IO._isSelectingFiles = true; + try { + if (data != null) { + var cache = FilePath.join(App.cachePath, filename); + if (File(cache).existsSync()) { + File(cache).deleteSync(); + } + await File(cache).writeAsBytes(data); + file = File(cache); } - await File(cache).writeAsBytes(data); - file = File(cache); - } - if (App.isMobile) { - final params = SaveFileDialogParams(sourceFilePath: file!.path); - await FlutterFileDialog.saveFile(params: params); - } else { - final result = await file_selector.getSaveLocation( - suggestedName: filename, - ); - if (result != null) { - var xFile = file_selector.XFile(file!.path); - await xFile.saveTo(result.path); + if (App.isMobile) { + final params = SaveFileDialogParams(sourceFilePath: file!.path); + await FlutterFileDialog.saveFile(params: params); + } else { + final result = await file_selector.getSaveLocation( + suggestedName: filename, + ); + if (result != null) { + var xFile = file_selector.XFile(file!.path); + await xFile.saveTo(result.path); + } } + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); } } @@ -302,3 +359,16 @@ String bytesToReadableString(int bytes) { return "${(bytes / 1024 / 1024 / 1024).toStringAsFixed(2)} GB"; } } + +class _AndroidFileSelectResult extends s.XFile { + _AndroidFileSelectResult(super.path) { + _finalizer.attach(this, dispose); + } + + void dispose() { + print("dispose $path"); + if (path.startsWith(App.cachePath)) { + File(path).deleteIgnoreError(); + } + } +}