diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d1f55572e699f..65ed556ca7138 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -186,6 +186,8 @@ updates: update-types: ["version-update:semver-minor", "version-update:semver-patch"] - dependency-name: "androidx.test:*" update-types: ["version-update:semver-minor", "version-update:semver-patch"] + - dependency-name: "org.robolectric:*" + update-types: ["version-update:semver-minor", "version-update:semver-patch"] - package-ecosystem: "gradle" directory: "/packages/file_selector/file_selector_android/example/android/app" diff --git a/packages/file_selector/file_selector_android/CHANGELOG.md b/packages/file_selector/file_selector_android/CHANGELOG.md index d71daf951fbf7..b8c7fc4665c9b 100644 --- a/packages/file_selector/file_selector_android/CHANGELOG.md +++ b/packages/file_selector/file_selector_android/CHANGELOG.md @@ -1,7 +1,8 @@ -## NEXT +## 0.5.1 * Updates minimum supported SDK version to Flutter 3.13/Dart 3.1. * Updates compileSdk version to 34. +* Modifies `getDirectoryPath`, `openFile`, and `openFiles` to return file/directory paths instead of URIs. ## 0.5.0+7 diff --git a/packages/file_selector/file_selector_android/android/build.gradle b/packages/file_selector/file_selector_android/android/build.gradle index 98a0a63deca4d..9bdba2f34f947 100644 --- a/packages/file_selector/file_selector_android/android/build.gradle +++ b/packages/file_selector/file_selector_android/android/build.gradle @@ -42,6 +42,7 @@ android { testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-inline:5.1.0' testImplementation 'androidx.test:core:1.3.0' + testImplementation "org.robolectric:robolectric:4.12.1" // org.jetbrains.kotlin:kotlin-bom artifact purpose is to align kotlin stdlib and related code versions. // See: https://youtrack.jetbrains.com/issue/KT-55297/kotlin-stdlib-should-declare-constraints-on-kotlin-stdlib-jdk8-and-kotlin-stdlib-jdk7 diff --git a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java index 32502ed2693a5..a20ab00e58ebb 100644 --- a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java +++ b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java @@ -4,6 +4,7 @@ package dev.flutter.packages.file_selector_android; +import android.annotation.TargetApi; import android.app.Activity; import android.content.ClipData; import android.content.ContentResolver; @@ -106,6 +107,12 @@ public void openFile( public void onResult(int resultCode, @Nullable Intent data) { if (resultCode == Activity.RESULT_OK && data != null) { final Uri uri = data.getData(); + if (uri == null) { + // No data retrieved from opening file. + result.error(new Exception("Failed to retrieve data from opening file.")); + return; + } + final GeneratedFileSelectorApi.FileResponse file = toFileResponse(uri); if (file != null) { result.success(file); @@ -183,6 +190,7 @@ public void onResult(int resultCode, @Nullable Intent data) { } @Override + @TargetApi(21) public void getDirectoryPath( @Nullable String initialDirectory, @NonNull GeneratedFileSelectorApi.Result result) { if (!sdkChecker.sdkIsAtLeast(android.os.Build.VERSION_CODES.LOLLIPOP)) { @@ -204,7 +212,22 @@ public void getDirectoryPath( public void onResult(int resultCode, @Nullable Intent data) { if (resultCode == Activity.RESULT_OK && data != null) { final Uri uri = data.getData(); - result.success(uri.toString()); + if (uri == null) { + // No data retrieved from opening directory. + result.error(new Exception("Failed to retrieve data from opening directory.")); + return; + } + + final Uri docUri = + DocumentsContract.buildDocumentUriUsingTree( + uri, DocumentsContract.getTreeDocumentId(uri)); + try { + final String path = + FileUtils.getPathFromUri(activityPluginBinding.getActivity(), docUri); + result.success(path); + } catch (UnsupportedOperationException exception) { + result.error(exception); + } } else { result.success(null); } @@ -332,10 +355,13 @@ GeneratedFileSelectorApi.FileResponse toFileResponse(@NonNull Uri uri) { return null; } + final String uriPath = + FileUtils.getPathFromCopyOfFileFromUri(activityPluginBinding.getActivity(), uri); + return new GeneratedFileSelectorApi.FileResponse.Builder() .setName(name) .setBytes(bytes) - .setPath(uri.toString()) + .setPath(uriPath) .setMimeType(contentResolver.getType(uri)) .setSize(size.longValue()) .build(); diff --git a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileUtils.java b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileUtils.java new file mode 100644 index 0000000000000..5d0b61312b36f --- /dev/null +++ b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileUtils.java @@ -0,0 +1,209 @@ +// 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. + +/* + * Copyright (C) 2007-2008 OpenIntents.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This file was modified by the Flutter authors from the following original file: + * https://raw.githubusercontent.com/iPaulPro/aFileChooser/master/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java + */ + +package dev.flutter.packages.file_selector_android; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.webkit.MimeTypeMap; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.UUID; + +public class FileUtils { + + /** URI authority that represents access to external storage providers. */ + public static final String EXTERNAL_DOCUMENT_AUTHORITY = "com.android.externalstorage.documents"; + + /** + * Retrieves path of directory represented by the specified {@code Uri}. + * + *

Intended to handle any cases needed to return paths from URIs retrieved from open + * documents/directories by starting one of {@code Intent.ACTION_OPEN_FILE}, {@code + * Intent.ACTION_OPEN_FILES}, or {@code Intent.ACTION_OPEN_DOCUMENT_TREE}. + * + *

Will return the path for on-device directories, but does not handle external storage + * volumes. + */ + @NonNull + public static String getPathFromUri(@NonNull Context context, @NonNull Uri uri) { + String uriAuthority = uri.getAuthority(); + + if (EXTERNAL_DOCUMENT_AUTHORITY.equals(uriAuthority)) { + String uriDocumentId = DocumentsContract.getDocumentId(uri); + String[] uriDocumentIdSplit = uriDocumentId.split(":"); + + if (uriDocumentIdSplit.length < 2) { + // We expect the URI document ID to contain its storage volume and name to determine its path. + throw new UnsupportedOperationException( + "Retrieving the path of a document with an unknown storage volume or name is unsupported by this plugin."); + } + + String documentStorageVolume = uriDocumentIdSplit[0]; + + // Non-primary storage volumes come from SD cards, USB drives, etc. and are + // not handled here. + // + // Constant for primary storage volumes found at + // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/provider/DocumentsContract.java;l=255?q=Documentscont&ss=android%2Fplatform%2Fsuperproject%2Fmain. + if (!documentStorageVolume.equals("primary")) { + throw new UnsupportedOperationException( + "Retrieving the path of a document from storage volume " + + documentStorageVolume + + " is unsupported by this plugin."); + } + String innermostDirectoryName = uriDocumentIdSplit[1]; + String externalStorageDirectory = Environment.getExternalStorageDirectory().getPath(); + + return externalStorageDirectory + "/" + innermostDirectoryName; + } else { + throw new UnsupportedOperationException( + "Retrieving the path from URIs with authority " + + uriAuthority.toString() + + " is unsupported by this plugin."); + } + } + + /** + * 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} + * + *

File extension is changed to match MIME type of the file, if known. Otherwise, the extension + * is left unchanged. + * + *

If the original file name is unknown, a predefined "file_selector" filename is used and the + * file extension is deduced from the mime type. + * + *

Will return null if copying the URI contents into a new file does not complete successfully + * or if a security exception is encountered when opening the input stream to start the copying. + */ + @Nullable + public static String getPathFromCopyOfFileFromUri(@NonNull Context context, @NonNull Uri uri) { + try (InputStream inputStream = context.getContentResolver().openInputStream(uri)) { + String uuid = UUID.nameUUIDFromBytes(uri.toString().getBytes()).toString(); + File targetDirectory = new File(context.getCacheDir(), uuid); + targetDirectory.mkdir(); + targetDirectory.deleteOnExit(); + String fileName = getFileName(context, uri); + String extension = getFileExtension(context, uri); + + if (fileName == null) { + if (extension == null) { + throw new IllegalArgumentException("No name nor extension found for file."); + } else { + fileName = "file_selector" + extension; + } + } else if (extension != null) { + fileName = getBaseName(fileName) + extension; + } + + 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; + } catch (SecurityException e) { + // Calling `ContentResolver#openInputStream()` has been reported to throw a + // `SecurityException` on some devices in certain circumstances. Instead of crashing, we + // return `null`. + // + // See https://github.com/flutter/flutter/issues/100025 for more details. + return null; + } + } + + /** Returns the extension of file with dot, or null if it's empty. */ + private static String getFileExtension(Context context, Uri uriFile) { + String extension; + + if (uriFile.getScheme().equals(ContentResolver.SCHEME_CONTENT)) { + final MimeTypeMap mime = MimeTypeMap.getSingleton(); + extension = mime.getExtensionFromMimeType(context.getContentResolver().getType(uriFile)); + } else { + try { + Uri uriFromFile = Uri.fromFile(new File(uriFile.getPath())); + extension = MimeTypeMap.getFileExtensionFromUrl(uriFromFile.toString()); + } catch (NullPointerException e) { + // File created from uriFile was null. + return null; + } + } + + if (extension == null || extension.isEmpty()) { + return null; + } + + return "." + extension; + } + + /** Returns the name of the file provided by ContentResolver; this may be null. */ + private static String getFileName(Context context, Uri uriFile) { + try (Cursor cursor = queryFileName(context, uriFile)) { + if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() < 1) return null; + return cursor.getString(0); + } + } + + private static Cursor queryFileName(Context context, Uri uriFile) { + return context + .getContentResolver() + .query(uriFile, 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; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + out.flush(); + } + + private static String getBaseName(String fileName) { + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex < 0) { + return fileName; + } + // Basename is everything before the last '.'. + return fileName.substring(0, lastDotIndex); + } +} diff --git a/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileSelectorAndroidPluginTest.java b/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileSelectorAndroidPluginTest.java index 63dad80954a28..dd4f74e0a287a 100644 --- a/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileSelectorAndroidPluginTest.java +++ b/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileSelectorAndroidPluginTest.java @@ -6,17 +6,21 @@ import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import android.app.Activity; import android.content.ClipData; import android.content.ContentResolver; +import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.Build; +import android.provider.DocumentsContract; import android.provider.OpenableColumns; import androidx.annotation.NonNull; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; @@ -30,8 +34,10 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; +import org.mockito.stubbing.Answer; public class FileSelectorAndroidPluginTest { @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); @@ -69,151 +75,193 @@ private void mockContentResolver( @SuppressWarnings({"rawtypes", "unchecked"}) @Test public void openFileReturnsSuccessfully() throws FileNotFoundException { - final ContentResolver mockContentResolver = mock(ContentResolver.class); - - final Uri mockUri = mock(Uri.class); - when(mockUri.toString()).thenReturn("some/path/"); - mockContentResolver(mockContentResolver, mockUri, "filename", 30, "text/plain"); - - when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT)).thenReturn(mockIntent); - when(mockObjectFactory.newDataInputStream(any())).thenReturn(mock(DataInputStream.class)); - when(mockActivity.getContentResolver()).thenReturn(mockContentResolver); - when(mockActivityBinding.getActivity()).thenReturn(mockActivity); - final FileSelectorApiImpl fileSelectorApi = - new FileSelectorApiImpl( - mockActivityBinding, mockObjectFactory, (version) -> Build.VERSION.SDK_INT >= version); - - final GeneratedFileSelectorApi.Result mockResult = mock(GeneratedFileSelectorApi.Result.class); - fileSelectorApi.openFile( - null, - new GeneratedFileSelectorApi.FileTypes.Builder() - .setMimeTypes(Collections.emptyList()) - .setExtensions(Collections.emptyList()) - .build(), - mockResult); - verify(mockIntent).addCategory(Intent.CATEGORY_OPENABLE); - - verify(mockActivity).startActivityForResult(mockIntent, 221); - - final ArgumentCaptor listenerArgumentCaptor = - ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); - verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture()); - - final Intent resultMockIntent = mock(Intent.class); - when(resultMockIntent.getData()).thenReturn(mockUri); - listenerArgumentCaptor.getValue().onActivityResult(221, Activity.RESULT_OK, resultMockIntent); - - final ArgumentCaptor fileCaptor = - ArgumentCaptor.forClass(GeneratedFileSelectorApi.FileResponse.class); - verify(mockResult).success(fileCaptor.capture()); - - final GeneratedFileSelectorApi.FileResponse file = fileCaptor.getValue(); - assertEquals(file.getBytes().length, 30); - assertEquals(file.getMimeType(), "text/plain"); - assertEquals(file.getName(), "filename"); - assertEquals(file.getSize(), (Long) 30L); - assertEquals(file.getPath(), "some/path/"); + try (MockedStatic mockedFileUtils = mockStatic(FileUtils.class)) { + final ContentResolver mockContentResolver = mock(ContentResolver.class); + + final Uri mockUri = mock(Uri.class); + final String mockUriPath = "/some/path"; + mockedFileUtils + .when(() -> FileUtils.getPathFromCopyOfFileFromUri(any(Context.class), eq(mockUri))) + .thenAnswer((Answer) invocation -> mockUriPath); + mockContentResolver(mockContentResolver, mockUri, "filename", 30, "text/plain"); + + when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT)).thenReturn(mockIntent); + when(mockObjectFactory.newDataInputStream(any())).thenReturn(mock(DataInputStream.class)); + when(mockActivity.getContentResolver()).thenReturn(mockContentResolver); + when(mockActivityBinding.getActivity()).thenReturn(mockActivity); + final FileSelectorApiImpl fileSelectorApi = + new FileSelectorApiImpl( + mockActivityBinding, + mockObjectFactory, + (version) -> Build.VERSION.SDK_INT >= version); + + final GeneratedFileSelectorApi.Result mockResult = + mock(GeneratedFileSelectorApi.Result.class); + fileSelectorApi.openFile( + null, + new GeneratedFileSelectorApi.FileTypes.Builder() + .setMimeTypes(Collections.emptyList()) + .setExtensions(Collections.emptyList()) + .build(), + mockResult); + verify(mockIntent).addCategory(Intent.CATEGORY_OPENABLE); + + verify(mockActivity).startActivityForResult(mockIntent, 221); + + final ArgumentCaptor listenerArgumentCaptor = + ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); + verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture()); + + final Intent resultMockIntent = mock(Intent.class); + when(resultMockIntent.getData()).thenReturn(mockUri); + listenerArgumentCaptor.getValue().onActivityResult(221, Activity.RESULT_OK, resultMockIntent); + + final ArgumentCaptor fileCaptor = + ArgumentCaptor.forClass(GeneratedFileSelectorApi.FileResponse.class); + verify(mockResult).success(fileCaptor.capture()); + + final GeneratedFileSelectorApi.FileResponse file = fileCaptor.getValue(); + assertEquals(file.getBytes().length, 30); + assertEquals(file.getMimeType(), "text/plain"); + assertEquals(file.getName(), "filename"); + assertEquals(file.getSize(), (Long) 30L); + assertEquals(file.getPath(), mockUriPath); + } } @SuppressWarnings({"rawtypes", "unchecked"}) @Test public void openFilesReturnsSuccessfully() throws FileNotFoundException { - final ContentResolver mockContentResolver = mock(ContentResolver.class); - - final Uri mockUri = mock(Uri.class); - when(mockUri.toString()).thenReturn("some/path/"); - mockContentResolver(mockContentResolver, mockUri, "filename", 30, "text/plain"); - - final Uri mockUri2 = mock(Uri.class); - when(mockUri2.toString()).thenReturn("some/other/path/"); - mockContentResolver(mockContentResolver, mockUri2, "filename2", 40, "image/jpg"); - - when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT)).thenReturn(mockIntent); - when(mockObjectFactory.newDataInputStream(any())).thenReturn(mock(DataInputStream.class)); - when(mockActivity.getContentResolver()).thenReturn(mockContentResolver); - when(mockActivityBinding.getActivity()).thenReturn(mockActivity); - final FileSelectorApiImpl fileSelectorApi = - new FileSelectorApiImpl( - mockActivityBinding, mockObjectFactory, (version) -> Build.VERSION.SDK_INT >= version); - - final GeneratedFileSelectorApi.Result mockResult = mock(GeneratedFileSelectorApi.Result.class); - fileSelectorApi.openFiles( - null, - new GeneratedFileSelectorApi.FileTypes.Builder() - .setMimeTypes(Collections.emptyList()) - .setExtensions(Collections.emptyList()) - .build(), - mockResult); - verify(mockIntent).addCategory(Intent.CATEGORY_OPENABLE); - verify(mockIntent).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - - verify(mockActivity).startActivityForResult(mockIntent, 222); - - final ArgumentCaptor listenerArgumentCaptor = - ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); - verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture()); - - final Intent resultMockIntent = mock(Intent.class); - final ClipData mockClipData = mock(ClipData.class); - when(mockClipData.getItemCount()).thenReturn(2); - - final ClipData.Item mockClipDataItem = mock(ClipData.Item.class); - when(mockClipDataItem.getUri()).thenReturn(mockUri); - when(mockClipData.getItemAt(0)).thenReturn(mockClipDataItem); - - final ClipData.Item mockClipDataItem2 = mock(ClipData.Item.class); - when(mockClipDataItem2.getUri()).thenReturn(mockUri2); - when(mockClipData.getItemAt(1)).thenReturn(mockClipDataItem2); - - when(resultMockIntent.getClipData()).thenReturn(mockClipData); - - listenerArgumentCaptor.getValue().onActivityResult(222, Activity.RESULT_OK, resultMockIntent); - - final ArgumentCaptor fileListCaptor = ArgumentCaptor.forClass(List.class); - verify(mockResult).success(fileListCaptor.capture()); - - final List fileList = fileListCaptor.getValue(); - assertEquals(fileList.get(0).getBytes().length, 30); - assertEquals(fileList.get(0).getMimeType(), "text/plain"); - assertEquals(fileList.get(0).getName(), "filename"); - assertEquals(fileList.get(0).getSize(), (Long) 30L); - assertEquals(fileList.get(0).getPath(), "some/path/"); - - assertEquals(fileList.get(1).getBytes().length, 40); - assertEquals(fileList.get(1).getMimeType(), "image/jpg"); - assertEquals(fileList.get(1).getName(), "filename2"); - assertEquals(fileList.get(1).getSize(), (Long) 40L); - assertEquals(fileList.get(1).getPath(), "some/other/path/"); + try (MockedStatic mockedFileUtils = mockStatic(FileUtils.class)) { + + final ContentResolver mockContentResolver = mock(ContentResolver.class); + + final Uri mockUri = mock(Uri.class); + final String mockUriPath = "some/path/"; + mockedFileUtils + .when(() -> FileUtils.getPathFromCopyOfFileFromUri(any(Context.class), eq(mockUri))) + .thenAnswer((Answer) invocation -> mockUriPath); + mockContentResolver(mockContentResolver, mockUri, "filename", 30, "text/plain"); + + final Uri mockUri2 = mock(Uri.class); + final String mockUri2Path = "some/other/path/"; + mockedFileUtils + .when(() -> FileUtils.getPathFromCopyOfFileFromUri(any(Context.class), eq(mockUri2))) + .thenAnswer((Answer) invocation -> mockUri2Path); + mockContentResolver(mockContentResolver, mockUri2, "filename2", 40, "image/jpg"); + + when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT)).thenReturn(mockIntent); + when(mockObjectFactory.newDataInputStream(any())).thenReturn(mock(DataInputStream.class)); + when(mockActivity.getContentResolver()).thenReturn(mockContentResolver); + when(mockActivityBinding.getActivity()).thenReturn(mockActivity); + final FileSelectorApiImpl fileSelectorApi = + new FileSelectorApiImpl( + mockActivityBinding, + mockObjectFactory, + (version) -> Build.VERSION.SDK_INT >= version); + + final GeneratedFileSelectorApi.Result mockResult = + mock(GeneratedFileSelectorApi.Result.class); + fileSelectorApi.openFiles( + null, + new GeneratedFileSelectorApi.FileTypes.Builder() + .setMimeTypes(Collections.emptyList()) + .setExtensions(Collections.emptyList()) + .build(), + mockResult); + verify(mockIntent).addCategory(Intent.CATEGORY_OPENABLE); + verify(mockIntent).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + + verify(mockActivity).startActivityForResult(mockIntent, 222); + + final ArgumentCaptor listenerArgumentCaptor = + ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); + verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture()); + + final Intent resultMockIntent = mock(Intent.class); + final ClipData mockClipData = mock(ClipData.class); + when(mockClipData.getItemCount()).thenReturn(2); + + final ClipData.Item mockClipDataItem = mock(ClipData.Item.class); + when(mockClipDataItem.getUri()).thenReturn(mockUri); + when(mockClipData.getItemAt(0)).thenReturn(mockClipDataItem); + + final ClipData.Item mockClipDataItem2 = mock(ClipData.Item.class); + when(mockClipDataItem2.getUri()).thenReturn(mockUri2); + when(mockClipData.getItemAt(1)).thenReturn(mockClipDataItem2); + + when(resultMockIntent.getClipData()).thenReturn(mockClipData); + + listenerArgumentCaptor.getValue().onActivityResult(222, Activity.RESULT_OK, resultMockIntent); + + final ArgumentCaptor fileListCaptor = ArgumentCaptor.forClass(List.class); + verify(mockResult).success(fileListCaptor.capture()); + + final List fileList = fileListCaptor.getValue(); + assertEquals(fileList.get(0).getBytes().length, 30); + assertEquals(fileList.get(0).getMimeType(), "text/plain"); + assertEquals(fileList.get(0).getName(), "filename"); + assertEquals(fileList.get(0).getSize(), (Long) 30L); + assertEquals(fileList.get(0).getPath(), mockUriPath); + + assertEquals(fileList.get(1).getBytes().length, 40); + assertEquals(fileList.get(1).getMimeType(), "image/jpg"); + assertEquals(fileList.get(1).getName(), "filename2"); + assertEquals(fileList.get(1).getSize(), (Long) 40L); + assertEquals(fileList.get(1).getPath(), mockUri2Path); + } } @SuppressWarnings({"rawtypes", "unchecked"}) @Test public void getDirectoryPathReturnsSuccessfully() { - final Uri mockUri = mock(Uri.class); - when(mockUri.toString()).thenReturn("some/path/"); - - when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT_TREE)).thenReturn(mockIntent); - when(mockActivityBinding.getActivity()).thenReturn(mockActivity); - final FileSelectorApiImpl fileSelectorApi = - new FileSelectorApiImpl( - mockActivityBinding, - mockObjectFactory, - (version) -> Build.VERSION_CODES.LOLLIPOP >= version); - - final GeneratedFileSelectorApi.Result mockResult = mock(GeneratedFileSelectorApi.Result.class); - fileSelectorApi.getDirectoryPath(null, mockResult); - - verify(mockActivity).startActivityForResult(mockIntent, 223); - - final ArgumentCaptor listenerArgumentCaptor = - ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); - verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture()); - - final Intent resultMockIntent = mock(Intent.class); - when(resultMockIntent.getData()).thenReturn(mockUri); - listenerArgumentCaptor.getValue().onActivityResult(223, Activity.RESULT_OK, resultMockIntent); - - verify(mockResult).success("some/path/"); + try (MockedStatic mockedFileUtils = mockStatic(FileUtils.class)) { + final Uri mockUri = mock(Uri.class); + final String mockUriPath = "some/path/"; + final String mockUriId = "someId"; + final Uri mockUriUsingTree = mock(Uri.class); + + mockedFileUtils + .when(() -> FileUtils.getPathFromUri(any(Context.class), eq(mockUriUsingTree))) + .thenAnswer((Answer) invocation -> mockUriPath); + + try (MockedStatic mockedDocumentsContract = + mockStatic(DocumentsContract.class)) { + + mockedDocumentsContract + .when(() -> DocumentsContract.getTreeDocumentId(mockUri)) + .thenAnswer((Answer) invocation -> mockUriId); + mockedDocumentsContract + .when(() -> DocumentsContract.buildDocumentUriUsingTree(mockUri, mockUriId)) + .thenAnswer((Answer) invocation -> mockUriUsingTree); + + when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT_TREE)).thenReturn(mockIntent); + when(mockActivityBinding.getActivity()).thenReturn(mockActivity); + final FileSelectorApiImpl fileSelectorApi = + new FileSelectorApiImpl( + mockActivityBinding, + mockObjectFactory, + (version) -> Build.VERSION_CODES.LOLLIPOP >= version); + + final GeneratedFileSelectorApi.Result mockResult = + mock(GeneratedFileSelectorApi.Result.class); + fileSelectorApi.getDirectoryPath(null, mockResult); + + verify(mockActivity).startActivityForResult(mockIntent, 223); + + final ArgumentCaptor listenerArgumentCaptor = + ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); + verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture()); + + final Intent resultMockIntent = mock(Intent.class); + when(resultMockIntent.getData()).thenReturn(mockUri); + listenerArgumentCaptor + .getValue() + .onActivityResult(223, Activity.RESULT_OK, resultMockIntent); + + verify(mockResult).success(mockUriPath); + } + } } @Test diff --git a/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileUtilsTest.java b/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileUtilsTest.java new file mode 100644 index 0000000000000..760874317efb4 --- /dev/null +++ b/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileUtilsTest.java @@ -0,0 +1,255 @@ +// 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 dev.flutter.packages.file_selector_android; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.webkit.MimeTypeMap; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.mockito.stubbing.Answer; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowContentResolver; +import org.robolectric.shadows.ShadowMimeTypeMap; + +@RunWith(RobolectricTestRunner.class) +public class FileUtilsTest { + + private Context context; + ShadowContentResolver shadowContentResolver; + ContentResolver contentResolver; + + @Before + public void before() { + context = ApplicationProvider.getApplicationContext(); + contentResolver = spy(context.getContentResolver()); + shadowContentResolver = shadowOf(context.getContentResolver()); + ShadowMimeTypeMap mimeTypeMap = shadowOf(MimeTypeMap.getSingleton()); + mimeTypeMap.addExtensionMimeTypeMapping("txt", "document/txt"); + mimeTypeMap.addExtensionMimeTypeMapping("jpg", "image/jpeg"); + mimeTypeMap.addExtensionMimeTypeMapping("png", "image/png"); + mimeTypeMap.addExtensionMimeTypeMapping("webp", "image/webp"); + } + + @Test + public void getPathFromUri_returnsExpectedPathForExternalDocumentUri() { + // Uri that represents Documents/test directory on device: + Uri uri = + Uri.parse( + "content://com.android.externalstorage.documents/tree/primary%3ADocuments%2Ftest"); + try (MockedStatic mockedDocumentsContract = + mockStatic(DocumentsContract.class)) { + mockedDocumentsContract + .when(() -> DocumentsContract.getDocumentId(uri)) + .thenAnswer((Answer) invocation -> "primary:Documents/test"); + String path = FileUtils.getPathFromUri(context, uri); + String externalStorageDirectoryPath = Environment.getExternalStorageDirectory().getPath(); + String expectedPath = externalStorageDirectoryPath + "/Documents/test"; + assertEquals(path, expectedPath); + } + } + + @Test + public void getPathFromUri_throwExceptionForExternalDocumentUriWithNonPrimaryStorageVolume() { + // Uri that represents Documents/test directory from some external storage volume ("external" for this test): + Uri uri = + Uri.parse( + "content://com.android.externalstorage.documents/tree/external%3ADocuments%2Ftest"); + try (MockedStatic mockedDocumentsContract = + mockStatic(DocumentsContract.class)) { + mockedDocumentsContract + .when(() -> DocumentsContract.getDocumentId(uri)) + .thenAnswer((Answer) invocation -> "external:Documents/test"); + assertThrows( + UnsupportedOperationException.class, () -> FileUtils.getPathFromUri(context, uri)); + } + } + + @Test + public void getPathFromUri_throwExceptionForUriWithUnhandledAuthority() { + Uri uri = Uri.parse("content://com.unsupported.authority/tree/primary%3ADocuments%2Ftest"); + assertThrows(UnsupportedOperationException.class, () -> FileUtils.getPathFromUri(context, uri)); + } + + @Test + public void getPathFromCopyOfFileFromUri_returnsPathWithContent() throws IOException { + Uri uri = MockContentProvider.PNG_URI; + Robolectric.buildContentProvider(MockContentProvider.class).create("dummy"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("fileStream".getBytes(UTF_8))); + + String path = FileUtils.getPathFromCopyOfFileFromUri(context, uri); + File file = new File(path); + int size = (int) file.length(); + byte[] bytes = new byte[size]; + + BufferedInputStream buf = new BufferedInputStream(new FileInputStream(file)); + buf.read(bytes, 0, bytes.length); + buf.close(); + + assertTrue(bytes.length > 0); + String fileStream = new String(bytes, UTF_8); + assertEquals("fileStream", fileStream); + } + + @Test + public void getPathFromCopyOfFileFromUri_returnsNullPathWhenSecurityExceptionThrown() + throws IOException { + Uri uri = Uri.parse("content://dummy/dummy.png"); + + ContentResolver mockContentResolver = mock(ContentResolver.class); + when(mockContentResolver.openInputStream(any(Uri.class))).thenThrow(SecurityException.class); + + Context mockContext = mock(Context.class); + when(mockContext.getContentResolver()).thenReturn(mockContentResolver); + + String path = FileUtils.getPathFromCopyOfFileFromUri(mockContext, uri); + + assertNull(path); + } + + @Test + public void getFileExtension_returnsExpectedFileExtension() throws IOException { + Uri uri = MockContentProvider.TXT_URI; + Robolectric.buildContentProvider(MockContentProvider.class).create("dummy"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("fileStream".getBytes(UTF_8))); + + String path = FileUtils.getPathFromCopyOfFileFromUri(context, uri); + System.out.println(path); + assertTrue(path.endsWith(".txt")); + } + + @Test + public void getFileName_returnsExpectedName() throws IOException { + Uri uri = MockContentProvider.PNG_URI; + Robolectric.buildContentProvider(MockContentProvider.class).create("dummy"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("fileStream".getBytes(UTF_8))); + String path = FileUtils.getPathFromCopyOfFileFromUri(context, uri); + assertTrue(path.endsWith("a.b.png")); + } + + @Test + public void getPathFromCopyOfFileFromUri_returnsExpectedPathForUriWithNoExtensionInBaseName() + throws IOException { + Uri uri = MockContentProvider.NO_EXTENSION_URI; + Robolectric.buildContentProvider(MockContentProvider.class).create("dummy"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("fileStream".getBytes(UTF_8))); + String path = FileUtils.getPathFromCopyOfFileFromUri(context, uri); + assertTrue(path.endsWith("abc.png")); + } + + @Test + public void getPathFromCopyOfFileFromUri_returnsExpectedPathForUriWithMismatchedTypeToFile() + throws IOException { + Uri uri = MockContentProvider.WEBP_URI; + Robolectric.buildContentProvider(MockContentProvider.class).create("dummy"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("fileStream".getBytes(UTF_8))); + String path = FileUtils.getPathFromCopyOfFileFromUri(context, uri); + assertTrue(path.endsWith("c.d.webp")); + } + + @Test + public void getPathFromCopyOfFileFromUri_returnsExpectedPathForUriWithUnknownType() + throws IOException { + Uri uri = MockContentProvider.UNKNOWN_URI; + Robolectric.buildContentProvider(MockContentProvider.class).create("dummy"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("fileStream".getBytes(UTF_8))); + String path = FileUtils.getPathFromCopyOfFileFromUri(context, uri); + assertTrue(path.endsWith("e.f.g")); + } + + private static class MockContentProvider extends ContentProvider { + public static final Uri TXT_URI = Uri.parse("content://dummy/dummydocument"); + public static final Uri PNG_URI = Uri.parse("content://dummy/a.b.png"); + public static final Uri WEBP_URI = Uri.parse("content://dummy/c.d.png"); + public static final Uri UNKNOWN_URI = Uri.parse("content://dummy/e.f.g"); + public static final Uri NO_EXTENSION_URI = Uri.parse("content://dummy/abc"); + + @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[] {uri.getLastPathSegment()}); + return cursor; + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + if (uri.equals(TXT_URI)) return "document/txt"; + if (uri.equals(PNG_URI)) return "image/png"; + if (uri.equals(WEBP_URI)) return "image/webp"; + if (uri.equals(NO_EXTENSION_URI)) return "image/png"; + return null; + } + + @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/file_selector/file_selector_android/example/android/app/src/androidTest/java/dev/flutter/packages/file_selector_android_example/FileSelectorAndroidTest.java b/packages/file_selector/file_selector_android/example/android/app/src/androidTest/java/dev/flutter/packages/file_selector_android_example/FileSelectorAndroidTest.java index b464d10c24926..b7f2390d019a0 100644 --- a/packages/file_selector/file_selector_android/example/android/app/src/androidTest/java/dev/flutter/packages/file_selector_android_example/FileSelectorAndroidTest.java +++ b/packages/file_selector/file_selector_android/example/android/app/src/androidTest/java/dev/flutter/packages/file_selector_android_example/FileSelectorAndroidTest.java @@ -14,15 +14,20 @@ import static androidx.test.espresso.intent.Intents.intending; import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; import static androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra; +import static org.junit.Assert.assertEquals; import android.app.Activity; import android.app.Instrumentation; import android.content.ClipData; import android.content.Intent; import android.net.Uri; +import android.view.View; import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.flutter.api.WidgetAssertion; +import androidx.test.espresso.flutter.model.WidgetInfo; import androidx.test.espresso.intent.rule.IntentsRule; import androidx.test.ext.junit.rules.ActivityScenarioRule; +import java.util.UUID; import org.junit.Rule; import org.junit.Test; @@ -50,17 +55,41 @@ public void perform(DriverExtensionActivity activity) { public void openImageFile() { clearAnySystemDialog(); + final String fileName = "dummy.png"; final Instrumentation.ActivityResult result = new Instrumentation.ActivityResult( Activity.RESULT_OK, - new Intent().setData(Uri.parse("content://file_selector_android_test/dummy.png"))); + new Intent().setData(Uri.parse("content://file_selector_android_test/" + fileName))); intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(result); onFlutterWidget(withText("Open an image")).perform(click()); onFlutterWidget(withText("Press to open an image file(png, jpg)")).perform(click()); intended(hasAction(Intent.ACTION_OPEN_DOCUMENT)); onFlutterWidget(withValueKey("result_image_name")) - .check(matches(withText("content://file_selector_android_test/dummy.png"))); + .check( + new WidgetAssertion() { + @Override + public void check(View flutterView, WidgetInfo widgetInfo) { + String filePath = widgetInfo.getText(); + String expectedContentUri = "content://file_selector_android_test/dummy.png"; + String expectedContentUriUuid = + UUID.nameUUIDFromBytes(expectedContentUri.toString().getBytes()).toString(); + + myActivityTestRule + .getScenario() + .onActivity( + activity -> { + String expectedCacheDirectory = activity.getCacheDir().getPath(); + String expectedFilePath = + expectedCacheDirectory + + "/" + + expectedContentUriUuid + + "/" + + fileName; + assertEquals(filePath, expectedFilePath); + }); + } + }); } @Test diff --git a/packages/file_selector/file_selector_android/pubspec.yaml b/packages/file_selector/file_selector_android/pubspec.yaml index 9f0843771ff18..6cb289b391ad7 100644 --- a/packages/file_selector/file_selector_android/pubspec.yaml +++ b/packages/file_selector/file_selector_android/pubspec.yaml @@ -2,7 +2,7 @@ name: file_selector_android description: Android implementation of the file_selector package. repository: https://github.com/flutter/packages/tree/main/packages/file_selector/file_selector_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+file_selector%22 -version: 0.5.0+7 +version: 0.5.1 environment: sdk: ^3.1.0