diff --git a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt index 0d37c0847b..f4faa1c12d 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt @@ -25,6 +25,7 @@ import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.thumbnails.BrowserThumbnails import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.concept.engine.permission.SitePermissions +import mozilla.components.concept.toolbar.Toolbar import mozilla.components.feature.app.links.AppLinksUseCases import mozilla.components.feature.contextmenu.ContextMenuCandidate import mozilla.components.feature.readerview.ReaderViewFeature @@ -67,6 +68,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { private val standardSnackbarErrorBinding = ViewBoundFeatureWrapper() private val reviewQualityCheckFeature = ViewBoundFeatureWrapper() + private val translationsBinding = ViewBoundFeatureWrapper() private var readerModeAvailable = false private var reviewQualityCheckAvailable = false @@ -150,7 +152,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { browserToolbarView.view.addPageAction(readerModeAction) - initTranslationsAction(context) + initTranslationsAction(context, view) initReviewQualityCheck(context, view) thumbnailsFeature.set( @@ -236,32 +238,58 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { } } - private fun initTranslationsAction(context: Context) { + private fun initTranslationsAction(context: Context, view: View) { if (!context.settings().enableTranslations) { return } - val translationsAction = - BrowserToolbar.ToggleButton( - image = AppCompatResources.getDrawable( - context, - R.drawable.mozac_ic_translate_24, - )!!.apply { - setTint(ContextCompat.getColor(context, R.color.fx_mobile_text_color_primary)) - }, - imageSelected = AppCompatResources.getDrawable( - context, - R.drawable.mozac_ic_translate_24, - )!!, - contentDescription = context.getString(R.string.browser_toolbar_translate), - contentDescriptionSelected = "", - visible = { - translationsAvailable || context.settings().enableTranslations - }, - listener = { browserToolbarInteractor.onTranslationsButtonClicked() }, - ) - + val translationsAction = Toolbar.ActionButton( + AppCompatResources.getDrawable( + context, + R.drawable.mozac_ic_translate_24, + )!!.apply { + setTint(ContextCompat.getColor(context, R.color.fx_mobile_text_color_primary)) + }, + contentDescription = context.getString(R.string.browser_toolbar_translate), + visible = { translationsAvailable }, + listener = { + browserToolbarInteractor.onTranslationsButtonClicked() + }, + ) browserToolbarView.view.addPageAction(translationsAction) + + getCurrentTab()?.let { + translationsBinding.set( + feature = TranslationsBinding( + browserStore = context.components.core.store, + sessionId = it.id, + onStateUpdated = { isVisible, isTranslated, fromSelectedLanguage, toSelectedLanguage -> + translationsAvailable = isVisible + + translationsAction.updateView( + tintColorResource = if (isTranslated) { + R.color.fx_mobile_icon_color_accent_violet + } else { + R.color.fx_mobile_text_color_primary + }, + contentDescription = if (isTranslated) { + context.getString( + R.string.browser_toolbar_translated_successfully, + fromSelectedLanguage?.localizedDisplayName, + toSelectedLanguage?.localizedDisplayName, + ) + } else { + context.getString(R.string.browser_toolbar_translate) + }, + ) + + safeInvalidateBrowserToolbarView() + }, + ), + owner = this, + view = view, + ) + } } private fun initReviewQualityCheck(context: Context, view: View) { diff --git a/app/src/main/java/org/mozilla/fenix/browser/TranslationsBinding.kt b/app/src/main/java/org/mozilla/fenix/browser/TranslationsBinding.kt new file mode 100644 index 0000000000..433657da3d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/browser/TranslationsBinding.kt @@ -0,0 +1,72 @@ +/* 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.browser + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.mapNotNull +import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.TranslationsState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.translate.Language +import mozilla.components.concept.engine.translate.initialFromLanguage +import mozilla.components.concept.engine.translate.initialToLanguage +import mozilla.components.lib.state.helpers.AbstractBinding + +/** + * A binding for observing [TranslationsState] changes + * from the [BrowserStore] and updating the translations action button. + * + * @param browserStore [BrowserStore] observed for any changes related to [TranslationsState]. + * @param sessionId Current open tab session id. + * @param onStateUpdated Invoked when the translations action button should be updated with the new translations state. + */ +class TranslationsBinding( + private val browserStore: BrowserStore, + private val sessionId: String, + private val onStateUpdated: ( + isVisible: Boolean, + isTranslated: Boolean, + fromSelectedLanguage: Language?, + toSelectedLanguage: Language?, + ) -> Unit, +) : AbstractBinding(browserStore) { + + override suspend fun onState(flow: Flow) { + flow.mapNotNull { state -> state.findTab(sessionId) }.distinctUntilChangedBy { + it.translationsState + }.collect { sessionState -> + val translationsState = sessionState.translationsState + + if (translationsState.isTranslated) { + val fromSelected = translationsState.translationEngineState?.initialFromLanguage( + translationsState.supportedLanguages?.fromLanguages, + ) + val toSelected = translationsState.translationEngineState?.initialToLanguage( + translationsState.supportedLanguages?.toLanguages, + ) + + if (fromSelected != null && toSelected != null) { + onStateUpdated( + true, + true, + fromSelected, + toSelected, + ) + } + } else if (translationsState.isExpectedTranslate) { + onStateUpdated( + true, + false, + null, + null, + ) + } else { + onStateUpdated(false, false, null, null) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 194a28d3c9..fd77f620b0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,6 +242,10 @@ Erase browsing history Translate page + + Page translated from %1$s to %2$s. diff --git a/app/src/test/java/org/mozilla/fenix/browser/TranslationsBindingTest.kt b/app/src/test/java/org/mozilla/fenix/browser/TranslationsBindingTest.kt new file mode 100644 index 0000000000..b58169f98b --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/browser/TranslationsBindingTest.kt @@ -0,0 +1,168 @@ +/* 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.browser + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.state.action.TranslationsAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.translate.DetectedLanguages +import mozilla.components.concept.engine.translate.Language +import mozilla.components.concept.engine.translate.TranslationEngineState +import mozilla.components.concept.engine.translate.TranslationOperation +import mozilla.components.concept.engine.translate.TranslationPair +import mozilla.components.concept.engine.translate.TranslationSupport +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class TranslationsBindingTest { + @get:Rule + val coroutineRule = MainCoroutineRule() + + lateinit var browserStore: BrowserStore + + private val tabId = "1" + private val tab = createTab(url = tabId, id = tabId) + private val onIconChanged: ( + isVisible: Boolean, + isTranslated: Boolean, + fromSelectedLanguage: Language?, + toSelectedLanguage: Language?, + ) -> Unit = spy() + + @Test + fun `GIVEN translationState WHEN translation status isTranslated THEN invoke onIconChanged callback`() = + runTestOnMain { + val englishLanguage = Language("en", "English") + val spanishLanguage = Language("es", "Spanish") + + browserStore = BrowserStore( + BrowserState( + tabs = listOf(tab), + selectedTabId = tabId, + ), + ) + + val binding = TranslationsBinding( + browserStore = browserStore, + sessionId = tabId, + onStateUpdated = onIconChanged, + ) + binding.start() + + val detectedLanguages = DetectedLanguages( + documentLangTag = englishLanguage.code, + supportedDocumentLang = true, + userPreferredLangTag = spanishLanguage.code, + ) + + val translationEngineState = TranslationEngineState( + detectedLanguages = detectedLanguages, + error = null, + isEngineReady = true, + requestedTranslationPair = TranslationPair( + fromLanguage = englishLanguage.code, + toLanguage = spanishLanguage.code, + ), + ) + + val supportLanguages = TranslationSupport( + fromLanguages = listOf(englishLanguage), + toLanguages = listOf(spanishLanguage), + ) + + browserStore.dispatch( + TranslationsAction.SetSupportedLanguagesAction( + tabId = tab.id, + supportedLanguages = supportLanguages, + ), + ).joinBlocking() + + browserStore.dispatch( + TranslationsAction.TranslateStateChangeAction( + tabId = tabId, + translationEngineState = translationEngineState, + ), + ).joinBlocking() + + browserStore.dispatch( + TranslationsAction.TranslateSuccessAction( + tabId = tab.id, + operation = TranslationOperation.TRANSLATE, + ), + ).joinBlocking() + + verify(onIconChanged).invoke( + true, + true, + englishLanguage, + spanishLanguage, + ) + } + + @Test + fun `GIVEN translationState WHEN translation status isExpectedTranslate THEN invoke onIconChanged callback`() = + runTestOnMain { + browserStore = BrowserStore( + BrowserState( + tabs = listOf(tab), + selectedTabId = tabId, + ), + ) + + val binding = TranslationsBinding( + browserStore = browserStore, + sessionId = tabId, + onStateUpdated = onIconChanged, + ) + binding.start() + + browserStore.dispatch( + TranslationsAction.TranslateExpectedAction( + tabId = tabId, + ), + ).joinBlocking() + + verify(onIconChanged).invoke( + true, + false, + null, + null, + ) + } + + @Test + fun `GIVEN translationState WHEN translation status is not isExpectedTranslate or isTranslated THEN invoke onIconChanged callback`() = + runTestOnMain { + browserStore = BrowserStore( + BrowserState( + tabs = listOf(tab), + selectedTabId = tabId, + ), + ) + + val binding = TranslationsBinding( + browserStore = browserStore, + sessionId = tabId, + onStateUpdated = onIconChanged, + ) + binding.start() + + verify(onIconChanged).invoke( + false, + false, + null, + null, + ) + } +}