diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index feb9edf..c09ab85 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,5 @@ to allow setting breakpoints, to provide hot reload, etc. --> + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index af25218..5292f69 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ android:icon="@mipmap/ic_launcher"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -28,4 +68,5 @@ android:value="2" /> + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index feb9edf..c09ab85 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -4,4 +4,5 @@ to allow setting breakpoints, to provide hot reload, etc. --> + diff --git a/assets/i18n/en.json b/assets/i18n/en.json index db65a25..5a4de9d 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -35,7 +35,8 @@ "dismiss": "Dismiss", "multipaste": "multipaste", "errors": { - "not_found": "Not found" + "not_found": "Not found", + "retrieval_intent": "An error occurred while retrieving shared data" } }, "startup": { @@ -67,7 +68,7 @@ "filesize": "Filesize", "link": "Link", "date": "Date", - "open_link": "Open in browser", + "mimetype": "Mimetype", "delete": "Delete permanently", "multipaste_element": "Included as multipaste item", "errors": { @@ -82,7 +83,9 @@ }, "about": { "headline": "Welcome to FileBin mobile!", - "description": "This application is a mobile client for FileBin and it's open source. It helps you to manage your pastes.\n\nIn order to use the application, you need access to a FileBin instance and an API key to sign in. It's recommended that the API key has at least 'apikey' access-level.", + "description": "This application is a mobile client for FileBin and it's open source. It helps you to manage your pastes.\n\nIn order to use the application, you need access to a FileBin instance.", + "faq_headline": "F.A.Q", + "faq": "- How do I login?\nInsert your instance URL and an API key which you can generate in the web interface of FileBin. You should use 'apikey' access-level in order to show history.\n\n- Why is storage permission required?\nIt's not required, but highly advised to grant it. Otherwise sharing files with the app won't work correctly and you might think that sharing has no effect.\n\n- When I am logged out, sharing files via share with the app won't list all files I selected after I login.\nPlease login before you start using the app. Account information are persisted. You only need to do it once.", "contact_us": "Feedback? Issues?", "website": "Main application: https://github.com/Bluewind/filebin\n\nMobile: https://github.com/v4rakh/fbmobile" }, @@ -108,6 +111,14 @@ "description": "Could not open '{link}'. Please ensure that you have an application installed which handles opening such link types." } }, + "permission_service": { + "dialog": { + "title": "Storage permission", + "description": "Storage permission should be granted to the app so that it can work properly. Do you want to grant permission or ignore this message permanently in the future?", + "grant": "Grant", + "ignore": "Ignore" + } + }, "dialog": { "confirm": "OK", "cancel": "Cancel" diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9618a80..8ba1451 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -48,6 +48,19 @@ NSAppleMusicUsageDescription Allow to select music files and upload them via the app + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ShareMedia + + + + + // TODO follow steps 2) on create share extension (https://pub.dev/packages/receive_sharing_intent) NSPhotoLibraryUsageDescription Allow to select photos and upload them via the app diff --git a/lib/constants.dart b/lib/constants.dart index 7d42486..0879c79 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,4 +1,6 @@ class Constants { static const int apiRequestTimeoutLimit = 8; static const String apiUrlSuffix = '/api/v2.2.0'; + + static const int mediaPermissionCheckInterval = 120 * 1000; } diff --git a/lib/core/manager/lifecycle_manager.dart b/lib/core/manager/lifecycle_manager.dart index d7a0a04..0cb5aa4 100644 --- a/lib/core/manager/lifecycle_manager.dart +++ b/lib/core/manager/lifecycle_manager.dart @@ -3,6 +3,7 @@ import 'package:logger/logger.dart'; import '../../core/services/session_service.dart'; import '../../locator.dart'; +import '../services/permission_service.dart'; import '../services/stoppable_service.dart'; import '../util/logger.dart'; @@ -18,7 +19,7 @@ class LifeCycleManager extends StatefulWidget { class _LifeCycleManagerState extends State with WidgetsBindingObserver { final Logger logger = getLogger(); - List servicesToManage = [locator()]; + List servicesToManage = [locator(), locator()]; @override Widget build(BuildContext context) { diff --git a/lib/core/services/permission_service.dart b/lib/core/services/permission_service.dart new file mode 100644 index 0000000..fe23b90 --- /dev/null +++ b/lib/core/services/permission_service.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:logger/logger.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../constants.dart'; +import '../../core/datamodels/dialog_response.dart'; +import '../../core/services/dialog_service.dart'; +import '../../core/services/stoppable_service.dart'; +import '../../core/util/logger.dart'; +import '../../locator.dart'; +import 'storage_service.dart'; + +class PermissionService extends StoppableService { + final Logger _logger = getLogger(); + final DialogService _dialogService = locator(); + final StorageService _storageService = locator(); + + Timer _serviceCheckTimer; + + PermissionStatus _permissionStatus; + + bool _permanentlyIgnored = false; + bool _devicePermissionDialogActive = false; + bool _ownPermissionDialogActive = false; + + PermissionService() { + _devicePermissionDialogActive = true; + + Permission.storage.request().then((status) { + _permissionStatus = status; + if (PermissionStatus.permanentlyDenied == status) { + _permanentlyIgnored = true; + } + }).whenComplete(() { + _logger.d('Initial device request permission finished'); + _devicePermissionDialogActive = false; + }); + } + + Future checkEnabledAndPermission() async { + if (_permanentlyIgnored) { + await _storageService.storeStoragePermissionDialogIgnored(); + _permanentlyIgnored = false; + _logger.d('Set permanently ignored permission request'); + stop(); + } + + if (_devicePermissionDialogActive) { + _logger.d('Device permission dialog active, skipping'); + return; + } + + if (_ownPermissionDialogActive) { + _logger.d('Own permission dialog already active, skipping'); + return; + } + + var ignoredDialog = await _storageService.hasStoragePermissionDialogIgnored(); + + if (ignoredDialog) { + _logger.d('Permanently ignored permission request, skipping'); + stop(); + return; + } + + _permissionStatus = await Permission.storage.status; + if (_permissionStatus != PermissionStatus.granted) { + if (_permissionStatus == PermissionStatus.permanentlyDenied) { + await _storageService.storeStoragePermissionDialogIgnored(); + return; + } + + _ownPermissionDialogActive = true; + DialogResponse response = await _dialogService.showConfirmationDialog( + title: translate('permission_service.dialog.title'), + description: translate('permission_service.dialog.description'), + buttonTitleAccept: translate('permission_service.dialog.grant'), + buttonTitleDeny: translate('permission_service.dialog.ignore')); + + if (!response.confirmed) { + await _storageService.storeStoragePermissionDialogIgnored(); + } else { + _devicePermissionDialogActive = true; + Permission.storage.request().then((status) async { + if (PermissionStatus.permanentlyDenied == status) { + await _storageService.storeStoragePermissionDialogIgnored(); + } + }).whenComplete(() { + _logger.d('Device request permission finished'); + _devicePermissionDialogActive = false; + }); + } + + _ownPermissionDialogActive = false; + } else { + await _storageService.storeStoragePermissionDialogIgnored(); + } + } + + @override + Future start() async { + super.start(); + await checkEnabledAndPermission(); + + _serviceCheckTimer = + Timer.periodic(Duration(milliseconds: Constants.mediaPermissionCheckInterval), (_serviceTimer) async { + if (!super.serviceStopped) { + await checkEnabledAndPermission(); + } else { + _serviceTimer.cancel(); + } + }); + _logger.d('PermissionService started'); + } + + @override + void stop() { + _removeServiceCheckTimer(); + super.stop(); + _logger.d('PermissionService stopped'); + } + + void _removeServiceCheckTimer() { + if (_serviceCheckTimer != null) { + _serviceCheckTimer.cancel(); + _serviceCheckTimer = null; + _logger.d('Removed service check timer'); + } + } +} diff --git a/lib/core/services/storage_service.dart b/lib/core/services/storage_service.dart index 5a7f597..7d5e297 100644 --- a/lib/core/services/storage_service.dart +++ b/lib/core/services/storage_service.dart @@ -7,6 +7,7 @@ import '../models/session.dart'; class StorageService { static const _SESSION_KEY = 'session'; static const _LAST_URL_KEY = 'last_url'; + static const _STORAGE_PERMISSION_DIALOG_IGNORED = 'storage_permission_ignored'; Future storeLastUrl(String url) { return _store(_LAST_URL_KEY, url); @@ -37,6 +38,14 @@ class StorageService { return _remove(_SESSION_KEY); } + Future storeStoragePermissionDialogIgnored() { + return _store(_STORAGE_PERMISSION_DIALOG_IGNORED, true.toString()); + } + + Future hasStoragePermissionDialogIgnored() { + return _exists(_STORAGE_PERMISSION_DIALOG_IGNORED); + } + Future _exists(String key) async { final SharedPreferences prefs = await SharedPreferences.getInstance(); return prefs.containsKey(key); diff --git a/lib/core/viewmodels/startup_model.dart b/lib/core/viewmodels/startup_model.dart index e8a2a91..6680729 100644 --- a/lib/core/viewmodels/startup_model.dart +++ b/lib/core/viewmodels/startup_model.dart @@ -14,11 +14,11 @@ class StartUpViewModel extends BaseModel { Future handleStartUpLogic() async { setState(ViewState.Busy); setStateMessage(translate('startup.init')); - await Future.delayed(Duration(milliseconds: 500)); + await Future.delayed(Duration(milliseconds: 150)); setStateMessage(translate('startup.start_services')); await _sessionService.start(); - await Future.delayed(Duration(milliseconds: 500)); + await Future.delayed(Duration(milliseconds: 150)); _navigationService.navigateAndReplaceTo(HomeView.routeName); diff --git a/lib/core/viewmodels/upload_model.dart b/lib/core/viewmodels/upload_model.dart index 4bfbf73..b34fcd1 100644 --- a/lib/core/viewmodels/upload_model.dart +++ b/lib/core/viewmodels/upload_model.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; @@ -5,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:logger/logger.dart'; +import 'package:path/path.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import '../../locator.dart'; import '../enums/error_code.dart'; @@ -15,15 +18,19 @@ import '../models/rest/rest_error.dart'; import '../models/rest/uploaded_multi_response.dart'; import '../models/rest/uploaded_response.dart'; import '../services/file_service.dart'; +import '../services/link_service.dart'; import '../util/logger.dart'; import 'base_model.dart'; class UploadModel extends BaseModel { final Logger _logger = getLogger(); final FileService _fileService = locator(); - TextEditingController _pasteTextController = TextEditingController(); - bool createMulti = false; + final LinkService _linkService = locator(); + TextEditingController _pasteTextController = TextEditingController(); + StreamSubscription _intentDataStreamSubscription; + + bool createMulti = false; String fileName; List paths; String _extension; @@ -32,6 +39,68 @@ class UploadModel extends BaseModel { TextEditingController get pasteTextController => _pasteTextController; + void init() { + // For sharing images coming from outside the app while the app is in the memory + _intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream().listen((List value) { + if (value != null && value.length > 0) { + setState(ViewState.Busy); + paths = value.map((sharedFile) { + return PlatformFile.fromMap({ + 'path': sharedFile.path, + 'name': basename(sharedFile.path), + 'size': File(sharedFile.path).lengthSync(), + 'bytes': null + }); + }).toList(); + setState(ViewState.Idle); + } + }, onError: (err) { + setState(ViewState.Busy); + errorMessage = translate('upload.retrieval_intent'); + _logger.e('Error while retrieving shared data: $err'); + setState(ViewState.Idle); + }); + + // For sharing images coming from outside the app while the app is closed + ReceiveSharingIntent.getInitialMedia().then((List value) { + if (value != null && value.length > 0) { + setState(ViewState.Busy); + paths = value.map((sharedFile) { + return PlatformFile.fromMap({ + 'path': sharedFile.path, + 'name': basename(sharedFile.path), + 'size': File(sharedFile.path).lengthSync(), + 'bytes': null + }); + }).toList(); + setState(ViewState.Idle); + } + }); + + // For sharing or opening urls/text coming from outside the app while the app is in the memory + _intentDataStreamSubscription = ReceiveSharingIntent.getTextStream().listen((String value) { + if (value != null && value.isNotEmpty) { + setState(ViewState.Busy); + pasteTextController.text = value; + setState(ViewState.Idle); + } + }, onError: (err) { + setState(ViewState.Busy); + errorMessage = translate('upload.retrieval_intent'); + _logger.e('Error while retrieving shared data: $err'); + setState(ViewState.Idle); + }); + + // For sharing or opening urls/text coming from outside the app while the app is closed + ReceiveSharingIntent.getInitialText().then((String value) { + if (value != null && value.isNotEmpty) { + setState(ViewState.Busy); + pasteTextController.text = value; + setState(ViewState.Idle); + } + }); + } + void toggleCreateMulti() { setState(ViewState.Busy); createMulti = !createMulti; @@ -140,9 +209,14 @@ class UploadModel extends BaseModel { return null; } + void openLink(String link) { + _linkService.open(link); + } + @override void dispose() { _pasteTextController.dispose(); + _intentDataStreamSubscription.cancel(); super.dispose(); } } diff --git a/lib/locator.dart b/lib/locator.dart index ab7c88b..9306122 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -6,6 +6,7 @@ import 'core/services/dialog_service.dart'; import 'core/services/file_service.dart'; import 'core/services/link_service.dart'; import 'core/services/navigation_service.dart'; +import 'core/services/permission_service.dart'; import 'core/services/session_service.dart'; import 'core/services/storage_service.dart'; import 'core/viewmodels/about_model.dart'; @@ -33,6 +34,7 @@ void setupLocator() { locator.registerLazySingleton(() => SessionService()); locator.registerLazySingleton(() => FileService()); locator.registerLazySingleton(() => LinkService()); + locator.registerLazySingleton(() => PermissionService()); /// view models locator.registerFactory(() => StartUpViewModel()); diff --git a/lib/ui/views/about_view.dart b/lib/ui/views/about_view.dart index d8d03b3..bffb3b3 100644 --- a/lib/ui/views/about_view.dart +++ b/lib/ui/views/about_view.dart @@ -19,7 +19,7 @@ class AboutView extends StatelessWidget { tag: 'hero', child: CircleAvatar( backgroundColor: Colors.transparent, - radius: 96.0, + radius: 30.0, child: Image.asset('assets/logo_caption.png'), ), ); @@ -39,12 +39,24 @@ class AboutView extends StatelessWidget { shrinkWrap: true, padding: EdgeInsets.only(left: 24.0, right: 24.0), children: [ + UIHelper.verticalSpaceMedium(), Center(child: logo), + UIHelper.verticalSpaceMedium(), Center( child: Text( translate(('about.description')), )), UIHelper.verticalSpaceMedium(), + Center( + child: Text( + translate(('about.faq_headline')), + style: subHeaderStyle, + )), + Center( + child: Text( + translate(('about.faq')), + )), + UIHelper.verticalSpaceMedium(), Center( child: Text( translate(('about.contact_us')), diff --git a/lib/ui/views/history_view.dart b/lib/ui/views/history_view.dart index 34ba876..8bb95e8 100644 --- a/lib/ui/views/history_view.dart +++ b/lib/ui/views/history_view.dart @@ -89,11 +89,15 @@ class HistoryView extends StatelessWidget { title: Text(paste.id), subtitle: Text(translate('history.id')), ); + var mimeTypeWidget = ListTile( + title: Text(paste.mimetype), + subtitle: Text(translate('history.mimetype')), + ); widgets.add(titleWidget); - - widgets.add(fileSizeWidget); widgets.add(idWidget); + widgets.add(fileSizeWidget); + widgets.add(mimeTypeWidget); } else { paste.items.forEach((element) { widgets.add(ListTile( @@ -110,18 +114,19 @@ class HistoryView extends StatelessWidget { var expandable = ExpandableTheme( data: ExpandableThemeData( - iconColor: Colors.blue, - tapHeaderToExpand: true, - iconPlacement: ExpandablePanelIconPlacement.right, - headerAlignment: ExpandablePanelHeaderAlignment.center, - hasIcon: true, - ), + iconPlacement: ExpandablePanelIconPlacement.right, + headerAlignment: ExpandablePanelHeaderAlignment.center, + hasIcon: true, + iconColor: Colors.blue, + tapHeaderToExpand: true), child: ExpandablePanel( - header: Text( - paste.id, - textAlign: TextAlign.left, - style: TextStyle(color: primaryAccentColor), - ), + header: InkWell( + onLongPress: () => model.deletePaste(paste.id), + child: Text( + paste.id, + style: TextStyle(color: Colors.blue), + textAlign: TextAlign.left, + )), expanded: Column( mainAxisSize: MainAxisSize.min, children: widgets, @@ -132,12 +137,15 @@ class HistoryView extends StatelessWidget { cards.add(Card( child: ListTile( title: expandable, - trailing: IconButton( - icon: Icon(Icons.share, color: Colors.blue, textDirection: TextDirection.ltr), - onPressed: () { - return Share.share(fullPasteUrl); - }), - subtitle: Text(!paste.isMulti ? paste.filename : ''), + trailing: Wrap(children: [ + openInBrowserButton, + IconButton( + icon: Icon(Icons.share, color: Colors.blue, textDirection: TextDirection.ltr), + onPressed: () { + return Share.share(fullPasteUrl); + }) + ]), + subtitle: Text(!paste.isMulti ? paste.filename : '', style: TextStyle(fontStyle: FontStyle.italic)), ), )); }); diff --git a/lib/ui/views/upload_view.dart b/lib/ui/views/upload_view.dart index 5751cef..0bcb07a 100644 --- a/lib/ui/views/upload_view.dart +++ b/lib/ui/views/upload_view.dart @@ -19,6 +19,7 @@ class UploadView extends StatelessWidget { var url = Provider.of(context).url; return BaseView( + onModelReady: (model) => model.init(), builder: (context, model, child) => Scaffold( appBar: MyAppBar(title: Text(translate('titles.upload'))), backgroundColor: backgroundColor, diff --git a/pubspec.yaml b/pubspec.yaml index 2cdd7bc..42159bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: share: 0.6.5+4 file_picker: 2.1.6 clipboard: 0.1.2+8 + receive_sharing_intent: 1.4.3 + permission_handler: 5.0.1+1 dev_dependencies: flutter_test: