mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-02-24 02:18:50 +01:00
Edited/enahanced images are now uploaded to the server
This commit is contained in:
parent
fe7cd38200
commit
abdded09ca
4 changed files with 282 additions and 43 deletions
|
@ -107,13 +107,19 @@ class MainActivity : FlutterActivity(), MethodChannel.MethodCallHandler,
|
|||
logE(TAG, "Image result uri == null")
|
||||
return null
|
||||
}
|
||||
val filename = UriUtil.resolveFilename(this, resultUri)?.let {
|
||||
URLEncoder.encode(it, Charsets.UTF_8.toString())
|
||||
return if (resultUri.scheme?.startsWith("http") == true) {
|
||||
// remote uri
|
||||
val encodedUrl = URLEncoder.encode(resultUri.toString(), "utf-8")
|
||||
"/result-viewer?url=$encodedUrl"
|
||||
} else {
|
||||
val filename = UriUtil.resolveFilename(this, resultUri)?.let {
|
||||
URLEncoder.encode(it, Charsets.UTF_8.toString())
|
||||
}
|
||||
StringBuilder().apply {
|
||||
append("/enhanced-photo-browser?")
|
||||
if (filename != null) append("filename=$filename")
|
||||
}.toString()
|
||||
}
|
||||
return StringBuilder().apply {
|
||||
append("/enhanced-photo-browser?")
|
||||
if (filename != null) append("filename=$filename")
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private var _initialRoute: String? = null
|
||||
|
|
|
@ -25,6 +25,7 @@ import 'package:nc_photos/widget/people_browser.dart';
|
|||
import 'package:nc_photos/widget/person_browser.dart';
|
||||
import 'package:nc_photos/widget/place_browser.dart';
|
||||
import 'package:nc_photos/widget/places_browser.dart';
|
||||
import 'package:nc_photos/widget/result_viewer.dart';
|
||||
import 'package:nc_photos/widget/root_picker.dart';
|
||||
import 'package:nc_photos/widget/settings.dart';
|
||||
import 'package:nc_photos/widget/setup.dart';
|
||||
|
@ -171,6 +172,7 @@ class _MyAppState extends State<MyApp>
|
|||
route ??= _handlePeopleBrowserRoute(settings);
|
||||
route ??= _handlePlaceBrowserRoute(settings);
|
||||
route ??= _handlePlacesBrowserRoute(settings);
|
||||
route ??= _handleResultViewerRoute(settings);
|
||||
return route;
|
||||
}
|
||||
|
||||
|
@ -600,6 +602,25 @@ class _MyAppState extends State<MyApp>
|
|||
return null;
|
||||
}
|
||||
|
||||
Route<dynamic>? _handleResultViewerRoute(RouteSettings settings) {
|
||||
try {
|
||||
if (settings.name == ResultViewer.routeName &&
|
||||
settings.arguments != null) {
|
||||
final args = settings.arguments as ResultViewerArguments;
|
||||
return ResultViewer.buildRoute(args);
|
||||
} else if (settings.name?.startsWith("${ResultViewer.routeName}?") ==
|
||||
true) {
|
||||
final queries = Uri.parse(settings.name!).queryParameters;
|
||||
final fileUrl = Uri.decodeQueryComponent(queries["url"]!);
|
||||
final args = ResultViewerArguments(fileUrl);
|
||||
return ResultViewer.buildRoute(args);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("[_handleResultViewerRoute] Failed while handling route", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
final _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
|
|
129
app/lib/widget/result_viewer.dart
Normal file
129
app/lib/widget/result_viewer.dart
Normal file
|
@ -0,0 +1,129 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/pref.dart';
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/use_case/ls_single_file.dart';
|
||||
import 'package:nc_photos/widget/viewer.dart';
|
||||
|
||||
class ResultViewerArguments {
|
||||
const ResultViewerArguments(this.resultUrl);
|
||||
|
||||
final String resultUrl;
|
||||
}
|
||||
|
||||
/// This is an intermediate widget in charge of preparing the file to be
|
||||
/// eventually shown in [Viewer]
|
||||
class ResultViewer extends StatefulWidget {
|
||||
static const routeName = "/result-viewer";
|
||||
|
||||
const ResultViewer({
|
||||
super.key,
|
||||
required this.resultUrl,
|
||||
});
|
||||
|
||||
ResultViewer.fromArgs(ResultViewerArguments args, {Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
resultUrl: args.resultUrl,
|
||||
);
|
||||
|
||||
static Route buildRoute(ResultViewerArguments args) => MaterialPageRoute(
|
||||
builder: (_) => ResultViewer.fromArgs(args),
|
||||
);
|
||||
|
||||
@override
|
||||
createState() => _ResultViewerState();
|
||||
|
||||
final String resultUrl;
|
||||
}
|
||||
|
||||
class _ResultViewerState extends State<ResultViewer> {
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_c = KiwiContainer().resolve<DiContainer>();
|
||||
_doWork();
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
if (_file == null) {
|
||||
return AppTheme(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
shadowColor: Colors.black,
|
||||
foregroundColor: Colors.white.withOpacity(.87),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Container(
|
||||
color: Colors.black,
|
||||
alignment: Alignment.topCenter,
|
||||
child: const LinearProgressIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Viewer(
|
||||
account: _account!,
|
||||
streamFiles: [_file!],
|
||||
startIndex: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doWork() async {
|
||||
_log.info("[_doWork] URL: ${widget.resultUrl}");
|
||||
_account = _c.pref.getCurrentAccount();
|
||||
if (_account == null) {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global().errorUnauthenticated),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
if (!widget.resultUrl
|
||||
.startsWith(RegExp(_account!.url, caseSensitive: false))) {
|
||||
_log.severe("[_doWork] File url and current account does not match");
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global().errorUnauthenticated),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
// +1 for the slash
|
||||
final filePath = widget.resultUrl.substring(_account!.url.length + 1);
|
||||
// query remote
|
||||
final File file;
|
||||
try {
|
||||
file = await LsSingleFile(_c)(_account!, filePath);
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("[_doWork] Failed while LsSingleFile", e, stackTrace);
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(exception_util.toUserString(e)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_file = file;
|
||||
});
|
||||
}
|
||||
|
||||
late final DiContainer _c;
|
||||
Account? _account;
|
||||
File? _file;
|
||||
|
||||
static final _log = Logger("widget.result_viewer._ResultViewerState");
|
||||
}
|
|
@ -625,8 +625,7 @@ private open class ImageProcessorCommandTask(context: Context) :
|
|||
val filter = cmd.filters.first() as Orientation
|
||||
try {
|
||||
return loselessRotate(
|
||||
filter.degree, file, cmd.filename,
|
||||
"Edited Photos"
|
||||
filter.degree, file, cmd.filename, cmd
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
logE(
|
||||
|
@ -644,14 +643,7 @@ private open class ImageProcessorCommandTask(context: Context) :
|
|||
cmd.apply(context, fileUri)
|
||||
})
|
||||
handleCancel()
|
||||
saveBitmap(
|
||||
output, cmd.filename, file,
|
||||
if (cmd.method in ImageProcessorService.EDIT_METHODS) {
|
||||
"Edited Photos"
|
||||
} else {
|
||||
"Enhanced Photos"
|
||||
}
|
||||
)
|
||||
saveBitmap(output, cmd.filename, file, cmd)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
|
@ -680,7 +672,8 @@ private open class ImageProcessorCommandTask(context: Context) :
|
|||
}
|
||||
|
||||
private fun loselessRotate(
|
||||
degree: Int, srcFile: File, outFilename: String, subDir: String
|
||||
degree: Int, srcFile: File, outFilename: String,
|
||||
cmd: ImageProcessorImageCommand
|
||||
): Uri {
|
||||
logI(TAG, "[loselessRotate] $outFilename")
|
||||
val outFile = File.createTempFile("out", null, getTempDir(context))
|
||||
|
@ -693,12 +686,8 @@ private open class ImageProcessorCommandTask(context: Context) :
|
|||
oExif.saveAttributes()
|
||||
|
||||
handleCancel()
|
||||
// move file to user accessible storage
|
||||
val uri = MediaStoreUtil.copyFileToDownload(
|
||||
context, Uri.fromFile(outFile), outFilename,
|
||||
"Photos (for Nextcloud)/$subDir"
|
||||
)
|
||||
return uri
|
||||
val persister = EnhancedFileServerPersisterWithFallback(context)
|
||||
return persister.persist(cmd, outFile)
|
||||
} finally {
|
||||
outFile.delete()
|
||||
}
|
||||
|
@ -738,31 +727,31 @@ private open class ImageProcessorCommandTask(context: Context) :
|
|||
}
|
||||
|
||||
private fun saveBitmap(
|
||||
bitmap: Bitmap, filename: String, srcFile: File, subDir: String
|
||||
bitmap: Bitmap, filename: String, srcFile: File,
|
||||
cmd: ImageProcessorImageCommand
|
||||
): Uri {
|
||||
logI(TAG, "[saveBitmap] $filename")
|
||||
val outFile = File.createTempFile("out", null, getTempDir(context))
|
||||
outFile.outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it)
|
||||
}
|
||||
|
||||
// then copy the EXIF tags
|
||||
try {
|
||||
val iExif = ExifInterface(srcFile)
|
||||
val oExif = ExifInterface(outFile)
|
||||
copyExif(iExif, oExif)
|
||||
oExif.saveAttributes()
|
||||
} catch (e: Throwable) {
|
||||
logE(TAG, "[copyExif] Failed while saving EXIF", e)
|
||||
}
|
||||
outFile.outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it)
|
||||
}
|
||||
|
||||
// move file to user accessible storage
|
||||
val uri = MediaStoreUtil.copyFileToDownload(
|
||||
context, Uri.fromFile(outFile), filename,
|
||||
"Photos (for Nextcloud)/$subDir"
|
||||
)
|
||||
outFile.delete()
|
||||
return uri
|
||||
// then copy the EXIF tags
|
||||
try {
|
||||
val iExif = ExifInterface(srcFile)
|
||||
val oExif = ExifInterface(outFile)
|
||||
copyExif(iExif, oExif)
|
||||
oExif.saveAttributes()
|
||||
} catch (e: Throwable) {
|
||||
logE(TAG, "[copyExif] Failed while saving EXIF", e)
|
||||
}
|
||||
|
||||
val persister = EnhancedFileServerPersisterWithFallback(context)
|
||||
return persister.persist(cmd, outFile)
|
||||
} finally {
|
||||
outFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyExif(from: ExifInterface, to: ExifInterface) {
|
||||
|
@ -801,3 +790,97 @@ private fun getTempDir(context: Context): File {
|
|||
}
|
||||
return f
|
||||
}
|
||||
|
||||
private interface EnhancedFilePersister {
|
||||
fun persist(cmd: ImageProcessorImageCommand, file: File): Uri
|
||||
}
|
||||
|
||||
private class EnhancedFileDevicePersister(context: Context) :
|
||||
EnhancedFilePersister {
|
||||
override fun persist(cmd: ImageProcessorImageCommand, file: File): Uri {
|
||||
val uri = MediaStoreUtil.copyFileToDownload(
|
||||
context, Uri.fromFile(file), cmd.filename,
|
||||
"Photos (for Nextcloud)/${getSubDir(cmd)}"
|
||||
)
|
||||
return uri
|
||||
}
|
||||
|
||||
private fun getSubDir(cmd: ImageProcessorImageCommand): String {
|
||||
return if (cmd.method in ImageProcessorService.EDIT_METHODS) {
|
||||
"Edited Photos"
|
||||
} else {
|
||||
"Enhanced Photos"
|
||||
}
|
||||
}
|
||||
|
||||
val context = context
|
||||
}
|
||||
|
||||
private class EnhancedFileServerPersister :
|
||||
EnhancedFilePersister {
|
||||
companion object {
|
||||
const val TAG = "EnhancedFileServerPersister"
|
||||
}
|
||||
|
||||
override fun persist(cmd: ImageProcessorImageCommand, file: File): Uri {
|
||||
val ext = cmd.fileUrl.substringAfterLast('.', "")
|
||||
val url = if (ext.contains('/')) {
|
||||
// no ext
|
||||
"${cmd.fileUrl}_${getSuffix(cmd)}.jpg"
|
||||
} else {
|
||||
"${cmd.fileUrl.substringBeforeLast('.', "")}_${getSuffix(cmd)}.jpg"
|
||||
}
|
||||
logI(TAG, "[persist] Persist file to server: $url")
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "PUT"
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 8000
|
||||
for (entry in (cmd.headers ?: mapOf()).entries) {
|
||||
setRequestProperty(entry.key, entry.value)
|
||||
}
|
||||
}.use {
|
||||
file.inputStream()
|
||||
.use { iStream -> iStream.copyTo(it.outputStream) }
|
||||
val responseCode = it.responseCode
|
||||
if (responseCode / 100 != 2) {
|
||||
logE(TAG, "[persist] Failed uploading file: HTTP$responseCode")
|
||||
throw HttpException(
|
||||
responseCode, "Failed uploading file (HTTP$responseCode)"
|
||||
)
|
||||
}
|
||||
}
|
||||
return Uri.parse(url)
|
||||
}
|
||||
|
||||
private fun getSuffix(cmd: ImageProcessorImageCommand): String {
|
||||
val epoch = System.currentTimeMillis() / 1000
|
||||
return if (cmd.method in ImageProcessorService.EDIT_METHODS) {
|
||||
"edited_$epoch"
|
||||
} else {
|
||||
"enhanced_$epoch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EnhancedFileServerPersisterWithFallback(context: Context) :
|
||||
EnhancedFilePersister {
|
||||
companion object {
|
||||
const val TAG = "EnhancedFileServerPersisterWithFallback"
|
||||
}
|
||||
|
||||
override fun persist(cmd: ImageProcessorImageCommand, file: File): Uri {
|
||||
try {
|
||||
return server.persist(cmd, file)
|
||||
} catch (e: Throwable) {
|
||||
logW(
|
||||
TAG,
|
||||
"[persist] Failed while persisting to server, switch to fallback",
|
||||
e
|
||||
)
|
||||
}
|
||||
return fallback.persist(cmd, file)
|
||||
}
|
||||
|
||||
private val server = EnhancedFileServerPersister()
|
||||
private val fallback = EnhancedFileDevicePersister(context)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue