Skip to content

Commit

Permalink
TF-3047 Handle save media (Video/Image) to gallery on mobile
Browse files Browse the repository at this point in the history
  • Loading branch information
dab246 committed Sep 30, 2024
1 parent 3002453 commit 013eaec
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 10 deletions.
4 changes: 4 additions & 0 deletions core/lib/domain/extensions/media_type_extension.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ extension MediaTypeExtension on MediaType {

bool isImageFile() => SupportedPreviewFileTypes.imageMimeTypes.contains(mimeType);

bool isVideoFile() => SupportedPreviewFileTypes.videoMimeTypes.contains(mimeType);

bool isDocFile() => SupportedPreviewFileTypes.docMimeTypes.contains(mimeType);

bool isPowerPointFile() => SupportedPreviewFileTypes.pptMimeTypes.contains(mimeType);
Expand All @@ -20,4 +22,6 @@ extension MediaTypeExtension on MediaType {
bool isPdfFile() => SupportedPreviewFileTypes.pdfMimeTypes.contains(mimeType);

DocumentUti getDocumentUti() => DocumentUti(SupportedPreviewFileTypes.iOSSupportedTypes[mimeType]);

bool isSaveToGallerySupported() => isVideoFile() || isImageFile();
}
15 changes: 14 additions & 1 deletion core/lib/domain/preview/supported_preview_file_types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,20 @@ class SupportedPreviewFileTypes {
'image/bmp',
'image/jpeg',
'image/gif',
'image/png',];
'image/png',
'image/vnd.microsoft.icon',];

static const videoMimeTypes = [
'video/x-m4v',
'video/x-ms-asf',
'video/x-msvideo',
'audio/x-mpeg',
'audio/mp4a-latm',
'video/vnd.mpegurl',
'video/quicktime',
'video/mp4',
'video/3gpp',
'video/mpeg',];

static const docMimeTypes = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
Expand Down
2 changes: 1 addition & 1 deletion ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ post_install do |installer|
'PERMISSION_SPEECH_RECOGNIZER=0',

## dart: PermissionGroup.photos
# 'PERMISSION_PHOTOS=0',
'PERMISSION_PHOTOS=1',

## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
'PERMISSION_LOCATION=0',
Expand Down
9 changes: 8 additions & 1 deletion ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ PODS:
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- gal (1.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (9.3.0):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
Expand Down Expand Up @@ -214,6 +217,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- pdf_render (from `.symlinks/plugins/pdf_render/ios`)
Expand Down Expand Up @@ -290,6 +294,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
Expand Down Expand Up @@ -342,6 +348,7 @@ SPEC CHECKSUMS:
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
GoogleDataTransport: 57c22343ab29bc686febbf7cbb13bad167c2d8fe
GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
Expand All @@ -366,6 +373,6 @@ SPEC CHECKSUMS:
url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6

PODFILE CHECKSUM: ded4724b53568389542fa0f7d7a64c05ffc03971
PODFILE CHECKSUM: 0cf859d325b6f61a10bec449642cfbd512637490

COCOAPODS: 1.14.3
137 changes: 137 additions & 0 deletions lib/features/base/mixin/save_media_to_gallery_mixin.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import 'dart:io';

import 'package:core/domain/extensions/media_type_extension.dart';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/platform_info.dart';
import 'package:flutter/material.dart';
import 'package:gal/gal.dart';
import 'package:http_parser/http_parser.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:tmail_ui_user/main/exceptions/storage_permission_exception.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
import 'package:tmail_ui_user/main/permissions/permission_dialog.dart';
import 'package:tmail_ui_user/main/permissions/storage_permission_service.dart';

typedef OnSaveCallbackAction = Function(bool isSuccess);

mixin SaveMediaToGalleryMixin {
Future<void> handleAndroidStoragePermission(BuildContext context) async {
if (await StoragePermissionService().isUserHaveToRequestStoragePermissionAndroid()) {
final permission = await Permission.storage.request();

if (permission.isPermanentlyDenied && context.mounted) {
showDialog(
useRootNavigator: false,
context: context,
builder: (_) {
return PermissionDialog(
icon: const Icon(Icons.storage_rounded),
permission: Permission.storage,
explainTextRequestPermission: Text(
AppLocalizations.of(context).explainPermissionToDownloadFiles(
AppLocalizations.of(context).app_name,
),
),
onAcceptButton: () =>
StoragePermissionService().goToSettingsForPermissionActions(),
);
},
);
}

if (!permission.isGranted) {
log('SaveMediaToGalleryMixin::handleAndroidStoragePermission: Permission Denied');
throw StoragePermissionException("Don't have permission to save file");
}
}
}

Future<void> handlePhotoPermissionIOS(BuildContext context) async {
final permissionStatus = await StoragePermissionService().requestPhotoAddOnlyPermissionIOS();
if (permissionStatus.isPermanentlyDenied && context.mounted) {
showDialog(
useRootNavigator: false,
context: context,
builder: (_) {
return PermissionDialog(
icon: const Icon(Icons.photo),
permission: Permission.photos,
explainTextRequestPermission: Text(
AppLocalizations.of(context).explainPermissionToGallery(
AppLocalizations.of(context).app_name,
),
),
onAcceptButton: () =>
StoragePermissionService().goToSettingsForPermissionActions(),
);
},
);
}
if (!permissionStatus.isGranted) {
throw StoragePermissionException('Permission denied');
}
}

Future<void> saveMediaToGallery({
required File fileInDownloadsInApp,
required MediaType mediaType,
OnSaveCallbackAction? onSaveCallbackAction
}) async {
if (mediaType.isImageFile()) {
await saveImageToGallery(file: fileInDownloadsInApp);
} else if (mediaType.isVideoFile()) {
await saveVideoToGallery(file: fileInDownloadsInApp);
} else {
return;
}
onSaveCallbackAction?.call(true);
}

Future<void> saveImageToGallery({
required File file,
}) async {
log('SaveMediaToGalleryMixin::saveImageToGallery:file path: ${file.path}');
await Gal.putImage(file.path);
}

Future<void> saveVideoToGallery({
required File file,
}) async {
log('SaveMediaToGalleryMixin::saveVideoToGallery:file path: ${file.path}');
await Gal.putVideo(file.path);
}


Future<void> saveToGallery({
required BuildContext context,
required String filePath,
required MediaType mediaType,
OnSaveCallbackAction? onSaveCallbackAction
}) async {
try {
if (PlatformInfo.isAndroid) {
await handleAndroidStoragePermission(context);
} else if (PlatformInfo.isIOS) {
await handlePhotoPermissionIOS(context);
} else {
return;
}

final fileInDownloadsInApp = File(filePath);

if (context.mounted) {
await saveMediaToGallery(
mediaType: mediaType,
fileInDownloadsInApp: fileInDownloadsInApp,
onSaveCallbackAction: onSaveCallbackAction
);
}

} catch (e) {
logError('SaveMediaToGalleryMixin::saveSelectedEventToGallery: $e');
if (e is! StoragePermissionException) {
onSaveCallbackAction?.call(false);
}
}
}
}
11 changes: 8 additions & 3 deletions lib/features/email/domain/state/export_attachment_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import 'package:core/presentation/state/success.dart';

class ExportAttachmentSuccess extends UIState {
final DownloadedResponse downloadedResponse;
final bool isPreview;

ExportAttachmentSuccess(this.downloadedResponse);
ExportAttachmentSuccess(this.downloadedResponse, this.isPreview);

@override
List<Object> get props => [downloadedResponse];
List<Object> get props => [downloadedResponse, isPreview];
}

class ExportAttachmentFailure extends FeatureFailure {
final bool isPreview;

ExportAttachmentFailure(dynamic exception) : super(exception: exception);
ExportAttachmentFailure(dynamic exception, this.isPreview) : super(exception: exception);

@override
List<Object?> get props => [...super.props, isPreview];
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class ExportAttachmentInteractor {
Attachment attachment,
AccountId accountId,
String baseDownloadUrl,
CancelToken cancelToken
CancelToken cancelToken,
bool isPreview
) async* {
try {
final currentAccount = await _accountRepository.getCurrentAccount();
Expand All @@ -55,10 +56,10 @@ class ExportAttachmentInteractor {
cancelToken
);

yield Right<Failure, Success>(ExportAttachmentSuccess(downloadedResponse));
yield Right<Failure, Success>(ExportAttachmentSuccess(downloadedResponse, isPreview));
} catch (exception) {
log('ExportAttachmentInteractor::execute(): exception: $exception');
yield Left<Failure, Success>(ExportAttachmentFailure(exception));
logError('ExportAttachmentInteractor::execute(): exception: $exception');
yield Left<Failure, Success>(ExportAttachmentFailure(exception, isPreview));
}
}
}
44 changes: 44 additions & 0 deletions lib/l10n/intl_messages.arb
Original file line number Diff line number Diff line change
Expand Up @@ -4017,5 +4017,49 @@
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"allow": "Allow",
"@allow": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"deny": "Deny",
"@deny": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"fileSavedToGallery": "File saved to Gallery",
"@fileSavedToGallery": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"saveFileToDownloadsError": "Failed to save file to Downloads",
"@saveFileToDownloadsError": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"explainPermissionToDownloadFiles": "To continue, please allow {appName} to access storage permission. This permission is essential for saving file to Downloads folder.",
"@explainPermissionToDownloadFiles": {
"type": "text",
"placeholders_order": [
"appName"
],
"placeholders": {
"appName": {}
}
},
"explainPermissionToGallery": "To continue, please allow {appName} to access photo permission. This permission is essential for saving file to gallery.",
"@explainPermissionToGallery": {
"type": "text",
"placeholders_order": [
"appName"
],
"placeholders": {
"appName": {}
}
}
}
5 changes: 5 additions & 0 deletions lib/main/exceptions/storage_permission_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class StoragePermissionException implements Exception {
final dynamic error;

StoragePermissionException(this.error);
}
44 changes: 44 additions & 0 deletions lib/main/localizations/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4213,4 +4213,48 @@ class AppLocalizations {
name: 'youAreOffline',
);
}

String get allow {
return Intl.message(
'Allow',
name: 'allow'
);
}

String get deny {
return Intl.message(
'Deny',
name: 'deny'
);
}

String get fileSavedToGallery {
return Intl.message(
'File saved to Gallery',
name: 'fileSavedToGallery'
);
}

String get saveFileToDownloadsError {
return Intl.message(
'Failed to save file to Downloads',
name: 'saveFileToDownloadsError'
);
}

String explainPermissionToDownloadFiles(String appName) {
return Intl.message(
'To continue, please allow $appName to access storage permission. This permission is essential for saving file to Downloads folder.',
name: 'explainPermissionToDownloadFiles',
args: [appName]
);
}

String explainPermissionToGallery(String appName) {
return Intl.message(
'To continue, please allow $appName to access photo permission. This permission is essential for saving file to gallery.',
name: 'explainPermissionToGallery',
args: [appName]
);
}
}
8 changes: 8 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.0"
gal:
dependency: "direct main"
description:
name: gal
sha256: "54c9b72528efce7c66234f3b6dd01cb0304fd8af8196de15571d7bdddb940977"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
get:
dependency: "direct main"
description:
Expand Down
Loading

0 comments on commit 013eaec

Please sign in to comment.