diff --git a/lib/presentation/localizations/app_localizations.dart b/lib/presentation/localizations/app_localizations.dart index 140c8e093..5111ff64a 100644 --- a/lib/presentation/localizations/app_localizations.dart +++ b/lib/presentation/localizations/app_localizations.dart @@ -3082,6 +3082,44 @@ class AppLocalizations { name: 'are_you_sure_you_want_to_discard_recording', ); } + String get allow { + return Intl.message( + 'Allow', + name: 'allow', + ); + } + + String get not_now { + return Intl.message( + 'Not now', + name: 'not_now', + ); + } + + String get explain_contact_permission { + return Intl.message( + 'LinShare requests access to your contacts solely to provide autocomplete suggestions when you are adding a recipient to share files or collaborate with others. Please note that your contacts are not synchronized with the server.', + name: 'explain_contact_permission', + ); + } + String get explain_audio_recorder_permission { + return Intl.message( + 'LinShare requests audio permission to record audio messages and phone state permission to handle pausing while recording during calls', + name: 'explain_audio_recorder_permission'); + } + + String get explain_camera_permission { + return Intl.message( + 'Linshare requests camera and microphone permissions to capture photos and videos and phone state permission to handle pausing while recording during calls ', + name: 'explain_camera_permission'); + } + + String get explain_storage_permission { + return Intl.message( + 'Linshare requests storage permission to let you upload files from your file system, ensuring you can easily share your documents, photos, and videos.', + name: 'explain_storage_permission', + ); + } } class AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/presentation/util/audio_recorder.dart b/lib/presentation/util/audio_recorder.dart index 592c966bb..930b2a3a8 100644 --- a/lib/presentation/util/audio_recorder.dart +++ b/lib/presentation/util/audio_recorder.dart @@ -33,19 +33,37 @@ import 'dart:io'; import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:dartz/dartz.dart'; import 'package:domain/domain.dart'; +import 'package:flutter/material.dart'; +import 'package:linshare_flutter_app/presentation/localizations/app_localizations.dart'; import 'package:linshare_flutter_app/presentation/util/permission_service.dart'; +import 'package:linshare_flutter_app/presentation/view/dialog/permission_dialog.dart'; import 'package:permission_handler/permission_handler.dart'; class AudioRecorder { final RecorderController recorderController = RecorderController(); - final PermissionService permissionService = PermissionService(); String? recordingPath; - Future> startRecordingAudio() async { + Future> startRecordingAudio( + BuildContext context) async { try { + if (!await PermissionService.arePermissionsGranted( + [Permission.microphone, Permission.phone])) { + final confirmExplanation = + await PermissionDialog.showPermissionExplanationDialog( + context, + Center( + child: Icon(Icons.warning, color: Colors.orange, size: 40), + ), + AppLocalizations.of(context) + .explain_audio_recorder_permission) ?? + false; + if (!confirmExplanation) { + return Left(AudioPermissionDenied(false)); + } + } final microphonePermission = - await permissionService.tryToGetPermissionForAudioRecording(); - await permissionService.tryToGetPermissionForPhoneState(); + await PermissionService.tryToGetPermissionForAudioRecording(); + await PermissionService.tryToGetPermissionForPhoneState(); if (microphonePermission.isGranted) { final tempPath = Directory.systemTemp.path; final currentTime = DateTime.now().millisecondsSinceEpoch; diff --git a/lib/presentation/util/local_file_picker.dart b/lib/presentation/util/local_file_picker.dart index 312185116..f65653859 100644 --- a/lib/presentation/util/local_file_picker.dart +++ b/lib/presentation/util/local_file_picker.dart @@ -28,16 +28,46 @@ // for the GNU Affero General Public License version // 3 and for // the Additional Terms applicable to LinShare software. - +import 'dart:io'; import 'package:dartz/dartz.dart'; import 'package:domain/domain.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:linshare_flutter_app/presentation/localizations/app_localizations.dart'; +import 'package:linshare_flutter_app/presentation/util/permission_service.dart'; +import 'package:linshare_flutter_app/presentation/view/dialog/permission_dialog.dart'; +import 'package:permission_handler/permission_handler.dart'; class LocalFilePicker { - - Future> pickFiles({FileType fileType = FileType.any}) async { + Future> pickFiles( + BuildContext context, + {FileType fileType = FileType.any}) async { try { - final filesResult = await FilePicker.platform.pickFiles(type: fileType, allowMultiple: true); + if (Platform.isAndroid && await PermissionService.isAndroid32AndLower()) { + final permissionStatus = await Permission.storage.status; + if (!permissionStatus.isGranted) { + final confirmExplanation = + await PermissionDialog.showPermissionExplanationDialog( + context, + Center( + child: + Icon(Icons.warning, color: Colors.orange, size: 40), + ), + AppLocalizations.of(context) + .explain_storage_permission) ?? + false; + if (!confirmExplanation) { + return Left(FilePickerFailure(Exception('Permission denied'))); + } + final requestedPermission = await PermissionService + .tryToGetPermissionForStorageForAndroid32AndLower(); + if (requestedPermission != PermissionStatus.granted) { + return Left(FilePickerFailure(Exception('Permission denied'))); + } + } + } + final filesResult = await FilePicker.platform + .pickFiles(type: fileType, allowMultiple: true); if (filesResult != null && filesResult.files.isNotEmpty) { final filesInfoResult = filesResult.files.map((platformFile) { return FileInfo( diff --git a/lib/presentation/util/media_picker_from_camera.dart b/lib/presentation/util/media_picker_from_camera.dart index 6aae66703..cb55c728c 100644 --- a/lib/presentation/util/media_picker_from_camera.dart +++ b/lib/presentation/util/media_picker_from_camera.dart @@ -32,10 +32,12 @@ import 'package:dartz/dartz.dart'; import 'package:domain/domain.dart'; import 'package:flutter/material.dart'; +import 'package:linshare_flutter_app/presentation/localizations/app_localizations.dart'; import 'package:linshare_flutter_app/presentation/util/permission_service.dart'; import 'package:linshare_flutter_app/presentation/util/router/app_navigation.dart'; import 'package:linshare_flutter_app/presentation/view/camera_picker/custom_camera_picker_viewer.dart'; import 'package:linshare_flutter_app/presentation/view/dialog/open_settings_dialog.dart'; +import 'package:linshare_flutter_app/presentation/view/dialog/permission_dialog.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:phone_state/phone_state.dart'; import 'package:wechat_camera_picker/wechat_camera_picker.dart'; @@ -63,12 +65,29 @@ class MediaPickerFromCamera { AppNavigation appNavigation, ) async { try { + if (!await PermissionService.arePermissionsGranted( + [Permission.camera, Permission.microphone])) { + final confirmExplanation = + await PermissionDialog.showPermissionExplanationDialog( + context, + Center( + child: Icon(Icons.warning, color: Colors.orange, size: 40), + ), + AppLocalizations.of(context).explain_camera_permission) ?? + false; + if (!confirmExplanation) { + return Left( + CameraPermissionDenied(), + ); + } + } final cameraPermission = - await PermissionService().tryToGetPermissionForCamera(); + await PermissionService.tryToGetPermissionForCamera(); + final microphonePermission = - await PermissionService().tryToGetPermissionForAudioRecording(); + await PermissionService.tryToGetPermissionForAudioRecording(); final phonePermission = - await PermissionService().tryToGetPermissionForPhoneState(); + await PermissionService.tryToGetPermissionForPhoneState(); if (cameraPermission.isGranted && microphonePermission.isGranted) { List pickedFiles = []; diff --git a/lib/presentation/util/permission_service.dart b/lib/presentation/util/permission_service.dart index 5b500e929..8d2e28ce0 100644 --- a/lib/presentation/util/permission_service.dart +++ b/lib/presentation/util/permission_service.dart @@ -1,21 +1,34 @@ +import 'package:device_info/device_info.dart'; import 'package:permission_handler/permission_handler.dart'; class PermissionService { - Future tryToGetPermissionForCamera() async { + static Future arePermissionsGranted( + List permissions) async { + for (var permission in permissions) { + if (!await permission.isGranted) { + return false; + } + } + return true; + } + + static Future tryToGetPermissionForCamera() async { final status = await Permission.camera.request(); return status; } - Future tryToGetPermissionForAudioRecording() async { + static Future tryToGetPermissionForAudioRecording() async { final status = await Permission.microphone.request(); return status; } - Future tryToGetPermissionForPhoneState() async { + + static Future tryToGetPermissionForPhoneState() async { final status = await Permission.phone.request(); return status; } - Future handleMediaPickerPermissionAndroidHigher33() async { + static Future + handleMediaPickerPermissionAndroidHigher33() async { PermissionStatus? photoPermission = await Permission.photos.status; if (photoPermission == PermissionStatus.denied) { photoPermission = await Permission.photos.request(); @@ -37,4 +50,18 @@ class PermissionService { return PermissionStatus.denied; } + + static Future + tryToGetPermissionForStorageForAndroid32AndLower() async { + final status = await Permission.storage.request(); + return status; + } + + static Future isAndroid32AndLower() async { + final deviceInfoPlugin = DeviceInfoPlugin(); + final androidInfo = await deviceInfoPlugin.androidInfo; + final apiLevel = androidInfo.version.sdkInt; + return apiLevel <= 32; + } + } diff --git a/lib/presentation/view/dialog/permission_dialog.dart b/lib/presentation/view/dialog/permission_dialog.dart new file mode 100644 index 000000000..0754e66f5 --- /dev/null +++ b/lib/presentation/view/dialog/permission_dialog.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:linshare_flutter_app/presentation/localizations/app_localizations.dart'; + +class PermissionDialog { + static Future showPermissionExplanationDialog( + BuildContext context, Widget title, String content) async { + return showDialog( + barrierDismissible: false, + context: context, + builder: (context) => AlertDialog( + title: title, + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context).not_now), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(AppLocalizations.of(context).allow), + ), + ], + )); + } +} diff --git a/lib/presentation/widget/myspace/my_space_viewmodel.dart b/lib/presentation/widget/myspace/my_space_viewmodel.dart index cf7e7f845..691d0306a 100644 --- a/lib/presentation/widget/myspace/my_space_viewmodel.dart +++ b/lib/presentation/widget/myspace/my_space_viewmodel.dart @@ -726,7 +726,8 @@ class MySpaceViewModel extends BaseViewModel { ThunkAction _pickFileAction(BuildContext context, FileType fileType) { return (Store store) async { store.dispatch(OutsideAppAction(outsideAppType: ActionOutsideAppType.PICKING_FILE)); - await _localFilePicker.pickFiles(fileType: fileType) + await _localFilePicker + .pickFiles(context, fileType: fileType) .then((result) { store.dispatch(OutsideAppAction(outsideAppType: ActionOutsideAppType.NONE)); result.fold( diff --git a/lib/presentation/widget/record_audio/record_audio_viewmodel.dart b/lib/presentation/widget/record_audio/record_audio_viewmodel.dart index fd8ed1c93..7c6e38de1 100644 --- a/lib/presentation/widget/record_audio/record_audio_viewmodel.dart +++ b/lib/presentation/widget/record_audio/record_audio_viewmodel.dart @@ -73,8 +73,8 @@ class RecordAudioViewModel extends BaseViewModel { ); } - void startAudioRecording() { - audioRecorder.startRecordingAudio().then((result) { + void startAudioRecording(BuildContext context) { + audioRecorder.startRecordingAudio(context).then((result) { result.fold((failure) { store.dispatch( StopRecording(), @@ -188,12 +188,12 @@ class RecordAudioViewModel extends BaseViewModel { ); } - void pauseAndStartAudioRecording() { + void pauseAndStartAudioRecording(BuildContext context) { store.state.audioRecorderState.viewState.fold( (failure) => null, (success) { if (success is IdleState) { - startAudioRecording(); + startAudioRecording(context); } else if (success is AudioRecorderStarted) { pauseAudioRecording(); } else if (success is AudioRecorderPaused) { diff --git a/lib/presentation/widget/record_audio/record_audio_widget.dart b/lib/presentation/widget/record_audio/record_audio_widget.dart index 064548c8f..d6d0f8739 100644 --- a/lib/presentation/widget/record_audio/record_audio_widget.dart +++ b/lib/presentation/widget/record_audio/record_audio_widget.dart @@ -177,7 +177,8 @@ class RecordAudioWidgetState extends State { Widget recordAudioStartPauseButton() { return FloatingActionButton( heroTag: 'record_audio_start_pause_button', - onPressed: recordAudioViewModel.pauseAndStartAudioRecording, + onPressed: () => + recordAudioViewModel.pauseAndStartAudioRecording(context), backgroundColor: Colors.red, child: StoreConnector( converter: (store) => store.state.audioRecorderState, diff --git a/lib/presentation/widget/shared_space_document/shared_space_document_viewmodel.dart b/lib/presentation/widget/shared_space_document/shared_space_document_viewmodel.dart index 17d0e2542..fd65d5de0 100644 --- a/lib/presentation/widget/shared_space_document/shared_space_document_viewmodel.dart +++ b/lib/presentation/widget/shared_space_document/shared_space_document_viewmodel.dart @@ -439,16 +439,17 @@ class SharedSpaceDocumentNodeViewModel extends BaseViewModel { store.dispatch(_handleUploadFileMenuAction(context, actionTiles)); } - void openFilePickerByType(FileType fileType) { + void openFilePickerByType(FileType fileType, BuildContext context) { _appNavigation.popBack(); - store.dispatch(_pickFileAction(fileType)); + store.dispatch(_pickFileAction(fileType, context)); } - ThunkAction _pickFileAction(FileType fileType) { + ThunkAction _pickFileAction( + FileType fileType, BuildContext context) { return (Store store) async { store.dispatch(OutsideAppAction(outsideAppType: ActionOutsideAppType.PICKING_FILE)); await _localFilePicker - .pickFiles(fileType: fileType) + .pickFiles(context, fileType: fileType) .then((result) { store.dispatch(OutsideAppAction(outsideAppType: ActionOutsideAppType.NONE)); result.fold( diff --git a/lib/presentation/widget/shared_space_document/shared_space_document_widget.dart b/lib/presentation/widget/shared_space_document/shared_space_document_widget.dart index b4b2c6e44..49f111343 100644 --- a/lib/presentation/widget/shared_space_document/shared_space_document_widget.dart +++ b/lib/presentation/widget/shared_space_document/shared_space_document_widget.dart @@ -920,7 +920,8 @@ class _SharedSpaceDocumentWidgetState extends State { Key('pick_photo_and_video_context_menu_action'), SvgPicture.asset(imagePath.icPhotoLibrary, width: 24, height: 24, fit: BoxFit.fill,color: AppColor.primaryColor,), AppLocalizations.of(context).photos_and_videos) - .onActionClick((_) => sharedSpaceDocumentViewModel.openFilePickerByType(FileType.media)) + .onActionClick((_) => sharedSpaceDocumentViewModel.openFilePickerByType( + FileType.media, context)) .build(); } @@ -935,7 +936,8 @@ class _SharedSpaceDocumentWidgetState extends State { color: AppColor.primaryColor, ), AppLocalizations.of(context).browse) - .onActionClick((_) => sharedSpaceDocumentViewModel.openFilePickerByType(FileType.any)) + .onActionClick((_) => sharedSpaceDocumentViewModel.openFilePickerByType( + FileType.any, context)) .build(); } diff --git a/lib/presentation/widget/upload_file/upload_file_viewmodel.dart b/lib/presentation/widget/upload_file/upload_file_viewmodel.dart index 71ca52590..10ac28bbf 100644 --- a/lib/presentation/widget/upload_file/upload_file_viewmodel.dart +++ b/lib/presentation/widget/upload_file/upload_file_viewmodel.dart @@ -41,6 +41,7 @@ import 'package:linshare_flutter_app/presentation/redux/states/app_state.dart'; import 'package:linshare_flutter_app/presentation/util/extensions/media_type_extension.dart'; import 'package:linshare_flutter_app/presentation/util/router/app_navigation.dart'; import 'package:linshare_flutter_app/presentation/util/router/route_paths.dart'; +import 'package:linshare_flutter_app/presentation/view/dialog/permission_dialog.dart'; import 'package:linshare_flutter_app/presentation/widget/base/base_viewmodel.dart'; import 'package:linshare_flutter_app/presentation/widget/destination_picker/destination_picker_action/choose_destination_picker_action.dart'; import 'package:linshare_flutter_app/presentation/widget/destination_picker/destination_picker_action/negative_destination_picker_action.dart'; @@ -117,8 +118,6 @@ class UploadFileViewModel extends BaseViewModel { break; } }); - - Future.delayed(Duration(milliseconds: 500), () => _checkContactPermission()); } void backToMySpace() { @@ -338,11 +337,22 @@ class UploadFileViewModel extends BaseViewModel { store.dispatch(MySpaceClearSelectedDocumentsAction()); } - void _checkContactPermission() async { + void checkContactPermission(BuildContext context) async { final permissionStatus = await Permission.contacts.status; if (permissionStatus.isGranted) { _contactSuggestionSource = ContactSuggestionSource.all; } else if (!permissionStatus.isPermanentlyDenied) { + final confirmExplanation = + await PermissionDialog.showPermissionExplanationDialog( + context, + Center( + child: Icon(Icons.warning, color: Colors.orange, size: 40), + ), + AppLocalizations.of(context).explain_contact_permission) ?? + false; + if (!confirmExplanation) { + return; + } final requestedPermission = await Permission.contacts.request(); _contactSuggestionSource = requestedPermission == PermissionStatus.granted ? ContactSuggestionSource.all diff --git a/lib/presentation/widget/upload_file/upload_file_widget.dart b/lib/presentation/widget/upload_file/upload_file_widget.dart index 06980a61f..35bbc0885 100644 --- a/lib/presentation/widget/upload_file/upload_file_widget.dart +++ b/lib/presentation/widget/upload_file/upload_file_widget.dart @@ -68,6 +68,7 @@ class _UploadFileWidgetState extends State { void initState() { super.initState(); uploadFileViewModel.cancelSelection(); + uploadFileViewModel.checkContactPermission(context); } @override diff --git a/lib/presentation/widget/upload_request_creation/upload_request_creation_viewmodel.dart b/lib/presentation/widget/upload_request_creation/upload_request_creation_viewmodel.dart index e947c9483..ae519924d 100644 --- a/lib/presentation/widget/upload_request_creation/upload_request_creation_viewmodel.dart +++ b/lib/presentation/widget/upload_request_creation/upload_request_creation_viewmodel.dart @@ -54,6 +54,7 @@ import 'package:linshare_flutter_app/presentation/util/extensions/list_functiona import 'package:linshare_flutter_app/presentation/util/extensions/string_extensions.dart'; import 'package:linshare_flutter_app/presentation/util/router/app_navigation.dart'; import 'package:linshare_flutter_app/presentation/util/value_notifier_common.dart'; +import 'package:linshare_flutter_app/presentation/view/dialog/permission_dialog.dart'; import 'package:linshare_flutter_app/presentation/view/modal_sheets/modal_card.dart'; import 'package:linshare_flutter_app/presentation/view/modal_sheets/reach_limitation_alert.dart'; import 'package:linshare_flutter_app/presentation/widget/base/base_viewmodel.dart'; @@ -63,6 +64,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:redux/src/store.dart'; import 'package:rxdart/rxdart.dart'; + class UploadRequestCreationViewModel extends BaseViewModel { final AppNavigation _appNavigation; @@ -146,8 +148,6 @@ class UploadRequestCreationViewModel extends BaseViewModel { }).listen((event) { event ? _enableCreateButton.add(true) : _enableCreateButton.add(false); }); - - Future.delayed(Duration(milliseconds: 500), () => _checkContactPermission()); } void _disposeValueNotifier() { @@ -534,11 +534,22 @@ class UploadRequestCreationViewModel extends BaseViewModel { ); } - void _checkContactPermission() async { + void checkContactPermission(BuildContext context) async { final permissionStatus = await Permission.contacts.status; if (permissionStatus.isGranted) { _contactSuggestionSource = ContactSuggestionSource.all; } else if (!permissionStatus.isPermanentlyDenied) { + final confirmExplanation = + await PermissionDialog.showPermissionExplanationDialog( + context, + Center( + child: Icon(Icons.warning, color: Colors.orange, size: 40), + ), + AppLocalizations.of(context).explain_contact_permission) ?? + false; + if (!confirmExplanation) { + return; + } final requestedPermission = await Permission.contacts.request(); _contactSuggestionSource = requestedPermission == PermissionStatus.granted ? ContactSuggestionSource.all diff --git a/lib/presentation/widget/upload_request_creation/upload_request_creation_widget.dart b/lib/presentation/widget/upload_request_creation/upload_request_creation_widget.dart index 92b88414d..41bff0728 100644 --- a/lib/presentation/widget/upload_request_creation/upload_request_creation_widget.dart +++ b/lib/presentation/widget/upload_request_creation/upload_request_creation_widget.dart @@ -73,6 +73,7 @@ class _UploadRequestCreationWidgetState extends State _checkContactPermission()); - } + : super(store); void initState(AddRecipientsUploadRequestGroupArgument argument) { store.dispatch(_getAllUploadRequests(argument.uploadRequestGroup.uploadRequestGroupId)); @@ -187,11 +187,22 @@ class AddRecipientsUploadRequestGroupViewModel extends BaseViewModel { } } - void _checkContactPermission() async { + void checkContactPermission(BuildContext context) async { final permissionStatus = await Permission.contacts.status; if (permissionStatus.isGranted) { _contactSuggestionSource = ContactSuggestionSource.all; } else if (!permissionStatus.isPermanentlyDenied) { + final confirmExplanation = + await PermissionDialog.showPermissionExplanationDialog( + context, + Center( + child: Icon(Icons.warning, color: Colors.orange, size: 40), + ), + AppLocalizations.of(context).explain_contact_permission) ?? + false; + if (!confirmExplanation) { + return; + } final requestedPermission = await Permission.contacts.request(); _contactSuggestionSource = requestedPermission == PermissionStatus.granted ? ContactSuggestionSource.all diff --git a/lib/presentation/widget/upload_request_group_add_recipient/add_recipients_upload_request_group_widget.dart b/lib/presentation/widget/upload_request_group_add_recipient/add_recipients_upload_request_group_widget.dart index 35dea4c52..7c1ca6fec 100644 --- a/lib/presentation/widget/upload_request_group_add_recipient/add_recipients_upload_request_group_widget.dart +++ b/lib/presentation/widget/upload_request_group_add_recipient/add_recipients_upload_request_group_widget.dart @@ -64,13 +64,14 @@ class _AddSharedSpaceMemberWidgetState extends State