export all images to albums

This commit is contained in:
niuhuan 2021-10-14 18:12:36 +08:00
parent 9e2027ef45
commit e3af9c25f1
13 changed files with 313 additions and 123 deletions

View File

@ -6,16 +6,19 @@
<uses-permission android:name="android.permission.WRITE_INTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_INTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application <application
android:icon="@mipmap/ic_launcher"
android:label="pikapi" android:label="pikapi"
android:icon="@mipmap/ic_launcher"> android:requestLegacyExternalStorage="true">
<!-- requestLegacyExternalStorage="true" api29 down -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
@ -23,8 +26,7 @@
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme" />
/>
<!-- Displays an Android View that continues showing the launch screen <!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual screen fades out. A splash screen is useful to avoid any visual
@ -32,8 +34,7 @@
Flutter's first frame. --> Flutter's first frame. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable" android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background" android:resource="@drawable/launch_background" />
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View File

@ -13,6 +13,7 @@ import android.provider.MediaStore
import android.view.Display import android.view.Display
import android.view.KeyEvent import android.view.KeyEvent
import androidx.annotation.NonNull import androidx.annotation.NonNull
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
@ -22,7 +23,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import mobile.Mobile import mobile.Mobile
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.Executors 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() { class MainActivity : FlutterActivity() {
@ -60,8 +66,12 @@ class MainActivity : FlutterActivity() {
} }
} }
private val resourceQueue: LinkedBlockingQueue<Any?> = LinkedBlockingQueue()
private var cacheDir: String? = null
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
cacheDir = context!!.cacheDir.absolutePath
Mobile.initApplication(context!!.filesDir.absolutePath) Mobile.initApplication(context!!.filesDir.absolutePath)
// Method Channel // Method Channel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "method").setMethodCallHandler { call, result -> MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "method").setMethodCallHandler { call, result ->
@ -86,6 +96,9 @@ class MainActivity : FlutterActivity() {
uiMode() uiMode()
} }
"androidGetVersion" -> Build.VERSION.SDK_INT "androidGetVersion" -> Build.VERSION.SDK_INT
// "exportComicDownloadAndroidQ" -> {
// exportComicDownloadAndroidQ(call.argument("comicId")!!)
// }
else -> { else -> {
notImplementedToken notImplementedToken
} }
@ -283,12 +296,63 @@ class MainActivity : FlutterActivity() {
} }
} }
// 安卓11以上使用了 MANAGE_EXTERNAL_STORAGE 权限来管理整个外置存储 (危险权限)
fun main(){ // private var tmpComicId: String? = null
startActivityForResult(Intent(Intent.ACTION_GET_CONTENT).also { // private val exportComicDownloadAndroidQRequestCode = 2
it.addCategory(Intent.CATEGORY_OPENABLE) //
},1) // 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<Any, Any?>().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))
// }
// }
// }
} }

View File

@ -14,6 +14,7 @@ import (
"path" "path"
"pgo/pikapi/const_value" "pgo/pikapi/const_value"
"pgo/pikapi/database/comic_center" "pgo/pikapi/database/comic_center"
"strings"
"time" "time"
) )
@ -67,7 +68,7 @@ func exportComicUsingSocketExit() error {
return nil return nil
} }
func exportComicDownload(params string) error { func exportComicDownload(params string) (filePath string, err error) {
var paramsStruct struct { var paramsStruct struct {
ComicId string `json:"comicId"` ComicId string `json:"comicId"`
Dir string `json:"dir"` Dir string `json:"dir"`
@ -78,29 +79,43 @@ func exportComicDownload(params string) error {
println(fmt.Sprintf("导出 %s 到 %s", comicId, dir)) println(fmt.Sprintf("导出 %s 到 %s", comicId, dir))
comic, err := comic_center.FindComicDownloadById(comicId) comic, err := comic_center.FindComicDownloadById(comicId)
if err != nil { if err != nil {
return err return
} }
if comic == nil { if comic == nil {
return errors.New("not found") err = errors.New("not found")
return
} }
if !comic.DownloadFinished { 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)) println(fmt.Sprintf("ZIP : %s", filePath))
fileStream, err := os.Create(filePath) fileStream, err := os.Create(filePath)
if err != nil { if err != nil {
return err return
} }
defer fileStream.Close() defer fileStream.Close()
zipWriter := zip.NewWriter(fileStream) zipWriter := zip.NewWriter(fileStream)
defer zipWriter.Close() 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 := tar.Header{}
header.Name = path header.Name = path
header.Size = size header.Size = size
return zipWriter.Create(path) 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 { 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 { if !comic.DownloadFinished {
return errors.New("not download finish") 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)) println(fmt.Sprintf("DIR : %s", dirPath))
err = os.Mkdir(dirPath, const_value.CreateDirMode) err = os.Mkdir(dirPath, const_value.CreateDirMode)
if err != nil { if err != nil {

View File

@ -575,7 +575,7 @@ func FlatInvoke(method string, params string) (string, error) {
case "resetAllDownloads": case "resetAllDownloads":
return "", comic_center.ResetAll() return "", comic_center.ResetAll()
case "exportComicDownload": case "exportComicDownload":
return "", exportComicDownload(params) return exportComicDownload(params)
case "exportComicDownloadToJPG": case "exportComicDownloadToJPG":
return "", exportComicDownloadToJPG(params) return "", exportComicDownloadToJPG(params)
case "exportComicUsingSocket": case "exportComicUsingSocket":
@ -599,6 +599,8 @@ func FlatInvoke(method string, params string) (string, error) {
return downloadGame(params) return downloadGame(params)
case "convertImageToJPEG100": case "convertImageToJPEG100":
return "", convertImageToJPEG100(params) return "", convertImageToJPEG100(params)
case "specialDownloadTitle":
return specialDownloadTitle(params)
} }
return "", errors.New("method not found : " + method) return "", errors.New("method not found : " + method)
} }

View File

@ -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
}

View File

@ -586,6 +586,20 @@ func DeleteRemoteImages(images []RemoteImage) error {
return db.Unscoped().Model(&RemoteImage{}).Delete("id in ?", ids).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 { func VACUUM() error {
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()

View File

@ -6,6 +6,7 @@ import 'package:filesystem_picker/filesystem_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:pikapi/basic/Common.dart'; import 'package:pikapi/basic/Common.dart';
import 'package:pikapi/basic/config/Platform.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'Method.dart'; import 'Method.dart';
import 'config/ChooserRoot.dart'; import 'config/ChooserRoot.dart';
@ -57,6 +58,16 @@ Future<dynamic> saveImage(String path, BuildContext context) async {
} }
} }
Future<dynamic> 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<dynamic> _saveImageAndroid(String path, BuildContext context) async { Future<dynamic> _saveImageAndroid(String path, BuildContext context) async {
var p = await Permission.storage.request(); var p = await Permission.storage.request();
if (!p.isGranted) { if (!p.isGranted) {
@ -68,10 +79,15 @@ Future<dynamic> _saveImageAndroid(String path, BuildContext context) async {
/// ///
Future<String?> chooseFolder(BuildContext context) async { Future<String?> chooseFolder(BuildContext context) async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
var p = await Permission.storage.request(); if (androidVersion >= 30) {
if (!p.isGranted) { if (!(await Permission.manageExternalStorage.request()).isGranted) {
return null; return null;
} }
} else {
if (!(await Permission.storage.request()).isGranted) {
return null;
}
}
} }
return FilesystemPicker.open( return FilesystemPicker.open(
title: '选择一个文件夹', title: '选择一个文件夹',

View File

@ -429,6 +429,12 @@ class Method {
}); });
} }
Future<dynamic> exportComicDownloadAndroidQ(String comicId) {
return _channel.invokeMethod("exportComicDownloadAndroidQ", {
"comicId": comicId,
});
}
Future<dynamic> exportComicDownloadToJPG(String comicId, String dir) { Future<dynamic> exportComicDownloadToJPG(String comicId, String dir) {
return _flatInvoke("exportComicDownloadToJPG", { return _flatInvoke("exportComicDownloadToJPG", {
"comicId": comicId, "comicId": comicId,

View File

@ -23,7 +23,10 @@ String currentChooserRoot() {
} else if (Platform.isLinux) { } else if (Platform.isLinux) {
return '/'; return '/';
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
return '/storage/emulated/0/Download'; // if (androidVersion >= 30) {
// return '/storage/emulated/0/Download';
// }
return '/storage/emulated/0';
} else { } else {
throw 'error'; throw 'error';
} }

View File

@ -0,0 +1,12 @@
import 'dart:io';
import '../Method.dart';
int androidVersion = 0;
Future<void> initPlatform()async{
if (Platform.isAndroid) {
androidVersion = await method.androidGetVersion();
}
}

View File

@ -6,6 +6,7 @@ import 'package:event/event.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../Method.dart'; import '../Method.dart';
import 'Platform.dart';
// //
abstract class _ThemePackage { abstract class _ThemePackage {
@ -124,7 +125,6 @@ final _themePackages = <_ThemePackage>[
// //
var themeEvent = Event<EventArgs>(); var themeEvent = Event<EventArgs>();
int _androidVersion = 1;
String? _themeCode; String? _themeCode;
ThemeData? _themeData; ThemeData? _themeData;
bool _androidNightMode = false; bool _androidNightMode = false;
@ -161,9 +161,7 @@ void _changeThemeByCode(String themeCode) {
const _nightModePropertyName = "androidNightMode"; const _nightModePropertyName = "androidNightMode";
Future<dynamic> initTheme() async { Future<dynamic> initTheme() async {
if (Platform.isAndroid) { if (androidVersion >= 29) {
_androidVersion = await method.androidGetVersion();
if (_androidVersion >= 29) {
_androidNightMode = _androidNightMode =
(await method.loadProperty(_nightModePropertyName, "false")) == (await method.loadProperty(_nightModePropertyName, "false")) ==
"true"; "true";
@ -173,7 +171,6 @@ Future<dynamic> initTheme() async {
themeEvent.broadcast(); themeEvent.broadcast();
}); });
} }
}
_changeThemeByCode(await method.loadTheme()); _changeThemeByCode(await method.loadTheme());
} }
@ -185,7 +182,7 @@ Future<dynamic> chooseTheme(BuildContext buildContext) async {
return StatefulBuilder( return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) { builder: (BuildContext context, StateSetter setState) {
var list = <SimpleDialogOption>[]; var list = <SimpleDialogOption>[];
if (_androidVersion >= 29) { if (androidVersion >= 29) {
var onChange = (bool? v) async { var onChange = (bool? v) async {
if (v != null) { if (v != null) {
await method.saveProperty( await method.saveProperty(

View File

@ -2,10 +2,13 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:pikapi/basic/Channels.dart'; import 'package:pikapi/basic/Channels.dart';
import 'package:pikapi/basic/Common.dart';
import 'package:pikapi/basic/Cross.dart'; import 'package:pikapi/basic/Cross.dart';
import 'package:pikapi/basic/Entities.dart'; import 'package:pikapi/basic/Entities.dart';
import 'package:pikapi/basic/Method.dart'; import 'package:pikapi/basic/Method.dart';
import 'package:pikapi/basic/config/Platform.dart';
import 'package:pikapi/screens/DownloadExportToSocketScreen.dart'; import 'package:pikapi/screens/DownloadExportToSocketScreen.dart';
import 'components/ContentError.dart'; import 'components/ContentError.dart';
@ -117,12 +120,12 @@ class _DownloadExportToFileScreenState
} }
List<Widget> _buildExportToFileButtons() { List<Widget> _buildExportToFileButtons() {
List<Widget> widgets = [];
if (Platform.isWindows || if (Platform.isWindows ||
Platform.isMacOS || Platform.isMacOS ||
Platform.isLinux || Platform.isLinux ||
Platform.isAndroid) { Platform.isAndroid) {
return [ widgets.add(MaterialButton(
MaterialButton(
onPressed: () async { onPressed: () async {
String? path = await chooseFolder(context); String? path = await chooseFolder(context);
print("path $path"); print("path $path");
@ -150,9 +153,9 @@ class _DownloadExportToFileScreenState
} }
}, },
child: _buildButtonInner('导出到HTML+JPG\n(可直接在相册中打开观看)'), child: _buildButtonInner('导出到HTML+JPG\n(可直接在相册中打开观看)'),
), ));
Container(height: 10), widgets.add(Container(height: 10));
MaterialButton( widgets.add(MaterialButton(
onPressed: () async { onPressed: () async {
String? path = await chooseFolder(context); String? path = await chooseFolder(context);
print("path $path"); print("path $path");
@ -180,11 +183,55 @@ class _DownloadExportToFileScreenState
} }
}, },
child: _buildButtonInner('导出到HTML.zip\n(可从其他设备导入 / 解压后可阅读)'), child: _buildButtonInner('导出到HTML.zip\n(可从其他设备导入 / 解压后可阅读)'),
), ));
Container(height: 10), 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<DownloadEp> 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) { Widget _buildButtonInner(String text) {

View File

@ -9,6 +9,7 @@ import 'package:pikapi/basic/config/FullScreenAction.dart';
import 'package:pikapi/basic/config/FullScreenUI.dart'; import 'package:pikapi/basic/config/FullScreenUI.dart';
import 'package:pikapi/basic/config/KeyboardController.dart'; import 'package:pikapi/basic/config/KeyboardController.dart';
import 'package:pikapi/basic/config/PagerAction.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/Proxy.dart';
import 'package:pikapi/basic/config/Quality.dart'; import 'package:pikapi/basic/config/Quality.dart';
import 'package:pikapi/basic/config/ReaderDirection.dart'; import 'package:pikapi/basic/config/ReaderDirection.dart';
@ -37,6 +38,7 @@ class _InitScreenState extends State<InitScreen> {
Future<dynamic> _init() async { Future<dynamic> _init() async {
// //
await initPlatform(); // ,
await autoClean(); await autoClean();
await initAddress(); await initAddress();
await initProxy(); await initProxy();