diff --git a/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt b/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt index 53b72ac3b5ff..40acee4d6b38 100644 --- a/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt +++ b/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt @@ -49,6 +49,7 @@ import mozilla.components.concept.engine.translate.TranslationEngineState import mozilla.components.concept.engine.translate.TranslationError import mozilla.components.concept.engine.translate.TranslationOperation import mozilla.components.concept.engine.translate.TranslationOptions +import mozilla.components.concept.engine.translate.TranslationPageSettingOperation import mozilla.components.concept.engine.translate.TranslationPageSettings import mozilla.components.concept.engine.translate.TranslationSupport import mozilla.components.concept.engine.webextension.WebExtensionBrowserAction @@ -970,6 +971,20 @@ sealed class TranslationsAction : BrowserAction() { val pageSettings: TranslationPageSettings?, ) : TranslationsAction(), ActionWithTab + /** + * Updates the specified page setting operation on the translation engine and ensures the final + * state on the given [tabId]'s store remains in-sync. + * + * @property tabId The ID of the tab the [EngineSession] should be linked to. + * @property operation The page setting update operation to perform. + * @property setting The boolean value of the corresponding [operation]. + */ + data class UpdatePageSettingAction( + override val tabId: String, + val operation: TranslationPageSettingOperation, + val setting: Boolean, + ) : TranslationsAction(), ActionWithTab + /** * Sets the list of sites that the user has opted to never translate. * diff --git a/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddleware.kt b/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddleware.kt index ab984747ef92..44e5dffee1c2 100644 --- a/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddleware.kt +++ b/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddleware.kt @@ -15,6 +15,7 @@ import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.translate.LanguageSetting import mozilla.components.concept.engine.translate.TranslationError import mozilla.components.concept.engine.translate.TranslationOperation +import mozilla.components.concept.engine.translate.TranslationPageSettingOperation import mozilla.components.concept.engine.translate.TranslationPageSettings import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareContext @@ -61,6 +62,47 @@ class TranslationsMiddleware( -> Unit } } + + is TranslationsAction.UpdatePageSettingAction -> { + when (action.operation) { + TranslationPageSettingOperation.UPDATE_ALWAYS_OFFER_POPUP -> + scope.launch { + updateAlwaysOfferPopupPageSetting( + setting = action.setting, + ) + } + + TranslationPageSettingOperation.UPDATE_ALWAYS_TRANSLATE_LANGUAGE -> + scope.launch { + updateLanguagePageSetting( + context = context, + tabId = action.tabId, + setting = action.setting, + settingType = LanguageSetting.ALWAYS, + ) + } + + TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_LANGUAGE -> + scope.launch { + updateLanguagePageSetting( + context = context, + tabId = action.tabId, + setting = action.setting, + settingType = LanguageSetting.NEVER, + ) + } + + TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_SITE -> + scope.launch { + updateNeverTranslateSitePageSetting( + context = context, + tabId = action.tabId, + setting = action.setting, + ) + } + } + } + else -> { // no-op } @@ -72,7 +114,7 @@ class TranslationsMiddleware( /** * Retrieves the list of supported languages using [scope] and dispatches the result to the - * store via [TranslationsAction.TranslateSetLanguagesAction] or else dispatches the failure + * store via [TranslationsAction.SetSupportedLanguagesAction] or else dispatches the failure * [TranslationsAction.TranslateExceptionAction]. * * @param context Context to use to dispatch to the store. @@ -240,4 +282,148 @@ class TranslationsMiddleware( ) } } + + /** + * Updates the always offer popup setting with the [Engine]. + * + * @param setting The value of the always offer setting to update. + */ + private fun updateAlwaysOfferPopupPageSetting( + setting: Boolean, + ) { + logger.info("Setting the always offer translations popup preference.") + engine.setTranslationsOfferPopup(setting) + } + + /** + * Updates the language settings with the [Engine]. + * + * If an error occurs, then the method will request the page settings be re-fetched and set on + * the browser store. + * + * @param context The context used to request the page settings. + * @param tabId Tab ID associated with the request. + * @param setting The value of the always offer setting to update. + * @param settingType If the boolean to update is from the + * [LanguageSetting.ALWAYS] or [LanguageSetting.NEVER] perspective. + */ + private fun updateLanguagePageSetting( + context: MiddlewareContext, + tabId: String, + setting: Boolean, + settingType: LanguageSetting, + ) { + logger.info("Preparing to update the translations language preference.") + + val pageLanguage = context.store.state.findTab(tabId) + ?.translationsState?.translationEngineState?.detectedLanguages?.documentLangTag + val convertedSetting = settingType.toLanguageSetting(setting) + + if (pageLanguage == null || convertedSetting == null) { + logger.info("An issue occurred while preparing to update the language setting.") + + // Fetch page settings to ensure the state matches the engine. + context.store.dispatch( + TranslationsAction.OperationRequestedAction( + tabId = tabId, + operation = TranslationOperation.FETCH_PAGE_SETTINGS, + ), + ) + } else { + updateLanguageSetting(context, tabId, pageLanguage, convertedSetting) + } + } + + /** + * Updates the language settings with the [Engine]. + * + * If an error occurs, then the method will request the page settings be re-fetched and set on + * the browser store. + * + * @param context The context used to request the page settings. + * @param tabId Tab ID associated with the request. + * @param languageCode The BCP-47 language to update. + * @param setting The new language setting for the [languageCode]. + */ + private fun updateLanguageSetting( + context: MiddlewareContext, + tabId: String, + languageCode: String, + setting: LanguageSetting, + ) { + logger.info("Setting the translations language preference.") + + engine.setLanguageSetting( + languageCode = languageCode, + languageSetting = setting, + + onSuccess = { + logger.info("Successfully updated the language preference.") + }, + + onError = { + logger.error("Could not update the language preference.", it) + + // Fetch page settings to ensure the state matches the engine. + context.store.dispatch( + TranslationsAction.OperationRequestedAction( + tabId = tabId, + operation = TranslationOperation.FETCH_PAGE_SETTINGS, + ), + ) + }, + ) + } + + /** + * Updates the never translate site settings with the [EngineSession] and ensures the global + * list of never translate sites remains in sync. + * + * If an error occurs, then the method will request the page settings be re-fetched and set on + * the browser store. + * + * Note: This method should be used when on the same page as the requested change. + * + * @param context The context used to request the page settings. + * @param tabId Tab ID associated with the request. + * @param setting The value of the site setting to update. + */ + private fun updateNeverTranslateSitePageSetting( + context: MiddlewareContext, + tabId: String, + setting: Boolean, + ) { + val engineSession = context.store.state.findTab(tabId) + ?.engineState?.engineSession + + if (engineSession == null) { + logger.error("Did not receive an engine session to set the never translate site preference.") + } else { + engineSession.setNeverTranslateSiteSetting( + setting = setting, + onResult = { + logger.info("Successfully updated the never translate site preference.") + + // Ensure the global sites store is in-sync with the page settings. + context.store.dispatch( + TranslationsAction.OperationRequestedAction( + tabId = tabId, + operation = TranslationOperation.FETCH_NEVER_TRANSLATE_SITES, + ), + ) + }, + onException = { + logger.error("Could not update the never translate site preference.", it) + + // Fetch page settings to ensure the state matches the engine. + context.store.dispatch( + TranslationsAction.OperationRequestedAction( + tabId = tabId, + operation = TranslationOperation.FETCH_PAGE_SETTINGS, + ), + ) + }, + ) + } + } } diff --git a/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/TranslationsStateReducer.kt b/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/TranslationsStateReducer.kt index 154ae8f4a99b..304d8c1637f7 100644 --- a/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/TranslationsStateReducer.kt +++ b/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/TranslationsStateReducer.kt @@ -5,9 +5,12 @@ package mozilla.components.browser.state.reducer import mozilla.components.browser.state.action.TranslationsAction +import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.TranslationsState import mozilla.components.concept.engine.translate.TranslationOperation +import mozilla.components.concept.engine.translate.TranslationPageSettingOperation +import mozilla.components.concept.engine.translate.TranslationPageSettings internal object TranslationsStateReducer { @@ -218,6 +221,53 @@ internal object TranslationsStateReducer { state } } + + is TranslationsAction.UpdatePageSettingAction -> { + var pageSettings = state.findTab(action.tabId)?.translationsState?.pageSettings + // Initialize page settings, if null. + if (pageSettings == null) { + pageSettings = TranslationPageSettings() + } + when (action.operation) { + TranslationPageSettingOperation.UPDATE_ALWAYS_OFFER_POPUP -> { + pageSettings.alwaysOfferPopup = action.setting + state.copyWithTranslationsState(action.tabId) { + it.copy( + pageSettings = pageSettings, + ) + } + } + TranslationPageSettingOperation.UPDATE_ALWAYS_TRANSLATE_LANGUAGE -> { + pageSettings.alwaysTranslateLanguage = action.setting + // Always and never translate sites are always opposites. + pageSettings.neverTranslateLanguage = !action.setting + + state.copyWithTranslationsState(action.tabId) { + it.copy( + pageSettings = pageSettings, + ) + } + } + TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_LANGUAGE -> { + pageSettings.neverTranslateLanguage = action.setting + // Always and never translate sites are always opposites. + pageSettings.alwaysTranslateLanguage = !action.setting + state.copyWithTranslationsState(action.tabId) { + it.copy( + pageSettings = pageSettings, + ) + } + } + TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_SITE -> { + pageSettings.neverTranslateSite = action.setting + state.copyWithTranslationsState(action.tabId) { + it.copy( + pageSettings = pageSettings, + ) + } + } + } + } } private inline fun BrowserState.copyWithTranslationsState( diff --git a/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/action/TranslationsActionTest.kt b/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/action/TranslationsActionTest.kt index 0eed569f0604..8b81aa01d3de 100644 --- a/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/action/TranslationsActionTest.kt +++ b/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/action/TranslationsActionTest.kt @@ -14,6 +14,7 @@ import mozilla.components.concept.engine.translate.Language import mozilla.components.concept.engine.translate.TranslationEngineState import mozilla.components.concept.engine.translate.TranslationError import mozilla.components.concept.engine.translate.TranslationOperation +import mozilla.components.concept.engine.translate.TranslationPageSettingOperation import mozilla.components.concept.engine.translate.TranslationPageSettings import mozilla.components.concept.engine.translate.TranslationPair import mozilla.components.concept.engine.translate.TranslationSupport @@ -387,4 +388,109 @@ class TranslationsActionTest { // Action success assertNull(tabState().translationsState.supportedLanguages) } + + @Test + fun `WHEN a UpdatePageSettingAction is dispatched for UPDATE_ALWAYS_OFFER_POPUP THEN set page settings for alwaysOfferPopup `() { + // Action started + store.dispatch( + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_ALWAYS_OFFER_POPUP, + setting = true, + ), + ).joinBlocking() + + // Action success + assertTrue(tabState().translationsState.pageSettings?.alwaysOfferPopup!!) + } + + @Test + fun `WHEN a UpdatePageSettingAction is dispatched for UPDATE_ALWAYS_TRANSLATE_LANGUAGE THEN set page settings for alwaysTranslateLanguage `() { + // Action started + store.dispatch( + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_ALWAYS_TRANSLATE_LANGUAGE, + setting = true, + ), + ).joinBlocking() + + // Action success + assertTrue(tabState().translationsState.pageSettings?.alwaysTranslateLanguage!!) + assertFalse(tabState().translationsState.pageSettings?.neverTranslateLanguage!!) + } + + @Test + fun `WHEN a UpdatePageSettingAction is dispatched for UPDATE_NEVER_TRANSLATE_LANGUAGE THEN set page settings for alwaysTranslateLanguage `() { + // Action started + store.dispatch( + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_LANGUAGE, + setting = true, + ), + ).joinBlocking() + + // Action success + assertTrue(tabState().translationsState.pageSettings?.neverTranslateLanguage!!) + assertFalse(tabState().translationsState.pageSettings?.alwaysTranslateLanguage!!) + } + + @Test + fun `WHEN a UpdatePageSettingAction is dispatched for UPDATE_NEVER_TRANSLATE_SITE THEN set page settings for neverTranslateSite`() { + // Action started + store.dispatch( + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_SITE, + setting = true, + ), + ).joinBlocking() + + // Action success + assertTrue(tabState().translationsState.pageSettings?.neverTranslateSite!!) + } + + @Test + fun `WHEN a UpdatePageSettingAction is dispatched for each option THEN the page setting is consistent`() { + // Action started + store.dispatch( + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_ALWAYS_OFFER_POPUP, + setting = true, + ), + ).joinBlocking() + + store.dispatch( + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_ALWAYS_TRANSLATE_LANGUAGE, + setting = true, + ), + ).joinBlocking() + + store.dispatch( + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_LANGUAGE, + setting = true, + ), + ).joinBlocking() + + store.dispatch( + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_SITE, + setting = true, + ), + ).joinBlocking() + + // Action success + assertTrue(tabState().translationsState.pageSettings?.alwaysOfferPopup!!) + // neverTranslateLanguage was posted last and will prevent a contradictory state on the alwaysTranslateLanguage state. + assertFalse(tabState().translationsState.pageSettings?.alwaysTranslateLanguage!!) + assertTrue(tabState().translationsState.pageSettings?.neverTranslateLanguage!!) + assertTrue(tabState().translationsState.pageSettings?.neverTranslateSite!!) + } } diff --git a/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddlewareTest.kt b/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddlewareTest.kt index 237d69a5a85a..e958d3c11ce0 100644 --- a/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddlewareTest.kt +++ b/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddlewareTest.kt @@ -21,6 +21,7 @@ import mozilla.components.concept.engine.translate.LanguageSetting import mozilla.components.concept.engine.translate.TranslationEngineState import mozilla.components.concept.engine.translate.TranslationError import mozilla.components.concept.engine.translate.TranslationOperation +import mozilla.components.concept.engine.translate.TranslationPageSettingOperation import mozilla.components.concept.engine.translate.TranslationPageSettings import mozilla.components.concept.engine.translate.TranslationSupport import mozilla.components.lib.state.MiddlewareContext @@ -34,6 +35,7 @@ import mozilla.components.support.test.whenever import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.Mockito.anyBoolean import org.mockito.Mockito.spy import org.mockito.Mockito.verify @@ -239,6 +241,106 @@ class TranslationsMiddlewareTest { waitForIdle() } + @Test + fun `WHEN UpdatePageSettingAction is dispatched WITH UPDATE_ALWAYS_TRANSLATE_LANGUAGE AND updating the setting is unsuccessful THEN OperationRequestedAction with FETCH_PAGE_SETTINGS is dispatched`() = runTest { + // Setup + setupMockState() + val errorCallback = argumentCaptor<((Throwable) -> Unit)>() + whenever( + engine.setLanguageSetting( + languageCode = any(), + languageSetting = any(), + onSuccess = any(), + onError = errorCallback.capture(), + ), + ).thenAnswer { errorCallback.value.invoke(Throwable()) } + + // Send Action + val action = + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_ALWAYS_TRANSLATE_LANGUAGE, + setting = true, + ) + translationsMiddleware.invoke(context, {}, action) + waitForIdle() + + // Verify Dispatch + verify(store).dispatch( + TranslationsAction.OperationRequestedAction( + tabId = tab.id, + operation = TranslationOperation.FETCH_PAGE_SETTINGS, + ), + ) + } + + @Test + fun `WHEN UpdatePageSettingAction is dispatched WITH UPDATE_NEVER_TRANSLATE_LANGUAGE AND updating the setting is unsuccessful THEN OperationRequestedAction with FETCH_PAGE_SETTINGS is dispatched`() = runTest { + // Setup + setupMockState() + val errorCallback = argumentCaptor<((Throwable) -> Unit)>() + whenever( + engine.setLanguageSetting( + languageCode = any(), + languageSetting = any(), + onSuccess = any(), + onError = errorCallback.capture(), + ), + ) + .thenAnswer { errorCallback.value.invoke(Throwable()) } + + // Send Action + val action = + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_LANGUAGE, + setting = true, + ) + translationsMiddleware.invoke(context, {}, action) + waitForIdle() + + // Verify Dispatch + verify(store).dispatch( + TranslationsAction.OperationRequestedAction( + tabId = tab.id, + operation = TranslationOperation.FETCH_PAGE_SETTINGS, + ), + ) + } + + @Test + fun `WHEN UpdatePageSettingAction is dispatched WITH UPDATE_NEVER_TRANSLATE_SITE AND updating the setting is unsuccessful THEN OperationRequestedAction with FETCH_PAGE_SETTINGS is dispatched`() = runTest { + // Setup + setupMockState() + val errorCallback = argumentCaptor<((Throwable) -> Unit)>() + whenever( + engineSession.setNeverTranslateSiteSetting( + setting = anyBoolean(), + onResult = any(), + onException = errorCallback.capture(), + ), + ) + .thenAnswer { errorCallback.value.invoke(Throwable()) } + + // Send Action + val action = + TranslationsAction.UpdatePageSettingAction( + tabId = tab.id, + operation = TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_SITE, + setting = true, + ) + translationsMiddleware.invoke(context, {}, action) + waitForIdle() + + // Verify Dispatch + verify(store).dispatch( + TranslationsAction.OperationRequestedAction( + tabId = tab.id, + operation = TranslationOperation.FETCH_PAGE_SETTINGS, + ), + ) + } + @Test fun `WHEN OperationRequestedAction is dispatched to fetch never translate sites AND succeeds THEN SetNeverTranslateSitesAction is dispatched`() = runTest { val neverTranslateSites = listOf("google.com") diff --git a/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettingOperation.kt b/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettingOperation.kt new file mode 100644 index 000000000000..82367408b365 --- /dev/null +++ b/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettingOperation.kt @@ -0,0 +1,32 @@ +/* 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.translate + +/** + * The container for referring to the different page settings. + * + * See [TranslationPageSettings] for the corresponding data model + */ +enum class TranslationPageSettingOperation { + /** + * The system should offer a translation on a page. + */ + UPDATE_ALWAYS_OFFER_POPUP, + + /** + * The page's always translate language setting. + */ + UPDATE_ALWAYS_TRANSLATE_LANGUAGE, + + /** + * The page's never translate language setting. + */ + UPDATE_NEVER_TRANSLATE_LANGUAGE, + + /** + * The page's never translate site setting. + */ + UPDATE_NEVER_TRANSLATE_SITE, +} diff --git a/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettings.kt b/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettings.kt index 7c253d6af2cc..2cc9eb598098 100644 --- a/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettings.kt +++ b/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettings.kt @@ -19,8 +19,8 @@ package mozilla.components.concept.engine.translate * When true, the engine will not offer a translation on the current host site. */ data class TranslationPageSettings( - val alwaysOfferPopup: Boolean? = null, - val alwaysTranslateLanguage: Boolean? = null, - val neverTranslateLanguage: Boolean? = null, - val neverTranslateSite: Boolean? = null, + var alwaysOfferPopup: Boolean? = null, + var alwaysTranslateLanguage: Boolean? = null, + var neverTranslateLanguage: Boolean? = null, + var neverTranslateSite: Boolean? = null, )