From 9e82f3d7b82a290f432da9f8cd4c7828c9f28346 Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 6 Dec 2024 14:36:48 +0800 Subject: [PATCH] fix: unable to open local file using afLaunchUrl function (#6927) * fix: unable to open local file using afLaunchUrl function * chore: use the latest api to open the local file * chore: use the latest api to open the local file * chore: use the latest api to open the local file * test: add local paht regex test --- .../lib/core/helpers/url_launcher.dart | 79 +++++++++++++++++-- .../base/view_page/more_bottom_sheet.dart | 2 +- .../desktop_grid/desktop_grid_media_cell.dart | 13 ++- .../desktop_row_detail_media_cell.dart | 3 +- .../file/file_block_component.dart | 19 +---- .../editor_plugins/file/file_util.dart | 5 +- .../lib/shared/patterns/common_patterns.dart | 3 + .../auth/af_cloud_auth_service.dart | 2 +- .../lib/util/share_log_files.dart | 4 +- .../settings/ai/plugin_state_bloc.dart | 3 +- .../pages/settings_manage_data_view.dart | 7 +- .../theme_upload_learn_more_button.dart | 7 +- .../interactive_image_toolbar.dart | 4 +- .../url_launcher/url_launcher_test.dart | 19 +++++ frontend/resources/translations/en.json | 9 ++- 15 files changed, 128 insertions(+), 51 deletions(-) create mode 100644 frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index 94d2074c6bf3d..2b0bc7b345e54 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -1,16 +1,24 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; +import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:open_filex/open_filex.dart'; import 'package:string_validator/string_validator.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; typedef OnFailureCallback = void Function(Uri uri); -Future afLaunchUrl( +/// Launch the uri +/// +/// If the uri is a local file path, it will be opened with the OpenFilex. +/// Otherwise, it will be launched with the url_launcher. +Future afLaunchUri( Uri uri, { BuildContext? context, OnFailureCallback? onFailure, @@ -18,6 +26,18 @@ Future afLaunchUrl( String? webOnlyWindowName, bool addingHttpSchemeWhenFailed = false, }) async { + final url = uri.toString(); + final decodedUrl = Uri.decodeComponent(url); + + // check if the uri is the local file path + if (localPathRegex.hasMatch(decodedUrl)) { + return _afLaunchLocalUri( + uri, + context: context, + onFailure: onFailure, + ); + } + // try to launch the uri directly bool result; try { @@ -32,7 +52,7 @@ Future afLaunchUrl( } // if the uri is not a valid url, try to launch it with http scheme - final url = uri.toString(); + if (addingHttpSchemeWhenFailed && !result && !isURL(url, {'require_protocol': true})) { @@ -54,9 +74,14 @@ Future afLaunchUrl( return result; } +/// Launch the url string +/// +/// See [afLaunchUri] for more details. Future afLaunchUrlString( String url, { bool addingHttpSchemeWhenFailed = false, + BuildContext? context, + OnFailureCallback? onFailure, }) async { final Uri uri; try { @@ -67,12 +92,56 @@ Future afLaunchUrlString( } // try to launch the uri directly - return afLaunchUrl( + return afLaunchUri( uri, addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed, + context: context, + onFailure: onFailure, ); } +/// Launch the local uri +/// +/// See [afLaunchUri] for more details. +Future _afLaunchLocalUri( + Uri uri, { + BuildContext? context, + OnFailureCallback? onFailure, +}) async { + final decodedUrl = Uri.decodeComponent(uri.toString()); + // open the file with the OpenfileX + var result = await OpenFilex.open(decodedUrl); + if (result.type != ResultType.done) { + // For the file cant be opened, fallback to open the folder + final parentFolder = Directory(decodedUrl).parent.path; + result = await OpenFilex.open(parentFolder); + } + // show the toast if the file is not found + final message = switch (result.type) { + ResultType.done => LocaleKeys.openFileMessage_success.tr(), + ResultType.fileNotFound => LocaleKeys.openFileMessage_fileNotFound.tr(), + ResultType.noAppToOpen => LocaleKeys.openFileMessage_noAppToOpenFile.tr(), + ResultType.permissionDenied => + LocaleKeys.openFileMessage_permissionDenied.tr(), + ResultType.error => LocaleKeys.failedToOpenUrl.tr(), + }; + if (context != null && context.mounted) { + showToastNotification( + context, + message: message, + type: result.type == ResultType.done + ? ToastificationType.success + : ToastificationType.error, + ); + } + final openFileSuccess = result.type == ResultType.done; + if (!openFileSuccess && onFailure != null) { + onFailure(uri); + Log.error('Failed to open file: $result.message'); + } + return openFileSuccess; +} + void _errorHandler( Uri uri, { BuildContext? context, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index eba8b090252cf..25541ed7d4cc8 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -189,7 +189,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { final url = context.read().state.url; if (url.isNotEmpty) { unawaited( - afLaunchUrl( + afLaunchUri( Uri.parse(url), mode: LaunchMode.externalApplication, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart index bcf136bcf10d9..f66d106ed0404 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart @@ -1,24 +1,23 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; -import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; -import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -159,7 +158,7 @@ class GridMediaCellSkin extends IEditableMediaCellSkin { List files, ) { if (file.fileType != MediaFileTypePB.Image) { - afLaunchUrlString(file.url); + afLaunchUrlString(file.url, context: context); return; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart index 772149fa5971f..68c5191682dec 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart @@ -27,7 +27,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:reorderables/reorderables.dart'; const _dropFileKey = 'files_media'; @@ -479,7 +478,7 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> { ? null : () { if (file.uploadType == FileUploadTypePB.LocalFile) { - OpenFilex.open(file.url); + afLaunchUrlString(file.url); return; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart index e4dd1d4e4a4c0..f1ffbe9d11179 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -10,7 +10,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; @@ -19,7 +18,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -323,22 +321,7 @@ class FileBlockComponentState extends State FileUrlType urlType, String url, ) async { - if ([FileUrlType.cloud, FileUrlType.network].contains(urlType)) { - await afLaunchUrlString(url); - } else { - final result = await OpenFilex.open(url); - if (result.type == ResultType.done) { - return; - } - - if (context.mounted) { - showToastNotification( - context, - message: LocaleKeys.document_plugins_file_failedToOpenMsg.tr(), - type: ToastificationType.error, - ); - } - } + await afLaunchUrlString(url, context: context); } void _openMenu() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart index f09aa1ca5e130..3ab93b4c95f9e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -1,8 +1,6 @@ import 'dart:convert'; import 'dart:io'; -import 'package:flutter/material.dart'; - import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; @@ -22,6 +20,7 @@ import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:universal_platform/universal_platform.dart'; @@ -102,7 +101,7 @@ Future downloadMediaFile( FileUploadTypePB.LocalFile, ].contains(file.uploadType)) { /// When the file is a network file or a local file, we can directly open the file. - await afLaunchUrl(Uri.parse(file.url)); + await afLaunchUrlString(file.url); } else { if (userProfile == null) { return showToastNotification( diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart index 20444f72059da..ebd310a747741 100644 --- a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -42,3 +42,6 @@ final appflowySharePageLinkRegex = RegExp(appflowySharePageLinkPattern); const _numberedListPattern = r'^(\d+)\.'; final numberedListRegex = RegExp(_numberedListPattern); + +const _localPathPattern = r'^(file:\/\/|\/|\\|[a-zA-Z]:[/\\]|\.{1,2}[/\\])'; +final localPathRegex = RegExp(_localPathPattern, caseSensitive: false); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart index ae7fe379823c7..149bddc951125 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -56,7 +56,7 @@ class AppFlowyCloudAuthService implements AuthService { (data) async { // Open the webview with oauth url final uri = Uri.parse(data.oauthUrl); - final isSuccess = await afLaunchUrl( + final isSuccess = await afLaunchUri( uri, mode: LaunchMode.externalApplication, webOnlyWindowName: '_self', diff --git a/frontend/appflowy_flutter/lib/util/share_log_files.dart b/frontend/appflowy_flutter/lib/util/share_log_files.dart index 21955e6c051bf..d7e7b6ce87151 100644 --- a/frontend/appflowy_flutter/lib/util/share_log_files.dart +++ b/frontend/appflowy_flutter/lib/util/share_log_files.dart @@ -1,11 +1,11 @@ import 'dart:io'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:archive/archive_io.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -67,7 +67,7 @@ Future shareLogFiles(BuildContext? context) async { await zipFile.delete(); } else { // open the directory - await OpenFilex.open(zipFile.path); + await afLaunchUri(zipFile.uri); } } catch (e) { if (context != null && context.mounted) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart index 41070e2fe4bb9..4f24309bdee47 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart @@ -8,6 +8,7 @@ import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:url_launcher/url_launcher.dart' show launchUrl; + part 'plugin_state_bloc.freezed.dart'; class PluginStateBloc extends Bloc { @@ -91,7 +92,7 @@ class PluginStateBloc extends Bloc { final result = await AIEventGetModelStorageDirectory().send(); result.fold( (data) { - afLaunchUrl(Uri.file(data.filePath)); + afLaunchUri(Uri.file(data.filePath)); }, (err) => Log.error(err.toString()), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index 3499580bbee13..7c096b4b2f99f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -1,8 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -28,6 +25,8 @@ import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -450,7 +449,7 @@ class _DataPathActions extends StatelessWidget { label: LocaleKeys.settings_manageDataPage_dataStorage_actions_open.tr(), icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)), - onPressed: () => afLaunchUrl(Uri.file(currentPath)), + onPressed: () => afLaunchUri(Uri.file(currentPath)), ), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart index d57d2d2a00f8d..9e25dece0ec84 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart @@ -1,14 +1,13 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; class ThemeUploadLearnMoreButton extends StatelessWidget { const ThemeUploadLearnMoreButton({super.key}); @@ -32,7 +31,7 @@ class ThemeUploadLearnMoreButton extends StatelessWidget { ), onPressed: () async { final uri = Uri.parse(learnMoreURL); - await afLaunchUrl( + await afLaunchUri( uri, context: context, onFailure: (_) async { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart index 223910ceac8e2..c1d9823bd1719 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -220,12 +220,12 @@ class InteractiveImageToolbar extends StatelessWidget { Future _locateOrDownloadImage(BuildContext context) async { if (currentImage.isLocal) { /// If the image type is local, we simply open the image - await afLaunchUrl(Uri.file(currentImage.url)); + await afLaunchUri(Uri.file(currentImage.url)); } else if (currentImage.isNotInternal) { // In case of eg. Unsplash images (images without extension type in URL), // we don't know their mimetype. In the future we can write a parser // using the Mime package and read the image to get the proper extension. - await afLaunchUrl(Uri.parse(currentImage.url)); + await afLaunchUri(Uri.parse(currentImage.url)); } else { if (userProfile == null) { return showSnapBar( diff --git a/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart b/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart new file mode 100644 index 0000000000000..feac569127cdd --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart @@ -0,0 +1,19 @@ +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('url launcher unit test', () { + test('launch local uri', () async { + const localUris = [ + 'file://path/to/file.txt', + '/path/to/file.txt', + 'C:\\path\\to\\file.txt', + '../path/to/file.txt', + ]; + for (final uri in localUris) { + final result = localPathRegex.hasMatch(uri); + expect(result, true); + } + }); + }); +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index dc93847dd6703..8b1d97dba5f9b 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2896,5 +2896,12 @@ "favoriteDisabledHint": "Cannot favorite this view", "pinTab": "Pin", "unpinTab": "Unpin" + }, + "openFileMessage": { + "success": "File opened successfully", + "fileNotFound": "File not found", + "noAppToOpenFile": "No app to open this file", + "permissionDenied": "No permission to open this file", + "unknownError": "File open failed" } -} \ No newline at end of file +}