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

@ -1,48 +1,49 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="niuhuan.pikapi">
package="niuhuan.pikapi">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_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.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_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.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application
android:label="pikapi"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="pikapi"
android:requestLegacyExternalStorage="true">
<!-- requestLegacyExternalStorage="true" api29 down -->
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
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">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

View File

@ -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<Any?> = 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<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"
"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 {

View File

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

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
}
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()

View File

@ -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<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 {
var p = await Permission.storage.request();
if (!p.isGranted) {
@ -68,9 +79,14 @@ Future<dynamic> _saveImageAndroid(String path, BuildContext context) async {
///
Future<String?> 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(

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) {
return _flatInvoke("exportComicDownloadToJPG", {
"comicId": comicId,

View File

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

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/services.dart';
import '../Method.dart';
import 'Platform.dart';
//
abstract class _ThemePackage {
@ -124,7 +125,6 @@ final _themePackages = <_ThemePackage>[
//
var themeEvent = Event<EventArgs>();
int _androidVersion = 1;
String? _themeCode;
ThemeData? _themeData;
bool _androidNightMode = false;
@ -161,18 +161,15 @@ void _changeThemeByCode(String themeCode) {
const _nightModePropertyName = "androidNightMode";
Future<dynamic> 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<dynamic> chooseTheme(BuildContext buildContext) async {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
var list = <SimpleDialogOption>[];
if (_androidVersion >= 29) {
if (androidVersion >= 29) {
var onChange = (bool? v) async {
if (v != null) {
await method.saveProperty(
@ -206,10 +203,10 @@ Future<dynamic> 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

View File

@ -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<Widget> _buildExportToFileButtons() {
List<Widget> 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<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) {

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/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<InitScreen> {
Future<dynamic> _init() async {
//
await initPlatform(); // ,
await autoClean();
await initAddress();
await initProxy();