diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/filechooser/FileChooserIntentBuilderTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/filechooser/FileChooserIntentBuilderTest.kt new file mode 100644 index 000000000000..5669c98abf7b --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/filechooser/FileChooserIntentBuilderTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2018 DuckDuckGo + * + * 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.duckduckgo.app.browser.filechooser + +import android.content.ClipData +import android.content.ClipData.Item +import android.content.ClipDescription +import android.content.ClipDescription.MIMETYPE_TEXT_URILIST +import android.content.Intent +import android.net.Uri +import androidx.net.toUri +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class FileChooserIntentBuilderTest { + + private lateinit var testee: FileChooserIntentBuilder + + @Before + fun setup() { + testee = FileChooserIntentBuilder() + } + + @Test + fun whenIntentBuiltThenReadUriPermissionFlagSet() { + val output = testee.intent(emptyArray()) + assertTrue("Intent.FLAG_GRANT_READ_URI_PERMISSION flag not set on intent", output.hasFlagSet(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + } + + @Test + fun whenIntentBuiltThenAcceptTypeSetToAll() { + val output = testee.intent(emptyArray()) + assertEquals("*/*", output.type) + } + + @Test + fun whenMultipleModeDisabledThenIntentExtraReturnsFalse() { + val output = testee.intent(emptyArray(), canChooseMultiple = false) + assertFalse(output.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) + } + + @Test + fun whenRequestedTypesAreMissingThenShouldNotAddMimeTypeExtra() { + val output = testee.intent(emptyArray()) + assertFalse(output.hasExtra(Intent.EXTRA_MIME_TYPES)) + } + + @Test + fun whenRequestedTypesArePresentThenShouldAddMimeTypeExtra() { + val output = testee.intent(arrayOf("image/png", "image/gif")) + assertTrue(output.hasExtra(Intent.EXTRA_MIME_TYPES)) + } + + @Test + fun whenUpperCaseTypesGivenThenNormalisedToLowercase() { + val output = testee.intent(arrayOf("ImAgE/PnG")) + assertEquals("image/png", output.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)[0]) + } + + @Test + fun whenEmptyTypesGivenThenNotIncludedInOutput() { + val output = testee.intent(arrayOf("image/png", "", " ", "image/gif")) + val mimeTypes = output.getStringArrayExtra(Intent.EXTRA_MIME_TYPES) + assertEquals(2, mimeTypes.size) + assertEquals("image/png", mimeTypes[0]) + assertEquals("image/gif", mimeTypes[1]) + } + + @Test + fun whenMultipleModeEnabledThenIntentExtraReturnsTrue() { + val output = testee.intent(emptyArray(), canChooseMultiple = true) + assertTrue(output.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) + } + + @Test + fun whenExtractingSingleUriEmptyClipThenSingleUriReturned() { + val intent = buildIntent("a") + val extractedUris = testee.extractSelectedFileUris(intent) + + assertEquals(1, extractedUris!!.size) + assertEquals("a", extractedUris.first().toString()) + } + + @Test + fun whenExtractingSingleUriNonEmptyClipThenUriReturnedFromClip() { + val intent = buildIntent("a", listOf("b")) + val extractedUris = testee.extractSelectedFileUris(intent) + + assertEquals(1, extractedUris!!.size) + assertEquals("b", extractedUris.first().toString()) + } + + @Test + fun whenExtractingMultipleClipItemsThenCorrectUrisReturnedFromClip() { + val intent = buildIntent("a", listOf("b", "c")) + val extractedUris = testee.extractSelectedFileUris(intent) + + assertEquals(2, extractedUris!!.size) + assertEquals("b", extractedUris[0].toString()) + assertEquals("c", extractedUris[1].toString()) + } + + @Test + fun whenExtractingSingleUriMissingButClipDataAvailableThenUriReturnedFromClip() { + val intent = buildIntent(clipData = listOf("b")) + val extractedUris = testee.extractSelectedFileUris(intent) + + assertEquals(1, extractedUris!!.size) + assertEquals("b", extractedUris.first().toString()) + } + + @Test + fun whenNoDataOrClipDataThenNullUriReturned() { + val intent = buildIntent(data = null, clipData = null) + val extractedUris = testee.extractSelectedFileUris(intent) + + assertNull(extractedUris) + } + + /** + * Helper function to build an `Intent` which contains one or more of `data` and `clipData` values. + * + * This is a bit messy but the Intent APIs are messy themselves; at least this contains the mess to this one helper function + */ + + private fun buildIntent(data: String? = null, clipData: List? = null): Intent { + return Intent().also { + if (data != null) { + it.data = data.toUri() + } + + if (clipData != null && clipData.isNotEmpty()) { + val clipDescription = ClipDescription("", arrayOf(MIMETYPE_TEXT_URILIST)) + it.clipData = ClipData(clipDescription, Item(Uri.parse(clipData.first()))) + + for (i in 1 until clipData.size) { + it.clipData.addItem(Item(Uri.parse(clipData[i]))) + } + } + } + } + + private fun Intent.hasFlagSet(expectedFlag: Int): Boolean { + val actual = flags and expectedFlag + return expectedFlag == actual + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 697d269af93e..70dc147fcdd8 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -16,22 +16,12 @@ package com.duckduckgo.app.browser -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.app.DownloadManager -import android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED import android.arch.lifecycle.Observer import android.arch.lifecycle.ViewModelProviders import android.content.Context import android.content.Intent import android.content.Intent.EXTRA_TEXT -import android.content.pm.PackageManager -import android.net.Uri import android.os.Bundle -import android.os.Environment -import android.support.design.widget.Snackbar -import android.support.v4.app.ActivityCompat.requestPermissions -import android.support.v4.content.ContextCompat.checkSelfPermission -import android.webkit.URLUtil import com.duckduckgo.app.bookmarks.ui.BookmarksActivity import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.BrowserViewModel.Command.* @@ -43,7 +33,6 @@ import com.duckduckgo.app.privacy.ui.PrivacyDashboardActivity import com.duckduckgo.app.settings.SettingsActivity import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.ui.TabSwitcherActivity -import kotlinx.android.synthetic.main.fragment_browser_tab.* import org.jetbrains.anko.longToast import timber.log.Timber import javax.inject.Inject @@ -60,9 +49,6 @@ class BrowserActivity : DuckDuckGoActivity() { ViewModelProviders.of(this, viewModelFactory).get(BrowserViewModel::class.java) } - // Used to represent a file to download, but may first require permission - private var pendingFileDownload: PendingFileDownload? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_browser) @@ -159,12 +145,13 @@ class BrowserActivity : DuckDuckGoActivity() { } } - private fun processCommand(it: Command?) { - when (it) { - is NewTab -> openNewTab(it.tabId, it.query) - is Query -> currentTab?.submitQuery(it.query) + private fun processCommand(command: Command?) { + Timber.i("Processing command: $command") + when (command) { + is NewTab -> openNewTab(command.tabId, command.query) + is Query -> currentTab?.submitQuery(command.query) is Refresh -> currentTab?.refresh() - is Command.DisplayMessage -> applicationContext?.longToast(it.messageId) + is Command.DisplayMessage -> applicationContext?.longToast(command.messageId) } } @@ -201,65 +188,12 @@ class BrowserActivity : DuckDuckGoActivity() { startActivity(BookmarksActivity.intent(this)) } - fun downloadFile(url: String) { - pendingFileDownload = PendingFileDownload(url, Environment.DIRECTORY_DOWNLOADS) - downloadFileWithPermissionCheck() - } - - fun downloadImage(url: String) { - pendingFileDownload = PendingFileDownload(url, Environment.DIRECTORY_PICTURES) - downloadFileWithPermissionCheck() - } - - private fun downloadFileWithPermissionCheck() { - if (hasWriteStoragePermission()) { - downloadFile() - } else { - requestStoragePermission() - } - } - - private fun downloadFile() { - val pending = pendingFileDownload - pending?.let { - val uri = Uri.parse(pending.url) - val guessedFileName = URLUtil.guessFileName(pending.url, null, null) - Timber.i("Guessed filename of $guessedFileName for url ${pending.url}") - val request = DownloadManager.Request(uri).apply { - allowScanningByMediaScanner() - setDestinationInExternalPublicDir(pending.directory, guessedFileName) - setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - } - val manager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - manager.enqueue(request) - pendingFileDownload = null - applicationContext?.longToast(getString(R.string.webviewDownload)) - } - } - - private fun hasWriteStoragePermission(): Boolean { - return checkSelfPermission(this, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED - } - - private fun requestStoragePermission() { - requestPermissions(this, arrayOf(WRITE_EXTERNAL_STORAGE), PERMISSION_REQUEST_EXTERNAL_STORAGE) - } - - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - if (requestCode == PERMISSION_REQUEST_EXTERNAL_STORAGE) { - if ((grantResults.isNotEmpty()) && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Timber.i("Permission granted") - downloadFile() - } else { - Timber.i("Permission refused") - Snackbar.make(toolbar, R.string.permissionRequiredToDownload, Snackbar.LENGTH_LONG).show() - } - } - } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == DASHBOARD_REQUEST_CODE) { viewModel.receivedDashboardResult(resultCode) + } else { + super.onActivityResult(requestCode, resultCode, data) } } @@ -269,11 +203,6 @@ class BrowserActivity : DuckDuckGoActivity() { } } - private data class PendingFileDownload( - val url: String, - val directory: String - ) - companion object { fun intent(context: Context, queryExtra: String? = null, newSearch: Boolean = false): Intent { @@ -285,6 +214,5 @@ class BrowserActivity : DuckDuckGoActivity() { private const val NEW_SEARCH_EXTRA = "NEW_SEARCH_EXTRA" private const val DASHBOARD_REQUEST_CODE = 100 - private const val PERMISSION_REQUEST_EXTERNAL_STORAGE = 200 } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt index 955e3e376f7f..393099ed73a8 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt @@ -16,7 +16,9 @@ package com.duckduckgo.app.browser +import android.net.Uri import android.view.View +import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebView import timber.log.Timber @@ -56,4 +58,8 @@ class BrowserChromeClient @Inject constructor() : WebChromeClient() { webViewClientListener?.titleReceived(title) } + override fun onShowFileChooser(webView: WebView, filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams): Boolean { + webViewClientListener?.showFileChooser(filePathCallback, fileChooserParams) + return true + } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index a6cc0a9d0bc8..9da58e883b50 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -16,25 +16,30 @@ package com.duckduckgo.app.browser +import android.Manifest import android.animation.LayoutTransition.CHANGING import android.annotation.SuppressLint +import android.app.Activity.RESULT_OK +import android.app.DownloadManager import android.arch.lifecycle.Observer import android.arch.lifecycle.ViewModelProviders import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.res.Configuration import android.net.Uri import android.os.Bundle +import android.os.Environment import android.support.annotation.StringRes +import android.support.design.widget.Snackbar import android.support.v4.app.Fragment +import android.support.v4.content.ContextCompat import android.support.v7.widget.LinearLayoutManager import android.text.Editable import android.view.* import android.view.View.VISIBLE import android.view.inputmethod.EditorInfo -import android.webkit.URLUtil -import android.webkit.WebSettings -import android.webkit.WebView +import android.webkit.* import android.webkit.WebView.FindListener import android.widget.EditText import android.widget.TextView @@ -43,6 +48,7 @@ import androidx.view.updatePaddingRelative import com.duckduckgo.app.bookmarks.ui.SaveBookmarkDialogFragment import com.duckduckgo.app.browser.BrowserTabViewModel.* import com.duckduckgo.app.browser.autoComplete.BrowserAutoCompleteSuggestionsAdapter +import com.duckduckgo.app.browser.filechooser.FileChooserIntentBuilder import com.duckduckgo.app.browser.omnibar.KeyboardAwareEditText import com.duckduckgo.app.browser.useragent.UserAgentProvider import com.duckduckgo.app.global.ViewModelFactory @@ -70,6 +76,9 @@ class BrowserTabFragment : Fragment(), FindListener { @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var fileChooserIntentBuilder: FileChooserIntentBuilder + val tabId get() = arguments!![TAB_ID_ARG] as String lateinit var userAgentProvider: UserAgentProvider @@ -78,6 +87,12 @@ class BrowserTabFragment : Fragment(), FindListener { private lateinit var autoCompleteSuggestionsAdapter: BrowserAutoCompleteSuggestionsAdapter + + // Used to represent a file to download, but may first require permission + private var pendingFileDownload: PendingFileDownload? = null + + private var pendingUploadTask: ValueCallback>? = null + private val viewModel: BrowserTabViewModel by lazy { val viewModel = ViewModelProviders.of(this, viewModelFactory).get(BrowserTabViewModel::class.java) viewModel.load(tabId) @@ -233,16 +248,32 @@ class BrowserTabFragment : Fragment(), FindListener { ) ) } - is Command.DownloadImage -> { - browserActivity?.downloadImage(it.url) - } + is Command.DownloadImage -> downloadImage(it.url) is Command.FindInPageCommand -> webView?.findAllAsync(it.searchTerm) Command.DismissFindInPage -> webView?.findAllAsync(null) is Command.ShareLink -> launchSharePageChooser(it.url) is Command.DisplayMessage -> showToast(it.messageId) + is Command.ShowFileChooser -> { launchFilePicker(it) } } } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if(requestCode == REQUEST_CODE_CHOOSE_FILE) { + handleFileUploadResult(resultCode, data) + } + } + + private fun handleFileUploadResult(resultCode: Int, intent: Intent?) { + if(resultCode != RESULT_OK || intent == null) { + Timber.i("Received resultCode $resultCode (or received null intent) indicating user did not select any files") + pendingUploadTask?.onReceiveValue(null) + return + } + + val uris = fileChooserIntentBuilder.extractSelectedFileUris(intent) + pendingUploadTask?.onReceiveValue(uris) + } + private fun showToast(@StringRes messageId: Int) { context?.applicationContext?.longToast(messageId) } @@ -499,7 +530,7 @@ class BrowserTabFragment : Fragment(), FindListener { } it.setDownloadListener { url, _, _, _, _ -> - browserActivity?.downloadFile(url) + downloadFile(url) } it.setOnTouchListener { _, _ -> @@ -654,6 +685,74 @@ class BrowserTabFragment : Fragment(), FindListener { webView = null } + private fun downloadFile(url: String) { + pendingFileDownload = PendingFileDownload(url, Environment.DIRECTORY_DOWNLOADS) + downloadFileWithPermissionCheck() + } + + fun downloadImage(url: String) { + pendingFileDownload = PendingFileDownload(url, Environment.DIRECTORY_PICTURES) + downloadFileWithPermissionCheck() + } + + private fun downloadFileWithPermissionCheck() { + if (hasWriteStoragePermission()) { + downloadFile() + } else { + requestWriteStoragePermission() + } + } + + private fun downloadFile() { + val pending = pendingFileDownload + pending?.let { + val uri = Uri.parse(pending.url) + val guessedFileName = URLUtil.guessFileName(pending.url, null, null) + Timber.i("Guessed filename of $guessedFileName for url ${pending.url}") + val request = DownloadManager.Request(uri).apply { + allowScanningByMediaScanner() + setDestinationInExternalPublicDir(pending.directory, guessedFileName) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + } + val manager = context?.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager? + manager?.enqueue(request) + pendingFileDownload = null + context?.applicationContext?.longToast(getString(R.string.webviewDownload)) + } + } + + private fun launchFilePicker(command: Command.ShowFileChooser) { + pendingUploadTask = command.filePathCallback + val canChooseMultipleFiles = command.fileChooserParams.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE + val intent = fileChooserIntentBuilder.intent(command.fileChooserParams.acceptTypes, canChooseMultipleFiles) + startActivityForResult(intent, REQUEST_CODE_CHOOSE_FILE) + } + + private fun hasWriteStoragePermission(): Boolean { + return ContextCompat.checkSelfPermission(context!!, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + } + + private fun requestWriteStoragePermission() { + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (requestCode == PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE) { + if ((grantResults.isNotEmpty()) && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Timber.i("Write external storage permission granted") + downloadFile() + } else { + Timber.i("Write external storage permission refused") + Snackbar.make(toolbar, R.string.permissionRequiredToDownload, Snackbar.LENGTH_LONG).show() + } + } + } + + private data class PendingFileDownload( + val url: String, + val directory: String + ) + companion object { private const val TAB_ID_ARG = "TAB_ID_ARG" @@ -661,6 +760,9 @@ class BrowserTabFragment : Fragment(), FindListener { private const val QUERY_EXTRA_ARG = "QUERY_EXTRA_ARG" private const val KEYBOARD_DELAY = 200L + private const val REQUEST_CODE_CHOOSE_FILE = 100 + private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200 + fun newInstance(tabId: String, query: String? = null): BrowserTabFragment { val fragment = BrowserTabFragment() val args = Bundle() diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 177f46e5f9e0..20800b2f420e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -26,6 +26,8 @@ import android.support.annotation.VisibleForTesting import android.view.ContextMenu import android.view.MenuItem import android.view.View +import android.webkit.ValueCallback +import android.webkit.WebChromeClient import android.webkit.WebView import com.duckduckgo.app.autocomplete.api.AutoCompleteApi import com.duckduckgo.app.autocomplete.api.AutoCompleteApi.AutoCompleteResult @@ -103,6 +105,7 @@ class BrowserTabViewModel( class FindInPageCommand(val searchTerm: String) : Command() class DisplayMessage(@StringRes val messageId: Int) : Command() object DismissFindInPage : Command() + class ShowFileChooser(val filePathCallback: ValueCallback>, val fileChooserParams: WebChromeClient.FileChooserParams) : Command() } val viewState: MutableLiveData = MutableLiveData() @@ -298,6 +301,10 @@ class BrowserTabViewModel( tabRepository.update(tabId, site) } + override fun showFileChooser(filePathCallback: ValueCallback>, fileChooserParams: WebChromeClient.FileChooserParams) { + command.value = Command.ShowFileChooser(filePathCallback, fileChooserParams) + } + private fun currentViewState(): ViewState = viewState.value!! fun onOmnibarInputStateChanged(query: String, hasFocus: Boolean) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt index 36a0e76bea54..d8fdec6c90f7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -16,7 +16,10 @@ package com.duckduckgo.app.browser +import android.net.Uri import android.view.View +import android.webkit.ValueCallback +import android.webkit.WebChromeClient import com.duckduckgo.app.trackerdetection.model.TrackingEvent interface WebViewClientListener { @@ -33,4 +36,5 @@ interface WebViewClientListener { fun dialTelephoneNumberRequested(telephoneNumber: String) fun goFullScreen(view: View) fun exitFullScreen() + fun showFileChooser(filePathCallback: ValueCallback>, fileChooserParams: WebChromeClient.FileChooserParams) } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/browser/filechooser/FileChooserIntentBuilder.kt b/app/src/main/java/com/duckduckgo/app/browser/filechooser/FileChooserIntentBuilder.kt new file mode 100644 index 000000000000..0069006984a7 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/filechooser/FileChooserIntentBuilder.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2018 DuckDuckGo + * + * 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.duckduckgo.app.browser.filechooser + +import android.content.Intent +import android.net.Uri +import timber.log.Timber +import javax.inject.Inject + + +class FileChooserIntentBuilder @Inject constructor() { + + fun intent(acceptTypes: Array, canChooseMultiple: Boolean = false): Intent { + return Intent(Intent.ACTION_GET_CONTENT).also { + it.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + configureSelectableFileTypes(it, acceptTypes) + configureAllowMultipleFile(it, canChooseMultiple) + } + } + + /** + * Some apps return data data as `intent.data` value, some in the `intent.clipData`; some use both. + * + * If a user selects multiple files, then both the `data` and `clipData` might be populated, but we'd want to use `clipData`. + * If we inspect `data` first, we might conclude there is only a single file selected. So we look for `clipData` first. + * + * Empirically, the first value of `clipData` might mirror what is in the `data` value. So if we have any in `clipData`, use + * them and return early. + * + * Order is important; + * we want to use the clip data if it exists. + * failing that, we check `data` value`. + * failing that, we bail. + */ + fun extractSelectedFileUris(intent: Intent): Array? { + + // first try to determine if multiple files were selected + val clipData = intent.clipData + if (clipData != null && clipData.itemCount > 0) { + val uris = arrayListOf() + for (i in 0 until clipData.itemCount) { + uris.add(clipData.getItemAt(i).uri) + } + return uris.toTypedArray() + } + + // next try to determine if a single file was selected + val singleFileResult = intent.data + if (singleFileResult != null) { + return arrayOf(singleFileResult) + } + + // failing that, give up + Timber.w("Failed to extract selected file information") + return null + } + + private fun configureSelectableFileTypes(intent: Intent, acceptTypes: Array) { + intent.type = "*/*" + + val acceptedMimeTypes = mutableSetOf() + + acceptTypes + .filter { it.isNotBlank() } + .forEach { acceptedMimeTypes.add(it.toLowerCase()) } + + if (acceptedMimeTypes.isNotEmpty()) { + Timber.d("Selectable file types limited to $acceptedMimeTypes") + intent.putExtra(Intent.EXTRA_MIME_TYPES, acceptedMimeTypes.toTypedArray()) + } else { + Timber.d("No selectable file type filters applied") + } + } + + private fun configureAllowMultipleFile(intent: Intent, canChooseMultiple: Boolean) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, canChooseMultiple) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 470514e3b9f2..4c863f6a972d 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.1.0-rc02' + classpath 'com.android.tools.build:gradle:3.1.0-rc03' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong