diff --git a/packages/image_picker/image_picker_android/CHANGELOG.md b/packages/image_picker/image_picker_android/CHANGELOG.md index 58c4951a2d4f..1ab21108d70f 100644 --- a/packages/image_picker/image_picker_android/CHANGELOG.md +++ b/packages/image_picker/image_picker_android/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 0.8.5+6 * Updates minimum Flutter version to 3.0. +* Fixes names of picked files to match original filenames where possible. ## 0.8.5+5 diff --git a/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java b/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java index 1f51a226c7e2..449480c19d9c 100644 --- a/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java +++ b/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java @@ -25,55 +25,60 @@ import android.content.ContentResolver; import android.content.Context; +import android.database.Cursor; import android.net.Uri; +import android.provider.MediaStore; import android.webkit.MimeTypeMap; +import io.flutter.Log; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.UUID; class FileUtils { - + /** + * Copies the file from the given content URI to a temporary directory, retaining the original + * file name if possible. + * + *

Each file is placed in its own directory to avoid conflicts according to the following + * scheme: {cacheDir}/{randomUuid}/{fileName} + * + *

If the original file name is unknown, a predefined "image_picker" filename is used and the + * file extension is deduced from the mime type (with fallback to ".jpg" in case of failure). + */ String getPathFromUri(final Context context, final Uri uri) { - File file = null; - InputStream inputStream = null; - OutputStream outputStream = null; - boolean success = false; - try { - String extension = getImageExtension(context, uri); - inputStream = context.getContentResolver().openInputStream(uri); - file = File.createTempFile("image_picker", extension, context.getCacheDir()); - file.deleteOnExit(); - outputStream = new FileOutputStream(file); - if (inputStream != null) { - copy(inputStream, outputStream); - success = true; + try (InputStream inputStream = context.getContentResolver().openInputStream(uri)) { + String uuid = UUID.randomUUID().toString(); + File targetDirectory = new File(context.getCacheDir(), uuid); + targetDirectory.mkdir(); + // TODO(SynSzakala) according to the docs, `deleteOnExit` does not work reliably on Android; we should preferably + // just clear the picked files after the app startup. + targetDirectory.deleteOnExit(); + String fileName = getImageName(context, uri); + if (fileName == null) { + Log.w("FileUtils", "Cannot get file name for " + uri); + fileName = "image_picker" + getImageExtension(context, uri); } - } catch (IOException ignored) { - } finally { - try { - if (inputStream != null) inputStream.close(); - } catch (IOException ignored) { - } - try { - if (outputStream != null) outputStream.close(); - } catch (IOException ignored) { - // If closing the output stream fails, we cannot be sure that the - // target file was written in full. Flushing the stream merely moves - // the bytes into the OS, not necessarily to the file. - success = false; + File file = new File(targetDirectory, fileName); + try (OutputStream outputStream = new FileOutputStream(file)) { + copy(inputStream, outputStream); + return file.getPath(); } + } catch (IOException e) { + // If closing the output stream fails, we cannot be sure that the + // target file was written in full. Flushing the stream merely moves + // the bytes into the OS, not necessarily to the file. + return null; } - return success ? file.getPath() : null; } /** @return extension of image with dot, or default .jpg if it none. */ private static String getImageExtension(Context context, Uri uriImage) { - String extension = null; + String extension; try { - String imagePath = uriImage.getPath(); if (uriImage.getScheme().equals(ContentResolver.SCHEME_CONTENT)) { final MimeTypeMap mime = MimeTypeMap.getSingleton(); extension = mime.getExtensionFromMimeType(context.getContentResolver().getType(uriImage)); @@ -94,6 +99,20 @@ private static String getImageExtension(Context context, Uri uriImage) { return "." + extension; } + /** @return name of the image provided by ContentResolver; this may be null. */ + private static String getImageName(Context context, Uri uriImage) { + try (Cursor cursor = queryImageName(context, uriImage)) { + if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() < 1) return null; + return cursor.getString(0); + } + } + + private static Cursor queryImageName(Context context, Uri uriImage) { + return context + .getContentResolver() + .query(uriImage, new String[] {MediaStore.MediaColumns.DISPLAY_NAME}, null, null, null); + } + private static void copy(InputStream in, OutputStream out) throws IOException { final byte[] buffer = new byte[4 * 1024]; int bytesRead; diff --git a/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java b/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java index 32e3ebc6183d..0ea0173fa954 100644 --- a/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java +++ b/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java @@ -8,8 +8,15 @@ import static org.junit.Assert.assertTrue; import static org.robolectric.Shadows.shadowOf; +import android.content.ContentProvider; +import android.content.ContentValues; import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; import android.net.Uri; +import android.provider.MediaStore; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; @@ -19,6 +26,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.shadows.ShadowContentResolver; @@ -63,4 +71,62 @@ public void FileUtil_getImageExtension() throws IOException { String path = fileUtils.getPathFromUri(context, uri); assertTrue(path.endsWith(".jpg")); } + + @Test + public void FileUtil_getImageName() throws IOException { + Uri uri = Uri.parse("content://dummy/dummy.png"); + Robolectric.buildContentProvider(MockContentProvider.class).create("dummy"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("imageStream".getBytes(UTF_8))); + String path = fileUtils.getPathFromUri(context, uri); + assertTrue(path.endsWith("dummy.png")); + } + + private static class MockContentProvider extends ContentProvider { + + @Override + public boolean onCreate() { + return true; + } + + @Nullable + @Override + public Cursor query( + @NonNull Uri uri, + @Nullable String[] projection, + @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + MatrixCursor cursor = new MatrixCursor(new String[] {MediaStore.MediaColumns.DISPLAY_NAME}); + cursor.addRow(new Object[] {"dummy.png"}); + return cursor; + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return "image/png"; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + return null; + } + + @Override + public int delete( + @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public int update( + @NonNull Uri uri, + @Nullable ContentValues values, + @Nullable String selection, + @Nullable String[] selectionArgs) { + return 0; + } + } } diff --git a/packages/image_picker/image_picker_android/example/android/app/build.gradle b/packages/image_picker/image_picker_android/example/android/app/build.gradle index 31d8c82a0a9d..f8487c7959f1 100755 --- a/packages/image_picker/image_picker_android/example/android/app/build.gradle +++ b/packages/image_picker/image_picker_android/example/android/app/build.gradle @@ -63,5 +63,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + implementation project(':image_picker_android') + implementation project(':espresso') api 'androidx.test:core:1.4.0' } diff --git a/packages/image_picker/image_picker_android/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerPickTest.java b/packages/image_picker/image_picker_android/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerPickTest.java new file mode 100644 index 000000000000..8b7ae11d5c2d --- /dev/null +++ b/packages/image_picker/image_picker_android/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerPickTest.java @@ -0,0 +1,43 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepickerexample; + +import static androidx.test.espresso.flutter.EspressoFlutter.onFlutterWidget; +import static androidx.test.espresso.flutter.action.FlutterActions.click; +import static androidx.test.espresso.flutter.assertion.FlutterAssertions.matches; +import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withText; +import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withValueKey; +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Intent; +import android.net.Uri; +import androidx.test.espresso.intent.rule.IntentsTestRule; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; + +public class ImagePickerPickTest { + + @Rule public TestRule rule = new IntentsTestRule<>(DriverExtensionActivity.class); + + @Test + @Ignore("Doesn't run in Firebase Test Lab: https://github.com/flutter/flutter/issues/94748") + public void imageIsPickedWithOriginalName() { + Instrumentation.ActivityResult result = + new Instrumentation.ActivityResult( + Activity.RESULT_OK, new Intent().setData(Uri.parse("content://dummy/dummy.png"))); + intending(hasAction(Intent.ACTION_GET_CONTENT)).respondWith(result); + onFlutterWidget(withValueKey("image_picker_example_from_gallery")).perform(click()); + onFlutterWidget(withText("PICK")).perform(click()); + intended(hasAction(Intent.ACTION_GET_CONTENT)); + onFlutterWidget(withValueKey("image_picker_example_picked_image_name")) + .check(matches(withText("dummy.png"))); + } +} diff --git a/packages/image_picker/image_picker_android/example/android/app/src/debug/AndroidManifest.xml b/packages/image_picker/image_picker_android/example/android/app/src/debug/AndroidManifest.xml index 6f85cefded34..317af1d1a371 100644 --- a/packages/image_picker/image_picker_android/example/android/app/src/debug/AndroidManifest.xml +++ b/packages/image_picker/image_picker_android/example/android/app/src/debug/AndroidManifest.xml @@ -13,5 +13,17 @@ android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> + + + diff --git a/packages/image_picker/image_picker_android/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/DriverExtensionActivity.java b/packages/image_picker/image_picker_android/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/DriverExtensionActivity.java new file mode 100644 index 000000000000..b35a6c4b0e49 --- /dev/null +++ b/packages/image_picker/image_picker_android/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/DriverExtensionActivity.java @@ -0,0 +1,16 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepickerexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; + +public class DriverExtensionActivity extends FlutterActivity { + @NonNull + @Override + public String getDartEntrypointFunctionName() { + return "appMain"; + } +} diff --git a/packages/image_picker/image_picker_android/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/DummyContentProvider.java b/packages/image_picker/image_picker_android/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/DummyContentProvider.java new file mode 100644 index 000000000000..8967318ee977 --- /dev/null +++ b/packages/image_picker/image_picker_android/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/DummyContentProvider.java @@ -0,0 +1,68 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepickerexample; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.provider.MediaStore; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class DummyContentProvider extends ContentProvider { + @Override + public boolean onCreate() { + return true; + } + + @Nullable + @Override + public AssetFileDescriptor openAssetFile(@NonNull Uri uri, @NonNull String mode) { + return getContext().getResources().openRawResourceFd(R.raw.ic_launcher); + } + + @Nullable + @Override + public Cursor query( + @NonNull Uri uri, + @Nullable String[] projection, + @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + MatrixCursor cursor = new MatrixCursor(new String[] {MediaStore.MediaColumns.DISPLAY_NAME}); + cursor.addRow(new Object[] {"dummy.png"}); + return cursor; + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return "image/png"; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + return null; + } + + @Override + public int delete( + @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public int update( + @NonNull Uri uri, + @Nullable ContentValues values, + @Nullable String selection, + @Nullable String[] selectionArgs) { + return 0; + } +} diff --git a/packages/image_picker/image_picker_android/example/android/app/src/main/res/raw/ic_launcher.png b/packages/image_picker/image_picker_android/example/android/app/src/main/res/raw/ic_launcher.png new file mode 100755 index 000000000000..17987b79bb8a Binary files /dev/null and b/packages/image_picker/image_picker_android/example/android/app/src/main/res/raw/ic_launcher.png differ diff --git a/packages/image_picker/image_picker_android/example/lib/main.dart b/packages/image_picker/image_picker_android/example/lib/main.dart index d7c82a3a9979..34f9114332f5 100755 --- a/packages/image_picker/image_picker_android/example/lib/main.dart +++ b/packages/image_picker/image_picker_android/example/lib/main.dart @@ -9,9 +9,15 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_driver/driver_extension.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; import 'package:video_player/video_player.dart'; +void appMain() { + enableFlutterDriverExtension(); + main(); +} + void main() { runApp(const MyApp()); } @@ -80,17 +86,23 @@ class _MyHomePageState extends State { } } - Future _onImageButtonPressed(ImageSource source, - {BuildContext? context, bool isMultiImage = false}) async { + Future _onImageButtonPressed( + BuildContext context, { + required ImageSource source, + bool isMultiImage = false, + }) async { if (_controller != null) { await _controller!.setVolume(0.0); } if (isVideo) { final XFile? file = await _picker.getVideo( source: source, maxDuration: const Duration(seconds: 10)); + if (file != null && context.mounted) { + _showPickedSnackBar(context, [file]); + } await _playVideo(file); - } else if (isMultiImage) { - await _displayPickImageDialog(context!, + } else if (isMultiImage && context.mounted) { + await _displayPickImageDialog(context, (double? maxWidth, double? maxHeight, int? quality) async { try { final List? pickedFileList = await _picker.getMultiImage( @@ -98,17 +110,16 @@ class _MyHomePageState extends State { maxHeight: maxHeight, imageQuality: quality, ); - setState(() { - _imageFileList = pickedFileList; - }); + if (pickedFileList != null && context.mounted) { + _showPickedSnackBar(context, pickedFileList); + } + setState(() => _imageFileList = pickedFileList); } catch (e) { - setState(() { - _pickImageError = e; - }); + setState(() => _pickImageError = e); } }); } else { - await _displayPickImageDialog(context!, + await _displayPickImageDialog(context, (double? maxWidth, double? maxHeight, int? quality) async { try { final XFile? pickedFile = await _picker.getImage( @@ -117,13 +128,12 @@ class _MyHomePageState extends State { maxHeight: maxHeight, imageQuality: quality, ); - setState(() { - _setImageFileListFromFile(pickedFile); - }); + if (pickedFile != null && context.mounted) { + _showPickedSnackBar(context, [pickedFile]); + } + setState(() => _setImageFileListFromFile(pickedFile)); } catch (e) { - setState(() { - _pickImageError = e; - }); + setState(() => _pickImageError = e); } }); } @@ -183,13 +193,21 @@ class _MyHomePageState extends State { child: ListView.builder( key: UniqueKey(), itemBuilder: (BuildContext context, int index) { - // Why network for web? - // See https://pub.dev/packages/image_picker#getting-ready-for-the-web-platform - return Semantics( - label: 'image_picker_example_picked_image', - child: kIsWeb - ? Image.network(_imageFileList![index].path) - : Image.file(File(_imageFileList![index].path)), + final XFile image = _imageFileList![index]; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(image.name, + key: const Key('image_picker_example_picked_image_name')), + // Why network for web? + // See https://pub.dev/packages/image_picker#getting-ready-for-the-web-platform + Semantics( + label: 'image_picker_example_picked_image', + child: kIsWeb + ? Image.network(image.path) + : Image.file(File(image.path)), + ), + ], ); }, itemCount: _imageFileList!.length, @@ -283,9 +301,10 @@ class _MyHomePageState extends State { Semantics( label: 'image_picker_example_from_gallery', child: FloatingActionButton( + key: const Key('image_picker_example_from_gallery'), onPressed: () { isVideo = false; - _onImageButtonPressed(ImageSource.gallery, context: context); + _onImageButtonPressed(context, source: ImageSource.gallery); }, heroTag: 'image0', tooltip: 'Pick Image from gallery', @@ -298,8 +317,8 @@ class _MyHomePageState extends State { onPressed: () { isVideo = false; _onImageButtonPressed( - ImageSource.gallery, - context: context, + context, + source: ImageSource.gallery, isMultiImage: true, ); }, @@ -313,7 +332,7 @@ class _MyHomePageState extends State { child: FloatingActionButton( onPressed: () { isVideo = false; - _onImageButtonPressed(ImageSource.camera, context: context); + _onImageButtonPressed(context, source: ImageSource.camera); }, heroTag: 'image2', tooltip: 'Take a Photo', @@ -326,7 +345,7 @@ class _MyHomePageState extends State { backgroundColor: Colors.red, onPressed: () { isVideo = true; - _onImageButtonPressed(ImageSource.gallery); + _onImageButtonPressed(context, source: ImageSource.gallery); }, heroTag: 'video0', tooltip: 'Pick Video from gallery', @@ -339,7 +358,7 @@ class _MyHomePageState extends State { backgroundColor: Colors.red, onPressed: () { isVideo = true; - _onImageButtonPressed(ImageSource.camera); + _onImageButtonPressed(context, source: ImageSource.camera); }, heroTag: 'video1', tooltip: 'Take a Video', @@ -417,6 +436,13 @@ class _MyHomePageState extends State { ); }); } + + void _showPickedSnackBar(BuildContext context, List files) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Picked: ${files.map((XFile it) => it.name).join(',')}'), + duration: const Duration(seconds: 2), + )); + } } typedef OnPickImageCallback = void Function( diff --git a/packages/image_picker/image_picker_android/example/pubspec.yaml b/packages/image_picker/image_picker_android/example/pubspec.yaml index bfcdbad511ae..bfeac3de14d5 100755 --- a/packages/image_picker/image_picker_android/example/pubspec.yaml +++ b/packages/image_picker/image_picker_android/example/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_driver: + sdk: flutter flutter_plugin_android_lifecycle: ^2.0.1 image_picker_android: # When depending on this package from a real application you should use: @@ -22,8 +24,6 @@ dependencies: dev_dependencies: espresso: ^0.2.0 - flutter_driver: - sdk: flutter flutter_test: sdk: flutter integration_test: diff --git a/packages/image_picker/image_picker_android/pubspec.yaml b/packages/image_picker/image_picker_android/pubspec.yaml index 7aa1a2258645..a0516685964c 100755 --- a/packages/image_picker/image_picker_android/pubspec.yaml +++ b/packages/image_picker/image_picker_android/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_android description: Android implementation of the image_picker plugin. repository: https://github.com/flutter/plugins/tree/main/packages/image_picker/image_picker_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.5+5 +version: 0.8.5+6 environment: sdk: ">=2.14.0 <3.0.0"