From e3af9c25f106bd83a808ba9571f22e4685340400 Mon Sep 17 00:00:00 2001 From: niuhuan Date: Thu, 14 Oct 2021 18:12:36 +0800 Subject: [PATCH] export all images to albums --- android/app/src/main/AndroidManifest.xml | 49 ++--- .../kotlin/niuhuan/pikapi/MainActivity.kt | 78 +++++++- go/pikapi/controller/export.go | 31 +++- go/pikapi/controller/pikapi.go | 4 +- go/pikapi/controller/special.go | 11 ++ go/pikapi/database/comic_center/center.go | 14 ++ lib/basic/Cross.dart | 22 ++- lib/basic/Method.dart | 6 + lib/basic/config/ChooserRoot.dart | 5 +- lib/basic/config/Platform.dart | 12 ++ lib/basic/config/Themes.dart | 33 ++-- lib/screens/DownloadExportToFileScreen.dart | 169 +++++++++++------- lib/screens/InitScreen.dart | 2 + 13 files changed, 313 insertions(+), 123 deletions(-) create mode 100644 go/pikapi/controller/special.go create mode 100644 lib/basic/config/Platform.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3c8f758..680938a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,48 +1,49 @@ + package="niuhuan.pikapi"> - - - - - + + + + + + + android:icon="@mipmap/ic_launcher" + android:label="pikapi" + android:requestLegacyExternalStorage="true"> + + android:name=".MainActivity" + android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" + android:hardwareAccelerated="true" + android:launchMode="singleTop" + android:theme="@style/LaunchTheme" + android:windowSoftInputMode="adjustResize"> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> + android:name="io.flutter.embedding.android.SplashScreenDrawable" + android:resource="@drawable/launch_background" /> - - + + + android:name="flutterEmbedding" + android:value="2" /> diff --git a/android/app/src/main/kotlin/niuhuan/pikapi/MainActivity.kt b/android/app/src/main/kotlin/niuhuan/pikapi/MainActivity.kt index 3b22ab0..daf65f1 100644 --- a/android/app/src/main/kotlin/niuhuan/pikapi/MainActivity.kt +++ b/android/app/src/main/kotlin/niuhuan/pikapi/MainActivity.kt @@ -13,6 +13,7 @@ import android.provider.MediaStore import android.view.Display import android.view.KeyEvent import androidx.annotation.NonNull +import com.google.gson.Gson import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel @@ -22,7 +23,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.sync.Mutex import mobile.Mobile +import java.io.File +import java.io.FileInputStream import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock class MainActivity : FlutterActivity() { @@ -60,8 +66,12 @@ class MainActivity : FlutterActivity() { } } + private val resourceQueue: LinkedBlockingQueue = LinkedBlockingQueue() + private var cacheDir: String? = null + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + cacheDir = context!!.cacheDir.absolutePath Mobile.initApplication(context!!.filesDir.absolutePath) // Method Channel MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "method").setMethodCallHandler { call, result -> @@ -86,6 +96,9 @@ class MainActivity : FlutterActivity() { uiMode() } "androidGetVersion" -> Build.VERSION.SDK_INT +// "exportComicDownloadAndroidQ" -> { +// exportComicDownloadAndroidQ(call.argument("comicId")!!) +// } else -> { notImplementedToken } @@ -194,7 +207,7 @@ class MainActivity : FlutterActivity() { private fun setMode(string: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { mixDisplay()?.let { display -> - if (string == ""){ + if (string == "") { uiThreadHandler.post { window.attributes = window.attributes.also { attr -> attr.preferredDisplayModeId = 0 @@ -283,12 +296,63 @@ class MainActivity : FlutterActivity() { } } + // 安卓11以上使用了 MANAGE_EXTERNAL_STORAGE 权限来管理整个外置存储 (危险权限) - fun main(){ - startActivityForResult(Intent(Intent.ACTION_GET_CONTENT).also { - it.addCategory(Intent.CATEGORY_OPENABLE) - },1) - } - +// private var tmpComicId: String? = null +// private val exportComicDownloadAndroidQRequestCode = 2 +// +// private fun exportComicDownloadAndroidQ(comicId: String) { +// val title = Mobile.flatInvoke("specialDownloadTitle", comicId) +// var fileName = title +// fileName = fileName.replace('/', '_') +// fileName = fileName.replace('\\', '_') +// fileName = fileName.replace('*', '_') +// fileName = fileName.replace('?', '_') +// fileName = fileName.replace('<', '_') +// fileName = fileName.replace('>', '_') +// fileName = fileName.replace('|', '_') +// fileName = fileName + "_" + System.currentTimeMillis() + ".zip" +// tmpComicId = comicId +// startActivityForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).also { +// it.addCategory(Intent.CATEGORY_OPENABLE) +// it.type = "application/octet-stream" +// it.putExtra(Intent.EXTRA_TITLE, fileName) +// }, exportComicDownloadAndroidQRequestCode) +// val result = resourceQueue.take() +// if (result is Throwable) { +// throw result +// } +// return +// } +// +// override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { +// pool.submit { +// try { +// if (resultCode === RESULT_OK && data != null) { +// when (requestCode) { +// exportComicDownloadAndroidQRequestCode -> { +// contentResolver.openOutputStream(data.data!!)?.use { os -> +// val path = Mobile.flatInvoke("exportComicDownload", Gson().toJson(HashMap().also { map -> +// map["comicId"] = tmpComicId +// map["dir"] = cacheDir +// })) +// try { +// FileInputStream(path).copyTo(os) +// } finally { +// File(path).delete() +// } +// } +// resourceQueue.put("OK") +// } +// else -> resourceQueue.put(Exception("WTF")) +// } +// } else { +// resourceQueue.put(Exception("NOT OK")) +// } +// } catch (e: Throwable) { +// resourceQueue.put(Exception(e)) +// } +// } +// } } diff --git a/go/pikapi/controller/export.go b/go/pikapi/controller/export.go index eff5a46..ca88117 100644 --- a/go/pikapi/controller/export.go +++ b/go/pikapi/controller/export.go @@ -14,6 +14,7 @@ import ( "path" "pgo/pikapi/const_value" "pgo/pikapi/database/comic_center" + "strings" "time" ) @@ -67,7 +68,7 @@ func exportComicUsingSocketExit() error { return nil } -func exportComicDownload(params string) error { +func exportComicDownload(params string) (filePath string, err error) { var paramsStruct struct { ComicId string `json:"comicId"` Dir string `json:"dir"` @@ -78,29 +79,43 @@ func exportComicDownload(params string) error { println(fmt.Sprintf("导出 %s 到 %s", comicId, dir)) comic, err := comic_center.FindComicDownloadById(comicId) if err != nil { - return err + return } if comic == nil { - return errors.New("not found") + err = errors.New("not found") + return } if !comic.DownloadFinished { - return errors.New("not download finish") + err = errors.New("not download finish") + return } - filePath := path.Join(dir, fmt.Sprintf("%s-%s.zip", comic.Title, time.Now().Format("2006_01_02_15_04_05.999"))) + filePath = path.Join(dir, fmt.Sprintf("%s-%s.zip", reasonablePath(comic.Title), time.Now().Format("2006_01_02_15_04_05.999"))) println(fmt.Sprintf("ZIP : %s", filePath)) fileStream, err := os.Create(filePath) if err != nil { - return err + return } defer fileStream.Close() zipWriter := zip.NewWriter(fileStream) defer zipWriter.Close() - return exportComicDownloadFetch(comicId, func(path string, size int64) (io.Writer, error) { + err = exportComicDownloadFetch(comicId, func(path string, size int64) (io.Writer, error) { header := tar.Header{} header.Name = path header.Size = size return zipWriter.Create(path) }) + return +} + +func reasonablePath(title string) string { + title = strings.ReplaceAll(title, "\\", "_") + title = strings.ReplaceAll(title, "/", "_") + title = strings.ReplaceAll(title, "*", "_") + title = strings.ReplaceAll(title, "?", "_") + title = strings.ReplaceAll(title, "<", "_") + title = strings.ReplaceAll(title, ">", "_") + title = strings.ReplaceAll(title, "|", "_") + return title } func exportComicDownloadFetch(comicId string, onWriteFile func(path string, size int64) (io.Writer, error)) error { @@ -367,7 +382,7 @@ func exportComicDownloadToJPG(params string) error { if !comic.DownloadFinished { return errors.New("not download finish") } - dirPath := path.Join(dir, fmt.Sprintf("%s-%s", comic.Title, time.Now().Format("2006_01_02_15_04_05.999"))) + dirPath := path.Join(dir, fmt.Sprintf("%s-%s", reasonablePath(comic.Title), time.Now().Format("2006_01_02_15_04_05.999"))) println(fmt.Sprintf("DIR : %s", dirPath)) err = os.Mkdir(dirPath, const_value.CreateDirMode) if err != nil { diff --git a/go/pikapi/controller/pikapi.go b/go/pikapi/controller/pikapi.go index 69d11c4..77b7f61 100644 --- a/go/pikapi/controller/pikapi.go +++ b/go/pikapi/controller/pikapi.go @@ -575,7 +575,7 @@ func FlatInvoke(method string, params string) (string, error) { case "resetAllDownloads": return "", comic_center.ResetAll() case "exportComicDownload": - return "", exportComicDownload(params) + return exportComicDownload(params) case "exportComicDownloadToJPG": return "", exportComicDownloadToJPG(params) case "exportComicUsingSocket": @@ -599,6 +599,8 @@ func FlatInvoke(method string, params string) (string, error) { return downloadGame(params) case "convertImageToJPEG100": return "", convertImageToJPEG100(params) + case "specialDownloadTitle": + return specialDownloadTitle(params) } return "", errors.New("method not found : " + method) } diff --git a/go/pikapi/controller/special.go b/go/pikapi/controller/special.go new file mode 100644 index 0000000..ed97d49 --- /dev/null +++ b/go/pikapi/controller/special.go @@ -0,0 +1,11 @@ +package controller + +import "pgo/pikapi/database/comic_center" + +func specialDownloadTitle(comicId string) (string, error) { + info, err := comic_center.DownloadInfo(comicId) + if err != nil { + return "", err + } + return info.Title, nil +} diff --git a/go/pikapi/database/comic_center/center.go b/go/pikapi/database/comic_center/center.go index b89fb29..7690d0f 100644 --- a/go/pikapi/database/comic_center/center.go +++ b/go/pikapi/database/comic_center/center.go @@ -586,6 +586,20 @@ func DeleteRemoteImages(images []RemoteImage) error { return db.Unscoped().Model(&RemoteImage{}).Delete("id in ?", ids).Error } +func DownloadInfo(comicId string) (*ComicDownload, error) { + mutex.Lock() + defer mutex.Unlock() + var download ComicDownload + err := db.First(&download, "id = ?", comicId).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &download, nil +} + func VACUUM() error { mutex.Lock() defer mutex.Unlock() diff --git a/lib/basic/Cross.dart b/lib/basic/Cross.dart index 6b62373..2b07782 100644 --- a/lib/basic/Cross.dart +++ b/lib/basic/Cross.dart @@ -6,6 +6,7 @@ import 'package:filesystem_picker/filesystem_picker.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/config/Platform.dart'; import 'package:url_launcher/url_launcher.dart'; import 'Method.dart'; import 'config/ChooserRoot.dart'; @@ -57,6 +58,16 @@ Future saveImage(String path, BuildContext context) async { } } +Future saveImageQuiet(String path, BuildContext context) async { + if (Platform.isIOS) { + return method.iosSaveFileToImage(path); + } else if (Platform.isAndroid) { + return _saveImageAndroid(path, context); + } else { + throw Exception("only mobile"); + } +} + Future _saveImageAndroid(String path, BuildContext context) async { var p = await Permission.storage.request(); if (!p.isGranted) { @@ -68,9 +79,14 @@ Future _saveImageAndroid(String path, BuildContext context) async { /// 选择一个文件夹用于保存文件 Future chooseFolder(BuildContext context) async { if (Platform.isAndroid) { - var p = await Permission.storage.request(); - if (!p.isGranted) { - return null; + if (androidVersion >= 30) { + if (!(await Permission.manageExternalStorage.request()).isGranted) { + return null; + } + } else { + if (!(await Permission.storage.request()).isGranted) { + return null; + } } } return FilesystemPicker.open( diff --git a/lib/basic/Method.dart b/lib/basic/Method.dart index c1d0ffa..279bf15 100644 --- a/lib/basic/Method.dart +++ b/lib/basic/Method.dart @@ -429,6 +429,12 @@ class Method { }); } + Future exportComicDownloadAndroidQ(String comicId) { + return _channel.invokeMethod("exportComicDownloadAndroidQ", { + "comicId": comicId, + }); + } + Future exportComicDownloadToJPG(String comicId, String dir) { return _flatInvoke("exportComicDownloadToJPG", { "comicId": comicId, diff --git a/lib/basic/config/ChooserRoot.dart b/lib/basic/config/ChooserRoot.dart index 9846c8f..1d4197f 100644 --- a/lib/basic/config/ChooserRoot.dart +++ b/lib/basic/config/ChooserRoot.dart @@ -23,7 +23,10 @@ String currentChooserRoot() { } else if (Platform.isLinux) { return '/'; } else if (Platform.isAndroid) { - return '/storage/emulated/0/Download'; + // if (androidVersion >= 30) { + // return '/storage/emulated/0/Download'; + // } + return '/storage/emulated/0'; } else { throw 'error'; } diff --git a/lib/basic/config/Platform.dart b/lib/basic/config/Platform.dart new file mode 100644 index 0000000..632ab51 --- /dev/null +++ b/lib/basic/config/Platform.dart @@ -0,0 +1,12 @@ + +import 'dart:io'; + +import '../Method.dart'; + +int androidVersion = 0; + +Future initPlatform()async{ + if (Platform.isAndroid) { + androidVersion = await method.androidGetVersion(); + } +} \ No newline at end of file diff --git a/lib/basic/config/Themes.dart b/lib/basic/config/Themes.dart index 93325ee..2dbe5f5 100644 --- a/lib/basic/config/Themes.dart +++ b/lib/basic/config/Themes.dart @@ -6,6 +6,7 @@ import 'package:event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../Method.dart'; +import 'Platform.dart'; // 主题包 abstract class _ThemePackage { @@ -124,7 +125,6 @@ final _themePackages = <_ThemePackage>[ // 主题更换事件 var themeEvent = Event(); -int _androidVersion = 1; String? _themeCode; ThemeData? _themeData; bool _androidNightMode = false; @@ -161,18 +161,15 @@ void _changeThemeByCode(String themeCode) { const _nightModePropertyName = "androidNightMode"; Future initTheme() async { - if (Platform.isAndroid) { - _androidVersion = await method.androidGetVersion(); - if (_androidVersion >= 29) { - _androidNightMode = - (await method.loadProperty(_nightModePropertyName, "false")) == - "true"; - _systemNight = (await method.androidGetUiMode()) == "NIGHT"; - EventChannel("ui_mode").receiveBroadcastStream().listen((event) { - _systemNight = "$event" == "NIGHT"; - themeEvent.broadcast(); - }); - } + if (androidVersion >= 29) { + _androidNightMode = + (await method.loadProperty(_nightModePropertyName, "false")) == + "true"; + _systemNight = (await method.androidGetUiMode()) == "NIGHT"; + EventChannel("ui_mode").receiveBroadcastStream().listen((event) { + _systemNight = "$event" == "NIGHT"; + themeEvent.broadcast(); + }); } _changeThemeByCode(await method.loadTheme()); } @@ -185,7 +182,7 @@ Future chooseTheme(BuildContext buildContext) async { return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { var list = []; - if (_androidVersion >= 29) { + if (androidVersion >= 29) { var onChange = (bool? v) async { if (v != null) { await method.saveProperty( @@ -206,10 +203,10 @@ Future chooseTheme(BuildContext buildContext) async { decoration: BoxDecoration( border: Border( top: BorderSide( - color: Theme - .of(context) - .dividerColor, - width: 0.5, + color: Theme + .of(context) + .dividerColor, + width: 0.5, ), bottom: BorderSide( color: Theme diff --git a/lib/screens/DownloadExportToFileScreen.dart b/lib/screens/DownloadExportToFileScreen.dart index 2eb41f6..5a5ffba 100644 --- a/lib/screens/DownloadExportToFileScreen.dart +++ b/lib/screens/DownloadExportToFileScreen.dart @@ -2,10 +2,13 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:pikapi/basic/Channels.dart'; +import 'package:pikapi/basic/Common.dart'; import 'package:pikapi/basic/Cross.dart'; import 'package:pikapi/basic/Entities.dart'; import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/basic/config/Platform.dart'; import 'package:pikapi/screens/DownloadExportToSocketScreen.dart'; import 'components/ContentError.dart'; @@ -117,74 +120,118 @@ class _DownloadExportToFileScreenState } List _buildExportToFileButtons() { + List widgets = []; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux || Platform.isAndroid) { - return [ - MaterialButton( - onPressed: () async { - String? path = await chooseFolder(context); - print("path $path"); - if (path != null) { - try { - setState(() { - exporting = true; - }); - await method.exportComicDownloadToJPG( - widget.comicId, - path, - ); - setState(() { - exportResult = "导出成功"; - }); - } catch (e) { - setState(() { - exportResult = "导出失败 $e"; - }); - } finally { - setState(() { - exporting = false; - }); - } + widgets.add(MaterialButton( + onPressed: () async { + String? path = await chooseFolder(context); + print("path $path"); + if (path != null) { + try { + setState(() { + exporting = true; + }); + await method.exportComicDownloadToJPG( + widget.comicId, + path, + ); + setState(() { + exportResult = "导出成功"; + }); + } catch (e) { + setState(() { + exportResult = "导出失败 $e"; + }); + } finally { + setState(() { + exporting = false; + }); } - }, - child: _buildButtonInner('导出到HTML+JPG\n(可直接在相册中打开观看)'), - ), - Container(height: 10), - MaterialButton( - onPressed: () async { - String? path = await chooseFolder(context); - print("path $path"); - if (path != null) { - try { - setState(() { - exporting = true; - }); - await method.exportComicDownload( - widget.comicId, - path, - ); - setState(() { - exportResult = "导出成功"; - }); - } catch (e) { - setState(() { - exportResult = "导出失败 $e"; - }); - } finally { - setState(() { - exporting = false; - }); - } + } + }, + child: _buildButtonInner('导出到HTML+JPG\n(可直接在相册中打开观看)'), + )); + widgets.add(Container(height: 10)); + widgets.add(MaterialButton( + onPressed: () async { + String? path = await chooseFolder(context); + print("path $path"); + if (path != null) { + try { + setState(() { + exporting = true; + }); + await method.exportComicDownload( + widget.comicId, + path, + ); + setState(() { + exportResult = "导出成功"; + }); + } catch (e) { + setState(() { + exportResult = "导出失败 $e"; + }); + } finally { + setState(() { + exporting = false; + }); } - }, - child: _buildButtonInner('导出到HTML.zip\n(可从其他设备导入 / 解压后可阅读)'), - ), - Container(height: 10), - ]; + } + }, + child: _buildButtonInner('导出到HTML.zip\n(可从其他设备导入 / 解压后可阅读)'), + )); + widgets.add(Container(height: 10)); } - return []; + if (Platform.isIOS || Platform.isAndroid) { + widgets.add(MaterialButton( + onPressed: () async { + if (!(await confirmDialog(context, "导出确认", "将本漫画所有图片到相册?"))) { + return; + } + if (!(await Permission.storage.request()).isGranted) { + return null; + } + try { + setState(() { + exporting = true; + }); + // 导出所有图片数据 + var count = 0; + List eps = await method.downloadEpList(widget.comicId); + for (var i = 0; i < eps.length; i++) { + var pics = await method.downloadPicturesByEpId(eps[i].id); + for (var j = 0; j < pics.length; j++) { + setState(() { + exportMessage = "导出图片 ${count++} 张"; + }); + await saveImageQuiet( + await method.downloadImagePath(pics[j].localPath), + context, + ); + } + } + setState(() { + exportResult = "导出成功"; + }); + } catch (e) { + setState(() { + exportResult = "导出失败 $e"; + }); + } finally { + setState(() { + exporting = false; + }); + } + }, + child: _buildButtonInner('将所有图片到处到手机相机'), + )); + widgets.add(Container(height: 10)); + } + return widgets; } Widget _buildButtonInner(String text) { diff --git a/lib/screens/InitScreen.dart b/lib/screens/InitScreen.dart index 878f2cc..282840c 100644 --- a/lib/screens/InitScreen.dart +++ b/lib/screens/InitScreen.dart @@ -9,6 +9,7 @@ import 'package:pikapi/basic/config/FullScreenAction.dart'; import 'package:pikapi/basic/config/FullScreenUI.dart'; import 'package:pikapi/basic/config/KeyboardController.dart'; import 'package:pikapi/basic/config/PagerAction.dart'; +import 'package:pikapi/basic/config/Platform.dart'; import 'package:pikapi/basic/config/Proxy.dart'; import 'package:pikapi/basic/config/Quality.dart'; import 'package:pikapi/basic/config/ReaderDirection.dart'; @@ -37,6 +38,7 @@ class _InitScreenState extends State { Future _init() async { // 初始化配置文件 + await initPlatform(); // 必须第一个初始化, 加载设备信息 await autoClean(); await initAddress(); await initProxy();