Edited/enahanced images are now uploaded to the server

This commit is contained in:
Ming Ming 2022-09-09 14:45:13 +08:00
parent fe7cd38200
commit abdded09ca
4 changed files with 282 additions and 43 deletions

View file

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

View file

@ -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>();

View 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");
}

View file

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