commit 210c997d4d847f064c6b17509a37c2d2a7023609 parent d70ecb63797300988864902b029ea2ba333dd142 Author: iorgamgabriel <iorgamgabriel@yahoo.com> Date: Thu, 8 Jan 2026 10:35:25 +0000 Bug 2004986 - [Downloads path] Add Settings section to let user select a custom download location. r=android-reviewers,android-l10n-reviewers,calu,flod,tthibaud Differential Revision: https://phabricator.services.mozilla.com/D275775 Diffstat:
16 files changed, 496 insertions(+), 41 deletions(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -17,6 +17,11 @@ object FeatureFlags { val customExtensionCollectionFeature = Config.channel.isNightlyOrDebug || Config.channel.isBeta /** + * Controls whether the "Choose download location" feature is enabled or not. + */ + val downloadsDefaultLocation = Config.channel.isDebug + + /** * Pull-to-refresh allows you to pull the web content down far enough to have the page to * reload. */ diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/DownloadsSettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/DownloadsSettingsFragment.kt @@ -1,37 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.settings - -import android.os.Bundle -import androidx.navigation.fragment.navArgs -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreference -import org.mozilla.fenix.R -import org.mozilla.fenix.ext.showToolbar - -/** - * A [PreferenceFragmentCompat] that displays settings related to downloads. - */ -class DownloadsSettingsFragment : PreferenceFragmentCompat() { - private val args by navArgs<DownloadsSettingsFragmentArgs>() - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.downloads_settings_preferences, rootKey) - requirePreference<SwitchPreference>(R.string.pref_key_downloads_clean_up_files_automatically).apply { - title = getString( - R.string.preferences_downloads_settings_clean_up_files_title, - getString(R.string.app_name), - ) - } - } - - override fun onResume() { - super.onResume() - showToolbar(getString(R.string.preferences_downloads)) - args.preferenceToScrollTo?.let { - scrollToPreference(it) - } - } -} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/AndroidFileUtils.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/AndroidFileUtils.kt @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.downloads + +import android.net.Uri +import java.io.File + +/** + * An interface for interacting with Android's file and document static helpers. + */ +interface AndroidFileUtils { + /** + * Returns the primary shared/external storage directory. + */ + val externalStorageDirectory: File + + /** + * Returns the standard public directory for storing downloaded files. + */ + val externalStoragePublicDownloadsDirectory: File + + /** + * Checks if the given URI represents a directory tree. + */ + fun isTreeUri(uri: Uri): Boolean + + /** + * Extracts the document ID from a tree URI, which represents the path of the directory. + */ + fun getTreeDocumentId(uri: Uri): String? + + /** + * Checks if the application holds persisted read and write permissions for a given content URI. + * + * @param uri The URI to check for permissions. + */ + fun hasUriPermission(uri: Uri): Boolean +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/DefaultAndroidFileUtils.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/DefaultAndroidFileUtils.kt @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.downloads + +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import java.io.File + +/** + * The default implementation of [AndroidFileUtils]. + * + * @param context The application context, used for checking URI permissions. + */ +class DefaultAndroidFileUtils( + private val context: Context, +) : AndroidFileUtils { + override val externalStorageDirectory: File + get() = Environment.getExternalStorageDirectory() + + override val externalStoragePublicDownloadsDirectory: File + get() = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + + override fun isTreeUri(uri: Uri): Boolean = DocumentsContract.isTreeUri(uri) + + override fun getTreeDocumentId(uri: Uri): String? = DocumentsContract.getTreeDocumentId(uri) + + override fun hasUriPermission(uri: Uri): Boolean { + val persistedPermissions = context.contentResolver.persistedUriPermissions + return persistedPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/DefaultDownloadLocationFormatter.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/DefaultDownloadLocationFormatter.kt @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.downloads + +import android.content.ContentResolver +import android.net.Uri +import androidx.core.net.toUri +import mozilla.components.support.base.log.logger.Logger +import java.io.File +import java.net.URLDecoder + +private const val EXTERNAL_STORAGE_PROVIDER_AUTHORITY = "com.android.externalstorage.documents" + +/** + * The default implementation of [DownloadLocationFormatter]. + * + * It handles various formats, including: + * - Standard file paths (`/storage/emulated/0/...`) + * - File URIs (`file://...`) + * - Storage Access Framework (SAF) content URIs (`content://...`) + * + * @param fileUtils A utility wrapper for interacting with Android's file system APIs. + */ +class DefaultDownloadLocationFormatter( + private val fileUtils: AndroidFileUtils, +) : DownloadLocationFormatter { + private val logger = Logger("DefaultDownloadLocationFormatter") + + override fun getFriendlyPath(uriString: String): String { + val uri = uriString.toUri() + + return when (uri.scheme) { + null, ContentResolver.SCHEME_FILE -> formatFilePath(uri) + ContentResolver.SCHEME_CONTENT -> formatContentUri(uri) + else -> uriString + } + } + + @Suppress("TooGenericExceptionCaught") + private fun formatContentUri(uri: Uri): String { + if (!fileUtils.hasUriPermission(uri)) { + throw MissingUriPermission("Missing permissions for URI: $uri") + } + + return try { + if (fileUtils.isTreeUri(uri) && uri.authority == EXTERNAL_STORAGE_PROVIDER_AUTHORITY) { + formatExternalStorageTreeUri(uri) + } else { + uri.lastPathSegment?.let { lastSegment -> "~/$lastSegment" } ?: uri.toString() + } + } catch (e: Exception) { + logger.warn("Failed to format content URI, falling back to raw string.", e) + uri.toString() + } + } + + private fun formatFilePath(uri: Uri): String { + val basePath = fileUtils.externalStorageDirectory.path + val filePath = uri.path ?: return uri.toString() + + return if (filePath.startsWith(basePath)) { + "~${filePath.substring(basePath.length)}" + } else { + "~/${File(filePath).name}" + } + } + + private fun formatExternalStorageTreeUri(uri: Uri): String { + val documentId = fileUtils.getTreeDocumentId(uri) ?: return uri.toString() + val decodedId = URLDecoder.decode(documentId, "UTF-8") + val path = decodedId.substringAfter("primary:", decodedId) + val finalPath = path.removePrefix("Download/") + return "~/$finalPath" + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/DownloadLocationFormatter.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/DownloadLocationFormatter.kt @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.downloads + +/** + * Defines a contract for converting file paths and URIs into human-readable, + * friendly strings for display in the UI. + */ +interface DownloadLocationFormatter { + /** + * Converts a Storage Access Framework URI string or a file path into a more human-readable format. + * Examples: + * "content://.../tree/primary%3ADownload%2FT" becomes "~/T" + * "/storage/emulated/0/Download" becomes "~/Download" + * + * @param uriString The URI string or file path to format. + * @return A user-friendly, shortened path string. + */ + fun getFriendlyPath(uriString: String): String +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/DownloadsSettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/DownloadsSettingsFragment.kt @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.downloads + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Environment +import androidx.activity.result.contract.ActivityResultContracts +import androidx.navigation.fragment.navArgs +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreference +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.kotlin.ifNullOrEmpty +import org.mozilla.fenix.FeatureFlags +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.settings.requirePreference + +/** + * A [androidx.preference.PreferenceFragmentCompat] that displays settings related to downloads. + */ +class DownloadsSettingsFragment : PreferenceFragmentCompat() { + private val logger = Logger("DownloadsSettingsFragment") + private val args by navArgs<DownloadsSettingsFragmentArgs>() + private lateinit var downloadLocationFormatter: DownloadLocationFormatter + + private var launcher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + handleSelectedDownloadDirectory(uri) + } + + /** + * Processes the URI returned from the SAF folder picker, takes persistable permission, + * and updates the relevant setting. + * + * @param uri The URI of the directory selected by the user. Can be null if the user cancelled. + */ + private fun handleSelectedDownloadDirectory(uri: Uri?) { + val safeUri = uri ?: return + + val flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + try { + context?.contentResolver?.takePersistableUriPermission(safeUri, flags) + } catch (e: SecurityException) { + logger.error( + "Failed to take persistable URI permission for the selected downloads directory.", + e, + ) + } + + context?.settings()?.downloadsDefaultLocation = safeUri.toString() + updateDownloadsLocationSummary() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + downloadLocationFormatter = DefaultDownloadLocationFormatter( + DefaultAndroidFileUtils(requireContext()), + ) + setPreferencesFromResource(R.xml.downloads_settings_preferences, rootKey) + requirePreference<SwitchPreference>(R.string.pref_key_downloads_clean_up_files_automatically).apply { + title = getString( + R.string.preferences_downloads_settings_clean_up_files_title, + getString(R.string.app_name), + ) + } + findPreference<Preference>(getString(R.string.pref_key_downloads_default_location))?.apply { + onPreferenceClickListener = Preference.OnPreferenceClickListener { + launcher.launch(null) + true + } + } + val fileStorageCategory = + findPreference<PreferenceCategory>(getString(R.string.pref_key_downloads_storage_category)) + fileStorageCategory?.isVisible = FeatureFlags.downloadsDefaultLocation + } + + override fun onResume() { + super.onResume() + showToolbar(getString(R.string.preferences_downloads)) + updateDownloadsLocationSummary() + args.preferenceToScrollTo?.let { + scrollToPreference(it) + } + } + + private fun updateDownloadsLocationSummary() { + val preference = + findPreference<Preference>(getString(R.string.pref_key_downloads_default_location)) + + val storedLocation = context?.settings()?.downloadsDefaultLocation + val defaultLocation = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS, + ).path + val locationToFormat = storedLocation.ifNullOrEmpty { defaultLocation } + + preference?.summary = try { + downloadLocationFormatter.getFriendlyPath(locationToFormat) + } catch (e: MissingUriPermission) { + logger.warn("Could not format download location path due to lost permissions.", e) + getString(R.string.preference_downloads_folder_permission_lost) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/MissingUriPermission.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/downloads/MissingUriPermission.kt @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.downloads + +import java.io.IOException + +/** + * Thrown to indicate that the application doesn't have permission + * to access a given content URI. + */ +class MissingUriPermission(message: String) : IOException(message) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -10,6 +10,7 @@ import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences import android.content.pm.ShortcutManager +import android.os.Environment import android.view.accessibility.AccessibilityManager import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.Companion.PRIVATE @@ -2839,4 +2840,9 @@ class Settings( val cleanupPreferenceKey = appContext.getString(R.string.pref_key_downloads_clean_up_files_automatically) return sharedPreferences.getBoolean(cleanupPreferenceKey, false) } + + var downloadsDefaultLocation by stringPreference( + appContext.getPreferenceKey(R.string.pref_key_downloads_default_location), + default = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path, + ) } diff --git a/mobile/android/fenix/app/src/main/res/layout/preference_spacer.xml b/mobile/android/fenix/app/src/main/res/layout/preference_spacer.xml @@ -0,0 +1,7 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<View xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="16dp" /> diff --git a/mobile/android/fenix/app/src/main/res/navigation/nav_graph.xml b/mobile/android/fenix/app/src/main/res/navigation/nav_graph.xml @@ -1061,7 +1061,7 @@ </fragment> <fragment android:id="@+id/openDownloadsSettingsFragment" - android:name="org.mozilla.fenix.settings.DownloadsSettingsFragment"> + android:name="org.mozilla.fenix.settings.downloads.DownloadsSettingsFragment"> <argument android:name="preference_to_scroll_to" android:defaultValue="@null" diff --git a/mobile/android/fenix/app/src/main/res/values/preference_keys.xml b/mobile/android/fenix/app/src/main/res/values/preference_keys.xml @@ -512,6 +512,8 @@ <string name="pref_key_distribution_id" translatable="false">pref_key_distribution_id</string> <!-- Downloads Settings--> + <string name="pref_key_downloads_default_location" translatable="false">pref_key_downloads_default_location</string> + <string name="pref_key_downloads_storage_category" translatable="false">pref_key_downloads_storage_category</string> <string name="pref_key_downloads" translatable="false">pref_key_downloads</string> <string name="pref_key_external_download_manager" translatable="false">pref_key_external_download_manager</string> <string name="pref_key_downloads_clean_up_files_automatically" translatable="false">pref_key_downloads_clean_up_files_automatically</string> diff --git a/mobile/android/fenix/app/src/main/res/values/strings.xml b/mobile/android/fenix/app/src/main/res/values/strings.xml @@ -909,6 +909,13 @@ <string name="preferences_open_links_in_apps_ask">Ask before opening</string> <!-- Preference for open links in third party apps never open in apps option --> <string name="preferences_open_links_in_apps_never">Never</string> + <!-- Preference category for file storage. --> + <string name="preferences_category_file_storage">File storage</string> + <!-- Preference title for downloads default location. --> + <string name="preferences_downloads_default_location_title">Default download location</string> + <!-- Message shown as the summary of the download location preference when the + app has lost the permission to access a previously selected custom folder. --> + <string name="preference_downloads_folder_permission_lost">You don’t have permission to use this folder. Try choosing a different one.</string> <!-- Preference title for choosing an app to handle downloads --> <string name="preferences_choose_app_for_downloads">Manage downloads with another app</string> <!-- Title for the setting to automatically clean up downloaded files. %s is the name of the app (for example "Firefox"). --> diff --git a/mobile/android/fenix/app/src/main/res/xml/downloads_settings_preferences.xml b/mobile/android/fenix/app/src/main/res/xml/downloads_settings_preferences.xml @@ -1,16 +1,38 @@ <?xml version="1.0" encoding="utf-8"?><!-- This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> +<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <androidx.preference.PreferenceCategory + android:key="@string/pref_key_downloads_storage_category" + android:layout="@layout/preference_category_no_icon_style" + android:title="@string/preferences_category_file_storage" + app:allowDividerBelow="true"> + <androidx.preference.Preference + android:key="@string/pref_key_downloads_default_location" + android:title="@string/preferences_downloads_default_location_title" + app:allowDividerBelow="true" + app:iconSpaceReserved="false" /> + <Preference + android:layout="@layout/preference_divider" + android:selectable="false" /> + </androidx.preference.PreferenceCategory> + + <Preference + android:layout="@layout/preference_spacer" + android:selectable="false"/> <SwitchPreference android:defaultValue="false" android:key="@string/pref_key_external_download_manager" - android:title="@string/preferences_choose_app_for_downloads" /> + android:title="@string/preferences_choose_app_for_downloads" + app:iconSpaceReserved="false" /> <SwitchPreference android:defaultValue="false" android:key="@string/pref_key_downloads_clean_up_files_automatically" android:summary="@string/preferences_downloads_settings_clean_up_files_summary" - android:title="@string/preferences_downloads_settings_clean_up_files_title" /> + android:title="@string/preferences_downloads_settings_clean_up_files_title" + app:iconSpaceReserved="false" /> </androidx.preference.PreferenceScreen> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/downloads/DefaultDownloadLocationFormatterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/downloads/DefaultDownloadLocationFormatterTest.kt @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.downloads + +import androidx.core.net.toUri +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultDownloadLocationFormatterTest { + + @Test + fun `GIVEN a URI with an unknown scheme, WHEN getFriendlyPath is called, THEN it should return the original path`() { + val fakeAndroidFileUtils = FakeAndroidFileUtils() + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val unknownSchemePath = "ftp://example.com/some/file" + val friendlyPath = formatter.getFriendlyPath(unknownSchemePath) + assertEquals(unknownSchemePath, friendlyPath) + } + + @Test + fun `GIVEN a standard file path, WHEN getFriendlyPath is called, THEN it should be formatted correctly`() { + val fakeAndroidFileUtils = FakeAndroidFileUtils() + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val filePath = "/storage/emulated/0/Download/MyFolder" + val friendlyPath = formatter.getFriendlyPath(filePath) + assertEquals("~/MyFolder", friendlyPath) + } + + @Test + fun `GIVEN a file URI scheme, WHEN getFriendlyPath is called, THEN it should be formatted correctly`() { + val fakeAndroidFileUtils = FakeAndroidFileUtils() + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val fileUri = "file:///storage/emulated/0/Movies" + val friendlyPath = formatter.getFriendlyPath(fileUri) + assertEquals("~/Movies", friendlyPath) + } + + @Test + fun `GIVEN a file path outside primary storage, WHEN getFriendlyPath is called, THEN it should be formatted correctly`() { + val fakeAndroidFileUtils = FakeAndroidFileUtils() + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val otherPath = "/data/data/org.mozilla.fenix/files/internal.pdf" + val friendlyPath = formatter.getFriendlyPath(otherPath) + assertEquals("~/internal.pdf", friendlyPath) + } + + @Test(expected = MissingUriPermission::class) + fun `GIVEN a content URI without permission, WHEN getFriendlyPath is called, THEN it should throw PermissionLostException`() { + val fakeAndroidFileUtils = FakeAndroidFileUtils(hasUriPermission = { false }) + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val contentUri = + "content://com.android.externalstorage.documents/tree/primary%3ADownload".toUri() + + formatter.getFriendlyPath(contentUri.toString()) + } + + @Test + fun `GIVEN an SAF tree URI inside Downloads, WHEN getFriendlyPath is called, THEN it should be formatted correctly`() { + val treeUri = "content://com.android.externalstorage.documents/tree/primary%3AMovies" + + val fakeAndroidFileUtils = FakeAndroidFileUtils(getTreeDocumentId = { treeUri }) + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val friendlyPath = formatter.getFriendlyPath(treeUri) + + assertEquals("~/Movies", friendlyPath) + } + + @Test + fun `GIVEN an SAF tree URI at the root of Downloads, WHEN getFriendlyPath is called, THEN it should be formatted correctly`() { + val treeUri = + "content://com.android.externalstorage.documents/tree/primary%3ADownload" + val fakeAndroidFileUtils = FakeAndroidFileUtils(getTreeDocumentId = { treeUri }) + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val friendlyPath = formatter.getFriendlyPath(treeUri) + + assertEquals("~/Download", friendlyPath) + } + + @Test + fun `GIVEN an SAF tree URI outside of Downloads, WHEN getFriendlyPath is called, THEN it should be formatted correctly`() { + val treeUri = + "content://com.android.externalstorage.documents/tree/primary%3ADownload" + val fakeAndroidFileUtils = FakeAndroidFileUtils(getTreeDocumentId = { treeUri }) + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val friendlyPath = formatter.getFriendlyPath(treeUri) + + assertEquals("~/Download", friendlyPath) + } + + @Test + fun `Given a non-tree content URI, When getFriendlyPath is called, Then it should use the last path segment`() { + val contentUri = "content://media/external/downloads/123" + val fakeAndroidFileUtils = FakeAndroidFileUtils(getTreeDocumentId = { contentUri }) + val formatter = DefaultDownloadLocationFormatter(fakeAndroidFileUtils) + + val friendlyPath = formatter.getFriendlyPath(contentUri) + + assertEquals("~/123", friendlyPath) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/downloads/FakeAndroidFileUtils.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/downloads/FakeAndroidFileUtils.kt @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.downloads + +import android.net.Uri +import android.os.Environment +import java.io.File + +internal class FakeAndroidFileUtils( + private val isTreeUri: (Uri) -> Boolean = { true }, + private val getTreeDocumentId: (Uri) -> String = { "getTreeDocumentId" }, + private val hasUriPermission: (Uri) -> Boolean = { true }, +) : AndroidFileUtils { + override val externalStorageDirectory: File + get() = Environment.getExternalStorageDirectory() + + override val externalStoragePublicDownloadsDirectory: File + get() = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + + override fun isTreeUri(uri: Uri): Boolean { + return isTreeUri.invoke(uri) + } + + override fun getTreeDocumentId(uri: Uri): String { + return getTreeDocumentId.invoke(uri) + } + + override fun hasUriPermission(uri: Uri): Boolean { + return hasUriPermission.invoke(uri) + } +}