diff --git a/README.md b/README.md index 9dadea64..6dc865ab 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Add the following key to your _AndroidManifest_ file, located in ```dart //Save Image await Gal.putImage('$filePath'); +await Gal.putImageBytes('$uint8List'); //Save Video await Gal.putVideo('$filePath'); diff --git a/android/src/main/java/studio/midoridesign/gal/GalPlugin.java b/android/src/main/java/studio/midoridesign/gal/GalPlugin.java index e5487b64..8ff91b52 100644 --- a/android/src/main/java/studio/midoridesign/gal/GalPlugin.java +++ b/android/src/main/java/studio/midoridesign/gal/GalPlugin.java @@ -32,6 +32,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.ByteArrayInputStream; import java.util.concurrent.CompletableFuture; public class GalPlugin @@ -55,13 +56,23 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { switch (call.method) { case "putVideo": case "putImage": { - String path = call.argument("path"); - Uri contentUri = call.method.equals("putVideo") ? MediaStore.Video.Media.EXTERNAL_CONTENT_URI + Uri uri = call.method.contains("Video") ? MediaStore.Video.Media.EXTERNAL_CONTENT_URI : MediaStore.Images.Media.EXTERNAL_CONTENT_URI; - CompletableFuture.runAsync(() -> { try { - putMedia(pluginBinding.getApplicationContext(), path, contentUri); + putMedia(pluginBinding.getApplicationContext(), (String) call.argument("path"), uri); + new Handler(Looper.getMainLooper()).post(() -> result.success(null)); + } catch (Exception e) { + handleError(e, result); + } + }); + break; + } + case "putImageBytes": { + Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + CompletableFuture.runAsync(() -> { + try { + putImageBytes(pluginBinding.getApplicationContext(), (byte[]) call.argument("bytes"), uri); new Handler(Looper.getMainLooper()).post(() -> result.success(null)); } catch (Exception e) { handleError(e, result); @@ -85,23 +96,32 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { } default: result.notImplemented(); - } } private void putMedia(Context context, String path, Uri contentUri) throws IOException, SecurityException, FileNotFoundException { - ContentResolver resolver = context.getContentResolver(); - ContentValues values = new ContentValues(); File file = new File(path); + try (InputStream in = new FileInputStream(file)) { + writeContent(context, in, contentUri); + } + } - values.put(MediaStore.MediaColumns.DISPLAY_NAME, file.getName()); - values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis()); + private void putImageBytes(Context context, byte[] bytes, Uri contentUri) + throws IOException, SecurityException { + try (InputStream in = new ByteArrayInputStream(bytes)) { + writeContent(context, in, contentUri); + } + } + private void writeContent(Context context, InputStream in, Uri contentUri) + throws IOException, SecurityException { + ContentResolver resolver = context.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis()); Uri mediaUri = resolver.insert(contentUri, values); - try (OutputStream out = resolver.openOutputStream(mediaUri); - InputStream in = new FileInputStream(file)) { + try (OutputStream out = resolver.openOutputStream(mediaUri)) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = in.read(buffer)) != -1) { @@ -129,7 +149,7 @@ private boolean hasAccess() { return hasAccess == PackageManager.PERMISSION_GRANTED; } - // If permissions have already been granted by the user, + // If permissions have already been granted by the user, // returns true immediately without displaying the dialog. private CompletableFuture requestAccess() { accessRequestResult = new CompletableFuture<>(); diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index e4701d9f..47b897b1 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -24,7 +24,8 @@ android:name="flutterEmbedding" android:value="2" /> - + + diff --git a/example/integration_test/integration_test.dart b/example/integration_test/integration_test.dart index 1e8f8c59..ecd455cd 100644 --- a/example/integration_test/integration_test.dart +++ b/example/integration_test/integration_test.dart @@ -37,6 +37,14 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('putImageBytes()', (tester) async { + app.main(); + await tester.pumpAndSettle(); + final button = find.byIcon(Icons.image_rounded); + await tester.tap(button); + expect(tester.takeException(), isNull); + }); + testWidgets('open()', (tester) async { app.main(); await tester.pumpAndSettle(); diff --git a/example/lib/main.dart b/example/lib/main.dart index b5fd51e9..9458869e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -115,6 +115,19 @@ class App extends StatelessWidget { label: 'Save Image from local', icon: Icons.image, ), + _Button( + onPressed: () async { + final res = await Dio().get( + 'https://github.com/natsuk4ze/gal/raw/main/example/assets/done.jpg', + options: Options(responseType: ResponseType.bytes)); + await Gal.putImageBytes(Uint8List.fromList(res.data)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + }, + label: 'Save Image from bytes', + icon: Icons.image_rounded, + ), _Button( onPressed: () async { final path = '${Directory.systemTemp.path}/done.jpg'; diff --git a/example/pubspec.lock b/example/pubspec.lock index 88cc93fe..458672a5 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: "direct main" description: name: dio - sha256: b99b1d56dc0d5dece70957023af002dbd49614b4a1bf86d3a254af3fe781bdf2 + sha256: a9d76e72985d7087eb7c5e7903224ae52b337131518d127c554b9405936752b8 url: "https://pub.dev" source: hosted - version: "5.2.0+1" + version: "5.2.1+1" fake_async: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9eacedfd..f40c8f32 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -7,7 +7,7 @@ environment: dependencies: flutter: sdk: flutter - dio: ^5.2.0+1 + dio: ^5.2.1+1 gal: path: ../ diff --git a/ios/Classes/GalPlugin.swift b/ios/Classes/GalPlugin.swift index 732c4f74..f6448297 100644 --- a/ios/Classes/GalPlugin.swift +++ b/ios/Classes/GalPlugin.swift @@ -11,7 +11,7 @@ public class GalPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { - case "putImage", "putVideo": + case "putVideo", "putImage": let args = call.arguments as! [String: String] self.putMedia( path: args["path"]!, @@ -23,6 +23,17 @@ public class GalPlugin: NSObject, FlutterPlugin { result(nil) } } + case "putImageBytes": + let args = call.arguments as! [String: FlutterStandardTypedData] + self.putImageBytes( + bytes: args["bytes"]!.data + ) { _, error in + if let error = error as NSError? { + result(self.handleError(error: error)) + } else { + result(nil) + } + } case "open": self.open { result(nil) @@ -50,6 +61,15 @@ public class GalPlugin: NSObject, FlutterPlugin { completionHandler: completion) } + private func putImageBytes( + bytes: Data, completion: @escaping (Bool, Error?) -> Void + ) { + PHPhotoLibrary.shared().performChanges( + { + PHAssetChangeRequest.creationRequestForAsset(from: UIImage(data: bytes)!) + }, completionHandler: completion) + } + private func open(completion: @escaping () -> Void) { guard let url = URL(string: "photos-redirect://") else { return } UIApplication.shared.open(url, options: [:]) { _ in completion() } diff --git a/lib/src/gal.dart b/lib/src/gal.dart index c9904651..e7da567e 100644 --- a/lib/src/gal.dart +++ b/lib/src/gal.dart @@ -3,35 +3,34 @@ import 'package:gal/src/gal_exception.dart'; import 'gal_platform_interface.dart'; -/// Plugin App Facing /// For detailed please see -/// https://github.com/natsuk4ze/gal/ or /// https://github.com/natsuk4ze/gal/wiki +/// these functions are first called, +/// [putImage],[putVideo],[putImageBytes],a native dialog is +/// called asking the use for permission. If the user chooses to deny, +/// [GalException] of [GalExceptionType.accessDenied] will be throwed. +/// You should either do error handling or call [requestAccess] once +/// before calling these function. final class Gal { Gal._(); /// Save video to standard gallery app /// [path] is local path. - /// When this function was called was the first access - /// to the gallery app, a native dialog is called asking the user - /// for permission. If the user chooses to deny, - /// [GalException] of [GalExceptionType.accessDenied] will be throwed. - /// You should either do error handling or call [requestAccess] once - /// before calling this function. static Future putVideo(String path) async => _voidOrThrow(() async => GalPlatform.instance.putVideo(path)); /// Save image to standard gallery app /// [path] is local path. - /// When this function was called was the first access - /// to the gallery app, a native dialog is called asking the user - /// for permission. If the user chooses to deny, - /// [GalException] of [GalExceptionType.accessDenied] will be throwed. - /// You should either do error handling or call [requestAccess] once - /// before calling this function. static Future putImage(String path) async => _voidOrThrow(() async => GalPlatform.instance.putImage(path)); + /// Save image to standard gallery app + /// [Uint8List] version of [putImage] + /// It does not require temporary files and saves directly from memory, + /// making it fast. + static Future putImageBytes(Uint8List bytes) async => + _voidOrThrow(() async => GalPlatform.instance.putImageBytes(bytes)); + /// Open OS standard gallery app. /// Open "iOS Photos" when iOS, "Google Photos" or something when Android. static Future open() async => GalPlatform.instance.open(); diff --git a/lib/src/gal_method_channel.dart b/lib/src/gal_method_channel.dart index 57a519d4..36dd1238 100644 --- a/lib/src/gal_method_channel.dart +++ b/lib/src/gal_method_channel.dart @@ -8,16 +8,18 @@ final class MethodChannelGal extends GalPlatform { @visibleForTesting final methodChannel = const MethodChannel('gal'); - /// argument is Map. @override Future putVideo(String path) async => methodChannel.invokeMethod('putVideo', {'path': path}); - /// argument is Map. @override Future putImage(String path) async => methodChannel.invokeMethod('putImage', {'path': path}); + @override + Future putImageBytes(Uint8List bytes) async => + methodChannel.invokeMethod('putImageBytes', {'bytes': bytes}); + @override Future open() async => methodChannel.invokeMethod('open'); @@ -26,6 +28,7 @@ final class MethodChannelGal extends GalPlatform { final hasAccess = await methodChannel.invokeMethod('hasAccess'); return hasAccess ?? false; } + @override Future requestAccess() async { final granted = await methodChannel.invokeMethod('requestAccess'); diff --git a/lib/src/gal_platform_interface.dart b/lib/src/gal_platform_interface.dart index 88023671..f24ff520 100644 --- a/lib/src/gal_platform_interface.dart +++ b/lib/src/gal_platform_interface.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'gal_method_channel.dart'; @@ -24,6 +26,11 @@ abstract class GalPlatform extends PlatformInterface { Future putImage(String path) => throw UnimplementedError('putImage() has not been implemented.'); + /// throw [UnimplementedError] when Plugin [MethodChannelGal] did not + /// define [putImageBytes]. + Future putImageBytes(Uint8List bytes) => + throw UnimplementedError('putImageBytes() has not been implemented.'); + /// throw [UnimplementedError] when Plugin [MethodChannelGal] did not /// define [open]. Future open() =>