diff --git a/app/build.gradle b/app/build.gradle index 6b5de4769..f98c5ecf3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -329,6 +329,7 @@ dependencies { implementation Deps.kotlin_stdlib implementation Deps.kotlin_coroutines + testImplementation Deps.kotlin_coroutines_test implementation Deps.androidx_appcompat implementation Deps.androidx_constraintlayout implementation Deps.androidx_coordinatorlayout diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index 3f53f6ca0..e3465a4d8 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -36,4 +36,10 @@ object FeatureFlags { * https://github.com/mozilla-mobile/fenix/issues/4431 */ const val mediaIntegration = true + + /** + * Granular data deletion provides additional choices on the Delete Browsing Data + * setting screen for cookies, cached images and files, and site permissions. + */ + val granularDataDeletion = nightly or debug } diff --git a/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataController.kt b/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataController.kt new file mode 100644 index 000000000..cbaee2879 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataController.kt @@ -0,0 +1,76 @@ +/* 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.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.Engine +import mozilla.components.feature.tab.collections.TabCollection +import org.mozilla.fenix.ext.components +import kotlin.coroutines.CoroutineContext + +interface DeleteBrowsingDataController { + suspend fun deleteTabs() + suspend fun deleteBrowsingData() + suspend fun deleteCollections(collections: List) + suspend fun deleteCookies() + suspend fun deleteCachedFiles() + suspend fun deleteSitePermissions() +} + +class DefaultDeleteBrowsingDataController( + val context: Context, + val coroutineContext: CoroutineContext = Dispatchers.Main +) : DeleteBrowsingDataController { + override suspend fun deleteTabs() { + withContext(coroutineContext) { + context.components.useCases.tabsUseCases.removeAllTabs.invoke() + } + } + + override suspend fun deleteBrowsingData() { + withContext(coroutineContext) { + context.components.core.engine.clearData(Engine.BrowsingData.all()) + } + context.components.core.historyStorage.deleteEverything() + } + + override suspend fun deleteCollections(collections: List) { + while (context.components.core.tabCollectionStorage.getTabCollectionsCount() != collections.size) { + delay(DELAY_IN_MILLIS) + } + + collections.forEach { context.components.core.tabCollectionStorage.removeCollection(it) } + } + + override suspend fun deleteCookies() { + withContext(coroutineContext) { + context.components.core.engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.COOKIES)) + } + } + + override suspend fun deleteCachedFiles() { + withContext(coroutineContext) { + context.components.core.engine.clearData( + Engine.BrowsingData.select(Engine.BrowsingData.ALL_CACHES) + ) + } + } + + override suspend fun deleteSitePermissions() { + withContext(coroutineContext) { + context.components.core.engine.clearData( + Engine.BrowsingData.select(Engine.BrowsingData.ALL_SITE_SETTINGS) + ) + } + context.components.core.permissionStorage.deleteAllSitePermissions() + } + + companion object { + private const val DELAY_IN_MILLIS = 500L + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataFragment.kt index 26b5b77da..978611272 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataFragment.kt @@ -15,16 +15,17 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.paging.PagedList +import androidx.paging.toLiveData import kotlinx.android.synthetic.main.fragment_delete_browsing_data.* import kotlinx.android.synthetic.main.fragment_delete_browsing_data.view.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager -import mozilla.components.concept.engine.Engine +import mozilla.components.feature.sitepermissions.SitePermissions import mozilla.components.feature.tab.collections.TabCollection +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.metrics.Event @@ -34,6 +35,7 @@ import org.mozilla.fenix.ext.requireComponents class DeleteBrowsingDataFragment : Fragment() { private lateinit var sessionObserver: SessionManager.Observer private var tabCollections: List = listOf() + private lateinit var controller: DeleteBrowsingDataController override fun onCreateView( inflater: LayoutInflater, @@ -45,6 +47,8 @@ class DeleteBrowsingDataFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + controller = DefaultDeleteBrowsingDataController(context!!) + sessionObserver = object : SessionManager.Observer { override fun onSessionAdded(session: Session) = updateTabCount() override fun onSessionRemoved(session: Session) = updateTabCount() @@ -60,9 +64,9 @@ class DeleteBrowsingDataFragment : Fragment() { }) } - view.open_tabs_item?.onCheckListener = { _ -> updateDeleteButton() } - view.browsing_data_item?.onCheckListener = { _ -> updateDeleteButton() } - view.collections_item?.onCheckListener = { _ -> updateDeleteButton() } + getCheckboxes().forEach { + it.onCheckListener = { _ -> updateDeleteButton() } + } view.delete_data?.setOnClickListener { askToDelete() } @@ -75,10 +79,11 @@ class DeleteBrowsingDataFragment : Fragment() { supportActionBar?.show() } - updateTabCount() - updateHistoryCount() - updateCollectionsCount() - updateDeleteButton() + getCheckboxes().forEach { + it.visibility = View.VISIBLE + } + + updateItemCounts() } private fun askToDelete() { @@ -100,15 +105,20 @@ class DeleteBrowsingDataFragment : Fragment() { } private fun deleteSelected() { - val openTabsChecked = view!!.open_tabs_item!!.isChecked - val browsingDataChecked = view!!.browsing_data_item!!.isChecked - val collectionsChecked = view!!.collections_item!!.isChecked - startDeletion() viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { - if (openTabsChecked) deleteTabs() - if (browsingDataChecked) deleteBrowsingData() - if (collectionsChecked) deleteCollections() + getCheckboxes().mapIndexed { i, v -> + if (v.isChecked) { + when (i) { + OPEN_TABS_INDEX -> controller.deleteTabs() + HISTORY_INDEX -> controller.deleteBrowsingData() + COLLECTIONS_INDEX -> controller.deleteCollections(tabCollections) + COOKIES_INDEX -> controller.deleteCookies() + CACHED_INDEX -> controller.deleteCachedFiles() + PERMS_INDEX -> controller.deleteSitePermissions() + } + } + } launch(Dispatchers.Main) { finishDeletion() @@ -131,28 +141,34 @@ class DeleteBrowsingDataFragment : Fragment() { delete_browsing_data_wrapper.isClickable = true delete_browsing_data_wrapper.alpha = ENABLED_ALPHA - listOf(open_tabs_item, browsing_data_item, collections_item).forEach { + getCheckboxes().forEach { it.isChecked = false } - updateTabCount() - updateHistoryCount() - updateCollectionsCount() + updateItemCounts() FenixSnackbar.make(view!!, FenixSnackbar.LENGTH_SHORT) .setText(resources.getString(R.string.preferences_delete_browsing_data_snackbar)) .show() - if (popAfter) viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + if (popAfter || FeatureFlags.granularDataDeletion) viewLifecycleOwner.lifecycleScope.launch( + Dispatchers.Main + ) { findNavController().popBackStack(R.id.homeFragment, false) } } + private fun updateItemCounts() { + updateTabCount() + updateHistoryCount() + updateCollectionsCount() + updateCookies() + updateCachedImagesAndFiles() + updateSitePermissions() + } + private fun updateDeleteButton() { - val openTabs = view!!.open_tabs_item!!.isChecked - val browsingData = view!!.browsing_data_item!!.isChecked - val collections = view!!.collections_item!!.isChecked - val enabled = openTabs || browsingData || collections + val enabled = getCheckboxes().any { it.isChecked } view?.delete_data?.isEnabled = enabled view?.delete_data?.alpha = if (enabled) ENABLED_ALPHA else DISABLED_ALPHA @@ -206,30 +222,52 @@ class DeleteBrowsingDataFragment : Fragment() { } } - private suspend fun deleteTabs() { - withContext(Dispatchers.Main) { - requireComponents.useCases.tabsUseCases.removeAllTabs.invoke() - } + private fun updateCookies() { + // NO OP until we have GeckoView methods to count cookies } - private suspend fun deleteBrowsingData() { - withContext(Dispatchers.Main) { - requireComponents.core.engine.clearData(Engine.BrowsingData.all()) - } - requireComponents.core.historyStorage.deleteEverything() + private fun updateCachedImagesAndFiles() { + // NO OP until we have GeckoView methods to count cached images and files } - private suspend fun deleteCollections() { - while (requireComponents.core.tabCollectionStorage.getTabCollectionsCount() != tabCollections.size) { - delay(DELAY_IN_MILLIS) - } + private fun updateSitePermissions() { + val liveData = + requireComponents.core.permissionStorage.getSitePermissionsPaged().toLiveData(1) + liveData.observe( + this, + object : Observer> { + override fun onChanged(list: PagedList?) { + view?.site_permissions_item?.isEnabled = !list.isNullOrEmpty() + liveData.removeObserver(this) + } + }) + } - tabCollections.forEach { requireComponents.core.tabCollectionStorage.removeCollection(it) } + private fun getCheckboxes(): List { + val fragmentView = view!! + val originalList = listOf( + fragmentView.open_tabs_item, + fragmentView.browsing_data_item, + fragmentView.collections_item + ) + @Suppress("ConstantConditionIf") + val granularList = if (FeatureFlags.granularDataDeletion) listOf( + fragmentView.cookies_item, + fragmentView.cached_files_item, + fragmentView.site_permissions_item + ) else emptyList() + return originalList + granularList } companion object { private const val ENABLED_ALPHA = 1f private const val DISABLED_ALPHA = 0.6f - private const val DELAY_IN_MILLIS = 500L + + private const val OPEN_TABS_INDEX = 0 + private const val HISTORY_INDEX = 1 + private const val COLLECTIONS_INDEX = 2 + private const val COOKIES_INDEX = 3 + private const val CACHED_INDEX = 4 + private const val PERMS_INDEX = 5 } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataItem.kt b/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataItem.kt index 6c8b96aef..d9841b27f 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataItem.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/DeleteBrowsingDataItem.kt @@ -48,20 +48,15 @@ class DeleteBrowsingDataItem @JvmOverloads constructor( } context.withStyledAttributes(attrs, R.styleable.DeleteBrowsingDataItem, defStyleAttr, 0) { - val iconId = getResourceId( - R.styleable.DeleteBrowsingDataItem_deleteBrowsingDataItemIcon, - R.drawable.library_icon_reading_list_circle_background - ) val titleId = getResourceId( R.styleable.DeleteBrowsingDataItem_deleteBrowsingDataItemTitle, R.string.browser_menu_your_library ) val subtitleId = getResourceId( R.styleable.DeleteBrowsingDataItem_deleteBrowsingDataItemSubtitle, - R.string.browser_menu_your_library + R.string.empty_string ) - icon.background = resources.getDrawable(iconId, context.theme) title.text = resources.getString(titleId) subtitle.text = resources.getString(subtitleId) } diff --git a/app/src/main/res/layout/delete_browsing_data_item.xml b/app/src/main/res/layout/delete_browsing_data_item.xml index 5c910bf5a..deb49beb7 100644 --- a/app/src/main/res/layout/delete_browsing_data_item.xml +++ b/app/src/main/res/layout/delete_browsing_data_item.xml @@ -9,17 +9,12 @@ android:layout_height="@dimen/library_item_height" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> - @@ -32,9 +27,9 @@ android:textAppearance="@style/ListItemTextStyle" android:clickable="false" android:layout_marginTop="16dp" - app:layout_constraintStart_toEndOf="@id/icon" - app:layout_constraintEnd_toStartOf="@id/checkbox" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintStart_toEndOf="@id/checkbox" + app:layout_constraintTop_toTopOf="parent" + tools:text="Open Tabs" /> - - + app:layout_constraintStart_toEndOf="@id/checkbox" + app:layout_constraintTop_toBottomOf="@id/title" + tools:text="2 Open Tabs" /> diff --git a/app/src/main/res/layout/fragment_delete_browsing_data.xml b/app/src/main/res/layout/fragment_delete_browsing_data.xml index 20ba5980d..eea5088a0 100644 --- a/app/src/main/res/layout/fragment_delete_browsing_data.xml +++ b/app/src/main/res/layout/fragment_delete_browsing_data.xml @@ -36,7 +36,6 @@ android:background="?android:attr/selectableItemBackground" android:clickable="true" android:focusable="true" - app:deleteBrowsingDataItemIcon="@drawable/ic_tab_circle_background" app:deleteBrowsingDataItemTitle="@string/preferences_delete_browsing_data_tabs_title" app:deleteBrowsingDataItemSubtitle="@string/preferences_delete_browsing_data_tabs_subtitle" /> + + + Are you sure you want to delete this bookmark? + + + diff --git a/app/src/test/java/org/mozilla/fenix/settings/DefaultDeleteBrowsingDataControllerTest.kt b/app/src/test/java/org/mozilla/fenix/settings/DefaultDeleteBrowsingDataControllerTest.kt new file mode 100644 index 000000000..b43b0ab4f --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/DefaultDeleteBrowsingDataControllerTest.kt @@ -0,0 +1,132 @@ +/* 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.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.setMain +import mozilla.components.concept.engine.Engine +import mozilla.components.feature.tab.collections.TabCollection +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.TestApplication +import org.mozilla.fenix.ext.components +import org.robolectric.annotation.Config + +@ObsoleteCoroutinesApi +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class DefaultDeleteBrowsingDataControllerTest { + + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + + private val context: Context = mockk(relaxed = true) + private lateinit var controller: DefaultDeleteBrowsingDataController + + @Before + fun setup() { + Dispatchers.setMain(mainThreadSurrogate) + + every { context.components.core.engine.clearData(any()) } just Runs + } + + @After + fun tearDown() { + Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher + mainThreadSurrogate.close() + } + + @Test + fun deleteTabs() = runBlockingTest { + controller = DefaultDeleteBrowsingDataController(context, coroutineContext) + every { context.components.useCases.tabsUseCases.removeAllTabs.invoke() } just Runs + + controller.deleteTabs() + + verify { + context.components.useCases.tabsUseCases.removeAllTabs.invoke() + } + } + + @Test + fun deleteBrowsingData() = runBlockingTest { + controller = DefaultDeleteBrowsingDataController(context, coroutineContext) + every { context.components.core.historyStorage } returns mockk(relaxed = true) + + controller.deleteBrowsingData() + + verify { + context.components.core.engine.clearData(Engine.BrowsingData.all()) + context.components.core.historyStorage + } + } + + @Test + fun deleteCollections() = runBlockingTest { + controller = DefaultDeleteBrowsingDataController(context, coroutineContext) + + val collections: List = listOf(mockk(relaxed = true)) + every { context.components.core.tabCollectionStorage.getTabCollectionsCount() } returns 1 + + controller.deleteCollections(collections) + + verify { + context.components.core.tabCollectionStorage.removeCollection(collections[0]) + } + } + + @Test + fun deleteCookies() = runBlockingTest { + controller = DefaultDeleteBrowsingDataController(context, coroutineContext) + + controller.deleteCookies() + + verify { + context.components.core.engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.COOKIES)) + } + } + + @Test + fun deleteCachedFiles() = runBlockingTest { + controller = DefaultDeleteBrowsingDataController(context, coroutineContext) + + controller.deleteCachedFiles() + + verify { + context.components.core.engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.ALL_CACHES)) + } + } + + @Test + fun deleteSitePermissions() = runBlockingTest { + controller = DefaultDeleteBrowsingDataController(context, coroutineContext) + every { context.components.core.permissionStorage.deleteAllSitePermissions() } just Runs + + launch(IO) { + controller.deleteSitePermissions() + } + + verify { + context.components.core.engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.ALL_SITE_SETTINGS)) + context.components.core.permissionStorage.deleteAllSitePermissions() + } + } +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index b6ab979ac..f9f966c59 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -4,7 +4,7 @@ object Versions { const val kotlin = "1.3.30" - const val coroutines = "1.3.0-RC2" + const val coroutines = "1.3.1" const val android_gradle_plugin = "3.5.0" const val newest_r8 = "ceaee94e172c6c057cc05e646f5324853fc5d4c5" const val rxAndroid = "2.1.0" @@ -73,6 +73,7 @@ object Deps { const val tools_kotlingradle = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" const val kotlin_stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.kotlin}" const val kotlin_coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}" + const val kotlin_coroutines_test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutines}" const val kotlin_coroutines_android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}" const val allopen = "org.jetbrains.kotlin:kotlin-allopen:${Versions.kotlin}"