diff --git a/android/app/src/com/android/bluetooth/BluetoothMethodProxy.java b/android/app/src/com/android/bluetooth/BluetoothMethodProxy.java index 8c494edd201821314f3d07ad6e0531051cbfe28d..8ce48c422628c1b26a1d72e9e784640757d2af6e 100644 --- a/android/app/src/com/android/bluetooth/BluetoothMethodProxy.java +++ b/android/app/src/com/android/bluetooth/BluetoothMethodProxy.java @@ -22,6 +22,7 @@ import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; +import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; @@ -34,6 +35,7 @@ import com.android.obex.HeaderSet; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; /** * Proxy class for method calls to help with unit testing @@ -132,6 +134,23 @@ public class BluetoothMethodProxy { return contentResolver.openFileDescriptor(uri, mode); } + /** + * Proxies {@link ContentResolver#openAssetFileDescriptor(Uri, String)}. + */ + public AssetFileDescriptor contentResolverOpenAssetFileDescriptor( + ContentResolver contentResolver, final Uri uri, final String mode) + throws FileNotFoundException { + return contentResolver.openAssetFileDescriptor(uri, mode); + } + + /** + * Proxies {@link ContentResolver#openInputStream(Uri)}. + */ + public InputStream contentResolverOpenInputStream(ContentResolver contentResolver, + final Uri uri) throws FileNotFoundException { + return contentResolver.openInputStream(uri); + } + /** * Proxies {@link Context#sendBroadcast(Intent)}. */ diff --git a/android/app/src/com/android/bluetooth/opp/BluetoothOppSendFileInfo.java b/android/app/src/com/android/bluetooth/opp/BluetoothOppSendFileInfo.java index 46e3ba144e70c599f93f324ea6ae336649af606e..2adb8e5f44c278b60bcf23c04b5b9f2f3e81430c 100644 --- a/android/app/src/com/android/bluetooth/opp/BluetoothOppSendFileInfo.java +++ b/android/app/src/com/android/bluetooth/opp/BluetoothOppSendFileInfo.java @@ -42,6 +42,7 @@ import android.provider.OpenableColumns; import android.util.EventLog; import android.util.Log; +import com.android.bluetooth.BluetoothMethodProxy; import com.android.bluetooth.R; import java.io.File; @@ -119,9 +120,10 @@ public class BluetoothOppSendFileInfo { contentType = contentResolver.getType(uri); Cursor metadataCursor; try { - metadataCursor = contentResolver.query(uri, new String[]{ - OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE - }, null, null, null); + metadataCursor = BluetoothMethodProxy.getInstance().contentResolverQuery( + contentResolver, uri, new String[]{ + OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE + }, null, null, null); } catch (SQLiteException e) { // some content providers don't support the DISPLAY_NAME or SIZE columns metadataCursor = null; @@ -180,7 +182,8 @@ public class BluetoothOppSendFileInfo { // right size in _OpenableColumns.SIZE // As a second source of getting the correct file length, // get a file descriptor and get the stat length - AssetFileDescriptor fd = contentResolver.openAssetFileDescriptor(uri, "r"); + AssetFileDescriptor fd = BluetoothMethodProxy.getInstance() + .contentResolverOpenAssetFileDescriptor(contentResolver, uri, "r"); long statLength = fd.getLength(); if (length != statLength && statLength > 0) { Log.e(TAG, "Content provider length is wrong (" + Long.toString(length) @@ -200,7 +203,8 @@ public class BluetoothOppSendFileInfo { length = getStreamSize(is); Log.w(TAG, "File length not provided. Length from stream = " + length); // Reset the stream - fd = contentResolver.openAssetFileDescriptor(uri, "r"); + fd = BluetoothMethodProxy.getInstance() + .contentResolverOpenAssetFileDescriptor(contentResolver, uri, "r"); is = fd.createInputStream(); } } catch (IOException e) { @@ -219,14 +223,16 @@ public class BluetoothOppSendFileInfo { if (is == null) { try { - is = (FileInputStream) contentResolver.openInputStream(uri); + is = (FileInputStream) BluetoothMethodProxy.getInstance() + .contentResolverOpenInputStream(contentResolver, uri); // If the database doesn't contain the file size, get the size // by reading through the entire stream if (length == 0) { length = getStreamSize(is); // Reset the stream - is = (FileInputStream) contentResolver.openInputStream(uri); + is = (FileInputStream) BluetoothMethodProxy.getInstance() + .contentResolverOpenInputStream(contentResolver, uri); } } catch (FileNotFoundException e) { return SEND_FILE_INFO_ERROR; diff --git a/android/app/tests/unit/src/com/android/bluetooth/opp/BluetoothOppSendFileInfoTest.java b/android/app/tests/unit/src/com/android/bluetooth/opp/BluetoothOppSendFileInfoTest.java new file mode 100644 index 0000000000000000000000000000000000000000..756836afaa829d9b90b9e5e3d1af35d0fb184652 --- /dev/null +++ b/android/app/tests/unit/src/com/android/bluetooth/opp/BluetoothOppSendFileInfoTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * 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. + */ + + +package com.android.bluetooth.opp; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Log; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import com.android.bluetooth.BluetoothMethodProxy; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.FileInputStream; +import java.io.IOException; + +@RunWith(AndroidJUnit4.class) +public class BluetoothOppSendFileInfoTest { + Context mContext; + MatrixCursor mCursor; + + @Mock + BluetoothMethodProxy mCallProxy; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + BluetoothMethodProxy.setInstanceForTesting(mCallProxy); + } + + @After + public void tearDown() { + BluetoothMethodProxy.setInstanceForTesting(null); + } + + @Test + public void createInstance_withFileInputStream() { + String fileName = "abc.txt"; + String type = "text/plain"; + long length = 10000; + FileInputStream inputStream = mock(FileInputStream.class); + int status = BluetoothShare.STATUS_SUCCESS; + BluetoothOppSendFileInfo info = + new BluetoothOppSendFileInfo(fileName, type, length, inputStream, status); + + assertThat(info.mStatus).isEqualTo(status); + assertThat(info.mFileName).isEqualTo(fileName); + assertThat(info.mLength).isEqualTo(length); + assertThat(info.mInputStream).isEqualTo(inputStream); + assertThat(info.mMimetype).isEqualTo(type); + } + + @Test + public void createInstance_withoutFileInputStream() { + String type = "text/plain"; + long length = 10000; + int status = BluetoothShare.STATUS_SUCCESS; + String data = "Testing is boring"; + BluetoothOppSendFileInfo info = + new BluetoothOppSendFileInfo(data, type, length, status); + + assertThat(info.mStatus).isEqualTo(status); + assertThat(info.mData).isEqualTo(data); + assertThat(info.mLength).isEqualTo(length); + assertThat(info.mMimetype).isEqualTo(type); + } + + @Test + public void generateFileInfo_withUnsupportedScheme_returnsSendFileInfoError() { + String type = "text/plain"; + Uri uri = Uri.parse("https://www.google.com"); + + BluetoothOppSendFileInfo info = BluetoothOppSendFileInfo.generateFileInfo(mContext, uri, + type, true); + assertThat(info).isEqualTo(BluetoothOppSendFileInfo.SEND_FILE_INFO_ERROR); + } + + @Test + public void generateFileInfo_withForbiddenExternalUri_returnsSendFileInfoError() { + String type = "text/plain"; + Uri uri = Uri.parse("content://com.android.bluetooth.map.MmsFileProvider:8080"); + + BluetoothOppSendFileInfo info = BluetoothOppSendFileInfo.generateFileInfo(mContext, uri, + type, true); + assertThat(info).isEqualTo(BluetoothOppSendFileInfo.SEND_FILE_INFO_ERROR); + } + + @Test + public void generateFileInfo_withoutPermissionForAccessingUri_returnsSendFileInfoError() { + String type = "text/plain"; + Uri uri = Uri.parse("content:///hello/world"); + + doThrow(new SecurityException()).when(mCallProxy).contentResolverQuery( + any(), eq(uri), any(), any(), any(), + any()); + + BluetoothOppSendFileInfo info = BluetoothOppSendFileInfo.generateFileInfo(mContext, uri, + type, true); + assertThat(info).isEqualTo(BluetoothOppSendFileInfo.SEND_FILE_INFO_ERROR); + } + + @Test + public void generateFileInfo_withUncorrectableMismatch_returnsSendFileInfoError() + throws IOException { + String type = "text/plain"; + Uri uri = Uri.parse("content:///hello/world"); + + long fileLength = 0; + String fileName = "coolName.txt"; + + AssetFileDescriptor fd = mock(AssetFileDescriptor.class); + FileInputStream fs = mock(FileInputStream.class); + + mCursor = new MatrixCursor(new String[]{ + OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE + }); + mCursor.addRow(new Object[]{fileName, fileLength}); + + doReturn(mCursor).when(mCallProxy).contentResolverQuery( + any(), eq(uri), any(), any(), any(), + any()); + + doReturn(fd).when(mCallProxy).contentResolverOpenAssetFileDescriptor( + any(), eq(uri), any()); + doReturn(0L).when(fd).getLength(); + doThrow(new IOException()).when(fd).createInputStream(); + doReturn(fs).when(mCallProxy).contentResolverOpenInputStream(any(), eq(uri)); + doReturn(0, -1).when(fs).read(any(), anyInt(), anyInt()); + + BluetoothOppSendFileInfo info = BluetoothOppSendFileInfo.generateFileInfo(mContext, uri, + type, true); + + assertThat(info).isEqualTo(BluetoothOppSendFileInfo.SEND_FILE_INFO_ERROR); + } + + @Test + public void generateFileInfo_withCorrectableMismatch_returnInfoWithCorrectLength() + throws IOException { + String type = "text/plain"; + Uri uri = Uri.parse("content:///hello/world"); + + long fileLength = 0; + long correctFileLength = 1000; + String fileName = "coolName.txt"; + + AssetFileDescriptor fd = mock(AssetFileDescriptor.class); + FileInputStream fs = mock(FileInputStream.class); + + mCursor = new MatrixCursor(new String[]{ + OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE + }); + mCursor.addRow(new Object[]{fileName, fileLength}); + + doReturn(mCursor).when(mCallProxy).contentResolverQuery( + any(), eq(uri), any(), any(), any(), + any()); + + doReturn(fd).when(mCallProxy).contentResolverOpenAssetFileDescriptor( + any(), eq(uri), any()); + doReturn(0L).when(fd).getLength(); + doReturn(fs).when(fd).createInputStream(); + + // the real size will be returned in getStreamSize(fs) + doReturn((int) correctFileLength, -1).when(fs).read(any(), anyInt(), anyInt()); + + BluetoothOppSendFileInfo info = BluetoothOppSendFileInfo.generateFileInfo(mContext, uri, + type, true); + + assertThat(info.mInputStream).isEqualTo(fs); + assertThat(info.mFileName).isEqualTo(fileName); + assertThat(info.mLength).isEqualTo(correctFileLength); + assertThat(info.mStatus).isEqualTo(0); + } + + @Test + public void generateFileInfo_withFileUriNotInExternalStorageDir_returnFileErrorInfo() { + String type = "text/plain"; + Uri uri = Uri.parse("file:///obviously/not/in/external/storage"); + + BluetoothOppSendFileInfo info = BluetoothOppSendFileInfo.generateFileInfo(mContext, uri, + type, true); + + assertThat(info).isEqualTo(BluetoothOppSendFileInfo.SEND_FILE_INFO_ERROR); + } +}