Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

Commit 73986f4

Browse files
authored
[image_picker_android] Name picked files to match the original filenames where possible (#6096)
* [image_picker_android] Name picked files to match the original filenames where possible. * [image_picker_android] Update CHANGELOG.md * [image_picker_android] Add license blocks * [image_picker_android] Clear imports, document FileUtils.getPathFromUri * [image_picker_android] Fix analysis issues
1 parent 435c46f commit 73986f4

File tree

12 files changed

+317
-64
lines changed

12 files changed

+317
-64
lines changed

packages/image_picker/image_picker_android/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
## NEXT
1+
## 0.8.5+6
22

33
* Updates minimum Flutter version to 3.0.
4+
* Fixes names of picked files to match original filenames where possible.
45

56
## 0.8.5+5
67

packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,55 +25,60 @@
2525

2626
import android.content.ContentResolver;
2727
import android.content.Context;
28+
import android.database.Cursor;
2829
import android.net.Uri;
30+
import android.provider.MediaStore;
2931
import android.webkit.MimeTypeMap;
32+
import io.flutter.Log;
3033
import java.io.File;
3134
import java.io.FileOutputStream;
3235
import java.io.IOException;
3336
import java.io.InputStream;
3437
import java.io.OutputStream;
38+
import java.util.UUID;
3539

3640
class FileUtils {
37-
41+
/**
42+
* Copies the file from the given content URI to a temporary directory, retaining the original
43+
* file name if possible.
44+
*
45+
* <p>Each file is placed in its own directory to avoid conflicts according to the following
46+
* scheme: {cacheDir}/{randomUuid}/{fileName}
47+
*
48+
* <p>If the original file name is unknown, a predefined "image_picker" filename is used and the
49+
* file extension is deduced from the mime type (with fallback to ".jpg" in case of failure).
50+
*/
3851
String getPathFromUri(final Context context, final Uri uri) {
39-
File file = null;
40-
InputStream inputStream = null;
41-
OutputStream outputStream = null;
42-
boolean success = false;
43-
try {
44-
String extension = getImageExtension(context, uri);
45-
inputStream = context.getContentResolver().openInputStream(uri);
46-
file = File.createTempFile("image_picker", extension, context.getCacheDir());
47-
file.deleteOnExit();
48-
outputStream = new FileOutputStream(file);
49-
if (inputStream != null) {
50-
copy(inputStream, outputStream);
51-
success = true;
52+
try (InputStream inputStream = context.getContentResolver().openInputStream(uri)) {
53+
String uuid = UUID.randomUUID().toString();
54+
File targetDirectory = new File(context.getCacheDir(), uuid);
55+
targetDirectory.mkdir();
56+
// TODO(SynSzakala) according to the docs, `deleteOnExit` does not work reliably on Android; we should preferably
57+
// just clear the picked files after the app startup.
58+
targetDirectory.deleteOnExit();
59+
String fileName = getImageName(context, uri);
60+
if (fileName == null) {
61+
Log.w("FileUtils", "Cannot get file name for " + uri);
62+
fileName = "image_picker" + getImageExtension(context, uri);
5263
}
53-
} catch (IOException ignored) {
54-
} finally {
55-
try {
56-
if (inputStream != null) inputStream.close();
57-
} catch (IOException ignored) {
58-
}
59-
try {
60-
if (outputStream != null) outputStream.close();
61-
} catch (IOException ignored) {
62-
// If closing the output stream fails, we cannot be sure that the
63-
// target file was written in full. Flushing the stream merely moves
64-
// the bytes into the OS, not necessarily to the file.
65-
success = false;
64+
File file = new File(targetDirectory, fileName);
65+
try (OutputStream outputStream = new FileOutputStream(file)) {
66+
copy(inputStream, outputStream);
67+
return file.getPath();
6668
}
69+
} catch (IOException e) {
70+
// If closing the output stream fails, we cannot be sure that the
71+
// target file was written in full. Flushing the stream merely moves
72+
// the bytes into the OS, not necessarily to the file.
73+
return null;
6774
}
68-
return success ? file.getPath() : null;
6975
}
7076

7177
/** @return extension of image with dot, or default .jpg if it none. */
7278
private static String getImageExtension(Context context, Uri uriImage) {
73-
String extension = null;
79+
String extension;
7480

7581
try {
76-
String imagePath = uriImage.getPath();
7782
if (uriImage.getScheme().equals(ContentResolver.SCHEME_CONTENT)) {
7883
final MimeTypeMap mime = MimeTypeMap.getSingleton();
7984
extension = mime.getExtensionFromMimeType(context.getContentResolver().getType(uriImage));
@@ -94,6 +99,20 @@ private static String getImageExtension(Context context, Uri uriImage) {
9499
return "." + extension;
95100
}
96101

102+
/** @return name of the image provided by ContentResolver; this may be null. */
103+
private static String getImageName(Context context, Uri uriImage) {
104+
try (Cursor cursor = queryImageName(context, uriImage)) {
105+
if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() < 1) return null;
106+
return cursor.getString(0);
107+
}
108+
}
109+
110+
private static Cursor queryImageName(Context context, Uri uriImage) {
111+
return context
112+
.getContentResolver()
113+
.query(uriImage, new String[] {MediaStore.MediaColumns.DISPLAY_NAME}, null, null, null);
114+
}
115+
97116
private static void copy(InputStream in, OutputStream out) throws IOException {
98117
final byte[] buffer = new byte[4 * 1024];
99118
int bytesRead;

packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@
88
import static org.junit.Assert.assertTrue;
99
import static org.robolectric.Shadows.shadowOf;
1010

11+
import android.content.ContentProvider;
12+
import android.content.ContentValues;
1113
import android.content.Context;
14+
import android.database.Cursor;
15+
import android.database.MatrixCursor;
1216
import android.net.Uri;
17+
import android.provider.MediaStore;
18+
import androidx.annotation.NonNull;
19+
import androidx.annotation.Nullable;
1320
import androidx.test.core.app.ApplicationProvider;
1421
import java.io.BufferedInputStream;
1522
import java.io.ByteArrayInputStream;
@@ -19,6 +26,7 @@
1926
import org.junit.Before;
2027
import org.junit.Test;
2128
import org.junit.runner.RunWith;
29+
import org.robolectric.Robolectric;
2230
import org.robolectric.RobolectricTestRunner;
2331
import org.robolectric.shadows.ShadowContentResolver;
2432

@@ -63,4 +71,62 @@ public void FileUtil_getImageExtension() throws IOException {
6371
String path = fileUtils.getPathFromUri(context, uri);
6472
assertTrue(path.endsWith(".jpg"));
6573
}
74+
75+
@Test
76+
public void FileUtil_getImageName() throws IOException {
77+
Uri uri = Uri.parse("content://dummy/dummy.png");
78+
Robolectric.buildContentProvider(MockContentProvider.class).create("dummy");
79+
shadowContentResolver.registerInputStream(
80+
uri, new ByteArrayInputStream("imageStream".getBytes(UTF_8)));
81+
String path = fileUtils.getPathFromUri(context, uri);
82+
assertTrue(path.endsWith("dummy.png"));
83+
}
84+
85+
private static class MockContentProvider extends ContentProvider {
86+
87+
@Override
88+
public boolean onCreate() {
89+
return true;
90+
}
91+
92+
@Nullable
93+
@Override
94+
public Cursor query(
95+
@NonNull Uri uri,
96+
@Nullable String[] projection,
97+
@Nullable String selection,
98+
@Nullable String[] selectionArgs,
99+
@Nullable String sortOrder) {
100+
MatrixCursor cursor = new MatrixCursor(new String[] {MediaStore.MediaColumns.DISPLAY_NAME});
101+
cursor.addRow(new Object[] {"dummy.png"});
102+
return cursor;
103+
}
104+
105+
@Nullable
106+
@Override
107+
public String getType(@NonNull Uri uri) {
108+
return "image/png";
109+
}
110+
111+
@Nullable
112+
@Override
113+
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
114+
return null;
115+
}
116+
117+
@Override
118+
public int delete(
119+
@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
120+
return 0;
121+
}
122+
123+
@Override
124+
public int update(
125+
@NonNull Uri uri,
126+
@Nullable ContentValues values,
127+
@Nullable String selection,
128+
@Nullable String[] selectionArgs) {
129+
return 0;
130+
}
131+
}
66132
}

packages/image_picker/image_picker_android/example/android/app/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,7 @@ dependencies {
6363
testImplementation 'junit:junit:4.13.2'
6464
androidTestImplementation 'androidx.test:runner:1.2.0'
6565
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
66+
implementation project(':image_picker_android')
67+
implementation project(':espresso')
6668
api 'androidx.test:core:1.4.0'
6769
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
package io.flutter.plugins.imagepickerexample;
6+
7+
import static androidx.test.espresso.flutter.EspressoFlutter.onFlutterWidget;
8+
import static androidx.test.espresso.flutter.action.FlutterActions.click;
9+
import static androidx.test.espresso.flutter.assertion.FlutterAssertions.matches;
10+
import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withText;
11+
import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withValueKey;
12+
import static androidx.test.espresso.intent.Intents.intended;
13+
import static androidx.test.espresso.intent.Intents.intending;
14+
import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction;
15+
16+
import android.app.Activity;
17+
import android.app.Instrumentation;
18+
import android.content.Intent;
19+
import android.net.Uri;
20+
import androidx.test.espresso.intent.rule.IntentsTestRule;
21+
import org.junit.Ignore;
22+
import org.junit.Rule;
23+
import org.junit.Test;
24+
import org.junit.rules.TestRule;
25+
26+
public class ImagePickerPickTest {
27+
28+
@Rule public TestRule rule = new IntentsTestRule<>(DriverExtensionActivity.class);
29+
30+
@Test
31+
@Ignore("Doesn't run in Firebase Test Lab: https://github.com/flutter/flutter/issues/94748")
32+
public void imageIsPickedWithOriginalName() {
33+
Instrumentation.ActivityResult result =
34+
new Instrumentation.ActivityResult(
35+
Activity.RESULT_OK, new Intent().setData(Uri.parse("content://dummy/dummy.png")));
36+
intending(hasAction(Intent.ACTION_GET_CONTENT)).respondWith(result);
37+
onFlutterWidget(withValueKey("image_picker_example_from_gallery")).perform(click());
38+
onFlutterWidget(withText("PICK")).perform(click());
39+
intended(hasAction(Intent.ACTION_GET_CONTENT));
40+
onFlutterWidget(withValueKey("image_picker_example_picked_image_name"))
41+
.check(matches(withText("dummy.png")));
42+
}
43+
}

packages/image_picker/image_picker_android/example/android/app/src/debug/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,17 @@
1313
android:hardwareAccelerated="true"
1414
android:windowSoftInputMode="adjustResize">
1515
</activity>
16+
<activity
17+
android:name=".DriverExtensionActivity"
18+
android:launchMode="singleTop"
19+
android:theme="@android:style/Theme.Black.NoTitleBar"
20+
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
21+
android:hardwareAccelerated="true"
22+
android:windowSoftInputMode="adjustResize">
23+
</activity>
24+
<provider
25+
android:authorities="dummy"
26+
android:name=".DummyContentProvider"
27+
android:exported="true"/>
1628
</application>
1729
</manifest>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
package io.flutter.plugins.imagepickerexample;
6+
7+
import androidx.annotation.NonNull;
8+
import io.flutter.embedding.android.FlutterActivity;
9+
10+
public class DriverExtensionActivity extends FlutterActivity {
11+
@NonNull
12+
@Override
13+
public String getDartEntrypointFunctionName() {
14+
return "appMain";
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
package io.flutter.plugins.imagepickerexample;
6+
7+
import android.content.ContentProvider;
8+
import android.content.ContentValues;
9+
import android.content.res.AssetFileDescriptor;
10+
import android.database.Cursor;
11+
import android.database.MatrixCursor;
12+
import android.net.Uri;
13+
import android.provider.MediaStore;
14+
import androidx.annotation.NonNull;
15+
import androidx.annotation.Nullable;
16+
17+
public class DummyContentProvider extends ContentProvider {
18+
@Override
19+
public boolean onCreate() {
20+
return true;
21+
}
22+
23+
@Nullable
24+
@Override
25+
public AssetFileDescriptor openAssetFile(@NonNull Uri uri, @NonNull String mode) {
26+
return getContext().getResources().openRawResourceFd(R.raw.ic_launcher);
27+
}
28+
29+
@Nullable
30+
@Override
31+
public Cursor query(
32+
@NonNull Uri uri,
33+
@Nullable String[] projection,
34+
@Nullable String selection,
35+
@Nullable String[] selectionArgs,
36+
@Nullable String sortOrder) {
37+
MatrixCursor cursor = new MatrixCursor(new String[] {MediaStore.MediaColumns.DISPLAY_NAME});
38+
cursor.addRow(new Object[] {"dummy.png"});
39+
return cursor;
40+
}
41+
42+
@Nullable
43+
@Override
44+
public String getType(@NonNull Uri uri) {
45+
return "image/png";
46+
}
47+
48+
@Nullable
49+
@Override
50+
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
51+
return null;
52+
}
53+
54+
@Override
55+
public int delete(
56+
@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
57+
return 0;
58+
}
59+
60+
@Override
61+
public int update(
62+
@NonNull Uri uri,
63+
@Nullable ContentValues values,
64+
@Nullable String selection,
65+
@Nullable String[] selectionArgs) {
66+
return 0;
67+
}
68+
}
Loading

0 commit comments

Comments
 (0)