Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added saveFile action #1799

Merged
merged 6 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/ensemble/lib/action/action_invokable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ abstract class ActionInvokable with Invokable {
ActionType.dismissDialog,
ActionType.closeAllDialogs,
ActionType.executeActionGroup,
ActionType.saveFile,
]);
}

Expand Down
190 changes: 190 additions & 0 deletions modules/ensemble/lib/action/save_file.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:io';

import 'package:ensemble/framework/action.dart';
import 'package:ensemble/framework/scope.dart';
import 'package:flutter/material.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:path_provider/path_provider.dart';
import 'package:ensemble/framework/error_handling.dart';
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import 'dart:html' as html;

/// Custom action to save files (images and documents) in platform-specific accessible directories
class SaveToFileSystemAction extends EnsembleAction {
final String? fileName;
final dynamic blobData;
final String? source; // Optional source for URL if blobData is not available
final String? type; // file type

SaveToFileSystemAction({
required this.fileName,
this.blobData,
this.source,
this.type,
});

factory SaveToFileSystemAction.from({Map? payload}) {
if (payload == null || payload['fileName'] == null) {
throw LanguageError('${ActionType.saveFile.name} requires fileName.');
}

return SaveToFileSystemAction(
fileName: payload['fileName'],
blobData: payload['blobData'],
source: payload['source'],
type: payload['type'],
);
}

@override
Future<void> execute(BuildContext context, ScopeManager scopeManager) async {
try {
if (fileName == null) {
throw Exception('Missing required parameter: fileName.');
}

Uint8List? fileBytes;

// If blobData is provided, process it
if (blobData != null) {
// Handle base64 blob or binary data
if (blobData is String) {
fileBytes = base64Decode(blobData); // Decode base64
} else if (blobData is List<int>) {
fileBytes = Uint8List.fromList(blobData);
} else {
throw Exception(
'Invalid blob data format. Must be base64 or List<int>.');
}
} else if (source != null) {
// If blobData is not available, check for source (network URL)
Dio dio = Dio();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why use dio when we have http package for network handling?

var response = await dio.get(source!,
options: Options(responseType: ResponseType.bytes));
fileBytes = Uint8List.fromList(response.data);
} else {
throw Exception('Missing blobData and source.');
}

if (type == 'image') {
// Save images to Default Image Path
await _saveImageToDCIM(fileName!, fileBytes);
} else if (type == 'document') {
// Save documents to Documents folder
await _saveDocumentToDocumentsFolder(fileName!, fileBytes);
}
} catch (e) {
throw Exception('Failed to save file: $e');
}
}

Future<void> _saveImageToDCIM(String fileName, Uint8List fileBytes) async {
try {
final result = await ImageGallerySaver.saveImage(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is reason for using ImageGallerySaver, cause adding in android would be placing file on that location. is it ios?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I just place the file in that location it doesn't register the image in the gallery, using ImageGallerySaver it automatically registers the image to the gallery in the respective platforms I.e. Android and IOS

fileBytes,
name: fileName,
);
if (result['isSuccess']) {
debugPrint('Image saved to gallery: $result');
} else {
throw Exception('Failed to save image to gallery.');
}
} catch (e) {
throw Exception('Failed to save image: $e');
}
}

/// Save documents to the default "Documents" directory
Future<void> _saveDocumentToDocumentsFolder(
String fileName, Uint8List fileBytes) async {
try {
String filePath;

if (Platform.isAndroid) {
// Get the default "Documents" directory on Android
Directory? directory = Directory('/storage/emulated/0/Documents');
if (!directory.existsSync()) {
directory.createSync(
recursive: true); // Create the directory if it doesn't exist
}
filePath = '${directory.path}/$fileName';
} else if (Platform.isIOS) {
// On iOS, use the app-specific Documents directory
final directory = await getApplicationDocumentsDirectory();
filePath = '${directory.path}/$fileName';

// Optionally, use a share intent to let users save the file to their desired location
} else if (kIsWeb) {
_downloadFileOnWeb(fileName, fileBytes);
return;
} else {
throw UnsupportedError('Platform not supported');
}

// Write the file to the determined path
final file = File(filePath);
await file.writeAsBytes(fileBytes);

debugPrint('Document saved to: $filePath');

// Notify Android's media store to make it visible
if (Platform.isAndroid) {
await _notifyFileSystem(filePath);
}
} catch (e) {
throw Exception('Failed to save document: $e');
}
}

/// Notify the Android media store about the new file
Future<void> _notifyFileSystem(String filePath) async {
try {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running a process is not recommended if possible please find another way

await Process.run('am', [
'broadcast',
'-a',
'android.intent.action.MEDIA_SCANNER_SCAN_FILE',
'-d',
'file://$filePath',
]);
} catch (e) {
debugPrint('Failed to notify media store: $e');
}
}

Future<void> _downloadFileOnWeb(String fileName, Uint8List fileBytes) async {
try {
// Convert Uint8List to a Blob
final blob = html.Blob([fileBytes]);

// Create an object URL for the Blob
final url = html.Url.createObjectUrlFromBlob(blob);

// Create a download anchor element
final anchor = html.AnchorElement(href: url)
..target = 'blank' // Open in a new tab if needed
..download = fileName; // Set the download file name

// Trigger the download
anchor.click();

// Revoke the object URL to free resources
html.Url.revokeObjectUrl(url);

debugPrint('File downloaded: $fileName');
} catch (e) {
throw Exception('Failed to download file: $e');
}
}

/// Factory method to construct the action from JSON
static SaveToFileSystemAction fromJson(Map<String, dynamic> json) {
return SaveToFileSystemAction(
fileName: json['fileName'],
blobData: json['blobData'],
source: json['source'],
);
}
}
20 changes: 11 additions & 9 deletions modules/ensemble/lib/framework/action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:ensemble/action/change_locale_actions.dart';
import 'package:ensemble/action/misc_action.dart';
import 'package:ensemble/action/navigation_action.dart';
import 'package:ensemble/action/notification_actions.dart';
import 'package:ensemble/action/save_file.dart';
import 'package:ensemble/action/phone_contact_action.dart';
import 'package:ensemble/action/sign_in_out_action.dart';
import 'package:ensemble/action/toast_actions.dart';
Expand Down Expand Up @@ -57,18 +58,16 @@ class ShowCameraAction extends EnsembleAction {
EnsembleAction? onClose;
EnsembleAction? onCapture;
EnsembleAction? onError;


factory ShowCameraAction.fromYaml({Invokable? initiator, Map? payload}) {
return ShowCameraAction(
initiator: initiator,
options: Utils.getMap(payload?['options']),
id: Utils.optionalString(payload?['id']),
onComplete: EnsembleAction.from(payload?['onComplete']),
onClose: EnsembleAction.from(payload?['onClose']),
onCapture: EnsembleAction.from(payload?['onCapture']),
onError: EnsembleAction.from(payload?['onError'])
);
initiator: initiator,
options: Utils.getMap(payload?['options']),
id: Utils.optionalString(payload?['id']),
onComplete: EnsembleAction.from(payload?['onComplete']),
onClose: EnsembleAction.from(payload?['onClose']),
onCapture: EnsembleAction.from(payload?['onCapture']),
onError: EnsembleAction.from(payload?['onError']));
}
}

Expand Down Expand Up @@ -1052,6 +1051,7 @@ enum ActionType {
bluetoothDisconnect,
bluetoothSubscribeCharacteristic,
bluetoothUnsubscribeCharacteristic,
saveFile
}

/// payload representing an Action to do (navigateToScreen, InvokeAPI, ..)
Expand Down Expand Up @@ -1171,6 +1171,8 @@ abstract class EnsembleAction {
return CopyToClipboardAction.from(payload: payload);
} else if (actionType == ActionType.share) {
return ShareAction.from(payload: payload);
} else if (actionType == ActionType.saveFile) {
return SaveToFileSystemAction.from(payload: payload);
} else if (actionType == ActionType.rateApp) {
return RateAppAction.from(payload: payload);
} else if (actionType == ActionType.getDeviceToken) {
Expand Down
2 changes: 2 additions & 0 deletions modules/ensemble/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ dependencies:
shared_preferences: ^2.1.1
workmanager: ^0.5.1
flutter_local_notifications: ^17.2.3
dio: ^5.7.0
image_gallery_saver: ^2.0.3
flutter_i18n: ^0.35.1
pointer_interceptor: ^0.9.3+4
flutter_secure_storage: ^9.2.2
Expand Down