diff --git a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt index 0debdf38..c5c5b0b8 100644 --- a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt @@ -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 diff --git a/app/lib/bloc/scan_account_dir.dart b/app/lib/bloc/scan_account_dir.dart index f7f0b4e7..ab594ed0 100644 --- a/app/lib/bloc/scan_account_dir.dart +++ b/app/lib/bloc/scan_account_dir.dart @@ -121,6 +121,7 @@ class ScanAccountDirBloc _accountPrefUpdatedEventListener.begin(); _nativeFileExifUpdatedListener?.begin(); + _imageProcessorUploadSuccessListener?.begin(); on(_onEvent, transformer: ((events, mapper) { return events.asyncExpand(mapper).distinct((a, b) { @@ -164,6 +165,7 @@ class ScanAccountDirBloc _accountPrefUpdatedEventListener.end(); _nativeFileExifUpdatedListener?.end(); + _imageProcessorUploadSuccessListener?.end(); _refreshThrottler.clear(); return super.close(); @@ -333,6 +335,14 @@ class ScanAccountDirBloc ); } + void _onImageProcessorUploadSuccessEvent( + ImageProcessorUploadSuccessEvent ev) { + _refreshThrottler.trigger( + maxResponceTime: const Duration(seconds: 3), + maxPendingCount: 10, + ); + } + /// Query a small amount of files to give an illusion of quick startup Future> _queryOfflineMini(ScanAccountDirBlocQueryBase ev) async { return await ScanDirOfflineMini(_c)( @@ -540,6 +550,10 @@ class ScanAccountDirBloc late final _nativeFileExifUpdatedListener = platform_k.isWeb ? null : NativeEventListener(_onNativeFileExifUpdated); + late final _imageProcessorUploadSuccessListener = platform_k.isWeb + ? null + : NativeEventListener( + _onImageProcessorUploadSuccessEvent); late final _refreshThrottler = Throttler( onTriggered: (_) { diff --git a/app/lib/event/native_event.dart b/app/lib/event/native_event.dart index f8bca097..62031b0f 100644 --- a/app/lib/event/native_event.dart +++ b/app/lib/event/native_event.dart @@ -31,6 +31,9 @@ class NativeEventListener { case FileExifUpdatedEvent._id: return FileExifUpdatedEvent.fromEvent(ev); + case ImageProcessorUploadSuccessEvent._id: + return ImageProcessorUploadSuccessEvent.fromEvent(ev); + default: throw ArgumentError("Invalid event: ${ev.event}"); } @@ -63,3 +66,15 @@ class FileExifUpdatedEvent { final List fileIds; } + +class ImageProcessorUploadSuccessEvent { + const ImageProcessorUploadSuccessEvent(); + + factory ImageProcessorUploadSuccessEvent.fromEvent(NativeEventObject ev) { + assert(ev.event == _id); + assert(ev.data == null); + return const ImageProcessorUploadSuccessEvent(); + } + + static const _id = "ImageProcessorUploadSuccessEvent"; +} diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index ebabd151..b0f95477 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -403,13 +403,19 @@ }, "settingsShowDateInAlbumTitle": "Group photos by date", "settingsShowDateInAlbumDescription": "Apply only when the album is sorted by time", - "settingsPhotoEnhancementTitle": "Photo enhancement", - "settingsPhotoEnhancementPageTitle": "Photo enhancement settings", - "@settingsPhotoEnhancementPageTitle": { - "description": "Dedicated page for photo enhancement settings" + "settingsImageEditTitle": "Editor", + "@settingsImageEditTitle": { + "description": "Include settings for image enhancements and the image editor" }, - "settingsEnhanceMaxResolutionTitle": "Max output resolution", + "settingsImageEditDescription": "Customize image enhancements and the image editor", + "settingsEnhanceMaxResolutionTitle2": "Image resolution for enhancements", "settingsEnhanceMaxResolutionDescription": "Photos larger than the selected resolution will be downscaled.\n\nHigh resolution photos require significantly more memory and time to process. Please lower this setting if the app crashed while enhancing your photos.", + "settingsImageEditSaveResultsToServerTitle": "Save results to server", + "@settingsImageEditSaveResultsToServerTitle": { + "description": "Whether to save the edit/enhance results to server instead of the current device" + }, + "settingsImageEditSaveResultsToServerTrueDescription": "Results are saved to server, fallback to device storage if failed", + "settingsImageEditSaveResultsToServerFalseDescription": "Results are saved to this device", "settingsThemeTitle": "Theme", "settingsThemeDescription": "Customize the appearance of the app", "settingsThemePageTitle": "Theme settings", diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 6fd6c4e3..96c61643 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -25,10 +25,13 @@ "settingsAlbumPageTitle", "settingsShowDateInAlbumTitle", "settingsShowDateInAlbumDescription", - "settingsPhotoEnhancementTitle", - "settingsPhotoEnhancementPageTitle", - "settingsEnhanceMaxResolutionTitle", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", "settingsEnhanceMaxResolutionDescription", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsDoubleTapExitTitle", @@ -190,10 +193,13 @@ "settingsAlbumPageTitle", "settingsShowDateInAlbumTitle", "settingsShowDateInAlbumDescription", - "settingsPhotoEnhancementTitle", - "settingsPhotoEnhancementPageTitle", - "settingsEnhanceMaxResolutionTitle", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", "settingsEnhanceMaxResolutionDescription", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsDoubleTapExitTitle", @@ -351,6 +357,12 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsDoubleTapExitTitle", "shareMethodPreviewTitle", "shareMethodPreviewDescription", @@ -406,6 +418,12 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "rootPickerSkipConfirmationDialogContent2", "shareMethodPreviewTitle", "shareMethodPreviewDescription", @@ -429,6 +447,12 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "shareMethodPreviewTitle", "shareMethodPreviewDescription", "shareMethodOriginalFileTitle", @@ -456,10 +480,13 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", - "settingsPhotoEnhancementTitle", - "settingsPhotoEnhancementPageTitle", - "settingsEnhanceMaxResolutionTitle", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", "settingsEnhanceMaxResolutionDescription", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsDoubleTapExitTitle", @@ -539,10 +566,13 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", - "settingsPhotoEnhancementTitle", - "settingsPhotoEnhancementPageTitle", - "settingsEnhanceMaxResolutionTitle", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", "settingsEnhanceMaxResolutionDescription", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsDoubleTapExitTitle", @@ -640,10 +670,13 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", - "settingsPhotoEnhancementTitle", - "settingsPhotoEnhancementPageTitle", - "settingsEnhanceMaxResolutionTitle", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", "settingsEnhanceMaxResolutionDescription", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsDoubleTapExitTitle", @@ -720,10 +753,13 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", - "settingsPhotoEnhancementTitle", - "settingsPhotoEnhancementPageTitle", - "settingsEnhanceMaxResolutionTitle", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", "settingsEnhanceMaxResolutionDescription", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsDoubleTapExitTitle", @@ -800,10 +836,13 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", - "settingsPhotoEnhancementTitle", - "settingsPhotoEnhancementPageTitle", - "settingsEnhanceMaxResolutionTitle", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", "settingsEnhanceMaxResolutionDescription", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsDoubleTapExitTitle", @@ -880,10 +919,13 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", - "settingsPhotoEnhancementTitle", - "settingsPhotoEnhancementPageTitle", - "settingsEnhanceMaxResolutionTitle", + "settingsImageEditTitle", + "settingsImageEditDescription", + "settingsEnhanceMaxResolutionTitle2", "settingsEnhanceMaxResolutionDescription", + "settingsImageEditSaveResultsToServerTitle", + "settingsImageEditSaveResultsToServerTrueDescription", + "settingsImageEditSaveResultsToServerFalseDescription", "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsDoubleTapExitTitle", diff --git a/app/lib/pref.dart b/app/lib/pref.dart index 14cfe451..19d25bb4 100644 --- a/app/lib/pref.dart +++ b/app/lib/pref.dart @@ -249,6 +249,15 @@ class Pref { Future setMemoriesRange(int value) => _set(PrefKey.memoriesRange, value, (key, value) => provider.setInt(key, value)); + bool? isSaveEditResultToServer() => + provider.getBool(PrefKey.saveEditResultToServer); + bool isSaveEditResultToServerOr([bool def = true]) => + isSaveEditResultToServer() ?? def; + Future setSaveEditResultToServer(bool value) => _set( + PrefKey.saveEditResultToServer, + value, + (key, value) => provider.setBool(key, value)); + Future _set(PrefKey key, T value, Future Function(PrefKey key, T value) setFn) async { if (await setFn(key, value)) { @@ -561,6 +570,7 @@ enum PrefKey { shouldProcessExifWifiOnly, doubleTapExit, memoriesRange, + saveEditResultToServer, // account pref isEnableFaceRecognitionApp, @@ -632,6 +642,8 @@ extension on PrefKey { return "doubleTapExit"; case PrefKey.memoriesRange: return "memoriesRange"; + case PrefKey.saveEditResultToServer: + return "saveEditResultToServer"; // account pref case PrefKey.isEnableFaceRecognitionApp: diff --git a/app/lib/widget/handler/enhance_handler.dart b/app/lib/widget/handler/enhance_handler.dart index e5b4a9ba..706ed2de 100644 --- a/app/lib/widget/handler/enhance_handler.dart +++ b/app/lib/widget/handler/enhance_handler.dart @@ -31,6 +31,7 @@ class EnhanceHandler { const EnhanceHandler({ required this.account, required this.file, + required this.isSaveToServer, }); static bool isSupportedFormat(File file) => @@ -67,6 +68,7 @@ class EnhanceHandler { headers: { "Authorization": Api.getAuthorizationHeaderValue(account), }, + isSaveToServer: isSaveToServer, ); break; @@ -80,6 +82,7 @@ class EnhanceHandler { headers: { "Authorization": Api.getAuthorizationHeaderValue(account), }, + isSaveToServer: isSaveToServer, ); break; @@ -92,6 +95,7 @@ class EnhanceHandler { headers: { "Authorization": Api.getAuthorizationHeaderValue(account), }, + isSaveToServer: isSaveToServer, ); break; @@ -108,6 +112,7 @@ class EnhanceHandler { headers: { "Authorization": Api.getAuthorizationHeaderValue(account), }, + isSaveToServer: isSaveToServer, ); break; } @@ -353,6 +358,7 @@ class EnhanceHandler { final Account account; final File file; + final bool isSaveToServer; static final _log = Logger("widget.handler.enhance_handler.EnhanceHandler"); } diff --git a/app/lib/widget/home_photos.dart b/app/lib/widget/home_photos.dart index 9532a7c5..4193eb7b 100644 --- a/app/lib/widget/home_photos.dart +++ b/app/lib/widget/home_photos.dart @@ -22,6 +22,7 @@ import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/event/native_event.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/language_util.dart' as language_util; @@ -78,11 +79,13 @@ class _HomePhotosState extends State _initBloc(); _web?.onInitState(); _prefUpdatedListener.begin(); + _imageProcessorUploadSuccessListener?.begin(); } @override dispose() { _prefUpdatedListener.end(); + _imageProcessorUploadSuccessListener?.end(); _web?.onDispose(); super.dispose(); } @@ -512,6 +515,13 @@ class _HomePhotosState extends State } } + void _onImageProcessorUploadSuccessEvent( + ImageProcessorUploadSuccessEvent ev) { + _log.info( + "[_onImageProcessorUploadSuccessEvent] Scheduling metadata task after next refresh"); + _hasFiredMetadataTask.value = false; + } + void _tryStartMetadataTask({ bool ignoreFired = false, }) { @@ -714,6 +724,10 @@ class _HomePhotosState extends State late final _prefUpdatedListener = AppEventListener(_onPrefUpdated); + late final _imageProcessorUploadSuccessListener = platform_k.isWeb + ? null + : NativeEventListener( + _onImageProcessorUploadSuccessEvent); late final _Web? _web = platform_k.isWeb ? _Web(this) : null; diff --git a/app/lib/widget/image_editor.dart b/app/lib/widget/image_editor.dart index 2dfbe447..17135233 100644 --- a/app/lib/widget/image_editor.dart +++ b/app/lib/widget/image_editor.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/help_utils.dart' as help_util; import 'package:nc_photos/k.dart' as k; @@ -258,6 +260,7 @@ class _ImageEditorState extends State { } Future _onSavePressed(BuildContext context) async { + final c = KiwiContainer().resolve(); await ImageProcessor.filter( "${widget.account.url}/${widget.file.path}", widget.file.filename, @@ -267,6 +270,7 @@ class _ImageEditorState extends State { headers: { "Authorization": Api.getAuthorizationHeaderValue(widget.account), }, + isSaveToServer: c.pref.isSaveEditResultToServerOr(), ); Navigator.of(context).pop(); } diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 6a71e4d2..f067d6ec 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -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 route ??= _handlePeopleBrowserRoute(settings); route ??= _handlePlaceBrowserRoute(settings); route ??= _handlePlacesBrowserRoute(settings); + route ??= _handleResultViewerRoute(settings); return route; } @@ -600,6 +602,25 @@ class _MyAppState extends State return null; } + Route? _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 args = ResultViewerArguments(queries["url"]!); + return ResultViewer.buildRoute(args); + } + } catch (e, stackTrace) { + _log.severe("[_handleResultViewerRoute] Failed while handling route", e, + stackTrace); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); final _navigatorKey = GlobalKey(); diff --git a/app/lib/widget/result_viewer.dart b/app/lib/widget/result_viewer.dart new file mode 100644 index 00000000..8aae2261 --- /dev/null +++ b/app/lib/widget/result_viewer.dart @@ -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 { + @override + initState() { + super.initState(); + _c = KiwiContainer().resolve(); + _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 _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"); +} diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index 42b57b4b..6c56d795 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -177,7 +177,8 @@ class _SettingsState extends State { Icons.auto_fix_high_outlined, color: AppTheme.getUnfocusedIconColor(context), ), - label: L10n.global().settingsPhotoEnhancementTitle, + label: L10n.global().settingsImageEditTitle, + description: L10n.global().settingsImageEditDescription, builder: () => const EnhancementSettings(), ), _buildSubSettings( @@ -1293,6 +1294,7 @@ class _EnhancementSettingsState extends State { super.initState(); _maxWidth = Pref().getEnhanceMaxWidthOr(); _maxHeight = Pref().getEnhanceMaxHeightOr(); + _isSaveEditResultToServer = Pref().isSaveEditResultToServerOr(); } @override @@ -1311,13 +1313,24 @@ class _EnhancementSettingsState extends State { slivers: [ SliverAppBar( pinned: true, - title: Text(L10n.global().settingsPhotoEnhancementPageTitle), + title: Text(L10n.global().settingsImageEditTitle), ), SliverList( delegate: SliverChildListDelegate( [ + SwitchListTile( + title: Text( + L10n.global().settingsImageEditSaveResultsToServerTitle), + subtitle: Text(_isSaveEditResultToServer + ? L10n.global() + .settingsImageEditSaveResultsToServerTrueDescription + : L10n.global() + .settingsImageEditSaveResultsToServerFalseDescription), + value: _isSaveEditResultToServer, + onChanged: _onSaveEditResultToServerChanged, + ), ListTile( - title: Text(L10n.global().settingsEnhanceMaxResolutionTitle), + title: Text(L10n.global().settingsEnhanceMaxResolutionTitle2), subtitle: Text("${_maxWidth}x$_maxHeight"), onTap: () => _onMaxResolutionTap(context), ), @@ -1335,7 +1348,7 @@ class _EnhancementSettingsState extends State { context: context, builder: (_) => AppTheme( child: AlertDialog( - title: Text(L10n.global().settingsEnhanceMaxResolutionTitle), + title: Text(L10n.global().settingsEnhanceMaxResolutionTitle2), content: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -1394,8 +1407,26 @@ class _EnhancementSettingsState extends State { } } + Future _onSaveEditResultToServerChanged(bool value) async { + final oldValue = _isSaveEditResultToServer; + setState(() { + _isSaveEditResultToServer = value; + }); + if (!await Pref().setSaveEditResultToServer(value)) { + _log.severe("[_onSaveEditResultToServerChanged] Failed writing pref"); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().writePreferenceFailureNotification), + duration: k.snackBarDurationNormal, + )); + setState(() { + _isSaveEditResultToServer = oldValue; + }); + } + } + late int _maxWidth; late int _maxHeight; + late bool _isSaveEditResultToServer; static final _log = Logger("widget.settings._EnhancementSettingsState"); } diff --git a/app/lib/widget/viewer.dart b/app/lib/widget/viewer.dart index 5756692e..3efec664 100644 --- a/app/lib/widget/viewer.dart +++ b/app/lib/widget/viewer.dart @@ -602,11 +602,13 @@ class _ViewerState extends State _log.shout("[_onEnhancePressed] Video file not supported"); return; } + final c = KiwiContainer().resolve(); _log.info("[_onEnhancePressed] Enhance file: ${file.path}"); EnhanceHandler( account: widget.account, file: file, + isSaveToServer: c.pref.isSaveEditResultToServerOr(), )(context); } diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt index fa6b04ec..d6fd252a 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt @@ -28,6 +28,7 @@ class ImageProcessorChannelHandler(context: Context) : call.argument("filename")!!, call.argument("maxWidth")!!, call.argument("maxHeight")!!, + call.argument("isSaveToServer")!!, call.argument("iteration")!!, result ) @@ -45,6 +46,7 @@ class ImageProcessorChannelHandler(context: Context) : call.argument("filename")!!, call.argument("maxWidth")!!, call.argument("maxHeight")!!, + call.argument("isSaveToServer")!!, call.argument("radius")!!, result ) @@ -62,6 +64,7 @@ class ImageProcessorChannelHandler(context: Context) : call.argument("filename")!!, call.argument("maxWidth")!!, call.argument("maxHeight")!!, + call.argument("isSaveToServer")!!, result ) } catch (e: Throwable) { @@ -78,6 +81,7 @@ class ImageProcessorChannelHandler(context: Context) : call.argument("filename")!!, call.argument("maxWidth")!!, call.argument("maxHeight")!!, + call.argument("isSaveToServer")!!, call.argument("styleUri")!!, call.argument("weight")!!, result @@ -96,6 +100,7 @@ class ImageProcessorChannelHandler(context: Context) : call.argument("filename")!!, call.argument("maxWidth")!!, call.argument("maxHeight")!!, + call.argument("isSaveToServer")!!, call.argument("filters")!!, result ) @@ -132,10 +137,10 @@ class ImageProcessorChannelHandler(context: Context) : private fun zeroDce( fileUrl: String, headers: Map?, filename: String, - maxWidth: Int, maxHeight: Int, iteration: Int, + maxWidth: Int, maxHeight: Int, isSaveToServer: Boolean, iteration: Int, result: MethodChannel.Result ) = method( - fileUrl, headers, filename, maxWidth, maxHeight, + fileUrl, headers, filename, maxWidth, maxHeight, isSaveToServer, ImageProcessorService.METHOD_ZERO_DCE, result, onIntent = { it.putExtra(ImageProcessorService.EXTRA_ITERATION, iteration) } @@ -143,9 +148,10 @@ class ImageProcessorChannelHandler(context: Context) : private fun deepLab3Portrait( fileUrl: String, headers: Map?, filename: String, - maxWidth: Int, maxHeight: Int, radius: Int, result: MethodChannel.Result + maxWidth: Int, maxHeight: Int, isSaveToServer: Boolean, radius: Int, + result: MethodChannel.Result ) = method( - fileUrl, headers, filename, maxWidth, maxHeight, + fileUrl, headers, filename, maxWidth, maxHeight, isSaveToServer, ImageProcessorService.METHOD_DEEP_LAP_PORTRAIT, result, onIntent = { it.putExtra(ImageProcessorService.EXTRA_RADIUS, radius) } @@ -153,18 +159,19 @@ class ImageProcessorChannelHandler(context: Context) : private fun esrgan( fileUrl: String, headers: Map?, filename: String, - maxWidth: Int, maxHeight: Int, result: MethodChannel.Result + maxWidth: Int, maxHeight: Int, isSaveToServer: Boolean, + result: MethodChannel.Result ) = method( - fileUrl, headers, filename, maxWidth, maxHeight, + fileUrl, headers, filename, maxWidth, maxHeight, isSaveToServer, ImageProcessorService.METHOD_ESRGAN, result ) private fun arbitraryStyleTransfer( fileUrl: String, headers: Map?, filename: String, - maxWidth: Int, maxHeight: Int, styleUri: String, weight: Float, - result: MethodChannel.Result + maxWidth: Int, maxHeight: Int, isSaveToServer: Boolean, + styleUri: String, weight: Float, result: MethodChannel.Result ) = method( - fileUrl, headers, filename, maxWidth, maxHeight, + fileUrl, headers, filename, maxWidth, maxHeight, isSaveToServer, ImageProcessorService.METHOD_ARBITRARY_STYLE_TRANSFER, result, onIntent = { it.putExtra( @@ -176,14 +183,14 @@ class ImageProcessorChannelHandler(context: Context) : private fun filter( fileUrl: String, headers: Map?, filename: String, - maxWidth: Int, maxHeight: Int, filters: List>, - result: MethodChannel.Result + maxWidth: Int, maxHeight: Int, isSaveToServer: Boolean, + filters: List>, result: MethodChannel.Result ) { // convert to serializable val l = arrayListOf() filters.mapTo(l, { HashMap(it) }) method( - fileUrl, headers, filename, maxWidth, maxHeight, + fileUrl, headers, filename, maxWidth, maxHeight, isSaveToServer, ImageProcessorService.METHOD_FILTER, result, onIntent = { it.putExtra(ImageProcessorService.EXTRA_FILTERS, l) @@ -204,7 +211,7 @@ class ImageProcessorChannelHandler(context: Context) : private fun method( fileUrl: String, headers: Map?, filename: String, - maxWidth: Int, maxHeight: Int, method: String, + maxWidth: Int, maxHeight: Int, isSaveToServer: Boolean, method: String, result: MethodChannel.Result, onIntent: ((Intent) -> Unit)? = null ) { val intent = Intent(context, ImageProcessorService::class.java).apply { @@ -216,6 +223,9 @@ class ImageProcessorChannelHandler(context: Context) : putExtra(ImageProcessorService.EXTRA_FILENAME, filename) putExtra(ImageProcessorService.EXTRA_MAX_WIDTH, maxWidth) putExtra(ImageProcessorService.EXTRA_MAX_HEIGHT, maxHeight) + putExtra( + ImageProcessorService.EXTRA_IS_SAVE_TO_SERVER, isSaveToServer + ) onIntent?.invoke(this) } ContextCompat.startForegroundService(context, intent) diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt index 84fdd76f..d04dbb22 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt @@ -37,6 +37,7 @@ class ImageProcessorService : Service() { const val EXTRA_FILENAME = "filename" const val EXTRA_MAX_WIDTH = "maxWidth" const val EXTRA_MAX_HEIGHT = "maxHeight" + const val EXTRA_IS_SAVE_TO_SERVER = "isSaveToServer" const val EXTRA_RADIUS = "radius" const val EXTRA_ITERATION = "iteration" const val EXTRA_STYLE_URI = "styleUri" @@ -134,7 +135,7 @@ class ImageProcessorService : Service() { // there are commands running in the bg addCommand( ImageProcessorEnhanceCommand( - startId, "null", "", null, "", 0, 0 + startId, "null", "", null, "", 0, 0, false ) ) } @@ -184,10 +185,11 @@ class ImageProcessorService : Service() { val filename = extras.getString(EXTRA_FILENAME)!! val maxWidth = extras.getInt(EXTRA_MAX_WIDTH) val maxHeight = extras.getInt(EXTRA_MAX_HEIGHT) + val isSaveToServer = extras.getBoolean(EXTRA_IS_SAVE_TO_SERVER) addCommand( ImageProcessorFilterCommand( startId, fileUrl, headers, filename, maxWidth, - maxHeight, filters + maxHeight, isSaveToServer, filters ) ) } @@ -211,10 +213,11 @@ class ImageProcessorService : Service() { val filename = extras.getString(EXTRA_FILENAME)!! val maxWidth = extras.getInt(EXTRA_MAX_WIDTH) val maxHeight = extras.getInt(EXTRA_MAX_HEIGHT) + val isSaveToServer = extras.getBoolean(EXTRA_IS_SAVE_TO_SERVER) addCommand( ImageProcessorEnhanceCommand( startId, method, fileUrl, headers, filename, maxWidth, - maxHeight, args = args + maxHeight, isSaveToServer, args = args ) ) } @@ -372,6 +375,7 @@ class ImageProcessorService : Service() { private fun notifyResult(event: MessageEvent) { if (event is ImageProcessorCompletedEvent) { + NativeEventChannelHandler.fire(ImageProcessorUploadSuccessEvent()) notificationManager.notify( RESULT_NOTIFICATION_ID, buildResultNotification(event.result) ) @@ -420,6 +424,7 @@ private abstract class ImageProcessorImageCommand( val filename: String, val maxWidth: Int, val maxHeight: Int, + val isSaveToServer: Boolean, ) : ImageProcessorCommand { abstract fun apply(context: Context, fileUri: Uri): Bitmap } @@ -432,9 +437,11 @@ private class ImageProcessorEnhanceCommand( filename: String, maxWidth: Int, maxHeight: Int, + isSaveToServer: Boolean, val args: Map = mapOf(), ) : ImageProcessorImageCommand( - startId, method, fileUrl, headers, filename, maxWidth, maxHeight + startId, method, fileUrl, headers, filename, maxWidth, maxHeight, + isSaveToServer ) { override fun apply(context: Context, fileUri: Uri): Bitmap { return when (method) { @@ -470,10 +477,11 @@ private class ImageProcessorFilterCommand( filename: String, maxWidth: Int, maxHeight: Int, + isSaveToServer: Boolean, val filters: List, ) : ImageProcessorImageCommand( startId, ImageProcessorService.METHOD_FILTER, fileUrl, headers, filename, - maxWidth, maxHeight + maxWidth, maxHeight, isSaveToServer ) { override fun apply(context: Context, fileUri: Uri): Bitmap { return ImageFilterProcessor( @@ -625,8 +633,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 +651,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 +680,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 +694,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 = getPersister(cmd.isSaveToServer) + return persister.persist(cmd, outFile) } finally { outFile.delete() } @@ -738,31 +735,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 = getPersister(cmd.isSaveToServer) + return persister.persist(cmd, outFile) + } finally { + outFile.delete() + } } private fun copyExif(from: ExifInterface, to: ExifInterface) { @@ -783,6 +780,14 @@ private open class ImageProcessorCommandTask(context: Context) : } } + private fun getPersister(isSaveToServer: Boolean): EnhancedFilePersister { + return if (isSaveToServer) { + EnhancedFileServerPersisterWithFallback(context) + } else { + EnhancedFileDevicePersister(context) + } + } + @SuppressLint("StaticFieldLeak") private val context = context } @@ -801,3 +806,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) +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEvent.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEvent.kt new file mode 100644 index 00000000..4151628c --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEvent.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin + +interface NativeEvent { + fun getId(): String + fun getData(): String? = null +} + +class ImageProcessorUploadSuccessEvent : NativeEvent { + companion object { + const val id = "ImageProcessorUploadSuccessEvent" + } + + override fun getId() = id +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEventChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEventChannelHandler.kt index 07d12957..dbd790dc 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEventChannelHandler.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEventChannelHandler.kt @@ -10,6 +10,20 @@ class NativeEventChannelHandler : MethodChannel.MethodCallHandler, const val EVENT_CHANNEL = "${K.LIB_ID}/native_event" const val METHOD_CHANNEL = "${K.LIB_ID}/native_event_method" + /** + * Fire native events on the native side + */ + fun fire(eventObj: NativeEvent) { + synchronized(eventSinks) { + for (s in eventSinks.values) { + s.success(buildMap { + put("event", eventObj.getId()) + eventObj.getData()?.also { put("data", it) } + }) + } + } + } + private val eventSinks = mutableMapOf() private var nextId = 0 } @@ -29,21 +43,27 @@ class NativeEventChannelHandler : MethodChannel.MethodCallHandler, } override fun onListen(arguments: Any?, events: EventChannel.EventSink) { - eventSinks[id] = events + synchronized(eventSinks) { + eventSinks[id] = events + } } override fun onCancel(arguments: Any?) { - eventSinks.remove(id) + synchronized(eventSinks) { + eventSinks.remove(id) + } } private fun fire( event: String, data: String?, result: MethodChannel.Result ) { - for (s in eventSinks.values) { - s.success(buildMap { - put("event", event) - if (data != null) put("data", data) - }) + synchronized(eventSinks) { + for (s in eventSinks.values) { + s.success(buildMap { + put("event", event) + if (data != null) put("data", data) + }) + } } result.success(null) } diff --git a/plugin/lib/src/image_processor.dart b/plugin/lib/src/image_processor.dart index 289a220e..e6821a86 100644 --- a/plugin/lib/src/image_processor.dart +++ b/plugin/lib/src/image_processor.dart @@ -82,6 +82,7 @@ class ImageProcessor { int maxHeight, int iteration, { Map? headers, + required bool isSaveToServer, }) => _methodChannel.invokeMethod("zeroDce", { "fileUrl": fileUrl, @@ -90,6 +91,7 @@ class ImageProcessor { "maxWidth": maxWidth, "maxHeight": maxHeight, "iteration": iteration, + "isSaveToServer": isSaveToServer, }); static Future deepLab3Portrait( @@ -99,6 +101,7 @@ class ImageProcessor { int maxHeight, int radius, { Map? headers, + required bool isSaveToServer, }) => _methodChannel.invokeMethod("deepLab3Portrait", { "fileUrl": fileUrl, @@ -107,6 +110,7 @@ class ImageProcessor { "maxWidth": maxWidth, "maxHeight": maxHeight, "radius": radius, + "isSaveToServer": isSaveToServer, }); static Future esrgan( @@ -115,6 +119,7 @@ class ImageProcessor { int maxWidth, int maxHeight, { Map? headers, + required bool isSaveToServer, }) => _methodChannel.invokeMethod("esrgan", { "fileUrl": fileUrl, @@ -122,6 +127,7 @@ class ImageProcessor { "filename": filename, "maxWidth": maxWidth, "maxHeight": maxHeight, + "isSaveToServer": isSaveToServer, }); static Future arbitraryStyleTransfer( @@ -132,6 +138,7 @@ class ImageProcessor { String styleUri, double weight, { Map? headers, + required bool isSaveToServer, }) => _methodChannel.invokeMethod("arbitraryStyleTransfer", { "fileUrl": fileUrl, @@ -141,6 +148,7 @@ class ImageProcessor { "maxHeight": maxHeight, "styleUri": styleUri, "weight": weight, + "isSaveToServer": isSaveToServer, }); static Future filter( @@ -150,6 +158,7 @@ class ImageProcessor { int maxHeight, List filters, { Map? headers, + required bool isSaveToServer, }) => _methodChannel.invokeMethod("filter", { "fileUrl": fileUrl, @@ -158,6 +167,7 @@ class ImageProcessor { "maxWidth": maxWidth, "maxHeight": maxHeight, "filters": filters.map((f) => f.toJson()).toList(), + "isSaveToServer": isSaveToServer, }); static Future filterPreview(