From bb6b9c6aec93afbe5ca75cdbf51cf05f4d387678 Mon Sep 17 00:00:00 2001 From: Arturo Mejia Date: Thu, 26 Sep 2019 17:04:05 -0400 Subject: [PATCH] For issue #3264 Add api for interacting with the tracking protection exceptions. --- .../browser/engine/gecko/GeckoEngine.kt | 6 +- .../TrackingProtectionExceptionFileStorage.kt | 130 ++++++++++ .../browser/engine/gecko/GeckoEngineTest.kt | 10 + ...ckingProtectionExceptionFileStorageTest.kt | 231 ++++++++++++++++++ .../browser/engine/gecko/GeckoEngine.kt | 7 +- .../TrackingProtectionExceptionFileStorage.kt | 130 ++++++++++ .../browser/engine/gecko/GeckoEngineTest.kt | 10 + ...ckingProtectionExceptionFileStorageTest.kt | 227 +++++++++++++++++ .../browser/session/ext/AtomicFile.kt | 35 +-- .../components/concept/engine/Engine.kt | 26 ++ .../TrackingProtectionExceptionStorage.kt | 51 ++++ .../session/TrackingProtectionUseCases.kt | 124 +++++++++- .../session/TrackingProtectionUseCasesTest.kt | 65 +++++ .../components/support/ktx/util/AtomicFile.kt | 50 ++++ .../support/ktx/util/AtomicFileTest.kt | 94 +++++++ docs/changelog.md | 20 ++ 16 files changed, 1176 insertions(+), 40 deletions(-) create mode 100644 components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorage.kt create mode 100644 components/browser/engine-gecko-beta/src/test/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorageTest.kt create mode 100644 components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorage.kt create mode 100644 components/browser/engine-gecko-nightly/src/test/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorageTest.kt create mode 100644 components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionExceptionStorage.kt create mode 100644 components/support/ktx/src/main/java/mozilla/components/support/ktx/util/AtomicFile.kt create mode 100644 components/support/ktx/src/test/java/mozilla/components/support/ktx/util/AtomicFileTest.kt diff --git a/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt b/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt index 5cc600e7d73..848f9fd58cb 100644 --- a/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt +++ b/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt @@ -19,6 +19,7 @@ import mozilla.components.concept.engine.EngineSessionState import mozilla.components.concept.engine.EngineView import mozilla.components.concept.engine.Settings import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage import mozilla.components.concept.engine.history.HistoryTrackingDelegate import mozilla.components.concept.engine.mediaquery.PreferredColorScheme import mozilla.components.concept.engine.utils.EngineVersion @@ -40,7 +41,9 @@ class GeckoEngine( context: Context, private val defaultSettings: Settings? = null, private val runtime: GeckoRuntime = GeckoRuntime.getDefault(context), - executorProvider: () -> GeckoWebExecutor = { GeckoWebExecutor(runtime) } + executorProvider: () -> GeckoWebExecutor = { GeckoWebExecutor(runtime) }, + override val trackingProtectionExceptionStore: TrackingProtectionExceptionStorage = + TrackingProtectionExceptionFileStorage(context, runtime) ) : Engine { private val executor by lazy { executorProvider.invoke() } @@ -56,6 +59,7 @@ class GeckoEngine( @Suppress("TooGenericExceptionThrown") throw RuntimeException("GeckoRuntime is shutting down") } + trackingProtectionExceptionStore.restore() } /** diff --git a/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorage.kt b/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorage.kt new file mode 100644 index 00000000000..a22a19164cb --- /dev/null +++ b/components/browser/engine-gecko-beta/src/main/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorage.kt @@ -0,0 +1,130 @@ +/* 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 mozilla.components.browser.engine.gecko + +import android.content.Context +import android.util.AtomicFile +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage +import mozilla.components.support.ktx.util.readAndDeserialize +import mozilla.components.support.ktx.util.writeString +import org.mozilla.geckoview.GeckoRuntime +import java.io.File + +private const val STORE_FILE_NAME_FORMAT = + "mozilla_components_tracking_protection_storage_gecko.json" + +/** + * A [TrackingProtectionExceptionStorage] implementation to store tracking protection exceptions. + */ +internal class TrackingProtectionExceptionFileStorage( + private val context: Context, + private val runtime: GeckoRuntime +) : TrackingProtectionExceptionStorage { + private val fileLock = Any() + internal var scope = CoroutineScope(Dispatchers.IO) + + /** + * Restore all exceptions from the [STORE_FILE_NAME_FORMAT] file, + * and provides them to the gecko [runtime]. + */ + override fun restore() { + scope.launch { + synchronized(fileLock) { + getFile(context).readAndDeserialize { json -> + if (json.isNotEmpty()) { + val exceptionList = runtime.contentBlockingController.ExceptionList(json) + runtime.contentBlockingController.restoreExceptionList(exceptionList) + } + } + } + } + } + + override fun contains(session: EngineSession, onFinish: (Boolean) -> Unit) { + val geckoSession = (session as GeckoEngineSession).geckoSession + runtime.contentBlockingController.checkException(geckoSession).accept { + if (it != null) { + onFinish(it) + } else { + onFinish(false) + } + } + } + + override fun fetchAll(onFinish: (List) -> Unit) { + runtime.contentBlockingController.saveExceptionList().accept { exceptionList -> + val exceptions = if (exceptionList != null) { + val uris = exceptionList.uris.map { uri -> + uri + } + uris + } else { + emptyList() + } + onFinish(exceptions) + } + } + + override fun add(session: EngineSession) { + val geckoSession = (session as GeckoEngineSession).geckoSession + runtime.contentBlockingController.addException(geckoSession) + persist() + } + + override fun remove(session: EngineSession) { + val geckoSession = (session as GeckoEngineSession).geckoSession + runtime.contentBlockingController.removeException(geckoSession) + persist() + } + + override fun removeAll() { + runtime.contentBlockingController.clearExceptionList() + removeFileFromDisk(context) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getFile(context: Context): AtomicFile { + return AtomicFile( + File( + context.filesDir, + STORE_FILE_NAME_FORMAT + ) + ) + } + + /** + * Take all the exception from the gecko [runtime] and saves them into the + * [STORE_FILE_NAME_FORMAT] file. + */ + private fun persist() { + runtime.contentBlockingController.saveExceptionList().accept { exceptionList -> + if (exceptionList != null) { + scope.launch { + synchronized(fileLock) { + getFile(context).writeString { + exceptionList.toJson().toString() + } + } + } + } else { + removeFileFromDisk(context) + } + } + } + + private fun removeFileFromDisk(context: Context) { + scope.launch { + synchronized(fileLock) { + getFile(context) + .delete() + } + } + } +} diff --git a/components/browser/engine-gecko-beta/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt b/components/browser/engine-gecko-beta/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt index 3ee7c69846e..6b7bb89e34b 100644 --- a/components/browser/engine-gecko-beta/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt +++ b/components/browser/engine-gecko-beta/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt @@ -15,6 +15,7 @@ import mozilla.components.concept.engine.EngineSession.SafeBrowsingPolicy import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy import mozilla.components.concept.engine.UnsupportedSettingException import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage import mozilla.components.concept.engine.mediaquery.PreferredColorScheme import mozilla.components.support.test.any import mozilla.components.support.test.argumentCaptor @@ -585,6 +586,15 @@ class GeckoEngineTest { assertTrue(version.isAtLeast(69, 0, 0)) } + @Test + fun `after init is called the trackingProtectionExceptionStore must be restored`() { + val mockStore: TrackingProtectionExceptionStorage = mock() + val runtime: GeckoRuntime = mock() + GeckoEngine(context, runtime = runtime, trackingProtectionExceptionStore = mockStore) + + verify(mockStore).restore() + } + private fun createDummyLogEntryList(): List { val addLogEntry = object : ContentBlockingController.LogEntry() {} diff --git a/components/browser/engine-gecko-beta/src/test/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorageTest.kt b/components/browser/engine-gecko-beta/src/test/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorageTest.kt new file mode 100644 index 00000000000..7d15b83ac0c --- /dev/null +++ b/components/browser/engine-gecko-beta/src/test/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorageTest.kt @@ -0,0 +1,231 @@ +/* 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 mozilla.components.browser.engine.gecko + +import android.app.Activity +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import mozilla.components.support.ktx.util.readAndDeserialize +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.json.JSONObject +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mozilla.geckoview.ContentBlockingController +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession +import org.robolectric.Robolectric.buildActivity + +@RunWith(AndroidJUnit4::class) +class TrackingProtectionExceptionFileStorageTest { + + private val context: Context + get() = buildActivity(Activity::class.java).get() + + @Test + fun `restoreAsync exception`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + val geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.toJson()).thenReturn(JSONObject("{\"principals\":[\"eyIxIjp7IjAiOiJodHRwczovL3d3dy5jbm4uY29tLyJ9fQ==\"],\"uris\":[\"https:\\/\\/www.cnn.com\\/\"]}")) + + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + storage.scope = CoroutineScope(Dispatchers.Main) + + assertNull(storage.getFile(context).readAndDeserialize { }) + + storage.add(session) + geckoResult.complete(mockExceptionList) + + storage.restore() + + verify(mockContentBlocking).restoreExceptionList(any()) + assertNotNull(storage.getFile(context).readAndDeserialize { }) + } + + @Test + fun `add exception`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + val geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.toJson()).thenReturn(JSONObject("{\"principals\":[\"eyIxIjp7IjAiOiJodHRwczovL3d3dy5jbm4uY29tLyJ9fQ==\"],\"uris\":[\"https:\\/\\/www.cnn.com\\/\"]}")) + + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + storage.scope = CoroutineScope(Dispatchers.Main) + + assertNull(storage.getFile(context).readAndDeserialize { }) + + storage.add(session) + geckoResult.complete(mockExceptionList) + + verify(mockContentBlocking).addException(mockGeckoSession) + verify(mockContentBlocking).saveExceptionList() + assertNotNull(storage.getFile(context).readAndDeserialize { }) + } + + @Test + fun `remove all exceptions`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + val geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.toJson()).thenReturn(JSONObject("{\"principals\":[\"eyIxIjp7IjAiOiJodHRwczovL3d3dy5jbm4uY29tLyJ9fQ==\"],\"uris\":[\"https:\\/\\/www.cnn.com\\/\"]}")) + + // Adding exception + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + storage.scope = CoroutineScope(Dispatchers.Main) + + assertNull(storage.getFile(context).readAndDeserialize { }) + + storage.add(session) + geckoResult.complete(mockExceptionList) + + verify(mockContentBlocking).addException(mockGeckoSession) + verify(mockContentBlocking).saveExceptionList() + assertNotNull(storage.getFile(context).readAndDeserialize { }) + + // Removing exceptions + storage.removeAll() + verify(mockContentBlocking).clearExceptionList() + assertNull(storage.getFile(context).readAndDeserialize { }) + } + + @Test + fun `remove exception`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + var geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.toJson()).thenReturn(JSONObject("{\"principals\":[\"eyIxIjp7IjAiOiJodHRwczovL3d3dy5jbm4uY29tLyJ9fQ==\"],\"uris\":[\"https:\\/\\/www.cnn.com\\/\"]}")) + + // Adding exception + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + storage.scope = CoroutineScope(Dispatchers.Main) + + assertNull(storage.getFile(context).readAndDeserialize { }) + + storage.add(session) + geckoResult.complete(mockExceptionList) + + verify(mockContentBlocking).addException(mockGeckoSession) + verify(mockContentBlocking).saveExceptionList() + assertNotNull(storage.getFile(context).readAndDeserialize { }) + + // Removing exception + geckoResult = GeckoResult() + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + storage.remove(session) + verify(mockContentBlocking).removeException(mockGeckoSession) + geckoResult.complete(null) + assertNull(storage.getFile(context).readAndDeserialize { }) + } + + @Test + fun `contains exception`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + var geckoResult = GeckoResult() + val mockGeckoSession = mock() + var containsException = false + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.checkException(mockGeckoSession)).thenReturn( + geckoResult + ) + + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + + storage.contains(session) { contains -> + containsException = contains + } + geckoResult.complete(true) + + verify(mockContentBlocking).checkException(mockGeckoSession) + assertTrue(containsException) + + geckoResult = GeckoResult() + whenever(runtime.contentBlockingController.checkException(mockGeckoSession)).thenReturn( + geckoResult + ) + + storage.contains(session) { contains -> + containsException = contains + } + geckoResult.complete(null) + assertFalse(containsException) + } + + @Test + fun `getAll exceptions`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + var geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + var exceptionList: List? = null + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.uris).thenReturn(arrayOf("mozilla.com")) + + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + + storage.fetchAll { exceptions -> + exceptionList = exceptions + } + geckoResult.complete(mockExceptionList) + + verify(mockContentBlocking).saveExceptionList() + assertTrue(exceptionList!!.isNotEmpty()) + + geckoResult = GeckoResult() + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + + storage.fetchAll { exceptions -> + exceptionList = exceptions + } + + geckoResult.complete(null) + assertTrue(exceptionList!!.isEmpty()) + } +} diff --git a/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt b/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt index 5cc600e7d73..0c4895d1eab 100644 --- a/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt +++ b/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt @@ -19,6 +19,7 @@ import mozilla.components.concept.engine.EngineSessionState import mozilla.components.concept.engine.EngineView import mozilla.components.concept.engine.Settings import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage import mozilla.components.concept.engine.history.HistoryTrackingDelegate import mozilla.components.concept.engine.mediaquery.PreferredColorScheme import mozilla.components.concept.engine.utils.EngineVersion @@ -40,10 +41,11 @@ class GeckoEngine( context: Context, private val defaultSettings: Settings? = null, private val runtime: GeckoRuntime = GeckoRuntime.getDefault(context), - executorProvider: () -> GeckoWebExecutor = { GeckoWebExecutor(runtime) } + executorProvider: () -> GeckoWebExecutor = { GeckoWebExecutor(runtime) }, + override val trackingProtectionExceptionStore: TrackingProtectionExceptionStorage = + TrackingProtectionExceptionFileStorage(context, runtime) ) : Engine { private val executor by lazy { executorProvider.invoke() } - private val localeUpdater = LocaleSettingUpdater(context, runtime) init { @@ -56,6 +58,7 @@ class GeckoEngine( @Suppress("TooGenericExceptionThrown") throw RuntimeException("GeckoRuntime is shutting down") } + trackingProtectionExceptionStore.restore() } /** diff --git a/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorage.kt b/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorage.kt new file mode 100644 index 00000000000..a22a19164cb --- /dev/null +++ b/components/browser/engine-gecko-nightly/src/main/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorage.kt @@ -0,0 +1,130 @@ +/* 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 mozilla.components.browser.engine.gecko + +import android.content.Context +import android.util.AtomicFile +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage +import mozilla.components.support.ktx.util.readAndDeserialize +import mozilla.components.support.ktx.util.writeString +import org.mozilla.geckoview.GeckoRuntime +import java.io.File + +private const val STORE_FILE_NAME_FORMAT = + "mozilla_components_tracking_protection_storage_gecko.json" + +/** + * A [TrackingProtectionExceptionStorage] implementation to store tracking protection exceptions. + */ +internal class TrackingProtectionExceptionFileStorage( + private val context: Context, + private val runtime: GeckoRuntime +) : TrackingProtectionExceptionStorage { + private val fileLock = Any() + internal var scope = CoroutineScope(Dispatchers.IO) + + /** + * Restore all exceptions from the [STORE_FILE_NAME_FORMAT] file, + * and provides them to the gecko [runtime]. + */ + override fun restore() { + scope.launch { + synchronized(fileLock) { + getFile(context).readAndDeserialize { json -> + if (json.isNotEmpty()) { + val exceptionList = runtime.contentBlockingController.ExceptionList(json) + runtime.contentBlockingController.restoreExceptionList(exceptionList) + } + } + } + } + } + + override fun contains(session: EngineSession, onFinish: (Boolean) -> Unit) { + val geckoSession = (session as GeckoEngineSession).geckoSession + runtime.contentBlockingController.checkException(geckoSession).accept { + if (it != null) { + onFinish(it) + } else { + onFinish(false) + } + } + } + + override fun fetchAll(onFinish: (List) -> Unit) { + runtime.contentBlockingController.saveExceptionList().accept { exceptionList -> + val exceptions = if (exceptionList != null) { + val uris = exceptionList.uris.map { uri -> + uri + } + uris + } else { + emptyList() + } + onFinish(exceptions) + } + } + + override fun add(session: EngineSession) { + val geckoSession = (session as GeckoEngineSession).geckoSession + runtime.contentBlockingController.addException(geckoSession) + persist() + } + + override fun remove(session: EngineSession) { + val geckoSession = (session as GeckoEngineSession).geckoSession + runtime.contentBlockingController.removeException(geckoSession) + persist() + } + + override fun removeAll() { + runtime.contentBlockingController.clearExceptionList() + removeFileFromDisk(context) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getFile(context: Context): AtomicFile { + return AtomicFile( + File( + context.filesDir, + STORE_FILE_NAME_FORMAT + ) + ) + } + + /** + * Take all the exception from the gecko [runtime] and saves them into the + * [STORE_FILE_NAME_FORMAT] file. + */ + private fun persist() { + runtime.contentBlockingController.saveExceptionList().accept { exceptionList -> + if (exceptionList != null) { + scope.launch { + synchronized(fileLock) { + getFile(context).writeString { + exceptionList.toJson().toString() + } + } + } + } else { + removeFileFromDisk(context) + } + } + } + + private fun removeFileFromDisk(context: Context) { + scope.launch { + synchronized(fileLock) { + getFile(context) + .delete() + } + } + } +} diff --git a/components/browser/engine-gecko-nightly/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt b/components/browser/engine-gecko-nightly/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt index e95a9caa107..19461596ebe 100644 --- a/components/browser/engine-gecko-nightly/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt +++ b/components/browser/engine-gecko-nightly/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt @@ -16,6 +16,7 @@ import mozilla.components.concept.engine.EngineSession.SafeBrowsingPolicy import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy import mozilla.components.concept.engine.UnsupportedSettingException import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage import mozilla.components.concept.engine.mediaquery.PreferredColorScheme import mozilla.components.support.test.any import mozilla.components.support.test.argumentCaptor @@ -588,6 +589,15 @@ class GeckoEngineTest { assertTrue(version.isAtLeast(69, 0, 0)) } + @Test + fun `after init is called the trackingProtectionExceptionStore must be restored`() { + val mockStore: TrackingProtectionExceptionStorage = mock() + val runtime: GeckoRuntime = mock() + GeckoEngine(context, runtime = runtime, trackingProtectionExceptionStore = mockStore) + + verify(mockStore).restore() + } + private fun createDummyLogEntryList(): List { val addLogEntry = object : ContentBlockingController.LogEntry() {} diff --git a/components/browser/engine-gecko-nightly/src/test/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorageTest.kt b/components/browser/engine-gecko-nightly/src/test/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorageTest.kt new file mode 100644 index 00000000000..3117789a5b8 --- /dev/null +++ b/components/browser/engine-gecko-nightly/src/test/java/mozilla/components/browser/engine/gecko/TrackingProtectionExceptionFileStorageTest.kt @@ -0,0 +1,227 @@ +/* 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 mozilla.components.browser.engine.gecko + +import android.app.Activity +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import mozilla.components.support.ktx.util.readAndDeserialize +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.json.JSONObject +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mozilla.geckoview.ContentBlockingController +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession +import org.robolectric.Robolectric.buildActivity + +@RunWith(AndroidJUnit4::class) +class TrackingProtectionExceptionFileStorageTest { + + private val context: Context + get() = buildActivity(Activity::class.java).get() + + @Test + fun `restoreAsync exception`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + val geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.toJson()).thenReturn(JSONObject("{\"principals\":[\"eyIxIjp7IjAiOiJodHRwczovL3d3dy5jbm4uY29tLyJ9fQ==\"],\"uris\":[\"https:\\/\\/www.cnn.com\\/\"]}")) + + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + storage.scope = CoroutineScope(Dispatchers.Main) + + assertNull(storage.getFile(context).readAndDeserialize { }) + + storage.add(session) + geckoResult.complete(mockExceptionList) + + storage.restore() + + verify(mockContentBlocking).restoreExceptionList(any()) + assertNotNull(storage.getFile(context).readAndDeserialize { }) + } + + @Test + fun `add exception`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + val geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.toJson()).thenReturn(JSONObject("{\"principals\":[\"eyIxIjp7IjAiOiJodHRwczovL3d3dy5jbm4uY29tLyJ9fQ==\"],\"uris\":[\"https:\\/\\/www.cnn.com\\/\"]}")) + + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + storage.scope = CoroutineScope(Dispatchers.Main) + + assertNull(storage.getFile(context).readAndDeserialize { }) + + storage.add(session) + geckoResult.complete(mockExceptionList) + + verify(mockContentBlocking).addException(mockGeckoSession) + verify(mockContentBlocking).saveExceptionList() + assertNotNull(storage.getFile(context).readAndDeserialize { }) + } + + @Test + fun `remove all exceptions`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + val geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.toJson()).thenReturn(JSONObject("{\"principals\":[\"eyIxIjp7IjAiOiJodHRwczovL3d3dy5jbm4uY29tLyJ9fQ==\"],\"uris\":[\"https:\\/\\/www.cnn.com\\/\"]}")) + + // Adding exception + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + storage.scope = CoroutineScope(Dispatchers.Main) + + assertNull(storage.getFile(context).readAndDeserialize { }) + + storage.add(session) + geckoResult.complete(mockExceptionList) + + verify(mockContentBlocking).addException(mockGeckoSession) + verify(mockContentBlocking).saveExceptionList() + assertNotNull(storage.getFile(context).readAndDeserialize { }) + + // Removing exceptions + storage.removeAll() + verify(mockContentBlocking).clearExceptionList() + assertNull(storage.getFile(context).readAndDeserialize { }) + } + + @Test + fun `remove exception`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + var geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.toJson()).thenReturn(JSONObject("{\"principals\":[\"eyIxIjp7IjAiOiJodHRwczovL3d3dy5jbm4uY29tLyJ9fQ==\"],\"uris\":[\"https:\\/\\/www.cnn.com\\/\"]}")) + + // Adding exception + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + storage.scope = CoroutineScope(Dispatchers.Main) + + assertNull(storage.getFile(context).readAndDeserialize { }) + + storage.add(session) + geckoResult.complete(mockExceptionList) + + verify(mockContentBlocking).addException(mockGeckoSession) + verify(mockContentBlocking).saveExceptionList() + assertNotNull(storage.getFile(context).readAndDeserialize { }) + + // Removing exception + geckoResult = GeckoResult() + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + storage.remove(session) + verify(mockContentBlocking).removeException(mockGeckoSession) + geckoResult.complete(null) + assertNull(storage.getFile(context).readAndDeserialize { }) + } + + @Test + fun `contains exception`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + var geckoResult = GeckoResult() + val mockGeckoSession = mock() + var containsException = false + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.checkException(mockGeckoSession)).thenReturn(geckoResult) + + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + + storage.contains(session) { contains -> + containsException = contains + } + geckoResult.complete(true) + + verify(mockContentBlocking).checkException(mockGeckoSession) + assertTrue(containsException) + + geckoResult = GeckoResult() + whenever(runtime.contentBlockingController.checkException(mockGeckoSession)).thenReturn(geckoResult) + + storage.contains(session) { contains -> + containsException = contains + } + geckoResult.complete(null) + assertFalse(containsException) + } + + @Test + fun `getAll exceptions`() { + val mockContentBlocking = mock() + val runtime: GeckoRuntime = mock() + val session = mock() + var geckoResult = GeckoResult() + val mockGeckoSession = mock() + val mockExceptionList = mock() + var exceptionList: List? = null + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlocking) + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + whenever(mockExceptionList.uris).thenReturn(arrayOf("mozilla.com")) + + val storage = TrackingProtectionExceptionFileStorage(testContext, runtime) + + storage.fetchAll { exceptions -> + exceptionList = exceptions + } + geckoResult.complete(mockExceptionList) + + verify(mockContentBlocking).saveExceptionList() + assertTrue(exceptionList!!.isNotEmpty()) + + geckoResult = GeckoResult() + whenever(runtime.contentBlockingController.saveExceptionList()).thenReturn(geckoResult) + + storage.fetchAll { exceptions -> + exceptionList = exceptions + } + + geckoResult.complete(null) + assertTrue(exceptionList!!.isEmpty()) + } +} diff --git a/components/browser/session/src/main/java/mozilla/components/browser/session/ext/AtomicFile.kt b/components/browser/session/src/main/java/mozilla/components/browser/session/ext/AtomicFile.kt index bf05f5f8d48..84c87874911 100644 --- a/components/browser/session/src/main/java/mozilla/components/browser/session/ext/AtomicFile.kt +++ b/components/browser/session/src/main/java/mozilla/components/browser/session/ext/AtomicFile.kt @@ -8,10 +8,9 @@ import android.util.AtomicFile import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.storage.SnapshotSerializer import mozilla.components.concept.engine.Engine -import org.json.JSONException +import mozilla.components.support.ktx.util.readAndDeserialize +import mozilla.components.support.ktx.util.writeString import org.json.JSONObject -import java.io.FileOutputStream -import java.io.IOException /** * Read a [SessionManager.Snapshot] from this [AtomicFile]. Returns `null` if no snapshot could be read. @@ -67,33 +66,3 @@ fun AtomicFile.writeSnapshotItem( serializer.itemToJSON(item).toString() } } - -private fun AtomicFile.writeString(block: () -> String): Boolean { - var outputStream: FileOutputStream? = null - - return try { - outputStream = startWrite() - outputStream.write(block().toByteArray()) - finishWrite(outputStream) - true - } catch (_: IOException) { - failWrite(outputStream) - false - } catch (_: JSONException) { - failWrite(outputStream) - false - } -} - -private fun AtomicFile.readAndDeserialize(block: (String) -> T): T? { - return try { - openRead().use { - val json = it.bufferedReader().use { reader -> reader.readText() } - block(json) - } - } catch (_: IOException) { - null - } catch (_: JSONException) { - null - } -} diff --git a/components/concept/engine/src/main/java/mozilla/components/concept/engine/Engine.kt b/components/concept/engine/src/main/java/mozilla/components/concept/engine/Engine.kt index 529c9f8312b..bb677bafce2 100644 --- a/components/concept/engine/src/main/java/mozilla/components/concept/engine/Engine.kt +++ b/components/concept/engine/src/main/java/mozilla/components/concept/engine/Engine.kt @@ -7,6 +7,7 @@ package mozilla.components.concept.engine import android.content.Context import android.util.AttributeSet import androidx.annotation.MainThread +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage import mozilla.components.concept.engine.content.blocking.TrackerLog import mozilla.components.concept.engine.utils.EngineVersion import mozilla.components.concept.engine.webextension.WebExtension @@ -162,6 +163,31 @@ interface Engine { ) ) + /** + * Provides access to the tracking protection exception list for this engine. + */ + val trackingProtectionExceptionStore: TrackingProtectionExceptionStorage + get() = + object : TrackingProtectionExceptionStorage { + override fun fetchAll(onFinish: (List) -> Unit) = + throw UnsupportedOperationException("fetchAll is not supported by this engine.") + + override fun add(session: EngineSession) = + throw UnsupportedOperationException("add is not supported by this engine.") + + override fun remove(session: EngineSession) = + throw UnsupportedOperationException("remove is not supported by this engine.") + + override fun contains(session: EngineSession, onFinish: (Boolean) -> Unit) = + throw UnsupportedOperationException("contains is not supported by this engine.") + + override fun removeAll() = + throw UnsupportedOperationException("removeAll is not supported by this engine.") + + override fun restore() = + throw UnsupportedOperationException("restore is not supported by this engine.") + } + /** * Provides access to the settings of this engine. */ diff --git a/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionExceptionStorage.kt b/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionExceptionStorage.kt new file mode 100644 index 00000000000..950b8eee6b7 --- /dev/null +++ b/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionExceptionStorage.kt @@ -0,0 +1,51 @@ +/* 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 mozilla.components.concept.engine.content.blocking + +import mozilla.components.concept.engine.EngineSession + +/** + * A contract that define how a tracking protection storage must behave. + */ +interface TrackingProtectionExceptionStorage { + + /** + * Fetch all the domains that will be ignored for tracking protection. + * @param onFinish A callback to inform that the domains in the exception list has been fetched, + * it provides a list of all the domains that are on the exception list, if there are none + * domains in the exception list, an empty list will be provided. + */ + fun fetchAll(onFinish: (List) -> Unit) + + /** + * Adds a new [session] to the exception list. + * @param session The [session] that will be added to the exception list. + */ + fun add(session: EngineSession) + + /** + * Removes a [session] from the exception list. + * @param session The [session] that will be removed from the exception list. + */ + fun remove(session: EngineSession) + + /** + * Indicates if a given [session] is in the exception list. + * @param session The [session] to be verified. + * @param onFinish A callback to inform if the given [session] is in + * the exception list, true if it is in, otherwise false. + */ + fun contains(session: EngineSession, onFinish: (Boolean) -> Unit) + + /** + * Removes all domains from the exception list. + */ + fun removeAll() + + /** + * Restore all domains stored in the storage. + */ + fun restore() +} diff --git a/components/feature/session/src/main/java/mozilla/components/feature/session/TrackingProtectionUseCases.kt b/components/feature/session/src/main/java/mozilla/components/feature/session/TrackingProtectionUseCases.kt index f034c26edb0..2a60736f850 100644 --- a/components/feature/session/src/main/java/mozilla/components/feature/session/TrackingProtectionUseCases.kt +++ b/components/feature/session/src/main/java/mozilla/components/feature/session/TrackingProtectionUseCases.kt @@ -8,6 +8,7 @@ import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.support.base.log.logger.Logger import java.lang.Exception /** @@ -21,6 +22,109 @@ class TrackingProtectionUseCases( val engine: Engine ) { + /** + * Use case for adding a new [Session] to the exception list. + */ + class AddExceptionUserCase internal constructor( + private val sessionManager: SessionManager, + private val engine: Engine + ) { + private val logger = Logger("TrackingProtectionUseCases") + + /** + * Adds a new [session] to the exception list, as a result this session will + * not get applied any tracking protection policy. + * @param session The [session] that will be added to the exception list. + */ + operator fun invoke(session: Session) { + val engineSession = sessionManager.getEngineSession(session) + ?: return logger.error("The engine session should not be null") + + engine.trackingProtectionExceptionStore.add(engineSession) + } + } + + /** + * Use case for removing a [Session] from the exception list. + */ + class RemoveExceptionUserCase internal constructor( + private val sessionManager: SessionManager, + private val engine: Engine + ) { + private val logger = Logger("TrackingProtectionUseCases") + + /** + * Removes a [session] from the exception list. + * @param session The [session] that will be removed from the exception list. + */ + operator fun invoke(session: Session) { + val engineSession = sessionManager.getEngineSession(session) + ?: return logger.error("The engine session should not be null") + + engine.trackingProtectionExceptionStore.remove(engineSession) + } + } + + /** + * Use case for removing all [Session]s from the exception list. + */ + class RemoveAllExceptionsUserCase internal constructor( + private val sessionManager: SessionManager, + private val engine: Engine + ) { + /** + * Removes all domains from the exception list. + */ + operator fun invoke() { + engine.trackingProtectionExceptionStore.removeAll() + } + } + + /** + * Use case for verifying if a [Session] is in the exception list. + */ + class ContainsExceptionUserCase internal constructor( + private val sessionManager: SessionManager, + private val engine: Engine + ) { + /** + * Indicates if a given [session] is in the exception list. + * @param session The [session] to verify. + * @param onSuccess A callback to inform if the given [session] is on + * the exception list, true if it is in otherwise false. + */ + operator fun invoke( + session: Session, + onSuccess: (Boolean) -> Unit + ) { + val engineSession = sessionManager.getEngineSession(session) ?: return onSuccess(false) + engine.trackingProtectionExceptionStore.contains(engineSession, onSuccess) + } + } + + /** + * Use case for fetching all the exceptions in the exception list. + */ + class FetchExceptionsUserCase internal constructor( + private val sessionManager: SessionManager, + private val engine: Engine + ) { + /** + * Fetch all the domains that will be ignored for tracking protection. + * @param onSuccess A callback to inform that the domains on the exception list has been fetched, + * it provides a list of domains that are on the exception list, if there are none domains + * on the exception list, an empty list will be provided. + */ + operator fun invoke(onSuccess: (List) -> Unit) { + engine.trackingProtectionExceptionStore.fetchAll { + onSuccess(it) + } + } + } + + /** + * Use case for fetching all the tracking protection logged information. + */ class FetchTrackingLogUserCase internal constructor( private val sessionManager: SessionManager, private val engine: Engine @@ -45,9 +149,21 @@ class TrackingProtectionUseCases( } val fetchTrackingLogs: FetchTrackingLogUserCase by lazy { - FetchTrackingLogUserCase( - sessionManager, - engine - ) + FetchTrackingLogUserCase(sessionManager, engine) + } + val addException: AddExceptionUserCase by lazy { + AddExceptionUserCase(sessionManager, engine) + } + val removeException: RemoveExceptionUserCase by lazy { + RemoveExceptionUserCase(sessionManager, engine) + } + val containsException: ContainsExceptionUserCase by lazy { + ContainsExceptionUserCase(sessionManager, engine) + } + val removeAllExceptions: RemoveAllExceptionsUserCase by lazy { + RemoveAllExceptionsUserCase(sessionManager, engine) + } + val fetchExceptions: FetchExceptionsUserCase by lazy { + FetchExceptionsUserCase(sessionManager, engine) } } diff --git a/components/feature/session/src/test/java/mozilla/components/feature/session/TrackingProtectionUseCasesTest.kt b/components/feature/session/src/test/java/mozilla/components/feature/session/TrackingProtectionUseCasesTest.kt index 4265748ef5b..f5fb8625f99 100644 --- a/components/feature/session/src/test/java/mozilla/components/feature/session/TrackingProtectionUseCasesTest.kt +++ b/components/feature/session/src/test/java/mozilla/components/feature/session/TrackingProtectionUseCasesTest.kt @@ -8,6 +8,7 @@ import mozilla.components.browser.session.SessionManager import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage import mozilla.components.support.test.any import mozilla.components.support.test.mock import mozilla.components.support.test.whenever @@ -17,6 +18,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mockito.Mockito.times import org.mockito.Mockito.verify class TrackingProtectionUseCasesTest { @@ -24,11 +26,13 @@ class TrackingProtectionUseCasesTest { private val mockSessionManager = mock() private val mockSession = mock() private val mockEngine = mock() + private val mockStore = mock() private val useCases = TrackingProtectionUseCases(mockSessionManager, mockEngine) @Before fun setup() { whenever(mockSessionManager.getEngineSession(any())).thenReturn(mockSession) + whenever(mockEngine.trackingProtectionExceptionStore).thenReturn(mockStore) } @Test @@ -87,4 +91,65 @@ class TrackingProtectionUseCasesTest { assertTrue(onErrorCalled) assertFalse(onSuccessCalled) } + + @Test + fun `add exception`() { + useCases.addException(mock()) + verify(mockSessionManager).getEngineSession(any()) + verify(mockStore).add(any()) + } + + @Test + fun `add exception with a null engine session will not call the store`() { + whenever(mockSessionManager.getEngineSession(any())).thenReturn(null) + + useCases.addException(mock()) + verify(mockStore, times(0)).add(any()) + } + + @Test + fun `remove exception`() { + useCases.removeException(mock()) + verify(mockSessionManager).getEngineSession(any()) + verify(mockStore).remove(any()) + } + + @Test + fun `remove exception with a null engine session will not call the store`() { + whenever(mockSessionManager.getEngineSession(any())).thenReturn(null) + + useCases.removeException(mock()) + verify(mockStore, times(0)).remove(any()) + } + + @Test + fun `removeAll exceptions`() { + useCases.removeAllExceptions() + verify(mockStore).removeAll() + } + + @Test + fun `contains exception`() { + useCases.containsException(mock()) {} + verify(mockStore).contains(any(), any()) + } + + @Test + fun `contains exception with a null engine session will not call the store`() { + var contains = true + whenever(mockSessionManager.getEngineSession(any())).thenReturn(null) + + useCases.containsException(mock()) { + contains = it + } + + assertFalse(contains) + verify(mockStore, times(0)).contains(any(), any()) + } + + @Test + fun `fetch exceptions`() { + useCases.fetchExceptions {} + verify(mockStore).fetchAll(any()) + } } diff --git a/components/support/ktx/src/main/java/mozilla/components/support/ktx/util/AtomicFile.kt b/components/support/ktx/src/main/java/mozilla/components/support/ktx/util/AtomicFile.kt new file mode 100644 index 00000000000..8e1149f1d97 --- /dev/null +++ b/components/support/ktx/src/main/java/mozilla/components/support/ktx/util/AtomicFile.kt @@ -0,0 +1,50 @@ +/* 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 mozilla.components.support.ktx.util + +import android.util.AtomicFile +import org.json.JSONException +import java.io.FileOutputStream +import java.io.IOException + +/** + * Reads an [AtomicFile] and provides a deserialized version of its content. + * @param block A function to be executed after the file is read and provides the content as + * a [String]. It is expected that this function returns a deserialized version of the content + * of the file. + */ +inline fun AtomicFile.readAndDeserialize(block: (String) -> T): T? { + return try { + openRead().use { + val text = it.bufferedReader().use { reader -> reader.readText() } + block(text) + } + } catch (_: IOException) { + null + } catch (_: JSONException) { + null + } +} + +/** + * Writes an [AtomicFile] and indicates if the file was wrote. + * @param block A function with provides the content of the file as a [String] + * @return true if the file wrote otherwise false + */ +inline fun AtomicFile.writeString(block: () -> String): Boolean { + var outputStream: FileOutputStream? = null + return try { + outputStream = startWrite() + outputStream.write(block().toByteArray()) + finishWrite(outputStream) + true + } catch (_: IOException) { + failWrite(outputStream) + false + } catch (_: JSONException) { + failWrite(outputStream) + false + } +} diff --git a/components/support/ktx/src/test/java/mozilla/components/support/ktx/util/AtomicFileTest.kt b/components/support/ktx/src/test/java/mozilla/components/support/ktx/util/AtomicFileTest.kt new file mode 100644 index 00000000000..a7ea4cee456 --- /dev/null +++ b/components/support/ktx/src/test/java/mozilla/components/support/ktx/util/AtomicFileTest.kt @@ -0,0 +1,94 @@ +/* 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 mozilla.components.support.ktx.util + +import android.util.AtomicFile +import androidx.core.util.writeText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.json.JSONException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.any +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class AtomicFileTest { + + @Test + fun `writeString - Fails write on IOException`() { + val mockedFile: AtomicFile = mock() + doThrow(IOException::class.java).`when`(mockedFile).startWrite() + + val result = mockedFile.writeString { "file_content" } + + assertFalse(result) + verify(mockedFile).failWrite(any()) + } + + @Test + fun `writeString - Fails write on JSONException`() { + val mockedFile: AtomicFile = mock() + whenever(mockedFile.startWrite()).thenAnswer { + throw JSONException("") + } + + val result = mockedFile.writeString { "file_content" } + + assertFalse(result) + verify(mockedFile).failWrite(any()) + } + + @Test + fun `writeString - writes the content of the file`() { + val tempFile = File.createTempFile("temp", ".tmp") + val atomicFile = AtomicFile(tempFile) + atomicFile.writeString { "file_content" } + + val result = atomicFile.writeString { "file_content" } + assertTrue(result) + } + + @Test + fun `readAndDeserialize - Returns the content of the file`() { + val tempFile = File.createTempFile("temp", ".tmp") + val atomicFile = AtomicFile(tempFile) + atomicFile.writeText("file_content") + + val fileContent = atomicFile.readAndDeserialize { it } + assertNotNull(fileContent) + assertEquals("file_content", fileContent) + } + + @Test + fun `readAndDeserialize - Returns null on FileNotFoundException`() { + val mockedFile: AtomicFile = mock() + doThrow(FileNotFoundException::class.java).`when`(mockedFile).openRead() + + val content = mockedFile.readAndDeserialize { it } + assertNull(content) + } + + @Test + fun `readAndDeserialize - Returns null on JSONException`() { + val mockedFile: AtomicFile = mock() + whenever(mockedFile.openRead()).thenAnswer { + throw JSONException("") + } + + val content = mockedFile.readAndDeserialize { it } + assertNull(content) + } +} \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index 48c2b46b7e1..8870fed24eb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,6 +19,26 @@ permalink: /changelog/ * Adds `Resources.Theme.resolveAttribute(Int)` to quickly get a resource ID from a theme. * Adds `Context.getColorFromAttr` to get a color int from an attribute. +* **feature-session**, **engine-gecko-nightly** and **engine-gecko-beta** + * Added api to manage the tracking protection exception list, any session added to the list will be ignored and the the current tracking policy will not be applied. + ```kotlin + val useCase = TrackingProtectionUseCases(sessionManager,engine) + + useCase.addException(session) + + useCase.removeException(session) + + useCase.removeAllExceptions() + + useCase.containsException(session){ contains -> + // contains indicates if this session is on the exception list. + } + + useCase.fetchExceptions { exceptions -> + // exceptions is a list of all the origins that are in the exception list. + } + ``` + # 14.0.1 * [Commits](https://github.com/mozilla-mobile/android-components/compare/v14.0.0...v14.0.1)